From 35093dedc37d8a5067e78a34b7601171deac7bba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 09:33:52 +0000 Subject: [PATCH 1/2] Import a cold wallet's NEAR public key from its export QR A cold wallet file holds only an SS58 address, which cannot be turned back into the ML-DSA-65 key NEAR needs. `quantus near import-cold-key` reads the key export the cold wallet app shows (v1 {kind: near-public-key, address, near_public_key}), refuses it unless the key hashes to the wallet's stored address, and saves it on the wallet record. Every `quantus near` command then accepts the cold wallet: hot wallets sign locally, cold wallets run the QR roundtrip of sign-cold. The simulator gains --export-near-key to emit the same payload a device would, for end-to-end testing. --- README.md | 26 +++++ src/cli/cold_signing.rs | 32 ++++++- src/cli/mod.rs | 7 ++ src/cli/near.rs | 157 ++++++++++++++++++++++++------ src/qr/key_export.rs | 207 ++++++++++++++++++++++++++++++++++++++++ src/qr/mod.rs | 2 + src/wallet/keystore.rs | 9 ++ src/wallet/mod.rs | 70 ++++++++++++++ 8 files changed, 480 insertions(+), 30 deletions(-) create mode 100644 src/qr/key_export.rs diff --git a/README.md b/README.md index 396c111..56bf586 100644 --- a/README.md +++ b/README.md @@ -719,6 +719,32 @@ address, so a transaction built for someone else's key cannot be signed as answers NEAR requests too, using a local ML-DSA-65 hot wallet, for end-to-end testing without a device. +#### Using the NEAR commands with a cold wallet + +A cold wallet file holds only an SS58 address, which is a hash of the key and +cannot be turned back into the 1952-byte ML-DSA-65 public key NEAR needs. +Import the key once from the QR the cold wallet app shows (account → Show +public key → NEAR); after that every `quantus near` command accepts the cold +wallet, signing over QR where a hot wallet would sign locally. + +```bash +# Scan the key export QR (or pass it as text with --key ml-dsa-65:) +quantus near import-cold-key --wallet my_cold + +# Now the same commands as for a hot wallet, no password involved +quantus near show-key --wallet my_cold +quantus near create-account --new-account vault.alice.testnet --wallet my_cold \ + --parent-credentials ~/.near-credentials/testnet/alice.testnet.json +quantus near send --wallet my_cold --account vault.alice.testnet --to bob.testnet --amount 1.5 +``` + +The import is refused unless the exported key hashes to the wallet's stored +address, so a QR from another device cannot be attached to `my_cold`. The +key is re-checked against the address every time it is read from the wallet +file. For testing without a device, `quantus developer cold-sign-sim --wallet + --export-near-key --response-file export.ur` writes the same QR +payload a device would show. + --- ### Sending Tokens diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs index 769278e..d7864e7 100644 --- a/src/cli/cold_signing.rs +++ b/src/cli/cold_signing.rs @@ -531,9 +531,15 @@ pub async fn handle_cold_sign_sim( wallet: String, request_file: Option, response_file: Option, + export_near_key: bool, password: Option, password_file: Option, ) -> Result<()> { + if export_near_key { + let export = near_key_export_as_device(&wallet, password, password_file)?; + return write_sim_response(&export.encode(), response_file.as_deref()); + } + // 1. Read the request UR (polling the file allows scripted pipelines). let request_source = match &request_file { Some(path) => UrSource::File(PathBuf::from(path)), @@ -601,6 +607,30 @@ pub async fn handle_cold_sign_sim( write_sim_response(&response_bytes, response_file.as_deref()) } +/// The simulator's answer to "Show public key": the wallet's ML-DSA-65 key in +/// NEAR text form, tied to its SS58 address, as the cold wallet app exports it. +fn near_key_export_as_device( + wallet: &str, + password: Option, + password_file: Option, +) -> Result { + let keypair = crate::wallet::load_keypair_from_wallet(wallet, password, password_file)?; + if keypair.scheme != crate::wallet::DilithiumScheme::MlDsa65 { + return Err(QuantusError::Generic(format!( + "wallet '{wallet}' is {:?}; only ML-DSA-65 keys are used on NEAR", + keypair.scheme + ))); + } + let key = crate::near::protocol::PublicKey::from_ml_dsa_65_bytes(&keypair.public_key)?; + let export = crate::qr::NearPublicKeyExport::new( + keypair.try_to_account_id_ss58check()?, + key.to_near_string(), + )?; + log_print!("🔑 NEAR key export for {}", export.address.bright_cyan()); + log_print!(" {}", export.near_public_key); + Ok(export) +} + /// The NEAR half of the simulator, mirroring what the cold wallet app will do /// with a v2 request: decode the borsh transaction, refuse it unless the /// transaction's declared key is this wallet's ML-DSA-65 key, and sign the @@ -661,7 +691,7 @@ fn write_sim_response(response_bytes: &[u8], response_file: Option<&str>) -> Res let tmp = format!("{path}.tmp"); std::fs::write(&tmp, parts.join("\n") + "\n")?; std::fs::rename(&tmp, path)?; - log_print!("📤 Signature response ({} UR parts) written to {}", parts.len(), path); + log_print!("📤 Response ({} UR parts) written to {}", parts.len(), path); }, None => for part in &parts { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e02ecbd..6796e39 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -371,6 +371,11 @@ pub enum DeveloperCommands { #[arg(long)] response_file: Option, + /// Instead of signing a request, emit the wallet's NEAR public key + /// export UR, as the cold wallet app's "Show public key" does + #[arg(long)] + export_near_key: bool, + /// Password for the wallet #[arg(short, long)] password: Option, @@ -672,6 +677,7 @@ pub async fn handle_developer_command(command: DeveloperCommands) -> crate::erro wallet, request_file, response_file, + export_near_key, password, password_file, } => @@ -679,6 +685,7 @@ pub async fn handle_developer_command(command: DeveloperCommands) -> crate::erro wallet, request_file, response_file, + export_near_key, password, password_file, ) diff --git a/src/cli/near.rs b/src/cli/near.rs index 4d2946e..bbaa1c8 100644 --- a/src/cli/near.rs +++ b/src/cli/near.rs @@ -14,23 +14,30 @@ //! ML-DSA-65-controlled account is a full member with no DAO-side changes. //! 6. `near sign-cold` — sign a transaction prepared by near-cli-rs with a cold (air-gapped) wallet //! over QR codes, for any contract call the hot commands do not cover. +//! 7. `near import-cold-key` — read the NEAR public key a cold wallet exports as a QR, so the +//! commands above work with a cold wallet too: a cold wallet file holds only an SS58 address, +//! which cannot be turned back into the key NEAR needs. +//! +//! Every command that takes `--wallet` accepts a hot or a cold wallet; with a +//! cold wallet, signing runs the QR roundtrip of `sign-cold`. //! //! ML-DSA-87 wallets are rejected: NEAR defined ML-DSA-65 only. use crate::{ - cli::cold_signing::ColdIo, + cli::cold_signing::{read_signature_response, response_source, ColdIo}, error::{QuantusError, Result}, log_print, log_success, log_verbose, near::{ cold::{load_unsigned_transaction, sign_transaction_cold}, protocol::{ validate_account_id, AccessKey, Action, AddKeyAction, FunctionCallAction, PublicKey, - Transaction, TransferAction, NEAR_DECIMALS, + SignedTransaction, Transaction, TransferAction, NEAR_DECIMALS, }, rpc::{decode_success_value, NearRpcClient}, sign::{load_credentials, sign_transaction_ed25519, sign_transaction_ml_dsa_65}, }, - wallet::QuantumKeyPair, + qr::NearPublicKeyExport, + wallet::{QuantumKeyPair, WalletManager, WalletSigner}, }; use clap::Subcommand; use colored::Colorize; @@ -41,7 +48,7 @@ use std::path::PathBuf; pub enum NearCommands { /// Show the wallet's ML-DSA-65 key in NEAR text forms ShowKey { - /// Quantus wallet (must be ML-DSA-65) + /// Quantus wallet (must be ML-DSA-65); a cold wallet needs `near import-cold-key` first #[arg(long, short)] wallet: String, @@ -167,7 +174,7 @@ pub enum NearCommands { #[arg(long)] unsigned_tx: String, - /// Cold wallet (created with `quantus wallet create-cold`) whose + /// Cold wallet (created with `quantus wallet import-cold`) whose /// ML-DSA-65 key the transaction declares #[arg(long, short)] wallet: String, @@ -189,6 +196,20 @@ pub enum NearCommands { rpc_url: Option, }, + /// Import a cold wallet's NEAR public key from the QR its device shows + /// (cold wallet app: account → Show public key → NEAR). The key must + /// belong to the wallet's address; afterwards the other commands accept + /// the cold wallet. + ImportColdKey { + /// Cold wallet (created with `quantus wallet import-cold`) + #[arg(long, short)] + wallet: String, + + /// The key as text (`ml-dsa-65:`) instead of scanning a QR + #[arg(long)] + key: Option, + }, + /// Act in a Sputnik DAO multisig (the contract behind Trezu) as a member /// account controlled by the wallet Dao { @@ -348,10 +369,55 @@ pub async fn handle_near_command(command: NearCommands) -> Result<()> { .await, NearCommands::SignCold { unsigned_tx, wallet, network, out, send, rpc_url } => handle_sign_cold(&unsigned_tx, &wallet, &network, out, send, rpc_url).await, + NearCommands::ImportColdKey { wallet, key } => handle_import_cold_key(&wallet, key).await, NearCommands::Dao { command } => handle_dao_command(command).await, } } +async fn handle_import_cold_key(wallet: &str, key: Option) -> Result<()> { + let manager = WalletManager::new()?; + let existing = manager.cold_near_public_key(wallet)?; + let WalletSigner::Cold { address, .. } = + crate::wallet::load_signer_from_wallet(wallet, None, None)? + else { + return Err(QuantusError::Generic(format!("wallet '{wallet}' is not a cold wallet"))); + }; + + let export = match key { + Some(key) => NearPublicKeyExport::new(address.clone(), key.trim())?, + None => { + let source = response_source(ColdIo::global())?; + log_print!( + "📷 On the cold wallet open the account, choose Show public key, then show the \ + NEAR key QR." + ); + let export = NearPublicKeyExport::decode(&read_signature_response(&source).await?)?; + if export.address != address { + return Err(QuantusError::Generic(format!( + "the QR exports the key of {}, but wallet '{wallet}' is {address}", + export.address + ))); + } + export + }, + }; + let public = export.public_key()?; + + if existing.as_deref() == Some(export.near_public_key.as_str()) { + log_print!("â„šī¸ Wallet '{wallet}' already holds this NEAR key"); + print_key(&public); + return Ok(()); + } + manager.set_cold_near_public_key(wallet, &export.near_public_key)?; + log_success!("✅ NEAR key of cold wallet '{wallet}' ({address}) saved"); + print_key(&public); + log_print!( + "💡 Register it on an account with `quantus near create-account --wallet {wallet} ...` or \ + `near account add-key`" + ); + Ok(()) +} + async fn handle_sign_cold( unsigned_tx: &str, wallet: &str, @@ -454,22 +520,57 @@ async fn handle_dao_command(command: DaoCommands) -> Result<()> { } } +/// How a NEAR transaction gets signed: locally from a hot wallet's key, or +/// over QR codes by a cold wallet. +enum NearSigner { + Hot(QuantumKeyPair), + Cold { name: String, address: String }, +} + +impl NearSigner { + async fn sign(&self, tx: Transaction, network: &str) -> Result { + match self { + NearSigner::Hot(keypair) => + sign_transaction_ml_dsa_65(tx, &keypair.to_dilithium65_pair()?), + NearSigner::Cold { name, address } => + sign_transaction_cold(tx, network, name, address, ColdIo::global()).await, + } + } +} + /// Load a wallet and its key as a NEAR public key, refusing non-65 schemes. -fn load_ml_dsa_65_wallet( +/// A cold wallet resolves without a password, from the key it imported with +/// `near import-cold-key`. +fn load_near_signer( wallet: &str, password: Option, password_file: Option, -) -> Result<(QuantumKeyPair, PublicKey)> { - let keypair = crate::wallet::load_keypair_from_wallet(wallet, password, password_file)?; - if keypair.scheme != crate::wallet::DilithiumScheme::MlDsa65 { - return Err(QuantusError::Generic(format!( - "wallet '{wallet}' is {:?}; NEAR supports ML-DSA-65 only — create one with `quantus \ - wallet create --scheme ml-dsa-65`", - keypair.scheme - ))); +) -> Result<(NearSigner, PublicKey)> { + match crate::wallet::load_signer_from_wallet(wallet, password, password_file)? { + WalletSigner::Hot(keypair) => { + if keypair.scheme != crate::wallet::DilithiumScheme::MlDsa65 { + return Err(QuantusError::Generic(format!( + "wallet '{wallet}' is {:?}; NEAR supports ML-DSA-65 only — create one with \ + `quantus wallet create --scheme ml-dsa-65`", + keypair.scheme + ))); + } + let public = PublicKey::from_ml_dsa_65_bytes(&keypair.public_key)?; + Ok((NearSigner::Hot(keypair), public)) + }, + WalletSigner::Cold { name, address } => { + let key = WalletManager::new()?.cold_near_public_key(&name)?.ok_or_else(|| { + QuantusError::Generic(format!( + "cold wallet '{wallet}' has no NEAR public key yet — import it from the \ + device with `quantus near import-cold-key --wallet {wallet}`" + )) + })?; + // The wallet file is unencrypted; re-check the key against the + // address rather than trusting what is on disk. + let public = NearPublicKeyExport::new(address.clone(), key)?.public_key()?; + Ok((NearSigner::Cold { name, address }, public)) + }, } - let public = PublicKey::from_ml_dsa_65_bytes(&keypair.public_key)?; - Ok((keypair, public)) } fn print_key(public: &PublicKey) { @@ -484,7 +585,7 @@ fn handle_show_key( password: Option, password_file: Option, ) -> Result<()> { - let (_, public) = load_ml_dsa_65_wallet(wallet, password, password_file)?; + let (_, public) = load_near_signer(wallet, password, password_file)?; print_key(&public); Ok(()) } @@ -518,7 +619,7 @@ async fn handle_create_account( password_file: Option, ) -> Result<()> { validate_account_id(new_account)?; - let (_, public) = load_ml_dsa_65_wallet(wallet, password, password_file)?; + let (_, public) = load_near_signer(wallet, password, password_file)?; let parent = load_credentials(parent_credentials)?; if !new_account.ends_with(&format!(".{}", parent.account_id)) { @@ -604,7 +705,7 @@ async fn handle_keys( validate_account_id(account)?; let our_handle = match wallet { Some(wallet) => { - let (_, public) = load_ml_dsa_65_wallet(&wallet, password, password_file)?; + let (_, public) = load_near_signer(&wallet, password, password_file)?; Some((wallet, public.handle_string().expect("65 key"))) }, None => None, @@ -639,7 +740,7 @@ async fn handle_send( ) -> Result<()> { validate_account_id(account)?; validate_account_id(to)?; - let (keypair, public) = load_ml_dsa_65_wallet(wallet, password, password_file)?; + let (signer, public) = load_near_signer(wallet, password, password_file)?; let amount_yocto = crate::cli::send::parse_amount_with_decimals(amount, NEAR_DECIMALS)?; let client = NearRpcClient::for_network(network, rpc_url)?; @@ -667,8 +768,7 @@ async fn handle_send( wallet ); - let pair = keypair.to_dilithium65_pair()?; - let signed = sign_transaction_ml_dsa_65(tx, &pair)?; + let signed = signer.sign(tx, network).await?; let outcome = client.send_tx(&signed).await?; report_outcome(network, &outcome); log_success!("✅ Transfer finalized"); @@ -741,7 +841,7 @@ fn policy_proposal_bond(policy: &serde_json::Value) -> Result { #[allow(clippy::too_many_arguments)] async fn submit_dao_call( client: &NearRpcClient, - keypair: &QuantumKeyPair, + signer: &NearSigner, public: PublicKey, account: &str, dao: &str, @@ -769,8 +869,7 @@ async fn submit_dao_call( })], }; - let pair = keypair.to_dilithium65_pair()?; - let signed = sign_transaction_ml_dsa_65(tx, &pair)?; + let signed = signer.sign(tx, network).await?; let outcome = client.send_tx(&signed).await?; report_outcome(network, &outcome); Ok(outcome) @@ -793,7 +892,7 @@ async fn handle_dao_propose_transfer( validate_account_id(dao)?; validate_account_id(account)?; validate_account_id(receiver)?; - let (keypair, public) = load_ml_dsa_65_wallet(wallet, password, password_file)?; + let (signer, public) = load_near_signer(wallet, password, password_file)?; let amount_yocto = crate::cli::send::parse_amount_with_decimals(amount, NEAR_DECIMALS)?; let client = NearRpcClient::for_network(network, rpc_url)?; @@ -821,7 +920,7 @@ async fn handle_dao_propose_transfer( let args = transfer_proposal_args(description, receiver, amount_yocto); let outcome = submit_dao_call( &client, - &keypair, + &signer, public, account, dao, @@ -863,7 +962,7 @@ async fn handle_dao_vote( validate_account_id(dao)?; validate_account_id(account)?; let action = vote_action(vote)?; - let (keypair, public) = load_ml_dsa_65_wallet(wallet, password, password_file)?; + let (signer, public) = load_near_signer(wallet, password, password_file)?; let client = NearRpcClient::for_network(network, rpc_url)?; client.ensure_ml_dsa_support().await?; @@ -886,7 +985,7 @@ async fn handle_dao_vote( let args = serde_json::json!({ "id": id, "action": action, "proposal": kind }); submit_dao_call( &client, - &keypair, + &signer, public, account, dao, diff --git a/src/qr/key_export.rs b/src/qr/key_export.rs new file mode 100644 index 0000000..ee2308a --- /dev/null +++ b/src/qr/key_export.rs @@ -0,0 +1,207 @@ +//! A cold wallet's NEAR public key, as published in a QR. +//! +//! A cold wallet record holds only an SS58 address, which is a hash of the +//! public key: it cannot be turned back into the 1952-byte ML-DSA-65 key that +//! NEAR needs to register an access key or to name the signer of a +//! transaction. The cold wallet app shows the key in a QR and this envelope is +//! what it shows — `NearPublicKeyExport` in quantus_sdk +//! (`lib/src/models/near_public_key_export.dart`). Keep the two in step: the +//! wallets write these four keys and no others. +//! +//! `address` lets the reader tie the key to a cold account it already knows, +//! and [`NearPublicKeyExport::decode`] refuses an export whose key does not +//! hash to that address, so a mismatched or forged QR cannot attach a stranger's +//! key to a wallet. +use crate::{ + error::{QuantusError, Result}, + near::protocol::PublicKey, +}; +use qp_dilithium_crypto::types::Dilithium65Public; +use serde::{Deserialize, Serialize}; +use sp_core::crypto::{AccountId32, ByteArray, Ss58Codec}; +use sp_runtime::traits::IdentifyAccount; + +pub const NEAR_KEY_EXPORT_VERSION: u8 = 1; +pub const NEAR_KEY_EXPORT_KIND: &str = "near-public-key"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NearPublicKeyExport { + /// Quantus SS58 address of the account holding the key. + pub address: String, + /// The key in NEAR's `ml-dsa-65:` text form. + pub near_public_key: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Wire { + v: u8, + kind: String, + address: String, + near_public_key: String, +} + +impl NearPublicKeyExport { + /// Build an export, checking that `near_public_key` is an ML-DSA-65 key + /// whose Quantus account is `address`. + pub fn new(address: impl Into, near_public_key: impl Into) -> Result { + let export = Self { address: address.into(), near_public_key: near_public_key.into() }; + export.public_key()?; + Ok(export) + } + + /// The bytes that go into the UR frames. + pub fn encode(&self) -> Vec { + let wire = Wire { + v: NEAR_KEY_EXPORT_VERSION, + kind: NEAR_KEY_EXPORT_KIND.to_string(), + address: self.address.clone(), + near_public_key: self.near_public_key.clone(), + }; + serde_json::to_vec(&wire).expect("key export serialises") + } + + /// Reads an export, rejecting anything that is not exactly one. + pub fn decode(bytes: &[u8]) -> Result { + let wire: Wire = serde_json::from_slice(bytes) + .map_err(|e| QuantusError::Generic(format!("Not a NEAR public key export ({e})")))?; + if wire.v != NEAR_KEY_EXPORT_VERSION { + return Err(QuantusError::Generic(format!( + "Unsupported NEAR public key export version: {} (this build reads \ + {NEAR_KEY_EXPORT_VERSION})", + wire.v + ))); + } + if wire.kind != NEAR_KEY_EXPORT_KIND { + return Err(QuantusError::Generic(format!( + "QR is a '{}', not a NEAR public key export", + wire.kind + ))); + } + Self::new(wire.address, wire.near_public_key) + } + + /// The exported key, verified to be ML-DSA-65 and to belong to `address`. + pub fn public_key(&self) -> Result { + let key = PublicKey::parse(&self.near_public_key)?; + let PublicKey::MlDsa65(bytes) = &key else { + let scheme = key.to_near_string(); + let scheme = scheme.split_once(':').map(|(s, _)| s).unwrap_or_default(); + return Err(QuantusError::Generic(format!( + "NEAR public key export carries a {scheme} key; only ML-DSA-65 keys belong to a \ + Quantus account" + ))); + }; + let public = Dilithium65Public::from_slice(bytes.as_slice()).map_err(|_| { + QuantusError::Generic("NEAR public key export key is not a valid ML-DSA-65 key".into()) + })?; + let derived: AccountId32 = public.into_account(); + let (claimed, _) = + AccountId32::from_ss58check_with_version(&self.address).map_err(|e| { + QuantusError::Generic(format!( + "NEAR public key export address '{}' is not a valid SS58 address: {e:?}", + self.address + )) + })?; + if derived != claimed { + return Err(QuantusError::Generic(format!( + "NEAR public key export is inconsistent: the key belongs to {}, not to {}", + derived + .to_ss58check_with_version(crate::cli::address_format::quantus_ss58_format()), + self.address + ))); + } + Ok(key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use qp_dilithium_crypto::types::Dilithium65Pair; + use sp_core::Pair; + + fn near_key(seed: u8) -> (String, String) { + let pair = Dilithium65Pair::from_seed(&[seed; 32]).expect("seed"); + let account: AccountId32 = pair.public().into_account(); + let address = + account.to_ss58check_with_version(crate::cli::address_format::quantus_ss58_format()); + let key = PublicKey::from_ml_dsa_65_bytes(pair.public().as_slice()) + .expect("1952 bytes") + .to_near_string(); + (address, key) + } + + fn export() -> NearPublicKeyExport { + let (address, key) = near_key(7); + NearPublicKeyExport::new(address, key).expect("consistent export") + } + + #[test] + fn round_trips_through_the_wire_format() { + let export = export(); + let decoded = NearPublicKeyExport::decode(&export.encode()).expect("decodes"); + assert_eq!(decoded, export); + } + + #[test] + fn wire_format_matches_the_sdk() { + let export = export(); + let json: serde_json::Value = serde_json::from_slice(&export.encode()).expect("json"); + let object = json.as_object().expect("object"); + let mut keys: Vec<_> = object.keys().cloned().collect(); + keys.sort(); + assert_eq!(keys, ["address", "kind", "near_public_key", "v"]); + assert_eq!(object["v"], 1); + assert_eq!(object["kind"], "near-public-key"); + assert!(object["near_public_key"].as_str().unwrap().starts_with("ml-dsa-65:")); + } + + #[test] + fn rejects_unknown_keys_version_and_kind() { + let export = export(); + let mut json: serde_json::Value = serde_json::from_slice(&export.encode()).unwrap(); + + json["extra"] = serde_json::json!(1); + assert!(NearPublicKeyExport::decode(json.to_string().as_bytes()).is_err()); + json.as_object_mut().unwrap().remove("extra"); + + json["v"] = serde_json::json!(2); + let err = NearPublicKeyExport::decode(json.to_string().as_bytes()).unwrap_err(); + assert!(err.to_string().contains("version"), "{err}"); + json["v"] = serde_json::json!(1); + + json["kind"] = serde_json::json!("quantus-address"); + let err = NearPublicKeyExport::decode(json.to_string().as_bytes()).unwrap_err(); + assert!(err.to_string().contains("not a NEAR public key export"), "{err}"); + } + + #[test] + fn rejects_a_signing_request() { + let request = crate::qr::SignRequest::new("qz...", vec![1, 2, 3]).encode(); + assert!(NearPublicKeyExport::decode(&request).is_err()); + } + + #[test] + fn rejects_a_key_that_does_not_belong_to_the_address() { + let export = export(); + let (_, other_key) = near_key(8); + let err = NearPublicKeyExport::new(export.address, other_key).unwrap_err(); + assert!(err.to_string().contains("inconsistent"), "{err}"); + } + + #[test] + fn rejects_a_non_ml_dsa_key() { + let export = export(); + let ed = format!("ed25519:{}", bs58::encode([1u8; 32]).into_string()); + let err = NearPublicKeyExport::new(export.address, ed).unwrap_err(); + assert!(err.to_string().contains("ML-DSA-65"), "{err}"); + } + + #[test] + fn rejects_a_bad_address() { + let export = export(); + let err = NearPublicKeyExport::new("not-an-address", export.near_public_key).unwrap_err(); + assert!(err.to_string().contains("SS58"), "{err}"); + } +} diff --git a/src/qr/mod.rs b/src/qr/mod.rs index a4f3850..7abf1b3 100644 --- a/src/qr/mod.rs +++ b/src/qr/mod.rs @@ -6,9 +6,11 @@ //! crate), while cold-wallet addresses are plain SS58 strings in a single QR. pub mod display; +pub mod key_export; pub mod scanner; pub mod sign_request; pub use display::{display_ur_until_enter, render_ur_frames}; +pub use key_export::NearPublicKeyExport; pub use scanner::{scan_quantus_address, scan_ur, UrSource}; pub use sign_request::{AnySignRequest, NearSignRequest, SignRequest}; diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index db53767..c81cdba 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -428,6 +428,11 @@ pub struct EncryptedWallet { pub aes_nonce: Vec, // AES-GCM nonce pub encryption_version: u32, // Version for future crypto upgrades pub created_at: chrono::DateTime, + /// NEAR public key (`ml-dsa-65:`) imported from a cold device's + /// key-export QR. Only set on cold wallets; hot wallets derive it from + /// their key material. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub near_public_key: Option, } impl EncryptedWallet { @@ -445,6 +450,7 @@ impl EncryptedWallet { aes_nonce: vec![], encryption_version: 0, created_at: chrono::Utc::now(), + near_public_key: None, } } } @@ -751,6 +757,7 @@ impl Keystore { aes_nonce: nonce.to_vec(), encryption_version: 2, // Version 2: Argon2 params+salt only (no digest) + AES-256-GCM created_at: chrono::Utc::now(), + near_public_key: None, }) } @@ -1447,6 +1454,7 @@ mod tests { aes_nonce: nonce.to_vec(), encryption_version: 1, created_at: chrono::Utc::now(), + near_public_key: None, } } @@ -1561,6 +1569,7 @@ mod tests { aes_nonce: nonce_bytes.to_vec(), encryption_version: 2, created_at: chrono::Utc::now(), + near_public_key: None, } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 506f91a..1eeacfe 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -335,6 +335,44 @@ impl WalletManager { }) } + fn load_cold_wallet( + &self, + keystore: &Keystore, + name: &str, + ) -> Result { + let wallet = keystore.load_wallet(name)?.ok_or(WalletError::NotFound)?; + if wallet.wallet_type != keystore::WalletType::Cold { + return Err(crate::error::QuantusError::Generic(format!( + "wallet '{name}' is a hot wallet; its NEAR public key comes from its own key \ + material — see `quantus near show-key`" + ))); + } + Ok(wallet) + } + + /// The NEAR public key (`ml-dsa-65:`) a cold wallet imported from + /// its device, if any. Errors if `name` is not a cold wallet. + pub fn cold_near_public_key(&self, name: &str) -> Result> { + let keystore = Keystore::new(&self.wallets_dir); + Ok(self.load_cold_wallet(&keystore, name)?.near_public_key) + } + + /// Record the NEAR public key a cold wallet's device exported. The caller + /// has already checked that the key belongs to the wallet's address. + pub fn set_cold_near_public_key(&self, name: &str, near_public_key: &str) -> Result<()> { + let keystore = Keystore::new(&self.wallets_dir); + let current = self.load_cold_wallet(&keystore, name)?; + let mut updated = keystore::EncryptedWallet::new_cold(name, ¤t.address); + updated.created_at = current.created_at; + updated.near_public_key = Some(near_public_key.to_string()); + if !keystore.save_wallet_if_current(&updated, ¤t)? { + return Err(crate::error::QuantusError::Generic(format!( + "wallet '{name}' changed on disk while importing the key; retry" + ))); + } + Ok(()) + } + /// Cheap wallet-type probe from the unencrypted wallet file. /// Returns `None` if no wallet with that name exists. pub fn wallet_type(&self, name: &str) -> Result> { @@ -1615,6 +1653,38 @@ mod tests { )); } + #[tokio::test] + async fn test_cold_wallet_near_public_key_round_trip() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let address = cold_test_address(); + wallet_manager.create_cold_wallet("frosty", &address).unwrap(); + + // Absent until imported, and absent from the file rather than null + assert_eq!(wallet_manager.cold_near_public_key("frosty").unwrap(), None); + let file = wallet_manager.wallets_dir.join("frosty.json"); + assert!(!fs::read_to_string(&file).unwrap().contains("near_public_key")); + + wallet_manager.set_cold_near_public_key("frosty", "ml-dsa-65:abc").unwrap(); + assert_eq!( + wallet_manager.cold_near_public_key("frosty").unwrap().as_deref(), + Some("ml-dsa-65:abc") + ); + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(json["wallet_type"], "cold"); + assert_eq!(json["address"], address); + assert_eq!(json["near_public_key"], "ml-dsa-65:abc"); + + // Hot and missing wallets are refused + wallet_manager.create_wallet("hot-one", Some("pw")).await.unwrap(); + assert!(wallet_manager.cold_near_public_key("hot-one").is_err()); + assert!(wallet_manager.set_cold_near_public_key("hot-one", "ml-dsa-65:abc").is_err()); + assert!(matches!( + wallet_manager.cold_near_public_key("nobody"), + Err(crate::error::QuantusError::Wallet(WalletError::NotFound)) + )); + } + #[tokio::test] async fn test_cold_wallet_rejects_bad_addresses_and_duplicates() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; From a9510f29a13f85c105e6f23e9a4a88882cf8c54b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 10:41:28 +0000 Subject: [PATCH 2/2] Bind the cold NEAR key save to the address the key was checked against The address is read before the QR scan waits; if the wallet name is deleted and re-imported with another address meanwhile, the guarded save would have attached the validated key to the replacement record. --- src/cli/near.rs | 2 +- src/wallet/mod.rs | 47 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/cli/near.rs b/src/cli/near.rs index bbaa1c8..0a8f283 100644 --- a/src/cli/near.rs +++ b/src/cli/near.rs @@ -408,7 +408,7 @@ async fn handle_import_cold_key(wallet: &str, key: Option) -> Result<()> print_key(&public); return Ok(()); } - manager.set_cold_near_public_key(wallet, &export.near_public_key)?; + manager.set_cold_near_public_key(wallet, &address, &export.near_public_key)?; log_success!("✅ NEAR key of cold wallet '{wallet}' ({address}) saved"); print_key(&public); log_print!( diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1eeacfe..e065ba5 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -358,10 +358,24 @@ impl WalletManager { } /// Record the NEAR public key a cold wallet's device exported. The caller - /// has already checked that the key belongs to the wallet's address. - pub fn set_cold_near_public_key(&self, name: &str, near_public_key: &str) -> Result<()> { + /// has checked that the key belongs to `address`; the save is refused if + /// the wallet on disk no longer has that address (e.g. it was deleted and + /// re-imported while the QR scan was waiting). + pub fn set_cold_near_public_key( + &self, + name: &str, + address: &str, + near_public_key: &str, + ) -> Result<()> { let keystore = Keystore::new(&self.wallets_dir); let current = self.load_cold_wallet(&keystore, name)?; + if current.address != address { + return Err(crate::error::QuantusError::Generic(format!( + "wallet '{name}' is now {}, not {address}; the key was checked against the old \ + address, so nothing was saved — retry", + current.address + ))); + } let mut updated = keystore::EncryptedWallet::new_cold(name, ¤t.address); updated.created_at = current.created_at; updated.near_public_key = Some(near_public_key.to_string()); @@ -1664,7 +1678,9 @@ mod tests { let file = wallet_manager.wallets_dir.join("frosty.json"); assert!(!fs::read_to_string(&file).unwrap().contains("near_public_key")); - wallet_manager.set_cold_near_public_key("frosty", "ml-dsa-65:abc").unwrap(); + wallet_manager + .set_cold_near_public_key("frosty", &address, "ml-dsa-65:abc") + .unwrap(); assert_eq!( wallet_manager.cold_near_public_key("frosty").unwrap().as_deref(), Some("ml-dsa-65:abc") @@ -1678,13 +1694,36 @@ mod tests { // Hot and missing wallets are refused wallet_manager.create_wallet("hot-one", Some("pw")).await.unwrap(); assert!(wallet_manager.cold_near_public_key("hot-one").is_err()); - assert!(wallet_manager.set_cold_near_public_key("hot-one", "ml-dsa-65:abc").is_err()); + assert!(wallet_manager + .set_cold_near_public_key("hot-one", &address, "ml-dsa-65:abc") + .is_err()); assert!(matches!( wallet_manager.cold_near_public_key("nobody"), Err(crate::error::QuantusError::Wallet(WalletError::NotFound)) )); } + #[tokio::test] + async fn test_cold_wallet_near_public_key_save_is_bound_to_the_validated_address() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let address = cold_test_address(); + wallet_manager.create_cold_wallet("frosty", &address).unwrap(); + + // The wallet name is deleted and re-imported with another address + // between validating the key and saving it. + let other = QuantumKeyPair::from_resonance_pair(&qp_dilithium_crypto::dilithium_bob()) + .try_to_account_id_ss58check() + .unwrap(); + fs::remove_file(wallet_manager.wallets_dir.join("frosty.json")).unwrap(); + wallet_manager.create_cold_wallet("frosty", &other).unwrap(); + + let err = wallet_manager + .set_cold_near_public_key("frosty", &address, "ml-dsa-65:abc") + .unwrap_err(); + assert!(err.to_string().contains("nothing was saved"), "{err}"); + assert_eq!(wallet_manager.cold_near_public_key("frosty").unwrap(), None); + } + #[tokio::test] async fn test_cold_wallet_rejects_bad_addresses_and_duplicates() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await;