addressed comments

This commit is contained in:
Marvin Jones 2026-05-29 19:54:53 -04:00
parent 738dfc0cc4
commit f4315d1832
31 changed files with 469 additions and 140 deletions

View File

@ -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/<account-id> > 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 recipients npk and vpk
### b. Send 3 tokens using the recipients 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
```

View File

@ -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,
};

View File

@ -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,
}),

View File

@ -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 = {

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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),
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
};

View File

@ -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<u8> {
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");

View File

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

View File

@ -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();

View File

@ -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 = <MlKem768 as Kem>::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)
}
}

View File

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

View File

@ -25,3 +25,4 @@ serde_json.workspace = true
[features]
default = []
host = ["dep:ml-kem"]
test_utils = ["host"]

View File

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

View File

@ -22,17 +22,32 @@ pub struct EphemeralPublicKey(pub Vec<u8>);
BorshSerialize,
BorshDeserialize,
)]
pub struct ViewingPublicKey(pub Vec<u8>);
pub struct MlKem768EncapsulationKey(Vec<u8>);
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<u8>) -> Result<Self, crate::error::NssaCoreError> {
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<ml_kem::EncapsulationKey768> = 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<ml_kem::EncapsulationKey768> =
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<ml_kem::EncapsulationKey768> = 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<ml_kem::EncapsulationKey768> =
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<Self> {
pub fn decapsulate(
ciphertext: &EphemeralPublicKey,
d: &[u8; 32],
z: &[u8; 32],
) -> Option<Self> {
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);
}

View File

@ -136,10 +136,7 @@ pub fn initial_priv_accounts_private_keys() -> Vec<PrivateAccountPrivateInitialD
secret_spending_key: SecretSpendingKey(SSK_PRIV_ACC_A),
private_key_holder: PrivateKeyHolder {
nullifier_secret_key: NSK_PRIV_ACC_A,
viewing_secret_key: ViewingSecretKey {
d: VSK_D_PRIV_ACC_A,
z: VSK_Z_PRIV_ACC_A,
},
viewing_secret_key: ViewingSecretKey::new(VSK_D_PRIV_ACC_A, VSK_Z_PRIV_ACC_A),
},
nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_A),
viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_A, &VSK_Z_PRIV_ACC_A),
@ -149,10 +146,7 @@ pub fn initial_priv_accounts_private_keys() -> Vec<PrivateAccountPrivateInitialD
secret_spending_key: SecretSpendingKey(SSK_PRIV_ACC_B),
private_key_holder: PrivateKeyHolder {
nullifier_secret_key: NSK_PRIV_ACC_B,
viewing_secret_key: ViewingSecretKey {
d: VSK_D_PRIV_ACC_B,
z: VSK_Z_PRIV_ACC_B,
},
viewing_secret_key: ViewingSecretKey::new(VSK_D_PRIV_ACC_B, VSK_Z_PRIV_ACC_B),
},
nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_B),
viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_B, &VSK_Z_PRIV_ACC_B),

View File

@ -180,6 +180,7 @@ async fn timed_token_send(
to: Some(public_mention(to_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount,
}),

View File

@ -50,6 +50,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
to: Some(public_mention(recipient_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: AMOUNT_PER_TRANSFER,
}),

View File

@ -69,6 +69,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
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<ScenarioOutput> {
to: Some(public_mention(*recipient_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: AMOUNT_PER_TRANSFER,
}),

View File

@ -46,6 +46,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
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<ScenarioOutput> {
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<ScenarioOutput> {
to: Some(private_mention(private_b)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 200,
}),

View File

@ -41,6 +41,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
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<ScenarioOutput> {
to: Some(private_mention(private_recipient_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 500,
}),

View File

@ -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)
}

View File

@ -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)
}
}
}
}

View File

@ -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);

View File

@ -256,6 +256,31 @@ pub fn read_password_from_stdin() -> Result<String> {
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<u8>, Vec<u8>)> {
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<Mnemonic> {
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());
}
}

View File

@ -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<CliAccountMention>,
/// `to_npk` - valid 32 byte hex string.
#[arg(long)]
#[arg(long, conflicts_with = "to_keys")]
to_npk: Option<String>,
/// `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<String>,
/// 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<String>,
/// 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<u128>,
/// 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(

View File

@ -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<CliAccountMention>,
/// `to_npk` - valid 32 byte hex string.
#[arg(long)]
#[arg(long, conflicts_with = "to_keys")]
to_npk: Option<String>,
/// `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<String>,
/// 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<String>,
/// 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<u128>,
/// amount - amount of balance to move.
@ -87,13 +91,17 @@ pub enum TokenProgramAgnosticSubcommand {
#[arg(long)]
holder: Option<CliAccountMention>,
/// `holder_npk` - valid 32 byte hex string.
#[arg(long)]
#[arg(long, conflicts_with = "holder_keys")]
holder_npk: Option<String>,
/// `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<String>,
/// 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<String>,
/// 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<u128>,
/// 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(

View File

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