derive identifier from shared secret and update circuit

This commit is contained in:
Sergio Chouhy 2026-04-22 00:20:44 -03:00
parent 9d2abc76a1
commit 338900b50d
3 changed files with 57 additions and 15 deletions

View File

@ -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;

View File

@ -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 = [

View File

@ -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,