feat!(lee): derive private authorization from an authorization secret key

BREAKING!

Before: Providing `nsk` was the same as authorizing a regular private
account.

After: A separate `ask` is required, making unauthorized private
updates possible.
This commit is contained in:
Artem Gureev 2026-08-03 13:33:56 +00:00
parent fc8f7f2d42
commit 279a9b8313
19 changed files with 480 additions and 131 deletions

View File

@ -348,9 +348,9 @@ Check the `run_hello_world_private.rs` file to see how it is used.
# 8. Account authorization mechanism
The Hello world example does not enforce any authorization on the input account. This means any user can execute it on any account, regardless of ownership.
LEE provides a mechanism for programs to enforce proper authorization before an execution can succeed. The meaning of authorization differs between public and private accounts:
- Public accounts: authorization requires that the transaction is signed with the accounts signing key.
- Private accounts: authorization requires that the circuit verifies knowledge of the accounts nullifier secret key.
LEE provides a mechanism for programs to enforce proper authorization before an execution can succeed. For both private and public accounts, the authorization is checked against knowledge of a secret key, yet the check is different:
- Public accounts: the transaction is signed with the accounts signing key.
- Private accounts: the circuit verifies knowledge of the accounts authorization secret key (`ask`), the key from which the accounts nullifier secret key is derived.
From the program development perspective it is very simple: input accounts come with a flag indicating whether they has been properly authorized. And so, the only difference between the program `hello_world.rs` and `hello_world_with_authorization.rs` is in the lines

View File

