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 304aaf49e..3f6fef3a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1374,6 +1374,7 @@ dependencies = [ "cfg-if", "cipher 0.5.1", "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -1959,7 +1960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2124,9 +2125,9 @@ dependencies = [ [[package]] name = "docker-compose-types" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edb75a85449fd9c34d9fb3376c6208ec4115d2ca43b965175a52d71349ecab8" +checksum = "6ea51e75cfa9371c4d760270c3da13516d7206121d668c1fbdd6fd83d1782b0f" dependencies = [ "derive_builder", "indexmap 2.13.0", @@ -2501,12 +2502,12 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "ferroid" -version = "0.8.9" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" dependencies = [ "portable-atomic", - "rand 0.9.3", + "rand 0.10.1", "web-time", ] @@ -2821,6 +2822,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", "wasm-bindgen", @@ -3993,6 +3995,7 @@ dependencies = [ "aes-gcm", "anyhow", "base58", + "bincode", "bip39", "common", "hex", @@ -5399,7 +5402,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6321,6 +6324,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -6359,6 +6373,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -8073,9 +8093,9 @@ dependencies = [ [[package]] name = "testcontainers" -version = "0.27.2" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd36b06a2a6c0c3c81a83be1ab05fe86460d054d4d51bf513bc56b3e15bdc22" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" dependencies = [ "astral-tokio-tar", "async-trait", diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index 36caad852..eb61930f0 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 9f5474a9a..ec0164088 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 bdbcef616..948b55fce 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 d3ca0daba..43441189a 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 5e6a011ba..c34fecffe 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 57a201c4e..4ec6b2318 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 dd6131434..7d99a1c07 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 6366eba66..c4074c2f0 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 f9e4d1d48..87f4b51d6 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 94a902361..af97ce052 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 58331d6c5..ff319aad9 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 2760b7a3f..197dac465 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 ff504da13..32d593b63 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 37c9a0049..b5f3b3094 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 3d69b8cbc..8b4a265d1 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 873ce66ad..dcab86670 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 0846f2550..cf772ab6c 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 1e2852453..3eb10f066 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/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index cc7576834..38cb32285 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 f152051d9..07516d823 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 6d83b95be..bbac6fa9e 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 29bcd715d..6aa244fa5 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 c7cc1571a..563b8b8e0 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 8f2b1e395..8aca7923f 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 993c14516..f291e594f 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 579db977d..5cd93004b 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 1a5413841..69218d03f 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 2b0d979a9..31473b3b9 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 4b55e8715..169cab362 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/private_pda_spender.bin b/artifacts/test_program_methods/private_pda_spender.bin new file mode 100644 index 000000000..461bfda27 Binary files /dev/null and b/artifacts/test_program_methods/private_pda_spender.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index 3bdabaded..f25e8673c 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 0aaf1a23a..0fe2907eb 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 5700322e6..034052691 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 600b819db..a404e17c7 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 02ccc1494..dc8bf3fe8 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 d239c7502..b110d6be9 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/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 53f0ee984..1fa0187ff 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -35,4 +35,4 @@ hex.workspace = true tempfile.workspace = true bytesize.workspace = true futures.workspace = true -testcontainers = { version = "0.27.0", features = ["docker-compose"] } +testcontainers = { version = "0.27.3", features = ["docker-compose"] } 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..9e7bd8fc8 --- /dev/null +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -0,0 +1,504 @@ +use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _}; +use nssa_core::{ + SharedSecretKey, + encryption::{Scalar, 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}; + +/// Public key used to seal a `GroupKeyHolder` for distribution to a recipient. +/// +/// Structurally identical to `ViewingPublicKey` (both are secp256k1 points), but given +/// a distinct alias to clarify intent: viewing keys encrypt account state, sealing keys +/// encrypt the GMS for off-chain distribution. +pub type SealingPublicKey = Secp256k1Point; + +/// Secret key used to unseal a `GroupKeyHolder` received from another member. +pub type SealingSecretKey = Scalar; + +/// 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 [`SealingPublicKey`] +/// (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], +} + +impl std::fmt::Debug for GroupKeyHolder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GroupKeyHolder") + .field("gms", &"") + .finish() + } +} + +impl Default for GroupKeyHolder { + fn default() -> Self { + Self::new() + } +} + +impl GroupKeyHolder { + /// Create a new group with a fresh random GMS. + #[must_use] + pub fn new() -> Self { + let mut gms = [0_u8; 32]; + OsRng.fill_bytes(&mut gms); + Self { gms } + } + + /// Restore from an existing GMS (received via `unseal`). + #[must_use] + pub const fn from_gms(gms: [u8; 32]) -> Self { + Self { gms } + } + + /// 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 + } + + /// 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 under the recipient's [`SealingPublicKey`]. + /// + /// 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 (48)` = 93 bytes. + /// + /// Each call generates a fresh ephemeral key, so two seals of the same holder produce + /// different ciphertexts. + #[must_use] + pub fn seal_for(&self, recipient_key: &SealingPublicKey) -> Vec { + let mut ephemeral_scalar: Scalar = [0_u8; 32]; + OsRng.fill_bytes(&mut ephemeral_scalar); + let ephemeral_pubkey = Secp256k1Point::from_scalar(ephemeral_scalar); + let shared = SharedSecretKey::new(&ephemeral_scalar, recipient_key); + 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 ciphertext = cipher + .encrypt(&nonce, self.gms.as_ref()) + .expect("AES-GCM encryption should not fail with valid key/nonce"); + + let capacity = 33_usize + .checked_add(12) + .and_then(|n| n.checked_add(ciphertext.len())) + .expect("seal capacity overflow"); + let mut out = Vec::with_capacity(capacity); + 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 [`SealingSecretKey`]. + /// + /// 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_key: &SealingSecretKey) -> 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_key, &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() != 32 { + return Err(SealError::DecryptionFailed); + } + + let mut gms = [0_u8; 32]; + gms.copy_from_slice(&plaintext); + Ok(Self::from_gms(gms)) + } + + /// 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 original = GroupKeyHolder::from_gms([7_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()); + } + + /// 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); + } + + /// Seal then unseal recovers the same GMS and derived keys. + #[test] + fn seal_unseal_round_trip() { + 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 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()); + + 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: SealingSecretKey = [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. + #[test] + fn group_pda_lifecycle() { + use nssa_core::account::AccountId; + + let alice_holder = GroupKeyHolder::new(); + 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); + } +} diff --git a/key_protocol/src/key_management/mod.rs b/key_protocol/src/key_management/mod.rs index 065af3649..aa5a1a754 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 4df6df823..d12f83a1a 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::key_management::{ KeyChain, + group_key_holder::GroupKeyHolder, key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex}, secret_holders::SeedHolder, }; @@ -30,6 +31,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 private PDA accounts, keyed by `AccountId`. + /// Updated after each private 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 private PDAs. + #[serde(default, alias = "group_pda_accounts")] + pub pda_accounts: BTreeMap, } impl NSSAUserData { @@ -88,6 +100,8 @@ impl NSSAUserData { default_user_private_accounts: default_accounts_key_chains, public_key_tree, private_key_tree, + group_key_holders: BTreeMap::new(), + pda_accounts: BTreeMap::new(), }) } @@ -195,6 +209,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 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 { @@ -214,6 +242,26 @@ 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.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 + .group_key_holder("test-group") + .expect("should exist"); + assert_eq!(retrieved.dangerous_raw_gms(), holder.dangerous_raw_gms()); + } + + #[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 1ef2ef6c2..e4e339327 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/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index f5bd8cea9..5c09fad3c 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -186,6 +186,7 @@ mod tests { use nssa_core::{ Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, Nullifier, SharedSecretKey, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, + program::PdaSeed, }; use super::*; @@ -411,4 +412,100 @@ 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 `private_pda_spender`. + #[test] + fn group_pda_deposit() { + let program = Program::private_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). + // The noop chained call is required to establish the mask-3 (seed, npk) binding + // that the circuit enforces for private PDAs. Without a caller providing pda_seeds, + // the circuit's binding check rejects the account. + let result = execute_and_prove( + vec![pda_pre, sender_pre], + instruction, + vec![3, 0], + vec![(npk, u128::MAX, 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::private_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, u128::MAX, 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); + } } diff --git a/nssa/src/program.rs b/nssa/src/program.rs index b8c3fe77a..a214b055c 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -312,6 +312,16 @@ mod tests { } } + #[must_use] + pub fn private_pda_spender() -> Self { + use test_program_methods::{PRIVATE_PDA_SPENDER_ELF, PRIVATE_PDA_SPENDER_ID}; + + Self { + id: PRIVATE_PDA_SPENDER_ID, + elf: PRIVATE_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 ff16175c0..6de2127b7 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -2599,7 +2599,6 @@ pub mod tests { assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); } - #[test] fn circuit_should_fail_with_too_many_nonces() { let program = Program::simple_balance_transfer(); diff --git a/test_program_methods/guest/src/bin/private_pda_spender.rs b/test_program_methods/guest/src/bin/private_pda_spender.rs new file mode 100644 index 000000000..04ef91a4b --- /dev/null +++ b/test_program_methods/guest/src/bin/private_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/cli/group.rs b/wallet/src/cli/group.rs new file mode 100644 index 000000000..5cdcc0af5 --- /dev/null +++ b/wallet/src/cli/group.rs @@ -0,0 +1,295 @@ +use anyhow::{Context as _, Result}; +use clap::Subcommand; +use key_protocol::key_management::group_key_holder::GroupKeyHolder; +use nssa::AccountId; +use nssa_core::program::PdaSeed; + +use crate::{ + WalletCore, + cli::{SubcommandReturnValue, WalletSubcommand}, +}; + +/// Group PDA management commands. +#[derive(Subcommand, Debug, Clone)] +pub enum GroupSubcommand { + /// Create a new group with a fresh random GMS. + New { + /// Human-readable name for the group. + name: String, + }, + /// Import a group from raw GMS bytes. + Import { + /// Human-readable name for the group. + name: String, + /// Raw GMS as 64-character hex string. + #[arg(long)] + gms: String, + /// Epoch (defaults to 0). + #[arg(long, default_value = "0")] + epoch: u32, + }, + /// Export the raw GMS hex for backup or manual distribution. + Export { + /// Group name. + name: String, + }, + /// List all groups with their epochs. + #[command(visible_alias = "ls")] + List, + /// Derive keys for a PDA seed and show the resulting AccountId. + Derive { + /// Group name. + name: String, + /// PDA seed as 64-character hex string. + #[arg(long)] + seed: String, + /// Program ID as hex string (u32x8 little-endian). + #[arg(long)] + program_id: String, + }, + /// Remove a group from the wallet. + Remove { + /// Group name. + name: String, + }, + /// Seal the group's GMS for a recipient (invite). + Invite { + /// Group name. + name: String, + /// Recipient's viewing public key as hex string. + #[arg(long)] + vpk: String, + }, + /// Unseal a received GMS and store it (join a group). + Join { + /// Human-readable name to store the group under. + name: String, + /// Sealed GMS as hex string (from the inviter). + #[arg(long)] + sealed: String, + /// Account label or Private/ whose VSK to use for decryption. + #[arg(long)] + account: String, + }, + /// Ratchet the GMS to exclude removed members. + Ratchet { + /// Group name. + name: String, + }, +} + +impl WalletSubcommand for GroupSubcommand { + async fn handle_subcommand( + self, + wallet_core: &mut WalletCore, + ) -> Result { + match self { + Self::New { name } => { + if wallet_core + .storage() + .user_data + .get_group_key_holder(&name) + .is_some() + { + anyhow::bail!("Group '{name}' already exists"); + } + + let holder = GroupKeyHolder::new(); + wallet_core + .storage_mut() + .user_data + .insert_group_key_holder(name.clone(), holder); + wallet_core.store_persistent_data().await?; + + println!("Created group '{name}' at epoch 0"); + Ok(SubcommandReturnValue::Empty) + } + + Self::Import { name, gms, epoch } => { + if wallet_core + .storage() + .user_data + .get_group_key_holder(&name) + .is_some() + { + anyhow::bail!("Group '{name}' already exists"); + } + + let gms_bytes: [u8; 32] = hex::decode(&gms) + .context("Invalid GMS hex")? + .try_into() + .map_err(|_| anyhow::anyhow!("GMS must be exactly 32 bytes"))?; + + let holder = GroupKeyHolder::from_gms_and_epoch(gms_bytes, epoch); + wallet_core + .storage_mut() + .user_data + .insert_group_key_holder(name.clone(), holder); + wallet_core.store_persistent_data().await?; + + println!("Imported group '{name}' at epoch {epoch}"); + Ok(SubcommandReturnValue::Empty) + } + + Self::Export { name } => { + let holder = wallet_core + .storage() + .user_data + .get_group_key_holder(&name) + .context(format!("Group '{name}' not found"))?; + + let gms_hex = hex::encode(holder.dangerous_raw_gms()); + let epoch = holder.epoch(); + + println!("Group: {name}"); + println!("Epoch: {epoch}"); + println!("GMS: {gms_hex}"); + Ok(SubcommandReturnValue::Empty) + } + + Self::List => { + let holders = &wallet_core.storage().user_data.group_key_holders; + if holders.is_empty() { + println!("No groups found"); + } else { + for (name, holder) in holders { + println!("{name} (epoch {})", holder.epoch()); + } + } + Ok(SubcommandReturnValue::Empty) + } + + Self::Derive { + name, + seed, + program_id, + } => { + let holder = wallet_core + .storage() + .user_data + .get_group_key_holder(&name) + .context(format!("Group '{name}' not found"))?; + + let seed_bytes: [u8; 32] = hex::decode(&seed) + .context("Invalid seed hex")? + .try_into() + .map_err(|_| anyhow::anyhow!("Seed must be exactly 32 bytes"))?; + let pda_seed = PdaSeed::new(seed_bytes); + + let pid_bytes = + hex::decode(&program_id).context("Invalid program ID hex")?; + if pid_bytes.len() != 32 { + anyhow::bail!("Program ID must be exactly 32 bytes"); + } + let mut pid: nssa_core::program::ProgramId = [0; 8]; + for (i, chunk) in pid_bytes.chunks_exact(4).enumerate() { + pid[i] = u32::from_le_bytes(chunk.try_into().unwrap()); + } + + let keys = holder.derive_keys_for_pda(&pda_seed); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let account_id = AccountId::for_private_pda(&pid, &pda_seed, &npk); + + println!("Group: {name}"); + println!("NPK: {}", hex::encode(npk.0)); + println!("VPK: {}", hex::encode(&vpk.0)); + println!("AccountId: {account_id}"); + Ok(SubcommandReturnValue::Empty) + } + + Self::Remove { name } => { + if wallet_core + .storage_mut() + .user_data + .group_key_holders + .remove(&name) + .is_none() + { + anyhow::bail!("Group '{name}' not found"); + } + + wallet_core.store_persistent_data().await?; + println!("Removed group '{name}'"); + Ok(SubcommandReturnValue::Empty) + } + + Self::Invite { name, vpk } => { + let holder = wallet_core + .storage() + .user_data + .get_group_key_holder(&name) + .context(format!("Group '{name}' not found"))?; + + let vpk_bytes = hex::decode(&vpk).context("Invalid VPK hex")?; + let recipient_vpk = + nssa_core::encryption::shared_key_derivation::Secp256k1Point(vpk_bytes); + + let sealed = holder.seal_for(&recipient_vpk); + println!("{}", hex::encode(&sealed)); + Ok(SubcommandReturnValue::Empty) + } + + Self::Join { + name, + sealed, + account, + } => { + if wallet_core + .storage() + .user_data + .get_group_key_holder(&name) + .is_some() + { + anyhow::bail!("Group '{name}' already exists"); + } + + let sealed_bytes = hex::decode(&sealed).context("Invalid sealed hex")?; + + // Resolve the account to get the VSK + let account_id: nssa::AccountId = account + .parse() + .context("Invalid account ID (use Private/)")?; + let (keychain, _) = wallet_core + .storage() + .user_data + .get_private_account(account_id) + .context("Private account not found")?; + let vsk = keychain.private_key_holder.viewing_secret_key; + + let holder = GroupKeyHolder::unseal(&sealed_bytes, &vsk) + .map_err(|e| anyhow::anyhow!("Failed to unseal: {e:?}"))?; + + let epoch = holder.epoch(); + wallet_core + .storage_mut() + .user_data + .insert_group_key_holder(name.clone(), holder); + wallet_core.store_persistent_data().await?; + + println!("Joined group '{name}' at epoch {epoch}"); + Ok(SubcommandReturnValue::Empty) + } + + Self::Ratchet { name } => { + let holder = wallet_core + .storage_mut() + .user_data + .group_key_holders + .get_mut(&name) + .context(format!("Group '{name}' not found"))?; + + let mut salt = [0_u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt); + holder.ratchet(salt); + + let epoch = holder.epoch(); + wallet_core.store_persistent_data().await?; + + println!("Ratcheted group '{name}' to epoch {epoch}"); + println!("Re-invite remaining members with 'group invite'"); + Ok(SubcommandReturnValue::Empty) + } + } + } +} diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index 3df2ecc12..252cdfadd 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -5,6 +5,7 @@ use nssa_core::{ Identifier, MembershipProof, NullifierPublicKey, NullifierSecretKey, SharedSecretKey, account::{AccountWithMetadata, Nonce}, encryption::{EphemeralPublicKey, ViewingPublicKey}, + program::{PdaSeed, ProgramId}, }; use crate::{ExecutionFailureKind, WalletCore}; @@ -18,6 +19,16 @@ pub enum PrivacyPreservingAccount { vpk: ViewingPublicKey, identifier: Identifier, }, + /// A private PDA with externally-provided keys. The caller resolves the keys + /// (e.g. via `GroupKeyHolder::derive_keys_for_pda`) before constructing this variant. + /// The wallet computes the `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`. + PrivatePda { + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + program_id: ProgramId, + seed: PdaSeed, + }, } impl PrivacyPreservingAccount { @@ -34,8 +45,9 @@ impl PrivacyPreservingAccount { | Self::PrivateForeign { npk: _, vpk: _, - identifier: _ + identifier: _, } + | Self::PrivatePda { .. } ) } } @@ -106,6 +118,18 @@ impl AccountManager { (State::Private(pre), 2) } + PrivacyPreservingAccount::PrivatePda { + nsk, + npk, + vpk, + program_id, + seed, + } => { + let pre = + private_pda_preparation(wallet, nsk, npk, vpk, &program_id, &seed).await?; + + (State::Private(pre), 3) + } }; pre_states.push(state); @@ -118,6 +142,7 @@ impl AccountManager { }) } + #[must_use] pub fn pre_states(&self) -> Vec { self.states .iter() @@ -128,10 +153,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() @@ -142,6 +169,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn private_account_keys(&self) -> Vec { self.states .iter() @@ -162,6 +190,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn private_account_auth(&self) -> Vec { self.states .iter() @@ -172,6 +201,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn private_account_membership_proofs(&self) -> Vec> { self.states .iter() @@ -182,6 +212,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn public_account_ids(&self) -> Vec { self.states .iter() @@ -192,6 +223,7 @@ impl AccountManager { .collect() } + #[must_use] pub fn public_account_auth(&self) -> Vec<&PrivateKey> { self.states .iter() @@ -212,6 +244,53 @@ struct AccountPreparedData { proof: Option, } +async fn private_pda_preparation( + wallet: &WalletCore, + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + program_id: &ProgramId, + seed: &PdaSeed, +) -> Result { + 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 + .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. + 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, + identifier: u128::MAX, + vpk, + pre_state, + proof, + }) +} + async fn private_acc_preparation( wallet: &WalletCore, account_id: AccountId, @@ -246,3 +325,21 @@ async fn private_acc_preparation( proof, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_pda_is_private() { + let acc = PrivacyPreservingAccount::PrivatePda { + nsk: [0; 32], + npk: NullifierPublicKey([1; 32]), + vpk: ViewingPublicKey::from_scalar([2; 32]), + program_id: [3; 8], + seed: PdaSeed::new([4; 32]), + }; + assert!(acc.is_private()); + assert!(!acc.is_public()); + } +}