From 738dfc0cc4a1180cc834ea3346e12e8b628882ed Mon Sep 17 00:00:00 2001 From: Marvin Jones Date: Fri, 29 May 2026 16:28:55 -0400 Subject: [PATCH] addressing comments --- docs/specs.md | 28 ++++++--- integration_tests/tests/shared_accounts.rs | 2 +- .../src/key_management/group_key_holder.rs | 7 ++- .../key_management/key_tree/keys_private.rs | 6 +- key_protocol/src/key_management/mod.rs | 11 ++-- .../src/key_management/secret_holders.rs | 12 ++-- nssa/core/src/encryption/mod.rs | 6 +- .../src/encryption/shared_key_derivation.rs | 62 ++++++++++++++----- nssa/src/state.rs | 8 +-- testnet_initial_state/src/lib.rs | 12 ++-- wallet/src/cli/group.rs | 8 +-- wallet/src/lib.rs | 15 ++--- 12 files changed, 108 insertions(+), 69 deletions(-) diff --git a/docs/specs.md b/docs/specs.md index b0c44572..1e403776 100644 --- a/docs/specs.md +++ b/docs/specs.md @@ -1,6 +1,6 @@ -# LEZ v0.3 specifications for Key Agreement +# LEE v0.3 specifications for Key Agreement -## LEZ v0.3 basic types and constants +## LEE v0.3 basic types and constants ```rust /// The ML-KEM-768 KEM ciphertext produced during encapsulation (1088 bytes) of a message. @@ -24,9 +24,9 @@ struct EncryptedAccountData { When creating a private account output, the sender uses the recipient's viewing public key to encapsulate a random message that is used to establish a shared secret between the sender and recipient: - Sender: $(\mathsf{ss},\, \mathsf{epk}) = \mathsf{encapsulate}(\mathsf{vpk\_recipient})$. The 1088-byte ciphertext `epk` is included in the transaction as the `EphemeralPublicKey` field. -- Receiver: $\mathsf{ss} = \mathsf{decapsulate}(\mathsf{epk},\, vsk.d,\, vks.r)$ +- Receiver: $\mathsf{ss} = \mathsf{decapsulate}(\mathsf{epk},\, vsk.d,\, vsk.z)$ -where `vpk` is the receiver's `ViewingPublicKey` and `(vsk.d, vsk.r)` are the two 32-byte halves of the receiver's `ViewingSecretKey`. +where `vpk` is the receiver's `ViewingPublicKey` and `(vsk.d, vsk.z)` are the two 32-byte halves of the receiver's `ViewingSecretKey`. #### KDF @@ -86,6 +86,11 @@ pub enum InputAccountIdentity { npk: NullifierPublicKey, ssk: SharedSecretKey, identifier: Identifier, + /// When `Some((seed, authority_program_id))`, the circuit binds this position via + /// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == pre_state.account_id` + /// rather than requiring a `Claim::Pda` or caller `pda_seeds`. The `pre_state` must + /// have `is_authorized == false`. + seed: Option<(PdaSeed, ProgramId)>, }, /// Update of an existing private PDA. npk is derived from nsk. @@ -95,6 +100,11 @@ pub enum InputAccountIdentity { nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, + /// When `Some((seed, authority_program_id))`, the circuit binds this position via + /// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == pre_state.account_id` + /// rather than requiring a caller `pda_seeds`. The `pre_state` must have + /// `is_authorized == false`. + seed: Option<(PdaSeed, ProgramId)>, }, } ``` @@ -102,9 +112,9 @@ pub enum InputAccountIdentity { The `ssk` field carries the **shared secret key** — the 32-byte shared secret used to encrypt the post-state. Note that the key protocol uses `ssk` for "spending secret key" (the master key that derives `nsk` and `vsk`); here `ssk` means the per-output KEM shared secret. It is established via ML-KEM-768: - Sender: `(ssk, epk) = encapsulate(vpk)` -- Receiver: `ssk = decapsulate(epk, vsk.d, vsk.r)` +- Receiver: `ssk = decapsulate(epk, vsk.d, vsk.z)` -where `epk` is the ML-KEM-768 ciphertext (1088 bytes) stored as the `EphemeralPublicKey`, `vpk` is the recipient's `ViewingPublicKey` (1184 bytes), and `(vsk.d, vsk.r)` are the 32-byte seed halves of the recipient's `ViewingSecretKey`. +where `epk` is the ML-KEM-768 ciphertext (1088 bytes) stored as the `EphemeralPublicKey`, `vpk` is the recipient's `ViewingPublicKey` (1184 bytes), and `(vsk.d, vsk.z)` are the 32-byte seed halves of the recipient's `ViewingSecretKey`. ## Encrypted private account discovery and tagging @@ -114,12 +124,12 @@ Each private account output includes a 1-byte view tag to allow wallets to quick $$\mathsf{ViewTag} = \mathsf{SHA256}(\text{"/LEE/v0.3/ViewTag/"} \;||\; \mathsf{Npk} \;||\; \mathsf{Vpk})[0]$$ -where `Npk` is the 32-byte nullifier public key and `Vpk` is the 1184 byte `ViewingPublicKey` of the recipient. On average only 1 in 256 outputs will pass this filter for a given account, avoiding expensive ECDH on irrelevant outputs. +where `Npk` is the 32-byte nullifier public key and `Vpk` is the 1184 byte `ViewingPublicKey` of the recipient. On average only 1 in 256 outputs will pass this filter for a given account, avoiding expensive ML-KEM decapsulation on irrelevant outputs. ### Private account discovery with viewing keys 1. For each encrypted output, compute the expected view tag from `(Npk, Vpk)`. Skip if it does not match. -2. Decapsulate using ML-KEM-768: `ss = decapsulate(epk, vsk.d, vsk.r)`. +2. Decapsulate using ML-KEM-768: `ss = decapsulate(epk, vsk.d, vsk.z)`. 3. Run `kdf(ss, commitment, output_index)` to derive the symmetric key. 4. Decrypt the ciphertext with ChaCha20. 5. Parse the 81-byte header to recover `PrivateAccountKind`. @@ -144,7 +154,7 @@ fn private_account_discovery( if encrypted_account.view_tag != expected_tag { continue; } - let ss = SharedSecretKey::decapsulate(&encrypted_account.epk, &vsk.d, &vsk.r); + let ss = SharedSecretKey::decapsulate(&encrypted_account.epk, &vsk.d, &vsk.z); if let Some((kind, account)) = EncryptionScheme::decrypt( &encrypted_account.ciphertext, &ss, commitment, output_index as u32 ) { diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index 5db6e7a9..7dbe8d2a 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -108,7 +108,7 @@ async fn group_invite_join_key_agreement() -> Result<()> { .sealing_secret_key() .context("Sealing key not found")?; let sealing_pk = key_protocol::key_management::group_key_holder::SealingPublicKey::from_bytes( - nssa_core::encryption::ViewingPublicKey::from_seed(&sealing_sk.d, &sealing_sk.r).0, + nssa_core::encryption::ViewingPublicKey::from_seed(&sealing_sk.d, &sealing_sk.z).0, ); let holder = ctx diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs index 67ca6382..7bc6c9bc 100644 --- a/key_protocol/src/key_management/group_key_holder.rs +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -32,7 +32,7 @@ impl SealingPublicKey { } /// Secret key used to unseal a `GroupKeyHolder` received from another member. -/// Holds the two 32-byte FIPS 203 seed halves `d` and `r`. +/// Holds the two 32-byte FIPS 203 seed halves `d` and `z`. pub type SealingSecretKey = ViewingSecretKey; /// Manages shared viewing keys for a group of controllers owning private PDAs. @@ -198,7 +198,8 @@ impl GroupKeyHolder { let nonce = aes_gcm::Nonce::from_slice(&sealed[KEM_CT_LEN..HEADER_LEN]); let ciphertext = &sealed[HEADER_LEN..]; - let shared = SharedSecretKey::decapsulate(&kem_ct, &own_key.d, &own_key.r); + let shared = SharedSecretKey::decapsulate(&kem_ct, &own_key.d, &own_key.z) + .expect("key_protocol::group_key_holder::GroupKeyHolder::unseal: KEM_CT_LEN guarantees exactly 1088 bytes"); let aes_key = Self::seal_kdf(&shared); let cipher = Aes256Gcm::new(&aes_key.into()); @@ -478,7 +479,7 @@ mod tests { fn unseal_too_short_fails() { let vsk = SealingSecretKey { d: [7_u8; 32], - r: [0_u8; 32], + z: [0_u8; 32], }; let result = GroupKeyHolder::unseal(&[0_u8; 10], &vsk); assert!(matches!(result, Err(super::SealError::TooShort))); diff --git a/key_protocol/src/key_management/key_tree/keys_private.rs b/key_protocol/src/key_management/key_tree/keys_private.rs index 4856c3e0..af60be19 100644 --- a/key_protocol/src/key_management/key_tree/keys_private.rs +++ b/key_protocol/src/key_management/key_tree/keys_private.rs @@ -65,7 +65,7 @@ impl ChildKeysPrivate { parent_hash.update([9_u8]); parent_hash.update(self.value.0.private_key_holder.nullifier_secret_key); parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.d); - parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.r); + parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.z); let parent_pt = parent_hash.finalize(); let mut input = vec![]; @@ -171,7 +171,7 @@ mod tests { 187, 143, 146, 12, 68, 148, 25, 203, 21, 92, 131, 2, 221, 81, 117, 62, 98, 194, 159, 177, 102, 254, 236, 182, 76, 242, 116, 219, 17, 166, 99, 36, ], - r: [ + z: [ 80, 97, 83, 209, 145, 99, 168, 99, 89, 29, 153, 236, 82, 99, 134, 114, 168, 19, 223, 69, 34, 47, 76, 76, 15, 97, 245, 184, 25, 103, 251, 82, ], @@ -285,7 +285,7 @@ mod tests { 81, 154, 68, 152, 72, 163, 82, 17, 125, 156, 193, 135, 129, 93, 227, 55, 224, 104, 119, 232, 13, 101, 241, 20, 175, 72, 192, 186, 176, 246, 140, 211, ], - r: [ + z: [ 31, 40, 109, 41, 185, 61, 173, 79, 102, 171, 158, 245, 232, 71, 57, 157, 142, 117, 184, 235, 216, 71, 55, 44, 33, 156, 167, 133, 184, 92, 47, 174, ], diff --git a/key_protocol/src/key_management/mod.rs b/key_protocol/src/key_management/mod.rs index e0f82ff9..194e2b06 100644 --- a/key_protocol/src/key_management/mod.rs +++ b/key_protocol/src/key_management/mod.rs @@ -69,10 +69,9 @@ impl KeyChain { pub fn calculate_shared_secret_receiver( &self, ephemeral_public_key_sender: &EphemeralPublicKey, - _index: Option, - ) -> SharedSecretKey { + ) -> Option { let vsk = &self.private_key_holder.viewing_secret_key; - SharedSecretKey::decapsulate(ephemeral_public_key_sender, &vsk.d, &vsk.r) + SharedSecretKey::decapsulate(ephemeral_public_key_sender, &vsk.d, &vsk.z) } } @@ -104,7 +103,7 @@ mod tests { // Create a proper KEM ciphertext by encapsulating toward this key chain's VPK. let (_, epk) = SharedSecretKey::encapsulate(&account_id_key_holder.viewing_public_key); - let _shared_secret = account_id_key_holder.calculate_shared_secret_receiver(&epk, None); + let _shared_secret = account_id_key_holder.calculate_shared_secret_receiver(&epk); } #[test] @@ -177,8 +176,8 @@ mod tests { let key_sender = eph_key_holder.calculate_shared_secret_sender(); let key_receiver = - keys.calculate_shared_secret_receiver(eph_key_holder.ephemeral_public_key(), Some(2)); + keys.calculate_shared_secret_receiver(eph_key_holder.ephemeral_public_key()); - assert_eq!(key_sender.0, key_receiver.0); + assert_eq!(key_sender.0, key_receiver.unwrap().0); } } diff --git a/key_protocol/src/key_management/secret_holders.rs b/key_protocol/src/key_management/secret_holders.rs index 473d9abc..36a78e9d 100644 --- a/key_protocol/src/key_management/secret_holders.rs +++ b/key_protocol/src/key_management/secret_holders.rs @@ -17,12 +17,12 @@ pub struct SeedHolder { /// Secret spending key object. Can produce `PrivateKeyHolder` objects. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct SecretSpendingKey(pub [u8; 32]); -/// Viewing secret key: the KEM seed split into its two 32-byte halves `d` and `r` (= z in -/// FIPS 203), from which the ML-KEM 768 decapsulation key is derived deterministically. +/// Viewing secret key: the FIPS 203 KEM seed split into its two 32-byte halves `d` and `z`, +/// from which the ML-KEM-768 decapsulation key is derived deterministically. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct ViewingSecretKey { pub d: [u8; 32], - pub r: [u8; 32], + pub z: [u8; 32], } /// Private key holder. Produces public keys. Can produce `account_id`. Can produce shared secret @@ -143,7 +143,7 @@ impl SecretSpendingKey { d: *full_seed .first_chunk::<32>() .expect("hash_value is 64 bytes, must be safe to get first 32"), - r: *full_seed + z: *full_seed .last_chunk::<32>() .expect("hash_value is 64 bytes, must be safe to get last 32"), } @@ -153,7 +153,7 @@ impl SecretSpendingKey { pub const fn generate_viewing_secret_key(seed: [u8; 64]) -> ViewingSecretKey { ViewingSecretKey { d: *seed.first_chunk::<32>().expect("seed is 64 bytes"), - r: *seed.last_chunk::<32>().expect("seed is 64 bytes"), + z: *seed.last_chunk::<32>().expect("seed is 64 bytes"), } } @@ -171,7 +171,7 @@ impl From<&ViewingSecretKey> for ViewingPublicKey { use ml_kem::{Kem, KeyExport as _, MlKem768, Seed}; let mut seed_bytes = [0_u8; 64]; seed_bytes[..32].copy_from_slice(&sk.d); - seed_bytes[32..].copy_from_slice(&sk.r); + seed_bytes[32..].copy_from_slice(&sk.z); let dk = ::DecapsulationKey::from_seed(Seed::from(seed_bytes)); Self(dk.encapsulation_key().to_bytes().to_vec()) } diff --git a/nssa/core/src/encryption/mod.rs b/nssa/core/src/encryption/mod.rs index 9e22f0ae..6a679f0c 100644 --- a/nssa/core/src/encryption/mod.rs +++ b/nssa/core/src/encryption/mod.rs @@ -161,11 +161,11 @@ mod tests { #[test] fn kem_to_chacha20_round_trip() { let d = [1_u8; 32]; - let r = [2_u8; 32]; - let vpk = shared_key_derivation::ViewingPublicKey::from_seed(&d, &r); + let z = [2_u8; 32]; + let vpk = shared_key_derivation::ViewingPublicKey::from_seed(&d, &z); let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk); - let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &r); + let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap(); let account = Account { program_owner: [12_u32; 8], diff --git a/nssa/core/src/encryption/shared_key_derivation.rs b/nssa/core/src/encryption/shared_key_derivation.rs index c75c7f6f..9eb2fcc3 100644 --- a/nssa/core/src/encryption/shared_key_derivation.rs +++ b/nssa/core/src/encryption/shared_key_derivation.rs @@ -30,14 +30,14 @@ impl ViewingPublicKey { &self.0 } - /// Derive the ML-KEM-768 encapsulation key from the FIPS 203 seed halves `d` and `r`. + /// Derive the ML-KEM-768 encapsulation key from the FIPS 203 seed halves `d` and `z`. /// Allows any crate to construct a VPK from raw seed bytes without importing /// `key_protocol::ViewingSecretKey`. #[must_use] - pub fn from_seed(d: &[u8; 32], r: &[u8; 32]) -> Self { + pub fn from_seed(d: &[u8; 32], z: &[u8; 32]) -> Self { let mut seed = Seed::default(); seed[..32].copy_from_slice(d); - seed[32..].copy_from_slice(r); + seed[32..].copy_from_slice(z); let dk = ml_kem::DecapsulationKey768::from_seed(seed); Self(dk.encapsulation_key().to_bytes().to_vec()) } @@ -104,21 +104,22 @@ impl SharedSecretKey { /// Receiver: decapsulate the shared secret from a KEM ciphertext. /// - /// `d` and `r` are the two 32-byte halves of the FIPS 203 `ViewingSecretKey` seed. + /// Returns `None` if the `EphemeralPublicKey` is not exactly 1088 bytes — callers on + /// the wallet scan path should skip the output rather than panic on malformed chain data. + /// + /// `d` and `z` are the two 32-byte halves of the FIPS 203 `ViewingSecretKey` seed. #[must_use] - pub fn decapsulate(ciphertext: &EphemeralPublicKey, d: &[u8; 32], r: &[u8; 32]) -> Self { + pub fn decapsulate(ciphertext: &EphemeralPublicKey, d: &[u8; 32], z: &[u8; 32]) -> Option { let mut seed = Seed::default(); seed[..32].copy_from_slice(d); - seed[32..].copy_from_slice(r); + seed[32..].copy_from_slice(z); let dk = ml_kem::DecapsulationKey768::from_seed(seed); - let ss = dk - .decapsulate_slice(&ciphertext.0) - .expect("EphemeralPublicKey must be 1088 bytes (ML-KEM-768 ciphertext)"); + let ss = dk.decapsulate_slice(&ciphertext.0).ok()?; let ss_bytes: [u8; 32] = ss .as_slice() .try_into() .expect("ML-KEM shared key is 32 bytes"); - Self(ss_bytes) + Some(Self(ss_bytes)) } } @@ -131,18 +132,18 @@ mod tests { #[test] fn encapsulate_decapsulate_round_trip() { let d = [1_u8; 32]; - let r = [2_u8; 32]; + let z = [2_u8; 32]; let mut seed = Seed::default(); seed[..32].copy_from_slice(&d); - seed[32..].copy_from_slice(&r); + seed[32..].copy_from_slice(&z); let dk = ml_kem::DecapsulationKey768::from_seed(seed); let ek_bytes = dk.encapsulation_key().to_bytes(); let vpk = ViewingPublicKey(ek_bytes.to_vec()); let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk); - let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &r); + let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap(); assert_eq!(sender_ss.0, receiver_ss.0, "shared secrets must match"); assert_eq!(epk.0.len(), 1088, "ML-KEM-768 ciphertext is 1088 bytes"); @@ -153,22 +154,49 @@ mod tests { ); } + #[test] + fn decapsulate_returns_none_for_malformed_epk() { + let d = [1_u8; 32]; + let z = [2_u8; 32]; + + // Too short — 100 bytes instead of 1088. + let short_epk = EphemeralPublicKey(vec![42_u8; 100]); + assert!( + SharedSecretKey::decapsulate(&short_epk, &d, &z).is_none(), + "short EphemeralPublicKey must return None" + ); + + // Too long — 1089 bytes instead of 1088. + let long_epk = EphemeralPublicKey(vec![42_u8; 1089]); + assert!( + SharedSecretKey::decapsulate(&long_epk, &d, &z).is_none(), + "long EphemeralPublicKey must return None" + ); + + // Empty. + let empty_epk = EphemeralPublicKey(vec![]); + assert!( + SharedSecretKey::decapsulate(&empty_epk, &d, &z).is_none(), + "empty EphemeralPublicKey must return None" + ); + } + #[test] fn different_vpks_produce_different_shared_secrets() { - let (d1, r1) = ([1_u8; 32], [2_u8; 32]); - let (d2, r2) = ([3_u8; 32], [4_u8; 32]); + let (d1, z1) = ([1_u8; 32], [2_u8; 32]); + let (d2, z2) = ([3_u8; 32], [4_u8; 32]); let vpk1 = { let mut seed = Seed::default(); seed[..32].copy_from_slice(&d1); - seed[32..].copy_from_slice(&r1); + seed[32..].copy_from_slice(&z1); let dk = ml_kem::DecapsulationKey768::from_seed(seed); ViewingPublicKey(dk.encapsulation_key().to_bytes().to_vec()) }; let vpk2 = { let mut seed = Seed::default(); seed[..32].copy_from_slice(&d2); - seed[32..].copy_from_slice(&r2); + seed[32..].copy_from_slice(&z2); let dk = ml_kem::DecapsulationKey768::from_seed(seed); ViewingPublicKey(dk.encapsulation_key().to_bytes().to_vec()) }; diff --git a/nssa/src/state.rs b/nssa/src/state.rs index 386a6428..b5aea2d4 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -519,7 +519,7 @@ pub mod tests { pub struct TestPrivateKeys { pub nsk: NullifierSecretKey, pub d: [u8; 32], - pub r: [u8; 32], + pub z: [u8; 32], } impl TestPrivateKeys { @@ -528,7 +528,7 @@ pub mod tests { } pub fn vpk(&self) -> ViewingPublicKey { - ViewingPublicKey::from_seed(&self.d, &self.r) + ViewingPublicKey::from_seed(&self.d, &self.z) } } @@ -1315,7 +1315,7 @@ pub mod tests { TestPrivateKeys { nsk: [13; 32], d: [31; 32], - r: [32; 32], + z: [32; 32], } } @@ -1323,7 +1323,7 @@ pub mod tests { TestPrivateKeys { nsk: [38; 32], d: [83; 32], - r: [84; 32], + z: [84; 32], } } diff --git a/testnet_initial_state/src/lib.rs b/testnet_initial_state/src/lib.rs index cd9a0f1a..5b3ed377 100644 --- a/testnet_initial_state/src/lib.rs +++ b/testnet_initial_state/src/lib.rs @@ -43,7 +43,7 @@ const VSK_D_PRIV_ACC_A: [u8; 32] = [ 187, 41, 163, 19, 231, 232, 122, 225, 55, 134, 184, ]; -const VSK_R_PRIV_ACC_A: [u8; 32] = [ +const VSK_Z_PRIV_ACC_A: [u8; 32] = [ 225, 24, 98, 78, 31, 203, 175, 248, 213, 17, 133, 207, 10, 135, 132, 151, 59, 184, 5, 81, 28, 238, 137, 62, 233, 227, 99, 17, 236, 159, 244, 63, ]; @@ -53,7 +53,7 @@ const VSK_D_PRIV_ACC_B: [u8; 32] = [ 12, 178, 229, 236, 255, 120, 146, 211, 169, 117, 153, 180, ]; -const VSK_R_PRIV_ACC_B: [u8; 32] = [ +const VSK_Z_PRIV_ACC_B: [u8; 32] = [ 165, 80, 169, 87, 248, 88, 167, 154, 27, 67, 131, 122, 50, 130, 111, 40, 164, 180, 204, 75, 188, 140, 110, 132, 113, 133, 222, 8, 49, 123, 187, 18, ]; @@ -138,11 +138,11 @@ pub fn initial_priv_accounts_private_keys() -> Vec Vec