@ -680,7 +680,7 @@ async fn prove_init_with_commitment_root(
vpk,
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular { ask: Some(ask) },
nullifier: NullifierWitness::Init {
npk,
commitment_root,

View File

@ -298,7 +298,9 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
vpk: sender_vpk,
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_nsk,
@ -309,7 +311,9 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
vpk: recipient_vpk,
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_npk,
commitment_root: DUMMY_COMMITMENT_HASH,

View File

@ -1,8 +1,8 @@
use lee_core::{
Commitment, CommitmentSetDigest, DummyInput, EncryptedAccountData, EncryptionScheme,
EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierSecretKey,
NullifierWitness, PrivacyPreservingCircuitOutput, PrivateAccountKind, PrivateAction,
PrivateWitness, PublicAction, SharedSecretKey, WitnessKind,
EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey,
NullifierSecretKey, NullifierWitness, PrivacyPreservingCircuitOutput, PrivateAccountKind,
PrivateAction, PrivateWitness, PublicAction, SharedSecretKey, WitnessKind,
account::{Account, AccountId, Nonce},
compute_digest_for_path,
encryption::{ViewTag, ViewingPublicKey},
@ -48,7 +48,7 @@ pub fn compute_circuit_output(
nullifier,
}) => {
let account_id = match kind {
WitnessKind::Regular => {
WitnessKind::Regular { .. } => {
let derived = AccountId::for_regular_private_account(
&nullifier.npk(),
vpk,
@ -68,12 +68,31 @@ pub fn compute_circuit_output(
match (kind, nullifier) {
(
WitnessKind::Regular,
WitnessKind::Regular { ask },
NullifierWitness::Init { .. } | NullifierWitness::Update { .. },
) => assert!(
pre_state.is_authorized,
"Regular private account pre-state must be authorized"
),
) => {
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"
@ -126,7 +145,7 @@ pub fn compute_circuit_output(
};
let account_kind = match kind {
WitnessKind::Regular => PrivateAccountKind::Regular(*identifier),
WitnessKind::Regular { .. } => PrivateAccountKind::Regular(*identifier),
WitnessKind::Pda { .. } => {
let (authority_program_id, seed) = pda_seed_by_position
.get(&pos)

View File

@ -2,8 +2,8 @@ use borsh::{BorshDeserialize, BorshSerialize};
use serde::{Deserialize, Serialize};
use crate::{
Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey,
NullifierSecretKey,
AuthorizationSecretKey, Commitment, CommitmentSetDigest, Identifier, MembershipProof,
Nullifier, NullifierPublicKey, NullifierSecretKey,
account::{Account, AccountWithMetadata},
encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey},
program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow},
@ -48,8 +48,9 @@ pub struct PrivateWitness {
pub enum WitnessKind {
/// Standalone private account. The `account_id` is derived as
/// `AccountId::for_regular_private_account(&npk, vpk, identifier)` and matched against
/// `pre_state.account_id`.
Regular,
/// `pre_state.account_id`. An honest authorized account's `npk` for Id computation gets
/// derived from the supplied `ask`.
Regular { ask: Option<AuthorizationSecretKey> },
/// 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.

View File

@ -93,7 +93,9 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts()
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -199,7 +201,9 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -212,7 +216,9 @@ fn prove_privacy_preserving_execution_circuit_fully_private() {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -284,7 +290,9 @@ fn init_note_view_tag_is_derived_from_account_keys() {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(keys.ask),
},
nullifier: NullifierWitness::Init {
npk: keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -329,7 +337,9 @@ fn update_note_view_tag_is_the_supplied_value() {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: fed_tag,
nsk: keys.nsk(),
@ -381,7 +391,9 @@ fn circuit_fails_when_chained_validity_windows_have_empty_intersection() {
vpk: account_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(account_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: account_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -574,7 +586,9 @@ fn shared_account_receives_via_simple_transfer() {
vpk: shared_keys.vpk(),
random_seed: [0; 32],
identifier: shared_identifier,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(shared_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: shared_npk,
commitment_root: DUMMY_COMMITMENT_HASH,
@ -613,7 +627,9 @@ fn private_authorized_init_encrypts_regular_kind_with_identifier() {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(keys.ask),
},
nullifier: NullifierWitness::Init {
npk: NullifierPublicKey::from(&keys.nsk()),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -653,7 +669,9 @@ fn private_foreign_init_encrypts_regular_kind_with_identifier() {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(keys.ask),
},
nullifier: NullifierWitness::Init {
npk: keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -701,7 +719,9 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: keys.nsk(),
@ -718,6 +738,215 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() {
);
}
/// Builds an on-chain regular private account owned by `program`, returning its id, pre-state
/// and a membership proof for its commitment.
fn seeded_regular_account(
keys: &crate::state::tests::TestPrivateKeys,
program: &Program,
identifier: u128,
) -> (AccountId, AccountWithMetadata, lee_core::MembershipProof) {
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
let account = Account {
program_owner: program.id(),
balance: 1,
..Account::default()
};
let commitment = Commitment::new(&account_id, &account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&commitment));
let proof = commitment_set.get_proof_for(&commitment).unwrap();
(
account_id,
AccountWithMetadata::new(account, false, account_id),
proof,
)
}
/// Spending without consenting. The witness carries no `ask`, so the pre-state is unauthorized,
/// and the nullifier is still produced from the `nsk`.
#[test]
fn private_regular_update_without_ask_is_spendable() {
let program = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let (_, pre, membership_proof) = seeded_regular_account(&keys, &program, 0);
assert!(!pre.is_authorized);
execute_and_prove(
vec![pre],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular { ask: None },
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: keys.nsk(),
membership_proof,
},
})],
&program.into(),
)
.unwrap();
}
/// Claiming authorization without supplying an `ask` is rejected.
#[test]
fn private_regular_witness_without_ask_cannot_assert_authorization() {
let program = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let (account_id, pre, membership_proof) = seeded_regular_account(&keys, &program, 0);
let pre = AccountWithMetadata::new(pre.account, true, account_id);
let result = execute_and_prove(
vec![pre],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular { ask: None },
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: keys.nsk(),
membership_proof,
},
})],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
/// An `ask` that does not derive this account's `nsk` is not a credential for it.
#[test]
fn regular_update_with_wrong_ask_nsk_is_rejected() {
let program = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let foreign = test_private_account_keys_2();
let (account_id, pre, membership_proof) = seeded_regular_account(&keys, &program, 0);
let pre = AccountWithMetadata::new(pre.account, true, account_id);
let result = execute_and_prove(
vec![pre],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular {
ask: Some(foreign.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: keys.nsk(),
membership_proof,
},
})],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
/// An `ask` tthat does not derive this account's `npk` is not a credential for it.
#[test]
fn regular_init_with_non_chaining_ask_npk_is_rejected() {
let program = crate::test_methods::claimer();
let keys = test_private_account_keys_1();
let foreign = test_private_account_keys_2();
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), 0);
let pre = AccountWithMetadata::new(Account::default(), true, account_id);
let result = execute_and_prove(
vec![pre],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular {
ask: Some(foreign.ask),
},
nullifier: NullifierWitness::Init {
npk: keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn unauthorized_private_init_can_be_claimed() {
let program = crate::test_methods::claimer();
let program_id = program.id();
let keys = test_private_account_keys_1();
let recipient_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id);
let esk = EphemeralSecretKey::new(
&recipient_id,
&[0; 32],
&Nonce::private_account_nonce_init(&recipient_id),
);
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0;
let (output, _) = execute_and_prove(
vec![recipient],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular { ask: None },
nullifier: NullifierWitness::Init {
npk: keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
&program.into(),
)
.unwrap();
let (_, claimed) = EncryptionScheme::decrypt(
&output.private_actions[0].encrypted_post_state.ciphertext,
&ssk,
&output.private_actions[0].nullifier,
)
.unwrap();
assert_eq!(claimed.program_owner, program_id);
}
/// A program that asserts authorization over its pre-states rejects a regular private account
/// whose witness supplied no `ask`.
#[test]
fn auth_asserting_program_rejects_unauthorized_regular_private_account() {
let program = crate::test_methods::auth_asserting_noop();
let keys = test_private_account_keys_1();
let (_, pre, membership_proof) = seeded_regular_account(&keys, &program, 0);
let result = execute_and_prove(
vec![pre],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular { ask: None },
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: keys.nsk(),
membership_proof,
},
})],
&program.into(),
);
assert!(matches!(result, Err(LeeError::ProgramProveFailed(_))));
}
/// A private-PDA update with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`.
#[test]

View File

@ -75,7 +75,9 @@ fn private_changer_claimer_no_data_change_no_claim_succeeds() {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -109,7 +111,9 @@ fn private_changer_claimer_data_change_no_claim_fails() {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),

View File

@ -65,7 +65,9 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: recipient_keys.nsk(),
@ -76,7 +78,9 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -121,7 +125,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -132,7 +138,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -177,7 +185,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -188,7 +198,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -233,7 +245,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided(
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -244,7 +258,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided(
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -289,7 +305,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -300,7 +318,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -343,7 +363,9 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -354,7 +376,9 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -749,7 +773,9 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -760,7 +786,9 @@ fn circuit_should_fail_if_there_are_repeated_ids() {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -802,7 +830,9 @@ fn private_authorized_uninitialized_account() {
vpk: private_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(private_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: NullifierPublicKey::from(&private_keys.nsk()),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -851,7 +881,9 @@ fn private_unauthorized_uninitialized_account_can_still_be_claimed() {
vpk: private_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(private_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: private_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -904,7 +936,9 @@ fn private_account_claimed_then_used_without_init_flag_should_fail() {
vpk: private_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(private_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: NullifierPublicKey::from(&private_keys.nsk()),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -949,7 +983,9 @@ fn private_account_claimed_then_used_without_init_flag_should_fail() {
vpk: private_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(private_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: NullifierPublicKey::from(&private_keys.nsk()),
commitment_root: DUMMY_COMMITMENT_HASH,

View File

@ -329,7 +329,9 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -446,7 +448,9 @@ fn private_chained_call(number_of_calls: u32) {
vpk: from_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(from_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: from_keys.nsk(),
@ -459,7 +463,9 @@ fn private_chained_call(number_of_calls: u32) {
vpk: to_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(to_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: to_keys.nsk(),

View File

@ -288,7 +288,9 @@ fn shielded_balance_transfer_for_tests(
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -335,7 +337,9 @@ fn private_balance_transfer_for_tests(
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),
@ -348,7 +352,9 @@ fn private_balance_transfer_for_tests(
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: recipient_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -396,7 +402,9 @@ fn deshielded_balance_transfer_for_tests(
vpk: sender_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(sender_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: sender_keys.nsk(),

View File

@ -525,7 +525,9 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(recipient_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: recipient_keys.nsk(),

View File

@ -142,7 +142,9 @@ fn validity_window_works_in_privacy_preserving_transactions(
vpk: account_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(account_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: account_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
@ -210,7 +212,9 @@ fn timestamp_validity_window_works_in_privacy_preserving_transactions(
vpk: account_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(account_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: account_keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,

View File

@ -168,7 +168,9 @@ fn privacy_malicious_programs_cannot_drain_public_victim() {
vpk: attacker_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(attacker_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: attacker_keys.nsk(),
@ -330,7 +332,9 @@ fn privacy_malicious_programs_cannot_drain_private_victim() {
vpk: attacker_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular,
kind: WitnessKind::Regular {
ask: Some(attacker_keys.ask),
},
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: attacker_keys.nsk(),

View File

@ -361,6 +361,7 @@ pub unsafe extern "C" fn wallet_ffi_free_account_identity(
kind: _,
account_id: _,
key_path,
authorization_secret_key: _,
nullifier_secret_key: _,
nullifier_public_key: _,
viewing_public_key,

View File

@ -8,7 +8,10 @@ use std::{
};
use lee::{Data, ProgramId, SharedSecretKey};
use lee_core::{encryption::MlKem768EncapsulationKey, program::PdaSeed, NullifierPublicKey};
use lee_core::{
encryption::MlKem768EncapsulationKey, program::PdaSeed, AuthorizationSecretKey,
NullifierPublicKey,
};
use wallet::{account::AccountIdWithPrivacy, AccountIdentity};
use crate::error::WalletFfiError;
@ -238,6 +241,7 @@ pub struct FfiAccountIdentity {
pub account_id: FfiBytes32,
/// C-compatible string.
pub key_path: *mut c_char,
pub authorization_secret_key: FfiBytes32,
pub nullifier_secret_key: FfiBytes32,
pub nullifier_public_key: FfiBytes32,
pub viewing_public_key: *const u8,
@ -251,6 +255,7 @@ impl Default for FfiAccountIdentity {
kind: FfiAccountIdentityKind::Public,
account_id: FfiBytes32::default(),
key_path: std::ptr::null_mut(),
authorization_secret_key: FfiBytes32::default(),
nullifier_secret_key: FfiBytes32::default(),
nullifier_public_key: FfiBytes32::default(),
viewing_public_key: std::ptr::null(),
@ -444,8 +449,7 @@ impl From<AccountIdentity> for FfiAccountIdentity {
}
}
AccountIdentity::PrivateShared {
nsk,
npk,
ask,
vpk,
identifier,
} => {
@ -460,8 +464,7 @@ impl From<AccountIdentity> for FfiAccountIdentity {
Self {
kind: FfiAccountIdentityKind::PrivateShared,
nullifier_secret_key: nsk.into(),
nullifier_public_key: npk.0.into(),
authorization_secret_key: ask.0.into(),
viewing_public_key: vpk_data,
viewing_public_key_len: vpk_len,
identifier: identifier.into(),
@ -579,8 +582,7 @@ impl TryFrom<&FfiAccountIdentity> for AccountIdentity {
}?;
Ok(Self::PrivateShared {
nsk: value.nullifier_secret_key.data,
npk: NullifierPublicKey(value.nullifier_public_key.data),
ask: AuthorizationSecretKey(value.authorization_secret_key.data),
vpk,
identifier: value.identifier.into(),
})
@ -658,7 +660,10 @@ impl From<FfiAccountIdWithPrivacy> for AccountIdWithPrivacy {
#[cfg(test)]
mod tests {
use lee::{AccountId, PrivateKey, PublicKey};
use lee_core::{encryption::ViewingPublicKey, program::PdaSeed, PrivateAccountKind};
use lee_core::{
encryption::ViewingPublicKey, program::PdaSeed, AuthorizationSecretKey, NullifierSecretKey,
PrivateAccountKind,
};
use wallet::AccountIdentity;
use crate::{FfiAccountIdentity, FfiAccountIdentityKind};
@ -669,7 +674,8 @@ mod tests {
let public_key = PublicKey::new_from_private_key(&private_key);
let pub_acc_id = (&public_key).into();
let nsk = [43; 32];
let ask = AuthorizationSecretKey([43; 32]);
let nsk = NullifierSecretKey::from(&ask);
let vpk = ViewingPublicKey::from_seed(&[44; 32], &[54; 32]);
let npk = (&nsk).into();
let identifier = u128::from_le_bytes([45; 16]);
@ -708,8 +714,7 @@ mod tests {
identifier,
};
let acc_identity_7 = AccountIdentity::PrivateShared {
nsk,
npk,
ask,
vpk: vpk.clone(),
identifier,
};

View File

@ -249,6 +249,7 @@ typedef struct FfiAccountIdentity {
* C-compatible string.
*/
char *key_path;
struct FfiBytes32 authorization_secret_key;
struct FfiBytes32 nullifier_secret_key;
struct FfiBytes32 nullifier_public_key;
const uint8_t *viewing_public_key;

View File

@ -4,9 +4,9 @@ use anyhow::Result;
use keycard_wallet::KeycardWallet;
use lee::{AccountId, PrivateKey, PublicKey, Signature};
use lee_core::{
Commitment, CommitmentSetDigest, DummyInput, Identifier, InputAccountIdentity, MembershipProof,
NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivateAccountKind, PrivateWitness,
SharedSecretKey, WitnessKind,
AuthorizationSecretKey, Commitment, CommitmentSetDigest, DummyInput, Identifier,
InputAccountIdentity, MembershipProof, NullifierPublicKey, NullifierSecretKey,
NullifierWitness, PrivateAccountKind, PrivateWitness, SharedSecretKey, WitnessKind,
account::{Account, AccountWithMetadata, Nonce},
compute_digest_for_path,
encryption::{
@ -45,11 +45,11 @@ pub enum AccountIdentity {
identifier: Identifier,
},
/// A shared regular private account with externally-provided keys (e.g. from GMS).
/// Uses standard `AccountId = from((&npk, identifier))` with authorized/unauthorized private
/// paths. Works with `authenticated_transfer` and all existing programs out of the box.
/// Carries the authorization secret key: the `nsk` and `npk` behind
/// `AccountId = from((&npk, &vpk, identifier))` are derived from it.
/// Works with `authenticated_transfer` and all existing programs out of the box.
PrivateShared {
nsk: NullifierSecretKey,
npk: NullifierPublicKey,
ask: AuthorizationSecretKey,
vpk: ViewingPublicKey,
identifier: Identifier,
},
@ -102,14 +102,10 @@ impl fmt::Debug for AccountIdentity {
.field("identifier", identifier)
.finish(),
Self::PrivateShared {
npk,
vpk,
identifier,
..
vpk, identifier, ..
} => f
.debug_struct("PrivateShared")
.field("nsk", &"<redacted>")
.field("npk", npk)
.field("ask", &"<redacted>")
.field("vpk", vpk)
.field("identifier", identifier)
.finish(),
@ -266,21 +262,10 @@ impl AccountManager {
vpk,
identifier,
} => {
let acc = lee_core::account::Account::default();
let auth_acc = AccountWithMetadata::new(acc, true, (&npk, &vpk, identifier));
let random_seed = random_bytes();
let pre = AccountPreparedData {
nsk: None,
npk,
identifier,
vpk,
pre_state: auth_acc,
proof: None,
random_seed,
is_pda: false,
};
State::Private(pre)
let account_id = lee::AccountId::from((&npk, &vpk, identifier));
State::Private(private_foreign_acc_preparation(
account_id, npk, vpk, identifier, false,
))
}
AccountIdentity::PrivatePdaOwned(account_id) => {
let pre = private_key_tree_acc_preparation(wallet, account_id, true)?;
@ -291,31 +276,26 @@ impl AccountManager {
npk,
vpk,
identifier,
} => {
let acc = lee_core::account::Account::default();
let auth_acc = AccountWithMetadata::new(acc, false, account_id);
let random_seed = random_bytes();
let pre = AccountPreparedData {
nsk: None,
npk,
identifier,
vpk,
pre_state: auth_acc,
proof: None,
random_seed,
is_pda: true,
};
State::Private(pre)
}
} => State::Private(private_foreign_acc_preparation(
account_id, npk, vpk, identifier, true,
)),
AccountIdentity::PrivateShared {
nsk,
npk,
ask,
vpk,
identifier,
} => {
let nsk = NullifierSecretKey::from(&ask);
let npk = NullifierPublicKey::from(&nsk);
let account_id = lee::AccountId::from((&npk, &vpk, identifier));
let pre = private_shared_acc_preparation(
wallet, account_id, nsk, npk, vpk, identifier, false,
wallet,
account_id,
nsk,
npk,
vpk,
identifier,
Some(ask),
false,
);
State::Private(pre)
@ -328,7 +308,7 @@ impl AccountManager {
identifier,
} => {
let pre = private_shared_acc_preparation(
wallet, account_id, nsk, npk, vpk, identifier, true,
wallet, account_id, nsk, npk, vpk, identifier, None, true,
);
State::Private(pre)
@ -448,7 +428,7 @@ impl AccountManager {
kind: if pre.is_pda {
WitnessKind::Pda { binding: None }
} else {
WitnessKind::Regular
WitnessKind::Regular { ask: pre.ask }
},
nullifier: match (pre.nsk, pre.proof.clone()) {
(Some(nsk), Some(membership_proof)) => NullifierWitness::Update {
@ -527,6 +507,7 @@ impl AccountManager {
}
struct AccountPreparedData {
ask: Option<AuthorizationSecretKey>,
nsk: Option<NullifierSecretKey>,
npk: NullifierPublicKey,
identifier: Identifier,
@ -550,6 +531,7 @@ 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;
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();
@ -561,6 +543,8 @@ fn private_key_tree_acc_preparation(
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),
nsk: Some(nsk),
npk: from_npk,
identifier: from_identifier,
@ -572,6 +556,31 @@ fn private_key_tree_acc_preparation(
})
}
/// Prepare a private account with no secret key knowledge, i.e. for inits.
fn private_foreign_acc_preparation(
account_id: AccountId,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
identifier: Identifier,
is_pda: bool,
) -> AccountPreparedData {
AccountPreparedData {
// The wallet holds no key for a recipient, so it can neither spend the account nor
// consent on its behalf. The program still claims it: a private claim never requires
// authorization.
ask: None,
nsk: None,
npk,
identifier,
vpk,
pre_state: AccountWithMetadata::new(Account::default(), false, account_id),
proof: None,
random_seed: random_bytes(),
is_pda,
}
}
#[expect(clippy::too_many_arguments, reason = "All keys need to be supplied")]
fn private_shared_acc_preparation(
wallet: &WalletCore,
account_id: AccountId,
@ -579,6 +588,7 @@ fn private_shared_acc_preparation(
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
identifier: Identifier,
ask: Option<AuthorizationSecretKey>,
is_pda: bool,
) -> AccountPreparedData {
let acc = wallet
@ -593,6 +603,7 @@ fn private_shared_acc_preparation(
let random_seed = random_bytes();
AccountPreparedData {
ask,
nsk: Some(nsk),
npk,
identifier,
@ -701,8 +712,7 @@ mod tests {
#[test]
fn private_shared_is_private() {
let acc = AccountIdentity::PrivateShared {
nsk: [0; 32],
npk: NullifierPublicKey([1; 32]),
ask: AuthorizationSecretKey([0; 32]),
vpk: ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]),
identifier: 42,
};
@ -715,6 +725,7 @@ mod tests {
let vpk = ViewingPublicKey::from_seed(&[0; 32], &[0; 32]);
let pre_state = AccountWithMetadata::new(Account::default(), false, (&npk, &vpk, 0));
State::Private(AccountPreparedData {
ask: None,
nsk: None,
npk,
identifier: 0,
@ -741,6 +752,23 @@ mod tests {
}
}
#[test]
fn foreign_private_init_is_unauthorized() {
let npk = NullifierPublicKey([7; 32]);
let vpk = ViewingPublicKey::from_seed(&[8; 32], &[9; 32]);
let account_id = lee::AccountId::from((&npk, &vpk, 0));
let pre = private_foreign_acc_preparation(account_id, npk, vpk, 0, false);
assert!(pre.ask.is_none());
assert!(!pre.pre_state.is_authorized);
let identities = manager(vec![State::Private(pre)]).account_identities();
let InputAccountIdentity::Private(witness) = &identities[0] else {
panic!("expected a private witness");
};
assert!(matches!(witness.kind, WitnessKind::Regular { ask: None }));
}
#[test]
fn dummy_inputs_default_pads_private_count_to_max() {
let max = AccountManager::MAX_PRIVATE_ACCOUNTS;

View File

@ -373,23 +373,20 @@ impl WalletCore {
.key_chain()
.shared_private_account(account_id)?;
let keys = self.storage.key_chain().derive_shared_account_keys(entry)?;
let nsk = keys.nullifier_secret_key();
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let identifier = entry.identifier;
if entry.pda_seed.is_some() {
Some(AccountIdentity::PrivatePdaShared {
account_id,
nsk,
npk,
nsk: keys.nullifier_secret_key(),
npk: keys.generate_nullifier_public_key(),
vpk,
identifier,
})
} else {
Some(AccountIdentity::PrivateShared {
nsk,
npk,
ask: keys.authorization_secret_key,
vpk,
identifier,
})

View File

@ -12,7 +12,7 @@ cargo bench -p crypto_primitives_bench --bench primitives
Criterion's per-operation report (point estimate, 95% CI, outlier counts) for:
- `keychain/new_os_random`: full mnemonic → SSK → NSK/VSK + public-key derivation (HMAC-SHA512 PBKDF dominates).
- `keychain/new_os_random`: full mnemonic → SSK → ASK → NSK, plus SSK → VSK, and public-key derivation (HMAC-SHA512 PBKDF dominates).
- `keychain/new_mnemonic`: same pipeline, mnemonic exposed.
- `shared_secret_key/sender_dh`: secp256k1 ECDH per recipient (includes ephemeral key gen).
- `encryption/encrypt` / `decrypt`: ChaCha20 over an Account note.