@@ -10,6 +10,8 @@ use age_core::{
1010} ;
1111use base64:: { prelude:: BASE64_STANDARD_NO_PAD , Engine } ;
1212use bech32:: { ToBase32 , Variant } ;
13+ #[ cfg( feature = "bip39" ) ]
14+ use bip39:: Mnemonic ;
1315use rand:: rngs:: OsRng ;
1416use subtle:: ConstantTimeEq ;
1517use 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+
101135impl 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