diff --git a/Cargo.lock b/Cargo.lock index 4fd6116e..125e57e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3600,7 +3600,6 @@ dependencies = [ "sequencer_service_rpc", "serde_json", "tempfile", - "test_program_methods", "testcontainers", "testnet_initial_state", "token_core", diff --git a/artifacts/test_program_methods/group_pda_router.bin b/artifacts/test_program_methods/group_pda_router.bin new file mode 100644 index 00000000..ad33d724 Binary files /dev/null and b/artifacts/test_program_methods/group_pda_router.bin differ diff --git a/artifacts/test_program_methods/group_pda_spender.bin b/artifacts/test_program_methods/group_pda_spender.bin index 55820394..16efb8a4 100644 Binary files a/artifacts/test_program_methods/group_pda_spender.bin and b/artifacts/test_program_methods/group_pda_spender.bin differ diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index feedf24b..cb5277d2 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -22,7 +22,6 @@ ata_core.workspace = true indexer_service_rpc.workspace = true sequencer_service_rpc = { workspace = true, features = ["client"] } wallet-ffi.workspace = true -test_program_methods.workspace = true testnet_initial_state.workspace = true url.workspace = true diff --git a/integration_tests/tests/group_pda.rs b/integration_tests/tests/group_pda.rs deleted file mode 100644 index fba54a16..00000000 --- a/integration_tests/tests/group_pda.rs +++ /dev/null @@ -1,145 +0,0 @@ -#![expect( - clippy::tests_outside_test_module, - reason = "Integration test file, not inside a #[cfg(test)] module" -)] - -//! Group-owned private PDA lifecycle integration test. -//! -//! Demonstrates: -//! 1. GMS creation and sealed distribution between controllers. -//! 2. Key agreement: both controllers derive identical keys from the shared GMS. -//! 3. Forward secrecy: ratcheting the GMS produces different keys, locking out removed members. - -use anyhow::{Context as _, Result}; -use integration_tests::TestContext; -use key_protocol::key_management::group_key_holder::GroupKeyHolder; -use log::info; -use nssa::{AccountId, program::Program}; -use nssa_core::program::PdaSeed; -use tokio::test; - -/// Group PDA lifecycle: create group, distribute GMS, verify key agreement, revoke. -#[test] -async fn group_pda_lifecycle() -> Result<()> { - let ctx = TestContext::new().await?; - - let alice_holder = GroupKeyHolder::new(); - assert_eq!(alice_holder.epoch(), 0); - let pda_seed = PdaSeed::new([42_u8; 32]); - let group_pda_spender = - Program::new(test_program_methods::GROUP_PDA_SPENDER_ELF.to_vec()).unwrap(); - - // ----------------------------------------------------------------------- - // Act 1: GMS creation and sealed distribution - // ----------------------------------------------------------------------- - - info!("Act 1: creating group and distributing GMS"); - - let alice_npk = alice_holder - .derive_keys_for_pda(&pda_seed) - .generate_nullifier_public_key(); - - let bob_private_account = ctx.existing_private_accounts()[1]; - let (bob_keychain, _) = ctx - .wallet() - .storage() - .user_data - .get_private_account(bob_private_account) - .cloned() - .context("Bob's private account not found")?; - - // Alice seals GMS for Bob, Bob unseals - let sealed = alice_holder.seal_for(&bob_keychain.viewing_public_key); - let bob_holder = - GroupKeyHolder::unseal(&sealed, &bob_keychain.private_key_holder.viewing_secret_key) - .expect("Bob should unseal the GMS"); - - // ----------------------------------------------------------------------- - // Act 2: Key agreement - // - // Both controllers independently derive identical keys for the same PDA - // seed. Neither communicated any per-PDA keys — they derived them from - // the shared GMS. - // ----------------------------------------------------------------------- - - info!("Act 2: verifying key agreement"); - - let bob_npk = bob_holder - .derive_keys_for_pda(&pda_seed) - .generate_nullifier_public_key(); - assert_eq!( - alice_npk, bob_npk, - "Key agreement: identical NPK from shared GMS" - ); - - let group_account_id = - AccountId::for_private_pda(&group_pda_spender.id(), &pda_seed, &alice_npk); - info!("Group PDA AccountId: {group_account_id}"); - - // Both derive the same AccountId independently - let bob_account_id = AccountId::for_private_pda(&group_pda_spender.id(), &pda_seed, &bob_npk); - assert_eq!(group_account_id, bob_account_id); - - info!("Act 2 complete: key agreement verified"); - - // ----------------------------------------------------------------------- - // Act 3: Revocation and forward secrecy - // - // Alice ratchets the GMS to exclude Bob. The new keys produce a different - // NPK and therefore a different AccountId. Bob's frozen holder can no - // longer derive the new keys. - // ----------------------------------------------------------------------- - - info!("Act 3: ratchet and forward secrecy"); - - let mut ratcheted_holder = alice_holder; - ratcheted_holder.ratchet([99_u8; 32]); - assert_eq!(ratcheted_holder.epoch(), 1); - - let ratcheted_npk = ratcheted_holder - .derive_keys_for_pda(&pda_seed) - .generate_nullifier_public_key(); - - let bob_stale_npk = bob_holder - .derive_keys_for_pda(&pda_seed) - .generate_nullifier_public_key(); - - // Forward secrecy: ratcheted keys differ from Bob's stale keys - assert_ne!(ratcheted_npk, bob_stale_npk); - assert_ne!(ratcheted_npk, alice_npk); - - // Different AccountId after ratchet - let new_account_id = - AccountId::for_private_pda(&group_pda_spender.id(), &pda_seed, &ratcheted_npk); - assert_ne!(group_account_id, new_account_id); - - // Bob's stale keys still point to the old address - let bob_stale_account_id = - AccountId::for_private_pda(&group_pda_spender.id(), &pda_seed, &bob_stale_npk); - assert_eq!(bob_stale_account_id, group_account_id); - assert_ne!(bob_stale_account_id, new_account_id); - - // Sealed round-trip of ratcheted GMS - let (alice_kc, _) = ctx - .wallet() - .storage() - .user_data - .get_private_account(ctx.existing_private_accounts()[0]) - .cloned() - .context("Alice's keys not found")?; - let sealed_ratcheted = ratcheted_holder.seal_for(&alice_kc.viewing_public_key); - let restored = GroupKeyHolder::unseal( - &sealed_ratcheted, - &alice_kc.private_key_holder.viewing_secret_key, - ) - .expect("Should unseal ratcheted GMS"); - assert_eq!( - restored.dangerous_raw_gms(), - ratcheted_holder.dangerous_raw_gms() - ); - assert_eq!(restored.epoch(), 1); - - info!("Act 3 complete: forward secrecy verified"); - info!("Group PDA lifecycle test complete"); - Ok(()) -} diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs index 965f36a3..34b68922 100644 --- a/key_protocol/src/key_management/group_key_holder.rs +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -598,4 +598,73 @@ mod tests { } } } + + /// Full lifecycle: create group, distribute GMS via seal/unseal, verify key + /// agreement, ratchet for forward secrecy. + #[test] + fn group_pda_lifecycle() { + use nssa_core::account::AccountId; + + let alice_holder = GroupKeyHolder::new(); + assert_eq!(alice_holder.epoch(), 0); + 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); + + // Ratchet: forward secrecy + let mut ratcheted_holder = alice_holder; + ratcheted_holder.ratchet([99_u8; 32]); + assert_eq!(ratcheted_holder.epoch(), 1); + + let ratcheted_npk = ratcheted_holder + .derive_keys_for_pda(&pda_seed) + .generate_nullifier_public_key(); + let bob_stale_npk = bob_holder + .derive_keys_for_pda(&pda_seed) + .generate_nullifier_public_key(); + + assert_ne!(ratcheted_npk, bob_stale_npk); + assert_ne!(ratcheted_npk, alice_npk); + + let new_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &ratcheted_npk); + assert_ne!(alice_account_id, new_account_id); + + // Bob's stale keys point to old address + let bob_stale_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_stale_npk); + assert_eq!(bob_stale_id, alice_account_id); + assert_ne!(bob_stale_id, new_account_id); + + // Sealed round-trip of ratcheted GMS + let sealed_ratcheted = ratcheted_holder.seal_for(&bob_vpk); + let restored = GroupKeyHolder::unseal(&sealed_ratcheted, &bob_vsk) + .expect("Should unseal ratcheted GMS"); + assert_eq!( + restored.dangerous_raw_gms(), + ratcheted_holder.dangerous_raw_gms() + ); + assert_eq!(restored.epoch(), 1); + } }