Merge pull request #449 from logos-blockchain/moudy/feat-group-key-holder

feat: GroupKeyHolder for GMS key management
This commit is contained in:
Moudy 2026-05-05 13:09:54 +02:00 committed by GitHub
commit cf6eab2538
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1210 additions and 11 deletions

View File

@ -225,7 +225,7 @@ jobs:
- uses: ./.github/actions/install-risc0
- name: Install just
run: cargo install just
run: cargo install --locked just
- name: Build artifacts
run: just build-artifacts

38
Cargo.lock generated
View File

@ -1374,6 +1374,7 @@ dependencies = [
"cfg-if",
"cipher 0.5.1",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
@ -1959,7 +1960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@ -2124,9 +2125,9 @@ dependencies = [
[[package]]
name = "docker-compose-types"
version = "0.22.0"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edb75a85449fd9c34d9fb3376c6208ec4115d2ca43b965175a52d71349ecab8"
checksum = "6ea51e75cfa9371c4d760270c3da13516d7206121d668c1fbdd6fd83d1782b0f"
dependencies = [
"derive_builder",
"indexmap 2.13.0",
@ -2501,12 +2502,12 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "ferroid"
version = "0.8.9"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986"
checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4"
dependencies = [
"portable-atomic",
"rand 0.9.3",
"rand 0.10.1",
"web-time",
]
@ -2821,6 +2822,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
"wasm-bindgen",
@ -3993,6 +3995,7 @@ dependencies = [
"aes-gcm",
"anyhow",
"base58",
"bincode",
"bip39",
"common",
"hex",
@ -5399,7 +5402,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -6321,6 +6324,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@ -6359,6 +6373,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xorshift"
version = "0.4.0"
@ -8073,9 +8093,9 @@ dependencies = [
[[package]]
name = "testcontainers"
version = "0.27.2"
version = "0.27.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bd36b06a2a6c0c3c81a83be1ab05fe86460d054d4d51bf513bc56b3e15bdc22"
checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e"
dependencies = [
"astral-tokio-tar",
"async-trait",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -35,4 +35,4 @@ hex.workspace = true
tempfile.workspace = true
bytesize.workspace = true
futures.workspace = true
testcontainers = { version = "0.27.0", features = ["docker-compose"] }
testcontainers = { version = "0.27.3", features = ["docker-compose"] }

View File

@ -26,3 +26,4 @@ itertools.workspace = true
[dev-dependencies]
base58.workspace = true
bincode.workspace = true

View File

@ -0,0 +1,504 @@
use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _};
use nssa_core::{
SharedSecretKey,
encryption::{Scalar, 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};
/// Public key used to seal a `GroupKeyHolder` for distribution to a recipient.
///
/// Structurally identical to `ViewingPublicKey` (both are secp256k1 points), but given
/// a distinct alias to clarify intent: viewing keys encrypt account state, sealing keys
/// encrypt the GMS for off-chain distribution.
pub type SealingPublicKey = Secp256k1Point;
/// Secret key used to unseal a `GroupKeyHolder` received from another member.
pub type SealingSecretKey = Scalar;
/// 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 [`SealingPublicKey`]
/// (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],
}
impl std::fmt::Debug for GroupKeyHolder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GroupKeyHolder")
.field("gms", &"<redacted>")
.finish()
}
}
impl Default for GroupKeyHolder {
fn default() -> Self {
Self::new()
}
}
impl GroupKeyHolder {
/// Create a new group with a fresh random GMS.
#[must_use]
pub fn new() -> Self {
let mut gms = [0_u8; 32];
OsRng.fill_bytes(&mut gms);
Self { gms }
}
/// Restore from an existing GMS (received via `unseal`).
#[must_use]
pub const fn from_gms(gms: [u8; 32]) -> Self {
Self { gms }
}
/// 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
}
/// 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 under the recipient's [`SealingPublicKey`].
///
/// 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 (48)` = 93 bytes.
///
/// Each call generates a fresh ephemeral key, so two seals of the same holder produce
/// different ciphertexts.
#[must_use]
pub fn seal_for(&self, recipient_key: &SealingPublicKey) -> Vec<u8> {
let mut ephemeral_scalar: Scalar = [0_u8; 32];
OsRng.fill_bytes(&mut ephemeral_scalar);
let ephemeral_pubkey = Secp256k1Point::from_scalar(ephemeral_scalar);
let shared = SharedSecretKey::new(&ephemeral_scalar, recipient_key);
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 ciphertext = cipher
.encrypt(&nonce, self.gms.as_ref())
.expect("AES-GCM encryption should not fail with valid key/nonce");
let capacity = 33_usize
.checked_add(12)
.and_then(|n| n.checked_add(ciphertext.len()))
.expect("seal capacity overflow");
let mut out = Vec::with_capacity(capacity);
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 [`SealingSecretKey`].
///
/// 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_key: &SealingSecretKey) -> Result<Self, SealError> {
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_key, &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() != 32 {
return Err(SealError::DecryptionFailed);
}
let mut gms = [0_u8; 32];
gms.copy_from_slice(&plaintext);
Ok(Self::from_gms(gms))
}
/// 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 original = GroupKeyHolder::from_gms([7_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());
}
/// 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);
}
/// Seal then unseal recovers the same GMS and derived keys.
#[test]
fn seal_unseal_round_trip() {
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 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());
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: SealingSecretKey = [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<NullifierPublicKey> = 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);
}
}
}
/// Full lifecycle: create group, distribute GMS via seal/unseal, verify key agreement.
#[test]
fn group_pda_lifecycle() {
use nssa_core::account::AccountId;
let alice_holder = GroupKeyHolder::new();
let pda_seed = PdaSeed::new([42_u8; 32]);
let program_id: nssa_core::program::ProgramId = [1; 8];
// Derive Alice's keys
let alice_keys = alice_holder.derive_keys_for_pda(&pda_seed);
let alice_npk = alice_keys.generate_nullifier_public_key();
// Seal GMS for Bob using Bob's viewing key, Bob unseals
let bob_ssk = SecretSpendingKey([77_u8; 32]);
let bob_keys = bob_ssk.produce_private_key_holder(None);
let bob_vpk = bob_keys.generate_viewing_public_key();
let bob_vsk = bob_keys.viewing_secret_key;
let sealed = alice_holder.seal_for(&bob_vpk);
let bob_holder =
GroupKeyHolder::unseal(&sealed, &bob_vsk).expect("Bob should unseal the GMS");
// Key agreement: both derive identical NPK and AccountId
let bob_npk = bob_holder
.derive_keys_for_pda(&pda_seed)
.generate_nullifier_public_key();
assert_eq!(alice_npk, bob_npk);
let alice_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk);
let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk);
assert_eq!(alice_account_id, bob_account_id);
}
}

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

