Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion rust/src/api/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ pub async fn load_identity_from_mnemonic(
privacy_mode: bool,
created_at: Option<i64>,
) -> Result<IdentityInfo> {
key_ops::validate_mnemonic(&words)?;
// Deriving is the validation: it parses the phrase and fails on a bad word
// or checksum with the same `invalid mnemonic` error the explicit check
// used to produce.
let keys = key_ops::derive_master_key(&words)?;
let public_key = keys.public_key().to_hex();

Expand Down
45 changes: 37 additions & 8 deletions rust/src/crypto/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,12 @@ pub fn generate_mnemonic() -> Result<Vec<String>> {
Ok(mnemonic.words().map(|w| w.to_string()).collect())
}

/// Parse and validate a mnemonic from a word list.
/// Accepts 12 or 24 words. Returns error on invalid words or checksum.
pub fn validate_mnemonic(words: &[String]) -> Result<()> {
let phrase = words.join(" ");
Mnemonic::parse(&phrase).map_err(|e| anyhow!("invalid mnemonic: {e}"))?;
Ok(())
}

/// Derive the identity (`N=0`) Nostr `Keys` from a mnemonic.
///
/// **This is also the validation.** Deriving parses the phrase, so a word list
/// with a bad word or a bad checksum fails here with `invalid mnemonic: …`.
/// There is no separate validate-then-derive pair: two parses of the same
/// phrase is two places for "what counts as a valid mnemonic" to be answered.
pub fn derive_master_key(mnemonic_words: &[String]) -> Result<Keys> {
derive_at_index(mnemonic_words, 0)
}
Expand Down Expand Up @@ -80,6 +77,38 @@ mod tests {
assert_eq!(k1.public_key(), k2.public_key());
}

/// The import path relies on derivation to reject a bad phrase — it no
/// longer validates separately first. If that ever stops being true, a
/// typo'd word becomes some other user's key rather than an error.
#[test]
fn deriving_refuses_a_phrase_that_is_not_a_mnemonic() {
// Arrange — real BIP-39 words, wrong checksum; then a word that is not
// in the list at all. The dialog that feeds this only checks the shape
// (12 or 24 alphabetic words), so both reach Rust.
let bad_checksum: Vec<String> = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
.split(' ')
.map(str::to_string)
.collect();
let not_a_word: Vec<String> = "mostro abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
.split(' ')
.map(str::to_string)
.collect();

// Act / Assert
for words in [&bad_checksum, &not_a_word] {
let err = derive_master_key(words).unwrap_err();
assert!(
err.to_string().contains("invalid mnemonic"),
"got {err}"
);
assert!(derive_trade_key(words, 1).is_err());
}

// And an empty list is not a mnemonic either — an nsec-imported
// identity stores no words.
assert!(derive_master_key(&[]).is_err());
}

#[test]
fn trade_key_differs_from_identity_key() {
let words = generate_mnemonic().unwrap();
Expand Down
Loading