diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02381dfce..f10532a84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,7 +225,7 @@ jobs: - uses: ./.github/actions/install-risc0 - name: Install just - run: cargo install just + run: cargo install --locked just - name: Build artifacts run: just build-artifacts diff --git a/Cargo.lock b/Cargo.lock index ca46abdee..125e57e23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3982,6 +3982,7 @@ dependencies = [ "aes-gcm", "anyhow", "base58", + "bincode", "bip39", "common", "hex", diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index 148a94036..e7981b527 100644 Binary files a/artifacts/program_methods/amm.bin and b/artifacts/program_methods/amm.bin differ diff --git a/artifacts/program_methods/associated_token_account.bin b/artifacts/program_methods/associated_token_account.bin index 463260673..c4a2343ac 100644 Binary files a/artifacts/program_methods/associated_token_account.bin and b/artifacts/program_methods/associated_token_account.bin differ diff --git a/artifacts/program_methods/authenticated_transfer.bin b/artifacts/program_methods/authenticated_transfer.bin index ad40805f8..d93bdeb95 100644 Binary files a/artifacts/program_methods/authenticated_transfer.bin and b/artifacts/program_methods/authenticated_transfer.bin differ diff --git a/artifacts/program_methods/clock.bin b/artifacts/program_methods/clock.bin index e2a6f1209..11d1cc89f 100644 Binary files a/artifacts/program_methods/clock.bin and b/artifacts/program_methods/clock.bin differ diff --git a/artifacts/program_methods/pinata.bin b/artifacts/program_methods/pinata.bin index d0460713b..49664cedd 100644 Binary files a/artifacts/program_methods/pinata.bin and b/artifacts/program_methods/pinata.bin differ diff --git a/artifacts/program_methods/pinata_token.bin b/artifacts/program_methods/pinata_token.bin index b0f81f795..a2310c070 100644 Binary files a/artifacts/program_methods/pinata_token.bin and b/artifacts/program_methods/pinata_token.bin differ diff --git a/artifacts/program_methods/privacy_preserving_circuit.bin b/artifacts/program_methods/privacy_preserving_circuit.bin index dcbee51a9..1f5c635a5 100644 Binary files a/artifacts/program_methods/privacy_preserving_circuit.bin and b/artifacts/program_methods/privacy_preserving_circuit.bin differ diff --git a/artifacts/program_methods/token.bin b/artifacts/program_methods/token.bin index e0358fa47..58f3d0b2e 100644 Binary files a/artifacts/program_methods/token.bin and b/artifacts/program_methods/token.bin differ diff --git a/artifacts/test_program_methods/auth_asserting_noop.bin b/artifacts/test_program_methods/auth_asserting_noop.bin index 9bd40a307..947fa1b95 100644 Binary files a/artifacts/test_program_methods/auth_asserting_noop.bin and b/artifacts/test_program_methods/auth_asserting_noop.bin differ diff --git a/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index 0353d78f1..e74815079 100644 Binary files a/artifacts/test_program_methods/burner.bin and b/artifacts/test_program_methods/burner.bin differ diff --git a/artifacts/test_program_methods/chain_caller.bin b/artifacts/test_program_methods/chain_caller.bin index cd74cf3fb..b852e9733 100644 Binary files a/artifacts/test_program_methods/chain_caller.bin and b/artifacts/test_program_methods/chain_caller.bin differ diff --git a/artifacts/test_program_methods/changer_claimer.bin b/artifacts/test_program_methods/changer_claimer.bin index 1f966befa..3df213821 100644 Binary files a/artifacts/test_program_methods/changer_claimer.bin and b/artifacts/test_program_methods/changer_claimer.bin differ diff --git a/artifacts/test_program_methods/claimer.bin b/artifacts/test_program_methods/claimer.bin index 8a48effdc..f1054baff 100644 Binary files a/artifacts/test_program_methods/claimer.bin and b/artifacts/test_program_methods/claimer.bin differ diff --git a/artifacts/test_program_methods/clock_chain_caller.bin b/artifacts/test_program_methods/clock_chain_caller.bin index e08df7124..f71db2494 100644 Binary files a/artifacts/test_program_methods/clock_chain_caller.bin and b/artifacts/test_program_methods/clock_chain_caller.bin differ diff --git a/artifacts/test_program_methods/data_changer.bin b/artifacts/test_program_methods/data_changer.bin index 37abf0f75..66a82b264 100644 Binary files a/artifacts/test_program_methods/data_changer.bin and b/artifacts/test_program_methods/data_changer.bin differ diff --git a/artifacts/test_program_methods/extra_output.bin b/artifacts/test_program_methods/extra_output.bin index ebd536216..e36566cef 100644 Binary files a/artifacts/test_program_methods/extra_output.bin and b/artifacts/test_program_methods/extra_output.bin differ diff --git a/artifacts/test_program_methods/flash_swap_callback.bin b/artifacts/test_program_methods/flash_swap_callback.bin index 29c660cd0..e396982ba 100644 Binary files a/artifacts/test_program_methods/flash_swap_callback.bin and b/artifacts/test_program_methods/flash_swap_callback.bin differ diff --git a/artifacts/test_program_methods/flash_swap_initiator.bin b/artifacts/test_program_methods/flash_swap_initiator.bin index a560d477d..fa523fbd4 100644 Binary files a/artifacts/test_program_methods/flash_swap_initiator.bin and b/artifacts/test_program_methods/flash_swap_initiator.bin differ diff --git a/artifacts/test_program_methods/group_pda_router.bin b/artifacts/test_program_methods/group_pda_router.bin new file mode 100644 index 000000000..ad33d724a Binary files /dev/null and b/artifacts/test_program_methods/group_pda_router.bin differ diff --git a/artifacts/test_program_methods/group_pda_spender.bin b/artifacts/test_program_methods/group_pda_spender.bin new file mode 100644 index 000000000..16efb8a41 Binary files /dev/null and b/artifacts/test_program_methods/group_pda_spender.bin differ diff --git a/artifacts/test_program_methods/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index c9d0facd1..90929b71e 100644 Binary files a/artifacts/test_program_methods/malicious_authorization_changer.bin and b/artifacts/test_program_methods/malicious_authorization_changer.bin differ diff --git a/artifacts/test_program_methods/malicious_caller_program_id.bin b/artifacts/test_program_methods/malicious_caller_program_id.bin index 9b31fd7e8..d0311f4de 100644 Binary files a/artifacts/test_program_methods/malicious_caller_program_id.bin and b/artifacts/test_program_methods/malicious_caller_program_id.bin differ diff --git a/artifacts/test_program_methods/malicious_self_program_id.bin b/artifacts/test_program_methods/malicious_self_program_id.bin index c4a2c0398..a38a2bd37 100644 Binary files a/artifacts/test_program_methods/malicious_self_program_id.bin and b/artifacts/test_program_methods/malicious_self_program_id.bin differ diff --git a/artifacts/test_program_methods/minter.bin b/artifacts/test_program_methods/minter.bin index 42d2171de..fb6423d80 100644 Binary files a/artifacts/test_program_methods/minter.bin and b/artifacts/test_program_methods/minter.bin differ diff --git a/artifacts/test_program_methods/missing_output.bin b/artifacts/test_program_methods/missing_output.bin index d2b992910..8dce2e69c 100644 Binary files a/artifacts/test_program_methods/missing_output.bin and b/artifacts/test_program_methods/missing_output.bin differ diff --git a/artifacts/test_program_methods/modified_transfer.bin b/artifacts/test_program_methods/modified_transfer.bin index f57ac2f17..30d2cbecd 100644 Binary files a/artifacts/test_program_methods/modified_transfer.bin and b/artifacts/test_program_methods/modified_transfer.bin differ diff --git a/artifacts/test_program_methods/nonce_changer.bin b/artifacts/test_program_methods/nonce_changer.bin index 6b79e0742..2b8e62c53 100644 Binary files a/artifacts/test_program_methods/nonce_changer.bin and b/artifacts/test_program_methods/nonce_changer.bin differ diff --git a/artifacts/test_program_methods/noop.bin b/artifacts/test_program_methods/noop.bin index eb89f4a98..b35d5b7f0 100644 Binary files a/artifacts/test_program_methods/noop.bin and b/artifacts/test_program_methods/noop.bin differ diff --git a/artifacts/test_program_methods/pda_claimer.bin b/artifacts/test_program_methods/pda_claimer.bin index 092a2191b..3d30aee99 100644 Binary files a/artifacts/test_program_methods/pda_claimer.bin and b/artifacts/test_program_methods/pda_claimer.bin differ diff --git a/artifacts/test_program_methods/pinata_cooldown.bin b/artifacts/test_program_methods/pinata_cooldown.bin index 559adea42..a320d5e1d 100644 Binary files a/artifacts/test_program_methods/pinata_cooldown.bin and b/artifacts/test_program_methods/pinata_cooldown.bin differ diff --git a/artifacts/test_program_methods/private_pda_claimer.bin b/artifacts/test_program_methods/private_pda_claimer.bin new file mode 100644 index 000000000..5a64c66d4 Binary files /dev/null and b/artifacts/test_program_methods/private_pda_claimer.bin differ diff --git a/artifacts/test_program_methods/private_pda_delegator.bin b/artifacts/test_program_methods/private_pda_delegator.bin index d7e81a9f3..904741416 100644 Binary files a/artifacts/test_program_methods/private_pda_delegator.bin and b/artifacts/test_program_methods/private_pda_delegator.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index 880e03b15..c51967681 100644 Binary files a/artifacts/test_program_methods/program_owner_changer.bin and b/artifacts/test_program_methods/program_owner_changer.bin differ diff --git a/artifacts/test_program_methods/simple_balance_transfer.bin b/artifacts/test_program_methods/simple_balance_transfer.bin index 3a4e811f2..816676ec4 100644 Binary files a/artifacts/test_program_methods/simple_balance_transfer.bin and b/artifacts/test_program_methods/simple_balance_transfer.bin differ diff --git a/artifacts/test_program_methods/time_locked_transfer.bin b/artifacts/test_program_methods/time_locked_transfer.bin index eeb80385a..27b02d807 100644 Binary files a/artifacts/test_program_methods/time_locked_transfer.bin and b/artifacts/test_program_methods/time_locked_transfer.bin differ diff --git a/artifacts/test_program_methods/two_pda_claimer.bin b/artifacts/test_program_methods/two_pda_claimer.bin index b71d87abb..366a96bc8 100644 Binary files a/artifacts/test_program_methods/two_pda_claimer.bin and b/artifacts/test_program_methods/two_pda_claimer.bin differ diff --git a/artifacts/test_program_methods/validity_window.bin b/artifacts/test_program_methods/validity_window.bin index 8d749f3c8..79cdc90e8 100644 Binary files a/artifacts/test_program_methods/validity_window.bin and b/artifacts/test_program_methods/validity_window.bin differ diff --git a/artifacts/test_program_methods/validity_window_chain_caller.bin b/artifacts/test_program_methods/validity_window_chain_caller.bin index 109829d2a..751c5ba8d 100644 Binary files a/artifacts/test_program_methods/validity_window_chain_caller.bin and b/artifacts/test_program_methods/validity_window_chain_caller.bin differ diff --git a/key_protocol/Cargo.toml b/key_protocol/Cargo.toml index 022f3ccd9..72829ca8c 100644 --- a/key_protocol/Cargo.toml +++ b/key_protocol/Cargo.toml @@ -26,3 +26,4 @@ itertools.workspace = true [dev-dependencies] base58.workspace = true +bincode.workspace = true diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs new file mode 100644 index 000000000..34b689227 --- /dev/null +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -0,0 +1,670 @@ +use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _}; +use nssa_core::{ + SharedSecretKey, + encryption::{Scalar, ViewingPublicKey, shared_key_derivation::Secp256k1Point}, + program::PdaSeed, +}; +use rand::{RngCore as _, rngs::OsRng}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, digest::FixedOutput as _}; + +use super::secret_holders::{PrivateKeyHolder, SecretSpendingKey, ViewingSecretKey}; + +/// Manages shared viewing keys for a group of controllers owning private PDAs. +/// +/// The Group Master Secret (GMS) is a 32-byte random value shared among controllers. +/// Each private PDA owned by the group gets a unique `SecretSpendingKey` derived from +/// the GMS by mixing the PDA seed into the SHA-256 input (see `secret_spending_key_for_pda`). +/// +/// # Distribution +/// +/// The GMS is a long-term secret and must never cross a trust boundary in raw form. +/// Controllers share it off-chain by sealing it under each recipient's `ViewingPublicKey` +/// (see `seal_for` / `unseal`). Wallets persisting a `GroupKeyHolder` must encrypt it at +/// rest; the raw bytes are exposed only via [`GroupKeyHolder::dangerous_raw_gms`], which +/// is intended for the sealing path exclusively. +/// +/// # Logging safety +/// +/// `Debug` is implemented manually to redact the GMS; formatting this value with `{:?}` +/// will not leak the secret. Code that formats through `{:#?}` on containing types is +/// safe for the same reason. +#[derive(Serialize, Deserialize, Clone)] +pub struct GroupKeyHolder { + gms: [u8; 32], + epoch: u32, +} + +impl std::fmt::Debug for GroupKeyHolder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GroupKeyHolder") + .field("gms", &"") + .field("epoch", &self.epoch) + .finish() + } +} + +impl Default for GroupKeyHolder { + fn default() -> Self { + Self::new() + } +} + +impl GroupKeyHolder { + /// Create a new group with a fresh random GMS at epoch 0. + #[must_use] + pub fn new() -> Self { + let mut gms = [0_u8; 32]; + OsRng.fill_bytes(&mut gms); + Self { gms, epoch: 0 } + } + + /// Restore from an existing GMS at epoch 0. Only valid for initial group creation; + /// post-ratchet restoration must use [`from_gms_and_epoch`](Self::from_gms_and_epoch). + #[must_use] + pub const fn from_gms(gms: [u8; 32]) -> Self { + Self { gms, epoch: 0 } + } + + /// Restore from an existing GMS and epoch (received via `unseal`). + #[must_use] + pub const fn from_gms_and_epoch(gms: [u8; 32], epoch: u32) -> Self { + Self { gms, epoch } + } + + /// Returns the raw 32-byte GMS. The name reflects intent: only the sealed-distribution + /// path (`seal_for`) and sealed-at-rest persistence should ever need the raw bytes. Do + /// not log the result, do not pass it across an untrusted channel. + #[must_use] + pub const fn dangerous_raw_gms(&self) -> &[u8; 32] { + &self.gms + } + + /// Returns the current epoch. Starts at 0 and increments by 1 on each `ratchet` call. + #[must_use] + pub const fn epoch(&self) -> u32 { + self.epoch + } + + /// Forward-ratchets the GMS so removed members cannot derive future keys. + /// + /// The new GMS is `SHA256(PREFIX || rotation_salt || old_gms)`. The rotation salt must + /// be a fresh 32-byte random value contributed by the member who initiates the rotation. + /// Reusing a salt from a previous ratchet produces the same GMS as that previous + /// ratchet, collapsing the key rotation. Callers must generate the salt from a CSPRNG. + /// + /// After ratcheting, all remaining controllers must receive the new `GroupKeyHolder` + /// via `seal_for` / `unseal`. + #[expect( + clippy::arithmetic_side_effects, + reason = "epoch overflow at 2^32 ratchets is not a realistic scenario" + )] + pub fn ratchet(&mut self, rotation_salt: [u8; 32]) { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyRatchet/GMS\x00\x00\x00"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(rotation_salt); + hasher.update(self.gms); + self.gms = hasher.finalize_fixed().into(); + self.epoch += 1; + } + + /// Derive a per-PDA [`SecretSpendingKey`] by mixing the seed into the SHA-256 input. + /// + /// Each distinct `pda_seed` produces a distinct SSK in the full 256-bit space, so + /// adversarial seed-grinding cannot collide two PDAs' derived keys under the same + /// group. Uses the codebase's 32-byte protocol-versioned domain-separation convention. + fn secret_spending_key_for_pda(&self, pda_seed: &PdaSeed) -> SecretSpendingKey { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SSK"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(self.gms); + hasher.update(pda_seed.as_ref()); + SecretSpendingKey(hasher.finalize_fixed().into()) + } + + /// Derive keys for a specific PDA. + /// + /// All controllers holding the same GMS independently derive the same keys for the + /// same PDA because the derivation is deterministic in (GMS, seed). + #[must_use] + pub fn derive_keys_for_pda(&self, pda_seed: &PdaSeed) -> PrivateKeyHolder { + self.secret_spending_key_for_pda(pda_seed) + .produce_private_key_holder(None) + } + + /// Encrypts this holder's GMS and epoch under the recipient's `ViewingPublicKey`. + /// + /// Uses an ephemeral ECDH key exchange to derive a shared secret, then AES-256-GCM + /// to encrypt the payload. The returned bytes are + /// `ephemeral_pubkey (33) || nonce (12) || ciphertext+tag (52)` = 97 bytes. + /// + /// Each call generates a fresh ephemeral key, so two seals of the same holder produce + /// different ciphertexts. + #[must_use] + #[expect( + clippy::arithmetic_side_effects, + reason = "capacity arithmetic on small constants cannot overflow" + )] + pub fn seal_for(&self, recipient_vpk: &ViewingPublicKey) -> Vec { + let mut ephemeral_scalar: Scalar = [0_u8; 32]; + OsRng.fill_bytes(&mut ephemeral_scalar); + let ephemeral_pubkey = ViewingPublicKey::from_scalar(ephemeral_scalar); + let shared = SharedSecretKey::new(&ephemeral_scalar, recipient_vpk); + let aes_key = Self::seal_kdf(&shared); + let cipher = Aes256Gcm::new(&aes_key.into()); + + let mut nonce_bytes = [0_u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = aes_gcm::Nonce::from(nonce_bytes); + + let mut plaintext = [0_u8; 36]; + plaintext[..32].copy_from_slice(&self.gms); + plaintext[32..].copy_from_slice(&self.epoch.to_le_bytes()); + + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_ref()) + .expect("AES-GCM encryption should not fail with valid key/nonce"); + + let mut out = Vec::with_capacity(33 + 12 + ciphertext.len()); + out.extend_from_slice(&ephemeral_pubkey.0); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + out + } + + /// Decrypts a sealed `GroupKeyHolder` using the recipient's `ViewingSecretKey`. + /// + /// Returns `Err` if the ciphertext is too short, the ECDH point is invalid, or the + /// AES-GCM authentication tag doesn't verify (wrong key or tampered data). + pub fn unseal(sealed: &[u8], own_vsk: &ViewingSecretKey) -> Result { + const HEADER_LEN: usize = 33 + 12; + const MIN_LEN: usize = HEADER_LEN + 16; + if sealed.len() < MIN_LEN { + return Err(SealError::TooShort); + } + // MIN_LEN (61) > HEADER_LEN (45), so all slicing below is in bounds. + let ephemeral_pubkey = Secp256k1Point(sealed[..33].to_vec()); + let nonce = aes_gcm::Nonce::from_slice(&sealed[33..HEADER_LEN]); + let ciphertext = &sealed[HEADER_LEN..]; + + let shared = SharedSecretKey::new(own_vsk, &ephemeral_pubkey); + let aes_key = Self::seal_kdf(&shared); + let cipher = Aes256Gcm::new(&aes_key.into()); + + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|_err| SealError::DecryptionFailed)?; + + if plaintext.len() != 36 { + return Err(SealError::DecryptionFailed); + } + + let mut gms = [0_u8; 32]; + gms.copy_from_slice(&plaintext[..32]); + let epoch = u32::from_le_bytes(plaintext[32..36].try_into().unwrap()); + Ok(Self::from_gms_and_epoch(gms, epoch)) + } + + /// Derives an AES-256 key from the ECDH shared secret via SHA-256 with a domain prefix. + fn seal_kdf(shared: &SharedSecretKey) -> [u8; 32] { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeySeal/AES\x00\x00\x00\x00\x00\x00"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(shared.0); + hasher.finalize_fixed().into() + } +} + +#[derive(Debug)] +pub enum SealError { + TooShort, + DecryptionFailed, +} + +#[cfg(test)] +mod tests { + use nssa_core::NullifierPublicKey; + + use super::*; + + /// Two holders from the same GMS derive identical keys for the same PDA seed. + #[test] + fn same_gms_same_seed_produces_same_keys() { + let gms = [42_u8; 32]; + let holder_a = GroupKeyHolder::from_gms(gms); + let holder_b = GroupKeyHolder::from_gms(gms); + let seed = PdaSeed::new([1; 32]); + + let keys_a = holder_a.derive_keys_for_pda(&seed); + let keys_b = holder_b.derive_keys_for_pda(&seed); + + assert_eq!( + keys_a.generate_nullifier_public_key().to_byte_array(), + keys_b.generate_nullifier_public_key().to_byte_array(), + ); + } + + /// Different PDA seeds produce different keys from the same GMS. + #[test] + fn same_gms_different_seed_produces_different_keys() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let seed_a = PdaSeed::new([1; 32]); + let seed_b = PdaSeed::new([2; 32]); + + let npk_a = holder + .derive_keys_for_pda(&seed_a) + .generate_nullifier_public_key(); + let npk_b = holder + .derive_keys_for_pda(&seed_b) + .generate_nullifier_public_key(); + + assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array()); + } + + /// Different GMS produce different keys for the same PDA seed. + #[test] + fn different_gms_same_seed_produces_different_keys() { + let holder_a = GroupKeyHolder::from_gms([42_u8; 32]); + let holder_b = GroupKeyHolder::from_gms([99_u8; 32]); + let seed = PdaSeed::new([1; 32]); + + let npk_a = holder_a + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let npk_b = holder_b + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array()); + } + + /// GMS round-trip: export and restore produces the same keys. + #[test] + fn gms_round_trip() { + let original = GroupKeyHolder::from_gms([7_u8; 32]); + let restored = GroupKeyHolder::from_gms(*original.dangerous_raw_gms()); + let seed = PdaSeed::new([1; 32]); + + let npk_original = original + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let npk_restored = restored + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + assert_eq!(npk_original.to_byte_array(), npk_restored.to_byte_array()); + } + + /// The derived `NullifierPublicKey` is non-zero (sanity check). + #[test] + fn derived_npk_is_non_zero() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let seed = PdaSeed::new([1; 32]); + let npk = holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + assert_ne!(npk, NullifierPublicKey([0; 32])); + } + + /// Pins the end-to-end derivation for a fixed (GMS, `ProgramId`, `PdaSeed`). Any change + /// to `secret_spending_key_for_pda`, the `PrivateKeyHolder` nsk/npk chain, or the + /// `AccountId::for_private_pda` formula breaks this test. Mirrors the pinned-value + /// pattern from `for_private_pda_matches_pinned_value` in `nssa_core`. + #[test] + fn pinned_end_to_end_derivation_for_private_pda() { + use nssa_core::{account::AccountId, program::ProgramId}; + + let gms = [42_u8; 32]; + let seed = PdaSeed::new([1; 32]); + let program_id: ProgramId = [9; 8]; + + let holder = GroupKeyHolder::from_gms(gms); + let npk = holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let account_id = AccountId::for_private_pda(&program_id, &seed, &npk); + + let expected_npk = NullifierPublicKey([ + 185, 161, 225, 224, 20, 156, 173, 0, 6, 173, 74, 136, 16, 88, 71, 154, 101, 160, 224, + 162, 247, 98, 183, 210, 118, 130, 143, 237, 20, 112, 111, 114, + ]); + let expected_account_id = AccountId::new([ + 236, 138, 175, 184, 194, 233, 144, 109, 157, 51, 193, 120, 83, 110, 147, 90, 154, 57, + 148, 236, 12, 92, 135, 38, 253, 79, 88, 143, 161, 175, 46, 144, + ]); + + assert_eq!(npk, expected_npk); + assert_eq!(account_id, expected_account_id); + } + + /// Wallets persist `GroupKeyHolder` to disk and reload it on startup. This test pins + /// the serde round-trip: serialize, deserialize, and assert the derived keys for a + /// sample seed match on both sides. A silent encoding drift would corrupt every + /// group-owned account. + #[test] + fn gms_serde_round_trip_preserves_derivation() { + let mut original = GroupKeyHolder::from_gms([7_u8; 32]); + original.ratchet([10_u8; 32]); + let encoded = bincode::serialize(&original).expect("serialize"); + let restored: GroupKeyHolder = bincode::deserialize(&encoded).expect("deserialize"); + + let seed = PdaSeed::new([1; 32]); + let npk_original = original + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let npk_restored = restored + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + assert_eq!(npk_original, npk_restored); + assert_eq!(original.dangerous_raw_gms(), restored.dangerous_raw_gms()); + assert_eq!(original.epoch(), restored.epoch()); + } + + /// A `GroupKeyHolder` constructed from the same 32 bytes as a personal + /// `SecretSpendingKey` must not derive the same `NullifierPublicKey` as the personal + /// path, so a private PDA cannot be spent by a personal nullifier even under + /// adversarial key-material reuse. The safety rests on the group path's distinct + /// domain-separation prefix plus the seed mix-in (see `secret_spending_key_for_pda`). + #[test] + fn group_derivation_does_not_collide_with_personal_path_at_shared_bytes() { + let shared_bytes = [13_u8; 32]; + let seed = PdaSeed::new([5; 32]); + + let group_npk = GroupKeyHolder::from_gms(shared_bytes) + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + let personal_npk = SecretSpendingKey(shared_bytes) + .produce_private_key_holder(None) + .generate_nullifier_public_key(); + + assert_ne!(group_npk, personal_npk); + } + + /// Ratcheting advances the epoch by 1. + #[test] + fn ratchet_advances_epoch() { + let mut holder = GroupKeyHolder::from_gms([42_u8; 32]); + assert_eq!(holder.epoch(), 0); + holder.ratchet([1_u8; 32]); + assert_eq!(holder.epoch(), 1); + holder.ratchet([2_u8; 32]); + assert_eq!(holder.epoch(), 2); + } + + /// After ratcheting, the same PDA seed produces a different npk. A removed member + /// holding the old GMS cannot derive the new keys. + #[test] + fn ratchet_changes_derived_keys() { + let mut holder = GroupKeyHolder::from_gms([42_u8; 32]); + let seed = PdaSeed::new([1; 32]); + let npk_before = holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + holder.ratchet([99_u8; 32]); + let npk_after = holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + assert_ne!(npk_before, npk_after); + } + + /// Two holders ratcheted with different salts diverge, even from the same starting GMS. + #[test] + fn different_salts_produce_different_ratcheted_keys() { + let mut holder_a = GroupKeyHolder::from_gms([42_u8; 32]); + let mut holder_b = GroupKeyHolder::from_gms([42_u8; 32]); + holder_a.ratchet([1_u8; 32]); + holder_b.ratchet([2_u8; 32]); + let seed = PdaSeed::new([1; 32]); + let npk_a = holder_a + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let npk_b = holder_b + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + assert_ne!(npk_a, npk_b); + } + + /// `from_gms_and_epoch` restores a holder at a specific epoch, matching the state + /// after that many ratchets. + #[test] + fn from_gms_and_epoch_restores_correctly() { + let mut holder = GroupKeyHolder::from_gms([42_u8; 32]); + holder.ratchet([1_u8; 32]); + let restored = + GroupKeyHolder::from_gms_and_epoch(*holder.dangerous_raw_gms(), holder.epoch()); + assert_eq!(restored.epoch(), 1); + let seed = PdaSeed::new([1; 32]); + assert_eq!( + holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(), + restored + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(), + ); + } + + /// A removed member holding the pre-ratchet GMS cannot derive the post-ratchet + /// keys, even if they know the PDA seed. This is the forward-secrecy property of + /// the ratchet: the old GMS is a preimage of the new one under SHA-256, so + /// reversing the ratchet requires breaking preimage resistance. + #[test] + fn removed_member_cannot_derive_post_ratchet_keys() { + let original_gms = [42_u8; 32]; + let seed = PdaSeed::new([1; 32]); + + // Removed member's frozen state + let removed = GroupKeyHolder::from_gms(original_gms); + let removed_npk = removed + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + // Remaining members ratchet twice + let mut active = GroupKeyHolder::from_gms(original_gms); + active.ratchet([10_u8; 32]); + active.ratchet([20_u8; 32]); + let active_npk = active + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + // The removed member's keys are useless for the current epoch + assert_ne!(removed_npk, active_npk); + assert_ne!(removed.dangerous_raw_gms(), active.dangerous_raw_gms()); + assert_eq!(removed.epoch(), 0); + assert_eq!(active.epoch(), 2); + } + + /// Seal then unseal recovers the same GMS, epoch, and derived keys. + #[test] + fn seal_unseal_round_trip() { + let mut holder = GroupKeyHolder::from_gms([42_u8; 32]); + holder.ratchet([10_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_keys = recipient_ssk.produce_private_key_holder(None); + let recipient_vpk = recipient_keys.generate_viewing_public_key(); + let recipient_vsk = recipient_keys.viewing_secret_key; + + let sealed = holder.seal_for(&recipient_vpk); + let restored = GroupKeyHolder::unseal(&sealed, &recipient_vsk).expect("unseal"); + + assert_eq!(restored.dangerous_raw_gms(), holder.dangerous_raw_gms()); + assert_eq!(restored.epoch(), holder.epoch()); + + let seed = PdaSeed::new([1; 32]); + assert_eq!( + holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(), + restored + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(), + ); + } + + /// Unsealing with a different VSK fails with `DecryptionFailed`. + #[test] + fn unseal_wrong_vsk_fails() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_vpk = recipient_ssk + .produce_private_key_holder(None) + .generate_viewing_public_key(); + + let wrong_ssk = SecretSpendingKey([99_u8; 32]); + let wrong_vsk = wrong_ssk + .produce_private_key_holder(None) + .viewing_secret_key; + + let sealed = holder.seal_for(&recipient_vpk); + let result = GroupKeyHolder::unseal(&sealed, &wrong_vsk); + assert!(matches!(result, Err(super::SealError::DecryptionFailed))); + } + + /// Tampered ciphertext fails authentication. + #[test] + fn unseal_tampered_ciphertext_fails() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_keys = recipient_ssk.produce_private_key_holder(None); + let recipient_vpk = recipient_keys.generate_viewing_public_key(); + let recipient_vsk = recipient_keys.viewing_secret_key; + + let mut sealed = holder.seal_for(&recipient_vpk); + // Flip a byte in the ciphertext portion (after ephemeral_pubkey + nonce) + let last = sealed.len() - 1; + sealed[last] ^= 0xFF; + + let result = GroupKeyHolder::unseal(&sealed, &recipient_vsk); + assert!(matches!(result, Err(super::SealError::DecryptionFailed))); + } + + /// Two seals of the same holder produce different ciphertexts (ephemeral randomness). + #[test] + fn two_seals_produce_different_ciphertexts() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_vpk = recipient_ssk + .produce_private_key_holder(None) + .generate_viewing_public_key(); + + let sealed_a = holder.seal_for(&recipient_vpk); + let sealed_b = holder.seal_for(&recipient_vpk); + assert_ne!(sealed_a, sealed_b); + } + + /// Sealed payload is too short. + #[test] + fn unseal_too_short_fails() { + let vsk: ViewingSecretKey = [7_u8; 32]; + let result = GroupKeyHolder::unseal(&[0_u8; 10], &vsk); + assert!(matches!(result, Err(super::SealError::TooShort))); + } + + /// Degenerate GMS values (all-zeros, all-ones, single-bit) must still produce valid, + /// non-zero, pairwise-distinct npks. Rules out accidental "if gms == default { return + /// default }" style shortcuts in the derivation. + #[test] + fn degenerate_gms_produces_distinct_non_zero_keys() { + let seed = PdaSeed::new([1; 32]); + let degenerate = [[0_u8; 32], [0xFF_u8; 32], { + let mut v = [0_u8; 32]; + v[0] = 1; + v + }]; + + let npks: Vec = degenerate + .iter() + .map(|gms| { + GroupKeyHolder::from_gms(*gms) + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key() + }) + .collect(); + + for npk in &npks { + assert_ne!(*npk, NullifierPublicKey([0; 32])); + } + for (i, a) in npks.iter().enumerate() { + for b in &npks[i + 1..] { + assert_ne!(a, b); + } + } + } + + /// Full lifecycle: create group, distribute GMS via seal/unseal, verify key + /// agreement, ratchet for forward secrecy. + #[test] + fn group_pda_lifecycle() { + use nssa_core::account::AccountId; + + let alice_holder = GroupKeyHolder::new(); + assert_eq!(alice_holder.epoch(), 0); + let pda_seed = PdaSeed::new([42_u8; 32]); + let program_id: nssa_core::program::ProgramId = [1; 8]; + + // Derive Alice's keys + let alice_keys = alice_holder.derive_keys_for_pda(&pda_seed); + let alice_npk = alice_keys.generate_nullifier_public_key(); + + // Seal GMS for Bob using Bob's viewing key, Bob unseals + let bob_ssk = SecretSpendingKey([77_u8; 32]); + let bob_keys = bob_ssk.produce_private_key_holder(None); + let bob_vpk = bob_keys.generate_viewing_public_key(); + let bob_vsk = bob_keys.viewing_secret_key; + + let sealed = alice_holder.seal_for(&bob_vpk); + let bob_holder = + GroupKeyHolder::unseal(&sealed, &bob_vsk).expect("Bob should unseal the GMS"); + + // Key agreement: both derive identical NPK and AccountId + let bob_npk = bob_holder + .derive_keys_for_pda(&pda_seed) + .generate_nullifier_public_key(); + assert_eq!(alice_npk, bob_npk); + + let alice_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk); + let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk); + assert_eq!(alice_account_id, bob_account_id); + + // Ratchet: forward secrecy + let mut ratcheted_holder = alice_holder; + ratcheted_holder.ratchet([99_u8; 32]); + assert_eq!(ratcheted_holder.epoch(), 1); + + let ratcheted_npk = ratcheted_holder + .derive_keys_for_pda(&pda_seed) + .generate_nullifier_public_key(); + let bob_stale_npk = bob_holder + .derive_keys_for_pda(&pda_seed) + .generate_nullifier_public_key(); + + assert_ne!(ratcheted_npk, bob_stale_npk); + assert_ne!(ratcheted_npk, alice_npk); + + let new_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &ratcheted_npk); + assert_ne!(alice_account_id, new_account_id); + + // Bob's stale keys point to old address + let bob_stale_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_stale_npk); + assert_eq!(bob_stale_id, alice_account_id); + assert_ne!(bob_stale_id, new_account_id); + + // Sealed round-trip of ratcheted GMS + let sealed_ratcheted = ratcheted_holder.seal_for(&bob_vpk); + let restored = GroupKeyHolder::unseal(&sealed_ratcheted, &bob_vsk) + .expect("Should unseal ratcheted GMS"); + assert_eq!( + restored.dangerous_raw_gms(), + ratcheted_holder.dangerous_raw_gms() + ); + assert_eq!(restored.epoch(), 1); + } +} diff --git a/key_protocol/src/key_management/mod.rs b/key_protocol/src/key_management/mod.rs index c038c4157..7a1f44dff 100644 --- a/key_protocol/src/key_management/mod.rs +++ b/key_protocol/src/key_management/mod.rs @@ -6,6 +6,7 @@ use secret_holders::{PrivateKeyHolder, SecretSpendingKey, SeedHolder}; use serde::{Deserialize, Serialize}; pub mod ephemeral_key_holder; +pub mod group_key_holder; pub mod key_tree; pub mod secret_holders; diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index 8186865f9..b0ee18cb6 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::key_management::{ KeyChain, + group_key_holder::GroupKeyHolder, key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex}, secret_holders::SeedHolder, }; @@ -23,6 +24,17 @@ pub struct NSSAUserData { pub public_key_tree: KeyTreePublic, /// Tree of private keys. pub private_key_tree: KeyTreePrivate, + /// Group key holders for private PDA groups, keyed by a human-readable label. + /// Defaults to empty for backward compatibility with wallets that predate group PDAs. + /// An older wallet binary that re-serializes this struct will drop the field. + #[serde(default)] + pub group_key_holders: BTreeMap, + /// Cached plaintext state of group PDA accounts, keyed by `AccountId`. + /// Updated after each group PDA transaction by decrypting the circuit output. + /// The sequencer only stores encrypted commitments, so this local cache is the + /// only source of plaintext state for group PDAs. + #[serde(default)] + pub group_pda_accounts: BTreeMap, } impl NSSAUserData { @@ -81,6 +93,8 @@ impl NSSAUserData { default_user_private_accounts: default_accounts_key_chains, public_key_tree, private_key_tree, + group_key_holders: BTreeMap::new(), + group_pda_accounts: BTreeMap::new(), }) } @@ -177,6 +191,20 @@ impl NSSAUserData { .copied() .chain(self.private_key_tree.account_id_map.keys().copied()) } + + /// Returns the `GroupKeyHolder` for the given label, if it exists. + #[must_use] + pub fn get_group_key_holder(&self, label: &str) -> Option<&GroupKeyHolder> { + self.group_key_holders.get(label) + } + + /// Inserts or replaces a `GroupKeyHolder` under the given label. + /// + /// If a holder already exists under this label, it is silently replaced and the old + /// GMS is lost. Callers must ensure label uniqueness across groups. + pub fn insert_group_key_holder(&mut self, label: String, holder: GroupKeyHolder) { + self.group_key_holders.insert(label, holder); + } } impl Default for NSSAUserData { @@ -196,6 +224,27 @@ impl Default for NSSAUserData { mod tests { use super::*; + #[test] + fn group_key_holder_storage_round_trip() { + let mut user_data = NSSAUserData::default(); + assert!(user_data.get_group_key_holder("test-group").is_none()); + + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + user_data.insert_group_key_holder(String::from("test-group"), holder.clone()); + + let retrieved = user_data + .get_group_key_holder("test-group") + .expect("should exist"); + assert_eq!(retrieved.dangerous_raw_gms(), holder.dangerous_raw_gms()); + assert_eq!(retrieved.epoch(), holder.epoch()); + } + + #[test] + fn group_key_holders_default_empty() { + let user_data = NSSAUserData::default(); + assert!(user_data.group_key_holders.is_empty()); + } + #[test] fn new_account() { let mut user_data = NSSAUserData::default(); diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index 5091cdffe..b18cadc2f 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -37,6 +37,12 @@ impl PdaSeed { } } +impl AsRef<[u8]> for PdaSeed { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + impl AccountId { /// Derives an [`AccountId`] for a public PDA from the program ID and seed. #[must_use] diff --git a/nssa/src/program.rs b/nssa/src/program.rs index b8c3fe77a..954c0525a 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -312,6 +312,16 @@ mod tests { } } + #[must_use] + pub fn group_pda_spender() -> Self { + use test_program_methods::{GROUP_PDA_SPENDER_ELF, GROUP_PDA_SPENDER_ID}; + + Self { + id: GROUP_PDA_SPENDER_ID, + elf: GROUP_PDA_SPENDER_ELF.to_vec(), + } + } + #[must_use] pub fn two_pda_claimer() -> Self { use test_program_methods::{TWO_PDA_CLAIMER_ELF, TWO_PDA_CLAIMER_ID}; diff --git a/nssa/src/state.rs b/nssa/src/state.rs index f86f429f0..63f0f6508 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -2568,6 +2568,100 @@ pub mod tests { assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); } + /// Group PDA deposit: creates a new PDA and transfers balance from the + /// counterparty. Both accounts owned by `group_pda_spender`. + #[test] + fn group_pda_deposit() { + let program = Program::group_pda_spender(); + let noop = Program::noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); + + // PDA (new, mask 3) + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); + let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); + + // Sender (mask 0, public, owned by this program, has balance) + let sender_id = AccountId::new([99; 32]); + let sender_pre = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 10000, + ..Account::default() + }, + true, + sender_id, + ); + + let noop_id = noop.id(); + let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into()); + + let instruction = Program::serialize_instruction((seed, noop_id, 500_u128, true)).unwrap(); + + // PDA is mask 3 (private PDA), sender is mask 0 (public). + // Public accounts don't need keys, nsks, or membership proofs. + let result = execute_and_prove( + vec![pda_pre, sender_pre], + instruction, + vec![3, 0], + vec![(npk, shared_secret_pda)], + vec![], + vec![None], + &program_with_deps, + ); + + let (output, _proof) = result.expect("group PDA deposit should succeed"); + // Only PDA (mask 3) produces a commitment; sender (mask 0) is public. + assert_eq!(output.new_commitments.len(), 1); + } + + /// Group PDA spend binding: the noop chained call with `pda_seeds` establishes + /// the mask-3 binding for an existing-but-default PDA. Uses amount=0 because + /// testing with a pre-funded PDA requires a two-tx sequence with membership proofs. + #[test] + fn group_pda_spend_binding() { + let program = Program::group_pda_spender(); + let noop = Program::noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); + + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); + let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); + + let bob_id = AccountId::new([88; 32]); + let bob_pre = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 10000, + ..Account::default() + }, + true, + bob_id, + ); + + let noop_id = noop.id(); + let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into()); + + let instruction = Program::serialize_instruction((seed, noop_id, 0_u128, false)).unwrap(); + + let result = execute_and_prove( + vec![pda_pre, bob_pre], + instruction, + vec![3, 0], + vec![(npk, shared_secret_pda)], + vec![], + vec![None], + &program_with_deps, + ); + + let (output, _proof) = result.expect("group PDA spend binding should succeed"); + assert_eq!(output.new_commitments.len(), 1); + } + #[test] fn circuit_should_fail_with_too_many_nonces() { let program = Program::simple_balance_transfer(); diff --git a/test_program_methods/guest/src/bin/group_pda_spender.rs b/test_program_methods/guest/src/bin/group_pda_spender.rs new file mode 100644 index 000000000..04ef91a4b --- /dev/null +++ b/test_program_methods/guest/src/bin/group_pda_spender.rs @@ -0,0 +1,118 @@ +use nssa_core::program::{ + AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, +}; + +/// Single program for group PDA operations. Owns and operates the PDA directly. +/// +/// Instruction: `(pda_seed, noop_program_id, amount, is_deposit)`. +/// Pre-states: `[group_pda, counterparty]`. +/// +/// **Deposit** (`is_deposit = true`, new PDA): +/// Claims PDA via `Claim::Pda(seed)`, increases PDA balance, decreases counterparty. +/// Counterparty must be authorized and owned by this program (or uninitialized). +/// +/// **Spend** (`is_deposit = false`, existing PDA): +/// Decreases PDA balance (this program owns it), increases counterparty. +/// Chains to a noop callee with `pda_seeds` to establish the mask-3 binding +/// that the circuit requires for existing private PDAs. +type Instruction = (PdaSeed, ProgramId, u128, bool); + +#[expect( + clippy::allow_attributes, + reason = "allow is needed because the clones are only redundant in test compilation" +)] +#[allow( + clippy::redundant_clone, + reason = "clones needed in non-test compilation" +)] +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (pda_seed, noop_id, amount, is_deposit), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pda_pre, counterparty_pre]) = <[_; 2]>::try_from(pre_states.clone()) else { + panic!("expected exactly 2 pre_states: [group_pda, counterparty]"); + }; + + if is_deposit { + // Deposit: claim PDA, transfer balance from counterparty to PDA. + // Both accounts must be owned by this program (or uninitialized) for + // validate_execution to allow balance changes. + assert!( + counterparty_pre.is_authorized, + "Counterparty must be authorized to deposit" + ); + + let mut pda_account = pda_pre.account; + let mut counterparty_account = counterparty_pre.account; + + pda_account.balance = pda_account + .balance + .checked_add(amount) + .expect("PDA balance overflow"); + counterparty_account.balance = counterparty_account + .balance + .checked_sub(amount) + .expect("Counterparty has insufficient balance"); + + let pda_post = AccountPostState::new_claimed_if_default(pda_account, Claim::Pda(pda_seed)); + let counterparty_post = AccountPostState::new(counterparty_account); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![pda_post, counterparty_post], + ) + .write(); + } else { + // Spend: decrease PDA balance (owned by this program), increase counterparty. + // Chain to noop with pda_seeds to establish the mask-3 binding for the + // existing PDA. The noop's pre_states must match our post_states. + // Authorization is enforced by the circuit's binding check, not here. + + let mut pda_account = pda_pre.account.clone(); + let mut counterparty_account = counterparty_pre.account.clone(); + + pda_account.balance = pda_account + .balance + .checked_sub(amount) + .expect("PDA has insufficient balance"); + counterparty_account.balance = counterparty_account + .balance + .checked_add(amount) + .expect("Counterparty balance overflow"); + + let pda_post = AccountPostState::new(pda_account.clone()); + let counterparty_post = AccountPostState::new(counterparty_account.clone()); + + // Chain to noop solely to establish the mask-3 binding via pda_seeds. + let mut noop_pda_pre = pda_pre; + noop_pda_pre.account = pda_account; + noop_pda_pre.is_authorized = true; + + let mut noop_counterparty_pre = counterparty_pre; + noop_counterparty_pre.account = counterparty_account; + + let noop_call = ChainedCall::new(noop_id, vec![noop_pda_pre, noop_counterparty_pre], &()) + .with_pda_seeds(vec![pda_seed]); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![pda_post, counterparty_post], + ) + .with_chained_calls(vec![noop_call]) + .write(); + } +} diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 460cfcfd8..e546ceec5 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -41,7 +41,7 @@ pub mod cli; pub mod config; pub mod helperfunctions; pub mod poller; -mod privacy_preserving_tx; +pub mod privacy_preserving_tx; pub mod program_facades; pub const HOME_DIR_ENV_VAR: &str = "NSSA_WALLET_HOME_DIR"; @@ -201,6 +201,12 @@ impl WalletCore { &self.storage } + /// Get mutable storage (e.g. for inserting group key holders). + #[must_use] + pub const fn storage_mut(&mut self) -> &mut WalletChainStore { + &mut self.storage + } + /// Restore storage from an existing mnemonic phrase. pub fn restore_storage(&mut self, mnemonic: &Mnemonic, password: &str) -> Result<()> { self.storage = WalletChainStore::restore_storage( diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index 14a805c7e..7272e33a2 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -5,6 +5,7 @@ use nssa_core::{ MembershipProof, NullifierPublicKey, NullifierSecretKey, SharedSecretKey, account::{AccountWithMetadata, Nonce}, encryption::{EphemeralPublicKey, ViewingPublicKey}, + program::{PdaSeed, ProgramId}, }; use crate::{ExecutionFailureKind, WalletCore}; @@ -17,6 +18,14 @@ pub enum PrivacyPreservingAccount { npk: NullifierPublicKey, vpk: ViewingPublicKey, }, + /// A private PDA owned by a group. The wallet derives keys from the + /// `GroupKeyHolder` stored under `group_label`, then computes the + /// `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`. + PrivateGroupPda { + group_label: String, + program_id: ProgramId, + seed: PdaSeed, + }, } impl PrivacyPreservingAccount { @@ -29,7 +38,9 @@ impl PrivacyPreservingAccount { pub const fn is_private(&self) -> bool { matches!( &self, - Self::PrivateOwned(_) | Self::PrivateForeign { npk: _, vpk: _ } + Self::PrivateOwned(_) + | Self::PrivateForeign { npk: _, vpk: _ } + | Self::PrivateGroupPda { .. } ) } } @@ -94,6 +105,16 @@ impl AccountManager { (State::Private(pre), 2) } + PrivacyPreservingAccount::PrivateGroupPda { + group_label, + program_id, + seed, + } => { + let pre = + group_pda_preparation(wallet, &group_label, &program_id, &seed).await?; + + (State::Private(pre), 3) + } }; pre_states.push(state); @@ -106,6 +127,7 @@ impl AccountManager { }) } + #[must_use] pub fn pre_states(&self) -> Vec { self.states .iter() @@ -116,10 +138,12 @@ impl AccountManager { .collect() } + #[must_use] pub fn visibility_mask(&self) -> &[u8] { &self.visibility_mask } + #[must_use] pub fn public_account_nonces(&self) -> Vec { self.states .iter() @@ -130,6 +154,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn private_account_keys(&self) -> Vec { self.states .iter() @@ -149,6 +174,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn private_account_auth(&self) -> Vec { self.states .iter() @@ -159,6 +185,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn private_account_membership_proofs(&self) -> Vec> { self.states .iter() @@ -169,6 +196,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn public_account_ids(&self) -> Vec { self.states .iter() @@ -179,6 +207,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn public_account_auth(&self) -> Vec<&PrivateKey> { self.states .iter() @@ -198,6 +227,61 @@ struct AccountPreparedData { proof: Option, } +async fn group_pda_preparation( + wallet: &WalletCore, + group_label: &str, + program_id: &ProgramId, + seed: &PdaSeed, +) -> Result { + let holder = wallet + .storage + .user_data + .get_group_key_holder(group_label) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; + + let keys = holder.derive_keys_for_pda(seed); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let nsk = keys.nullifier_secret_key; + let account_id = nssa::AccountId::for_private_pda(program_id, seed, &npk); + + // Check local cache first (private PDA state is encrypted on-chain, the sequencer + // only stores commitments). Fall back to default for new PDAs. + let acc = wallet + .storage + .user_data + .group_pda_accounts + .get(&account_id) + .cloned() + .unwrap_or_default(); + + let exists = acc != nssa_core::account::Account::default(); + + // is_authorized tracks whether the account existed on-chain before this tx. + // NSK is only provided for existing accounts: the circuit consumes NSKs sequentially + // from an iterator and asserts none are left over, so supplying an NSK for a new + // (unauthorized) account would trigger the over-supply assertion. This matches the + // PrivateForeign path (nsk: None for unauthorized accounts). + let pre_state = AccountWithMetadata::new(acc, exists, account_id); + + let proof = if exists { + wallet + .check_private_account_initialized(account_id) + .await + .unwrap_or(None) + } else { + None + }; + + Ok(AccountPreparedData { + nsk: exists.then_some(nsk), + npk, + vpk, + pre_state, + proof, + }) +} + async fn private_acc_preparation( wallet: &WalletCore, account_id: AccountId, @@ -234,3 +318,19 @@ async fn private_acc_preparation( proof, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_group_pda_is_private() { + let acc = PrivacyPreservingAccount::PrivateGroupPda { + group_label: String::from("test"), + program_id: [1; 8], + seed: PdaSeed::new([2; 32]), + }; + assert!(acc.is_private()); + assert!(!acc.is_public()); + } +}