@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use crate::key_management::{
KeyChain,
group_key_holder::GroupKeyHolder,
key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex},
secret_holders::SeedHolder,
};
@ -30,6 +31,17 @@ pub struct NSSAUserData {
pub public_key_tree: KeyTreePublic,
/// Tree of private keys.
pub private_key_tree: KeyTreePrivate,
/// Group key holders for private PDA groups, keyed by a human-readable label.
/// Defaults to empty for backward compatibility with wallets that predate group PDAs.
/// An older wallet binary that re-serializes this struct will drop the field.
#[serde(default)]
pub group_key_holders: BTreeMap<String, GroupKeyHolder>,
/// Cached plaintext state of private PDA accounts, keyed by `AccountId`.
/// Updated after each private PDA transaction by decrypting the circuit output.
/// The sequencer only stores encrypted commitments, so this local cache is the
/// only source of plaintext state for private PDAs.
#[serde(default, alias = "group_pda_accounts")]
pub pda_accounts: BTreeMap<nssa::AccountId, nssa_core::account::Account>,
}
impl NSSAUserData {
@ -88,6 +100,8 @@ impl NSSAUserData {
default_user_private_accounts: default_accounts_key_chains,
public_key_tree,
private_key_tree,
group_key_holders: BTreeMap::new(),
pda_accounts: BTreeMap::new(),
})
}
@ -195,6 +209,20 @@ impl NSSAUserData {
.copied()
.chain(self.private_key_tree.account_id_map.keys().copied())
}
/// Returns the `GroupKeyHolder` for the given label, if it exists.
#[must_use]
pub fn group_key_holder(&self, label: &str) -> Option<&GroupKeyHolder> {
self.group_key_holders.get(label)
}
/// Inserts or replaces a `GroupKeyHolder` under the given label.
///
/// If a holder already exists under this label, it is silently replaced and the old
/// GMS is lost. Callers must ensure label uniqueness across groups.
pub fn insert_group_key_holder(&mut self, label: String, holder: GroupKeyHolder) {
self.group_key_holders.insert(label, holder);
}
}
impl Default for NSSAUserData {
@ -214,6 +242,26 @@ impl Default for NSSAUserData {
mod tests {
use super::*;
#[test]
fn group_key_holder_storage_round_trip() {
let mut user_data = NSSAUserData::default();
assert!(user_data.group_key_holder("test-group").is_none());
let holder = GroupKeyHolder::from_gms([42_u8; 32]);
user_data.insert_group_key_holder(String::from("test-group"), holder.clone());
let retrieved = user_data
.group_key_holder("test-group")
.expect("should exist");
assert_eq!(retrieved.dangerous_raw_gms(), holder.dangerous_raw_gms());
}
#[test]
fn group_key_holders_default_empty() {
let user_data = NSSAUserData::default();
assert!(user_data.group_key_holders.is_empty());
}
#[test]
fn new_account() {
let mut user_data = NSSAUserData::default();

View File

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

View File

@ -178,6 +178,7 @@ mod tests {
use nssa_core::{
Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, Nullifier, SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
program::PdaSeed,
};
use super::*;
@ -416,4 +417,106 @@ mod tests {
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// Group PDA deposit: creates a new PDA and transfers balance from the
/// counterparty. Both accounts owned by `private_pda_spender`.
#[test]
fn group_pda_deposit() {
let program = Program::private_pda_spender();
let noop = Program::noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk());
// PDA (new, mask 3)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
// Sender (mask 0, public, owned by this program, has balance)
let sender_id = AccountId::new([99; 32]);
let sender_pre = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 10000,
..Account::default()
},
true,
sender_id,
);
let noop_id = noop.id();
let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into());
let instruction = Program::serialize_instruction((seed, noop_id, 500_u128, true)).unwrap();
// PDA is mask 3 (private PDA), sender is mask 0 (public).
// The noop chained call is required to establish the mask-3 (seed, npk) binding
// that the circuit enforces for private PDAs. Without a caller providing pda_seeds,
// the circuit's binding check rejects the account.
let result = execute_and_prove(
vec![pda_pre, sender_pre],
instruction,
vec![
InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret_pda,
},
InputAccountIdentity::Public,
],
&program_with_deps,
);
let (output, _proof) = result.expect("group PDA deposit should succeed");
// Only PDA (mask 3) produces a commitment; sender (mask 0) is public.
assert_eq!(output.new_commitments.len(), 1);
}
/// Group PDA spend binding: the noop chained call with `pda_seeds` establishes
/// the mask-3 binding for an existing-but-default PDA. Uses amount=0 because
/// testing with a pre-funded PDA requires a two-tx sequence with membership proofs.
#[test]
fn group_pda_spend_binding() {
let program = Program::private_pda_spender();
let noop = Program::noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk());
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
let bob_id = AccountId::new([88; 32]);
let bob_pre = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 10000,
..Account::default()
},
true,
bob_id,
);
let noop_id = noop.id();
let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into());
let instruction = Program::serialize_instruction((seed, noop_id, 0_u128, false)).unwrap();
let result = execute_and_prove(
vec![pda_pre, bob_pre],
instruction,
vec![
InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret_pda,
},
InputAccountIdentity::Public,
],
&program_with_deps,
);
let (output, _proof) = result.expect("group PDA spend binding should succeed");
assert_eq!(output.new_commitments.len(), 1);
}
}

