From f3215606fbc6671ba053999627393e569bf688a4 Mon Sep 17 00:00:00 2001 From: Moudy Date: Mon, 27 Apr 2026 02:43:26 +0200 Subject: [PATCH] feat: add GroupKeyHolder with per-PDA derivation, epoch ratchet, and seal/unseal --- key_protocol/Cargo.toml | 1 + .../src/key_management/group_key_holder.rs | 601 ++++++++++++++++++ key_protocol/src/key_management/mod.rs | 1 + nssa/core/src/program.rs | 6 + 4 files changed, 609 insertions(+) create mode 100644 key_protocol/src/key_management/group_key_holder.rs diff --git a/key_protocol/Cargo.toml b/key_protocol/Cargo.toml index 022f3ccd..72829ca8 100644 --- a/key_protocol/Cargo.toml +++ b/key_protocol/Cargo.toml @@ -26,3 +26,4 @@ itertools.workspace = true [dev-dependencies] base58.workspace = true +bincode.workspace = true diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs new file mode 100644 index 00000000..965f36a3 --- /dev/null +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -0,0 +1,601 @@ +use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _}; +use nssa_core::{ + SharedSecretKey, + encryption::{Scalar, ViewingPublicKey, shared_key_derivation::Secp256k1Point}, + program::PdaSeed, +}; +use rand::{RngCore as _, rngs::OsRng}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, digest::FixedOutput as _}; + +use super::secret_holders::{PrivateKeyHolder, SecretSpendingKey, ViewingSecretKey}; + +/// Manages shared viewing keys for a group of controllers owning private PDAs. +/// +/// The Group Master Secret (GMS) is a 32-byte random value shared among controllers. +/// Each private PDA owned by the group gets a unique `SecretSpendingKey` derived from +/// the GMS by mixing the PDA seed into the SHA-256 input (see `secret_spending_key_for_pda`). +/// +/// # Distribution +/// +/// The GMS is a long-term secret and must never cross a trust boundary in raw form. +/// Controllers share it off-chain by sealing it under each recipient's `ViewingPublicKey` +/// (see `seal_for` / `unseal`). Wallets persisting a `GroupKeyHolder` must encrypt it at +/// rest; the raw bytes are exposed only via [`GroupKeyHolder::dangerous_raw_gms`], which +/// is intended for the sealing path exclusively. +/// +/// # Logging safety +/// +/// `Debug` is implemented manually to redact the GMS; formatting this value with `{:?}` +/// will not leak the secret. Code that formats through `{:#?}` on containing types is +/// safe for the same reason. +#[derive(Serialize, Deserialize, Clone)] +pub struct GroupKeyHolder { + gms: [u8; 32], + epoch: u32, +} + +impl std::fmt::Debug for GroupKeyHolder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GroupKeyHolder") + .field("gms", &"") + .field("epoch", &self.epoch) + .finish() + } +} + +impl Default for GroupKeyHolder { + fn default() -> Self { + Self::new() + } +} + +impl GroupKeyHolder { + /// Create a new group with a fresh random GMS at epoch 0. + #[must_use] + pub fn new() -> Self { + let mut gms = [0_u8; 32]; + OsRng.fill_bytes(&mut gms); + Self { gms, epoch: 0 } + } + + /// Restore from an existing GMS at epoch 0. Only valid for initial group creation; + /// post-ratchet restoration must use [`from_gms_and_epoch`](Self::from_gms_and_epoch). + #[must_use] + pub const fn from_gms(gms: [u8; 32]) -> Self { + Self { gms, epoch: 0 } + } + + /// Restore from an existing GMS and epoch (received via `unseal`). + #[must_use] + pub const fn from_gms_and_epoch(gms: [u8; 32], epoch: u32) -> Self { + Self { gms, epoch } + } + + /// Returns the raw 32-byte GMS. The name reflects intent: only the sealed-distribution + /// path (`seal_for`) and sealed-at-rest persistence should ever need the raw bytes. Do + /// not log the result, do not pass it across an untrusted channel. + #[must_use] + pub const fn dangerous_raw_gms(&self) -> &[u8; 32] { + &self.gms + } + + /// Returns the current epoch. Starts at 0 and increments by 1 on each `ratchet` call. + #[must_use] + pub const fn epoch(&self) -> u32 { + self.epoch + } + + /// Forward-ratchets the GMS so removed members cannot derive future keys. + /// + /// The new GMS is `SHA256(PREFIX || rotation_salt || old_gms)`. The rotation salt must + /// be a fresh 32-byte random value contributed by the member who initiates the rotation. + /// Reusing a salt from a previous ratchet produces the same GMS as that previous + /// ratchet, collapsing the key rotation. Callers must generate the salt from a CSPRNG. + /// + /// After ratcheting, all remaining controllers must receive the new `GroupKeyHolder` + /// via `seal_for` / `unseal`. + #[expect( + clippy::arithmetic_side_effects, + reason = "epoch overflow at 2^32 ratchets is not a realistic scenario" + )] + pub fn ratchet(&mut self, rotation_salt: [u8; 32]) { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyRatchet/GMS\x00\x00\x00"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(rotation_salt); + hasher.update(self.gms); + self.gms = hasher.finalize_fixed().into(); + self.epoch += 1; + } + + /// Derive a per-PDA [`SecretSpendingKey`] by mixing the seed into the SHA-256 input. + /// + /// Each distinct `pda_seed` produces a distinct SSK in the full 256-bit space, so + /// adversarial seed-grinding cannot collide two PDAs' derived keys under the same + /// group. Uses the codebase's 32-byte protocol-versioned domain-separation convention. + fn secret_spending_key_for_pda(&self, pda_seed: &PdaSeed) -> SecretSpendingKey { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SSK"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(self.gms); + hasher.update(pda_seed.as_ref()); + SecretSpendingKey(hasher.finalize_fixed().into()) + } + + /// Derive keys for a specific PDA. + /// + /// All controllers holding the same GMS independently derive the same keys for the + /// same PDA because the derivation is deterministic in (GMS, seed). + #[must_use] + pub fn derive_keys_for_pda(&self, pda_seed: &PdaSeed) -> PrivateKeyHolder { + self.secret_spending_key_for_pda(pda_seed) + .produce_private_key_holder(None) + } + + /// Encrypts this holder's GMS and epoch under the recipient's `ViewingPublicKey`. + /// + /// Uses an ephemeral ECDH key exchange to derive a shared secret, then AES-256-GCM + /// to encrypt the payload. The returned bytes are + /// `ephemeral_pubkey (33) || nonce (12) || ciphertext+tag (52)` = 97 bytes. + /// + /// Each call generates a fresh ephemeral key, so two seals of the same holder produce + /// different ciphertexts. + #[must_use] + #[expect( + clippy::arithmetic_side_effects, + reason = "capacity arithmetic on small constants cannot overflow" + )] + pub fn seal_for(&self, recipient_vpk: &ViewingPublicKey) -> Vec { + let mut ephemeral_scalar: Scalar = [0_u8; 32]; + OsRng.fill_bytes(&mut ephemeral_scalar); + let ephemeral_pubkey = ViewingPublicKey::from_scalar(ephemeral_scalar); + let shared = SharedSecretKey::new(&ephemeral_scalar, recipient_vpk); + let aes_key = Self::seal_kdf(&shared); + let cipher = Aes256Gcm::new(&aes_key.into()); + + let mut nonce_bytes = [0_u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = aes_gcm::Nonce::from(nonce_bytes); + + let mut plaintext = [0_u8; 36]; + plaintext[..32].copy_from_slice(&self.gms); + plaintext[32..].copy_from_slice(&self.epoch.to_le_bytes()); + + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_ref()) + .expect("AES-GCM encryption should not fail with valid key/nonce"); + + let mut out = Vec::with_capacity(33 + 12 + ciphertext.len()); + out.extend_from_slice(&ephemeral_pubkey.0); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + out + } + + /// Decrypts a sealed `GroupKeyHolder` using the recipient's `ViewingSecretKey`. + /// + /// Returns `Err` if the ciphertext is too short, the ECDH point is invalid, or the + /// AES-GCM authentication tag doesn't verify (wrong key or tampered data). + pub fn unseal(sealed: &[u8], own_vsk: &ViewingSecretKey) -> Result { + const HEADER_LEN: usize = 33 + 12; + const MIN_LEN: usize = HEADER_LEN + 16; + if sealed.len() < MIN_LEN { + return Err(SealError::TooShort); + } + // MIN_LEN (61) > HEADER_LEN (45), so all slicing below is in bounds. + let ephemeral_pubkey = Secp256k1Point(sealed[..33].to_vec()); + let nonce = aes_gcm::Nonce::from_slice(&sealed[33..HEADER_LEN]); + let ciphertext = &sealed[HEADER_LEN..]; + + let shared = SharedSecretKey::new(own_vsk, &ephemeral_pubkey); + let aes_key = Self::seal_kdf(&shared); + let cipher = Aes256Gcm::new(&aes_key.into()); + + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|_err| SealError::DecryptionFailed)?; + + if plaintext.len() != 36 { + return Err(SealError::DecryptionFailed); + } + + let mut gms = [0_u8; 32]; + gms.copy_from_slice(&plaintext[..32]); + let epoch = u32::from_le_bytes(plaintext[32..36].try_into().unwrap()); + Ok(Self::from_gms_and_epoch(gms, epoch)) + } + + /// Derives an AES-256 key from the ECDH shared secret via SHA-256 with a domain prefix. + fn seal_kdf(shared: &SharedSecretKey) -> [u8; 32] { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeySeal/AES\x00\x00\x00\x00\x00\x00"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(shared.0); + hasher.finalize_fixed().into() + } +} + +#[derive(Debug)] +pub enum SealError { + TooShort, + DecryptionFailed, +} + +#[cfg(test)] +mod tests { + use nssa_core::NullifierPublicKey; + + use super::*; + + /// Two holders from the same GMS derive identical keys for the same PDA seed. + #[test] + fn same_gms_same_seed_produces_same_keys() { + let gms = [42_u8; 32]; + let holder_a = GroupKeyHolder::from_gms(gms); + let holder_b = GroupKeyHolder::from_gms(gms); + let seed = PdaSeed::new([1; 32]); + + let keys_a = holder_a.derive_keys_for_pda(&seed); + let keys_b = holder_b.derive_keys_for_pda(&seed); + + assert_eq!( + keys_a.generate_nullifier_public_key().to_byte_array(), + keys_b.generate_nullifier_public_key().to_byte_array(), + ); + } + + /// Different PDA seeds produce different keys from the same GMS. + #[test] + fn same_gms_different_seed_produces_different_keys() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let seed_a = PdaSeed::new([1; 32]); + let seed_b = PdaSeed::new([2; 32]); + + let npk_a = holder + .derive_keys_for_pda(&seed_a) + .generate_nullifier_public_key(); + let npk_b = holder + .derive_keys_for_pda(&seed_b) + .generate_nullifier_public_key(); + + assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array()); + } + + /// Different GMS produce different keys for the same PDA seed. + #[test] + fn different_gms_same_seed_produces_different_keys() { + let holder_a = GroupKeyHolder::from_gms([42_u8; 32]); + let holder_b = GroupKeyHolder::from_gms([99_u8; 32]); + let seed = PdaSeed::new([1; 32]); + + let npk_a = holder_a + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let npk_b = holder_b + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array()); + } + + /// GMS round-trip: export and restore produces the same keys. + #[test] + fn gms_round_trip() { + let original = GroupKeyHolder::from_gms([7_u8; 32]); + let restored = GroupKeyHolder::from_gms(*original.dangerous_raw_gms()); + let seed = PdaSeed::new([1; 32]); + + let npk_original = original + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let npk_restored = restored + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + assert_eq!(npk_original.to_byte_array(), npk_restored.to_byte_array()); + } + + /// The derived `NullifierPublicKey` is non-zero (sanity check). + #[test] + fn derived_npk_is_non_zero() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let seed = PdaSeed::new([1; 32]); + let npk = holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + assert_ne!(npk, NullifierPublicKey([0; 32])); + } + + /// Pins the end-to-end derivation for a fixed (GMS, `ProgramId`, `PdaSeed`). Any change + /// to `secret_spending_key_for_pda`, the `PrivateKeyHolder` nsk/npk chain, or the + /// `AccountId::for_private_pda` formula breaks this test. Mirrors the pinned-value + /// pattern from `for_private_pda_matches_pinned_value` in `nssa_core`. + #[test] + fn pinned_end_to_end_derivation_for_private_pda() { + use nssa_core::{account::AccountId, program::ProgramId}; + + let gms = [42_u8; 32]; + let seed = PdaSeed::new([1; 32]); + let program_id: ProgramId = [9; 8]; + + let holder = GroupKeyHolder::from_gms(gms); + let npk = holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let account_id = AccountId::for_private_pda(&program_id, &seed, &npk); + + let expected_npk = NullifierPublicKey([ + 185, 161, 225, 224, 20, 156, 173, 0, 6, 173, 74, 136, 16, 88, 71, 154, 101, 160, 224, + 162, 247, 98, 183, 210, 118, 130, 143, 237, 20, 112, 111, 114, + ]); + let expected_account_id = AccountId::new([ + 236, 138, 175, 184, 194, 233, 144, 109, 157, 51, 193, 120, 83, 110, 147, 90, 154, 57, + 148, 236, 12, 92, 135, 38, 253, 79, 88, 143, 161, 175, 46, 144, + ]); + + assert_eq!(npk, expected_npk); + assert_eq!(account_id, expected_account_id); + } + + /// Wallets persist `GroupKeyHolder` to disk and reload it on startup. This test pins + /// the serde round-trip: serialize, deserialize, and assert the derived keys for a + /// sample seed match on both sides. A silent encoding drift would corrupt every + /// group-owned account. + #[test] + fn gms_serde_round_trip_preserves_derivation() { + let mut original = GroupKeyHolder::from_gms([7_u8; 32]); + original.ratchet([10_u8; 32]); + let encoded = bincode::serialize(&original).expect("serialize"); + let restored: GroupKeyHolder = bincode::deserialize(&encoded).expect("deserialize"); + + let seed = PdaSeed::new([1; 32]); + let npk_original = original + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let npk_restored = restored + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + assert_eq!(npk_original, npk_restored); + assert_eq!(original.dangerous_raw_gms(), restored.dangerous_raw_gms()); + assert_eq!(original.epoch(), restored.epoch()); + } + + /// A `GroupKeyHolder` constructed from the same 32 bytes as a personal + /// `SecretSpendingKey` must not derive the same `NullifierPublicKey` as the personal + /// path, so a private PDA cannot be spent by a personal nullifier even under + /// adversarial key-material reuse. The safety rests on the group path's distinct + /// domain-separation prefix plus the seed mix-in (see `secret_spending_key_for_pda`). + #[test] + fn group_derivation_does_not_collide_with_personal_path_at_shared_bytes() { + let shared_bytes = [13_u8; 32]; + let seed = PdaSeed::new([5; 32]); + + let group_npk = GroupKeyHolder::from_gms(shared_bytes) + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + let personal_npk = SecretSpendingKey(shared_bytes) + .produce_private_key_holder(None) + .generate_nullifier_public_key(); + + assert_ne!(group_npk, personal_npk); + } + + /// Ratcheting advances the epoch by 1. + #[test] + fn ratchet_advances_epoch() { + let mut holder = GroupKeyHolder::from_gms([42_u8; 32]); + assert_eq!(holder.epoch(), 0); + holder.ratchet([1_u8; 32]); + assert_eq!(holder.epoch(), 1); + holder.ratchet([2_u8; 32]); + assert_eq!(holder.epoch(), 2); + } + + /// After ratcheting, the same PDA seed produces a different npk. A removed member + /// holding the old GMS cannot derive the new keys. + #[test] + fn ratchet_changes_derived_keys() { + let mut holder = GroupKeyHolder::from_gms([42_u8; 32]); + let seed = PdaSeed::new([1; 32]); + let npk_before = holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + holder.ratchet([99_u8; 32]); + let npk_after = holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + assert_ne!(npk_before, npk_after); + } + + /// Two holders ratcheted with different salts diverge, even from the same starting GMS. + #[test] + fn different_salts_produce_different_ratcheted_keys() { + let mut holder_a = GroupKeyHolder::from_gms([42_u8; 32]); + let mut holder_b = GroupKeyHolder::from_gms([42_u8; 32]); + holder_a.ratchet([1_u8; 32]); + holder_b.ratchet([2_u8; 32]); + let seed = PdaSeed::new([1; 32]); + let npk_a = holder_a + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + let npk_b = holder_b + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + assert_ne!(npk_a, npk_b); + } + + /// `from_gms_and_epoch` restores a holder at a specific epoch, matching the state + /// after that many ratchets. + #[test] + fn from_gms_and_epoch_restores_correctly() { + let mut holder = GroupKeyHolder::from_gms([42_u8; 32]); + holder.ratchet([1_u8; 32]); + let restored = + GroupKeyHolder::from_gms_and_epoch(*holder.dangerous_raw_gms(), holder.epoch()); + assert_eq!(restored.epoch(), 1); + let seed = PdaSeed::new([1; 32]); + assert_eq!( + holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(), + restored + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(), + ); + } + + /// A removed member holding the pre-ratchet GMS cannot derive the post-ratchet + /// keys, even if they know the PDA seed. This is the forward-secrecy property of + /// the ratchet: the old GMS is a preimage of the new one under SHA-256, so + /// reversing the ratchet requires breaking preimage resistance. + #[test] + fn removed_member_cannot_derive_post_ratchet_keys() { + let original_gms = [42_u8; 32]; + let seed = PdaSeed::new([1; 32]); + + // Removed member's frozen state + let removed = GroupKeyHolder::from_gms(original_gms); + let removed_npk = removed + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + // Remaining members ratchet twice + let mut active = GroupKeyHolder::from_gms(original_gms); + active.ratchet([10_u8; 32]); + active.ratchet([20_u8; 32]); + let active_npk = active + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(); + + // The removed member's keys are useless for the current epoch + assert_ne!(removed_npk, active_npk); + assert_ne!(removed.dangerous_raw_gms(), active.dangerous_raw_gms()); + assert_eq!(removed.epoch(), 0); + assert_eq!(active.epoch(), 2); + } + + /// Seal then unseal recovers the same GMS, epoch, and derived keys. + #[test] + fn seal_unseal_round_trip() { + let mut holder = GroupKeyHolder::from_gms([42_u8; 32]); + holder.ratchet([10_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_keys = recipient_ssk.produce_private_key_holder(None); + let recipient_vpk = recipient_keys.generate_viewing_public_key(); + let recipient_vsk = recipient_keys.viewing_secret_key; + + let sealed = holder.seal_for(&recipient_vpk); + let restored = GroupKeyHolder::unseal(&sealed, &recipient_vsk).expect("unseal"); + + assert_eq!(restored.dangerous_raw_gms(), holder.dangerous_raw_gms()); + assert_eq!(restored.epoch(), holder.epoch()); + + let seed = PdaSeed::new([1; 32]); + assert_eq!( + holder + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(), + restored + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key(), + ); + } + + /// Unsealing with a different VSK fails with `DecryptionFailed`. + #[test] + fn unseal_wrong_vsk_fails() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_vpk = recipient_ssk + .produce_private_key_holder(None) + .generate_viewing_public_key(); + + let wrong_ssk = SecretSpendingKey([99_u8; 32]); + let wrong_vsk = wrong_ssk + .produce_private_key_holder(None) + .viewing_secret_key; + + let sealed = holder.seal_for(&recipient_vpk); + let result = GroupKeyHolder::unseal(&sealed, &wrong_vsk); + assert!(matches!(result, Err(super::SealError::DecryptionFailed))); + } + + /// Tampered ciphertext fails authentication. + #[test] + fn unseal_tampered_ciphertext_fails() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_keys = recipient_ssk.produce_private_key_holder(None); + let recipient_vpk = recipient_keys.generate_viewing_public_key(); + let recipient_vsk = recipient_keys.viewing_secret_key; + + let mut sealed = holder.seal_for(&recipient_vpk); + // Flip a byte in the ciphertext portion (after ephemeral_pubkey + nonce) + let last = sealed.len() - 1; + sealed[last] ^= 0xFF; + + let result = GroupKeyHolder::unseal(&sealed, &recipient_vsk); + assert!(matches!(result, Err(super::SealError::DecryptionFailed))); + } + + /// Two seals of the same holder produce different ciphertexts (ephemeral randomness). + #[test] + fn two_seals_produce_different_ciphertexts() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_vpk = recipient_ssk + .produce_private_key_holder(None) + .generate_viewing_public_key(); + + let sealed_a = holder.seal_for(&recipient_vpk); + let sealed_b = holder.seal_for(&recipient_vpk); + assert_ne!(sealed_a, sealed_b); + } + + /// Sealed payload is too short. + #[test] + fn unseal_too_short_fails() { + let vsk: ViewingSecretKey = [7_u8; 32]; + let result = GroupKeyHolder::unseal(&[0_u8; 10], &vsk); + assert!(matches!(result, Err(super::SealError::TooShort))); + } + + /// Degenerate GMS values (all-zeros, all-ones, single-bit) must still produce valid, + /// non-zero, pairwise-distinct npks. Rules out accidental "if gms == default { return + /// default }" style shortcuts in the derivation. + #[test] + fn degenerate_gms_produces_distinct_non_zero_keys() { + let seed = PdaSeed::new([1; 32]); + let degenerate = [[0_u8; 32], [0xFF_u8; 32], { + let mut v = [0_u8; 32]; + v[0] = 1; + v + }]; + + let npks: Vec = degenerate + .iter() + .map(|gms| { + GroupKeyHolder::from_gms(*gms) + .derive_keys_for_pda(&seed) + .generate_nullifier_public_key() + }) + .collect(); + + for npk in &npks { + assert_ne!(*npk, NullifierPublicKey([0; 32])); + } + for (i, a) in npks.iter().enumerate() { + for b in &npks[i + 1..] { + assert_ne!(a, b); + } + } + } +} diff --git a/key_protocol/src/key_management/mod.rs b/key_protocol/src/key_management/mod.rs index c038c415..7a1f44df 100644 --- a/key_protocol/src/key_management/mod.rs +++ b/key_protocol/src/key_management/mod.rs @@ -6,6 +6,7 @@ use secret_holders::{PrivateKeyHolder, SecretSpendingKey, SeedHolder}; use serde::{Deserialize, Serialize}; pub mod ephemeral_key_holder; +pub mod group_key_holder; pub mod key_tree; pub mod secret_holders; diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index 5091cdff..b18cadc2 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -37,6 +37,12 @@ impl PdaSeed { } } +impl AsRef<[u8]> for PdaSeed { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + impl AccountId { /// Derives an [`AccountId`] for a public PDA from the program ID and seed. #[must_use]