diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index 53e74aae..daf990be 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 faec156d..e5f41acb 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 b3679d7f..0c7a91d3 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 50af623d..99b35010 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 892af356..a877d206 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 af0505b6..d318ae28 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 23cafe17..8c001d69 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 47b8aad6..cef64fc8 100644 Binary files a/artifacts/program_methods/token.bin and b/artifacts/program_methods/token.bin differ diff --git a/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index 449a1827..cf156804 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 5063ff86..9a887285 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 ffbe1be5..9b6cda17 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 b8e112a9..98d525dd 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 4bd7e371..7109d1d3 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 ce262ad3..8c661508 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 2dded1eb..610e20b5 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 f7abd7d8..373105b7 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 3b521d52..4eb2aab0 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 ea6e4079..f55db10b 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 b7c7443d..1e4777b4 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 f965a588..ffabd847 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 9f0f596d..2d828a6d 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 81dca34b..136e1755 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 187b6914..0ebf3385 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 70ce9cb0..3e83434c 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 5eb3b82a..a268b059 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 153ac1fb..44143e79 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 5fd0ec0f..884cfad6 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 00000000..d69eff5c 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 7155e3ab..9cfa53f9 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 2da131e6..1a4d101b 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 b42ab836..35fb37b8 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 e0dbd7f3..0c26ef2e 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 new file mode 100644 index 00000000..0fb00640 Binary files /dev/null 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 86ffa307..730b3e29 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 6c332016..a0001f20 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/nssa/core/src/program.rs b/nssa/core/src/program.rs index 51741da4..e8c56414 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -27,7 +27,7 @@ pub struct ProgramInput { /// Each program can derive up to `2^256` unique account IDs by choosing different /// seeds. PDAs allow programs to control namespaced account identifiers without /// collisions between programs. -#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct PdaSeed([u8; 32]); impl PdaSeed { diff --git a/nssa/src/program.rs b/nssa/src/program.rs index 129979ee..f9c439bc 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -312,6 +312,16 @@ mod tests { } } + #[must_use] + pub fn two_pda_claimer() -> Self { + use test_program_methods::{TWO_PDA_CLAIMER_ELF, TWO_PDA_CLAIMER_ID}; + + Self { + id: TWO_PDA_CLAIMER_ID, + elf: TWO_PDA_CLAIMER_ELF.to_vec(), + } + } + #[must_use] pub fn changer_claimer() -> Self { use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID}; diff --git a/nssa/src/state.rs b/nssa/src/state.rs index 2fbfa7d9..f09d6b08 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -2494,6 +2494,42 @@ pub mod tests { /// `(seed, original_owner_program_id)` side input per mask-3 `pre_state` so the circuit /// can re-verify `private_pda_account_id(owner, seed, npk) == pre.account_id` without a /// claim. + /// Exploit-scenario pin. A single `(program_id, seed)` pair can derive a family of + /// `AccountId`s (one public PDA and one private PDA per distinct npk). Without the tx-wide + /// family-binding check, a program could claim `PDA_alice` (mask-3, `alice_npk`) and + /// `PDA_bob` (mask-3, `bob_npk`) under the same seed in one transaction, and — once reuse + /// is supported — a later chained call could delegate both to a callee via + /// `pda_seeds: [S]` and mix balances across them. The binding check rejects the setup + /// here: after the first claim records `(program, seed) → PDA_alice`, the second claim + /// tries to record `(program, seed) → PDA_bob` and panics. + #[test] + fn two_mask_3_claims_under_same_seed_are_rejected() { + let program = Program::two_pda_claimer(); + let keys_a = test_private_account_keys_1(); + let keys_b = test_private_account_keys_2(); + let seed = PdaSeed::new([55; 32]); + let shared_a = SharedSecretKey::new(&[66; 32], &keys_a.vpk()); + let shared_b = SharedSecretKey::new(&[77; 32], &keys_b.vpk()); + + let account_a = private_pda_account_id(&program.id(), &seed, &keys_a.npk()); + let account_b = private_pda_account_id(&program.id(), &seed, &keys_b.npk()); + + let pre_a = AccountWithMetadata::new(Account::default(), false, account_a); + let pre_b = AccountWithMetadata::new(Account::default(), false, account_b); + + let result = execute_and_prove( + vec![pre_a, pre_b], + Program::serialize_instruction(seed).unwrap(), + vec![3, 3], + vec![(keys_a.npk(), shared_a), (keys_b.npk(), shared_b)], + vec![], + vec![None, None], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + #[test] fn mask_3_reuse_across_txs_currently_unsupported() { let program = Program::noop(); @@ -2502,8 +2538,8 @@ pub mod tests { let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk()); let seed = PdaSeed::new([99; 32]); - // Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized = true, - // account_id derived via the private formula. + // Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized = + // true, account_id derived via the private formula. let account_id = private_pda_account_id(&program.id(), &seed, &npk); let owned_pre_state = AccountWithMetadata::new( Account { diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit.rs b/program_methods/guest/src/bin/privacy_preserving_circuit.rs index 734a452c..d6885aaf 100644 --- a/program_methods/guest/src/bin/privacy_preserving_circuit.rs +++ b/program_methods/guest/src/bin/privacy_preserving_circuit.rs @@ -11,7 +11,7 @@ use nssa_core::{ compute_digest_for_path, program::{ AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_ID, - MAX_NUMBER_CHAINED_CALLS, ProgramId, ProgramOutput, TimestampValidityWindow, + MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow, private_pda_account_id, validate_execution, }, }; @@ -30,6 +30,14 @@ struct ExecutionState { /// main loop, every mask-3 position must appear in this set; otherwise the npk is unbound /// and the circuit rejects. mask3_bound_positions: HashSet, + /// Across the whole transaction, each `(program_id, seed)` pair may resolve to at most one + /// `AccountId`. A seed under a program can derive a family of accounts (one public PDA and + /// one private PDA per distinct npk), and unifying `Claim::PrivatePda` and `ChainedCall`'s + /// private seeds into plain `Claim::Pda(seed)` / `pda_seeds` would otherwise let a single + /// `pda_seeds: [S]` in a chained call authorize multiple family members at once. We record + /// every claim and caller-authorization resolution here and reject any mismatch, making the + /// rule: one `(program, seed)` → one account per tx. + pda_family_binding: HashMap<(ProgramId, PdaSeed), AccountId>, } impl ExecutionState { @@ -75,6 +83,7 @@ impl ExecutionState { block_validity_window, timestamp_validity_window, mask3_bound_positions: HashSet::new(), + pda_family_binding: HashMap::new(), }; let Some(first_output) = program_outputs.first() else { @@ -150,17 +159,12 @@ impl ExecutionState { chained_calls.push_front((next_call.clone(), Some(chained_call.program_id))); } - let authorized_public_pdas = nssa_core::program::compute_authorized_pdas( - caller_program_id, - &chained_call.pda_seeds, - ); execution_state.validate_and_sync_states( visibility_mask, mask3_npk_by_position, chained_call.program_id, caller_program_id, &chained_call.pda_seeds, - &authorized_public_pdas, program_output.pre_states, program_output.post_states, ); @@ -212,15 +216,17 @@ impl ExecutionState { } /// Validate program pre and post states and populate the execution state. - #[expect(clippy::too_many_arguments, reason = "breaking out a context struct does not buy us anything here")] + #[expect( + clippy::too_many_arguments, + reason = "breaking out a context struct does not buy us anything here" + )] fn validate_and_sync_states( &mut self, visibility_mask: &[u8], mask3_npk_by_position: &HashMap, program_id: ProgramId, caller_program_id: Option, - caller_pda_seeds: &[nssa_core::program::PdaSeed], - authorized_public_pdas: &HashSet, + caller_pda_seeds: &[PdaSeed], pre_states: Vec, post_states: Vec, ) { @@ -259,28 +265,42 @@ impl ExecutionState { |(pos, acc)| (acc.is_authorized, pos) ); - let authorized_via_public = authorized_public_pdas.contains(&pre_account_id); - // Mask-3 PDAs are authorized by matching a caller seed against the private - // derivation with this pre_state's npk. The equality check binds the npk. + // Find which caller seed (if any) authorizes this pre_state, under the + // public or the private derivation. We need the *specific* seed (not just a + // bool) so we can record the `(caller, seed) → account_id` family binding. // Only reachable when `caller_program_id.is_some()` — top-level flows have - // no caller-emitted seeds, so binding at top level must come from the - // claim path below. - let authorized_via_private = mask3_npk_by_position - .get(&pre_state_position) - .and_then(|npk| { - let caller = caller_program_id?; - caller_pda_seeds.iter().find(|seed| { - private_pda_account_id(&caller, seed, npk) == pre_account_id - })?; - Some(()) - }) - .is_some(); - if authorized_via_private { - self.mask3_bound_positions.insert(pre_state_position); + // no caller-emitted seeds, so binding at top level must come from the claim + // path below. + let matched_caller_seed: Option<(PdaSeed, bool)> = + caller_program_id.and_then(|caller| { + caller_pda_seeds.iter().find_map(|seed| { + if AccountId::from((&caller, seed)) == pre_account_id { + return Some((*seed, false)); + } + if let Some(npk) = mask3_npk_by_position.get(&pre_state_position) + && private_pda_account_id(&caller, seed, npk) == pre_account_id + { + return Some((*seed, true)); + } + None + }) + }); + + if let Some((seed, is_private_form)) = matched_caller_seed { + let caller = caller_program_id + .expect("matched caller seed implies caller_program_id is set"); + assert_family_binding( + &mut self.pda_family_binding, + caller, + seed, + pre_account_id, + ); + if is_private_form { + self.mask3_bound_positions.insert(pre_state_position); + } } - let is_authorized = - previous_is_authorized || authorized_via_public || authorized_via_private; + let is_authorized = previous_is_authorized || matched_caller_seed.is_some(); assert_eq!( pre_is_authorized, is_authorized, @@ -324,6 +344,12 @@ impl ExecutionState { pre_account_id, pda, "Invalid PDA claim for account {pre_account_id} which does not match derived PDA {pda}" ); + assert_family_binding( + &mut self.pda_family_binding, + program_id, + seed, + pre_account_id, + ); } }, 3 => match claim { @@ -343,6 +369,12 @@ impl ExecutionState { "Invalid private PDA claim for account {pre_account_id}" ); self.mask3_bound_positions.insert(pre_state_position); + assert_family_binding( + &mut self.pda_family_binding, + program_id, + seed, + pre_account_id, + ); } }, _ => { @@ -373,6 +405,34 @@ impl ExecutionState { } } +/// Record or re-verify the `(program_id, seed) → account_id` family binding for the +/// transaction. Any claim or caller-seed authorization that resolves a `pre_state` under +/// `(program_id, seed)` must agree with every prior resolution of the same pair; otherwise a +/// single `pda_seeds: [seed]` entry could authorize multiple private-PDA family members at +/// once (different npks under the same seed) and let a callee mix balances across them. Free +/// function so callers can pass `&mut self.pda_family_binding` without holding a borrow on +/// the surrounding struct's other fields. +fn assert_family_binding( + bindings: &mut HashMap<(ProgramId, PdaSeed), AccountId>, + program_id: ProgramId, + seed: PdaSeed, + account_id: AccountId, +) { + match bindings.entry((program_id, seed)) { + Entry::Vacant(e) => { + e.insert(account_id); + } + Entry::Occupied(e) => { + assert_eq!( + *e.get(), + account_id, + "Two different accounts resolved under the same (program, seed) in one transaction: existing {}, new {account_id}", + e.get() + ); + } + } +} + fn compute_circuit_output( execution_state: ExecutionState, visibility_mask: &[u8], diff --git a/test_program_methods/guest/src/bin/two_pda_claimer.rs b/test_program_methods/guest/src/bin/two_pda_claimer.rs new file mode 100644 index 00000000..53aae666 --- /dev/null +++ b/test_program_methods/guest/src/bin/two_pda_claimer.rs @@ -0,0 +1,37 @@ +use nssa_core::program::{ + AccountPostState, Claim, PdaSeed, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +/// Claims two `pre_states` under the same `seed`. Used to exercise the tx-wide +/// `(program_id, seed) → AccountId` family-binding check: when both `pre_states` are mask-3 +/// with different npks, each `Claim::Pda(seed)` resolves to a different `AccountId` under the +/// same `(program, seed)` key, and the circuit must reject. +type Instruction = PdaSeed; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: seed, + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre_a, pre_b]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + let claim_a = AccountPostState::new_claimed(pre_a.account.clone(), Claim::Pda(seed)); + let claim_b = AccountPostState::new_claimed(pre_b.account.clone(), Claim::Pda(seed)); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre_a, pre_b], + vec![claim_a, claim_b], + ) + .write(); +}