View File

@ -312,6 +312,16 @@ mod tests {
}
}
#[must_use]
pub fn private_pda_spender() -> Self {
use test_program_methods::{PRIVATE_PDA_SPENDER_ELF, PRIVATE_PDA_SPENDER_ID};
Self {
id: PRIVATE_PDA_SPENDER_ID,
elf: PRIVATE_PDA_SPENDER_ELF.to_vec(),
}
}
#[must_use]
pub fn two_pda_claimer() -> Self {
use test_program_methods::{TWO_PDA_CLAIMER_ELF, TWO_PDA_CLAIMER_ID};

View File

@ -0,0 +1,118 @@
use nssa_core::program::{
AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId, ProgramInput, ProgramOutput,
read_nssa_inputs,
};
/// Single program for group PDA operations. Owns and operates the PDA directly.
///
/// Instruction: `(pda_seed, noop_program_id, amount, is_deposit)`.
/// Pre-states: `[group_pda, counterparty]`.
///
/// **Deposit** (`is_deposit = true`, new PDA):
/// Claims PDA via `Claim::Pda(seed)`, increases PDA balance, decreases counterparty.
/// Counterparty must be authorized and owned by this program (or uninitialized).
///
/// **Spend** (`is_deposit = false`, existing PDA):
/// Decreases PDA balance (this program owns it), increases counterparty.
/// Chains to a noop callee with `pda_seeds` to establish the mask-3 binding
/// that the circuit requires for existing private PDAs.
type Instruction = (PdaSeed, ProgramId, u128, bool);
#[expect(
clippy::allow_attributes,
reason = "allow is needed because the clones are only redundant in test compilation"
)]
#[allow(
clippy::redundant_clone,
reason = "clones needed in non-test compilation"
)]
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction: (pda_seed, noop_id, amount, is_deposit),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let Ok([pda_pre, counterparty_pre]) = <[_; 2]>::try_from(pre_states.clone()) else {
panic!("expected exactly 2 pre_states: [group_pda, counterparty]");
};
if is_deposit {
// Deposit: claim PDA, transfer balance from counterparty to PDA.
// Both accounts must be owned by this program (or uninitialized) for
// validate_execution to allow balance changes.
assert!(
counterparty_pre.is_authorized,
"Counterparty must be authorized to deposit"
);
let mut pda_account = pda_pre.account;
let mut counterparty_account = counterparty_pre.account;
pda_account.balance = pda_account
.balance
.checked_add(amount)
.expect("PDA balance overflow");
counterparty_account.balance = counterparty_account
.balance
.checked_sub(amount)
.expect("Counterparty has insufficient balance");
let pda_post = AccountPostState::new_claimed_if_default(pda_account, Claim::Pda(pda_seed));
let counterparty_post = AccountPostState::new(counterparty_account);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
pre_states,
vec![pda_post, counterparty_post],
)
.write();
} else {
// Spend: decrease PDA balance (owned by this program), increase counterparty.
// Chain to noop with pda_seeds to establish the mask-3 binding for the
// existing PDA. The noop's pre_states must match our post_states.
// Authorization is enforced by the circuit's binding check, not here.
let mut pda_account = pda_pre.account.clone();
let mut counterparty_account = counterparty_pre.account.clone();
pda_account.balance = pda_account
.balance
.checked_sub(amount)
.expect("PDA has insufficient balance");
counterparty_account.balance = counterparty_account
.balance
.checked_add(amount)
.expect("Counterparty balance overflow");
let pda_post = AccountPostState::new(pda_account.clone());
let counterparty_post = AccountPostState::new(counterparty_account.clone());
// Chain to noop solely to establish the mask-3 binding via pda_seeds.
let mut noop_pda_pre = pda_pre;
noop_pda_pre.account = pda_account;
noop_pda_pre.is_authorized = true;
let mut noop_counterparty_pre = counterparty_pre;
noop_counterparty_pre.account = counterparty_account;
let noop_call = ChainedCall::new(noop_id, vec![noop_pda_pre, noop_counterparty_pre], &())
.with_pda_seeds(vec![pda_seed]);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
pre_states,
vec![pda_post, counterparty_post],
)
.with_chained_calls(vec![noop_call])
.write();
}
}

