feat: add GroupKeyHolder for GMS key management

This commit is contained in:
Moudy 2026-04-16 01:25:40 +02:00
parent 10687d6562
commit dbd8ee122b
3 changed files with 165 additions and 0 deletions

View File

@ -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]));
}
}

View File

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

View File

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