146 lines
5.2 KiB
Rust

#![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(())
}