295
wallet/src/cli/group.rs Normal file
View File

@ -0,0 +1,295 @@
use anyhow::{Context as _, Result};
use clap::Subcommand;
use key_protocol::key_management::group_key_holder::GroupKeyHolder;
use nssa::AccountId;
use nssa_core::program::PdaSeed;
use crate::{
WalletCore,
cli::{SubcommandReturnValue, WalletSubcommand},
};
/// Group PDA management commands.
#[derive(Subcommand, Debug, Clone)]
pub enum GroupSubcommand {
/// Create a new group with a fresh random GMS.
New {
/// Human-readable name for the group.
name: String,
},
/// Import a group from raw GMS bytes.
Import {
/// Human-readable name for the group.
name: String,
/// Raw GMS as 64-character hex string.
#[arg(long)]
gms: String,
/// Epoch (defaults to 0).
#[arg(long, default_value = "0")]
epoch: u32,
},
/// Export the raw GMS hex for backup or manual distribution.
Export {
/// Group name.
name: String,
},
/// List all groups with their epochs.
#[command(visible_alias = "ls")]
List,
/// Derive keys for a PDA seed and show the resulting AccountId.
Derive {
/// Group name.
name: String,
/// PDA seed as 64-character hex string.
#[arg(long)]
seed: String,
/// Program ID as hex string (u32x8 little-endian).
#[arg(long)]
program_id: String,
},
/// Remove a group from the wallet.
Remove {
/// Group name.
name: String,
},
/// Seal the group's GMS for a recipient (invite).
Invite {
/// Group name.
name: String,
/// Recipient's viewing public key as hex string.
#[arg(long)]
vpk: String,
},
/// Unseal a received GMS and store it (join a group).
Join {
/// Human-readable name to store the group under.
name: String,
/// Sealed GMS as hex string (from the inviter).
#[arg(long)]
sealed: String,
/// Account label or Private/<id> whose VSK to use for decryption.
#[arg(long)]
account: String,
},
/// Ratchet the GMS to exclude removed members.
Ratchet {
/// Group name.
name: String,
},
}
impl WalletSubcommand for GroupSubcommand {
async fn handle_subcommand(
self,
wallet_core: &mut WalletCore,
) -> Result<SubcommandReturnValue> {
match self {
Self::New { name } => {
if wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.is_some()
{
anyhow::bail!("Group '{name}' already exists");
}
let holder = GroupKeyHolder::new();
wallet_core
.storage_mut()
.user_data
.insert_group_key_holder(name.clone(), holder);
wallet_core.store_persistent_data().await?;
println!("Created group '{name}' at epoch 0");
Ok(SubcommandReturnValue::Empty)
}
Self::Import { name, gms, epoch } => {
if wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.is_some()
{
anyhow::bail!("Group '{name}' already exists");
}
let gms_bytes: [u8; 32] = hex::decode(&gms)
.context("Invalid GMS hex")?
.try_into()
.map_err(|_| anyhow::anyhow!("GMS must be exactly 32 bytes"))?;
let holder = GroupKeyHolder::from_gms_and_epoch(gms_bytes, epoch);
wallet_core
.storage_mut()
.user_data
.insert_group_key_holder(name.clone(), holder);
wallet_core.store_persistent_data().await?;
println!("Imported group '{name}' at epoch {epoch}");
Ok(SubcommandReturnValue::Empty)
}
Self::Export { name } => {
let holder = wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.context(format!("Group '{name}' not found"))?;
let gms_hex = hex::encode(holder.dangerous_raw_gms());
let epoch = holder.epoch();
println!("Group: {name}");
println!("Epoch: {epoch}");
println!("GMS: {gms_hex}");
Ok(SubcommandReturnValue::Empty)
}
Self::List => {
let holders = &wallet_core.storage().user_data.group_key_holders;
if holders.is_empty() {
println!("No groups found");
} else {
for (name, holder) in holders {
println!("{name} (epoch {})", holder.epoch());
}
}
Ok(SubcommandReturnValue::Empty)
}
Self::Derive {
name,
seed,
program_id,
} => {
let holder = wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.context(format!("Group '{name}' not found"))?;
let seed_bytes: [u8; 32] = hex::decode(&seed)
.context("Invalid seed hex")?
.try_into()
.map_err(|_| anyhow::anyhow!("Seed must be exactly 32 bytes"))?;
let pda_seed = PdaSeed::new(seed_bytes);
let pid_bytes =
hex::decode(&program_id).context("Invalid program ID hex")?;
if pid_bytes.len() != 32 {
anyhow::bail!("Program ID must be exactly 32 bytes");
}
let mut pid: nssa_core::program::ProgramId = [0; 8];
for (i, chunk) in pid_bytes.chunks_exact(4).enumerate() {
pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());
}
let keys = holder.derive_keys_for_pda(&pda_seed);
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let account_id = AccountId::for_private_pda(&pid, &pda_seed, &npk);
println!("Group: {name}");
println!("NPK: {}", hex::encode(npk.0));
println!("VPK: {}", hex::encode(&vpk.0));
println!("AccountId: {account_id}");
Ok(SubcommandReturnValue::Empty)
}
Self::Remove { name } => {
if wallet_core
.storage_mut()
.user_data
.group_key_holders
.remove(&name)
.is_none()
{
anyhow::bail!("Group '{name}' not found");
}
wallet_core.store_persistent_data().await?;
println!("Removed group '{name}'");
Ok(SubcommandReturnValue::Empty)
}
Self::Invite { name, vpk } => {
let holder = wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.context(format!("Group '{name}' not found"))?;
let vpk_bytes = hex::decode(&vpk).context("Invalid VPK hex")?;
let recipient_vpk =
nssa_core::encryption::shared_key_derivation::Secp256k1Point(vpk_bytes);
let sealed = holder.seal_for(&recipient_vpk);
println!("{}", hex::encode(&sealed));
Ok(SubcommandReturnValue::Empty)
}
Self::Join {
name,
sealed,
account,
} => {
if wallet_core
.storage()
.user_data
.get_group_key_holder(&name)
.is_some()
{
anyhow::bail!("Group '{name}' already exists");
}
let sealed_bytes = hex::decode(&sealed).context("Invalid sealed hex")?;
// Resolve the account to get the VSK
let account_id: nssa::AccountId = account
.parse()
.context("Invalid account ID (use Private/<base58>)")?;
let (keychain, _) = wallet_core
.storage()
.user_data
.get_private_account(account_id)
.context("Private account not found")?;
let vsk = keychain.private_key_holder.viewing_secret_key;
let holder = GroupKeyHolder::unseal(&sealed_bytes, &vsk)
.map_err(|e| anyhow::anyhow!("Failed to unseal: {e:?}"))?;
let epoch = holder.epoch();
wallet_core
.storage_mut()
.user_data
.insert_group_key_holder(name.clone(), holder);
wallet_core.store_persistent_data().await?;
println!("Joined group '{name}' at epoch {epoch}");
Ok(SubcommandReturnValue::Empty)
}
Self::Ratchet { name } => {
let holder = wallet_core
.storage_mut()
.user_data
.group_key_holders
.get_mut(&name)
.context(format!("Group '{name}' not found"))?;
let mut salt = [0_u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
holder.ratchet(salt);
let epoch = holder.epoch();
wallet_core.store_persistent_data().await?;
println!("Ratcheted group '{name}' to epoch {epoch}");
println!("Re-invite remaining members with 'group invite'");
Ok(SubcommandReturnValue::Empty)
}
}
}
}

