Skip to content

Commit d626f04

Browse files
Redact private keys and expose them only via Unredacted (#115)
1 parent 32108d1 commit d626f04

16 files changed

Lines changed: 275 additions & 137 deletions

fuzz/fuzz_targets/fuzz_compare_v13.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22

33
use libfuzzer_sys::{fuzz_target, Corpus};
44

5-
use stellar_strkey::Strkey as StrkeyNew;
5+
use stellar_strkey::ed25519::PrivateKey as PrivateKeyNew;
6+
use stellar_strkey::{Strkey as StrkeyNew, Unredacted};
67
use stellar_strkey_v13::Strkey as StrkeyOld;
78

89
// Compare parsing and encoding between the current library and v0.13.
910
fuzz_target!(|s: &str| -> Corpus {
11+
// `S…` strkeys are parsed via ed25519::PrivateKey on the new side and
12+
// Strkey::PrivateKeyEd25519 on the old side.
13+
if s.starts_with('S') {
14+
return compare_private_key(s);
15+
}
16+
1017
// Try parsing with both versions.
1118
let old_result: Result<StrkeyOld, _> = s.parse();
1219
let new_result: Result<StrkeyNew, _> = s.parse();
@@ -57,9 +64,6 @@ fn compare_internals(new: &StrkeyNew, old: &StrkeyOld) {
5764
(StrkeyNew::PublicKeyEd25519(n), StrkeyOld::PublicKeyEd25519(o)) => {
5865
assert_eq!(n.0, o.0, "PublicKeyEd25519 data mismatch");
5966
}
60-
(StrkeyNew::PrivateKeyEd25519(n), StrkeyOld::PrivateKeyEd25519(o)) => {
61-
assert_eq!(n.0, o.0, "PrivateKeyEd25519 data mismatch");
62-
}
6367
(StrkeyNew::PreAuthTx(n), StrkeyOld::PreAuthTx(o)) => {
6468
assert_eq!(n.0, o.0, "PreAuthTx data mismatch");
6569
}
@@ -101,3 +105,19 @@ fn compare_internals(new: &StrkeyNew, old: &StrkeyOld) {
101105
}
102106
}
103107
}
108+
109+
/// Compare new's `ed25519::PrivateKey` parse against old's
110+
/// `Strkey::PrivateKeyEd25519` for `S…` inputs.
111+
fn compare_private_key(s: &str) -> Corpus {
112+
let new: Result<PrivateKeyNew, _> = s.parse();
113+
let old: Result<StrkeyOld, _> = s.parse();
114+
match (new, old) {
115+
(Ok(new), Ok(StrkeyOld::PrivateKeyEd25519(old))) => {
116+
assert_eq!(new.0, old.0, "PrivateKeyEd25519 data mismatch");
117+
assert_eq!(Unredacted(&new).to_string().as_str(), s, "roundtrip failed");
118+
Corpus::Keep
119+
}
120+
(Err(_), Err(_)) => Corpus::Keep,
121+
(new, old) => panic!("private-key parse mismatch\nInput: {s}\nNew: {new:?}\nOld: {old:?}"),
122+
}
123+
}

fuzz/fuzz_targets/fuzz_compare_v16.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22

33
use libfuzzer_sys::{fuzz_target, Corpus};
44

5-
use stellar_strkey::Strkey as StrkeyNew;
5+
use stellar_strkey::ed25519::PrivateKey as PrivateKeyNew;
6+
use stellar_strkey::{Strkey as StrkeyNew, Unredacted};
67
use stellar_strkey_v16::Strkey as StrkeyOld;
78

89
// Compare parsing and encoding between the current library and v0.0.16.
910
fuzz_target!(|s: &str| -> Corpus {
11+
// `S…` strkeys are parsed via ed25519::PrivateKey on the new side and
12+
// Strkey::PrivateKeyEd25519 on the old side.
13+
if s.starts_with('S') {
14+
return compare_private_key(s);
15+
}
16+
1017
// Try parsing with both versions.
1118
let old_result: Result<StrkeyOld, _> = s.parse();
1219
let new_result: Result<StrkeyNew, _> = s.parse();
@@ -57,9 +64,6 @@ fn compare_internals(new: &StrkeyNew, old: &StrkeyOld) {
5764
(StrkeyNew::PublicKeyEd25519(n), StrkeyOld::PublicKeyEd25519(o)) => {
5865
assert_eq!(n.0, o.0, "PublicKeyEd25519 data mismatch");
5966
}
60-
(StrkeyNew::PrivateKeyEd25519(n), StrkeyOld::PrivateKeyEd25519(o)) => {
61-
assert_eq!(n.0, o.0, "PrivateKeyEd25519 data mismatch");
62-
}
6367
(StrkeyNew::PreAuthTx(n), StrkeyOld::PreAuthTx(o)) => {
6468
assert_eq!(n.0, o.0, "PreAuthTx data mismatch");
6569
}
@@ -101,3 +105,19 @@ fn compare_internals(new: &StrkeyNew, old: &StrkeyOld) {
101105
}
102106
}
103107
}
108+
109+
/// Compare new's `ed25519::PrivateKey` parse against old's
110+
/// `Strkey::PrivateKeyEd25519` for `S…` inputs.
111+
fn compare_private_key(s: &str) -> Corpus {
112+
let new: Result<PrivateKeyNew, _> = s.parse();
113+
let old: Result<StrkeyOld, _> = s.parse();
114+
match (new, old) {
115+
(Ok(new), Ok(StrkeyOld::PrivateKeyEd25519(old))) => {
116+
assert_eq!(new.0, old.0, "PrivateKeyEd25519 data mismatch");
117+
assert_eq!(Unredacted(&new).to_string().as_str(), s, "roundtrip failed");
118+
Corpus::Keep
119+
}
120+
(Err(_), Err(_)) => Corpus::Keep,
121+
(new, old) => panic!("private-key parse mismatch\nInput: {s}\nNew: {new:?}\nOld: {old:?}"),
122+
}
123+
}

fuzz/fuzz_targets/fuzz_roundtrip.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
use libfuzzer_sys::{arbitrary::Result, fuzz_target, Corpus};
44

5-
use stellar_strkey::Strkey;
5+
use stellar_strkey::{ed25519::PrivateKey, Strkey, Unredacted};
66

77
const BASE32_ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=";
88

@@ -12,6 +12,19 @@ fuzz_target!(|s: &str| -> Corpus {
1212
return Corpus::Reject;
1313
}
1414

15+
// `S…` strkeys are parsed via ed25519::PrivateKey rather than the Strkey
16+
// enum; handle that path separately so private-key roundtrips remain
17+
// covered.
18+
if s.starts_with('S') {
19+
let Ok(pk): Result<PrivateKey, _> = s.parse() else {
20+
return Corpus::Keep;
21+
};
22+
let roundtrip_s = Unredacted(&pk).to_string();
23+
assert_eq!(roundtrip_s.as_str(), s);
24+
assert_eq!(s.len(), 56);
25+
return Corpus::Keep;
26+
}
27+
1528
// Parse the input as a strkey. Ignore invalid strkeys.
1629
let Ok(r): Result<Strkey, _> = s.parse() else {
1730
return Corpus::Keep;
@@ -27,7 +40,6 @@ fuzz_target!(|s: &str| -> Corpus {
2740
first_char,
2841
match r {
2942
Strkey::PublicKeyEd25519(_) => 'G',
30-
Strkey::PrivateKeyEd25519(_) => 'S',
3143
Strkey::MuxedAccountEd25519(_) => 'M',
3244
Strkey::PreAuthTx(_) => 'T',
3345
Strkey::HashX(_) => 'X',
@@ -42,7 +54,6 @@ fuzz_target!(|s: &str| -> Corpus {
4254
let len = s.len();
4355
match &r {
4456
Strkey::PublicKeyEd25519(_) => assert_eq!(len, 56),
45-
Strkey::PrivateKeyEd25519(_) => assert_eq!(len, 56),
4657
Strkey::PreAuthTx(_) => assert_eq!(len, 56),
4758
Strkey::HashX(_) => assert_eq!(len, 56),
4859
Strkey::MuxedAccountEd25519(_) => assert_eq!(len, 69),

src/cli/decode.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::io::{IsTerminal, Read};
22
use std::str::FromStr;
33

4-
use crate::{DecodeError, Decoded, Strkey};
4+
use crate::{ed25519, DecodeError, Decoded, Strkey, Unredacted};
55
use clap::Args;
66

77
#[derive(Debug)]
@@ -55,11 +55,21 @@ impl Cmd {
5555
});
5656
}
5757
let input = buf.trim();
58-
let strkey = Strkey::from_str(input).map_err(|e| Error::Decode(input.to_string(), e))?;
59-
if !opts.quiet {
60-
super::warn_if_private(&strkey);
61-
}
62-
let json = serde_json::to_string_pretty(&Decoded(&strkey)).unwrap();
58+
// `S…` strkeys are decoded via `ed25519::PrivateKey` directly; the
59+
// Strkey enum intentionally excludes that variant.
60+
let json = if let Ok(k) = Strkey::from_str(input) {
61+
serde_json::to_string_pretty(&Decoded(&k)).unwrap()
62+
} else {
63+
let pk = ed25519::PrivateKey::from_str(input)
64+
.map_err(|e| Error::Decode(input.to_string(), e))?;
65+
if !opts.quiet {
66+
super::warn_private_key();
67+
}
68+
serde_json::to_string_pretty(&serde_json::json!({
69+
"private_key_ed25519": Decoded(Unredacted(&pk)),
70+
}))
71+
.unwrap()
72+
};
6373
println!("{json}");
6474
Ok(())
6575
}

src/cli/encode.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::io::{IsTerminal, Read};
22

33
use clap::Args;
44

5-
use crate::{Decoded, Strkey};
5+
use crate::{ed25519, Decoded, Strkey, Unredacted};
66

77
// Bound on the JSON input size. The largest legitimate Decoded<Strkey> JSON
88
// (a pretty-printed signed_payload_ed25519 with a max 64-byte payload) is
@@ -56,11 +56,26 @@ impl Cmd {
5656
max: MAX_JSON_LEN,
5757
});
5858
}
59-
let Decoded(strkey): Decoded<Strkey> = serde_json::from_str(&input).map_err(Error::Json)?;
60-
if !opts.quiet {
61-
super::warn_if_private(&strkey);
59+
// Peek at the variant key: `private_key_ed25519` is handled outside
60+
// the Strkey enum and routed through `ed25519::PrivateKey`.
61+
let value: serde_json::Value = serde_json::from_str(&input).map_err(Error::Json)?;
62+
let pk_value = value
63+
.as_object()
64+
.filter(|m| m.len() == 1)
65+
.and_then(|m| m.get("private_key_ed25519"))
66+
.cloned();
67+
if let Some(pk_value) = pk_value {
68+
let Decoded(Unredacted(pk)): Decoded<Unredacted<ed25519::PrivateKey>> =
69+
serde_json::from_value(pk_value).map_err(Error::Json)?;
70+
if !opts.quiet {
71+
super::warn_private_key();
72+
}
73+
println!("{}", Unredacted(&pk));
74+
} else {
75+
let Decoded(strkey): Decoded<Strkey> =
76+
serde_json::from_value(value).map_err(Error::Json)?;
77+
println!("{strkey}");
6278
}
63-
println!("{strkey}");
6479
Ok(())
6580
}
6681
}

src/cli/mod.rs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ pub mod zero;
66
use clap::{Parser, Subcommand};
77
use std::{ffi::OsString, fmt::Debug};
88

9-
use crate::Strkey;
10-
119
#[derive(Parser, Debug, Clone)]
1210
#[command(
1311
author,
@@ -92,13 +90,8 @@ where
9290
root.run()
9391
}
9492

95-
/// Emit a stderr warning when a `Strkey` bound for stdout contains secret
96-
/// material. Centralizes the invariant that every CLI path producing a
97-
/// `Strkey` for the user must screen it first.
98-
pub(crate) fn warn_if_private(strkey: &Strkey) {
99-
if matches!(strkey, Strkey::PrivateKeyEd25519(_)) {
100-
eprintln!(
101-
"⚠️ Warning: output contains a private key with secret material. Handle with care."
102-
);
103-
}
93+
/// Emit a stderr warning that the output bound for stdout contains secret
94+
/// material. Called from CLI paths that handle private-key strkeys.
95+
pub(crate) fn warn_private_key() {
96+
eprintln!("⚠️ Warning: output contains a private key with secret material. Handle with care.");
10497
}

src/decoded_json_format.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,9 @@
3030
/// If the input is untrusted, or this type is used in an
3131
/// allocation-sensitive application, callers should validate the input
3232
/// length prior to deserializing to avoid unexpected or unbounded allocations.
33+
///
34+
/// `Decoded` is not implemented directly for
35+
/// [`ed25519::PrivateKey`](crate::ed25519::PrivateKey); private-key bytes are
36+
/// serialized only through `Decoded<Unredacted<&PrivateKey>>`, requiring an
37+
/// explicit [`Unredacted`](crate::Unredacted) wrap to opt in.
3338
pub struct Decoded<T>(pub T);

0 commit comments

Comments
 (0)