diff --git a/CHANGELOG.md b/CHANGELOG.md index f81aea52d..b53f7b636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ * Incremented the limits for various RPC calls to accommodate larger data sets ([#1621](https://github.com/0xMiden/miden-client/pull/1621)). * [BREAKING] Introduced named storage slots, changed `FilesystemKeystore` to not be generic over RNG ([#1626](https://github.com/0xMiden/miden-client/pull/1626)). * Added `submit_new_transaction_with_prover` to the Rust client and `submitNewTransactionWithProver` to the WebClient([#1622](https://github.com/0xMiden/miden-client/pull/1622)). +* [BREAKING] Added E2E encryption for private notes to the client ([#1636](https://github.com/0xMiden/miden-client/pull/1636)). ## 0.12.5 (2025-12-01) diff --git a/Cargo.lock b/Cargo.lock index 3205b4a8a..0d820829a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2650,6 +2650,7 @@ dependencies = [ name = "miden-client-web" version = "0.13.0" dependencies = [ + "async-trait", "console_error_panic_hook", "hex", "js-sys", diff --git a/bin/integration-tests/src/tests/transport.rs b/bin/integration-tests/src/tests/transport.rs index 53cd5bbd6..d7e5a1b29 100644 --- a/bin/integration-tests/src/tests/transport.rs +++ b/bin/integration-tests/src/tests/transport.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use anyhow::{Context, Result}; use miden_client::account::AccountStorageMode; -use miden_client::address::{Address, AddressInterface, RoutingParameters}; use miden_client::asset::FungibleAsset; use miden_client::auth::{AuthSchemeId, RPO_FALCON_SCHEME_ID}; use miden_client::note::NoteType; @@ -159,10 +158,16 @@ async fn run_flow( .await .context("failed to insert faucet in sender")?; - // Build recipient address - let recipient_address = Address::new(recipient_account.id()) - .with_routing_parameters(RoutingParameters::new(AddressInterface::BasicWallet)) - .context("failed to build recipient address")?; + // Get a recipient's address + let recipient_addresses = recipient + .test_store() + .get_addresses_by_account_id(recipient_account.id()) + .await + .context("failed to get recipient addresses")?; + let recipient_address = recipient_addresses + .first() + .context("recipient should have a default address (with encryption key)")? + .clone(); // Ensure recipient has no input notes recipient.sync_state().await.context("recipient initial sync")?; diff --git a/bin/miden-cli/src/lib.rs b/bin/miden-cli/src/lib.rs index edeac016c..5b3343b4b 100644 --- a/bin/miden-cli/src/lib.rs +++ b/bin/miden-cli/src/lib.rs @@ -186,10 +186,15 @@ impl Cli { let keystore = FilesystemKeyStore::new(cli_config.secret_keys_directory.clone()) .map_err(CliError::KeyStore)?; + // Create encryption keystore (shares directory with auth keystore) + let encryption_keystore = FilesystemKeyStore::new(cli_config.secret_keys_directory.clone()) + .map_err(CliError::KeyStore)?; + let mut builder = ClientBuilder::new() .sqlite_store(cli_config.store_filepath.clone()) .grpc_client(&cli_config.rpc.endpoint.clone().into(), Some(cli_config.rpc.timeout_ms)) .authenticator(Arc::new(keystore.clone())) + .encryption_keystore(Arc::new(encryption_keystore)) .in_debug_mode(in_debug_mode) .tx_graceful_blocks(Some(TX_GRACEFUL_BLOCK_DELTA)); diff --git a/bin/miden-cli/tests/cli.rs b/bin/miden-cli/tests/cli.rs index 677f967a1..1e6e20624 100644 --- a/bin/miden-cli/tests/cli.rs +++ b/bin/miden-cli/tests/cli.rs @@ -749,8 +749,7 @@ async fn list_addresses_add() -> Result<()> { assert!(output.status.success()); let formatted_output = String::from_utf8(output.stdout).unwrap(); assert!(formatted_output.contains(&basic_account_id)); - assert!(formatted_output.contains("Unspecified")); - assert!(!formatted_output.contains("BasicWallet")); + assert!(formatted_output.contains("BasicWallet")); // Add a basic wallet address to the account let mut add_address_cmd = cargo_bin_cmd!("miden-client"); @@ -765,14 +764,13 @@ async fn list_addresses_add() -> Result<()> { let output = add_address_cmd.current_dir(temp_dir.clone()).output().unwrap(); assert!(output.status.success()); - // List of addresses for created account should now contain a BasicWallet address + // List of addresses for created account should now contain two BasicWallet addresses sync_cli(&temp_dir); let output = list_addresses_cmd.current_dir(temp_dir.clone()).output().unwrap(); assert!(output.status.success()); let formatted_output = String::from_utf8(output.stdout).unwrap(); assert!(formatted_output.contains(&basic_account_id)); - assert_eq!(formatted_output.matches("Unspecified").count(), 1); - assert_eq!(formatted_output.matches("BasicWallet").count(), 1); + assert_eq!(formatted_output.matches("BasicWallet").count(), 2); // Add another basic wallet address to the account let mut add_address_cmd = cargo_bin_cmd!("miden-client"); @@ -787,14 +785,13 @@ async fn list_addresses_add() -> Result<()> { let output = add_address_cmd.current_dir(temp_dir.clone()).output().unwrap(); assert!(output.status.success()); - // List of addresses for created account should now contain two BasicWallet addresses + // List of addresses for created account should now contain three BasicWallet addresses sync_cli(&temp_dir); let output = list_addresses_cmd.current_dir(temp_dir.clone()).output().unwrap(); assert!(output.status.success()); let formatted_output = String::from_utf8(output.stdout).unwrap(); assert!(formatted_output.contains(&basic_account_id)); - assert_eq!(formatted_output.matches("Unspecified").count(), 1); - assert_eq!(formatted_output.matches("BasicWallet").count(), 2); + assert_eq!(formatted_output.matches("BasicWallet").count(), 3); Ok(()) } @@ -808,33 +805,33 @@ async fn list_addresses_remove() -> Result<()> { sync_cli(&temp_dir); - // List of addresses for created account should contain an Unspecified address + // List of addresses for created account should contain a BasicWallet address let mut list_addresses_cmd = cargo_bin_cmd!("miden-client"); list_addresses_cmd.args(["address", "list", &basic_account_id]); let output = list_addresses_cmd.current_dir(temp_dir.clone()).output().unwrap(); assert!(output.status.success()); let formatted_output = String::from_utf8(output.stdout).unwrap(); assert!(formatted_output.contains(&basic_account_id)); - assert_eq!(formatted_output.matches("Unspecified").count(), 1); + assert_eq!(formatted_output.matches("BasicWallet").count(), 1); - // Remove the Unspecified wallet from the account + // Remove the BasicWallet address from the account let mut remove_address_cmd = cargo_bin_cmd!("miden-client"); - let unspecified_wallet_address = regex::Regex::new(r"mlcl1[0-9a-z]+") + let wallet_address = regex::Regex::new(r"mlcl1[0-9a-z_]+") .unwrap() .find(&formatted_output) .unwrap() .as_str(); - remove_address_cmd.args(["address", "remove", &basic_account_id, unspecified_wallet_address]); + remove_address_cmd.args(["address", "remove", &basic_account_id, wallet_address]); let output = remove_address_cmd.current_dir(temp_dir.clone()).output().unwrap(); assert!(output.status.success()); - // List of addresses for created account should now contain one BasicWallet address + // List of addresses for created account should now be empty sync_cli(&temp_dir); let output = list_addresses_cmd.current_dir(temp_dir.clone()).output().unwrap(); assert!(output.status.success()); let formatted_output = String::from_utf8(output.stdout).unwrap(); assert!(formatted_output.contains(&basic_account_id)); - assert_eq!(formatted_output.matches("Unspecified").count(), 0); + assert_eq!(formatted_output.matches("BasicWallet").count(), 0); Ok(()) } @@ -1079,12 +1076,15 @@ async fn create_rust_client_with_store_path( let keystore = FilesystemKeyStore::new(temp_dir())?; + let encryption_keystore = Arc::new(keystore.clone()); + Ok(( TestClient::new( Arc::new(GrpcClient::new(&endpoint, 10_000)), rng, store, Some(std::sync::Arc::new(keystore.clone())), + Some(encryption_keystore), ExecutionOptions::new( Some(MAX_TX_EXECUTION_CYCLES), MIN_TX_EXECUTION_CYCLES, diff --git a/crates/idxdb-store/src/auth.rs b/crates/idxdb-store/src/auth.rs index 993159e73..ae33afd4c 100644 --- a/crates/idxdb-store/src/auth.rs +++ b/crates/idxdb-store/src/auth.rs @@ -46,3 +46,41 @@ pub async fn get_account_auth_by_pub_key(pub_key: String) -> Result Err(JsValue::from_str(&format!("Pub key {pub_key} not found in the store"))), } } + +// ENCRYPTION KEY STORAGE +// ================================================================================================ + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptionKeyIdxdbObject { + pub key: String, +} + +#[wasm_bindgen(module = "/src/js/accounts.js")] +extern "C" { + #[wasm_bindgen(js_name = insertEncryptionKey)] + pub fn idxdb_insert_encryption_key(address_hash: String, key: String) -> js_sys::Promise; + + #[wasm_bindgen(js_name = getEncryptionKeyByAddressHash)] + pub fn idxdb_get_encryption_key_by_address_hash(address_hash: String) -> js_sys::Promise; +} + +pub async fn insert_encryption_key(address_hash: String, key: String) -> Result<(), JsValue> { + let promise = idxdb_insert_encryption_key(address_hash, key); + JsFuture::from(promise).await?; + Ok(()) +} + +pub async fn get_encryption_key_by_address_hash( + address_hash: String, +) -> Result, JsValue> { + let promise = idxdb_get_encryption_key_by_address_hash(address_hash.clone()); + let js_key = JsFuture::from(promise).await?; + + let encryption_key_idxdb: Option = + from_value(js_key).map_err(|err| { + JsValue::from_str(&format!("Error: failed to deserialize encryption key: {err}")) + })?; + + Ok(encryption_key_idxdb.map(|k| k.key)) +} diff --git a/crates/idxdb-store/src/encryption.rs b/crates/idxdb-store/src/encryption.rs new file mode 100644 index 000000000..75c4e295d --- /dev/null +++ b/crates/idxdb-store/src/encryption.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; +use serde_wasm_bindgen::from_value; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen_futures::{JsFuture, js_sys}; + +// WEB ENCRYPTION KEYSTORE HELPER +// ================================================================================================ + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptionKeyIdxdbObject { + pub key: String, +} + +#[wasm_bindgen(module = "/src/js/accounts.js")] +extern "C" { + #[wasm_bindgen(js_name = insertEncryptionKey)] + pub fn idxdb_insert_encryption_key(address_hash: String, key_hex: String) -> js_sys::Promise; + + #[wasm_bindgen(js_name = getEncryptionKeyByAddressHash)] + pub fn idxdb_get_encryption_key(address_hash: String) -> js_sys::Promise; +} + +pub async fn insert_encryption_key(address_hash: String, key_hex: String) -> Result<(), JsValue> { + let promise = idxdb_insert_encryption_key(address_hash, key_hex); + JsFuture::from(promise).await?; + + Ok(()) +} + +pub async fn get_encryption_key(address_hash: String) -> Result, JsValue> { + let promise = idxdb_get_encryption_key(address_hash); + let js_key = JsFuture::from(promise).await?; + + let encryption_key_idxdb: Option = + from_value(js_key).map_err(|err| { + JsValue::from_str(&format!("Error: failed to deserialize encryption key: {err}")) + })?; + + Ok(encryption_key_idxdb.map(|k| k.key)) +} diff --git a/crates/idxdb-store/src/js/accounts.js b/crates/idxdb-store/src/js/accounts.js index 917e261ba..8162816c6 100644 --- a/crates/idxdb-store/src/js/accounts.js +++ b/crates/idxdb-store/src/js/accounts.js @@ -1,4 +1,4 @@ -import { accountCodes, accountStorages, accountAssets, accountAuths, accounts, addresses, foreignAccountCode, storageMapEntries, trackedAccounts, } from "./schema.js"; +import { accountCodes, accountStorages, accountAssets, accountAuths, accounts, addresses, foreignAccountCode, storageMapEntries, trackedAccounts, encryptionKeys, } from "./schema.js"; import { logWebStoreError, uint8ArrayToBase64 } from "./utils.js"; // GET FUNCTIONS export async function getAccountIds() { @@ -422,3 +422,32 @@ export async function undoAccountStates(accountCommitments) { logWebStoreError(error, `Error undoing account states: ${accountCommitments.join(",")}`); } } +// ENCRYPTION KEY FUNCTIONS +export async function insertEncryptionKey(addressHash, key) { + try { + const data = { + addressHash: addressHash, + key: key, + }; + await encryptionKeys.put(data); + } + catch (error) { + logWebStoreError(error, `Error inserting encryption key for address hash: ${addressHash}`); + } +} +export async function getEncryptionKeyByAddressHash(addressHash) { + try { + const encryptionKey = await encryptionKeys + .where("addressHash") + .equals(addressHash) + .first(); + if (!encryptionKey) { + return null; + } + return { key: encryptionKey.key }; + } + catch (error) { + logWebStoreError(error, `Error getting encryption key for address hash: ${addressHash}`); + return null; + } +} diff --git a/crates/idxdb-store/src/js/schema.js b/crates/idxdb-store/src/js/schema.js index 0f87437bb..8edf5f644 100644 --- a/crates/idxdb-store/src/js/schema.js +++ b/crates/idxdb-store/src/js/schema.js @@ -39,6 +39,7 @@ var Table; Table["ForeignAccountCode"] = "foreignAccountCode"; Table["Settings"] = "settings"; Table["TrackedAccounts"] = "trackedAccounts"; + Table["EncryptionKeys"] = "encryptionKeys"; })(Table || (Table = {})); const db = new Dexie(DATABASE_NAME); db.version(1).stores({ @@ -61,6 +62,7 @@ db.version(1).stores({ [Table.ForeignAccountCode]: indexes("accountId"), [Table.Settings]: indexes("key"), [Table.TrackedAccounts]: indexes("&id"), + [Table.EncryptionKeys]: indexes("&addressHash"), }); function indexes(...items) { return items.join(","); @@ -90,6 +92,7 @@ const tags = db.table(Table.Tags); const foreignAccountCode = db.table(Table.ForeignAccountCode); const settings = db.table(Table.Settings); const trackedAccounts = db.table(Table.TrackedAccounts); +const encryptionKeys = db.table(Table.EncryptionKeys); async function ensureClientVersion(clientVersion) { if (!clientVersion) { console.warn("openDatabase called without a client version; skipping version enforcement."); @@ -137,4 +140,4 @@ async function persistClientVersion(clientVersion) { value: textEncoder.encode(clientVersion), }); } -export { db, accountCodes, accountStorages, storageMapEntries, accountAssets, accountAuths, accounts, addresses, transactions, transactionScripts, inputNotes, outputNotes, notesScripts, stateSync, blockHeaders, partialBlockchainNodes, tags, foreignAccountCode, settings, trackedAccounts, }; +export { db, accountCodes, accountStorages, storageMapEntries, accountAssets, accountAuths, accounts, addresses, transactions, transactionScripts, inputNotes, outputNotes, notesScripts, stateSync, blockHeaders, partialBlockchainNodes, tags, foreignAccountCode, settings, trackedAccounts, encryptionKeys, }; diff --git a/crates/idxdb-store/src/lib.rs b/crates/idxdb-store/src/lib.rs index 75fa6c08a..6121d57a4 100644 --- a/crates/idxdb-store/src/lib.rs +++ b/crates/idxdb-store/src/lib.rs @@ -50,6 +50,7 @@ use wasm_bindgen_futures::{JsFuture, js_sys}; pub mod account; pub mod auth; pub mod chain_data; +pub mod encryption; pub mod export; pub mod import; pub mod note; diff --git a/crates/idxdb-store/src/ts/accounts.ts b/crates/idxdb-store/src/ts/accounts.ts index 696cef45b..8a1814d93 100644 --- a/crates/idxdb-store/src/ts/accounts.ts +++ b/crates/idxdb-store/src/ts/accounts.ts @@ -13,6 +13,7 @@ import { storageMapEntries, IStorageMapEntry, trackedAccounts, + encryptionKeys, } from "./schema.js"; import { JsStorageMapEntry, JsStorageSlot, JsVaultAsset } from "./sync.js"; import { logWebStoreError, uint8ArrayToBase64 } from "./utils.js"; @@ -538,3 +539,40 @@ export async function undoAccountStates(accountCommitments: string[]) { ); } } + +// ENCRYPTION KEY FUNCTIONS +export async function insertEncryptionKey(addressHash: string, key: string) { + try { + const data = { + addressHash: addressHash, + key: key, + }; + await encryptionKeys.put(data); + } catch (error) { + logWebStoreError( + error, + `Error inserting encryption key for address hash: ${addressHash}` + ); + } +} + +export async function getEncryptionKeyByAddressHash(addressHash: string) { + try { + const encryptionKey = await encryptionKeys + .where("addressHash") + .equals(addressHash) + .first(); + + if (!encryptionKey) { + return null; + } + + return { key: encryptionKey.key }; + } catch (error) { + logWebStoreError( + error, + `Error getting encryption key for address hash: ${addressHash}` + ); + return null; + } +} diff --git a/crates/idxdb-store/src/ts/schema.ts b/crates/idxdb-store/src/ts/schema.ts index 11c64665c..92a05c3eb 100644 --- a/crates/idxdb-store/src/ts/schema.ts +++ b/crates/idxdb-store/src/ts/schema.ts @@ -40,6 +40,7 @@ enum Table { ForeignAccountCode = "foreignAccountCode", Settings = "settings", TrackedAccounts = "trackedAccounts", + EncryptionKeys = "encryptionKeys", } export interface IAccountCode { @@ -169,6 +170,11 @@ export interface ITrackedAccount { id: string; } +export interface IEncryptionKey { + addressHash: string; + key: string; +} + const db = new Dexie(DATABASE_NAME) as Dexie & { accountCodes: Dexie.Table; accountStorages: Dexie.Table; @@ -189,6 +195,7 @@ const db = new Dexie(DATABASE_NAME) as Dexie & { foreignAccountCode: Dexie.Table; settings: Dexie.Table; trackedAccounts: Dexie.Table; + encryptionKeys: Dexie.Table; }; db.version(1).stores({ @@ -223,6 +230,7 @@ db.version(1).stores({ [Table.ForeignAccountCode]: indexes("accountId"), [Table.Settings]: indexes("key"), [Table.TrackedAccounts]: indexes("&id"), + [Table.EncryptionKeys]: indexes("&addressHash"), }); function indexes(...items: string[]): string { @@ -265,6 +273,7 @@ const settings = db.table(Table.Settings); const trackedAccounts = db.table( Table.TrackedAccounts ); +const encryptionKeys = db.table(Table.EncryptionKeys); async function ensureClientVersion(clientVersion: string): Promise { if (!clientVersion) { @@ -347,4 +356,5 @@ export { foreignAccountCode, settings, trackedAccounts, + encryptionKeys, }; diff --git a/crates/rust-client/src/account/mod.rs b/crates/rust-client/src/account/mod.rs index 77eda1be2..eee038101 100644 --- a/crates/rust-client/src/account/mod.rs +++ b/crates/rust-client/src/account/mod.rs @@ -39,6 +39,9 @@ use alloc::vec::Vec; use miden_lib::account::auth::{AuthEcdsaK256Keccak, AuthRpoFalcon512}; use miden_lib::account::wallets::BasicWallet; use miden_objects::account::auth::PublicKey; +use miden_objects::address::RoutingParameters; +use miden_objects::crypto::dsa::eddsa_25519::SecretKey; +use miden_objects::crypto::ies::{SealingKey, UnsealingKey}; use miden_objects::note::NoteTag; // RE-EXPORTS // ================================================================================================ @@ -172,7 +175,40 @@ impl Client { match tracked_account { None => { - let default_address = Address::new(account.id()); + // Generate encryption key pair and create default address with public key + let default_address = if let Some(ref keystore) = self.encryption_keystore { + // Generate encryption X25519 key pair + let mut rng = rand::rng(); + let secret_key = SecretKey::with_rng(&mut rng); + let public_key = secret_key.public_key(); + + let sealing_key = SealingKey::X25519XChaCha20Poly1305(public_key); + let unsealing_key = UnsealingKey::X25519XChaCha20Poly1305(secret_key); + + // Create address with encryption key + let address = Address::new(account.id()) + .with_routing_parameters( + RoutingParameters::new(AddressInterface::BasicWallet) + .with_encryption_key(sealing_key), + ) + .map_err(|e| { + ClientError::ClientInitializationError(format!( + "Failed to create address with encryption key: {e}" + )) + })?; + + // Store key in keystore by address + keystore.add_encryption_key(&address, &unsealing_key).await.map_err(|e| { + ClientError::ClientInitializationError(format!( + "Failed to store encryption key: {e}" + )) + })?; + + address + } else { + // No keystore - use plain address + Address::new(account.id()) + }; // If the account is not being tracked, insert it into the store regardless of the // `overwrite` flag @@ -289,6 +325,14 @@ impl Client { Ok(()) } + /// Returns all addresses associated with the given [`AccountId`]. + /// + /// # Errors + /// - If there is an issue retrieving addresses from the store. + pub async fn get_addresses(&self, account_id: AccountId) -> Result, ClientError> { + Ok(self.store.get_addresses_by_account_id(account_id).await?) + } + // ACCOUNT DATA RETRIEVAL // -------------------------------------------------------------------------------------------- diff --git a/crates/rust-client/src/builder.rs b/crates/rust-client/src/builder.rs index 79d0590f3..3f15fb034 100644 --- a/crates/rust-client/src/builder.rs +++ b/crates/rust-client/src/builder.rs @@ -8,7 +8,7 @@ use miden_tx::ExecutionOptions; use miden_tx::auth::TransactionAuthenticator; use rand::Rng; -use crate::keystore::FilesystemKeyStore; +use crate::keystore::{EncryptionKeyStore, FilesystemKeyStore}; use crate::note_transport::NoteTransportClient; use crate::rpc::NodeRpcClient; use crate::store::{Store, StoreError}; @@ -36,6 +36,16 @@ enum AuthenticatorConfig { Instance(Arc), } +/// Represents the configuration for an encryption keystore. +/// +/// This enum defers encryption keystore instantiation until the build phase. +enum EncryptionKeystoreConfig { + /// Use a filesystem keystore at the given path. + Path(String), + /// Use a custom encryption keystore instance. + Instance(Arc), +} + // STORE BUILDER // ================================================================================================ @@ -70,6 +80,8 @@ pub struct ClientBuilder { rng: Option>, /// The keystore configuration provided by the user. keystore: Option>, + /// The encryption keystore configuration. + encryption_keystore: Option, /// A flag to enable debug mode. in_debug_mode: DebugMode, /// The number of blocks that are considered old enough to discard pending transactions. If @@ -91,6 +103,7 @@ impl Default for ClientBuilder { store: None, rng: None, keystore: None, + encryption_keystore: None, in_debug_mode: DebugMode::Disabled, tx_graceful_blocks: Some(TX_GRACEFUL_BLOCKS), max_block_number_delta: None, @@ -154,6 +167,16 @@ where self } + /// Optionally provide a custom encryption keystore instance. + #[must_use] + pub fn encryption_keystore(mut self, keystore: Arc) -> Self + where + ENC: EncryptionKeyStore + Send + Sync + 'static, + { + self.encryption_keystore = Some(EncryptionKeystoreConfig::Instance(keystore)); + self + } + /// Optionally set a maximum number of blocks that the client can be behind the network. /// By default, there's no maximum. #[must_use] @@ -175,9 +198,11 @@ where /// /// This stores the keystore path as a configuration option so that actual keystore /// initialization is deferred until `build()`. This avoids panicking during builder chaining. + /// The same directory will be used for both authentication and encryption keys. #[must_use] pub fn filesystem_keystore(mut self, keystore_path: &str) -> Self { self.keystore = Some(AuthenticatorConfig::Path(keystore_path.to_string())); + self.encryption_keystore = Some(EncryptionKeystoreConfig::Path(keystore_path.to_string())); self } @@ -246,11 +271,24 @@ where None => None, }; + // Initialize the encryption keystore. + let encryption_keystore: Option> = + match self.encryption_keystore { + Some(EncryptionKeystoreConfig::Instance(ks)) => Some(ks), + Some(EncryptionKeystoreConfig::Path(ref path)) => { + let keystore = FilesystemKeyStore::new(path.into()) + .map_err(|err| ClientError::ClientInitializationError(err.to_string()))?; + Some(Arc::new(keystore)) + }, + None => None, + }; + Client::new( rpc_api, rng, store, authenticator, + encryption_keystore, ExecutionOptions::new( Some(MAX_TX_EXECUTION_CYCLES), MIN_TX_EXECUTION_CYCLES, @@ -271,7 +309,7 @@ where // ================================================================================================ /// Marker trait to capture the bounds the builder requires for the authenticator type -/// parameter +/// parameter. pub trait BuilderAuthenticator: TransactionAuthenticator + From + 'static { diff --git a/crates/rust-client/src/keystore/fs_keystore.rs b/crates/rust-client/src/keystore/fs_keystore.rs index daf7310cd..82b06a216 100644 --- a/crates/rust-client/src/keystore/fs_keystore.rs +++ b/crates/rust-client/src/keystore/fs_keystore.rs @@ -1,4 +1,6 @@ +use alloc::boxed::Box; use alloc::string::String; +use alloc::vec::Vec; use std::fs::OpenOptions; use std::hash::{DefaultHasher, Hash, Hasher}; use std::io::{BufRead, BufReader, BufWriter, Write}; @@ -7,11 +9,15 @@ use std::string::ToString; use miden_objects::Word; use miden_objects::account::auth::{AuthSecretKey, PublicKey, PublicKeyCommitment, Signature}; +use miden_objects::address::Address; +use miden_objects::crypto::dsa::ecdsa_k256_keccak::SecretKey as K256SecretKey; +use miden_objects::crypto::dsa::eddsa_25519::SecretKey as X25519SecretKey; +use miden_objects::crypto::ies::UnsealingKey; use miden_tx::AuthenticationError; use miden_tx::auth::{SigningInputs, TransactionAuthenticator}; use miden_tx::utils::{Deserializable, Serializable}; -use super::KeyStoreError; +use super::{EncryptionKeyStore, KeyStoreError}; /// A filesystem-based keystore that stores keys in separate files and provides transaction /// authentication functionality. The public key is hashed and the result is used as the filename @@ -124,6 +130,74 @@ impl TransactionAuthenticator for FilesystemKeyStore { } } +#[async_trait::async_trait] +impl EncryptionKeyStore for FilesystemKeyStore { + async fn add_encryption_key( + &self, + address: &Address, + key: &UnsealingKey, + ) -> Result<(), KeyStoreError> { + let encryption_dir = self.keys_directory.join("encryption"); + if !encryption_dir.exists() { + std::fs::create_dir_all(&encryption_dir).map_err(|err| { + KeyStoreError::StorageError(format!( + "error creating encryption keys directory: {err:?}" + )) + })?; + } + + // Use hash of address as filename + let filename = hash_address(address); + let file_path = encryption_dir.join(&filename); + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(file_path) + .map_err(|err| { + KeyStoreError::StorageError(format!("error opening encryption key file: {err:?}")) + })?; + + let key_bytes = serialize_unsealing_key(key); + let mut writer = BufWriter::new(file); + let key_hex = hex::encode(key_bytes); + writer.write_all(key_hex.as_bytes()).map_err(|err| { + KeyStoreError::StorageError(format!("error writing encryption key file: {err:?}")) + })?; + + Ok(()) + } + + async fn get_encryption_key( + &self, + address: &Address, + ) -> Result, KeyStoreError> { + let encryption_dir = self.keys_directory.join("encryption"); + let filename = hash_address(address); + let file_path = encryption_dir.join(filename); + + if !file_path.exists() { + return Ok(None); + } + + let file = OpenOptions::new().read(true).open(file_path).map_err(|err| { + KeyStoreError::StorageError(format!("error opening encryption key file: {err:?}")) + })?; + let mut reader = BufReader::new(file); + let mut key_hex = String::new(); + reader.read_line(&mut key_hex).map_err(|err| { + KeyStoreError::StorageError(format!("error reading encryption key file: {err:?}")) + })?; + + let key_bytes = hex::decode(key_hex.trim()).map_err(|err| { + KeyStoreError::DecodingError(format!("error decoding encryption key hex: {err:?}")) + })?; + let key = deserialize_unsealing_key(&key_bytes)?; + + Ok(Some(key)) + } +} + /// Hashes a public key to a string representation. fn hash_pub_key(pub_key: Word) -> String { let pub_key = pub_key.to_hex(); @@ -131,3 +205,96 @@ fn hash_pub_key(pub_key: Word) -> String { pub_key.hash(&mut hasher); hasher.finish().to_string() } + +/// Hashes an address to a string representation for use as a filename. +fn hash_address(address: &Address) -> String { + use miden_tx::utils::Serializable; + + let address_bytes = address.to_bytes(); + let mut hasher = DefaultHasher::new(); + address_bytes.hash(&mut hasher); + hasher.finish().to_string() +} + +// UNSEALING KEY SERIALIZATION +// ================================================================================================ + +// IES scheme discriminants - must match miden-crypto's IesScheme enum order. +// TODO: Remove these helpers once miden-crypto 0.19 is available, which provides +// Serializable/Deserializable impls for UnsealingKey. +const IES_SCHEME_K256_XCHACHA20_POLY1305: u8 = 0; +const IES_SCHEME_X25519_XCHACHA20_POLY1305: u8 = 1; +const IES_SCHEME_K256_AEAD_RPO: u8 = 2; +const IES_SCHEME_X25519_AEAD_RPO: u8 = 3; + +/// Serializes an [`UnsealingKey`] to bytes. +fn serialize_unsealing_key(key: &UnsealingKey) -> Vec { + let mut bytes = Vec::new(); + + match key { + UnsealingKey::K256XChaCha20Poly1305(secret_key) => { + bytes.push(IES_SCHEME_K256_XCHACHA20_POLY1305); + bytes.extend_from_slice(&secret_key.to_bytes()); + }, + UnsealingKey::X25519XChaCha20Poly1305(secret_key) => { + bytes.push(IES_SCHEME_X25519_XCHACHA20_POLY1305); + bytes.extend_from_slice(&secret_key.to_bytes()); + }, + UnsealingKey::K256AeadRpo(secret_key) => { + bytes.push(IES_SCHEME_K256_AEAD_RPO); + bytes.extend_from_slice(&secret_key.to_bytes()); + }, + UnsealingKey::X25519AeadRpo(secret_key) => { + bytes.push(IES_SCHEME_X25519_AEAD_RPO); + bytes.extend_from_slice(&secret_key.to_bytes()); + }, + } + + bytes +} + +/// Deserializes an [`UnsealingKey`] from bytes. +fn deserialize_unsealing_key(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(KeyStoreError::DecodingError("empty bytes for unsealing key".to_string())); + } + + let scheme = bytes[0]; + let key_bytes = &bytes[1..]; + + match scheme { + IES_SCHEME_K256_XCHACHA20_POLY1305 => { + let secret_key = K256SecretKey::read_from_bytes(key_bytes).map_err(|e| { + KeyStoreError::DecodingError(format!( + "failed to deserialize K256 secret key: {e:?}" + )) + })?; + Ok(UnsealingKey::K256XChaCha20Poly1305(secret_key)) + }, + IES_SCHEME_X25519_XCHACHA20_POLY1305 => { + let secret_key = X25519SecretKey::read_from_bytes(key_bytes).map_err(|e| { + KeyStoreError::DecodingError(format!( + "failed to deserialize X25519 secret key: {e:?}" + )) + })?; + Ok(UnsealingKey::X25519XChaCha20Poly1305(secret_key)) + }, + IES_SCHEME_K256_AEAD_RPO => { + let secret_key = K256SecretKey::read_from_bytes(key_bytes).map_err(|e| { + KeyStoreError::DecodingError(format!( + "failed to deserialize K256 secret key: {e:?}" + )) + })?; + Ok(UnsealingKey::K256AeadRpo(secret_key)) + }, + IES_SCHEME_X25519_AEAD_RPO => { + let secret_key = X25519SecretKey::read_from_bytes(key_bytes).map_err(|e| { + KeyStoreError::DecodingError(format!( + "failed to deserialize X25519 secret key: {e:?}" + )) + })?; + Ok(UnsealingKey::X25519AeadRpo(secret_key)) + }, + _ => Err(KeyStoreError::DecodingError(format!("unsupported IES scheme: {scheme}"))), + } +} diff --git a/crates/rust-client/src/keystore/mod.rs b/crates/rust-client/src/keystore/mod.rs index 5404d08f1..17faed17a 100644 --- a/crates/rust-client/src/keystore/mod.rs +++ b/crates/rust-client/src/keystore/mod.rs @@ -1,5 +1,8 @@ +use alloc::boxed::Box; use alloc::string::String; +use miden_objects::address::Address; +use miden_objects::crypto::ies::UnsealingKey; use thiserror::Error; #[derive(Debug, Error)] @@ -10,6 +13,27 @@ pub enum KeyStoreError { DecodingError(String), } +/// Trait for storing and retrieving encryption keys by address. +/// +/// Encryption keys are used for end-to-end encryption of private notes. +/// Each address has an associated encryption key stored in the keystore. +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +pub trait EncryptionKeyStore: Send + Sync { + /// Stores an encryption secret key (unsealing key) for an address. + async fn add_encryption_key( + &self, + address: &Address, + key: &UnsealingKey, + ) -> Result<(), KeyStoreError>; + + /// Retrieves the encryption secret key (unsealing key) for an address. + async fn get_encryption_key( + &self, + address: &Address, + ) -> Result, KeyStoreError>; +} + #[cfg(feature = "std")] mod fs_keystore; #[cfg(feature = "std")] diff --git a/crates/rust-client/src/lib.rs b/crates/rust-client/src/lib.rs index 0f0331c40..56ba8929d 100644 --- a/crates/rust-client/src/lib.rs +++ b/crates/rust-client/src/lib.rs @@ -80,6 +80,8 @@ //! // Initialize the random coin using the generated seed. //! let rng = RpoRandomCoin::new(coin_seed.map(Felt::new).into()); //! let keystore = FilesystemKeyStore::new("path/to/keys/directory".try_into()?)?; +//! // Create a separate keystore instance for encryption (can share the same directory) +//! let encryption_keystore = FilesystemKeyStore::new("path/to/keys/directory".try_into()?)?; //! //! // Determine the number of blocks to consider a transaction stale. //! // 20 is simply an example value. @@ -100,6 +102,7 @@ //! Box::new(rng), //! store, //! Some(Arc::new(keystore)), // or None if no authenticator is needed +//! Some(Arc::new(encryption_keystore)), // or None if no encryption is needed //! ExecutionOptions::new( //! Some(MAX_TX_EXECUTION_CYCLES), //! MIN_TX_EXECUTION_CYCLES, @@ -339,6 +342,9 @@ pub struct Client { /// An instance of a [`TransactionAuthenticator`] which will be used by the transaction /// executor whenever a signature is requested from within the VM. authenticator: Option>, + /// An instance of an [`EncryptionKeyStore`] which provides encryption keys for + /// end-to-end encryption of private notes. + encryption_keystore: Option>, /// Shared source manager used to retain MASM source information for assembled programs. source_manager: Arc, /// Options that control the transaction executor’s runtime behaviour (e.g. debug mode). @@ -373,7 +379,9 @@ where /// provide persistence. /// - `authenticator`: Defines the transaction authenticator that will be used by the /// transaction executor whenever a signature is requested from within the VM. - /// - `exec_options`: Options that control the transaction executor’s runtime behaviour (e.g. + /// - `encryption_keystore`: An optional instance of [`keystore::EncryptionKeyStore`] which + /// provides encryption keys for end-to-end encryption of notes in the note transport layer. + /// - `exec_options`: Options that control the transaction executor's runtime behaviour (e.g. /// debug mode). /// - `tx_graceful_blocks`: The number of blocks that are considered old enough to discard /// pending transactions. @@ -393,6 +401,7 @@ where rng: Box, store: Arc, authenticator: Option>, + encryption_keystore: Option>, exec_options: ExecutionOptions, tx_graceful_blocks: Option, max_block_number_delta: Option, @@ -420,6 +429,7 @@ where rpc_api, tx_prover, authenticator, + encryption_keystore, source_manager, exec_options, tx_graceful_blocks, diff --git a/crates/rust-client/src/note_transport/errors.rs b/crates/rust-client/src/note_transport/errors.rs index 895e212c1..be33b9727 100644 --- a/crates/rust-client/src/note_transport/errors.rs +++ b/crates/rust-client/src/note_transport/errors.rs @@ -15,4 +15,12 @@ pub enum NoteTransportError { Deserialization(#[from] DeserializationError), #[error("note transport network error: {0}")] Network(String), + #[error("recipient address does not contain an encryption key")] + MissingEncryptionKey, + #[error("encryption error: {0}")] + EncryptionError(String), + #[error("note decoding error: {0}")] + NoteDecodingError(String), + #[error("note reconstruction error: {0}")] + NoteReconstructionError(String), } diff --git a/crates/rust-client/src/note_transport/mod.rs b/crates/rust-client/src/note_transport/mod.rs index b99dc797d..c2e316edc 100644 --- a/crates/rust-client/src/note_transport/mod.rs +++ b/crates/rust-client/src/note_transport/mod.rs @@ -4,18 +4,22 @@ pub mod generated; pub mod grpc; use alloc::boxed::Box; +use alloc::collections::BTreeMap; use alloc::sync::Arc; use alloc::vec::Vec; use futures::Stream; use miden_lib::utils::Serializable; use miden_objects::address::Address; +use miden_objects::crypto::ies::SealedMessage; use miden_objects::note::{Note, NoteDetails, NoteFile, NoteHeader, NoteTag}; use miden_tx::auth::TransactionAuthenticator; -use miden_tx::utils::{ByteReader, ByteWriter, Deserializable, DeserializationError, SliceReader}; +use miden_tx::utils::Deserializable; +use tracing::debug; pub use self::errors::NoteTransportError; use crate::store::Store; +use crate::sync::NoteTagSource; use crate::{Client, ClientError}; pub const NOTE_TRANSPORT_DEFAULT_ENDPOINT: &str = "https://transport.miden.io"; @@ -39,22 +43,37 @@ impl Client { /// Send a note through the note transport network. /// - /// The note will be end-to-end encrypted (unimplemented, currently plaintext) - /// using the provided recipient's `address` details. + /// The note will be end-to-end encrypted using the provided recipient's `address` details. /// The recipient will be able to retrieve this note through the note's [`NoteTag`]. + /// + /// # Errors + /// + /// Returns an error if: + /// - The note transport is not configured + /// - The recipient's address does not contain an encryption key + /// - Encryption fails + /// - Note transport fails pub async fn send_private_note( &mut self, note: Note, - _address: &Address, + address: &Address, ) -> Result<(), ClientError> { let api = self.get_note_transport_api()?; let header = *note.header(); let details = NoteDetails::from(note); let details_bytes = details.to_bytes(); - // e2ee impl hint: - // address.key().encrypt(details_bytes) - api.send_note(header, details_bytes).await?; + + // Encrypt the note details using the recipient's address encryption key + let encryption_key = + address.encryption_key().ok_or(NoteTransportError::MissingEncryptionKey)?; + + let mut rng = rand::rng(); + let sealed_message = encryption_key + .seal_bytes(&mut rng, &details_bytes) + .map_err(|e| NoteTransportError::EncryptionError(format!("{e:#}")))?; + + api.send_note(header, sealed_message.to_bytes()).await?; Ok(()) } @@ -70,7 +89,7 @@ where /// To list tracked tags please use [`Client::get_note_tags`]. To add a new note tag please use /// [`Client::add_note_tag`]. /// Only notes directed at your addresses will be stored and readable given the use of - /// end-to-end encryption (unimplemented). + /// end-to-end encryption. Notes that cannot be decrypted are silently ignored. /// Fetched notes will be stored into the client's store. /// /// An internal pagination mechanism is employed to reduce the number of downloaded notes. @@ -103,6 +122,7 @@ where /// Fetch notes from the note transport network for provided note tags /// /// Pagination is employed, where only notes after the provided cursor are requested. + /// Trial decryption is performed using the encryption key of the address that owns the tag. /// Downloaded notes are imported. pub(crate) async fn fetch_transport_notes( &mut self, @@ -112,21 +132,33 @@ where where I: IntoIterator, { + let tags: Vec = tags.into_iter().collect(); + + // Build a mapping from tag -> address that owns that tag + let tag_to_address = self.build_tag_to_address_map(&tags).await?; + let mut notes = Vec::new(); + // Fetch notes - let (note_infos, rcursor) = self - .get_note_transport_api()? - .fetch_notes(&tags.into_iter().collect::>(), cursor) - .await?; + let (note_infos, rcursor) = + self.get_note_transport_api()?.fetch_notes(&tags, cursor).await?; + for note_info in ¬e_infos { - // e2ee impl hint: - // for key in self.store.decryption_keys() try - // key.decrypt(details_bytes_encrypted) - let note = rejoin_note(¬e_info.header, ¬e_info.details_bytes)?; - notes.push(note); + // Get the tag from the note header metadata + let tag = note_info.header.metadata().tag(); + + // Try to decrypt with the key of the address that owns this tag + if let Some(address) = tag_to_address.get(&tag) + && let Some(note) = self + .try_decrypt_note(¬e_info.header, ¬e_info.details_bytes, address) + .await + { + notes.push(note); + } } let sync_height = self.get_sync_height().await?; + // Import fetched notes for note in notes { let tag = note.metadata().tag(); @@ -143,6 +175,65 @@ where Ok(()) } + + /// Builds a mapping from `NoteTag` to the address that owns that tag. + async fn build_tag_to_address_map( + &self, + tags: &[NoteTag], + ) -> Result, ClientError> { + let note_tag_records = self.store.get_note_tags().await?; + let mut tag_to_address: BTreeMap = BTreeMap::new(); + + for record in note_tag_records { + // Only process tags that are in our query set + if !tags.contains(&record.tag) { + continue; + } + + // Get the account that owns this tag + if let NoteTagSource::Account(account_id) = record.source { + // Find the address that matches this tag + let addresses = self.store.get_addresses_by_account_id(account_id).await?; + for address in addresses { + if address.to_note_tag() == record.tag { + tag_to_address.insert(record.tag, address); + break; + } + } + } + } + + Ok(tag_to_address) + } + + /// Attempts to decrypt a note using the encryption key for the provided address. + /// Returns `Some(Note)` if decryption succeeds, `None` otherwise. + async fn try_decrypt_note( + &self, + header: &NoteHeader, + encrypted_details: &[u8], + address: &Address, + ) -> Option { + let encryption_keystore = self.encryption_keystore.as_ref()?; + + // Parse the sealed message + let sealed_message = SealedMessage::read_from_bytes(encrypted_details).ok()?; + + // Try decrypt + let unsealing_key = encryption_keystore.get_encryption_key(address).await.ok()??; + let details_bytes = unsealing_key.unseal_bytes(sealed_message).ok()?; + + match rejoin_note(header, &details_bytes) { + Ok(note) => { + debug!("Successfully decrypted note {}", note.id()); + Some(note) + }, + Err(e) => { + debug!("Failed to parse decrypted note details: {:?}", e); + None + }, + } + } } /// Populates the note transport cursor setting with 0, if it is not setup @@ -162,14 +253,6 @@ pub(crate) async fn init_note_transport_cursor(store: Arc) -> Result< #[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Eq, Ord)] pub struct NoteTransportCursor(u64); -/// Note Transport update -pub struct NoteTransportUpdate { - /// Pagination cursor for next fetch - pub cursor: NoteTransportCursor, - /// Fetched notes - pub notes: Vec, -} - impl NoteTransportCursor { pub fn new(value: u64) -> Self { Self(value) @@ -190,6 +273,21 @@ impl From for NoteTransportCursor { } } +impl miden_tx::utils::Serializable for NoteTransportCursor { + fn write_into(&self, target: &mut W) { + target.write_u64(self.0); + } +} + +impl miden_tx::utils::Deserializable for NoteTransportCursor { + fn read_from( + source: &mut R, + ) -> Result { + let value = source.read_u64()?; + Ok(Self::new(value)) + } +} + /// The main transport client trait for sending and receiving encrypted notes #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] @@ -207,7 +305,7 @@ pub trait NoteTransportClient: Send + Sync { /// Returns notes labelled after the provided cursor (pagination), and an updated cursor. async fn fetch_notes( &self, - tag: &[NoteTag], + tags: &[NoteTag], cursor: NoteTransportCursor, ) -> Result<(Vec, NoteTransportCursor), NoteTransportError>; @@ -225,49 +323,53 @@ pub trait NoteStream: { } -/// Information about a note fetched from the note transport network -#[derive(Debug, Clone)] +/// Represents a note info returned from the note transport layer. +#[derive(Clone)] pub struct NoteInfo { - /// Note header + /// Note header (metadata + id). pub header: NoteHeader, - /// Note details, can be encrypted + /// Note details serialized as bytes (may be encrypted). pub details_bytes: Vec, } // SERIALIZATION // ================================================================================================ -impl Serializable for NoteInfo { - fn write_into(&self, target: &mut W) { +impl miden_tx::utils::Serializable for NoteInfo { + fn write_into(&self, target: &mut W) { self.header.write_into(target); self.details_bytes.write_into(target); } } -impl Deserializable for NoteInfo { - fn read_from(source: &mut R) -> Result { +impl miden_tx::utils::Deserializable for NoteInfo { + fn read_from( + source: &mut R, + ) -> Result { let header = NoteHeader::read_from(source)?; let details_bytes = Vec::::read_from(source)?; - Ok(NoteInfo { header, details_bytes }) + Ok(Self { header, details_bytes }) } } -impl Serializable for NoteTransportCursor { - fn write_into(&self, target: &mut W) { - self.0.write_into(target); - } -} +// HELPER FUNCTIONS +// ================================================================================================ -impl Deserializable for NoteTransportCursor { - fn read_from(source: &mut R) -> Result { - let value = u64::read_from(source)?; - Ok(Self::new(value)) +/// Reconstructs a full Note from header and decrypted details bytes. +fn rejoin_note(header: &NoteHeader, details_bytes: &[u8]) -> Result { + let details = NoteDetails::read_from_bytes(details_bytes) + .map_err(|e| NoteTransportError::NoteDecodingError(format!("{e:#}")))?; + + let note = Note::new(details.assets().clone(), *header.metadata(), details.recipient().clone()); + + // Verify that the reconstructed note matches the header + if *note.header() != *header { + return Err(NoteTransportError::NoteReconstructionError(format!( + "Reconstructed note header doesn't match received header. Got {:?}, expected {:?}", + note.header(), + header + ))); } -} -fn rejoin_note(header: &NoteHeader, details_bytes: &[u8]) -> Result { - let mut reader = SliceReader::new(details_bytes); - let details = NoteDetails::read_from(&mut reader)?; - let metadata = *header.metadata(); - Ok(Note::new(details.assets().clone(), metadata, details.recipient().clone())) + Ok(note) } diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 887258766..d99211ef5 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -2289,10 +2289,13 @@ async fn account_addresses_basic_wallet() { client.add_account(&account, false).await.unwrap(); let retrieved_acc = client.get_account(account.id()).await.unwrap().unwrap(); - let unspecified_default_address = Address::new(account.id()); - assert!(retrieved_acc.addresses().contains(&unspecified_default_address)); + // The default address should have an encryption key (auto-generated) + let addresses = retrieved_acc.addresses(); + assert_eq!(addresses.len(), 1); + let default_address = &addresses[0]; + assert!(default_address.encryption_key().is_some()); - // Even when the account has a basic wallet, the address list should not contain it by default + // An address with only BasicWallet routing params (no encryption key) should not be present let routing_params = RoutingParameters::new(AddressInterface::BasicWallet); let basic_wallet_address = Address::new(account.id()).with_routing_parameters(routing_params).unwrap(); @@ -2309,9 +2312,13 @@ async fn account_addresses_non_basic_wallet() { client.add_account(&account, false).await.unwrap(); let retrieved_acc = client.get_account(account.id()).await.unwrap().unwrap(); - let unspecified_default_address = Address::new(account.id()); - assert!(retrieved_acc.addresses().contains(&unspecified_default_address)); + // The default address should have an encryption key + let addresses = retrieved_acc.addresses(); + assert_eq!(addresses.len(), 1); + let default_address = &addresses[0]; + assert!(default_address.encryption_key().is_some()); + // An address with only BasicWallet routing params (no encryption key) should not be present let routing_params = RoutingParameters::new(AddressInterface::BasicWallet); let basic_wallet_address = Address::new(account.id()).with_routing_parameters(routing_params).unwrap(); @@ -2330,30 +2337,25 @@ async fn account_add_address_after_creation() { client.add_account(&account, false).await.unwrap(); - let unspecified_default_address = Address::new(account.id()); - - // The default unspecified address cannot be added - // as it is already present after account creation - assert!(client.add_address(unspecified_default_address, account.id()).await.is_err()); + // Get the default address (has an encryption key) + let retrieved_acc = client.get_account(account.id()).await.unwrap().unwrap(); + let default_address = retrieved_acc.addresses()[0].clone(); + assert!(default_address.encryption_key().is_some()); - // The basic wallet address cannot be added - // as it is already present after account creation - let routing_params = RoutingParameters::new(AddressInterface::BasicWallet); - let basic_wallet_address = - Address::new(account.id()).with_routing_parameters(routing_params).unwrap(); - assert!(client.add_address(basic_wallet_address.clone(), account.id()).await.is_err()); + // The default address cannot be added again as it is already present + assert!(client.add_address(default_address.clone(), account.id()).await.is_err()); - // We can remove the basic wallet address - assert!(client.remove_address(basic_wallet_address.clone(), account.id()).await.is_ok()); + // We can remove the default address + assert!(client.remove_address(default_address.clone(), account.id()).await.is_ok()); // Derived note tag should also be removed - let derived_note_tag = basic_wallet_address.to_note_tag(); + let derived_note_tag = default_address.to_note_tag(); let note_tag_record = NoteTagRecord::with_account_source(derived_note_tag, account.id()); let note_tags = client.get_note_tags().await.unwrap(); assert!(!note_tags.contains(¬e_tag_record)); // Then add it again - assert!(client.add_address(basic_wallet_address, account.id()).await.is_ok()); + assert!(client.add_address(default_address.clone(), account.id()).await.is_ok()); // Derived note tag should now be available let note_tags = client.get_note_tags().await.unwrap(); diff --git a/crates/testing/miden-client-tests/src/tests/transport.rs b/crates/testing/miden-client-tests/src/tests/transport.rs index fc37143a0..dadc6548d 100644 --- a/crates/testing/miden-client-tests/src/tests/transport.rs +++ b/crates/testing/miden-client-tests/src/tests/transport.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use miden_client::Felt; use miden_client::account::{Account, AccountStorageMode}; -use miden_client::address::{Address, AddressInterface, RoutingParameters}; use miden_client::keystore::FilesystemKeyStore; use miden_client::note::NoteType; use miden_client::store::NoteFilter; @@ -19,9 +18,18 @@ async fn transport_basic() { let mock_node = Arc::new(RwLock::new(MockNoteTransportNode::new())); let (mut sender, sender_account) = create_test_user_transport(mock_node.clone()).await; let (mut recipient, recipient_account) = create_test_user_transport(mock_node.clone()).await; - let recipient_address = Address::new(recipient_account.id()) - .with_routing_parameters(RoutingParameters::new(AddressInterface::BasicWallet)) + + // Get recipient address + let recipient_addresses = recipient + .test_store() + .get_addresses_by_account_id(recipient_account.id()) + .await .unwrap(); + let recipient_address = recipient_addresses + .first() + .expect("Recipient should have a at least one address (with encryption key)") + .clone(); + let (mut observer, _observer_account) = create_test_user_transport(mock_node.clone()).await; // Create note diff --git a/crates/web-client/Cargo.toml b/crates/web-client/Cargo.toml index 6348d5bcf..7db389147 100644 --- a/crates/web-client/Cargo.toml +++ b/crates/web-client/Cargo.toml @@ -28,6 +28,7 @@ miden-client = { default-features = false, features = ["testing", "tonic"], pat miden-objects = { workspace = true } # External dependencies +async-trait = { workspace = true } console_error_panic_hook = { version = "0.1.7" } hex = { version = "0.4" } js-sys = { version = "0.3" } diff --git a/crates/web-client/src/account.rs b/crates/web-client/src/account.rs index e4e4eefff..c10e46d7e 100644 --- a/crates/web-client/src/account.rs +++ b/crates/web-client/src/account.rs @@ -98,4 +98,20 @@ impl WebClient { Err(JsValue::from_str("Client not initialized")) } } + + #[wasm_bindgen(js_name = "getAccountAddresses")] + pub async fn get_account_addresses( + &mut self, + account_id: &AccountId, + ) -> Result, JsValue> { + if let Some(client) = self.get_mut_inner() { + let addresses = client + .get_addresses(account_id.into()) + .await + .map_err(|err| js_error_with_context(err, "failed to get account addresses"))?; + Ok(addresses.into_iter().map(Into::into).collect()) + } else { + Err(JsValue::from_str("Client not initialized")) + } + } } diff --git a/crates/web-client/src/lib.rs b/crates/web-client/src/lib.rs index 0a1b25374..00cc1fe8a 100644 --- a/crates/web-client/src/lib.rs +++ b/crates/web-client/src/lib.rs @@ -172,12 +172,14 @@ impl WebClient { ); let keystore = WebKeyStore::new_with_callbacks(rng, get_key_cb, insert_key_cb, sign_cb); + let keystore_arc = Arc::new(keystore.clone()); let mut client = Client::new( rpc_client, Box::new(rng), web_store.clone(), - Some(Arc::new(keystore.clone())), + Some(keystore_arc.clone()), + Some(keystore_arc.clone()), ExecutionOptions::new( Some(MAX_TX_EXECUTION_CYCLES), MIN_TX_EXECUTION_CYCLES, diff --git a/crates/web-client/src/web_keystore.rs b/crates/web-client/src/web_keystore.rs index 79372ac48..abb1bd1c5 100644 --- a/crates/web-client/src/web_keystore.rs +++ b/crates/web-client/src/web_keystore.rs @@ -1,7 +1,12 @@ use alloc::string::ToString; use alloc::sync::Arc; +use alloc::vec::Vec; +use core::hash::{Hash, Hasher}; +use std::collections::hash_map::DefaultHasher; use idxdb_store::auth::{get_account_auth_by_pub_key, insert_account_auth}; +use idxdb_store::encryption::{get_encryption_key, insert_encryption_key}; +use miden_client::account::Address; use miden_client::auth::{ AuthSecretKey, PublicKey, @@ -10,9 +15,12 @@ use miden_client::auth::{ SigningInputs, TransactionAuthenticator, }; -use miden_client::keystore::KeyStoreError; +use miden_client::keystore::{EncryptionKeyStore, KeyStoreError}; use miden_client::utils::{RwLock, Serializable}; -use miden_client::{AuthenticationError, Word, Word as NativeWord}; +use miden_client::{AuthenticationError, Deserializable, Word, Word as NativeWord}; +use miden_objects::crypto::dsa::ecdsa_k256_keccak::SecretKey as K256SecretKey; +use miden_objects::crypto::dsa::eddsa_25519::SecretKey as X25519SecretKey; +use miden_objects::crypto::ies::UnsealingKey; use rand::Rng; use wasm_bindgen_futures::js_sys::Function; @@ -120,6 +128,52 @@ impl WebKeyStore { } } +// ENCRYPTION KEY STORE IMPLEMENTATION +// ================================================================================================ + +#[async_trait::async_trait(?Send)] +impl EncryptionKeyStore for WebKeyStore { + async fn add_encryption_key( + &self, + address: &Address, + key: &UnsealingKey, + ) -> Result<(), KeyStoreError> { + let address_hash = hash_address(address); + let key_bytes = serialize_unsealing_key(key); + let key_hex = hex::encode(key_bytes); + + insert_encryption_key(address_hash, key_hex) + .await + .map_err(|e| KeyStoreError::StorageError(format!("{e:?}")))?; + + Ok(()) + } + + async fn get_encryption_key( + &self, + address: &Address, + ) -> Result, KeyStoreError> { + let address_hash = hash_address(address); + + let key_hex = get_encryption_key(address_hash) + .await + .map_err(|e| KeyStoreError::StorageError(format!("{e:?}")))?; + + match key_hex { + Some(hex) => { + let bytes = + hex::decode(hex).map_err(|e| KeyStoreError::DecodingError(format!("{e:?}")))?; + let key = deserialize_unsealing_key(&bytes)?; + Ok(Some(key)) + }, + None => Ok(None), + } + } +} + +// TRANSACTION AUTHENTICATOR IMPLEMENTATION +// ================================================================================================ + impl TransactionAuthenticator for WebKeyStore { /// Gets a signature over a message, given a public key. /// @@ -163,3 +217,95 @@ impl TransactionAuthenticator for WebKeyStore { None } } + +// HELPER FUNCTIONS +// ================================================================================================ + +/// Hashes an address to a string representation for use as a key. +fn hash_address(address: &Address) -> String { + let address_bytes = address.to_bytes(); + let mut hasher = DefaultHasher::new(); + address_bytes.hash(&mut hasher); + hasher.finish().to_string() +} + +// UNSEALING KEY SERIALIZATION +// ================================================================================================ + +// IES scheme discriminants - must match miden-crypto's IesScheme enum order. +const IES_SCHEME_K256_XCHACHA20_POLY1305: u8 = 0; +const IES_SCHEME_X25519_XCHACHA20_POLY1305: u8 = 1; +const IES_SCHEME_K256_AEAD_RPO: u8 = 2; +const IES_SCHEME_X25519_AEAD_RPO: u8 = 3; + +/// Serializes an [`UnsealingKey`] to bytes. +fn serialize_unsealing_key(key: &UnsealingKey) -> Vec { + let mut bytes = Vec::new(); + + match key { + UnsealingKey::K256XChaCha20Poly1305(secret_key) => { + bytes.push(IES_SCHEME_K256_XCHACHA20_POLY1305); + bytes.extend_from_slice(&secret_key.to_bytes()); + }, + UnsealingKey::X25519XChaCha20Poly1305(secret_key) => { + bytes.push(IES_SCHEME_X25519_XCHACHA20_POLY1305); + bytes.extend_from_slice(&secret_key.to_bytes()); + }, + UnsealingKey::K256AeadRpo(secret_key) => { + bytes.push(IES_SCHEME_K256_AEAD_RPO); + bytes.extend_from_slice(&secret_key.to_bytes()); + }, + UnsealingKey::X25519AeadRpo(secret_key) => { + bytes.push(IES_SCHEME_X25519_AEAD_RPO); + bytes.extend_from_slice(&secret_key.to_bytes()); + }, + } + + bytes +} + +/// Deserializes an [`UnsealingKey`] from bytes. +fn deserialize_unsealing_key(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(KeyStoreError::DecodingError("empty bytes for unsealing key".to_string())); + } + + let scheme = bytes[0]; + let key_bytes = &bytes[1..]; + + match scheme { + IES_SCHEME_K256_XCHACHA20_POLY1305 => { + let secret_key = K256SecretKey::read_from_bytes(key_bytes).map_err(|e| { + KeyStoreError::DecodingError(format!( + "failed to deserialize K256 secret key: {e:?}" + )) + })?; + Ok(UnsealingKey::K256XChaCha20Poly1305(secret_key)) + }, + IES_SCHEME_X25519_XCHACHA20_POLY1305 => { + let secret_key = X25519SecretKey::read_from_bytes(key_bytes).map_err(|e| { + KeyStoreError::DecodingError(format!( + "failed to deserialize X25519 secret key: {e:?}" + )) + })?; + Ok(UnsealingKey::X25519XChaCha20Poly1305(secret_key)) + }, + IES_SCHEME_K256_AEAD_RPO => { + let secret_key = K256SecretKey::read_from_bytes(key_bytes).map_err(|e| { + KeyStoreError::DecodingError(format!( + "failed to deserialize K256 secret key: {e:?}" + )) + })?; + Ok(UnsealingKey::K256AeadRpo(secret_key)) + }, + IES_SCHEME_X25519_AEAD_RPO => { + let secret_key = X25519SecretKey::read_from_bytes(key_bytes).map_err(|e| { + KeyStoreError::DecodingError(format!( + "failed to deserialize X25519 secret key: {e:?}" + )) + })?; + Ok(UnsealingKey::X25519AeadRpo(secret_key)) + }, + _ => Err(KeyStoreError::DecodingError(format!("unsupported IES scheme: {scheme}"))), + } +} diff --git a/crates/web-client/test/note_transport.test.ts b/crates/web-client/test/note_transport.test.ts index fe2a92ba1..76e13e203 100644 --- a/crates/web-client/test/note_transport.test.ts +++ b/crates/web-client/test/note_transport.test.ts @@ -23,11 +23,11 @@ test("transport basic", async ({ page }) => { recipientSeed ); - // Create recipient address - const recipientAddress = window.Address.fromAccountId( - recipientAccount.id(), - "BasicWallet" + // Get recipient address + const recipientAddresses = await client.getAccountAddresses( + recipientAccount.id() ); + const recipientAddress = recipientAddresses[0]; // Create note const noteAssets = new window.NoteAssets([]); diff --git a/docs/typedoc/web-client/classes/WebClient.md b/docs/typedoc/web-client/classes/WebClient.md index 6afb6e897..ac733a30f 100644 --- a/docs/typedoc/web-client/classes/WebClient.md +++ b/docs/typedoc/web-client/classes/WebClient.md @@ -333,6 +333,22 @@ Uses an internal pagination mechanism to avoid fetching duplicate notes. *** +### getAccountAddresses() + +> **getAccountAddresses**(`account_id`): `Promise`\<[`Address`](Address.md)[]\> + +#### Parameters + +##### account\_id + +[`AccountId`](AccountId.md) + +#### Returns + +`Promise`\<[`Address`](Address.md)[]\> + +*** + ### getAccountAuthByPubKey() > **getAccountAuthByPubKey**(`pub_key`): `Promise`\<[`AuthSecretKey`](AuthSecretKey.md)\>