diff --git a/README.md b/README.md index 64ae53e..396c111 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A modern command line interface for interacting with the Quantus Network, featur - **Quantum-Safe Wallets**: Built with Dilithium post-quantum cryptography - **Cold Wallet Signing**: Air-gapped signing over QR codes with Keystone or the Quantus cold wallet app +- **NEAR Accounts**: Control NEAR accounts with an ML-DSA-65 wallet, hot or cold, including any contract call prepared with near-cli-rs - **Airdrop Claims**: Find and claim testnet rewards across every historical key-derivation scheme - **SubXT Integration**: Modern Substrate client with type-safe API - **Generic Pallet Calls**: Call ANY blockchain function using metadata-driven parsing @@ -655,6 +656,71 @@ Notes: --- +### NEAR Accounts + +NEAR accepts ML-DSA-65 access keys and signatures from protocol version 85, +so a Quantus ML-DSA-65 wallet (`quantus wallet create --scheme ml-dsa-65`) +can be the sole key on a NEAR account. ML-DSA-87 wallets are rejected: NEAR +defined ML-DSA-65 only. + +```bash +# The wallet's key in NEAR text form (ml-dsa-65:) and its on-chain handle +quantus near show-key --wallet my65 + +# Create a sub-account whose only access key is the wallet's key +quantus near create-account --new-account vault.alice.testnet --wallet my65 \ + --parent-credentials ~/.near-credentials/testnet/alice.testnet.json + +# Verify the key list from chain state +quantus near keys --account vault.alice.testnet --wallet my65 + +# Spend from it +quantus near send --wallet my65 --account vault.alice.testnet --to bob.testnet --amount 1.5 + +# Sputnik DAO membership: propose and vote +quantus near dao propose-transfer --dao treasury.sputnik-dao.testnet --account vault.alice.testnet \ + --wallet my65 --receiver bob.testnet --amount 10 +quantus near dao vote --dao treasury.sputnik-dao.testnet --account vault.alice.testnet \ + --wallet my65 --id 3 --vote approve +``` + +#### Cold signing any NEAR transaction + +`quantus near sign-cold` signs a transaction prepared by +[near-cli-rs](https://github.com/near/near-cli-rs) with a cold wallet over +the same QR transport as Quantus extrinsics. This covers every contract +call β€” swaps, lending, staking β€” not just the commands above. + +```bash +# 1. Build the unsigned transaction offline with near-cli-rs. The signer key is +# the cold wallet's ML-DSA-65 key; nonce and block hash come from +# `near account view-account-summary` / `view-access-key` or any RPC. +near contract call-function as-transaction v2.ref-finance.near storage_deposit \ + json-args '{"registration_only": true}' prepaid-gas '30 Tgas' attached-deposit '0.125 NEAR' \ + sign-as vault.alice.near network-config mainnet \ + sign-later --signer-public-key ml-dsa-65: --nonce --block-hash \ + save-to-file unsigned.json + +# 2. Sign it on the cold wallet: the CLI shows the transaction as a QR, +# then scans the device's signature QR and verifies it against the stored +# address before producing a signed transaction. +quantus near sign-cold --unsigned-tx @unsigned.json --wallet my_cold --network mainnet --out signed.b64 + +# 3. Submit with near-cli-rs, or add --send in step 2 to submit directly. +near transaction send-signed-transaction file-with-base64-signed-transaction signed.b64 \ + network-config mainnet send +``` + +The device sees the decoded transaction (signer, receiver, nonce, every +action) and signs only if the transaction's declared key is its own. The CLI +accepts the response only if that key also hashes to the cold wallet's stored +address, so a transaction built for someone else's key cannot be signed as +`--wallet my_cold`. The hidden `quantus developer cold-sign-sim` command +answers NEAR requests too, using a local ML-DSA-65 hot wallet, for end-to-end +testing without a device. + +--- + ### Sending Tokens ```bash diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs index ec87239..769278e 100644 --- a/src/cli/cold_signing.rs +++ b/src/cli/cold_signing.rs @@ -17,12 +17,18 @@ //! Invariant: nonce and block context are captured **once** into [`TxContext`]; //! the QR payload and the final extrinsic are both built from it. Refetching //! anything in between would silently invalidate the signature. +//! +//! The same QR transport also carries NEAR transactions (envelope v2, see +//! `crate::near::cold`); the simulator below answers both kinds. use crate::{ chain::client::{ChainConfig, QuantusClient}, error::{QuantusError, Result}, log_print, log_verbose, - qr::{display_ur_until_enter, render_ur_frames, scan_ur, SignRequest, UrSource}, + qr::{ + display_ur_until_enter, render_ur_frames, scan_ur, AnySignRequest, NearSignRequest, + SignRequest, UrSource, + }, }; use colored::Colorize; use qp_dilithium_crypto::types::{ @@ -283,7 +289,7 @@ fn validate_signature_response( Ok(signature) } -fn confirm_or_abort(prompt: &str) -> Result<()> { +pub(crate) fn confirm_or_abort(prompt: &str) -> Result<()> { use std::io::Write; print!("{prompt}"); std::io::stdout().flush()?; @@ -295,6 +301,72 @@ fn confirm_or_abort(prompt: &str) -> Result<()> { Ok(()) } +/// Hand an encoded sign request to the cold wallet: UR-encode it, write the +/// request file if configured, and in an interactive session display the +/// animated QR and prompt until the user is ready to scan the response. +/// +/// Returns whether the session is interactive, which decides if a rescan-safe +/// validation failure may prompt for a rescan. +pub(crate) async fn present_sign_request(request: &[u8], io: &ColdIo) -> Result { + let parts = quantus_ur::encode_bytes(request) + .map_err(|e| QuantusError::Generic(format!("Failed to UR-encode payload: {e:?}")))?; + + // A response file existing before this request is handed out is necessarily + // from an earlier session (the response depends on this request); together + // with consume-on-read in the scanner this keeps every roundtrip fresh. + if let Some(UrSource::File(path)) = &io.response_in { + match std::fs::remove_file(path) { + Ok(()) => log_print!("🧹 Removed stale response file {}", path.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}, + Err(e) => return Err(e.into()), + } + } + + if let Some(path) = &io.request_out { + let content = parts.join("\n") + "\n"; + std::fs::write(path, content)?; + log_print!("πŸ“€ Sign request written to {}", path.display()); + } + + // Skip the QR display and prompts when scripted (stdin is not a terminal); + // the request file above is the handoff instead. + let interactive = std::io::stdin().is_terminal(); + require_interactive_or_request_out(interactive, io)?; + if interactive { + let frames = render_ur_frames(&parts)?; + log_print!(""); + display_ur_until_enter( + &frames, + "πŸ“± Scan this QR with your cold wallet, then press Enter here…", + ) + .await?; + confirm_or_abort( + "✍️ Sign the transaction on the cold wallet. Ready to scan its response? [Enter to scan / q to abort]: ", + )?; + } else { + log_print!( + "πŸ€– Non-interactive session: skipping QR display, waiting for the signature response…" + ); + } + Ok(interactive) +} + +/// Where the signature response comes from: the configured override, else the +/// camera. +pub(crate) fn response_source(io: &ColdIo) -> Result { + match &io.response_in { + Some(source) => Ok(source.clone()), + None => default_response_source(io), + } +} + +/// Read one signature response from `source`. +pub(crate) async fn read_signature_response(source: &UrSource) -> Result> { + let response = scan_ur(source, RESPONSE_TIMEOUT).await?; + log_verbose!("πŸ“₯ Received {} response bytes", response.len()); + Ok(response) +} + /// Run the full cold signing flow for `call` and submit the result. /// /// Generic over the call payload; the shared submit stage in `cli::common` @@ -350,56 +422,12 @@ pub async fn sign_and_submit_cold( // accounts cannot tell which key this wants, and one holding none of them // cannot tell that it holds the wrong key. let request = SignRequest::new(cold_address_ss58, raw_payload.clone()); - let parts = quantus_ur::encode_bytes(&request.encode()) - .map_err(|e| QuantusError::Generic(format!("Failed to UR-encode payload: {e:?}")))?; - - // A response file existing before this request is handed out is necessarily - // from an earlier session (the response depends on this request); together - // with consume-on-read in the scanner this keeps every roundtrip fresh. - if let Some(UrSource::File(path)) = &io.response_in { - match std::fs::remove_file(path) { - Ok(()) => log_print!("🧹 Removed stale response file {}", path.display()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}, - Err(e) => return Err(e.into()), - } - } - - if let Some(path) = &io.request_out { - let content = parts.join("\n") + "\n"; - std::fs::write(path, content)?; - log_print!("πŸ“€ Sign request written to {}", path.display()); - } - - // Skip the QR display and prompts when scripted (stdin is not a terminal); - // the request file above is the handoff instead. - let interactive = std::io::stdin().is_terminal(); - require_interactive_or_request_out(interactive, io)?; - if interactive { - let frames = render_ur_frames(&parts)?; - log_print!(""); - display_ur_until_enter( - &frames, - "πŸ“± Scan this QR with your cold wallet, then press Enter here…", - ) - .await?; - confirm_or_abort( - "✍️ Sign the transaction on the cold wallet. Ready to scan its response? [Enter to scan / q to abort]: ", - )?; - } else { - log_print!( - "πŸ€– Non-interactive session: skipping QR display, waiting for the signature response…" - ); - } + let interactive = present_sign_request(&request.encode(), io).await?; - // 3. Collect and validate the signature response. Fall back to the camera - // only when no explicit source was given. - let source = match &io.response_in { - Some(source) => source.clone(), - None => default_response_source(io)?, - }; + // 3. Collect and validate the signature response. + let source = response_source(io)?; let signature = loop { - let response = scan_ur(&source, RESPONSE_TIMEOUT).await?; - log_verbose!("πŸ“₯ Received {} response bytes", response.len()); + let response = read_signature_response(&source).await?; match validate_signature_response( &raw_payload, @@ -511,7 +539,15 @@ pub async fn handle_cold_sign_sim( Some(path) => UrSource::File(PathBuf::from(path)), None => UrSource::StdinLines, }; - let request = SignRequest::decode(&scan_ur(&request_source, Duration::from_secs(60)).await?)?; + let request = + match AnySignRequest::decode(&scan_ur(&request_source, Duration::from_secs(60)).await?)? { + AnySignRequest::Quantus(request) => request, + AnySignRequest::Near(request) => { + let response_bytes = + sign_near_request_as_device(&request, &wallet, password, password_file)?; + return write_sim_response(&response_bytes, response_file.as_deref()); + }, + }; let payload = request.payload.clone(); if payload.len() < 2 { @@ -562,10 +598,64 @@ pub async fn handle_cold_sign_sim( }; // 3. Emit the response UR. - let parts = quantus_ur::encode_bytes(&response_bytes) + write_sim_response(&response_bytes, response_file.as_deref()) +} + +/// 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 +/// SHA-256 of the transaction bytes as a pure (empty-context) ML-DSA-65 +/// signature. The response is `signature β€– public_key`, the same shape as a +/// Quantus response, so the CLI can tie the signature to the wallet's SS58 +/// address as well as to the transaction's key. +fn sign_near_request_as_device( + request: &NearSignRequest, + wallet: &str, + password: Option, + password_file: Option, +) -> Result> { + use crate::near::protocol::{PublicKey, Transaction}; + + let tx = Transaction::from_bytes(&request.transaction)?; + log_print!("🧾 NEAR sign request ({} bytes, {})", request.transaction.len(), request.network); + log_print!(" Signer: {}", crate::near::protocol::render_text(&tx.signer_id).bright_cyan()); + log_print!(" Key: {}", tx.public_key.to_near_string()); + log_print!( + " Receiver: {}", + crate::near::protocol::render_text(&tx.receiver_id).bright_cyan() + ); + log_print!(" Nonce: {}", tx.nonce); + for action in tx.describe_actions() { + log_print!(" Action: {action}"); + } + + 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 transactions are signed with ML-DSA-65 only. Nothing \ + was signed.", + keypair.scheme + ))); + } + let our_key = PublicKey::from_ml_dsa_65_bytes(&keypair.public_key)?; + if tx.public_key != our_key { + return Err(QuantusError::Generic(format!( + "This transaction is signed by {}, but wallet '{wallet}' is {}. Nothing was signed.", + tx.public_key.to_near_string(), + our_key.to_near_string() + ))); + } + + let hash = crate::near::sign::transaction_hash(&tx)?; + let pair = keypair.to_dilithium65_pair()?; + Ok(crate::chain::signing::sign_ml_dsa_65(&pair, &hash, None).to_bytes().to_vec()) +} + +fn write_sim_response(response_bytes: &[u8], response_file: Option<&str>) -> Result<()> { + let parts = quantus_ur::encode_bytes(response_bytes) .map_err(|e| QuantusError::Generic(format!("Failed to UR-encode response: {e:?}")))?; - match &response_file { + match response_file { Some(path) => { // Atomic write: pollers must never observe a half-written response. let tmp = format!("{path}.tmp"); diff --git a/src/cli/near.rs b/src/cli/near.rs index bd00c72..4d2946e 100644 --- a/src/cli/near.rs +++ b/src/cli/near.rs @@ -12,13 +12,17 @@ //! 5. `near dao ...` β€” act as a co-signer in a Sputnik DAO multisig (the contract behind Trezu): //! propose transfers and vote, signed by the wallet. Sputnik authorizes by account id, so an //! 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. //! //! ML-DSA-87 wallets are rejected: NEAR defined ML-DSA-65 only. use crate::{ + cli::cold_signing::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, @@ -151,6 +155,40 @@ pub enum NearCommands { password_file: Option, }, + /// Sign a prepared NEAR transaction with a cold (air-gapped) Quantus + /// wallet over QR codes. Build the transaction with near-cli-rs + /// (`... sign-later --signer-public-key ml-dsa-65: ... save-to-file`) + /// and pass its output here; the result is a base64 signed transaction + /// for `near transaction send-signed-transaction`, or send it directly + /// with --send. + SignCold { + /// Unsigned transaction: base64 borsh, or @path to a file holding + /// base64 or the JSON `sign-later ... save-to-file` writes + #[arg(long)] + unsigned_tx: String, + + /// Cold wallet (created with `quantus wallet create-cold`) whose + /// ML-DSA-65 key the transaction declares + #[arg(long, short)] + wallet: String, + + /// NEAR network the transaction is for, shown on the device: testnet or mainnet + #[arg(long, default_value = "testnet")] + network: String, + + /// Write the signed transaction (base64) to this file + #[arg(long)] + out: Option, + + /// Submit the signed transaction to the network and wait for finality + #[arg(long)] + send: bool, + + /// Custom NEAR RPC URL for --send (overrides --network) + #[arg(long)] + rpc_url: Option, + }, + /// Act in a Sputnik DAO multisig (the contract behind Trezu) as a member /// account controlled by the wallet Dao { @@ -308,10 +346,57 @@ pub async fn handle_near_command(command: NearCommands) -> Result<()> { } => handle_send(&wallet, &account, &to, &amount, &network, rpc_url, password, password_file) .await, + NearCommands::SignCold { unsigned_tx, wallet, network, out, send, rpc_url } => + handle_sign_cold(&unsigned_tx, &wallet, &network, out, send, rpc_url).await, NearCommands::Dao { command } => handle_dao_command(command).await, } } +async fn handle_sign_cold( + unsigned_tx: &str, + wallet: &str, + network: &str, + out: Option, + send: bool, + rpc_url: Option, +) -> Result<()> { + let cold_address = match crate::wallet::load_signer_from_wallet(wallet, None, None)? { + crate::wallet::WalletSigner::Cold { address, .. } => address, + crate::wallet::WalletSigner::Hot(_) => + return Err(QuantusError::Generic(format!( + "wallet '{wallet}' is a hot wallet; sign-cold is for cold wallets. Use `quantus \ + near send` or the other hot commands with it instead." + ))), + }; + let tx = load_unsigned_transaction(unsigned_tx)?; + + let signed = + sign_transaction_cold(tx, network, wallet, &cold_address, ColdIo::global()).await?; + let signed_b64 = signed.to_base64()?; + + if let Some(path) = &out { + std::fs::write(path, format!("{signed_b64}\n"))?; + log_print!("πŸ“ Signed transaction written to {}", path.display()); + } else if !send { + log_print!("Signed transaction (base64):"); + println!("{signed_b64}"); + } + + if send { + let client = NearRpcClient::for_network(network, rpc_url)?; + client.ensure_ml_dsa_support().await?; + let outcome = client.send_tx(&signed).await?; + report_outcome(network, &outcome); + log_success!("βœ… Transaction finalized"); + } else { + log_print!( + "πŸ’‘ Submit with: near transaction send-signed-transaction base64-signed-transaction \ + '' network-config {network} send" + ); + } + Ok(()) +} + async fn handle_dao_command(command: DaoCommands) -> Result<()> { match command { DaoCommands::ProposeTransfer { diff --git a/src/near/cold.rs b/src/near/cold.rs new file mode 100644 index 0000000..436dcdf --- /dev/null +++ b/src/near/cold.rs @@ -0,0 +1,365 @@ +//! Air-gapped signing of NEAR transactions with a Quantus cold wallet. +//! +//! The transaction is built elsewhere (near-cli-rs `sign-later`, or any tool +//! that emits a borsh `TransactionV0`) and handed to the cold wallet over the +//! same `ur:quantus-sign-request` QR transport Quantus extrinsics use, in the +//! version-2 envelope ([`crate::qr::NearSignRequest`]): +//! +//! 1. The CLI displays `{v: 2, chain: "near", network, payload: borsh(tx)}`. The transaction is +//! sent raw so the device can decode and show the actions. +//! 2. The device checks `tx.public_key` is its own ML-DSA-65 key, signs `SHA-256(borsh(tx))` as a +//! pure FIPS 204 signature (empty context β€” NEAR verifies without one), and answers +//! `signature[3309] β€– public_key[1952]`. +//! 3. The CLI checks the response key is `tx.public_key` *and* hashes to the cold wallet's stored +//! SS58 address, verifies the signature, and assembles `borsh(tx) β€– 0x02 β€– signature` β€” the +//! `SignedTransaction` NEAR accepts. +//! +//! The ML-DSA-87 response size is refused: NEAR defined ML-DSA-65 only. + +use crate::{ + cli::cold_signing::{ + confirm_or_abort, present_sign_request, read_signature_response, response_source, ColdIo, + SIGNATURE_RESPONSE_LEN_ML_DSA_65, + }, + error::{QuantusError, Result}, + log_print, log_verbose, + near::{ + protocol::{ + render_text, PublicKey, Signature, SignedTransaction, Transaction, + ML_DSA_65_SIGNATURE_LEN, + }, + sign::transaction_hash, + }, + qr::NearSignRequest, +}; +use colored::Colorize; +use qp_dilithium_crypto::types::Dilithium65SignatureWithPublic; +use sp_core::crypto::AccountId32; +use sp_runtime::traits::IdentifyAccount; + +/// Why a NEAR signature response was rejected. +#[derive(Debug)] +pub enum ResponseError { + /// Wrong size β€” an incomplete scan, or an ML-DSA-87 device. Rescan-safe. + BadLength(usize), + /// Bytes do not parse as signature β€– public key. Rescan-safe. + Malformed(String), + /// The response key is not the key the transaction declares. + WrongKey { got: String }, + /// The response key is `tx.public_key` but not the cold wallet's. The + /// transaction was built for a different wallet than `--wallet` names. + WrongWallet { got: String }, + /// Right key, signature does not verify over this transaction. + BadSignature, +} + +impl ResponseError { + pub fn rescan_safe(&self) -> bool { + matches!(self, ResponseError::BadLength(_) | ResponseError::Malformed(_)) + } + + pub fn message(&self, wallet_name: &str) -> String { + match self { + ResponseError::BadLength(got) => format!( + "Response has {got} bytes, expected {SIGNATURE_RESPONSE_LEN_ML_DSA_65} \ + (ML-DSA-65 signature β€– public key). The scan was likely incomplete, or the device \ + signed with an ML-DSA-87 key, which NEAR does not accept β€” rescan the response." + ), + ResponseError::Malformed(e) => format!( + "Response bytes do not parse as an ML-DSA-65 signature + public key ({e}) β€” \ + rescan the response." + ), + ResponseError::WrongKey { got } => format!( + "Response was signed by {got}, which is not the key this transaction declares. \ + Aborting β€” check that the right device/account signed." + ), + ResponseError::WrongWallet { got } => format!( + "Response key {got} matches the transaction but is not cold wallet \ + '{wallet_name}'. Aborting β€” the transaction was built for a different wallet's \ + key." + ), + ResponseError::BadSignature => + "Signature does not verify over this transaction. Aborting β€” the cold wallet may \ + have signed a stale QR; run the command again." + .to_string(), + } + } +} + +/// Check a device response against the transaction it was requested for and +/// the cold wallet it was requested from, returning the bare NEAR signature. +pub fn validate_near_response( + tx: &Transaction, + response: &[u8], + expected_account: &AccountId32, +) -> std::result::Result { + if response.len() != SIGNATURE_RESPONSE_LEN_ML_DSA_65 { + return Err(ResponseError::BadLength(response.len())); + } + let swp = Dilithium65SignatureWithPublic::from_bytes(response) + .map_err(|e| ResponseError::Malformed(format!("{e:?}")))?; + + let response_key = PublicKey::from_ml_dsa_65_bytes(swp.public().as_ref()) + .map_err(|e| ResponseError::Malformed(e.to_string()))?; + if response_key != tx.public_key { + return Err(ResponseError::WrongKey { got: response_key.to_near_string() }); + } + + let derived_account: AccountId32 = swp.public().into_account(); + if derived_account != *expected_account { + return Err(ResponseError::WrongWallet { got: response_key.to_near_string() }); + } + + let hash = transaction_hash(tx).map_err(|e| ResponseError::Malformed(e.to_string()))?; + if !crate::chain::signing::verify_ml_dsa_65(&swp, &hash, None) { + return Err(ResponseError::BadSignature); + } + + let signature: [u8; ML_DSA_65_SIGNATURE_LEN] = swp + .signature() + .as_ref() + .try_into() + .expect("ML-DSA-65 signature length is fixed"); + Ok(Signature::MlDsa65(Box::new(signature))) +} + +/// Parse the cold wallet's stored SS58 address into the account the response +/// key must hash to. +pub fn parse_cold_account(wallet_name: &str, cold_address_ss58: &str) -> Result { + use sp_core::crypto::Ss58Codec; + AccountId32::from_ss58check_with_version(cold_address_ss58) + .map(|(account, _)| account) + .map_err(|e| { + QuantusError::Generic(format!( + "Cold wallet '{wallet_name}' has an invalid stored address: {e:?}" + )) + }) +} + +/// Run the QR roundtrip for `tx` against cold wallet `wallet_name` and return +/// the signed transaction. Nothing is submitted here. +pub async fn sign_transaction_cold( + tx: Transaction, + network: &str, + wallet_name: &str, + cold_address_ss58: &str, + io: &ColdIo, +) -> Result { + let PublicKey::MlDsa65(_) = &tx.public_key else { + return Err(QuantusError::Generic(format!( + "transaction declares key {}; a Quantus cold wallet can only sign for an ml-dsa-65 \ + key", + tx.public_key.to_near_string() + ))); + }; + let account = parse_cold_account(wallet_name, cold_address_ss58)?; + let tx_bytes = tx.to_bytes()?; + let request = NearSignRequest::new(network, tx_bytes)?; + + log_print!("🧊 Cold wallet signing with '{}'", wallet_name.bright_blue().bold()); + log_print!(" Wallet: {}", cold_address_ss58.bright_cyan()); + log_print!(" Network: {network}"); + log_print!(" Signer: {}", render_text(&tx.signer_id).bright_cyan()); + log_print!(" Key: {}", tx.public_key.to_near_string()); + log_print!(" Receiver: {}", render_text(&tx.receiver_id).bright_cyan()); + log_print!(" Nonce: {}", tx.nonce); + log_print!(" Block: {}", bs58::encode(tx.block_hash).into_string()); + for action in tx.describe_actions() { + log_print!(" Action: {action}"); + } + log_print!(" Hash: {}", hex::encode(transaction_hash(&tx)?)); + log_verbose!(" Transaction borsh: 0x{}", hex::encode(&request.transaction)); + + let interactive = present_sign_request(&request.encode(), io).await?; + + let source = response_source(io)?; + let signature = loop { + let response = read_signature_response(&source).await?; + match validate_near_response(&tx, &response, &account) { + Ok(signature) => break signature, + Err(err) => { + let msg = err.message(wallet_name); + if err.rescan_safe() && interactive { + log_print!("⚠️ {}", msg); + confirm_or_abort("Rescan? [Enter to rescan / q to abort]: ")?; + continue; + } + return Err(QuantusError::Generic(msg)); + }, + } + }; + log_print!("βœ… Signature verified against cold wallet '{}'", wallet_name.bright_blue()); + + Ok(SignedTransaction { transaction: tx, signature }) +} + +/// Read an unsigned transaction from the CLI argument: inline base64, or +/// `@path` to a file holding either bare base64 or the JSON near-cli-rs +/// `sign-later ... save-to-file` writes. +pub fn load_unsigned_transaction(arg: &str) -> Result { + let Some(path) = arg.strip_prefix('@') else { + return Transaction::from_base64(arg); + }; + let text = std::fs::read_to_string(path) + .map_err(|e| QuantusError::Generic(format!("reading {path}: {e}")))?; + transaction_from_file_text(&text) +} + +fn transaction_from_file_text(text: &str) -> Result { + let text = text.trim(); + if let Ok(json) = serde_json::from_str::(text) { + // near-cli-rs keys its file by prose; pick the value that decodes. + let candidates: Vec<&str> = match &json { + serde_json::Value::Object(map) => map.values().filter_map(|v| v.as_str()).collect(), + serde_json::Value::String(s) => vec![s.as_str()], + _ => Vec::new(), + }; + return candidates.iter().find_map(|c| Transaction::from_base64(c).ok()).ok_or_else(|| { + QuantusError::Generic( + "no field in the JSON file decodes as a base64 NEAR transaction".to_string(), + ) + }); + } + Transaction::from_base64(text) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::near::protocol::{Action, TransferAction}; + use qp_dilithium_crypto::types::Dilithium65Pair; + use sp_core::Pair as _; + + fn tx_for(pair: &Dilithium65Pair) -> Transaction { + Transaction { + signer_id: "vault.alice.testnet".to_string(), + public_key: PublicKey::from_ml_dsa_65_bytes(pair.public().as_ref()).unwrap(), + nonce: 12, + receiver_id: "bob.testnet".to_string(), + block_hash: [0x22; 32], + actions: vec![Action::Transfer(TransferAction { deposit: 10u128.pow(24) })], + } + } + + fn device_response(pair: &Dilithium65Pair, tx: &Transaction) -> Vec { + let hash = transaction_hash(tx).unwrap(); + crate::chain::signing::sign_ml_dsa_65(pair, &hash, None).to_bytes().to_vec() + } + + #[test] + fn validates_a_correct_device_response_into_a_near_signature() { + let pair = Dilithium65Pair::from_seed(&[7u8; 32]).unwrap(); + let account: AccountId32 = pair.public().into_account(); + let tx = tx_for(&pair); + let response = device_response(&pair, &tx); + assert_eq!(response.len(), SIGNATURE_RESPONSE_LEN_ML_DSA_65); + + let signature = validate_near_response(&tx, &response, &account).unwrap(); + let Signature::MlDsa65(sig) = &signature else { panic!("ml-dsa-65 signature") }; + + // The bare signature verifies as pure ML-DSA over the hash, as NEAR does. + let verifier = + qp_rusty_crystals_dilithium::ml_dsa_65::PublicKey::from_bytes(pair.public().as_ref()) + .unwrap(); + assert!(verifier.verify(&transaction_hash(&tx).unwrap(), sig.as_ref(), None)); + + // And the assembled wire form is borsh(tx) β€– tag β€– signature. + let signed = SignedTransaction { transaction: tx.clone(), signature }; + let bytes = borsh::to_vec(&signed).unwrap(); + let tx_bytes = tx.to_bytes().unwrap(); + assert_eq!(&bytes[..tx_bytes.len()], &tx_bytes[..]); + assert_eq!(bytes[tx_bytes.len()], 2); + assert_eq!(bytes.len(), tx_bytes.len() + 1 + ML_DSA_65_SIGNATURE_LEN); + } + + #[test] + fn rejects_wrong_lengths_keys_wallets_and_signatures() { + let pair = Dilithium65Pair::from_seed(&[7u8; 32]).unwrap(); + let account: AccountId32 = pair.public().into_account(); + let tx = tx_for(&pair); + let response = device_response(&pair, &tx); + + // Truncated β†’ rescan-safe. + let err = + validate_near_response(&tx, &response[..response.len() - 1], &account).unwrap_err(); + assert!(err.rescan_safe()); + assert!(matches!(err, ResponseError::BadLength(_))); + + // An ML-DSA-87 device answers with the 87 size β†’ refused, rescan-safe. + let alice87 = qp_dilithium_crypto::crystal_alice(); + let hash = transaction_hash(&tx).unwrap(); + let r87 = crate::chain::signing::sign_ml_dsa_87(&alice87, &hash, None).to_bytes(); + let err = validate_near_response(&tx, &r87, &account).unwrap_err(); + assert!(matches!(err, ResponseError::BadLength(_))); + + // A different 65 key signed β†’ WrongKey, not retryable. + let other = Dilithium65Pair::from_seed(&[9u8; 32]).unwrap(); + let err = validate_near_response(&tx, &device_response(&other, &tx), &account).unwrap_err(); + assert!(!err.rescan_safe()); + assert!(matches!(err, ResponseError::WrongKey { .. })); + + // Right key for the tx, but --wallet names another cold wallet β†’ WrongWallet. + let other_account: AccountId32 = other.public().into_account(); + let err = validate_near_response(&tx, &response, &other_account).unwrap_err(); + assert!(matches!(err, ResponseError::WrongWallet { .. })); + + // Right key, signed a different transaction β†’ BadSignature. + let mut stale = tx.clone(); + stale.nonce = 13; + let err = validate_near_response(&stale, &response, &account).unwrap_err(); + assert!(matches!(err, ResponseError::BadSignature)); + + // Signed under the Quantus extrinsic context instead of none β†’ BadSignature. + let ctx_signed = crate::chain::signing::sign_ml_dsa_65( + &pair, + &hash, + Some(crate::chain::signing::EXTRINSIC), + ) + .to_bytes(); + let err = validate_near_response(&tx, &ctx_signed, &account).unwrap_err(); + assert!(matches!(err, ResponseError::BadSignature)); + } + + #[test] + fn reads_near_cli_save_to_file_json_and_bare_base64() { + let b64 = "DQAAAGFsaWNlLnRlc3RuZXQAEREREREREREREREREREREREREREREREREREREREREREqAAAAAAAAAAsAAABib2IudGVzdG5ldCIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiAQAAAAMAAACh7czOG8LTAAAAAAAA"; + let json = serde_json::json!({ + "Transaction hash to sign": "be358ec90e89b70256db586f72c4a280daa1199ba6f91398e9d034b72a7a6c9f", + "Unsigned transaction (serialized as base64)": b64, + }) + .to_string(); + + let from_json = transaction_from_file_text(&json).unwrap(); + let from_bare = transaction_from_file_text(&format!("{b64}\n")).unwrap(); + let inline = load_unsigned_transaction(b64).unwrap(); + assert_eq!(from_json, from_bare); + assert_eq!(from_json, inline); + assert_eq!(from_json.signer_id, "alice.testnet"); + + assert!(transaction_from_file_text(r#"{"x": "not a tx"}"#).is_err()); + assert!(load_unsigned_transaction("@/nonexistent/path").is_err()); + } + + /// The full simulator loop as the CLI drives it: v2 request β†’ UR β†’ device + /// decodes, signs β†’ UR β†’ CLI validates. + #[test] + fn end_to_end_request_and_response_over_ur() { + let pair = Dilithium65Pair::from_seed(&[7u8; 32]).unwrap(); + let account: AccountId32 = pair.public().into_account(); + let tx = tx_for(&pair); + + let request = NearSignRequest::new("testnet", tx.to_bytes().unwrap()).unwrap(); + let parts = quantus_ur::encode_bytes(&request.encode()).unwrap(); + let received = NearSignRequest::decode(&quantus_ur::decode_bytes(&parts).unwrap()).unwrap(); + assert_eq!(received.network, "testnet"); + let device_tx = Transaction::from_bytes(&received.transaction).unwrap(); + assert_eq!(device_tx, tx); + + let response = device_response(&pair, &device_tx); + let response_parts = quantus_ur::encode_bytes(&response).unwrap(); + assert!(response_parts.len() > 1, "5261-byte response must be multi-part"); + let response = quantus_ur::decode_bytes(&response_parts).unwrap(); + + assert!(validate_near_response(&tx, &response, &account).is_ok()); + } +} diff --git a/src/near/mod.rs b/src/near/mod.rs index 9c45bfb..68ce287 100644 --- a/src/near/mod.rs +++ b/src/near/mod.rs @@ -4,10 +4,12 @@ //! access-key type from protocol version 85, which lets a Quantus ML-DSA-65 //! wallet control a NEAR account. This module provides the minimal client //! side: borsh transaction encoding ([`protocol`]), transaction hashing and -//! signing ([`sign`]), and a JSON-RPC client ([`rpc`]). +//! signing ([`sign`]), air-gapped signing with a cold wallet over QR +//! ([`cold`]), and a JSON-RPC client ([`rpc`]). //! //! ML-DSA-87 has no NEAR equivalent; only ML-DSA-65 wallets can sign here. +pub mod cold; pub mod protocol; pub mod rpc; pub mod sign; diff --git a/src/near/protocol.rs b/src/near/protocol.rs index 62756b2..7f7c424 100644 --- a/src/near/protocol.rs +++ b/src/near/protocol.rs @@ -12,7 +12,7 @@ //! form; [`PublicKey::handle_string`] computes it for reconciliation. use crate::error::{QuantusError, Result}; -use borsh::BorshSerialize; +use borsh::{BorshDeserialize, BorshSerialize}; pub const ED25519_PUBLIC_KEY_LEN: usize = 32; pub const SECP256K1_PUBLIC_KEY_LEN: usize = 64; @@ -25,7 +25,7 @@ pub const ML_DSA_65_SIGNATURE_LEN: usize = 3309; pub const ML_DSA_65_HANDLE_DOMAIN_TAG: &[u8] = b"near:ml-dsa-65-pubkey-hash:v1"; /// A public key as carried in transactions and actions. -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub enum PublicKey { Ed25519([u8; ED25519_PUBLIC_KEY_LEN]), Secp256k1(Box<[u8; SECP256K1_PUBLIC_KEY_LEN]>), @@ -105,7 +105,7 @@ impl PublicKey { } /// A transaction signature. Same tag space as [`PublicKey`]. -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub enum Signature { Ed25519([u8; ED25519_SIGNATURE_LEN]), /// Tag position only; this CLI never signs secp256k1. @@ -114,7 +114,7 @@ pub enum Signature { MlDsa65(Box<[u8; ML_DSA_65_SIGNATURE_LEN]>), } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct AccessKey { /// Starting nonce for the key. 0 for a key on a brand-new account. pub nonce: u64, @@ -127,7 +127,7 @@ impl AccessKey { } } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub enum AccessKeyPermission { /// Tag position only; this CLI adds full-access keys. #[allow(dead_code)] @@ -135,7 +135,7 @@ pub enum AccessKeyPermission { FullAccess, } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct FunctionCallPermission { /// Allowance in yoctoNEAR the key may spend on gas; `None` = unlimited. pub allowance: Option, @@ -145,7 +145,7 @@ pub struct FunctionCallPermission { /// A transaction action. Variant order fixes the borsh tags; only the ones /// this CLI builds carry real payload types, but every position must exist. -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub enum Action { CreateAccount, #[allow(dead_code)] @@ -161,12 +161,12 @@ pub enum Action { DeleteAccount(DeleteAccountAction), } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct DeployContractAction { pub code: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct FunctionCallAction { pub method_name: String, pub args: Vec, @@ -174,36 +174,36 @@ pub struct FunctionCallAction { pub deposit: u128, } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct TransferAction { /// Amount in yoctoNEAR (24 decimals). pub deposit: u128, } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct StakeAction { pub stake: u128, pub public_key: PublicKey, } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct AddKeyAction { pub public_key: PublicKey, pub access_key: AccessKey, } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct DeleteKeyAction { pub public_key: PublicKey, } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct DeleteAccountAction { pub beneficiary_id: String, } /// The signable transaction body (nearcore `TransactionV0`). -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct Transaction { pub signer_id: String, /// Key the signer signs with; full ML-DSA-65 key, never the hash form. @@ -216,15 +216,161 @@ pub struct Transaction { pub actions: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +impl Transaction { + /// Borsh-encode the body: the bytes NEAR hashes for signing. + pub fn to_bytes(&self) -> Result> { + borsh::to_vec(self) + .map_err(|e| QuantusError::Generic(format!("borsh-encoding transaction: {e}"))) + } + + /// Decode a borsh `TransactionV0`, refusing trailing bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + borsh::from_slice(bytes).map_err(|e| { + QuantusError::Generic(format!("bytes are not a borsh NEAR transaction: {e}")) + }) + } + + /// Decode the base64 form near-cli-rs `sign-later` emits. + pub fn from_base64(s: &str) -> Result { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(s.trim()) + .map_err(|e| QuantusError::Generic(format!("unsigned transaction base64: {e}")))?; + Self::from_bytes(&bytes) + } + + /// One line per action, for showing a signer what they are about to sign. + pub fn describe_actions(&self) -> Vec { + self.actions.iter().map(Action::describe).collect() + } +} + +impl Action { + pub fn describe(&self) -> String { + match self { + Action::CreateAccount => "CreateAccount".to_string(), + Action::DeployContract(a) => + format!("DeployContract ({} bytes, sha256 {})", a.code.len(), code_sha256(&a.code)), + Action::FunctionCall(a) => format!( + "FunctionCall {}({}) gas {} TGas, deposit {} NEAR", + render_text(&a.method_name), + render_args(&a.args), + format_tgas(a.gas), + format_near(a.deposit) + ), + Action::Transfer(a) => format!("Transfer {} NEAR", format_near(a.deposit)), + Action::Stake(a) => format!( + "Stake {} NEAR with {}", + format_near(a.stake), + a.public_key.to_near_string() + ), + Action::AddKey(a) => { + let permission = match &a.access_key.permission { + AccessKeyPermission::FullAccess => "full-access".to_string(), + AccessKeyPermission::FunctionCall(p) => { + let methods = + p.method_names.iter().map(|m| render_text(m)).collect::>(); + format!( + "function-call on {} methods {methods:?} allowance {}", + render_text(&p.receiver_id), + match p.allowance { + Some(allowance) => format!("{} NEAR", format_near(allowance)), + None => "unlimited".to_string(), + } + ) + }, + }; + format!("AddKey {} ({permission})", a.public_key.to_near_string()) + }, + Action::DeleteKey(a) => format!("DeleteKey {}", a.public_key.to_near_string()), + Action::DeleteAccount(a) => + format!("DeleteAccount (beneficiary {})", render_text(&a.beneficiary_id)), + } + } +} + +/// Function-call arguments come from whoever prepared the transaction, and +/// are shown on the terminal that asks the user to approve it. Text is shown +/// as-is only when every character is one a terminal renders in place: +/// control characters could move the cursor and overwrite the lines above, +/// and bidi/zero-width format characters could reorder or hide what is +/// shown. Anything else, and anything that is not UTF-8, is shown as hex. +pub fn render_args(args: &[u8]) -> String { + match std::str::from_utf8(args) { + Ok(text) => render_text(text), + Err(_) => format!("0x{}", hex::encode(args)), + } +} + +/// Same rule as [`render_args`] for a string already known to be UTF-8 +/// (method names, account ids). Unsafe text is hex so it cannot move the cursor. +pub fn render_text(text: &str) -> String { + if text.chars().all(is_display_safe) { + text.to_string() + } else { + format!("0x{}", hex::encode(text.as_bytes())) + } +} + +/// SHA-256 of deployed code, so two WASM blobs of the same length do not +/// share a preview line. NEAR identifies contract code by this digest. +fn code_sha256(code: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(code)) +} + +/// Gas in TGas without rounding. 1 TGas is 10^12 gas units; a remainder is +/// kept as a fractional TGas so 1,999,999,999,999 does not display as 1. +fn format_tgas(gas: u64) -> String { + const TGAS: u64 = 1_000_000_000_000; + let whole = gas / TGAS; + let frac = gas % TGAS; + if frac == 0 { + return whole.to_string(); + } + let frac = format!("{frac:012}"); + format!("{whole}.{}", frac.trim_end_matches('0')) +} + +fn is_display_safe(c: char) -> bool { + !c.is_control() && + !matches!( + c, + '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}' + ) +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct SignedTransaction { pub transaction: Transaction, pub signature: Signature, } +impl SignedTransaction { + /// The base64 form `near transaction send-signed-transaction` reads. + pub fn to_base64(&self) -> Result { + use base64::Engine; + let bytes = borsh::to_vec(self) + .map_err(|e| QuantusError::Generic(format!("borsh-encoding transaction: {e}")))?; + Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) + } +} + /// NEAR uses 24 decimals (yoctoNEAR). pub const NEAR_DECIMALS: u8 = 24; +/// yoctoNEAR β†’ decimal NEAR string, trailing zeros trimmed. +pub fn format_near(yocto: u128) -> String { + let unit = 10u128.pow(NEAR_DECIMALS as u32); + let whole = yocto / unit; + let frac = yocto % unit; + if frac == 0 { + return whole.to_string(); + } + let frac = format!("{frac:024}"); + format!("{whole}.{}", frac.trim_end_matches('0')) +} + /// Light client-side validation of a NEAR account id (the chain re-validates). pub fn validate_account_id(account_id: &str) -> Result<()> { let valid_len = (2..=64).contains(&account_id.len()); @@ -291,6 +437,87 @@ mod tests { ); } + /// Produced by near-cli-rs 0.30.1: + /// `near tokens alice.testnet send-near bob.testnet '1 NEAR' network-config testnet + /// sign-later --signer-public-key ed25519:29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2 + /// --nonce 42 --block-hash 3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3 display` + /// (0x11Γ—32 key, 0x22Γ—32 block hash). Pins our borsh layout and hash to + /// what the reference tooling emits, so `sign-later` output can be signed here. + const NEAR_CLI_UNSIGNED_B64: &str = "DQAAAGFsaWNlLnRlc3RuZXQAEREREREREREREREREREREREREREREREREREREREREREqAAAAAAAAAAsAAABib2IudGVzdG5ldCIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiAQAAAAMAAACh7czOG8LTAAAAAAAA"; + const NEAR_CLI_HASH_HEX: &str = + "be358ec90e89b70256db586f72c4a280daa1199ba6f91398e9d034b72a7a6c9f"; + + #[test] + fn near_cli_sign_later_output_decodes_and_reencodes_byte_identically() { + let tx = Transaction::from_base64(NEAR_CLI_UNSIGNED_B64).unwrap(); + assert_eq!( + tx, + Transaction { + signer_id: "alice.testnet".to_string(), + public_key: PublicKey::Ed25519([0x11; 32]), + nonce: 42, + receiver_id: "bob.testnet".to_string(), + block_hash: [0x22; 32], + actions: vec![Action::Transfer(TransferAction { deposit: 10u128.pow(24) })], + } + ); + use base64::Engine; + assert_eq!( + base64::engine::general_purpose::STANDARD.encode(tx.to_bytes().unwrap()), + NEAR_CLI_UNSIGNED_B64 + ); + assert_eq!( + hex::encode(crate::near::sign::transaction_hash(&tx).unwrap()), + NEAR_CLI_HASH_HEX + ); + assert_eq!(tx.describe_actions(), vec!["Transfer 1 NEAR"]); + } + + #[test] + fn transaction_decode_rejects_trailing_or_truncated_bytes() { + let tx = Transaction::from_base64(NEAR_CLI_UNSIGNED_B64).unwrap(); + let bytes = tx.to_bytes().unwrap(); + + let mut extended = bytes.clone(); + extended.push(0); + assert!(Transaction::from_bytes(&extended).is_err()); + assert!(Transaction::from_bytes(&bytes[..bytes.len() - 1]).is_err()); + assert!(Transaction::from_base64("not base64!").is_err()); + } + + #[test] + fn ml_dsa_65_transaction_roundtrips_through_borsh() { + let tx = Transaction { + signer_id: "vault.alice.testnet".to_string(), + public_key: PublicKey::MlDsa65(Box::new([0x33; ML_DSA_65_PUBLIC_KEY_LEN])), + nonce: 7, + receiver_id: "v2.ref-finance.near".to_string(), + block_hash: [0x22; 32], + actions: vec![Action::FunctionCall(FunctionCallAction { + method_name: "storage_deposit".to_string(), + args: br#"{"registration_only":true}"#.to_vec(), + gas: 30_000_000_000_000, + deposit: 125 * 10u128.pow(21), + })], + }; + let decoded = Transaction::from_bytes(&tx.to_bytes().unwrap()).unwrap(); + assert_eq!(decoded, tx); + assert_eq!( + decoded.describe_actions(), + vec![ + r#"FunctionCall storage_deposit({"registration_only":true}) gas 30 TGas, deposit 0.125 NEAR"# + ] + ); + } + + #[test] + fn format_near_trims_zeros() { + assert_eq!(format_near(0), "0"); + assert_eq!(format_near(10u128.pow(24)), "1"); + assert_eq!(format_near(15 * 10u128.pow(23)), "1.5"); + assert_eq!(format_near(1), "0.000000000000000000000001"); + } + #[test] fn function_call_action_borsh_layout() { let args = br#"{"id":0,"action":"VoteApprove"}"#.to_vec(); @@ -313,6 +540,123 @@ mod tests { assert_eq!(borsh::to_vec(&action).unwrap(), expected); } + #[test] + fn deploy_preview_distinguishes_same_length_code() { + let left = Action::DeployContract(DeployContractAction { code: b"wasm-aaaa".to_vec() }); + let right = Action::DeployContract(DeployContractAction { code: b"wasm-bbbb".to_vec() }); + let left = left.describe(); + let right = right.describe(); + assert_ne!(left, right); + assert!(left.contains("9 bytes"), "{left}"); + assert!(right.contains("9 bytes"), "{right}"); + let digest = { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(b"wasm-aaaa")) + }; + assert!(left.contains(&digest), "{left}"); + assert!(!right.contains(&digest), "{right}"); + } + + #[test] + fn function_call_preview_keeps_fractional_gas() { + let describe = |gas| { + Action::FunctionCall(FunctionCallAction { + method_name: "m".to_string(), + args: Vec::new(), + gas, + deposit: 0, + }) + .describe() + }; + assert!( + describe(1_999_999_999_999).contains("gas 1.999999999999 TGas"), + "{}", + describe(1_999_999_999_999) + ); + assert!(describe(1).contains("gas 0.000000000001 TGas"), "{}", describe(1)); + assert!( + describe(30_000_000_000_000).contains("gas 30 TGas"), + "{}", + describe(30_000_000_000_000) + ); + assert!(describe(0).contains("gas 0 TGas"), "{}", describe(0)); + } + + #[test] + fn function_call_args_with_terminal_controls_are_shown_as_hex() { + let describe = |args: &[u8]| { + Action::FunctionCall(FunctionCallAction { + method_name: "m".to_string(), + args: args.to_vec(), + gas: 0, + deposit: 0, + }) + .describe() + }; + assert_eq!( + describe(br#"{"a":1}"#), + r#"FunctionCall m({"a":1}) gas 0 TGas, deposit 0 NEAR"# + ); + + // ESC, CR, LF: each could rewrite the lines already printed above. + for args in [b"\x1b[2Jx".as_slice(), b"a\rb", b"a\nb", b"a\tb"] { + let shown = describe(args); + assert!(shown.contains(&format!("0x{}", hex::encode(args))), "{shown:?}"); + assert!(!shown.chars().any(char::is_control), "{shown:?}"); + } + // Right-to-left override and zero-width space reorder or hide text. + assert!(describe("a\u{202E}b".as_bytes()).contains("0x61e280ae62")); + assert!(describe("a\u{200B}b".as_bytes()).contains("0x61e2808b62")); + // Not UTF-8 at all. + assert!(describe(&[0xff, 0x00]).contains("0xff00")); + } + + #[test] + fn other_preview_strings_with_terminal_controls_are_shown_as_hex() { + let method = "m\rReceiver: spoofed.near"; + let line = Action::FunctionCall(FunctionCallAction { + method_name: method.to_string(), + args: br#"{"a":1}"#.to_vec(), + gas: 0, + deposit: 0, + }) + .describe(); + assert!(!line.chars().any(char::is_control), "{line:?}"); + assert!(line.contains(&format!("0x{}", hex::encode(method))), "{line}"); + + let account = "bob.testnet\nb"; + assert_eq!(render_text("bob.testnet"), "bob.testnet"); + let shown = render_text(account); + assert!(!shown.chars().any(char::is_control), "{shown:?}"); + assert_eq!(shown, format!("0x{}", hex::encode(account))); + } + + #[test] + fn add_key_describe_shows_the_allowance() { + let key = PublicKey::Ed25519([0x11; 32]); + let add = |allowance: Option| { + Action::AddKey(AddKeyAction { + public_key: key.clone(), + access_key: AccessKey { + nonce: 0, + permission: AccessKeyPermission::FunctionCall(FunctionCallPermission { + allowance, + receiver_id: "app.testnet".to_string(), + method_names: vec!["claim".to_string()], + }), + }, + }) + .describe() + }; + assert!(add(None).ends_with(r#"methods ["claim"] allowance unlimited)"#), "{}", add(None)); + assert!( + add(Some(250_000_000_000_000_000_000_000)) + .ends_with(r#"methods ["claim"] allowance 0.25 NEAR)"#), + "{}", + add(Some(250_000_000_000_000_000_000_000)) + ); + } + #[test] fn signed_transaction_appends_the_signature() { let tx = Transaction { diff --git a/src/qr/mod.rs b/src/qr/mod.rs index 638b874..a4f3850 100644 --- a/src/qr/mod.rs +++ b/src/qr/mod.rs @@ -11,4 +11,4 @@ pub mod sign_request; pub use display::{display_ur_until_enter, render_ur_frames}; pub use scanner::{scan_quantus_address, scan_ur, UrSource}; -pub use sign_request::SignRequest; +pub use sign_request::{AnySignRequest, NearSignRequest, SignRequest}; diff --git a/src/qr/sign_request.rs b/src/qr/sign_request.rs index 8ac9b13..ed2a3e2 100644 --- a/src/qr/sign_request.rs +++ b/src/qr/sign_request.rs @@ -10,14 +10,160 @@ //! the Keystone firmware read β€” `SigningRequest` in quantus_sdk //! (`lib/src/models/signing_request.dart`). Keep the two in step: the wallets //! accept these three keys and no others, and refuse any other version. +//! +//! Version 2 ([`NearSignRequest`]) carries a NEAR transaction instead of a +//! Substrate payload: `{v: 2, chain: "near", network, payload}`. There is no +//! `signer` field because the borsh transaction already names its signer +//! account and the exact public key that must sign; a device matches that key +//! against its own. Both versions travel in the same `ur:quantus-sign-request` +//! UR type, so wallets that only read v1 refuse v2 by version, as before. use crate::error::{QuantusError, Result}; use serde::{Deserialize, Serialize}; /// Envelope version the wallets accept. pub const SIGN_REQUEST_VERSION: u8 = 1; +/// Envelope version carrying a NEAR transaction. +pub const NEAR_SIGN_REQUEST_VERSION: u8 = 2; + /// Largest payload a wallet will read, matching `maxPayloadBytes` in the SDK. -const MAX_PAYLOAD_BYTES: usize = 8 * 1024; +/// Largest payload any client accepts in a signing request, v1 or v2. Shared +/// with the cold wallet app, so a request above it is refused on both sides. +pub const MAX_PAYLOAD_BYTES: usize = 8 * 1024; + +fn encode_payload(payload: &[u8]) -> String { + format!("0x{}", hex::encode(payload)) +} + +fn decode_payload(payload: &str) -> Result> { + let hex_payload = payload.strip_prefix("0x").ok_or_else(|| { + QuantusError::Generic("Signing request payload is not 0x hex".to_string()) + })?; + let payload = hex::decode(hex_payload) + .map_err(|e| QuantusError::Generic(format!("Signing request payload is not hex: {e}")))?; + + if payload.is_empty() { + return Err(QuantusError::Generic("Signing request payload is empty".to_string())); + } + if payload.len() > MAX_PAYLOAD_BYTES { + return Err(QuantusError::Generic(format!( + "Signing request payload too large: {} bytes", + payload.len() + ))); + } + Ok(payload) +} + +/// Reads only the version, so a decoder can pick the envelope to parse. +#[derive(Deserialize)] +struct VersionProbe { + v: u8, +} + +/// Any envelope this CLI reads, dispatched on `v`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AnySignRequest { + Quantus(SignRequest), + Near(NearSignRequest), +} + +impl AnySignRequest { + pub fn decode(bytes: &[u8]) -> Result { + let probe: VersionProbe = serde_json::from_slice(bytes).map_err(|e| { + QuantusError::Generic(format!( + "Not a signing request. A wallet built before the request envelope sends a bare \ + payload, which cannot say which account it is for ({e})" + )) + })?; + match probe.v { + SIGN_REQUEST_VERSION => SignRequest::decode(bytes).map(Self::Quantus), + NEAR_SIGN_REQUEST_VERSION => NearSignRequest::decode(bytes).map(Self::Near), + other => Err(QuantusError::Generic(format!( + "Unsupported signing request version: {other} (this build reads \ + {SIGN_REQUEST_VERSION} and {NEAR_SIGN_REQUEST_VERSION})" + ))), + } + } +} + +/// A NEAR transaction for a cold wallet to sign (envelope v2). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NearSignRequest { + /// `mainnet` or `testnet`; shown by the device, not part of what is signed + /// (NEAR transactions carry no chain id β€” the block hash pins the chain). + pub network: String, + /// Borsh-encoded NEAR `TransactionV0`. The device decodes it to display the + /// actions and signs SHA-256 of these exact bytes. + pub transaction: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct NearWire { + v: u8, + chain: String, + network: String, + payload: String, +} + +const NEAR_CHAIN: &str = "near"; + +/// The only network labels a NEAR request may carry, spelled exactly. The +/// cold wallet shows the label and picks its account-name check from it, so +/// both ends refuse anything else rather than display a lookalike. +pub const NEAR_NETWORKS: [&str; 2] = ["mainnet", "testnet"]; + +impl NearSignRequest { + /// Refuses a transaction the decoders on the other side would refuse, so + /// the user hears about it before a QR is shown rather than after a + /// fruitless scan. + pub fn new(network: impl Into, transaction: Vec) -> Result { + let network = network.into(); + if !NEAR_NETWORKS.contains(&network.as_str()) { + return Err(QuantusError::Generic(format!( + "NEAR network {network:?} is not one of {NEAR_NETWORKS:?}" + ))); + } + if transaction.len() > MAX_PAYLOAD_BYTES { + return Err(QuantusError::Generic(format!( + "Transaction is {} bytes; cold-signing requests carry at most {MAX_PAYLOAD_BYTES} bytes. \ + Split the call or shrink its arguments.", + transaction.len() + ))); + } + Ok(Self { network, transaction }) + } + + /// The bytes that go into the UR frames. + pub fn encode(&self) -> Vec { + let wire = NearWire { + v: NEAR_SIGN_REQUEST_VERSION, + chain: NEAR_CHAIN.to_string(), + network: self.network.clone(), + payload: encode_payload(&self.transaction), + }; + serde_json::to_vec(&wire).expect("sign request serialises") + } + + pub fn decode(bytes: &[u8]) -> Result { + let wire: NearWire = serde_json::from_slice(bytes) + .map_err(|e| QuantusError::Generic(format!("Not a NEAR signing request ({e})")))?; + if wire.v != NEAR_SIGN_REQUEST_VERSION { + return Err(QuantusError::Generic(format!( + "Unsupported signing request version: {} (NEAR requests are version \ + {NEAR_SIGN_REQUEST_VERSION})", + wire.v + ))); + } + if wire.chain != NEAR_CHAIN { + return Err(QuantusError::Generic(format!( + "Signing request is for chain '{}', not NEAR", + wire.chain + ))); + } + Self::new(wire.network, decode_payload(&wire.payload)?) + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct SignRequest { @@ -44,7 +190,7 @@ impl SignRequest { let wire = Wire { v: SIGN_REQUEST_VERSION, signer: self.signer.clone(), - payload: format!("0x{}", hex::encode(&self.payload)), + payload: encode_payload(&self.payload), }; // The struct has no field that can fail to serialise. serde_json::to_vec(&wire).expect("sign request serialises") @@ -69,24 +215,7 @@ impl SignRequest { ))); } - let hex_payload = wire.payload.strip_prefix("0x").ok_or_else(|| { - QuantusError::Generic("Signing request payload is not 0x hex".to_string()) - })?; - let payload = hex::decode(hex_payload).map_err(|e| { - QuantusError::Generic(format!("Signing request payload is not hex: {e}")) - })?; - - if payload.is_empty() { - return Err(QuantusError::Generic("Signing request payload is empty".to_string())); - } - if payload.len() > MAX_PAYLOAD_BYTES { - return Err(QuantusError::Generic(format!( - "Signing request payload too large: {} bytes", - payload.len() - ))); - } - - Ok(Self { signer: wire.signer, payload }) + Ok(Self { signer: wire.signer, payload: decode_payload(&wire.payload)? }) } } @@ -152,4 +281,87 @@ mod tests { assert!(SignRequest::decode(wire.to_string().as_bytes()).is_err()); } + + #[test] + fn near_request_round_trips_through_the_wire_format() { + let request = NearSignRequest::new("testnet", vec![0x0d, 0x00, 0x00, 0x00]).unwrap(); + let encoded = request.encode(); + + assert_eq!(NearSignRequest::decode(&encoded).unwrap(), request); + assert_eq!(AnySignRequest::decode(&encoded).unwrap(), AnySignRequest::Near(request)); + + let json: serde_json::Value = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(json["v"], 2); + assert_eq!(json["chain"], "near"); + assert_eq!(json["network"], "testnet"); + assert_eq!(json["payload"], "0x0d000000"); + assert_eq!(json.as_object().unwrap().len(), 4); + } + + #[test] + fn any_request_dispatches_on_version() { + let v1 = SignRequest::new(ADDRESS, vec![0x02, 0x00]); + assert_eq!(AnySignRequest::decode(&v1.encode()).unwrap(), AnySignRequest::Quantus(v1)); + + let v3 = serde_json::json!({ "v": 3, "payload": "0xab" }); + let error = AnySignRequest::decode(v3.to_string().as_bytes()).unwrap_err().to_string(); + assert!(error.contains("version"), "unexpected error: {error}"); + + assert!(AnySignRequest::decode(&[0x02, 0x00, 0x01]).is_err()); + } + + #[test] + fn near_request_accepts_only_exact_network_labels() { + for network in NEAR_NETWORKS { + assert_eq!(NearSignRequest::new(network, vec![1]).unwrap().network, network); + } + // Each would read as a known network on the device while disabling + // that network's account-name check. + for lookalike in + ["testnet ", " testnet", "Testnet", "test\u{200B}net", "mainnet\n", "localnet", ""] + { + assert!(NearSignRequest::new(lookalike, vec![1]).is_err(), "{lookalike:?}"); + let wire = serde_json::json!({ + "v": 2, "chain": "near", "network": lookalike, "payload": "0x01" + }); + assert!( + NearSignRequest::decode(&serde_json::to_vec(&wire).unwrap()).is_err(), + "{lookalike:?}" + ); + } + } + + #[test] + fn near_request_refuses_a_transaction_its_decoder_would_refuse() { + assert!(NearSignRequest::new("testnet", vec![0u8; MAX_PAYLOAD_BYTES]).is_ok()); + let err = NearSignRequest::new("testnet", vec![0u8; MAX_PAYLOAD_BYTES + 1]).unwrap_err(); + assert!(err.to_string().contains("at most 8192 bytes"), "{err}"); + } + + #[test] + fn near_request_refuses_other_chains_versions_and_extra_keys() { + let base = + serde_json::json!({ "v": 2, "chain": "near", "network": "mainnet", "payload": "0xab" }); + assert!(NearSignRequest::decode(base.to_string().as_bytes()).is_ok()); + + let mut other_chain = base.clone(); + other_chain["chain"] = "solana".into(); + assert!(NearSignRequest::decode(other_chain.to_string().as_bytes()).is_err()); + + let mut v1 = base.clone(); + v1["v"] = 1.into(); + assert!(NearSignRequest::decode(v1.to_string().as_bytes()).is_err()); + + let mut no_network = base.clone(); + no_network["network"] = "".into(); + assert!(NearSignRequest::decode(no_network.to_string().as_bytes()).is_err()); + + let mut extra = base.clone(); + extra["signer"] = ADDRESS.into(); + assert!(NearSignRequest::decode(extra.to_string().as_bytes()).is_err()); + + let mut missing = base; + missing.as_object_mut().unwrap().remove("network"); + assert!(NearSignRequest::decode(missing.to_string().as_bytes()).is_err()); + } }