From bdb0b604e92cb2afd2b2c4d8d1a4eee787a85302 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Wed, 10 Jun 2026 09:26:17 +0000 Subject: [PATCH] fix(secret): carry BIP39 passphrase in Unlock protocol variant The Unlock variant had a single field used as the mnemonic, with no way to convey the BIP39 password extension (25th word). The actor handler silently passed for the passphrase, making it impossible to unlock with a BIP39 passphrase via irpc. Split into + to match the spec and SecretServiceHandle::unlock() signature. --- crates/alknet-secret/src/protocol.rs | 13 ++-- crates/alknet-secret/src/service.rs | 67 ++++++++++++++++++- .../secret-service/unlock-passphrase-gap.md | 2 +- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/crates/alknet-secret/src/protocol.rs b/crates/alknet-secret/src/protocol.rs index 58038eb..2c2a6be 100644 --- a/crates/alknet-secret/src/protocol.rs +++ b/crates/alknet-secret/src/protocol.rs @@ -192,15 +192,18 @@ pub enum SecretProtocol { #[wrap(Lock)] Lock, - /// Unlock the service with a BIP39 passphrase. + /// Unlock the service with a BIP39 mnemonic and optional passphrase. /// - /// The passphrase is used to derive the master seed from the mnemonic. - /// After unlocking, derive and encrypt/decrypt operations are available. + /// The mnemonic is the space-separated BIP39 word list. The passphrase is + /// the optional BIP39 password extension (the "25th word"). After unlocking, + /// derive and encrypt/decrypt operations are available. #[rpc(tx = irpc::channel::oneshot::Sender>)] #[wrap(Unlock)] Unlock { - /// The BIP39 passphrase (may be empty for no passphrase). - passphrase: String, + /// The BIP39 mnemonic phrase (space-separated word list). + mnemonic: String, + /// Optional BIP39 passphrase (the "25th word" password extension). + passphrase: Option, }, } diff --git a/crates/alknet-secret/src/service.rs b/crates/alknet-secret/src/service.rs index 41a493b..9c39391 100644 --- a/crates/alknet-secret/src/service.rs +++ b/crates/alknet-secret/src/service.rs @@ -537,8 +537,11 @@ impl SecretServiceActor { } SecretMessage::Unlock(msg) => { let WithChannels { inner, tx, .. } = msg; - let Unlock { passphrase } = inner; - let result = self.handle.unlock(&passphrase, None); + let Unlock { + mnemonic, + passphrase, + } = inner; + let result = self.handle.unlock(&mnemonic, passphrase.as_deref()); tokio::spawn(async move { let _ = tx.send(result).await; }); @@ -854,7 +857,8 @@ mod tests { let (resp_tx, resp_rx) = oneshot::channel(); let msg = SecretMessage::Unlock(WithChannels::from(( Unlock { - passphrase: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(), + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(), + passphrase: None, }, resp_tx, ))); @@ -908,4 +912,61 @@ mod tests { assert!(result.is_ok(), "Lock via actor must succeed"); assert!(!handle.is_unlocked(), "Handle must be locked after Lock"); } + + #[test] + fn test_unlock_with_passphrase_produces_different_seed() { + let service_a = SecretServiceHandle::new(); + let service_b = SecretServiceHandle::new(); + + let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + service_a.unlock(phrase, None).unwrap(); + let key_a = service_a.derive_ed25519(PATHS::IDENTITY).unwrap(); + + service_a.lock(); + + service_a.unlock(phrase, Some("TREZOR")).unwrap(); + let key_b = service_a.derive_ed25519(PATHS::IDENTITY).unwrap(); + + assert_ne!( + key_a.private_key, key_b.private_key, + "Unlock with passphrase must produce different seed than without" + ); + + service_a.lock(); + + service_b.unlock(phrase, None).unwrap(); + let key_c = service_b.derive_ed25519(PATHS::IDENTITY).unwrap(); + + assert_eq!( + key_a.private_key, key_c.private_key, + "Unlock with None passphrase must produce same seed as another None passphrase unlock" + ); + } + + #[tokio::test] + async fn test_actor_unlock_with_passphrase() { + let handle = SecretServiceHandle::new(); + let (tx, rx) = tokio::sync::mpsc::channel(64); + let actor = SecretServiceActor::new(handle); + tokio::task::spawn(actor.run(rx)); + + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let (resp_tx, resp_rx) = oneshot::channel(); + let msg = SecretMessage::Unlock(WithChannels::from(( + Unlock { + mnemonic: mnemonic.to_string(), + passphrase: Some("TREZOR".to_string()), + }, + resp_tx, + ))); + tx.send(msg).await.unwrap(); + + let result = resp_rx.await.unwrap(); + assert!( + result.is_ok(), + "Unlock with passphrase via actor must succeed" + ); + } } diff --git a/tasks/integration/phase3/secret-service/unlock-passphrase-gap.md b/tasks/integration/phase3/secret-service/unlock-passphrase-gap.md index e6a6bf4..468ecaa 100644 --- a/tasks/integration/phase3/secret-service/unlock-passphrase-gap.md +++ b/tasks/integration/phase3/secret-service/unlock-passphrase-gap.md @@ -1,7 +1,7 @@ --- id: unlock-passphrase-gap name: Fix Unlock protocol variant to carry both mnemonic and BIP39 passphrase -status: pending +status: complete depends_on: [irpc-secret-protocol-integration] scope: narrow risk: low