addressing comments

This commit is contained in:
Marvin Jones 2026-05-29 16:28:55 -04:00
parent aa3935bdaa
commit 738dfc0cc4
12 changed files with 108 additions and 69 deletions

View File

@ -1,6 +1,6 @@
# LEZ v0.3 specifications for Key Agreement
# LEE v0.3 specifications for Key Agreement
## LEZ v0.3 basic types and constants
## LEE v0.3 basic types and constants
```rust
/// The ML-KEM-768 KEM ciphertext produced during encapsulation (1088 bytes) of a message.
@ -24,9 +24,9 @@ struct EncryptedAccountData {
When creating a private account output, the sender uses the recipient's viewing public key to encapsulate a random message that is used to establish a shared secret between the sender and recipient:
- Sender: $(\mathsf{ss},\, \mathsf{epk}) = \mathsf{encapsulate}(\mathsf{vpk\_recipient})$. The 1088-byte ciphertext `epk` is included in the transaction as the `EphemeralPublicKey` field.
- Receiver: $\mathsf{ss} = \mathsf{decapsulate}(\mathsf{epk},\, vsk.d,\, vks.r)$
- Receiver: $\mathsf{ss} = \mathsf{decapsulate}(\mathsf{epk},\, vsk.d,\, vsk.z)$
where `vpk` is the receiver's `ViewingPublicKey` and `(vsk.d, vsk.r)` are the two 32-byte halves of the receiver's `ViewingSecretKey`.
where `vpk` is the receiver's `ViewingPublicKey` and `(vsk.d, vsk.z)` are the two 32-byte halves of the receiver's `ViewingSecretKey`.
#### KDF
@ -86,6 +86,11 @@ pub enum InputAccountIdentity {
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
/// When `Some((seed, authority_program_id))`, the circuit binds this position via
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == pre_state.account_id`
/// rather than requiring a `Claim::Pda` or caller `pda_seeds`. The `pre_state` must
/// have `is_authorized == false`.
seed: Option<(PdaSeed, ProgramId)>,
},
/// Update of an existing private PDA. npk is derived from nsk.
@ -95,6 +100,11 @@ pub enum InputAccountIdentity {
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
/// When `Some((seed, authority_program_id))`, the circuit binds this position via
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == pre_state.account_id`
/// rather than requiring a caller `pda_seeds`. The `pre_state` must have
/// `is_authorized == false`.
seed: Option<(PdaSeed, ProgramId)>,
},
}
```
@ -102,9 +112,9 @@ pub enum InputAccountIdentity {
The `ssk` field carries the **shared secret key** — the 32-byte shared secret used to encrypt the post-state. Note that the key protocol uses `ssk` for "spending secret key" (the master key that derives `nsk` and `vsk`); here `ssk` means the per-output KEM shared secret. It is established via ML-KEM-768:
- Sender: `(ssk, epk) = encapsulate(vpk)`
- Receiver: `ssk = decapsulate(epk, vsk.d, vsk.r)`
- Receiver: `ssk = decapsulate(epk, vsk.d, vsk.z)`
where `epk` is the ML-KEM-768 ciphertext (1088 bytes) stored as the `EphemeralPublicKey`, `vpk` is the recipient's `ViewingPublicKey` (1184 bytes), and `(vsk.d, vsk.r)` are the 32-byte seed halves of the recipient's `ViewingSecretKey`.
where `epk` is the ML-KEM-768 ciphertext (1088 bytes) stored as the `EphemeralPublicKey`, `vpk` is the recipient's `ViewingPublicKey` (1184 bytes), and `(vsk.d, vsk.z)` are the 32-byte seed halves of the recipient's `ViewingSecretKey`.
## Encrypted private account discovery and tagging
@ -114,12 +124,12 @@ Each private account output includes a 1-byte view tag to allow wallets to quick
$$\mathsf{ViewTag} = \mathsf{SHA256}(\text{"/LEE/v0.3/ViewTag/"} \;||\; \mathsf{Npk} \;||\; \mathsf{Vpk})[0]$$
where `Npk` is the 32-byte nullifier public key and `Vpk` is the 1184 byte `ViewingPublicKey` of the recipient. On average only 1 in 256 outputs will pass this filter for a given account, avoiding expensive ECDH on irrelevant outputs.
where `Npk` is the 32-byte nullifier public key and `Vpk` is the 1184 byte `ViewingPublicKey` of the recipient. On average only 1 in 256 outputs will pass this filter for a given account, avoiding expensive ML-KEM decapsulation on irrelevant outputs.
### Private account discovery with viewing keys
1. For each encrypted output, compute the expected view tag from `(Npk, Vpk)`. Skip if it does not match.
2. Decapsulate using ML-KEM-768: `ss = decapsulate(epk, vsk.d, vsk.r)`.
2. Decapsulate using ML-KEM-768: `ss = decapsulate(epk, vsk.d, vsk.z)`.
3. Run `kdf(ss, commitment, output_index)` to derive the symmetric key.
4. Decrypt the ciphertext with ChaCha20.
5. Parse the 81-byte header to recover `PrivateAccountKind`.
@ -144,7 +154,7 @@ fn private_account_discovery(
if encrypted_account.view_tag != expected_tag {
continue;
}
let ss = SharedSecretKey::decapsulate(&encrypted_account.epk, &vsk.d, &vsk.r);
let ss = SharedSecretKey::decapsulate(&encrypted_account.epk, &vsk.d, &vsk.z);
if let Some((kind, account)) = EncryptionScheme::decrypt(
&encrypted_account.ciphertext, &ss, commitment, output_index as u32
) {

View File

@ -108,7 +108,7 @@ async fn group_invite_join_key_agreement() -> Result<()> {
.sealing_secret_key()
.context("Sealing key not found")?;
let sealing_pk = key_protocol::key_management::group_key_holder::SealingPublicKey::from_bytes(
nssa_core::encryption::ViewingPublicKey::from_seed(&sealing_sk.d, &sealing_sk.r).0,
nssa_core::encryption::ViewingPublicKey::from_seed(&sealing_sk.d, &sealing_sk.z).0,
);
let holder = ctx

View File

@ -32,7 +32,7 @@ impl SealingPublicKey {
}
/// Secret key used to unseal a `GroupKeyHolder` received from another member.
/// Holds the two 32-byte FIPS 203 seed halves `d` and `r`.
/// Holds the two 32-byte FIPS 203 seed halves `d` and `z`.
pub type SealingSecretKey = ViewingSecretKey;
/// Manages shared viewing keys for a group of controllers owning private PDAs.
@ -198,7 +198,8 @@ impl GroupKeyHolder {
let nonce = aes_gcm::Nonce::from_slice(&sealed[KEM_CT_LEN..HEADER_LEN]);
let ciphertext = &sealed[HEADER_LEN..];
let shared = SharedSecretKey::decapsulate(&kem_ct, &own_key.d, &own_key.r);
let shared = SharedSecretKey::decapsulate(&kem_ct, &own_key.d, &own_key.z)
.expect("key_protocol::group_key_holder::GroupKeyHolder::unseal: KEM_CT_LEN guarantees exactly 1088 bytes");
let aes_key = Self::seal_kdf(&shared);
let cipher = Aes256Gcm::new(&aes_key.into());
@ -478,7 +479,7 @@ mod tests {
fn unseal_too_short_fails() {
let vsk = SealingSecretKey {
d: [7_u8; 32],
r: [0_u8; 32],
z: [0_u8; 32],
};
let result = GroupKeyHolder::unseal(&[0_u8; 10], &vsk);
assert!(matches!(result, Err(super::SealError::TooShort)));

View File

@ -65,7 +65,7 @@ impl ChildKeysPrivate {
parent_hash.update([9_u8]);
parent_hash.update(self.value.0.private_key_holder.nullifier_secret_key);
parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.d);
parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.r);
parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.z);
let parent_pt = parent_hash.finalize();
let mut input = vec![];
@ -171,7 +171,7 @@ mod tests {
187, 143, 146, 12, 68, 148, 25, 203, 21, 92, 131, 2, 221, 81, 117, 62, 98, 194,
159, 177, 102, 254, 236, 182, 76, 242, 116, 219, 17, 166, 99, 36,
],
r: [
z: [
80, 97, 83, 209, 145, 99, 168, 99, 89, 29, 153, 236, 82, 99, 134, 114, 168, 19,
223, 69, 34, 47, 76, 76, 15, 97, 245, 184, 25, 103, 251, 82,
],
@ -285,7 +285,7 @@ mod tests {
81, 154, 68, 152, 72, 163, 82, 17, 125, 156, 193, 135, 129, 93, 227, 55, 224, 104,
119, 232, 13, 101, 241, 20, 175, 72, 192, 186, 176, 246, 140, 211,
],
r: [
z: [
31, 40, 109, 41, 185, 61, 173, 79, 102, 171, 158, 245, 232, 71, 57, 157, 142, 117,
184, 235, 216, 71, 55, 44, 33, 156, 167, 133, 184, 92, 47, 174,
],

View File

@ -69,10 +69,9 @@ impl KeyChain {
pub fn calculate_shared_secret_receiver(
&self,
ephemeral_public_key_sender: &EphemeralPublicKey,
_index: Option<u32>,
) -> SharedSecretKey {
) -> Option<SharedSecretKey> {
let vsk = &self.private_key_holder.viewing_secret_key;
SharedSecretKey::decapsulate(ephemeral_public_key_sender, &vsk.d, &vsk.r)
SharedSecretKey::decapsulate(ephemeral_public_key_sender, &vsk.d, &vsk.z)
}
}
@ -104,7 +103,7 @@ mod tests {
// Create a proper KEM ciphertext by encapsulating toward this key chain's VPK.
let (_, epk) = SharedSecretKey::encapsulate(&account_id_key_holder.viewing_public_key);
let _shared_secret = account_id_key_holder.calculate_shared_secret_receiver(&epk, None);
let _shared_secret = account_id_key_holder.calculate_shared_secret_receiver(&epk);
}
#[test]
@ -177,8 +176,8 @@ mod tests {
let key_sender = eph_key_holder.calculate_shared_secret_sender();
let key_receiver =
keys.calculate_shared_secret_receiver(eph_key_holder.ephemeral_public_key(), Some(2));
keys.calculate_shared_secret_receiver(eph_key_holder.ephemeral_public_key());
assert_eq!(key_sender.0, key_receiver.0);
assert_eq!(key_sender.0, key_receiver.unwrap().0);
}
}

View File

@ -17,12 +17,12 @@ pub struct SeedHolder {
/// Secret spending key object. Can produce `PrivateKeyHolder` objects.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SecretSpendingKey(pub [u8; 32]);
/// Viewing secret key: the KEM seed split into its two 32-byte halves `d` and `r` (= z in
/// FIPS 203), from which the ML-KEM 768 decapsulation key is derived deterministically.
/// Viewing secret key: the FIPS 203 KEM seed split into its two 32-byte halves `d` and `z`,
/// from which the ML-KEM-768 decapsulation key is derived deterministically.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ViewingSecretKey {
pub d: [u8; 32],
pub r: [u8; 32],
pub z: [u8; 32],
}
/// Private key holder. Produces public keys. Can produce `account_id`. Can produce shared secret
@ -143,7 +143,7 @@ impl SecretSpendingKey {
d: *full_seed
.first_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get first 32"),
r: *full_seed
z: *full_seed
.last_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get last 32"),
}
@ -153,7 +153,7 @@ impl SecretSpendingKey {
pub const fn generate_viewing_secret_key(seed: [u8; 64]) -> ViewingSecretKey {
ViewingSecretKey {
d: *seed.first_chunk::<32>().expect("seed is 64 bytes"),
r: *seed.last_chunk::<32>().expect("seed is 64 bytes"),
z: *seed.last_chunk::<32>().expect("seed is 64 bytes"),
}
}
@ -171,7 +171,7 @@ impl From<&ViewingSecretKey> for ViewingPublicKey {
use ml_kem::{Kem, KeyExport as _, MlKem768, Seed};
let mut seed_bytes = [0_u8; 64];
seed_bytes[..32].copy_from_slice(&sk.d);
seed_bytes[32..].copy_from_slice(&sk.r);
seed_bytes[32..].copy_from_slice(&sk.z);
let dk = <MlKem768 as Kem>::DecapsulationKey::from_seed(Seed::from(seed_bytes));
Self(dk.encapsulation_key().to_bytes().to_vec())
}

View File

@ -161,11 +161,11 @@ mod tests {
#[test]
fn kem_to_chacha20_round_trip() {
let d = [1_u8; 32];
let r = [2_u8; 32];
let vpk = shared_key_derivation::ViewingPublicKey::from_seed(&d, &r);
let z = [2_u8; 32];
let vpk = shared_key_derivation::ViewingPublicKey::from_seed(&d, &z);
let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk);
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &r);
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap();
let account = Account {
program_owner: [12_u32; 8],

View File

@ -30,14 +30,14 @@ impl ViewingPublicKey {
&self.0
}
/// Derive the ML-KEM-768 encapsulation key from the FIPS 203 seed halves `d` and `r`.
/// Derive the ML-KEM-768 encapsulation key from the FIPS 203 seed halves `d` and `z`.
/// Allows any crate to construct a VPK from raw seed bytes without importing
/// `key_protocol::ViewingSecretKey`.
#[must_use]
pub fn from_seed(d: &[u8; 32], r: &[u8; 32]) -> Self {
pub fn from_seed(d: &[u8; 32], z: &[u8; 32]) -> Self {
let mut seed = Seed::default();
seed[..32].copy_from_slice(d);
seed[32..].copy_from_slice(r);
seed[32..].copy_from_slice(z);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
Self(dk.encapsulation_key().to_bytes().to_vec())
}
@ -104,21 +104,22 @@ impl SharedSecretKey {
/// Receiver: decapsulate the shared secret from a KEM ciphertext.
///
/// `d` and `r` are the two 32-byte halves of the FIPS 203 `ViewingSecretKey` seed.
/// Returns `None` if the `EphemeralPublicKey` is not exactly 1088 bytes — callers on
/// the wallet scan path should skip the output rather than panic on malformed chain data.
///
/// `d` and `z` are the two 32-byte halves of the FIPS 203 `ViewingSecretKey` seed.
#[must_use]
pub fn decapsulate(ciphertext: &EphemeralPublicKey, d: &[u8; 32], r: &[u8; 32]) -> Self {
pub fn decapsulate(ciphertext: &EphemeralPublicKey, d: &[u8; 32], z: &[u8; 32]) -> Option<Self> {
let mut seed = Seed::default();
seed[..32].copy_from_slice(d);
seed[32..].copy_from_slice(r);
seed[32..].copy_from_slice(z);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
let ss = dk
.decapsulate_slice(&ciphertext.0)
.expect("EphemeralPublicKey must be 1088 bytes (ML-KEM-768 ciphertext)");
let ss = dk.decapsulate_slice(&ciphertext.0).ok()?;
let ss_bytes: [u8; 32] = ss
.as_slice()
.try_into()
.expect("ML-KEM shared key is 32 bytes");
Self(ss_bytes)
Some(Self(ss_bytes))
}
}
@ -131,18 +132,18 @@ mod tests {
#[test]
fn encapsulate_decapsulate_round_trip() {
let d = [1_u8; 32];
let r = [2_u8; 32];
let z = [2_u8; 32];
let mut seed = Seed::default();
seed[..32].copy_from_slice(&d);
seed[32..].copy_from_slice(&r);
seed[32..].copy_from_slice(&z);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
let ek_bytes = dk.encapsulation_key().to_bytes();
let vpk = ViewingPublicKey(ek_bytes.to_vec());
let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk);
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &r);
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap();
assert_eq!(sender_ss.0, receiver_ss.0, "shared secrets must match");
assert_eq!(epk.0.len(), 1088, "ML-KEM-768 ciphertext is 1088 bytes");
@ -153,22 +154,49 @@ mod tests {
);
}
#[test]
fn decapsulate_returns_none_for_malformed_epk() {
let d = [1_u8; 32];
let z = [2_u8; 32];
// Too short — 100 bytes instead of 1088.
let short_epk = EphemeralPublicKey(vec![42_u8; 100]);
assert!(
SharedSecretKey::decapsulate(&short_epk, &d, &z).is_none(),
"short EphemeralPublicKey must return None"
);
// Too long — 1089 bytes instead of 1088.
let long_epk = EphemeralPublicKey(vec![42_u8; 1089]);
assert!(
SharedSecretKey::decapsulate(&long_epk, &d, &z).is_none(),
"long EphemeralPublicKey must return None"
);
// Empty.
let empty_epk = EphemeralPublicKey(vec![]);
assert!(
SharedSecretKey::decapsulate(&empty_epk, &d, &z).is_none(),
"empty EphemeralPublicKey must return None"
);
}
#[test]
fn different_vpks_produce_different_shared_secrets() {
let (d1, r1) = ([1_u8; 32], [2_u8; 32]);
let (d2, r2) = ([3_u8; 32], [4_u8; 32]);
let (d1, z1) = ([1_u8; 32], [2_u8; 32]);
let (d2, z2) = ([3_u8; 32], [4_u8; 32]);
let vpk1 = {
let mut seed = Seed::default();
seed[..32].copy_from_slice(&d1);
seed[32..].copy_from_slice(&r1);
seed[32..].copy_from_slice(&z1);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
ViewingPublicKey(dk.encapsulation_key().to_bytes().to_vec())
};
let vpk2 = {
let mut seed = Seed::default();
seed[..32].copy_from_slice(&d2);
seed[32..].copy_from_slice(&r2);
seed[32..].copy_from_slice(&z2);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
ViewingPublicKey(dk.encapsulation_key().to_bytes().to_vec())
};

View File

@ -519,7 +519,7 @@ pub mod tests {
pub struct TestPrivateKeys {
pub nsk: NullifierSecretKey,
pub d: [u8; 32],
pub r: [u8; 32],
pub z: [u8; 32],
}
impl TestPrivateKeys {
@ -528,7 +528,7 @@ pub mod tests {
}
pub fn vpk(&self) -> ViewingPublicKey {
ViewingPublicKey::from_seed(&self.d, &self.r)
ViewingPublicKey::from_seed(&self.d, &self.z)
}
}
@ -1315,7 +1315,7 @@ pub mod tests {
TestPrivateKeys {
nsk: [13; 32],
d: [31; 32],
r: [32; 32],
z: [32; 32],
}
}
@ -1323,7 +1323,7 @@ pub mod tests {
TestPrivateKeys {
nsk: [38; 32],
d: [83; 32],
r: [84; 32],
z: [84; 32],
}
}

View File

@ -43,7 +43,7 @@ const VSK_D_PRIV_ACC_A: [u8; 32] = [
187, 41, 163, 19, 231, 232, 122, 225, 55, 134, 184,
];
const VSK_R_PRIV_ACC_A: [u8; 32] = [
const VSK_Z_PRIV_ACC_A: [u8; 32] = [
225, 24, 98, 78, 31, 203, 175, 248, 213, 17, 133, 207, 10, 135, 132, 151, 59, 184, 5, 81, 28,
238, 137, 62, 233, 227, 99, 17, 236, 159, 244, 63,
];
@ -53,7 +53,7 @@ const VSK_D_PRIV_ACC_B: [u8; 32] = [
12, 178, 229, 236, 255, 120, 146, 211, 169, 117, 153, 180,
];
const VSK_R_PRIV_ACC_B: [u8; 32] = [
const VSK_Z_PRIV_ACC_B: [u8; 32] = [
165, 80, 169, 87, 248, 88, 167, 154, 27, 67, 131, 122, 50, 130, 111, 40, 164, 180, 204, 75,
188, 140, 110, 132, 113, 133, 222, 8, 49, 123, 187, 18,
];
@ -138,11 +138,11 @@ pub fn initial_priv_accounts_private_keys() -> Vec<PrivateAccountPrivateInitialD
nullifier_secret_key: NSK_PRIV_ACC_A,
viewing_secret_key: ViewingSecretKey {
d: VSK_D_PRIV_ACC_A,
r: VSK_R_PRIV_ACC_A,
z: VSK_Z_PRIV_ACC_A,
},
},
nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_A),
viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_A, &VSK_R_PRIV_ACC_A),
viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_A, &VSK_Z_PRIV_ACC_A),
};
let key_chain_2 = KeyChain {
@ -151,11 +151,11 @@ pub fn initial_priv_accounts_private_keys() -> Vec<PrivateAccountPrivateInitialD
nullifier_secret_key: NSK_PRIV_ACC_B,
viewing_secret_key: ViewingSecretKey {
d: VSK_D_PRIV_ACC_B,
r: VSK_R_PRIV_ACC_B,
z: VSK_Z_PRIV_ACC_B,
},
},
nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_B),
viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_B, &VSK_R_PRIV_ACC_B),
viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_B, &VSK_Z_PRIV_ACC_B),
};
vec![

View File

@ -153,11 +153,11 @@ impl WalletSubcommand for GroupSubcommand {
}
let mut d = [0_u8; 32];
let mut r = [0_u8; 32];
let mut z = [0_u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut d);
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut r);
let secret = ViewingSecretKey { d, r };
let ek_bytes = nssa_core::encryption::ViewingPublicKey::from_seed(&d, &r).0;
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut z);
let secret = ViewingSecretKey { d, z };
let ek_bytes = nssa_core::encryption::ViewingPublicKey::from_seed(&d, &z).0;
let public_key = SealingPublicKey::from_bytes(ek_bytes);
wallet_core.set_sealing_secret_key(secret);

View File

@ -669,7 +669,7 @@ impl WalletCore {
.storage
.key_chain()
.private_account_key_chains()
.flat_map(|(_account_id, key_chain, index)| {
.flat_map(|(_account_id, key_chain, _index)| {
let view_tag = EncryptedAccountData::compute_view_tag(
&key_chain.nullifier_public_key,
&key_chain.viewing_public_key,
@ -684,10 +684,8 @@ impl WalletCore {
.filter_map(move |(ciph_id, encrypted_data)| {
let ciphertext = &encrypted_data.ciphertext;
let commitment = &new_commitments[ciph_id];
let shared_secret = key_chain.calculate_shared_secret_receiver(
&encrypted_data.epk,
index.and_then(ChainIndex::index),
);
let shared_secret = key_chain
.calculate_shared_secret_receiver(&encrypted_data.epk)?;
nssa_core::EncryptionScheme::decrypt(
ciphertext,
@ -769,8 +767,11 @@ impl WalletCore {
continue;
}
let shared_secret =
SharedSecretKey::decapsulate(&encrypted_data.epk, &vsk.d, &vsk.r);
let Some(shared_secret) =
SharedSecretKey::decapsulate(&encrypted_data.epk, &vsk.d, &vsk.z)
else {
continue;
};
let commitment = &tx.message.new_commitments[ciph_id];
if let Some((_kind, new_acc)) = nssa_core::EncryptionScheme::decrypt(