fix(lee): check private PDA authorization on first sight

This commit is contained in:
agureev
2026-08-17 17:23:39 +00:00
committed by Artem Gureev
parent 79c43dc5a0
commit c77313b5ca
11 changed files with 270 additions and 104 deletions
@@ -306,21 +306,16 @@ impl ExecutionState {
)
});
let is_authorized = resolve_authorization_and_record_bindings(
assert_authorization_and_record_bindings(
&mut self.pda_family_binding,
&mut self.private_pda_bound_positions,
&self.private_pda_by_position,
&self.globally_authorized,
&caller.authorized_accounts,
&caller,
caller_pda_seeds,
pre_account_id,
pre_state_position,
caller.program_id,
caller_pda_seeds,
);
assert_eq!(
pre_is_authorized, is_authorized,
"Inconsistent authorization for account {pre_account_id}",
pre_is_authorized,
);
}
Entry::Vacant(_) => {
@@ -356,10 +351,6 @@ impl ExecutionState {
// Subsequent calls need no re-check because the entry is already recorded on
// private_pda_bound_positions.
if let Some((authority_program_id, seed)) = external_seed {
assert!(
!pre.is_authorized,
"Private PDA with externally-provided seed must not be authorized at position {pre_state_position}"
);
bind_private_pda_position(
&mut self.private_pda_bound_positions,
pre_state_position,
@@ -373,13 +364,25 @@ impl ExecutionState {
pre_account_id,
);
}
let is_private_pda = self
.private_pda_by_position
.contains_key(&pre_state_position);
if is_private_pda {
assert_authorization_and_record_bindings(
&mut self.pda_family_binding,
&mut self.private_pda_bound_positions,
&self.private_pda_by_position,
&self.globally_authorized,
&caller,
caller_pda_seeds,
pre_account_id,
pre_state_position,
pre_is_authorized,
);
}
// If an account is regular and authorized, make it globally-authorized.
if pre_is_authorized
&& !self
.private_pda_by_position
.contains_key(&pre_state_position)
{
if pre_is_authorized && !is_private_pda {
self.globally_authorized.insert(pre_account_id);
}
self.pre_states.push(pre);
@@ -563,58 +566,60 @@ fn bind_private_pda_position(
}
}
/// Resolve the authorization state of a `pre_state` seen again in a chained call and record
/// any resulting bindings. When a caller seed matches, also records the
/// `(caller, seed) → account_id` family binding and, for the private form, marks the position
/// in `private_pda_bound_positions`. 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. Free function so callers can pass individual `&mut self.*` field borrows
/// without holding a borrow on the surrounding struct's other fields.
/// When a caller seed matches, also records the `(caller, seed) → account_id` family binding
/// and, for the private form, marks the position in `private_pda_bound_positions`. Free
/// function so callers can pass individual `&mut self.*` field borrows without holding a borrow
/// on the surrounding struct's other fields.
#[expect(
clippy::too_many_arguments,
reason = "breaking out a context struct does not buy us anything here"
)]
fn resolve_authorization_and_record_bindings(
fn assert_authorization_and_record_bindings(
pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>,
private_pda_bound_positions: &mut HashMap<usize, (ProgramId, PdaSeed)>,
private_pda_by_position: &HashMap<usize, (NullifierPublicKey, ViewingPublicKey, Identifier)>,
globally_authorized: &HashSet<AccountId>,
caller_authorized: &HashSet<AccountId>,
caller: &CallerData,
caller_pda_seeds: &[PdaSeed],
pre_account_id: AccountId,
pre_state_position: usize,
caller_program_id: Option<ProgramId>,
caller_pda_seeds: &[PdaSeed],
) -> bool {
pre_is_authorized: bool,
) {
let matched_caller_seed: Option<(PdaSeed, bool, ProgramId)> =
caller_program_id.and_then(|caller| {
caller.program_id.and_then(|caller_program_id| {
caller_pda_seeds.iter().find_map(|seed| {
if AccountId::for_public_pda(&caller, seed) == pre_account_id {
return Some((*seed, false, caller));
if AccountId::for_public_pda(&caller_program_id, seed) == pre_account_id {
return Some((*seed, false, caller_program_id));
}
if let Some((npk, vpk, identifier)) =
private_pda_by_position.get(&pre_state_position)
&& AccountId::for_private_pda(&caller, seed, npk, vpk, *identifier)
&& AccountId::for_private_pda(&caller_program_id, seed, npk, vpk, *identifier)
== pre_account_id
{
return Some((*seed, true, caller));
return Some((*seed, true, caller_program_id));
}
None
})
});
if let Some((seed, is_private_form, caller)) = matched_caller_seed {
assert_family_binding(pda_family_binding, caller, seed, pre_account_id);
if let Some((seed, is_private_form, caller_program_id)) = matched_caller_seed {
assert_family_binding(pda_family_binding, caller_program_id, seed, pre_account_id);
if is_private_form {
bind_private_pda_position(
private_pda_bound_positions,
pre_state_position,
caller,
caller_program_id,
seed,
);
}
}
matched_caller_seed.is_some()
let is_authorized = matched_caller_seed.is_some()
|| globally_authorized.contains(&pre_account_id)
|| caller_authorized.contains(&pre_account_id)
|| caller.authorized_accounts.contains(&pre_account_id);
assert_eq!(
pre_is_authorized, is_authorized,
"Inconsistent authorization for account {pre_account_id}",
);
}
+20 -38
View File
@@ -66,46 +66,28 @@ pub fn compute_circuit_output(
WitnessKind::Pda { .. } => pre_state.account_id,
};
match (kind, nullifier) {
(
WitnessKind::Regular { ask },
NullifierWitness::Init { .. } | NullifierWitness::Update { .. },
) => {
if let Some(ask) = ask {
let derived = NullifierSecretKey::from(ask);
match nullifier {
// Check that the authorization key is actually bound to the
// account Id.
NullifierWitness::Update { nsk, .. } => assert_eq!(
derived, *nsk,
"Authorization secret key does not derive this account's nullifier secret key"
),
NullifierWitness::Init { npk, .. } => assert_eq!(
NullifierPublicKey::from(&derived),
*npk,
"Authorization secret key does not derive this account's nullifier public key"
),
}
if let WitnessKind::Regular { ask } = kind {
if let Some(ask) = ask {
let derived = NullifierSecretKey::from(ask);
match nullifier {
// Check that the authorization key is actually bound to the
// account Id.
NullifierWitness::Update { nsk, .. } => assert_eq!(
derived, *nsk,
"Authorization secret key does not derive this account's nullifier secret key"
),
NullifierWitness::Init { npk, .. } => assert_eq!(
NullifierPublicKey::from(&derived),
*npk,
"Authorization secret key does not derive this account's nullifier public key"
),
}
assert_eq!(
pre_state.is_authorized,
ask.is_some(),
"Regular private account authorization must match the supplied credential"
);
}
(WitnessKind::Pda { .. }, NullifierWitness::Init { .. }) => assert!(
!pre_state.is_authorized,
"Private PDA init requires unauthorized pre_state"
),
// With an external seed the binding comes from the circuit input and the
// pre_state is intentionally unauthorized; without one the binding comes from
// a Claim or caller pda_seeds, so the pre_state must already be authorized.
// When `binding` is `Some`, execution_state already asserted
// `!pre_state.is_authorized`.
(WitnessKind::Pda { binding }, NullifierWitness::Update { .. }) => assert!(
pre_state.is_authorized ^ binding.is_some(),
"Private PDA update requires authorized pre_state or external seed"
),
assert_eq!(
pre_state.is_authorized,
ask.is_some(),
"Regular private account authorization must match the supplied credential"
);
}
let (new_nullifier, new_nonce, view_tag) = match nullifier {
+1 -4
View File
@@ -54,15 +54,12 @@ pub enum WitnessKind {
/// Private PDA. The npk-to-account_id binding is proven upstream via `Claim::Pda(seed)` or a
/// caller's `pda_seeds` match. The identifier diversifies the PDA within the
/// `(program_id, seed, npk)` family: `AccountId::for_private_pda` uses it as the 4th input.
/// An init is unauthorized; on an update, authorization may be established upstream by a
/// caller `pda_seeds` match or a previously-seen authorization in a chained call.
Pda {
/// When `Some((authority_program_id, seed))`, the circuit binds this position via the
/// external derivation check
/// `AccountId::for_private_pda(authority_program_id, seed, npk, vpk, identifier) ==
/// pre_state.account_id` rather than requiring a `Claim::Pda` or caller
/// `pda_seeds` to establish the binding. The `pre_state` must have `is_authorized
/// == false`.
/// `pda_seeds` to establish the binding.
binding: Option<(ProgramId, PdaSeed)>,
},
}
+8
View File
@@ -143,6 +143,14 @@ mod test_methods {
)
}
#[must_use]
pub const fn undeclaring_pda_delegator() -> Program {
Program::new_unchecked(
test_methods::UNDECLARING_PDA_DELEGATOR_ID,
Cow::Borrowed(test_methods::UNDECLARING_PDA_DELEGATOR_ELF),
)
}
#[must_use]
pub const fn non_delegating_forwarder() -> Program {
Program::new_unchecked(
@@ -974,7 +974,7 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() {
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id);
let pda_pre = AccountWithMetadata::new(pda_account, false, pda_id);
let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps = ProgramWithDependencies::new(
@@ -1013,6 +1013,54 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() {
);
}
#[test]
fn private_pda_update_at_root_call_may_not_declare_authorization() {
let program = crate::test_methods::pda_spend_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let simple_transfer_id = simple_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier);
let pda_account = Account {
program_owner: simple_transfer_id,
balance: 1,
..Account::default()
};
let pda_commitment = Commitment::new(&pda_id, &pda_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id);
let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps =
ProgramWithDependencies::new(program, [(simple_transfer_id, simple_transfer)].into());
let result = execute_and_prove(
vec![pda_pre, recipient_pre],
Program::serialize_instruction((seed, 1_u128, simple_transfer_id)).unwrap(),
vec![
InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: keys.nsk(),
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
},
}),
InputAccountIdentity::Public,
],
&program_with_deps,
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn private_pda_init_identifier_mismatch_fails() {
let program = crate::test_methods::pda_claimer();
@@ -1020,7 +1068,7 @@ fn private_pda_init_identifier_mismatch_fails() {
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5);
let pre_state = AccountWithMetadata::new(Account::default(), true, account_id);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
vec![pre_state],
@@ -1041,6 +1089,36 @@ fn private_pda_init_identifier_mismatch_fails() {
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn private_pda_init_at_root_call_may_not_declare_authorization() {
let program = crate::test_methods::pda_claimer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 5;
let account_id =
AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier);
let pre_state = AccountWithMetadata::new(Account::default(), true, account_id);
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn private_pda_update_identifier_mismatch_fails() {
let program = crate::test_methods::pda_spend_proxy();
@@ -1059,7 +1137,7 @@ fn private_pda_update_identifier_mismatch_fails() {
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id);
let pda_pre = AccountWithMetadata::new(pda_account, false, pda_id);
let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps =
+61 -5
View File
@@ -815,6 +815,64 @@ fn inherited_scope_passes_through_intermediate_calls() {
);
}
fn undeclaring_delegation(delegated: bool, external_binding: bool) -> Result<(), LeeError> {
let delegator = crate::test_methods::undeclaring_pda_delegator();
let callee = crate::test_methods::auth_asserting_noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([77; 32]);
let delegator_id = delegator.id();
let account_id = AccountId::for_private_pda(&delegator_id, &seed, &npk, &keys.vpk(), 0);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let callee_id = callee.id();
let program_with_deps = ProgramWithDependencies::new(delegator, [(callee_id, callee)].into());
execute_and_prove(
vec![pre_state],
Program::serialize_instruction((
delegated.then_some(seed),
callee_id,
Program::serialize_instruction(()).unwrap(),
))
.unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Pda {
binding: external_binding.then_some((delegator_id, seed)),
},
nullifier: NullifierWitness::Init {
npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
&program_with_deps,
)
.map(|_| ())
}
#[test]
fn delegated_private_pda_first_seen_in_callee_is_authorized() {
undeclaring_delegation(true, true)
.expect("a caller's pda_seeds must authorize a private PDA it delegates at first sight");
}
#[test]
fn caller_seeds_bind_a_private_pda_first_seen_in_the_callee() {
undeclaring_delegation(true, false)
.expect("a caller's pda_seeds must bind a private PDA it delegates at first sight");
}
#[test]
fn undelegated_private_pda_in_a_callee_may_not_declare_authorization() {
let result = undeclaring_delegation(false, true);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
/// 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` (`alice_npk`) and
@@ -891,15 +949,13 @@ fn private_pda_top_level_reuse_rejected_by_binding_check() {
let npk = keys.npk();
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.
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), u128::MAX);
let owned_pre_state = AccountWithMetadata::new(
Account {
program_owner: program.id(),
..Account::default()
},
true,
false,
account_id,
);
@@ -1354,7 +1410,7 @@ fn two_private_pda_family_members_receive_and_spend() {
let recipient_account = state.get_account_by_id(recipient_id);
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(alice_pda_0_account, true, alice_pda_0_id),
AccountWithMetadata::new(alice_pda_0_account, false, alice_pda_0_id),
AccountWithMetadata::new(recipient_account, true, recipient_id),
],
Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(),
@@ -1393,7 +1449,7 @@ fn two_private_pda_family_members_receive_and_spend() {
let recipient_account = state.get_account_by_id(recipient_id);
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(alice_pda_1_account.clone(), true, alice_pda_1_id),
AccountWithMetadata::new(alice_pda_1_account.clone(), false, alice_pda_1_id),
AccountWithMetadata::new(recipient_account, false, recipient_id),
],
Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(),
@@ -5,7 +5,7 @@ use risc0_zkvm::serde::to_vec;
/// Proxy for spending from a private PDA via `simple_transfer`.
///
/// `pre_states = [pda (authorized), recipient]`. Debits the PDA and credits the recipient.
/// `pre_states = [pda, recipient]`. Debits the PDA and credits the recipient.
/// The PDA-to-npk binding is established via `pda_seeds` in the chained call to `simple_transfer`.
type Instruction = (PdaSeed, u128, ProgramId);
@@ -24,15 +24,16 @@ fn main() {
return;
};
assert!(first.is_authorized, "first pre_state must be authorized");
let first_post = AccountPostState::new(first.account.clone());
let second_post = AccountPostState::new(second.account.clone());
let mut first_for_callee = first.clone();
first_for_callee.is_authorized = true;
let chained_call = ChainedCall {
program_id: simple_transfer_id,
instruction_data: to_vec(&amount).unwrap(),
pre_states: vec![first.clone(), second.clone()],
pre_states: vec![first_for_callee, second.clone()],
pda_seeds: vec![seed],
};
@@ -51,7 +51,7 @@ fn main() {
let recipient_post = AccountPostState::new(recipient_pre.account.clone());
// Chain to simple_transfer with pda_seeds to authorize the PDA.
// The circuit's resolve_authorization_and_record_bindings establishes the
// The circuit's assert_authorization_and_record_bindings establishes the
// private PDA (seed, npk) binding when pda_seeds match the private PDA derivation.
let mut auth_pda_pre = pda_pre;
auth_pda_pre.is_authorized = true;
@@ -0,0 +1,38 @@
use lee_core::program::{
ChainedCall, InstructionData, PdaSeed, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs,
};
type Instruction = (Option<PdaSeed>, ProgramId, InstructionData);
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
mut pre_states,
instruction: (seed, callee_program_id, callee_instruction),
},
instruction_words,
) = read_lee_inputs::<Instruction>();
let Some(first) = pre_states.first_mut() else {
return;
};
first.is_authorized = true;
// Emit an output with only chained calls and no pre or post-states.
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
Vec::new(),
Vec::new(),
)
.with_chained_calls(vec![ChainedCall {
program_id: callee_program_id,
instruction_data: callee_instruction,
pre_states,
pda_seeds: seed.into_iter().collect(),
}])
.write();
}
+5 -5
View File
@@ -527,20 +527,20 @@ fn private_key_tree_acc_preparation(
let from_identifier = from_acc.kind.identifier();
let from_keys = &from_acc.key_chain;
let ask = from_keys.private_key_holder.authorization_secret_key;
// A PDA is program-authorized and carries no credential of its own.
let ask = (!is_pda).then_some(from_keys.private_key_holder.authorization_secret_key);
let nsk = from_keys.private_key_holder.nullifier_secret_key();
let from_npk = from_keys.nullifier_public_key;
let from_vpk = from_keys.viewing_public_key.clone();
// TODO: Technically we could allow unauthorized owned accounts, but currently we don't have
// support from that in the wallet.
let sender_pre = AccountWithMetadata::new(from_acc.account.clone(), true, account_id);
let sender_pre = AccountWithMetadata::new(from_acc.account.clone(), ask.is_some(), account_id);
let random_seed = random_bytes();
Ok(AccountPreparedData {
// A PDA is program-authorized and carries no credential of its own.
ask: (!is_pda).then_some(ask),
ask,
nsk: Some(nsk),
npk: from_npk,
identifier: from_identifier,
@@ -593,7 +593,7 @@ fn private_shared_acc_preparation(
.map(|e| e.account.clone())
.unwrap_or_default();
let pre_state = AccountWithMetadata::new(acc, true, account_id);
let pre_state = AccountWithMetadata::new(acc, ask.is_some(), account_id);
let random_seed = random_bytes();
@@ -5,7 +5,7 @@ use risc0_zkvm::serde::to_vec;
/// Proxy for spending from a private PDA via `auth_transfer`.
///
/// `pre_states = [pda (authorized), recipient]`. Debits the PDA and credits the recipient.
/// `pre_states = [pda, recipient]`. Debits the PDA and credits the recipient.
/// The PDA-to-npk binding is established via `pda_seeds` in the chained call to `auth_transfer`.
type Instruction = (PdaSeed, u128, ProgramId);
@@ -24,16 +24,17 @@ fn main() {
return;
};
assert!(first.is_authorized, "first pre_state must be authorized");
let first_post = AccountPostState::new(first.account.clone());
let second_post = AccountPostState::new(second.account.clone());
let mut first_for_callee = first.clone();
first_for_callee.is_authorized = true;
let chained_call = ChainedCall {
program_id: auth_transfer_id,
instruction_data: to_vec(&authenticated_transfer_core::Instruction::Transfer { amount })
.unwrap(),
pre_states: vec![first.clone(), second.clone()],
pre_states: vec![first_for_callee, second.clone()],
pda_seeds: vec![seed],
};