From 4d6fe8a1d7929ad7674faf9485579e8ddb3f5336 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Thu, 21 May 2026 23:50:19 -0300 Subject: [PATCH] fix authorization propagation in the public execution path to match the private path --- docs/specs.md | 29 ++++++++++++++++++----------- nssa/src/error.rs | 3 +++ nssa/src/validated_state_diff.rs | 27 +++++++++++++++++++++------ 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/docs/specs.md b/docs/specs.md index eb68e7ec..5b50d0bb 100644 --- a/docs/specs.md +++ b/docs/specs.md @@ -750,7 +750,7 @@ The message hash is `SHA256(PREFIX || borsh_serialize(message))`. ### Witness set -A list of `(signature, public_key)` pairs. Each pair must produce a valid BIP-340 Schnorr signature over the message hash. The accounts derived from each `public_key` (via `AccountId::from(&public_key)`) form the *authorized signer set*. The program receives `is_authorized = true` for any pre-state account in this set (programs use the flag to decide whether to permit user-driven actions like transfers). Programs may also gain authorization for additional accounts via the PDA mechanism in chained calls. Authorization propagates down the call chain: any account that was `is_authorized` in a program's verified pre-states is passed as an authorized account to that program's chained calls, so PDAs authorized at hop N remain authorized at hop N+1 if the hop-N program includes them in its chained call's pre-states. Upon acceptance, each signing account's nonce is incremented by 1. +A list of `(signature, public_key)` pairs. Each pair must produce a valid BIP-340 Schnorr signature over the message hash. The accounts derived from each `public_key` (via `AccountId::from(&public_key)`) form the *authorized signer set*. The program receives `is_authorized = true` for any pre-state account in this set (programs use the flag to decide whether to permit user-driven actions like transfers). Programs may also gain authorization for additional accounts via the PDA mechanism in chained calls. Authorization propagates down the call chain monotonically: the authorized set passed to each child call is the union of the parent's own authorized set and the parent's verified authorized pre-states, so an account authorized at any hop remains authorized for all subsequent calls even if an intermediate hop does not include it in its pre-states. Upon acceptance, each signing account's nonce is incremented by 1. ## Structure of a privacy-preserving transaction @@ -1092,9 +1092,10 @@ fn validate_and_produce_public_state_diff( let expected_pre = state_diff.get(pre.account_id) .unwrap_or(nssa_state.public_state.get(pre.account_id)); assert_eq!(pre.account, expected_pre); - // Programs may under-report authorization (flag false on a truly-authorized account) - // but may never forge it (flag true on an unauthorized account). - assert!(!pre.is_authorized || is_authorized(pre.account_id)); + // The is_authorized flag must exactly match the actual authorization status: + // programs cannot forge it (flag true on an unauthorized account) nor + // under-report it (flag false on a truly-authorized account). + assert_eq!(pre.is_authorized, is_authorized(pre.account_id)); } // Verify the output identifies its own program ID and caller correctly. @@ -1127,13 +1128,19 @@ fn validate_and_produce_public_state_diff( state_diff.insert(pre.account_id, post.account); } - // Build the authorized set for child calls from the verified pre-states of this call. - // Using program_output.pre_states (not call.pre_states) ensures the set is derived - // from already-validated data and cannot be forged by a caller-supplied input. - // This propagates both signer and PDA authorization down the call chain. - let authorized_accounts = program_output.pre_states - .filter(|pre| pre.is_authorized) - .map(|pre| pre.account_id); + // Build the authorized set for child calls: the union of the caller's authorized set + // and the verified authorized pre-states of this call. Using program_output.pre_states + // (not call.pre_states) ensures the new entries are derived from already-validated data + // and cannot be forged by a caller-supplied input. Authorization is monotonically + // growing — once an account is authorized at any point in the chain it remains + // authorized for all subsequent calls, even if an intermediate hop does not include + // it in its own pre-states. + let authorized_accounts = caller_data.authorized_accounts + .union( + program_output.pre_states + .filter(|pre| pre.is_authorized) + .map(|pre| pre.account_id) + ); // Push chained calls (in declared order). Pushing them to the front of the queue // produces a depth-first traversal. diff --git a/nssa/src/error.rs b/nssa/src/error.rs index 65079d25..31f78461 100644 --- a/nssa/src/error.rs +++ b/nssa/src/error.rs @@ -96,6 +96,9 @@ pub enum InvalidProgramBehaviorError { #[error("Unauthorized account marked as authorized")] InvalidAccountAuthorization { account_id: AccountId }, + #[error("Authorized account marked as not authorized")] + AuthorizedAccountMarkedAsNotAuthorized { account_id: AccountId }, + #[error("Program ID mismatch: expected {expected:?}, actual {actual:?}")] MismatchedProgramId { expected: ProgramId, diff --git a/nssa/src/validated_state_diff.rs b/nssa/src/validated_state_diff.rs index 87bde206..3356b72c 100644 --- a/nssa/src/validated_state_diff.rs +++ b/nssa/src/validated_state_diff.rs @@ -173,12 +173,18 @@ impl ValidatedStateDiff { ); // Check that the program output pre_states marked as authorized are indeed - // authorized. + // authorized, and vice-versa. let is_indeed_authorized = is_authorized(&account_id); ensure!( !pre.is_authorized || is_indeed_authorized, InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id } ); + ensure!( + pre.is_authorized || !is_indeed_authorized, + InvalidProgramBehaviorError::AuthorizedAccountMarkedAsNotAuthorized { + account_id + } + ); } // Verify that the program output's self_program_id matches the expected program ID. @@ -269,11 +275,20 @@ impl ValidatedStateDiff { // the loop above already gates program_output's `is_authorized` via the // `!pre.is_authorized || is_indeed_authorized` check, while `chained_call. // pre_states` is caller-controlled and can be forged (audit-issue 91). - let authorized_accounts: HashSet<_> = program_output - .pre_states - .iter() - .filter(|pre| pre.is_authorized) - .map(|pre| pre.account_id) + // + // Union with the caller's authorized set so that authorization is monotonically + // growing: once an account is authorized at any point in the chain it remains + // authorized for all subsequent calls. + let authorized_accounts: HashSet<_> = caller_data + .authorized_accounts + .into_iter() + .chain( + program_output + .pre_states + .iter() + .filter(|pre| pre.is_authorized) + .map(|pre| pre.account_id), + ) .collect(); for new_call in program_output.chained_calls.into_iter().rev() { chained_calls.push_front((