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..50d77af6 --- /dev/null +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -0,0 +1,159 @@ +use nssa_core::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}; + +/// 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 key pair derived from the GMS +/// at a deterministic index computed from the PDA seed. +/// +/// The GMS must be distributed off-chain (sealed under each controller's viewing +/// public key). The wallet is responsible for encrypting the GMS at rest. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GroupKeyHolder { + gms: [u8; 32], +} + +impl GroupKeyHolder { + /// Create a new group with a fresh random GMS. + #[must_use] + pub fn new() -> Self { + let mut gms = [0u8; 32]; + OsRng.fill_bytes(&mut gms); + Self { gms } + } + + /// Restore from an existing GMS (received from another controller). + #[must_use] + pub const fn from_gms(gms: [u8; 32]) -> Self { + Self { gms } + } + + /// Export the raw GMS for distribution to other controllers. + #[must_use] + pub const fn gms(&self) -> &[u8; 32] { + &self.gms + } + + /// Derive the group's `SecretSpendingKey` from the GMS. + /// + /// Uses a domain-separated SHA256 hash with a group-specific prefix, + /// bypassing the 2048-round HMAC chain used for personal seeds. + fn secret_spending_key(&self) -> SecretSpendingKey { + let mut hasher = sha2::Sha256::new(); + hasher.update(b"LEE/group/ssk/v0"); + hasher.update(self.gms); + SecretSpendingKey(hasher.finalize_fixed().into()) + } + + /// Derive keys for a specific PDA. + /// + /// The index is deterministic from the PDA seed, so all controllers holding + /// the same GMS independently derive the same keys for the same PDA. + #[must_use] + pub fn derive_keys_for_pda(&self, pda_seed: &PdaSeed) -> PrivateKeyHolder { + let index = Self::pda_key_index(pda_seed); + self.secret_spending_key() + .produce_private_key_holder(Some(index)) + } + + /// Compute a deterministic `u32` index from a PDA seed. + fn pda_key_index(pda_seed: &PdaSeed) -> u32 { + let mut hasher = sha2::Sha256::new(); + hasher.update(b"LEE/pda-index"); + hasher.update(pda_seed.as_bytes()); + let hash: [u8; 32] = hasher.finalize_fixed().into(); + u32::from_le_bytes([hash[0], hash[1], hash[2], hash[3]]) + } +} + +#[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 = [42u8; 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([42u8; 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([42u8; 32]); + let holder_b = GroupKeyHolder::from_gms([99u8; 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([7u8; 32]); + let restored = GroupKeyHolder::from_gms(*original.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([42u8; 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])); + } +} 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 546529e9..929642e6 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -35,6 +35,11 @@ impl PdaSeed { pub const fn new(value: [u8; 32]) -> Self { Self(value) } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } } impl From<(&ProgramId, &PdaSeed)> for AccountId {