diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index 1abab24b..e17c35a7 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -21,6 +21,15 @@ pub struct UserPrivateAccountData { pub accounts: Vec<(Identifier, Account)>, } +/// Metadata for a shared account (GMS-derived), stored alongside the cached plaintext state. +/// The group label and identifier are needed to re-derive keys during sync. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SharedAccountEntry { + pub group_label: String, + pub identifier: Identifier, + pub account: Account, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct NSSAUserData { /// Default public accounts. @@ -37,11 +46,12 @@ pub struct NSSAUserData { #[serde(default)] pub group_key_holders: BTreeMap, /// Cached plaintext state of shared accounts (PDAs and regular shared accounts), - /// keyed by `AccountId`. Updated after each transaction by decrypting the circuit output. - /// The sequencer only stores encrypted commitments, so this local cache is the - /// only source of plaintext state for these accounts. - #[serde(default, alias = "group_pda_accounts", alias = "pda_accounts")] - pub shared_accounts: BTreeMap, + /// keyed by `AccountId`. Each entry stores the group label and identifier needed + /// to re-derive keys during sync. + /// Old wallet files with `pda_accounts` (plain Account values) are incompatible with + /// this type. The `default` attribute ensures they deserialize as empty rather than failing. + #[serde(default)] + pub shared_accounts: BTreeMap, } impl NSSAUserData { diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index b5e80854..1355eb69 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -83,14 +83,27 @@ pub enum NewSubcommand { label: Option, }, /// Single-account convenience: creates a key node and auto-registers one account with a random - /// identifier. + /// identifier. When `--for-gms` is provided, derives keys from the named group instead of + /// the wallet's key tree. Private { #[arg(long)] - /// Chain index of a parent node. + /// Chain index of a parent node (ignored when --for-gms is used). cci: Option, #[arg(short, long)] /// Label to assign to the new account. label: Option, + #[arg(long)] + /// Derive keys from a group's GMS instead of the wallet tree. + for_gms: Option, + #[arg(long, requires = "for_gms")] + /// Create a PDA account (requires --seed and --program-id). + pda: bool, + #[arg(long, requires = "pda")] + /// PDA seed as 64-character hex string. + seed: Option, + #[arg(long, requires = "pda")] + /// Program ID as hex string. + program_id: Option, }, /// Recommended for receiving from multiple senders: creates a key node (npk + vpk) without /// registering any account. @@ -144,7 +157,14 @@ impl WalletSubcommand for NewSubcommand { Ok(SubcommandReturnValue::RegisterAccount { account_id }) } - Self::Private { cci, label } => { + Self::Private { + cci, + label, + for_gms, + pda, + seed, + program_id, + } => { if let Some(label) = &label && wallet_core .storage @@ -155,36 +175,132 @@ impl WalletSubcommand for NewSubcommand { anyhow::bail!("Label '{label}' is already in use by another account"); } - let (account_id, chain_index) = wallet_core.create_new_account_private(cci); + if let Some(group_name) = for_gms { + // GMS-derived account + let holder = wallet_core + .storage() + .user_data + .group_key_holder(&group_name) + .context(format!("Group '{group_name}' not found"))?; - let node = wallet_core - .storage - .user_data - .private_key_tree - .key_map - .get(&chain_index) - .expect("Node was just inserted"); - let key = &node.value.0; + if pda { + // PDA shared account + let seed_hex = seed.context("--seed is required for PDA accounts")?; + let pid_hex = + program_id.context("--program-id is required for PDA accounts")?; - if let Some(label) = label { - wallet_core + let seed_bytes: [u8; 32] = hex::decode(&seed_hex) + .context("Invalid seed hex")? + .try_into() + .map_err(|_err| anyhow::anyhow!("Seed must be exactly 32 bytes"))?; + let pda_seed = nssa_core::program::PdaSeed::new(seed_bytes); + + let pid_bytes = hex::decode(&pid_hex).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 = nssa::AccountId::for_private_pda(&pid, &pda_seed, &npk); + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + wallet_core.register_shared_account( + account_id, + group_name.clone(), + u128::MAX, + ); + + println!("PDA shared account from group '{group_name}'"); + println!("AccountId: {account_id}"); + println!("NPK: {}", hex::encode(npk.0)); + println!("VPK: {}", hex::encode(&vpk.0)); + + wallet_core.store_persistent_data().await?; + Ok(SubcommandReturnValue::RegisterAccount { account_id }) + } else { + // Regular shared account. The tag is derived deterministically + // from the identifier so that keys can be re-derived without + // storing the tag separately. + let identifier: nssa_core::Identifier = rand::random(); + let tag = { + use sha2::Digest as _; + let mut hasher = sha2::Sha256::new(); + hasher.update(b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00"); + hasher.update(identifier.to_le_bytes()); + let result: [u8; 32] = hasher.finalize().into(); + result + }; + + let keys = holder.derive_keys_for_shared_account(&tag); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let account_id = nssa::AccountId::from((&npk, identifier)); + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + wallet_core.register_shared_account( + account_id, + group_name.clone(), + identifier, + ); + + println!("Shared account from group '{group_name}'"); + println!("AccountId: Private/{account_id}"); + println!("NPK: {}", hex::encode(npk.0)); + println!("VPK: {}", hex::encode(&vpk.0)); + + wallet_core.store_persistent_data().await?; + Ok(SubcommandReturnValue::RegisterAccount { account_id }) + } + } else { + // Standard wallet-tree-derived account + let (account_id, chain_index) = wallet_core.create_new_account_private(cci); + + let node = wallet_core .storage - .labels - .insert(account_id.to_string(), Label::new(label)); + .user_data + .private_key_tree + .key_map + .get(&chain_index) + .expect("Node was just inserted"); + let key = &node.value.0; + + if let Some(label) = label { + wallet_core + .storage + .labels + .insert(account_id.to_string(), Label::new(label)); + } + + println!( + "Generated new account with account_id Private/{account_id} at path {chain_index}" + ); + println!("With npk {}", hex::encode(key.nullifier_public_key.0)); + println!( + "With vpk {}", + hex::encode(key.viewing_public_key.to_bytes()) + ); + + wallet_core.store_persistent_data().await?; + Ok(SubcommandReturnValue::RegisterAccount { account_id }) } - - println!( - "Generated new account with account_id Private/{account_id} at path {chain_index}" - ); - println!("With npk {}", hex::encode(key.nullifier_public_key.0)); - println!( - "With vpk {}", - hex::encode(key.viewing_public_key.to_bytes()) - ); - - wallet_core.store_persistent_data().await?; - - Ok(SubcommandReturnValue::RegisterAccount { account_id }) } Self::PrivateAccountsKey { cci } => { let chain_index = wallet_core.create_private_accounts_key(cci); diff --git a/wallet/src/cli/group.rs b/wallet/src/cli/group.rs index 5cdcc0af..0a1d8d54 100644 --- a/wallet/src/cli/group.rs +++ b/wallet/src/cli/group.rs @@ -1,15 +1,13 @@ 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. +/// Group key management commands. #[derive(Subcommand, Debug, Clone)] pub enum GroupSubcommand { /// Create a new group with a fresh random GMS. @@ -24,29 +22,15 @@ pub enum GroupSubcommand { /// 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. + /// List all groups. #[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. @@ -56,9 +40,9 @@ pub enum GroupSubcommand { Invite { /// Group name. name: String, - /// Recipient's viewing public key as hex string. + /// Recipient's sealing public key as hex string. #[arg(long)] - vpk: String, + key: String, }, /// Unseal a received GMS and store it (join a group). Join { @@ -67,15 +51,10 @@ pub enum GroupSubcommand { /// Sealed GMS as hex string (from the inviter). #[arg(long)] sealed: String, - /// Account label or Private/ whose VSK to use for decryption. + /// Account ID whose viewing secret key to use for decryption. #[arg(long)] account: String, }, - /// Ratchet the GMS to exclude removed members. - Ratchet { - /// Group name. - name: String, - }, } impl WalletSubcommand for GroupSubcommand { @@ -88,28 +67,25 @@ impl WalletSubcommand for GroupSubcommand { if wallet_core .storage() .user_data - .get_group_key_holder(&name) + .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.insert_group_key_holder(name.clone(), holder); wallet_core.store_persistent_data().await?; - println!("Created group '{name}' at epoch 0"); + println!("Created group '{name}'"); Ok(SubcommandReturnValue::Empty) } - Self::Import { name, gms, epoch } => { + Self::Import { name, gms } => { if wallet_core .storage() .user_data - .get_group_key_holder(&name) + .group_key_holder(&name) .is_some() { anyhow::bail!("Group '{name}' already exists"); @@ -118,16 +94,13 @@ impl WalletSubcommand for GroupSubcommand { let gms_bytes: [u8; 32] = hex::decode(&gms) .context("Invalid GMS hex")? .try_into() - .map_err(|_| anyhow::anyhow!("GMS must be exactly 32 bytes"))?; + .map_err(|_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); + let holder = GroupKeyHolder::from_gms(gms_bytes); + wallet_core.insert_group_key_holder(name.clone(), holder); wallet_core.store_persistent_data().await?; - println!("Imported group '{name}' at epoch {epoch}"); + println!("Imported group '{name}'"); Ok(SubcommandReturnValue::Empty) } @@ -135,14 +108,12 @@ impl WalletSubcommand for GroupSubcommand { let holder = wallet_core .storage() .user_data - .get_group_key_holder(&name) + .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) } @@ -152,60 +123,15 @@ impl WalletSubcommand for GroupSubcommand { if holders.is_empty() { println!("No groups found"); } else { - for (name, holder) in holders { - println!("{name} (epoch {})", holder.epoch()); + for name in holders.keys() { + println!("{name}"); } } 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() - { + if wallet_core.remove_group_key_holder(&name).is_none() { anyhow::bail!("Group '{name}' not found"); } @@ -214,18 +140,18 @@ impl WalletSubcommand for GroupSubcommand { Ok(SubcommandReturnValue::Empty) } - Self::Invite { name, vpk } => { + Self::Invite { name, key } => { let holder = wallet_core .storage() .user_data - .get_group_key_holder(&name) + .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 key_bytes = hex::decode(&key).context("Invalid key hex")?; + let recipient_key = + nssa_core::encryption::shared_key_derivation::Secp256k1Point(key_bytes); - let sealed = holder.seal_for(&recipient_vpk); + let sealed = holder.seal_for(&recipient_key); println!("{}", hex::encode(&sealed)); Ok(SubcommandReturnValue::Empty) } @@ -238,7 +164,7 @@ impl WalletSubcommand for GroupSubcommand { if wallet_core .storage() .user_data - .get_group_key_holder(&name) + .group_key_holder(&name) .is_some() { anyhow::bail!("Group '{name}' already exists"); @@ -246,11 +172,8 @@ impl WalletSubcommand for GroupSubcommand { 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/)")?; - let (keychain, _) = wallet_core + let account_id: nssa::AccountId = account.parse().context("Invalid account ID")?; + let (keychain, _, _) = wallet_core .storage() .user_data .get_private_account(account_id) @@ -260,34 +183,10 @@ impl WalletSubcommand for GroupSubcommand { 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.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'"); + println!("Joined group '{name}'"); Ok(SubcommandReturnValue::Empty) } } diff --git a/wallet/src/cli/mod.rs b/wallet/src/cli/mod.rs index 1653e938..09cc1799 100644 --- a/wallet/src/cli/mod.rs +++ b/wallet/src/cli/mod.rs @@ -14,6 +14,7 @@ use crate::{ account::AccountSubcommand, chain::ChainSubcommand, config::ConfigSubcommand, + group::GroupSubcommand, programs::{ amm::AmmProgramAgnosticSubcommand, ata::AtaSubcommand, native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand, @@ -25,6 +26,7 @@ use crate::{ pub mod account; pub mod chain; pub mod config; +pub mod group; pub mod programs; pub(crate) trait WalletSubcommand { @@ -57,6 +59,9 @@ pub enum Command { /// Associated Token Account program interaction subcommand. #[command(subcommand)] Ata(AtaSubcommand), + /// Group key management (create, invite, join, derive keys). + #[command(subcommand)] + Group(GroupSubcommand), /// Check the wallet can connect to the node and builtin local programs /// match the remote versions. CheckHealth, @@ -164,6 +169,7 @@ pub async fn execute_subcommand( Command::Token(token_subcommand) => token_subcommand.handle_subcommand(wallet_core).await?, Command::AMM(amm_subcommand) => amm_subcommand.handle_subcommand(wallet_core).await?, Command::Ata(ata_subcommand) => ata_subcommand.handle_subcommand(wallet_core).await?, + Command::Group(group_subcommand) => group_subcommand.handle_subcommand(wallet_core).await?, Command::Config(config_subcommand) => { config_subcommand.handle_subcommand(wallet_core).await? } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index c8244ef9..7a293139 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -287,6 +287,41 @@ impl WalletCore { (account_id, cci) } + /// Insert a group key holder into storage. + pub fn insert_group_key_holder( + &mut self, + name: String, + holder: key_protocol::key_management::group_key_holder::GroupKeyHolder, + ) { + self.storage.user_data.insert_group_key_holder(name, holder); + } + + /// Remove a group key holder from storage. Returns the removed holder if it existed. + pub fn remove_group_key_holder( + &mut self, + name: &str, + ) -> Option { + self.storage.user_data.group_key_holders.remove(name) + } + + /// Register a shared account in storage for sync tracking. + pub fn register_shared_account( + &mut self, + account_id: AccountId, + group_label: String, + identifier: nssa_core::Identifier, + ) { + use key_protocol::key_protocol_core::SharedAccountEntry; + self.storage.user_data.shared_accounts.insert( + account_id, + SharedAccountEntry { + group_label, + identifier, + account: Account::default(), + }, + ); + } + /// Get account balance. pub async fn get_account_balance(&self, acc: AccountId) -> Result { Ok(self.sequencer_client.get_account_balance(acc).await?) diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index ba1a6a73..dfac8180 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -337,7 +337,7 @@ async fn private_pda_preparation( .user_data .shared_accounts .get(&account_id) - .cloned() + .map(|e| e.account.clone()) .unwrap_or_default(); let exists = acc != nssa_core::account::Account::default(); @@ -388,7 +388,7 @@ async fn private_shared_preparation( .user_data .shared_accounts .get(&account_id) - .cloned() + .map(|e| e.account.clone()) .unwrap_or_default(); let exists = acc != nssa_core::account::Account::default();