diff --git a/docs/LEZ testnet v0.1 tutorials/token-transfer.md b/docs/LEZ testnet v0.1 tutorials/token-transfer.md index 3a1ef43f..e3d04663 100644 --- a/docs/LEZ testnet v0.1 tutorials/token-transfer.md +++ b/docs/LEZ testnet v0.1 tutorials/token-transfer.md @@ -155,7 +155,7 @@ wallet account new private # Output: Generated new account with account_id Private/HacPU3hakLYzWtSqUPw6TUr8fqoMieVWovsUR6sJf7cL With npk e6366f79d026c8bd64ae6b3d601f0506832ec682ab54897f205fffe64ec0d951 -With vpk 02ddc96d0eb56e00ce14994cfdaec5ae1f76244180a919545983156e3519940a17 +With vpk <1184-byte ML-KEM-768 encapsulation key, hex-encoded> ``` > [!Tip] @@ -231,19 +231,29 @@ wallet account new private-accounts-key # Output: Generated new private accounts key at path /1 With npk 0c95ebc4b3830f53da77bb0b80a276a776cdcf6410932acc718dcdb3f788a00e -With vpk 039fd12a3674a880d3e917804129141e4170d419d1f9e28a3dcf979c1f2369cb72 +With vpk <1184-byte ML-KEM-768 encapsulation key, hex-encoded> ``` -> [!Tip] -> Ignore the account ID here and use the `npk` and `vpk` values to send to a foreign private account. +> [!Important] +> The VPK is now a 1184-byte ML-KEM-768 encapsulation key — too large to copy-paste into a command. +> The recommended workflow is: +> +> **Recipient:** export both keys to a single file and send the file to the sender (e.g. as an email attachment): +> ```bash +> wallet account show-keys --account-id Private/ > recipient.keys +> # Send recipient.keys to the sender out-of-band +> ``` +> The file contains two lines: the npk (hex) on line 1, the vpk (hex) on line 2. +> +> **Sender:** reference the received file with `--to-keys`: -### b. Send 3 tokens using the recipient’s npk and vpk +### b. Send 3 tokens using the recipient’s keys file ```bash +# The sender has received recipient.keys from the recipient out-of-band wallet auth-transfer send \ --from Public/Ev1JprP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPAWS \ - --to-npk 0c95ebc4b3830f53da77bb0b80a276a776cdcf6410932acc718dcdb3f788a00e \ - --to-vpk 039fd12a3674a880d3e917804129141e4170d419d1f9e28a3dcf979c1f2369cb72 \ + --to-keys recipient.keys \ --amount 3 ``` @@ -270,18 +280,19 @@ wallet account new private-accounts-key # Output: Generated new private accounts key at path /2 With npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 -With vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c +With vpk <1184-byte ML-KEM-768 encapsulation key, hex-encoded> ``` Alice shares the `npk` and `vpk` values with Bob and Charlie out of band. ### b. Bob sends 10 tokens to Alice using identifier 1 +Bob uses the received `alice.keys` file: + ```bash wallet auth-transfer send \ --from Public/BobXqJprP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPA \ - --to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \ - --to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \ + --to-keys alice.keys \ --to-identifier 1 \ --amount 10 ``` @@ -291,8 +302,7 @@ wallet auth-transfer send \ ```bash wallet auth-transfer send \ --from Public/CharlieYrP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPB \ - --to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \ - --to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \ + --to-keys alice.keys \ --to-identifier 2 \ --amount 5 ``` diff --git a/integration_tests/tests/amm.rs b/integration_tests/tests/amm.rs index b7a747f1..9f953001 100644 --- a/integration_tests/tests/amm.rs +++ b/integration_tests/tests/amm.rs @@ -132,6 +132,7 @@ async fn amm_public() -> Result<()> { to: Some(public_mention(recipient_account_id_1)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 7, }; @@ -158,6 +159,7 @@ async fn amm_public() -> Result<()> { to: Some(public_mention(recipient_account_id_2)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 7, }; @@ -530,6 +532,7 @@ async fn amm_new_pool_using_labels() -> Result<()> { to: Some(public_mention(holding_a_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 5, }; @@ -551,6 +554,7 @@ async fn amm_new_pool_using_labels() -> Result<()> { to: Some(public_mention(holding_b_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 5, }; diff --git a/integration_tests/tests/ata.rs b/integration_tests/tests/ata.rs index d0eddeae..37006aee 100644 --- a/integration_tests/tests/ata.rs +++ b/integration_tests/tests/ata.rs @@ -260,6 +260,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> { to: Some(public_mention(sender_ata_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: fund_amount, }), @@ -487,6 +488,7 @@ async fn transfer_via_ata_private_owner() -> Result<()> { to: Some(public_mention(sender_ata_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: fund_amount, }), @@ -598,6 +600,7 @@ async fn burn_via_ata_private_owner() -> Result<()> { to: Some(public_mention(holder_ata_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: fund_amount, }), diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index 2abb31fc..92040729 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -8,7 +8,10 @@ use integration_tests::{ }; use log::info; use nssa::{AccountId, program::Program}; -use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey}; +use nssa_core::{ + NullifierPublicKey, + encryption::{MlKem768EncapsulationKey, ViewingPublicKey}, +}; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{ @@ -32,6 +35,7 @@ async fn private_transfer_to_owned_account() -> Result<()> { to: Some(private_mention(to)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -71,7 +75,8 @@ async fn private_transfer_to_foreign_account() -> Result<()> { from: private_mention(from), to: None, to_npk: Some(to_npk_string), - to_vpk: Some(hex::encode(to_vpk.0)), + to_vpk: Some(hex::encode(to_vpk.to_bytes())), + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -121,6 +126,7 @@ async fn deshielded_transfer_to_public_account() -> Result<()> { to: Some(public_mention(to)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -183,7 +189,8 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { from: private_mention(from), to: None, to_npk: Some(hex::encode(to.key_chain.nullifier_public_key.0)), - to_vpk: Some(hex::encode(&to.key_chain.viewing_public_key.0)), + to_vpk: Some(hex::encode(to.key_chain.viewing_public_key.to_bytes())), + to_keys: None, to_identifier: Some(to.kind.identifier()), amount: 100, }); @@ -233,6 +240,7 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> { to: Some(private_mention(to)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -275,7 +283,8 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> { from: public_mention(from), to: None, to_npk: Some(to_npk_string), - to_vpk: Some(hex::encode(to_vpk.0)), + to_vpk: Some(hex::encode(to_vpk.to_bytes())), + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -345,7 +354,8 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { from: private_mention(from), to: None, to_npk: Some(hex::encode(to.key_chain.nullifier_public_key.0)), - to_vpk: Some(hex::encode(&to.key_chain.viewing_public_key.0)), + to_vpk: Some(hex::encode(to.key_chain.viewing_public_key.to_bytes())), + to_keys: None, to_identifier: Some(to.kind.identifier()), amount: 100, }); @@ -446,6 +456,7 @@ async fn private_transfer_using_from_label() -> Result<()> { to: Some(private_mention(to)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -539,7 +550,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { }; let npk_hex = hex::encode(npk.0); - let vpk_hex = hex::encode(vpk.0); + let vpk_hex = hex::encode(vpk.to_bytes()); let identifier_1 = 1_u128; let identifier_2 = 2_u128; @@ -554,6 +565,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { to: None, to_npk: Some(npk_hex.clone()), to_vpk: Some(vpk_hex.clone()), + to_keys: None, to_identifier: Some(identifier_1), amount: 100, }), @@ -567,6 +579,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { to: None, to_npk: Some(npk_hex), to_vpk: Some(vpk_hex), + to_keys: None, to_identifier: Some(identifier_2), amount: 200, }), @@ -654,7 +667,7 @@ async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> { let auth_transfer_program_id = Program::authenticated_transfer_program().id(); let nsk: nssa_core::NullifierSecretKey = [3; 32]; let npk = NullifierPublicKey::from(&nsk); - let vpk = ViewingPublicKey(vec![4_u8; 1184]); + let vpk = MlKem768EncapsulationKey::from_bytes(vec![4_u8; 1184]).unwrap(); let ssk = SharedSecretKey([55_u8; 32]); let epk = EphemeralPublicKey(vec![55_u8; 1088]); let attacker_vault_id = { diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index 72685d0b..cb8f94a0 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -25,6 +25,7 @@ async fn successful_transfer_to_existing_account() -> Result<()> { to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -83,6 +84,7 @@ pub async fn successful_transfer_to_new_account() -> Result<()> { to: Some(public_mention(new_persistent_account_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -120,6 +122,7 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> { to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 1_000_000, }); @@ -159,6 +162,7 @@ async fn two_consecutive_successful_transfers() -> Result<()> { to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -192,6 +196,7 @@ async fn two_consecutive_successful_transfers() -> Result<()> { to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -274,6 +279,7 @@ async fn successful_transfer_using_from_label() -> Result<()> { to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -319,6 +325,7 @@ async fn successful_transfer_using_to_label() -> Result<()> { to: Some(CliAccountMention::Label(label)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); diff --git a/integration_tests/tests/indexer.rs b/integration_tests/tests/indexer.rs index 5cf33cde..6e0daf1e 100644 --- a/integration_tests/tests/indexer.rs +++ b/integration_tests/tests/indexer.rs @@ -116,6 +116,7 @@ async fn indexer_state_consistency() -> Result<()> { to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -151,6 +152,7 @@ async fn indexer_state_consistency() -> Result<()> { to: Some(private_mention(to)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -234,6 +236,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> { to: Some(CliAccountMention::Label(to_label)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); diff --git a/integration_tests/tests/indexer_ffi.rs b/integration_tests/tests/indexer_ffi.rs index 178b2640..4ceaec40 100644 --- a/integration_tests/tests/indexer_ffi.rs +++ b/integration_tests/tests/indexer_ffi.rs @@ -194,6 +194,7 @@ fn indexer_ffi_state_consistency() -> Result<()> { to_npk: None, to_vpk: None, amount: 100, + to_keys: None, to_identifier: Some(0), }); @@ -233,6 +234,7 @@ fn indexer_ffi_state_consistency() -> Result<()> { to_npk: None, to_vpk: None, amount: 100, + to_keys: None, to_identifier: Some(0), }); @@ -344,6 +346,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> { to_npk: None, to_vpk: None, amount: 100, + to_keys: None, to_identifier: Some(0), }); diff --git a/integration_tests/tests/keys.rs b/integration_tests/tests/keys.rs index 0cc3c187..4c9b4521 100644 --- a/integration_tests/tests/keys.rs +++ b/integration_tests/tests/keys.rs @@ -71,7 +71,10 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { from: private_mention(from), to: None, to_npk: Some(hex::encode(to_account.key_chain.nullifier_public_key.0)), - to_vpk: Some(hex::encode(&to_account.key_chain.viewing_public_key.0)), + to_vpk: Some(hex::encode( + to_account.key_chain.viewing_public_key.to_bytes(), + )), + to_keys: None, to_identifier: Some(to_account.kind.identifier()), amount: 100, }); @@ -147,6 +150,7 @@ async fn restore_keys_from_seed() -> Result<()> { to: Some(private_mention(to_account_id1)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }); @@ -158,6 +162,7 @@ async fn restore_keys_from_seed() -> Result<()> { to: Some(private_mention(to_account_id2)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 101, }); @@ -197,6 +202,7 @@ async fn restore_keys_from_seed() -> Result<()> { to: Some(public_mention(to_account_id3)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 102, }); @@ -208,6 +214,7 @@ async fn restore_keys_from_seed() -> Result<()> { to: Some(public_mention(to_account_id4)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 103, }); @@ -268,6 +275,7 @@ async fn restore_keys_from_seed() -> Result<()> { to: Some(private_mention(to_account_id2)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 10, }); @@ -278,6 +286,7 @@ async fn restore_keys_from_seed() -> Result<()> { to: Some(public_mention(to_account_id4)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 11, }); diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index 7dbe8d2a..48207977 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -108,7 +108,9 @@ async fn group_invite_join_key_agreement() -> Result<()> { .sealing_secret_key() .context("Sealing key not found")?; let sealing_pk = key_protocol::key_management::group_key_holder::SealingPublicKey::from_bytes( - nssa_core::encryption::ViewingPublicKey::from_seed(&sealing_sk.d, &sealing_sk.z).0, + nssa_core::encryption::ViewingPublicKey::from_seed(&sealing_sk.d, &sealing_sk.z) + .to_bytes() + .to_vec(), ); let holder = ctx @@ -205,6 +207,7 @@ async fn fund_shared_account_from_public() -> Result<()> { to: Some(private_mention(shared_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: None, amount: 100, }); diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index 65011976..943ef3b9 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -133,6 +133,7 @@ async fn create_and_transfer_public_token() -> Result<()> { to: Some(public_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: transfer_amount, }; @@ -223,6 +224,7 @@ async fn create_and_transfer_public_token() -> Result<()> { holder: Some(public_mention(recipient_account_id)), holder_npk: None, holder_vpk: None, + holder_keys: None, holder_identifier: None, amount: mint_amount, }; @@ -365,6 +367,7 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { to: Some(private_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: transfer_amount, }; @@ -554,6 +557,7 @@ async fn create_token_with_private_definition() -> Result<()> { holder: Some(public_mention(recipient_account_id_public)), holder_npk: None, holder_vpk: None, + holder_keys: None, holder_identifier: None, amount: mint_amount_public, }; @@ -601,6 +605,7 @@ async fn create_token_with_private_definition() -> Result<()> { holder: Some(private_mention(recipient_account_id_private)), holder_npk: None, holder_vpk: None, + holder_keys: None, holder_identifier: None, amount: mint_amount_private, }; @@ -740,6 +745,7 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { to: Some(private_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: transfer_amount, }; @@ -868,6 +874,7 @@ async fn shielded_token_transfer() -> Result<()> { to: Some(private_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: transfer_amount, }; @@ -991,6 +998,7 @@ async fn deshielded_token_transfer() -> Result<()> { to: Some(public_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: transfer_amount, }; @@ -1124,7 +1132,8 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { definition: private_mention(definition_account_id), holder: None, holder_npk: Some(hex::encode(holder_keys.nullifier_public_key.0)), - holder_vpk: Some(hex::encode(&holder_keys.viewing_public_key.0)), + holder_vpk: Some(hex::encode(holder_keys.viewing_public_key.to_bytes())), + holder_keys: None, holder_identifier: Some(holder_identifier), amount: mint_amount, }; @@ -1323,6 +1332,7 @@ async fn transfer_token_using_from_label() -> Result<()> { to: Some(public_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: transfer_amount, }; diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs index 7bc6c9bc..656d9e36 100644 --- a/key_protocol/src/key_management/group_key_holder.rs +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -1,7 +1,7 @@ use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _}; use nssa_core::{ SharedSecretKey, - encryption::{EphemeralPublicKey, ViewingPublicKey}, + encryption::{EphemeralPublicKey, MlKem768EncapsulationKey}, program::{PdaSeed, ProgramId}, }; use rand::{RngCore as _, rngs::OsRng}; @@ -156,8 +156,9 @@ impl GroupKeyHolder { /// different ciphertexts. #[must_use] pub fn seal_for(&self, recipient_key: &SealingPublicKey) -> Vec { - let vpk = ViewingPublicKey(recipient_key.0.clone()); - let (shared, kem_ct) = SharedSecretKey::encapsulate(&vpk); + let sealing_key = MlKem768EncapsulationKey::from_bytes(recipient_key.0.clone()) + .expect("key_protocol::group_key_holder::GroupKeyHolder::seal_for: SealingPublicKey must be a valid ML-KEM-768 encapsulation key"); + let (shared, kem_ct) = SharedSecretKey::encapsulate(&sealing_key); let aes_key = Self::seal_kdf(&shared); let cipher = Aes256Gcm::new(&aes_key.into()); @@ -404,7 +405,9 @@ mod tests { let recipient_vpk = recipient_keys.generate_viewing_public_key(); let recipient_vsk = recipient_keys.viewing_secret_key; - let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0)); + let sealed = holder.seal_for(&SealingPublicKey::from_bytes( + recipient_vpk.to_bytes().to_vec(), + )); let restored = GroupKeyHolder::unseal(&sealed, &recipient_vsk).expect("unseal"); assert_eq!(restored.dangerous_raw_gms(), holder.dangerous_raw_gms()); @@ -434,7 +437,9 @@ mod tests { .produce_private_key_holder(None) .viewing_secret_key; - let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0)); + let sealed = holder.seal_for(&SealingPublicKey::from_bytes( + recipient_vpk.to_bytes().to_vec(), + )); let result = GroupKeyHolder::unseal(&sealed, &wrong_vsk); assert!(matches!(result, Err(super::SealError::DecryptionFailed))); } @@ -449,7 +454,9 @@ mod tests { let recipient_vpk = recipient_keys.generate_viewing_public_key(); let recipient_vsk = recipient_keys.viewing_secret_key; - let mut sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0)); + let mut sealed = holder.seal_for(&SealingPublicKey::from_bytes( + recipient_vpk.to_bytes().to_vec(), + )); // Flip a byte in the AES-GCM ciphertext portion (after KEM ciphertext + nonce). let last = sealed.len() - 1; sealed[last] ^= 0xFF; @@ -468,7 +475,7 @@ mod tests { .produce_private_key_holder(None) .generate_viewing_public_key(); - let sealing_key = SealingPublicKey::from_bytes(recipient_vpk.0); + let sealing_key = SealingPublicKey::from_bytes(recipient_vpk.to_bytes().to_vec()); let sealed_a = holder.seal_for(&sealing_key); let sealed_b = holder.seal_for(&sealing_key); assert_ne!(sealed_a, sealed_b); @@ -531,7 +538,8 @@ mod tests { let bob_vpk = bob_keys.generate_viewing_public_key(); let bob_vsk = bob_keys.viewing_secret_key; - let sealed = alice_holder.seal_for(&SealingPublicKey::from_bytes(bob_vpk.0)); + let sealed = + alice_holder.seal_for(&SealingPublicKey::from_bytes(bob_vpk.to_bytes().to_vec())); let bob_holder = GroupKeyHolder::unseal(&sealed, &bob_vsk).expect("Bob should unseal the GMS"); diff --git a/key_protocol/src/key_management/key_tree/keys_private.rs b/key_protocol/src/key_management/key_tree/keys_private.rs index af60be19..cb324897 100644 --- a/key_protocol/src/key_management/key_tree/keys_private.rs +++ b/key_protocol/src/key_management/key_tree/keys_private.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use nssa_core::{NullifierPublicKey, PrivateAccountKind, encryption::ViewingPublicKey}; +use nssa_core::{NullifierPublicKey, PrivateAccountKind, encryption::MlKem768EncapsulationKey}; use serde::{Deserialize, Serialize}; use sha2::Digest as _; @@ -37,7 +37,7 @@ impl ChildKeysPrivate { let vsk = ssk.generate_viewing_secret_seed_key(None); let npk = NullifierPublicKey::from(&nsk); - let vpk = ViewingPublicKey::from(&vsk); + let vpk = MlKem768EncapsulationKey::from(&vsk); Self { value: ( @@ -59,15 +59,17 @@ impl ChildKeysPrivate { #[must_use] pub fn nth_child(&self, cci: u32) -> Self { + // `parent_hash`` is used to incorporate entropy based on the parent node's keys + // to generate the `ssk` and `ccc` values. let mut parent_hash = sha2::Sha256::new(); parent_hash.update(b"LEE/keys"); - parent_hash.update([0_u8; 16]); - parent_hash.update([9_u8]); parent_hash.update(self.value.0.private_key_holder.nullifier_secret_key); parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.d); parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.z); let parent_pt = parent_hash.finalize(); + // Each child (of the same parent node) share the same `parent_pt`. + // To ensure that each child generates unique keys, we include the child index. let mut input = vec![]; input.extend_from_slice(b"LEE_seed_priv"); input.extend_from_slice(&parent_pt); @@ -89,7 +91,7 @@ impl ChildKeysPrivate { let vsk = ssk.generate_viewing_secret_seed_key(Some(cci)); let npk = NullifierPublicKey::from(&nsk); - let vpk = ViewingPublicKey::from(&vsk); + let vpk = MlKem768EncapsulationKey::from(&vsk); Self { value: ( @@ -166,16 +168,16 @@ mod tests { 247, 155, 113, 122, 246, 192, 0, 70, 61, 76, 71, 70, 2, ]); - let expected_vsk: ViewingSecretKey = ViewingSecretKey { - d: [ + let expected_vsk = ViewingSecretKey::new( + [ 187, 143, 146, 12, 68, 148, 25, 203, 21, 92, 131, 2, 221, 81, 117, 62, 98, 194, 159, 177, 102, 254, 236, 182, 76, 242, 116, 219, 17, 166, 99, 36, ], - z: [ + [ 80, 97, 83, 209, 145, 99, 168, 99, 89, 29, 153, 236, 82, 99, 134, 114, 168, 19, 223, 69, 34, 47, 76, 76, 15, 97, 245, 184, 25, 103, 251, 82, ], - }; + ); let expected_vpk: [u8; 1184] = [ 127, 229, 162, 212, 104, 117, 4, 150, 192, 103, 122, 195, 14, 35, 12, 60, 52, 23, 220, @@ -280,16 +282,16 @@ mod tests { 219, 114, 113, 16, 42, 27, 220, 96, 151, 124, 8, 65, ]); - let expected_vsk: ViewingSecretKey = ViewingSecretKey { - d: [ + let expected_vsk = ViewingSecretKey::new( + [ 81, 154, 68, 152, 72, 163, 82, 17, 125, 156, 193, 135, 129, 93, 227, 55, 224, 104, 119, 232, 13, 101, 241, 20, 175, 72, 192, 186, 176, 246, 140, 211, ], - z: [ + [ 31, 40, 109, 41, 185, 61, 173, 79, 102, 171, 158, 245, 232, 71, 57, 157, 142, 117, 184, 235, 216, 71, 55, 44, 33, 156, 167, 133, 184, 92, 47, 174, ], - }; + ); let expected_vpk: [u8; 1184] = [ 67, 150, 145, 133, 41, 124, 194, 102, 104, 131, 195, 8, 168, 170, 200, 40, 210, 84, 85, diff --git a/key_protocol/src/key_management/mod.rs b/key_protocol/src/key_management/mod.rs index 194e2b06..c3bfc829 100644 --- a/key_protocol/src/key_management/mod.rs +++ b/key_protocol/src/key_management/mod.rs @@ -106,6 +106,27 @@ mod tests { let _shared_secret = account_id_key_holder.calculate_shared_secret_receiver(&epk); } + #[test] + fn calculate_shared_secret_receiver_returns_none_for_malformed_epk() { + let key_chain = KeyChain::new_os_random(); + + let short_epk = EphemeralPublicKey(vec![42_u8; 100]); + assert!( + key_chain + .calculate_shared_secret_receiver(&short_epk) + .is_none(), + "short EphemeralPublicKey must return None" + ); + + let long_epk = EphemeralPublicKey(vec![42_u8; 1089]); + assert!( + key_chain + .calculate_shared_secret_receiver(&long_epk) + .is_none(), + "long EphemeralPublicKey must return None" + ); + } + #[test] fn key_generation_test() { let seed_holder = SeedHolder::new_os_random(); diff --git a/key_protocol/src/key_management/secret_holders.rs b/key_protocol/src/key_management/secret_holders.rs index 36a78e9d..553c939f 100644 --- a/key_protocol/src/key_management/secret_holders.rs +++ b/key_protocol/src/key_management/secret_holders.rs @@ -1,7 +1,7 @@ use bip39::Mnemonic; use common::HashType; use ml_kem; -use nssa_core::{NullifierPublicKey, NullifierSecretKey, encryption::ViewingPublicKey}; +use nssa_core::{NullifierPublicKey, NullifierSecretKey, encryption::MlKem768EncapsulationKey}; use rand::{RngCore as _, rngs::OsRng}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, digest::FixedOutput as _}; @@ -25,6 +25,13 @@ pub struct ViewingSecretKey { pub z: [u8; 32], } +impl ViewingSecretKey { + #[must_use] + pub const fn new(d: [u8; 32], z: [u8; 32]) -> Self { + Self { d, z } + } +} + /// Private key holder. Produces public keys. Can produce `account_id`. Can produce shared secret /// for recepient. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -139,22 +146,22 @@ impl SecretSpendingKey { let full_seed = hmac_sha512::HMAC::mac(bytes, b"LEE_viewing_seed"); - ViewingSecretKey { - d: *full_seed + ViewingSecretKey::new( + *full_seed .first_chunk::<32>() .expect("hash_value is 64 bytes, must be safe to get first 32"), - z: *full_seed + *full_seed .last_chunk::<32>() .expect("hash_value is 64 bytes, must be safe to get last 32"), - } + ) } #[must_use] pub const fn generate_viewing_secret_key(seed: [u8; 64]) -> ViewingSecretKey { - ViewingSecretKey { - d: *seed.first_chunk::<32>().expect("seed is 64 bytes"), - z: *seed.last_chunk::<32>().expect("seed is 64 bytes"), - } + ViewingSecretKey::new( + *seed.first_chunk::<32>().expect("seed is 64 bytes"), + *seed.last_chunk::<32>().expect("seed is 64 bytes"), + ) } #[must_use] @@ -166,14 +173,15 @@ impl SecretSpendingKey { } } -impl From<&ViewingSecretKey> for ViewingPublicKey { +impl From<&ViewingSecretKey> for MlKem768EncapsulationKey { fn from(sk: &ViewingSecretKey) -> Self { use ml_kem::{Kem, KeyExport as _, MlKem768, Seed}; let mut seed_bytes = [0_u8; 64]; seed_bytes[..32].copy_from_slice(&sk.d); seed_bytes[32..].copy_from_slice(&sk.z); let dk = ::DecapsulationKey::from_seed(Seed::from(seed_bytes)); - Self(dk.encapsulation_key().to_bytes().to_vec()) + Self::from_bytes(dk.encapsulation_key().to_bytes().to_vec()) + .expect("key_protocol::secret_holders::From<&ViewingSecretKey>: ML-KEM-768 encapsulation key is always 1184 bytes") } } @@ -184,8 +192,8 @@ impl PrivateKeyHolder { } #[must_use] - pub fn generate_viewing_public_key(&self) -> ViewingPublicKey { - ViewingPublicKey::from(&self.viewing_secret_key) + pub fn generate_viewing_public_key(&self) -> MlKem768EncapsulationKey { + MlKem768EncapsulationKey::from(&self.viewing_secret_key) } } diff --git a/nssa/Cargo.toml b/nssa/Cargo.toml index ba2de48d..c4a80784 100644 --- a/nssa/Cargo.toml +++ b/nssa/Cargo.toml @@ -30,6 +30,7 @@ risc0-build = "3.0.3" risc0-binfmt = "3.0.2" [dev-dependencies] +nssa_core = { workspace = true, features = ["test_utils"] } token_core.workspace = true authenticated_transfer_core.workspace = true test_program_methods.workspace = true diff --git a/nssa/core/Cargo.toml b/nssa/core/Cargo.toml index a1508686..0ed8f4b4 100644 --- a/nssa/core/Cargo.toml +++ b/nssa/core/Cargo.toml @@ -25,3 +25,4 @@ serde_json.workspace = true [features] default = [] host = ["dep:ml-kem"] +test_utils = ["host"] diff --git a/nssa/core/src/encryption/mod.rs b/nssa/core/src/encryption/mod.rs index 6a679f0c..7f1eadc4 100644 --- a/nssa/core/src/encryption/mod.rs +++ b/nssa/core/src/encryption/mod.rs @@ -6,7 +6,7 @@ use chacha20::{ use risc0_zkvm::sha::{Impl, Sha256 as _}; use serde::{Deserialize, Serialize}; #[cfg(feature = "host")] -pub use shared_key_derivation::{EphemeralPublicKey, ViewingPublicKey}; +pub use shared_key_derivation::{EphemeralPublicKey, MlKem768EncapsulationKey, ViewingPublicKey}; use crate::{Commitment, account::Account, program::PrivateAccountKind}; #[cfg(feature = "host")] diff --git a/nssa/core/src/encryption/shared_key_derivation.rs b/nssa/core/src/encryption/shared_key_derivation.rs index 9eb2fcc3..0e8f0f94 100644 --- a/nssa/core/src/encryption/shared_key_derivation.rs +++ b/nssa/core/src/encryption/shared_key_derivation.rs @@ -22,17 +22,32 @@ pub struct EphemeralPublicKey(pub Vec); BorshSerialize, BorshDeserialize, )] -pub struct ViewingPublicKey(pub Vec); +pub struct MlKem768EncapsulationKey(Vec); + +pub type ViewingPublicKey = MlKem768EncapsulationKey; + +impl MlKem768EncapsulationKey { + /// Expected byte length of an ML-KEM-768 encapsulation key. + pub const LEN: usize = 1184; + + /// Construct from raw bytes, returning an error if the length is not [`Self::LEN`]. + pub fn from_bytes(bytes: Vec) -> Result { + if bytes.len() != Self::LEN { + return Err(crate::error::NssaCoreError::DeserializationError(format!( + "MlKem768EncapsulationKey must be {} bytes, got {}", + Self::LEN, + bytes.len() + ))); + } + Ok(Self(bytes)) + } -impl ViewingPublicKey { #[must_use] pub fn to_bytes(&self) -> &[u8] { &self.0 } /// Derive the ML-KEM-768 encapsulation key from the FIPS 203 seed halves `d` and `z`. - /// Allows any crate to construct a VPK from raw seed bytes without importing - /// `key_protocol::ViewingSecretKey`. #[must_use] pub fn from_seed(d: &[u8; 32], z: &[u8; 32]) -> Self { let mut seed = Seed::default(); @@ -44,21 +59,21 @@ impl ViewingPublicKey { } impl SharedSecretKey { - /// Sender: encapsulate a fresh shared secret toward `vpk`. + /// Sender: encapsulate a fresh shared secret toward `ek`. /// /// Returns `(shared_secret, ciphertext)`. The ciphertext must be included in the transaction /// as the `EphemeralPublicKey`; the receiver recovers the same shared secret via - /// [`decapsulate`][Self::decapsulate]. + /// [`Self::decapsulate`]. #[must_use] - pub fn encapsulate(vpk: &ViewingPublicKey) -> (Self, EphemeralPublicKey) { - let ek_bytes: ml_kem::kem::Key = vpk - .0 - .as_slice() - .try_into() - .expect("ViewingPublicKey must be 1184 bytes (ML-KEM-768 encapsulation key)"); - let ek = ml_kem::EncapsulationKey768::new(&ek_bytes) - .expect("ViewingPublicKey bytes must encode a valid ML-KEM-768 encapsulation key"); - let (ct, ss) = ek.encapsulate(); + pub fn encapsulate(ek: &MlKem768EncapsulationKey) -> (Self, EphemeralPublicKey) { + let ek_bytes: ml_kem::kem::Key = + ek.0.as_slice() + .try_into() + .expect("MlKem768EncapsulationKey must be 1184 bytes"); + let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect( + "MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key", + ); + let (ct, ss) = ek_obj.encapsulate(); let ss_bytes: [u8; 32] = ss .as_slice() .try_into() @@ -66,15 +81,19 @@ impl SharedSecretKey { (Self(ss_bytes), EphemeralPublicKey(ct.to_vec())) } - /// Sender: deterministically encapsulate a shared secret toward `vpk`. + /// Deterministically encapsulate a shared secret toward `ek` for use in tests. /// - /// The KEM randomness is derived as `SHA-256(message_hash || output_index_le)`, - /// making the ciphertext reproducible from the same `(vpk, message_hash, output_index)` - /// triple. Use a distinct `output_index` for each private account output in the same - /// transaction to ensure per-output EPK uniqueness. + /// The shared secret has no secret entropy — it is fully determined by `ek`, + /// `message_hash`, and `output_index`, all of which are public. This makes it + /// unsuitable for real encryption but useful for producing stable, reproducible + /// shared secrets in unit tests. Use a distinct `output_index` per output to + /// avoid EPK collisions across multiple outputs in the same test. + /// + /// For production use [`Self::encapsulate`], which draws randomness from the OS. + #[cfg(any(test, feature = "test_utils"))] #[must_use] pub fn encapsulate_deterministic( - vpk: &ViewingPublicKey, + ek: &MlKem768EncapsulationKey, message_hash: &[u8; 32], output_index: u32, ) -> (Self, EphemeralPublicKey) { @@ -87,14 +106,14 @@ impl SharedSecretKey { let m: ml_kem::B32 = ml_kem::array::Array::try_from(hash.as_bytes()).expect("SHA-256 output is 32 bytes"); - let ek_bytes: ml_kem::kem::Key = vpk - .0 - .as_slice() - .try_into() - .expect("ViewingPublicKey must be 1184 bytes (ML-KEM-768 encapsulation key)"); - let ek = ml_kem::EncapsulationKey768::new(&ek_bytes) - .expect("ViewingPublicKey bytes must encode a valid ML-KEM-768 encapsulation key"); - let (ct, ss) = ek.encapsulate_deterministic(&m); + let ek_bytes: ml_kem::kem::Key = + ek.0.as_slice() + .try_into() + .expect("MlKem768EncapsulationKey must be 1184 bytes"); + let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect( + "MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key", + ); + let (ct, ss) = ek_obj.encapsulate_deterministic(&m); let ss_bytes: [u8; 32] = ss .as_slice() .try_into() @@ -109,7 +128,11 @@ impl SharedSecretKey { /// /// `d` and `z` are the two 32-byte halves of the FIPS 203 `ViewingSecretKey` seed. #[must_use] - pub fn decapsulate(ciphertext: &EphemeralPublicKey, d: &[u8; 32], z: &[u8; 32]) -> Option { + pub fn decapsulate( + ciphertext: &EphemeralPublicKey, + d: &[u8; 32], + z: &[u8; 32], + ) -> Option { let mut seed = Seed::default(); seed[..32].copy_from_slice(d); seed[32..].copy_from_slice(z); @@ -140,15 +163,15 @@ mod tests { let dk = ml_kem::DecapsulationKey768::from_seed(seed); let ek_bytes = dk.encapsulation_key().to_bytes(); - let vpk = ViewingPublicKey(ek_bytes.to_vec()); + let ek = MlKem768EncapsulationKey(ek_bytes.to_vec()); - let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk); + let (sender_ss, epk) = SharedSecretKey::encapsulate(&ek); let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap(); assert_eq!(sender_ss.0, receiver_ss.0, "shared secrets must match"); assert_eq!(epk.0.len(), 1088, "ML-KEM-768 ciphertext is 1088 bytes"); assert_eq!( - vpk.0.len(), + ek.0.len(), 1184, "ML-KEM-768 encapsulation key is 1184 bytes" ); @@ -186,23 +209,23 @@ mod tests { let (d1, z1) = ([1_u8; 32], [2_u8; 32]); let (d2, z2) = ([3_u8; 32], [4_u8; 32]); - let vpk1 = { + let ek1 = { let mut seed = Seed::default(); seed[..32].copy_from_slice(&d1); seed[32..].copy_from_slice(&z1); let dk = ml_kem::DecapsulationKey768::from_seed(seed); - ViewingPublicKey(dk.encapsulation_key().to_bytes().to_vec()) + MlKem768EncapsulationKey(dk.encapsulation_key().to_bytes().to_vec()) }; - let vpk2 = { + let ek2 = { let mut seed = Seed::default(); seed[..32].copy_from_slice(&d2); seed[32..].copy_from_slice(&z2); let dk = ml_kem::DecapsulationKey768::from_seed(seed); - ViewingPublicKey(dk.encapsulation_key().to_bytes().to_vec()) + MlKem768EncapsulationKey(dk.encapsulation_key().to_bytes().to_vec()) }; - let (ss1, _) = SharedSecretKey::encapsulate(&vpk1); - let (ss2, _) = SharedSecretKey::encapsulate(&vpk2); + let (ss1, _) = SharedSecretKey::encapsulate(&ek1); + let (ss2, _) = SharedSecretKey::encapsulate(&ek2); assert_ne!(ss1.0, ss2.0); } diff --git a/testnet_initial_state/src/lib.rs b/testnet_initial_state/src/lib.rs index 5b3ed377..eac658fd 100644 --- a/testnet_initial_state/src/lib.rs +++ b/testnet_initial_state/src/lib.rs @@ -136,10 +136,7 @@ pub fn initial_priv_accounts_private_keys() -> Vec Vec Result { to: Some(public_mention(recipient_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: AMOUNT_PER_TRANSFER, }), diff --git a/tools/integration_bench/src/scenarios/parallel.rs b/tools/integration_bench/src/scenarios/parallel.rs index c6a265b9..da908bde 100644 --- a/tools/integration_bench/src/scenarios/parallel.rs +++ b/tools/integration_bench/src/scenarios/parallel.rs @@ -69,6 +69,7 @@ pub async fn run(ctx: &mut TestContext) -> Result { to: Some(public_mention(sender_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: AMOUNT_PER_TRANSFER * 5, }), @@ -97,6 +98,7 @@ pub async fn run(ctx: &mut TestContext) -> Result { to: Some(public_mention(*recipient_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: AMOUNT_PER_TRANSFER, }), diff --git a/tools/integration_bench/src/scenarios/private.rs b/tools/integration_bench/src/scenarios/private.rs index 2be8c43c..e8293870 100644 --- a/tools/integration_bench/src/scenarios/private.rs +++ b/tools/integration_bench/src/scenarios/private.rs @@ -46,6 +46,7 @@ pub async fn run(ctx: &mut TestContext) -> Result { to: Some(private_mention(private_a)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 1_000, }), @@ -64,6 +65,7 @@ pub async fn run(ctx: &mut TestContext) -> Result { to: Some(public_mention(public_recipient_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 100, }), @@ -82,6 +84,7 @@ pub async fn run(ctx: &mut TestContext) -> Result { to: Some(private_mention(private_b)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 200, }), diff --git a/tools/integration_bench/src/scenarios/token.rs b/tools/integration_bench/src/scenarios/token.rs index d1dfdef3..bd628b86 100644 --- a/tools/integration_bench/src/scenarios/token.rs +++ b/tools/integration_bench/src/scenarios/token.rs @@ -41,6 +41,7 @@ pub async fn run(ctx: &mut TestContext) -> Result { to: Some(public_mention(recipient_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 1_000, }), @@ -61,6 +62,7 @@ pub async fn run(ctx: &mut TestContext) -> Result { to: Some(private_mention(private_recipient_id)), to_npk: None, to_vpk: None, + to_keys: None, to_identifier: Some(0), amount: 500, }), diff --git a/wallet-ffi/src/types.rs b/wallet-ffi/src/types.rs index 45b746f1..554f04b7 100644 --- a/wallet-ffi/src/types.rs +++ b/wallet-ffi/src/types.rs @@ -164,7 +164,11 @@ impl FfiPrivateAccountKeys { let slice = unsafe { slice::from_raw_parts(self.viewing_public_key, self.viewing_public_key_len) }; - Ok(nssa_core::encryption::ViewingPublicKey(slice.to_vec())) + Ok( + nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(slice.to_vec()).expect( + "wallet_ffi::types::FfiPrivateAccountKeys::vpk: length already validated above", + ), + ) } else { Err(WalletFfiError::InvalidKeyValue) } diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index 1dcea1d5..eec9440d 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -51,6 +51,20 @@ pub enum AccountSubcommand { /// Import external account. #[command(subcommand)] Import(ImportSubcommand), + /// Print the npk and vpk for a private account, one per line. + /// + /// Outputs two lines: npk (hex) then vpk (hex). Save to a file and share it + /// with senders so they can reference it with `--to-keys /path/to/file`. + /// + /// ```text + /// wallet account show-keys --account-id Private/... > alice.keys + /// ``` + #[command(name = "show-keys")] + ShowKeys { + /// Either 32 byte base58 account id string with privacy prefix or a label. + #[arg(long)] + account_id: CliAccountMention, + }, } /// Represents generic register CLI subcommand. @@ -225,7 +239,7 @@ impl WalletSubcommand for NewSubcommand { println!("Shared account from group '{group}'"); println!("AccountId: Private/{}", info.account_id); println!("NPK: {}", hex::encode(info.npk.0)); - println!("VPK: {}", hex::encode(&info.vpk.0)); + println!("VPK: {}", hex::encode(info.vpk.to_bytes())); wallet_core.store_persistent_data()?; Ok(SubcommandReturnValue::RegisterAccount { @@ -419,6 +433,25 @@ impl WalletSubcommand for AccountSubcommand { Self::Import(import_subcommand) => { import_subcommand.handle_subcommand(wallet_core).await } + Self::ShowKeys { account_id } => { + let resolved = account_id.resolve(wallet_core.storage())?; + let AccountIdWithPrivacy::Private(account_id) = resolved else { + anyhow::bail!( + "wallet::cli::account::AccountSubcommand::ShowKeys: show-keys is only available for private accounts" + ); + }; + let entry = wallet_core + .storage() + .key_chain() + .private_account(account_id) + .ok_or_else(|| anyhow::anyhow!("wallet::cli::account::AccountSubcommand::ShowKeys: private account not found in wallet"))?; + println!("{}", hex::encode(entry.key_chain.nullifier_public_key.0)); + println!( + "{}", + hex::encode(entry.key_chain.viewing_public_key.to_bytes()) + ); + Ok(SubcommandReturnValue::Empty) + } } } } diff --git a/wallet/src/cli/group.rs b/wallet/src/cli/group.rs index 60d0f512..d6621ee7 100644 --- a/wallet/src/cli/group.rs +++ b/wallet/src/cli/group.rs @@ -156,8 +156,10 @@ impl WalletSubcommand for GroupSubcommand { let mut z = [0_u8; 32]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut d); rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut z); - let secret = ViewingSecretKey { d, z }; - let ek_bytes = nssa_core::encryption::ViewingPublicKey::from_seed(&d, &z).0; + let secret = ViewingSecretKey::new(d, z); + let ek_bytes = nssa_core::encryption::ViewingPublicKey::from_seed(&d, &z) + .to_bytes() + .to_vec(); let public_key = SealingPublicKey::from_bytes(ek_bytes); wallet_core.set_sealing_secret_key(secret); diff --git a/wallet/src/cli/mod.rs b/wallet/src/cli/mod.rs index 22e8333f..4f7c2a53 100644 --- a/wallet/src/cli/mod.rs +++ b/wallet/src/cli/mod.rs @@ -256,6 +256,31 @@ pub fn read_password_from_stdin() -> Result { Ok(password.trim().to_owned()) } +/// Parse a keys file exported by `wallet account show-keys`. +/// +/// The file format is two lines: +/// - Line 1: npk as hex (64 chars, 32 bytes). +/// - Line 2: vpk as hex (2368 chars, 1184 bytes). +/// +/// Returns `(npk_bytes, vpk_bytes)`. +pub fn read_keys_file(path: &str) -> Result<(Vec, Vec)> { + let content = std::fs::read_to_string(path).with_context(|| { + format!("wallet::cli::read_keys_file: failed to read keys file: {path}") + })?; + let mut lines = content.lines().filter(|l| !l.trim().is_empty()); + let npk_hex = lines.next().ok_or_else(|| { + anyhow::anyhow!("wallet::cli::read_keys_file: keys file is missing npk (line 1)") + })?; + let vpk_hex = lines.next().ok_or_else(|| { + anyhow::anyhow!("wallet::cli::read_keys_file: keys file is missing vpk (line 2)") + })?; + let npk = hex::decode(npk_hex.trim()) + .context("wallet::cli::read_keys_file: npk in keys file must be valid hex")?; + let vpk = hex::decode(vpk_hex.trim()) + .context("wallet::cli::read_keys_file: vpk in keys file must be valid hex")?; + Ok((npk, vpk)) +} + pub fn read_mnemonic_from_stdin() -> Result { let mut phrase = String::new(); @@ -299,3 +324,77 @@ pub async fn execute_keys_restoration(wallet_core: &mut WalletCore, depth: u32) Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_keys_file_roundtrip() { + let npk = [0xab_u8; 32]; + let vpk = [0xcd_u8; 1184]; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.keys"); + + // Simulate what `wallet account show-keys` writes. + std::fs::write( + &path, + format!("{}\n{}\n", hex::encode(npk), hex::encode(vpk)), + ) + .unwrap(); + + let (parsed_npk, parsed_vpk) = read_keys_file(path.to_str().unwrap()).unwrap(); + + assert_eq!(parsed_npk, npk, "npk must round-trip through the keys file"); + assert_eq!( + parsed_vpk, + vpk.to_vec(), + "vpk must round-trip through the keys file" + ); + } + + #[test] + fn read_keys_file_missing_vpk_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("incomplete.keys"); + std::fs::write(&path, format!("{}\n", hex::encode([0xab_u8; 32]))).unwrap(); + + let result = read_keys_file(path.to_str().unwrap()); + assert!(result.is_err(), "missing vpk line must return an error"); + assert!( + result.unwrap_err().to_string().contains("missing vpk"), + "error must mention missing vpk" + ); + } + + #[test] + fn read_keys_file_invalid_hex_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("badhex.keys"); + std::fs::write(&path, "not-hex\nalso-not-hex\n").unwrap(); + + let result = read_keys_file(path.to_str().unwrap()); + assert!(result.is_err(), "invalid hex must return an error"); + } + + #[test] + fn read_keys_file_ignores_blank_lines() { + let npk = [0x11_u8; 32]; + let vpk = [0x22_u8; 1184]; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("blanks.keys"); + + // Extra blank lines around the data should be tolerated. + std::fs::write( + &path, + format!("\n{}\n\n{}\n\n", hex::encode(npk), hex::encode(vpk)), + ) + .unwrap(); + + let (parsed_npk, parsed_vpk) = read_keys_file(path.to_str().unwrap()).unwrap(); + assert_eq!(parsed_npk, npk); + assert_eq!(parsed_vpk, vpk.to_vec()); + } +} diff --git a/wallet/src/cli/programs/native_token_transfer.rs b/wallet/src/cli/programs/native_token_transfer.rs index bde4e0a4..e184d9ff 100644 --- a/wallet/src/cli/programs/native_token_transfer.rs +++ b/wallet/src/cli/programs/native_token_transfer.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context as _, Result}; use clap::Subcommand; use common::transaction::NSSATransaction; use nssa::AccountId; @@ -34,13 +34,17 @@ pub enum AuthTransferSubcommand { #[arg(long)] to: Option, /// `to_npk` - valid 32 byte hex string. - #[arg(long)] + #[arg(long, conflicts_with = "to_keys")] to_npk: Option, - /// `to_vpk` - valid 33 byte hex string. - #[arg(long)] + /// `to_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes). + #[arg(long, conflicts_with = "to_keys")] to_vpk: Option, + /// Path to a keys file exported by `wallet account show-keys`, containing npk + /// and vpk on separate lines. Replaces `--to-npk` and `--to-vpk`. + #[arg(long, conflicts_with_all = ["to_npk", "to_vpk"])] + to_keys: Option, /// Identifier for the recipient's private account (only used when sending to a foreign - /// private account via `--to-npk`/`--to-vpk`). + /// private account via `--to-npk`/`--to-vpk` or `--to-keys`). #[arg(long)] to_identifier: Option, /// amount - amount of balance to move. @@ -100,9 +104,18 @@ impl WalletSubcommand for AuthTransferSubcommand { to, to_npk, to_vpk, + to_keys, to_identifier, amount, } => { + // Resolve --to-keys into --to-npk / --to-vpk equivalents. + let (to_npk, to_vpk) = if let Some(path) = to_keys { + let (npk_bytes, vpk_bytes) = crate::cli::read_keys_file(&path)?; + (Some(hex::encode(npk_bytes)), Some(hex::encode(vpk_bytes))) + } else { + (to_npk, to_vpk) + }; + let from = from.resolve(wallet_core.storage())?; let to = to .map(|account_mention| account_mention.resolve(wallet_core.storage())) @@ -247,7 +260,7 @@ pub enum NativeTokenTransferProgramSubcommandShielded { /// `to_npk` - valid 32 byte hex string. #[arg(long)] to_npk: String, - /// `to_vpk` - valid 33 byte hex string. + /// `to_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes). #[arg(long)] to_vpk: String, /// Identifier for the recipient's private account. @@ -287,7 +300,7 @@ pub enum NativeTokenTransferProgramSubcommandPrivate { /// `to_npk` - valid 32 byte hex string. #[arg(long)] to_npk: String, - /// `to_vpk` - valid 33 byte hex string. + /// `to_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes). #[arg(long)] to_vpk: String, /// Identifier for the recipient's private account. @@ -339,8 +352,11 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommandPrivate { to_npk.copy_from_slice(&to_npk_res); let to_npk = nssa_core::NullifierPublicKey(to_npk); - let to_vpk_res = hex::decode(to_vpk)?; - let to_vpk = nssa_core::encryption::ViewingPublicKey(to_vpk_res); + let to_vpk_res = hex::decode(&to_vpk) + .context("wallet::cli::programs::native_token_transfer: to_vpk must be a valid hex string")?; + let to_vpk = + nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(to_vpk_res) + .map_err(|e| anyhow::anyhow!("{e}"))?; let (tx_hash, [secret_from, _]) = NativeTokenTransfer(wallet_core) .send_private_transfer_to_outer_account( @@ -413,8 +429,11 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommandShielded { to_npk.copy_from_slice(&to_npk_res); let to_npk = nssa_core::NullifierPublicKey(to_npk); - let to_vpk_res = hex::decode(to_vpk)?; - let to_vpk = nssa_core::encryption::ViewingPublicKey(to_vpk_res); + let to_vpk_res = hex::decode(&to_vpk) + .context("wallet::cli::programs::native_token_transfer: to_vpk must be a valid hex string")?; + let to_vpk = + nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(to_vpk_res) + .map_err(|e| anyhow::anyhow!("{e}"))?; let (tx_hash, _) = NativeTokenTransfer(wallet_core) .send_shielded_transfer_to_outer_account( diff --git a/wallet/src/cli/programs/token.rs b/wallet/src/cli/programs/token.rs index 27478dd3..19d19648 100644 --- a/wallet/src/cli/programs/token.rs +++ b/wallet/src/cli/programs/token.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context as _, Result}; use clap::Subcommand; use common::transaction::NSSATransaction; use nssa::AccountId; @@ -41,13 +41,17 @@ pub enum TokenProgramAgnosticSubcommand { #[arg(long)] to: Option, /// `to_npk` - valid 32 byte hex string. - #[arg(long)] + #[arg(long, conflicts_with = "to_keys")] to_npk: Option, - /// `to_vpk` - valid 33 byte hex string. - #[arg(long)] + /// `to_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes). + #[arg(long, conflicts_with = "to_keys")] to_vpk: Option, + /// Path to a keys file exported by `wallet account show-keys`, containing npk + /// and vpk on separate lines. Replaces `--to-npk` and `--to-vpk`. + #[arg(long, conflicts_with_all = ["to_npk", "to_vpk"])] + to_keys: Option, /// Identifier for the recipient's private account (only used when sending to a foreign - /// private account via `--to-npk`/`--to-vpk`). + /// private account via `--to-npk`/`--to-vpk` or `--to-keys`). #[arg(long)] to_identifier: Option, /// amount - amount of balance to move. @@ -87,13 +91,17 @@ pub enum TokenProgramAgnosticSubcommand { #[arg(long)] holder: Option, /// `holder_npk` - valid 32 byte hex string. - #[arg(long)] + #[arg(long, conflicts_with = "holder_keys")] holder_npk: Option, - /// `to_vpk` - valid 33 byte hex string. - #[arg(long)] + /// `holder_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes). + #[arg(long, conflicts_with = "holder_keys")] holder_vpk: Option, + /// Path to a keys file exported by `wallet account show-keys`, containing npk + /// and vpk on separate lines. Replaces `--holder-npk` and `--holder-vpk`. + #[arg(long, conflicts_with_all = ["holder_npk", "holder_vpk"])] + holder_keys: Option, /// Identifier for the holder's private account (only used when minting to a foreign - /// private account via `--holder-npk`/`--holder-vpk`). + /// private account via `--holder-npk`/`--holder-vpk` or `--holder-keys`). #[arg(long)] holder_identifier: Option, /// amount - amount of balance to mint. @@ -170,9 +178,17 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { to, to_npk, to_vpk, + to_keys, to_identifier, amount, } => { + let (to_npk, to_vpk) = if let Some(path) = to_keys { + let (npk_bytes, vpk_bytes) = crate::cli::read_keys_file(&path)?; + (Some(hex::encode(npk_bytes)), Some(hex::encode(vpk_bytes))) + } else { + (to_npk, to_vpk) + }; + let from = from.resolve(wallet_core.storage())?; let to = to .map(|account_mention| account_mention.resolve(wallet_core.storage())) @@ -309,9 +325,17 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand { holder, holder_npk, holder_vpk, + holder_keys, holder_identifier, amount, } => { + let (holder_npk, holder_vpk) = if let Some(path) = holder_keys { + let (npk_bytes, vpk_bytes) = crate::cli::read_keys_file(&path)?; + (Some(hex::encode(npk_bytes)), Some(hex::encode(vpk_bytes))) + } else { + (holder_npk, holder_vpk) + }; + let definition = definition.resolve(wallet_core.storage())?; let holder = holder .map(|account_mention| account_mention.resolve(wallet_core.storage())) @@ -475,7 +499,7 @@ pub enum TokenProgramSubcommandPrivate { /// `recipient_npk` - valid 32 byte hex string. #[arg(long)] recipient_npk: String, - /// `recipient_vpk` - valid 33 byte hex string. + /// `recipient_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes). #[arg(long)] recipient_vpk: String, /// Identifier for the recipient's private account. @@ -569,7 +593,7 @@ pub enum TokenProgramSubcommandShielded { /// `recipient_npk` - valid 32 byte hex string. #[arg(long)] recipient_npk: String, - /// `recipient_vpk` - valid 33 byte hex string. + /// `recipient_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes). #[arg(long)] recipient_vpk: String, /// Identifier for the recipient's private account. @@ -764,8 +788,12 @@ impl WalletSubcommand for TokenProgramSubcommandPrivate { recipient_npk.copy_from_slice(&recipient_npk_res); let recipient_npk = nssa_core::NullifierPublicKey(recipient_npk); - let recipient_vpk_res = hex::decode(recipient_vpk)?; - let recipient_vpk = nssa_core::encryption::ViewingPublicKey(recipient_vpk_res); + let recipient_vpk_res = hex::decode(&recipient_vpk).context( + "wallet::cli::programs::token: recipient_vpk must be a valid hex string", + )?; + let recipient_vpk = + nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(recipient_vpk_res) + .map_err(|e| anyhow::anyhow!("{e}"))?; let (tx_hash, [secret_sender, _]) = Token(wallet_core) .send_transfer_transaction_private_foreign_account( @@ -872,8 +900,12 @@ impl WalletSubcommand for TokenProgramSubcommandPrivate { holder_npk.copy_from_slice(&holder_npk_res); let holder_npk = nssa_core::NullifierPublicKey(holder_npk); - let holder_vpk_res = hex::decode(holder_vpk)?; - let holder_vpk = nssa_core::encryption::ViewingPublicKey(holder_vpk_res); + let holder_vpk_res = hex::decode(&holder_vpk).context( + "wallet::cli::programs::token: holder_vpk must be a valid hex string", + )?; + let holder_vpk = + nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(holder_vpk_res) + .map_err(|e| anyhow::anyhow!("{e}"))?; let (tx_hash, [secret_definition, _]) = Token(wallet_core) .send_mint_transaction_private_foreign_account( @@ -1024,8 +1056,12 @@ impl WalletSubcommand for TokenProgramSubcommandShielded { recipient_npk.copy_from_slice(&recipient_npk_res); let recipient_npk = nssa_core::NullifierPublicKey(recipient_npk); - let recipient_vpk_res = hex::decode(recipient_vpk)?; - let recipient_vpk = nssa_core::encryption::ViewingPublicKey(recipient_vpk_res); + let recipient_vpk_res = hex::decode(&recipient_vpk).context( + "wallet::cli::programs::token: recipient_vpk must be a valid hex string", + )?; + let recipient_vpk = + nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(recipient_vpk_res) + .map_err(|e| anyhow::anyhow!("{e}"))?; let (tx_hash, _) = Token(wallet_core) .send_transfer_transaction_shielded_foreign_account( @@ -1151,8 +1187,12 @@ impl WalletSubcommand for TokenProgramSubcommandShielded { holder_npk.copy_from_slice(&holder_npk_res); let holder_npk = nssa_core::NullifierPublicKey(holder_npk); - let holder_vpk_res = hex::decode(holder_vpk)?; - let holder_vpk = nssa_core::encryption::ViewingPublicKey(holder_vpk_res); + let holder_vpk_res = hex::decode(&holder_vpk).context( + "wallet::cli::programs::token: holder_vpk must be a valid hex string", + )?; + let holder_vpk = + nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(holder_vpk_res) + .map_err(|e| anyhow::anyhow!("{e}"))?; let (tx_hash, _) = Token(wallet_core) .send_mint_transaction_shielded_foreign_account( diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 1fc87af1..3144001f 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -684,8 +684,8 @@ impl WalletCore { .filter_map(move |(ciph_id, encrypted_data)| { let ciphertext = &encrypted_data.ciphertext; let commitment = &new_commitments[ciph_id]; - let shared_secret = key_chain - .calculate_shared_secret_receiver(&encrypted_data.epk)?; + let shared_secret = + key_chain.calculate_shared_secret_receiver(&encrypted_data.epk)?; nssa_core::EncryptionScheme::decrypt( ciphertext,