From 7be0ed926cdef1d39661487d4104db8ce20fb3d4 Mon Sep 17 00:00:00 2001 From: Moudy Date: Tue, 5 May 2026 15:38:18 +0200 Subject: [PATCH] feat(wallet)!: add derive_keys_for_shared_account and PrivateShared variant BREAKING CHANGE: `pda_accounts` field in NSSAUserData renamed to `shared_accounts`. `PrivacyPreservingAccount` enum has a new `PrivateShared` variant, exhaustive matches must handle it. --- .../src/key_management/group_key_holder.rs | 63 ++++++++++ key_protocol/src/key_protocol_core/mod.rs | 12 +- wallet/src/privacy_preserving_tx.rs | 116 +++++++++++++++--- 3 files changed, 168 insertions(+), 23 deletions(-) diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs index 9e7bd8fc..e6634f88 100644 --- a/key_protocol/src/key_management/group_key_holder.rs +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -105,6 +105,21 @@ impl GroupKeyHolder { .produce_private_key_holder(None) } + /// Derive keys for a shared regular (non-PDA) private account. + /// + /// Uses a distinct domain separator from `derive_keys_for_pda` to prevent cross-domain + /// key collisions. The `tag` should be a stable, unique 32-byte value (e.g. derived from + /// a random identifier at account creation time). + #[must_use] + pub fn derive_keys_for_shared_account(&self, tag: &[u8; 32]) -> PrivateKeyHolder { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SHA"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(self.gms); + hasher.update(tag); + SecretSpendingKey(hasher.finalize_fixed().into()).produce_private_key_holder(None) + } + /// Encrypts this holder's GMS under the recipient's [`SealingPublicKey`]. /// /// Uses an ephemeral ECDH key exchange to derive a shared secret, then AES-256-GCM @@ -501,4 +516,52 @@ mod tests { let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk); assert_eq!(alice_account_id, bob_account_id); } + + /// Same GMS + same tag produces same keys for shared accounts. + #[test] + fn shared_account_same_gms_same_tag_produces_same_keys() { + let gms = [42_u8; 32]; + let tag = [1_u8; 32]; + let holder_a = GroupKeyHolder::from_gms(gms); + let holder_b = GroupKeyHolder::from_gms(gms); + + let npk_a = holder_a + .derive_keys_for_shared_account(&tag) + .generate_nullifier_public_key(); + let npk_b = holder_b + .derive_keys_for_shared_account(&tag) + .generate_nullifier_public_key(); + + assert_eq!(npk_a, npk_b); + } + + /// Different tags produce different keys for shared accounts. + #[test] + fn shared_account_different_tags_produce_different_keys() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let npk_a = holder + .derive_keys_for_shared_account(&[1_u8; 32]) + .generate_nullifier_public_key(); + let npk_b = holder + .derive_keys_for_shared_account(&[2_u8; 32]) + .generate_nullifier_public_key(); + + assert_ne!(npk_a, npk_b); + } + + /// PDA and shared account derivations from the same GMS + same bytes never collide. + #[test] + fn pda_and_shared_derivations_do_not_collide() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let bytes = [1_u8; 32]; + + let pda_npk = holder + .derive_keys_for_pda(&PdaSeed::new(bytes)) + .generate_nullifier_public_key(); + let shared_npk = holder + .derive_keys_for_shared_account(&bytes) + .generate_nullifier_public_key(); + + assert_ne!(pda_npk, shared_npk); + } } diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index d12f83a1..1abab24b 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -36,12 +36,12 @@ pub struct NSSAUserData { /// An older wallet binary that re-serializes this struct will drop the field. #[serde(default)] pub group_key_holders: BTreeMap, - /// Cached plaintext state of private PDA accounts, keyed by `AccountId`. - /// Updated after each private PDA transaction by decrypting the circuit output. + /// Cached plaintext state of shared accounts (PDAs and regular shared accounts), + /// keyed by `AccountId`. Updated after each transaction by decrypting the circuit output. /// The sequencer only stores encrypted commitments, so this local cache is the - /// only source of plaintext state for private PDAs. - #[serde(default, alias = "group_pda_accounts")] - pub pda_accounts: BTreeMap, + /// only source of plaintext state for these accounts. + #[serde(default, alias = "group_pda_accounts", alias = "pda_accounts")] + pub shared_accounts: BTreeMap, } impl NSSAUserData { @@ -101,7 +101,7 @@ impl NSSAUserData { public_key_tree, private_key_tree, group_key_holders: BTreeMap::new(), - pda_accounts: BTreeMap::new(), + shared_accounts: BTreeMap::new(), }) } diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index 35419534..ba1a6a73 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -30,6 +30,15 @@ pub enum PrivacyPreservingAccount { program_id: ProgramId, seed: PdaSeed, }, + /// A shared regular private account with externally-provided keys (e.g. from GMS). + /// Uses standard `AccountId = from((&npk, identifier))` and mask 1/2. + /// Works with `authenticated_transfer` and all existing programs out of the box. + PrivateShared { + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, + }, } impl PrivacyPreservingAccount { @@ -49,6 +58,7 @@ impl PrivacyPreservingAccount { identifier: _, } | Self::PrivatePda { .. } + | Self::PrivateShared { .. } ) } } @@ -111,6 +121,7 @@ impl AccountManager { nsk: None, npk, identifier, + is_pda: false, vpk, pre_state: auth_acc, proof: None, @@ -130,6 +141,16 @@ impl AccountManager { let pre = private_pda_preparation(wallet, nsk, npk, vpk, &program_id, &seed).await?; + State::Private(pre) + } + PrivacyPreservingAccount::PrivateShared { + nsk, + npk, + vpk, + identifier, + } => { + let pre = private_shared_preparation(wallet, nsk, npk, vpk, identifier).await?; + State::Private(pre) } }; @@ -184,22 +205,17 @@ impl AccountManager { .iter() .map(|state| match state { State::Public { .. } => InputAccountIdentity::Public, - State::Private(pre) if pre.identifier == u128::MAX => { - // Private PDA account - match (pre.nsk, pre.proof.clone()) { - (Some(nsk), Some(membership_proof)) => { - InputAccountIdentity::PrivatePdaUpdate { - ssk: pre.ssk, - nsk, - membership_proof, - } - } - _ => InputAccountIdentity::PrivatePdaInit { - npk: pre.npk, - ssk: pre.ssk, - }, - } - } + State::Private(pre) if pre.is_pda => match (pre.nsk, pre.proof.clone()) { + (Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate { + ssk: pre.ssk, + nsk, + membership_proof, + }, + _ => InputAccountIdentity::PrivatePdaInit { + npk: pre.npk, + ssk: pre.ssk, + }, + }, State::Private(pre) => match (pre.nsk, pre.proof.clone()) { (Some(nsk), Some(membership_proof)) => { InputAccountIdentity::PrivateAuthorizedUpdate { @@ -249,6 +265,7 @@ struct AccountPreparedData { nsk: Option, npk: NullifierPublicKey, identifier: Identifier, + is_pda: bool, vpk: ViewingPublicKey, pre_state: AccountWithMetadata, proof: Option, @@ -294,6 +311,7 @@ async fn private_acc_preparation( nsk: Some(nsk), npk: from_npk, identifier: from_identifier, + is_pda: false, vpk: from_vpk, pre_state: sender_pre, proof, @@ -317,7 +335,7 @@ async fn private_pda_preparation( let acc = wallet .storage .user_data - .pda_accounts + .shared_accounts .get(&account_id) .cloned() .unwrap_or_default(); @@ -347,6 +365,7 @@ async fn private_pda_preparation( nsk: exists.then_some(nsk), npk, identifier: u128::MAX, + is_pda: true, vpk, pre_state, proof, @@ -354,3 +373,66 @@ async fn private_pda_preparation( epk, }) } + +async fn private_shared_preparation( + wallet: &WalletCore, + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, +) -> Result { + let account_id = nssa::AccountId::from((&npk, identifier)); + + let acc = wallet + .storage + .user_data + .shared_accounts + .get(&account_id) + .cloned() + .unwrap_or_default(); + + let exists = acc != nssa_core::account::Account::default(); + let pre_state = AccountWithMetadata::new(acc, exists, (&npk, identifier)); + + let proof = if exists { + wallet + .check_private_account_initialized(account_id) + .await + .unwrap_or(None) + } else { + None + }; + + let eph_holder = EphemeralKeyHolder::new(&npk); + let ssk = eph_holder.calculate_shared_secret_sender(&vpk); + let epk = eph_holder.generate_ephemeral_public_key(); + + Ok(AccountPreparedData { + nsk: exists.then_some(nsk), + npk, + identifier, + is_pda: false, + vpk, + pre_state, + proof, + ssk, + epk, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_shared_is_private() { + let acc = PrivacyPreservingAccount::PrivateShared { + nsk: [0; 32], + npk: NullifierPublicKey([1; 32]), + vpk: ViewingPublicKey::from_scalar([2; 32]), + identifier: 42, + }; + assert!(acc.is_private()); + assert!(!acc.is_public()); + } +}