diff --git a/nssa/core/src/lib.rs b/nssa/core/src/lib.rs index 478d475c..08d11b13 100644 --- a/nssa/core/src/lib.rs +++ b/nssa/core/src/lib.rs @@ -9,7 +9,9 @@ pub use commitment::{ compute_digest_for_path, }; pub use encryption::{EncryptionScheme, SharedSecretKey}; -pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey}; +pub use nullifier::{ + Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey, derive_identifier, +}; pub mod account; mod circuit_io; diff --git a/nssa/core/src/nullifier.rs b/nssa/core/src/nullifier.rs index c7dfd721..e5772973 100644 --- a/nssa/core/src/nullifier.rs +++ b/nssa/core/src/nullifier.rs @@ -2,9 +2,11 @@ use borsh::{BorshDeserialize, BorshSerialize}; use risc0_zkvm::sha::{Impl, Sha256 as _}; use serde::{Deserialize, Serialize}; -use crate::{Commitment, account::AccountId}; +use crate::{Commitment, SharedSecretKey, account::AccountId}; const PRIVATE_ACCOUNT_ID_PREFIX: &[u8; 32] = b"/LEE/v0.3/AccountId/Private/\x00\x00\x00\x00"; +const IDENTIFIER_DOMAIN_SEPARATOR: &[u8; 32] = + b"/LEE/v0.3/Identifier/\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; pub type Identifier = u128; @@ -99,6 +101,20 @@ impl Nullifier { } } +// TODO: use a dedicated struct for Identifier and make this into a constructor of the type +#[must_use] +pub fn derive_identifier(shared_secret: &SharedSecretKey) -> Identifier { + let mut bytes = [0_u8; 64]; + bytes[..32].copy_from_slice(IDENTIFIER_DOMAIN_SEPARATOR); + bytes[32..].copy_from_slice(&shared_secret.0); + let hash = Impl::hash_bytes(&bytes); + Identifier::from_le_bytes( + hash.as_bytes()[..16] + .try_into() + .expect("slice of length 16"), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -143,6 +159,27 @@ mod tests { assert_eq!(npk, expected_npk); } + #[test] + fn derive_identifier_is_deterministic_and_domain_separated() { + let shared_secret = SharedSecretKey([0x11; 32]); + let other_secret = SharedSecretKey([0x12; 32]); + + let a = derive_identifier(&shared_secret); + let b = derive_identifier(&shared_secret); + let c = derive_identifier(&other_secret); + + assert_eq!(a, b, "same shared secret must derive the same identifier"); + assert_ne!(a, c, "different secrets must derive different identifiers"); + + // Cross-check: output equals first 16 LE bytes of sha256(domain || secret). + let mut preimage = [0_u8; 64]; + preimage[..32].copy_from_slice(IDENTIFIER_DOMAIN_SEPARATOR); + preimage[32..].copy_from_slice(&shared_secret.0); + let expected = + Identifier::from_le_bytes(Impl::hash_bytes(&preimage).as_bytes()[..16].try_into().unwrap()); + assert_eq!(a, expected); + } + #[test] fn account_id_from_nullifier_public_key() { let nsk = [ diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit.rs b/program_methods/guest/src/bin/privacy_preserving_circuit.rs index a3690352..085ae0e3 100644 --- a/program_methods/guest/src/bin/privacy_preserving_circuit.rs +++ b/program_methods/guest/src/bin/privacy_preserving_circuit.rs @@ -8,7 +8,7 @@ use nssa_core::{ MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, SharedSecretKey, account::{Account, AccountId, AccountWithMetadata, Nonce}, - compute_digest_for_path, + compute_digest_for_path, derive_identifier, program::{ AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_ID, MAX_NUMBER_CHAINED_CALLS, ProgramId, ProgramOutput, TimestampValidityWindow, @@ -343,11 +343,22 @@ fn compute_circuit_output( output.public_post_states.push(post_state); } 1 | 2 => { - let Some((npk, identifier, shared_secret)) = private_keys_iter.next() else { + let Some((npk, input_identifier, shared_secret)) = private_keys_iter.next() else { panic!("Missing private account key"); }; - let account_id = AccountId::from((npk, *identifier)); + let Some(membership_proof_opt) = private_membership_proofs_iter.next() else { + panic!("Missing membership proof"); + }; + + // On init the identifier is fixed by the protocol using the shared secret as source of entropy. On update the witness is used + let identifier = if membership_proof_opt.is_none() { + derive_identifier(shared_secret) + } else { + *input_identifier + }; + + let account_id = AccountId::from((npk, identifier)); assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); @@ -371,10 +382,6 @@ fn compute_circuit_output( "Pre-state not authorized for authenticated private account" ); - let Some(membership_proof_opt) = private_membership_proofs_iter.next() else { - panic!("Missing membership proof"); - }; - let new_nullifier = compute_nullifier_and_set_digest( membership_proof_opt.as_ref(), &pre_state.account, @@ -386,7 +393,7 @@ fn compute_circuit_output( (new_nullifier, new_nonce) } else { - // Private account without authentication + // Private account without authentication (always INIT). assert_eq!( pre_state.account, @@ -399,10 +406,6 @@ fn compute_circuit_output( "Found new private account marked as authorized." ); - let Some(membership_proof_opt) = private_membership_proofs_iter.next() else { - panic!("Missing membership proof"); - }; - assert!( membership_proof_opt.is_none(), "Membership proof must be None for unauthorized accounts" @@ -426,7 +429,7 @@ fn compute_circuit_output( // Encrypt and push post state let encrypted_account = EncryptionScheme::encrypt( &post_with_updated_nonce, - *identifier, + identifier, shared_secret, &commitment_post, output_index,