Skip to content

Commit 42382f4

Browse files
feat(age): add BIP39 support for Identity behind 'bip39' feature
1 parent effb1b1 commit 42382f4

File tree

3 files changed

+117
-0
lines changed

3 files changed

+117
-0
lines changed

Cargo.lock

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

age/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ age-core.workspace = true
2020
# Dependencies exposed in a public API:
2121
# (Breaking upgrades to these require a breaking upgrade to this crate.)
2222
base64.workspace = true
23+
bip39 = { version = "2.0.0", optional = true }
2324
chacha20poly1305.workspace = true
2425
hmac.workspace = true
2526
i18n-embed.workspace = true
@@ -114,6 +115,7 @@ ssh = [
114115
"rsa",
115116
]
116117
unstable = ["age-core/unstable"]
118+
bip39 = ["dep:bip39"]
117119

118120
[lib]
119121
bench = false

age/src/x25519.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ use age_core::{
1010
};
1111
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
1212
use bech32::{ToBase32, Variant};
13+
#[cfg(feature = "bip39")]
14+
use bip39::Mnemonic;
1315
use rand::rngs::OsRng;
1416
use subtle::ConstantTimeEq;
1517
use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
@@ -71,6 +73,11 @@ impl Identity {
7173
///
7274
/// **Do not** use this method with low-entropy input (like a simple string cast to bytes),
7375
/// as this will result in a weak key that is trivial to crack.
76+
///
77+
/// Note that this method applies X25519 clamping to the input bytes, so the resulting key
78+
/// may differ from the input.
79+
///
80+
/// The caller is responsible for zeroizing the source data if it is no longer needed.
7481
pub fn from_secret_bytes(bytes: [u8; 32]) -> Self {
7582
Identity(StaticSecret::from(bytes))
7683
}
@@ -98,6 +105,33 @@ impl Identity {
98105
}
99106
}
100107

108+
#[cfg(feature = "bip39")]
109+
#[cfg_attr(docsrs, doc(cfg(feature = "bip39")))]
110+
impl Identity {
111+
/// Restores a secret key from a BIP39 mnemonic.
112+
///
113+
/// This method treats the mnemonic's entropy directly as the secret key. Therefore, the
114+
/// mnemonic must have been generated from exactly 32 bytes of entropy (typically 24 words).
115+
pub fn from_mnemonic(mnemonic: &Mnemonic) -> Result<Self, &'static str> {
116+
let mut entropy = mnemonic.to_entropy();
117+
let bytes: [u8; 32] = entropy
118+
.as_slice()
119+
.try_into()
120+
.map_err(|_| "mnemonic must represent exactly 32 bytes (24 words)")?;
121+
entropy.zeroize();
122+
Ok(Self::from_secret_bytes(bytes))
123+
}
124+
125+
/// Serializes this secret key as a BIP39 mnemonic (24 words).
126+
///
127+
/// The mnemonic is generated using the English wordlist.
128+
pub fn to_mnemonic(&self) -> Mnemonic {
129+
// We can safely unwrap because the secret key is guaranteed to be 32 bytes,
130+
// which is a valid length for BIP39 entropy.
131+
Mnemonic::from_entropy(&self.0.to_bytes()).expect("32 bytes is valid entropy")
132+
}
133+
}
134+
101135
impl crate::Identity for Identity {
102136
fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
103137
if stanza.tag != X25519_RECIPIENT_TAG {
@@ -284,6 +318,33 @@ pub(crate) mod tests {
284318
assert_eq!(key.to_string().expose_secret(), TEST_SK);
285319
}
286320

321+
#[cfg(feature = "bip39")]
322+
#[test]
323+
fn mnemonic_round_trip() {
324+
let key = Identity::generate();
325+
let mnemonic = key.to_mnemonic();
326+
let restored = Identity::from_mnemonic(&mnemonic).expect("Mnemonic should be valid");
327+
328+
assert_eq!(
329+
key.to_string().expose_secret(),
330+
restored.to_string().expose_secret()
331+
);
332+
}
333+
334+
#[cfg(feature = "bip39")]
335+
#[test]
336+
fn invalid_mnemonic_length() {
337+
// Generate a 12-word mnemonic (128 bits of entropy)
338+
let entropy = [0u8; 16];
339+
let mnemonic = bip39::Mnemonic::from_entropy(&entropy).unwrap();
340+
341+
let res = Identity::from_mnemonic(&mnemonic);
342+
match res {
343+
Err(e) => assert_eq!(e, "mnemonic must represent exactly 32 bytes (24 words)"),
344+
Ok(_) => panic!("Should not succeed with 16 bytes of entropy"),
345+
}
346+
}
347+
287348
proptest! {
288349
#[test]
289350
fn wrap_and_unwrap(sk_bytes in proptest::collection::vec(any::<u8>(), ..=32)) {

0 commit comments

Comments
 (0)