View File

@ -6,6 +6,7 @@ use nssa_core::{
SharedSecretKey,
account::{AccountWithMetadata, Nonce},
encryption::{EphemeralPublicKey, ViewingPublicKey},
program::{PdaSeed, ProgramId},
};
use crate::{ExecutionFailureKind, WalletCore};
@ -19,6 +20,16 @@ pub enum PrivacyPreservingAccount {
vpk: ViewingPublicKey,
identifier: Identifier,
},
/// A private PDA with externally-provided keys. The caller resolves the keys
/// (e.g. via `GroupKeyHolder::derive_keys_for_pda`) before constructing this variant.
/// The wallet computes the `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`.
PrivatePda {
nsk: NullifierSecretKey,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
program_id: ProgramId,
seed: PdaSeed,
},
}
impl PrivacyPreservingAccount {
@ -37,6 +48,7 @@ impl PrivacyPreservingAccount {
vpk: _,
identifier: _,
}
| Self::PrivatePda { .. }
)
}
}
@ -106,6 +118,18 @@ impl AccountManager {
epk,
};
State::Private(pre)
}
PrivacyPreservingAccount::PrivatePda {
nsk,
npk,
vpk,
program_id,
seed,
} => {
let pre =
private_pda_preparation(wallet, nsk, npk, vpk, &program_id, &seed).await?;
State::Private(pre)
}
};
@ -160,6 +184,22 @@ impl AccountManager {
.iter()
.map(|state| match state {
State::Public { .. } => InputAccountIdentity::Public,
State::Private(pre) if pre.identifier == u128::MAX => {
// Private PDA account
match (pre.nsk, pre.proof.clone()) {
(Some(nsk), Some(membership_proof)) => {
InputAccountIdentity::PrivatePdaUpdate {
ssk: pre.ssk,
nsk,
membership_proof,
}
}
_ => InputAccountIdentity::PrivatePdaInit {
npk: pre.npk,
ssk: pre.ssk,
},
}
}
State::Private(pre) => match (pre.nsk, pre.proof.clone()) {
(Some(nsk), Some(membership_proof)) => {
InputAccountIdentity::PrivateAuthorizedUpdate {
@ -261,3 +301,56 @@ async fn private_acc_preparation(
epk,
})
}
async fn private_pda_preparation(
wallet: &WalletCore,
nsk: NullifierSecretKey,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
program_id: &ProgramId,
seed: &PdaSeed,
) -> Result<AccountPreparedData, ExecutionFailureKind> {
let account_id = nssa::AccountId::for_private_pda(program_id, seed, &npk);
// Check local cache first (private PDA state is encrypted on-chain, the sequencer
// only stores commitments). Fall back to default for new PDAs.
let acc = wallet
.storage
.user_data
.pda_accounts
.get(&account_id)
.cloned()
.unwrap_or_default();
let exists = acc != nssa_core::account::Account::default();
// is_authorized tracks whether the account existed on-chain before this tx.
// NSK is only provided for existing accounts: the circuit consumes NSKs sequentially
// from an iterator and asserts none are left over, so supplying an NSK for a new
// (unauthorized) account would trigger the over-supply assertion.
let pre_state = AccountWithMetadata::new(acc, exists, account_id);
let proof = if exists {
wallet
.check_private_account_initialized(account_id)
.await
.unwrap_or(None)
} else {
None
};
let eph_holder = EphemeralKeyHolder::new(&npk);
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
let epk = eph_holder.generate_ephemeral_public_key();
Ok(AccountPreparedData {
nsk: exists.then_some(nsk),
npk,
identifier: u128::MAX,
vpk,
pre_state,
proof,
ssk,
epk,
})
}