diff --git a/Cargo.lock b/Cargo.lock index e69b6cb..90fd9ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -901,6 +901,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "bounded-collections" version = "0.3.2" @@ -5093,12 +5117,16 @@ dependencies = [ "aes-gcm", "anyhow", "argon2", + "base64", "blake3", + "borsh", + "bs58", "bytes", "chrono", "clap", "colored", "dirs", + "ed25519-dalek", "event-listener", "hex", "indicatif", @@ -5144,6 +5172,7 @@ dependencies = [ "serde_json", "serial_test", "sha2 0.10.9", + "sha3", "sp-core", "sp-runtime", "subxt", @@ -7245,7 +7274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -7742,7 +7771,7 @@ version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ - "cfg-if 0.1.10", + "cfg-if 1.0.4", "digest 0.10.7", "rand 0.8.6", "static_assertions", diff --git a/Cargo.toml b/Cargo.toml index 0d5b3f7..bf93893 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,14 @@ nam-tiny-hderive = { version = "=0.3.1-nam.1", default-features = false } blake3 = "1.8" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +# NEAR integration: borsh transaction encoding, base58 key text forms, +# SHA3-256 access-key handles, ed25519 parent-account signing, base64 tx submit. +base64 = "0.22" +borsh = { version = "1.5", features = ["derive"] } +bs58 = "0.5" +ed25519-dalek = "2" +sha3 = "0.10" + # Self-update: download and replace the running binary from GitHub releases. # 1.0.0-rc.x is required for quick-xml >= 0.41 (RUSTSEC-2026-0194 / 0195); stable # 0.44 still pins quick-xml ^0.38. Pulls reqwest 0.13 alongside our 0.12. diff --git a/src/chain/signing.rs b/src/chain/signing.rs index eb6ab23..fa18a82 100644 --- a/src/chain/signing.rs +++ b/src/chain/signing.rs @@ -57,21 +57,27 @@ context_sign!( ml_dsa_65 ); -/// Whether `sig_with_public` signs `message` under `context`. Cold wallets sign ML-DSA-87 only, -/// so that is the only response the CLI checks. -pub fn verify_ml_dsa_87( - sig_with_public: &Dilithium87SignatureWithPublic, - message: &[u8], - context: Option<&[u8]>, -) -> bool { - let Ok(public) = qp_rusty_crystals_dilithium::ml_dsa_87::PublicKey::from_bytes( - sig_with_public.public().as_slice(), - ) else { - return false; +macro_rules! context_verify { + ($verify:ident, $sig_with_public:ty, $module:ident) => { + /// Whether `sig_with_public` signs `message` under `context`. + pub fn $verify( + sig_with_public: &$sig_with_public, + message: &[u8], + context: Option<&[u8]>, + ) -> bool { + let Ok(public) = qp_rusty_crystals_dilithium::$module::PublicKey::from_bytes( + sig_with_public.public().as_slice(), + ) else { + return false; + }; + public.verify(message, sig_with_public.signature().as_slice(), context) + } }; - public.verify(message, sig_with_public.signature().as_slice(), context) } +context_verify!(verify_ml_dsa_87, Dilithium87SignatureWithPublic, ml_dsa_87); +context_verify!(verify_ml_dsa_65, Dilithium65SignatureWithPublic, ml_dsa_65); + #[cfg(test)] mod tests { use super::*; @@ -90,13 +96,9 @@ mod tests { fn ml_dsa_65_signature_verifies_under_the_context_it_was_made_with() { let pair = Dilithium65Pair::from_seed(&[7u8; 32]).expect("seed is well-formed"); let signature = sign_ml_dsa_65(&pair, b"payload", CTX); - let public = qp_rusty_crystals_dilithium::ml_dsa_65::PublicKey::from_bytes( - signature.public().as_slice(), - ) - .expect("public key must parse"); - let sig = signature.signature(); - assert!(public.verify(b"payload", sig.as_slice(), CTX)); - assert!(!public.verify(b"payload", sig.as_slice(), None)); + assert!(verify_ml_dsa_65(&signature, b"payload", CTX)); + assert!(!verify_ml_dsa_65(&signature, b"payload", None)); + assert!(!verify_ml_dsa_65(&signature, b"a different payload", CTX)); } /// FIPS 204 contexts are domain separated, so this cuts both ways: a spec-148 node rejects a diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs index 280f118..ec87239 100644 --- a/src/cli/cold_signing.rs +++ b/src/cli/cold_signing.rs @@ -7,8 +7,10 @@ //! `ur:quantus-sign-request` QR (animated when multi-part): `call ‖ era ‖ nonce ‖ tip ‖ mode ‖ //! specVersion ‖ txVersion ‖ genesis ‖ blockHash ‖ Option`. Raw (not hashed) so //! the device can parse and show the user what they are signing. -//! 2. The cold wallet signs `payload.len() > 256 ? blake2b_256(payload) : payload` with ML-DSA-87 -//! and answers with an animated UR containing `signature[4627] ‖ public_key[2592]`. +//! 2. The cold wallet signs `payload.len() > 256 ? blake2b_256(payload) : payload` with its +//! Dilithium key and answers with an animated UR containing `signature ‖ public_key` — +//! `signature[4627] ‖ public_key[2592]` for ML-DSA-87 or `signature[3309] ‖ public_key[1952]` +//! for ML-DSA-65. The CLI detects the scheme from the response length. //! 3. The CLI validates the response against the wallet's stored address, assembles the V4 //! extrinsic with the *identical* parameters, and submits. //! @@ -23,7 +25,9 @@ use crate::{ qr::{display_ur_until_enter, render_ur_frames, scan_ur, SignRequest, UrSource}, }; use colored::Colorize; -use qp_dilithium_crypto::types::{Dilithium87SignatureWithPublic, DilithiumSignatureScheme}; +use qp_dilithium_crypto::types::{ + Dilithium65SignatureWithPublic, Dilithium87SignatureWithPublic, DilithiumSignatureScheme, +}; use sp_core::crypto::AccountId32; use sp_runtime::traits::IdentifyAccount; use std::{io::IsTerminal, path::PathBuf, time::Duration}; @@ -39,8 +43,12 @@ pub const MORTALITY_BLOCKS: u64 = 256; /// Cold-wallet parsers reject payloads above this size. pub const MAX_COLD_PAYLOAD: usize = 8 * 1024; -/// `ML-DSA-87 signature (4627) ‖ public key (2592)` — the only valid response size. -pub const SIGNATURE_RESPONSE_LEN: usize = Dilithium87SignatureWithPublic::TOTAL_LEN; +/// `ML-DSA-87 signature (4627) ‖ public key (2592)` response size. +pub const SIGNATURE_RESPONSE_LEN_ML_DSA_87: usize = Dilithium87SignatureWithPublic::TOTAL_LEN; + +/// `ML-DSA-65 signature (3309) ‖ public key (1952)` response size. Distinct from +/// the ML-DSA-87 size, so the scheme is detected from the response length. +pub const SIGNATURE_RESPONSE_LEN_ML_DSA_65: usize = Dilithium65SignatureWithPublic::TOTAL_LEN; /// How long to wait for the signed response before giving up. const RESPONSE_TIMEOUT: Duration = Duration::from_secs(300); @@ -145,10 +153,10 @@ impl ResponseError { fn message(&self, wallet_name: &str) -> String { match self { ResponseError::BadLength(got) => format!( - "Response has {got} bytes, expected {SIGNATURE_RESPONSE_LEN} (signature ‖ public key). The scan was likely incomplete or picked up the wrong QR — rescan the response on the cold wallet." + "Response has {got} bytes, expected {SIGNATURE_RESPONSE_LEN_ML_DSA_87} (ML-DSA-87) or {SIGNATURE_RESPONSE_LEN_ML_DSA_65} (ML-DSA-65) for signature ‖ public key. The scan was likely incomplete or picked up the wrong QR — rescan the response on the cold wallet." ), ResponseError::Malformed(e) => format!( - "Response bytes do not parse as an ML-DSA-87 signature + public key ({e}) — rescan the response." + "Response bytes do not parse as a Dilithium signature + public key ({e}) — rescan the response." ), ResponseError::WrongSigner { got } => format!( "Response was signed by a DIFFERENT key ({got}) than cold wallet '{wallet_name}'. Aborting — check that the right device/account signed." @@ -234,34 +242,45 @@ pub fn signable_payload(raw: &[u8]) -> Vec { } } -/// Split and verify a signature response. Checks length, structure, that the -/// embedded public key matches the cold wallet's address (poseidon binding), -/// and that the signature verifies over this exact payload. +/// Split and verify a signature response. The scheme is detected from the +/// response length. Checks length, structure, that the embedded public key +/// matches the cold wallet's address (poseidon binding), and that the +/// signature verifies over this exact payload. fn validate_signature_response( raw_payload: &[u8], response: &[u8], expected_account: &AccountId32, context: Option<&[u8]>, -) -> std::result::Result { - if response.len() != SIGNATURE_RESPONSE_LEN { - return Err(ResponseError::BadLength(response.len())); - } +) -> std::result::Result { + let msg = signable_payload(raw_payload); - let sig_with_public = Dilithium87SignatureWithPublic::from_bytes(response) - .map_err(|e| ResponseError::Malformed(format!("{e:?}")))?; + let (signature, derived_account, verified) = match response.len() { + SIGNATURE_RESPONSE_LEN_ML_DSA_87 => { + let swp = Dilithium87SignatureWithPublic::from_bytes(response) + .map_err(|e| ResponseError::Malformed(format!("{e:?}")))?; + let account = swp.public().into_account(); + let verified = crate::chain::signing::verify_ml_dsa_87(&swp, &msg, context); + (DilithiumSignatureScheme::Dilithium87(swp), account, verified) + }, + SIGNATURE_RESPONSE_LEN_ML_DSA_65 => { + let swp = Dilithium65SignatureWithPublic::from_bytes(response) + .map_err(|e| ResponseError::Malformed(format!("{e:?}")))?; + let account = swp.public().into_account(); + let verified = crate::chain::signing::verify_ml_dsa_65(&swp, &msg, context); + (DilithiumSignatureScheme::Dilithium65(swp), account, verified) + }, + got => return Err(ResponseError::BadLength(got)), + }; - let derived_account = sig_with_public.public().into_account(); if derived_account != *expected_account { use crate::cli::address_format::QuantusSS58; return Err(ResponseError::WrongSigner { got: derived_account.to_quantus_ss58() }); } - - let msg = signable_payload(raw_payload); - if !crate::chain::signing::verify_ml_dsa_87(&sig_with_public, &msg, context) { + if !verified { return Err(ResponseError::BadSignature); } - Ok(sig_with_public) + Ok(signature) } fn confirm_or_abort(prompt: &str) -> Result<()> { @@ -378,7 +397,7 @@ pub async fn sign_and_submit_cold( Some(source) => source.clone(), None => default_response_source(io)?, }; - let sig_with_public = loop { + let signature = loop { let response = scan_ur(&source, RESPONSE_TIMEOUT).await?; log_verbose!("📥 Received {} response bytes", response.len()); @@ -419,7 +438,6 @@ pub async fn sign_and_submit_cold( )); } - let signature = DilithiumSignatureScheme::Dilithium87(sig_with_public); let submittable = partial.sign_with_account_and_signature(&account, &signature); crate::cli::common::submit_prepared_transaction(client, submittable, execution_mode).await @@ -428,13 +446,19 @@ pub async fn sign_and_submit_cold( /// Estimate the fee by assembling a throwaway extrinsic with an all-zero /// (correct-length) signature. Returns `None` on any failure — the preview is /// informational and must never block signing. +/// +/// The signing scheme is unknown until the device responds, so the dummy is +/// ML-DSA-87 sized — the larger of the two, making the estimate conservative +/// for ML-DSA-65 signers. async fn estimate_fee_with_dummy_signature( client: &QuantusClient, call: &Call, ctx: &TxContext, account: &AccountId32, ) -> Option { - let dummy = Dilithium87SignatureWithPublic::from_bytes(&[0u8; SIGNATURE_RESPONSE_LEN]).ok()?; + let dummy = + Dilithium87SignatureWithPublic::from_bytes(&[0u8; SIGNATURE_RESPONSE_LEN_ML_DSA_87]) + .ok()?; let mut partial = client.client().tx().create_v4_partial_offline(call, build_params(ctx)).ok()?; let tx = partial @@ -511,21 +535,34 @@ pub async fn handle_cold_sign_sim( request.signer ))); } - if keypair.scheme != crate::wallet::DilithiumScheme::MlDsa87 { - return Err(QuantusError::Generic( - "cold-sign-sim and real devices sign ML-DSA-87 only; use an ML-DSA-87 wallet" - .to_string(), - )); - } - let pair = keypair.to_resonance_pair()?; let msg = signable_payload(&payload); - // Mirrors the device: Keystone firmware always signs under the extrinsic context, so a + // Mirrors the device: firmware always signs under the extrinsic context, so a // simulated signature is only good for a runtime that verifies with it. - let sig_with_public = - crate::chain::signing::sign_ml_dsa_87(&pair, &msg, Some(crate::chain::signing::EXTRINSIC)); + let response_bytes: Vec = match keypair.scheme { + crate::wallet::DilithiumScheme::MlDsa87 => { + let pair = keypair.to_resonance_pair()?; + crate::chain::signing::sign_ml_dsa_87( + &pair, + &msg, + Some(crate::chain::signing::EXTRINSIC), + ) + .to_bytes() + .to_vec() + }, + crate::wallet::DilithiumScheme::MlDsa65 => { + let pair = keypair.to_dilithium65_pair()?; + crate::chain::signing::sign_ml_dsa_65( + &pair, + &msg, + Some(crate::chain::signing::EXTRINSIC), + ) + .to_bytes() + .to_vec() + }, + }; // 3. Emit the response UR. - let parts = quantus_ur::encode_bytes(&sig_with_public.to_bytes()) + let parts = quantus_ur::encode_bytes(&response_bytes) .map_err(|e| QuantusError::Generic(format!("Failed to UR-encode response: {e:?}")))?; match &response_file { @@ -710,13 +747,16 @@ mod tests { let msg = signable_payload(&raw); let swp = crate::chain::signing::sign_ml_dsa_87(&alice, &msg, CTX); let response = swp.to_bytes(); - assert_eq!(response.len(), SIGNATURE_RESPONSE_LEN); + assert_eq!(response.len(), SIGNATURE_RESPONSE_LEN_ML_DSA_87); // Valid response verifies let validated = validate_signature_response(&raw, &response, &alice_account(), CTX) .ok() .unwrap(); - assert_eq!(validated.to_bytes(), response); + let DilithiumSignatureScheme::Dilithium87(validated_swp) = validated else { + panic!("87-sized response must validate as Dilithium87"); + }; + assert_eq!(validated_swp.to_bytes(), response); // Truncated response → BadLength (rescan-safe) let err = validate_signature_response(&raw, &response[..1000], &alice_account(), CTX) @@ -745,6 +785,80 @@ mod tests { assert!(matches!(err, ResponseError::BadSignature)); } + /// The 65 mirror of the roundtrip above: the scheme is picked from the + /// response length, every rejection path holds, and the validated + /// signature assembles into a submittable extrinsic. + #[test] + fn test_validate_signature_response_ml_dsa_65() { + use qp_dilithium_crypto::types::Dilithium65Pair; + use sp_core::Pair as _; + + let state = test_client_state(); + let ctx = test_ctx(); + let call = transfer_call(); + let raw = build_raw_signer_payload(&state, &call, &ctx).unwrap(); + let msg = signable_payload(&raw); + + let pair = Dilithium65Pair::from_seed(&[7u8; 32]).expect("valid seed"); + let account: AccountId32 = pair.public().into_account(); + let swp = crate::chain::signing::sign_ml_dsa_65(&pair, &msg, CTX); + let response = swp.to_bytes(); + assert_eq!(response.len(), SIGNATURE_RESPONSE_LEN_ML_DSA_65); + + // Valid 65 response validates as Dilithium65 + let validated = validate_signature_response(&raw, &response, &account, CTX).ok().unwrap(); + assert!(matches!(validated, DilithiumSignatureScheme::Dilithium65(_))); + + // A 65 response against an 87 wallet's account → WrongSigner (abort) + let err = validate_signature_response(&raw, &response, &alice_account(), CTX) + .err() + .unwrap(); + assert!(!err.rescan_safe()); + assert!(matches!(err, ResponseError::WrongSigner { .. })); + + // Signed by a different 65 key → WrongSigner (abort) + let other = Dilithium65Pair::from_seed(&[9u8; 32]).expect("valid seed"); + let other_swp = crate::chain::signing::sign_ml_dsa_65(&other, &msg, CTX); + let err = validate_signature_response(&raw, &other_swp.to_bytes(), &account, CTX) + .err() + .unwrap(); + assert!(matches!(err, ResponseError::WrongSigner { .. })); + + // Right key, stale payload → BadSignature (abort) + let mut other_ctx = test_ctx(); + other_ctx.nonce = 8; + let other_raw = build_raw_signer_payload(&state, &call, &other_ctx).unwrap(); + let err = validate_signature_response(&other_raw, &response, &account, CTX).err().unwrap(); + assert!(!err.rescan_safe()); + assert!(matches!(err, ResponseError::BadSignature)); + + // Truncated response → BadLength (rescan-safe) + let err = validate_signature_response( + &raw, + &response[..SIGNATURE_RESPONSE_LEN_ML_DSA_65 - 1], + &account, + CTX, + ) + .err() + .unwrap(); + assert!(err.rescan_safe()); + assert!(matches!(err, ResponseError::BadLength(_))); + + // The validated 65 signature assembles into a signed V4 extrinsic + let client = OfflineClient::::new( + state.genesis_hash, + state.runtime_version, + state.metadata.clone(), + ); + let mut partial = client.tx().create_v4_partial_offline(&call, build_params(&ctx)).unwrap(); + let submittable = partial.sign_with_account_and_signature(&account, &validated); + let encoded = submittable.encoded().to_vec(); + use codec::Decode; + let mut cursor = &encoded[..]; + let _len = codec::Compact::::decode(&mut cursor).unwrap(); + assert_eq!(cursor[0], 0b1000_0000 | 4, "signed V4 extrinsic marker"); + } + /// The full simulator loop: request UR → sign → response UR → validate → /// assemble a submittable extrinsic offline. #[test] @@ -788,8 +902,7 @@ mod tests { let mut partial = client.tx().create_v4_partial_offline(&call, build_params(&ctx)).unwrap(); assert_eq!(partial.signer_payload(), signable_payload(&raw)); - let signature = DilithiumSignatureScheme::Dilithium87(validated); - let submittable = partial.sign_with_account_and_signature(&alice_account(), &signature); + let submittable = partial.sign_with_account_and_signature(&alice_account(), &validated); // V4 signed extrinsic: version byte 0x84 after the compact length prefix let encoded = submittable.encoded().to_vec(); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 318cd40..2790f6c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -16,6 +16,7 @@ pub mod high_security; pub mod metadata; pub mod multisend; pub mod multisig; +pub mod near; pub mod preimage; pub mod reversible; pub mod runtime; @@ -114,6 +115,10 @@ pub enum Commands { #[command(subcommand)] Multisig(multisig::MultisigCommands), + /// NEAR accounts controlled by ML-DSA-65 wallets + #[command(subcommand)] + Near(near::NearCommands), + /// Scheduler commands #[command(subcommand)] Scheduler(scheduler::SchedulerCommands), @@ -409,6 +414,7 @@ pub async fn execute_command( high_security::handle_high_security_command(hs_cmd, node_url, execution_mode).await, Commands::Multisig(multisig_cmd) => multisig::handle_multisig_command(multisig_cmd, node_url, execution_mode).await, + Commands::Near(near_cmd) => near::handle_near_command(near_cmd).await, Commands::Scheduler(scheduler_cmd) => scheduler::handle_scheduler_command(scheduler_cmd, node_url, execution_mode).await, Commands::Storage(storage_cmd) => diff --git a/src/cli/near.rs b/src/cli/near.rs new file mode 100644 index 0000000..225c82f --- /dev/null +++ b/src/cli/near.rs @@ -0,0 +1,413 @@ +//! `quantus near` — control NEAR accounts with a Quantus ML-DSA-65 wallet. +//! +//! NEAR accepts ML-DSA-65 access keys and transaction signatures from +//! protocol version 85. These commands create and drive a NEAR account whose +//! only access key is a Quantus wallet's ML-DSA-65 key: +//! +//! 1. `near show-key` — the wallet's key in NEAR text form. +//! 2. `near create-account` — a parent account creates a sub-account born with the ML-DSA-65 key as +//! its only key. The parent holds no key on it. +//! 3. `near keys` — verify the account's key list from the chain. +//! 4. `near send` — spend from the account, signed by the wallet. +//! +//! ML-DSA-87 wallets are rejected: NEAR defined ML-DSA-65 only. + +use crate::{ + error::{QuantusError, Result}, + log_print, log_success, log_verbose, + near::{ + protocol::{ + validate_account_id, AccessKey, Action, AddKeyAction, PublicKey, Transaction, + TransferAction, NEAR_DECIMALS, + }, + rpc::NearRpcClient, + sign::{load_credentials, sign_transaction_ed25519, sign_transaction_ml_dsa_65}, + }, + wallet::QuantumKeyPair, +}; +use clap::Subcommand; +use colored::Colorize; +use std::path::PathBuf; + +/// NEAR subcommands +#[derive(Subcommand, Debug)] +pub enum NearCommands { + /// Show the wallet's ML-DSA-65 key in NEAR text forms + ShowKey { + /// Quantus wallet (must be ML-DSA-65) + #[arg(long, short)] + wallet: String, + + /// Password for the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] + password: Option, + + /// Read password from file (for scripting) + #[arg(long)] + password_file: Option, + }, + + /// Create a NEAR sub-account whose only access key is the wallet's + /// ML-DSA-65 key. The parent signs the creation and holds no key on the + /// new account. + CreateAccount { + /// New account id; must be a sub-account of the parent + /// (e.g. vault.alice.testnet under alice.testnet) + #[arg(long)] + new_account: String, + + /// Quantus wallet whose ML-DSA-65 key controls the new account + #[arg(long, short)] + wallet: String, + + /// near-cli credentials JSON for the parent account + /// (~/.near-credentials//.json) + #[arg(long)] + parent_credentials: PathBuf, + + /// Initial balance for the new account, in NEAR + #[arg(long, default_value = "0.1")] + deposit: String, + + /// NEAR network: testnet or mainnet + #[arg(long, default_value = "testnet")] + network: String, + + /// Custom NEAR RPC URL (overrides --network) + #[arg(long)] + rpc_url: Option, + + /// Password for the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] + password: Option, + + /// Read password from file (for scripting) + #[arg(long)] + password_file: Option, + }, + + /// List an account's access keys as stored on-chain + Keys { + /// NEAR account id to inspect + #[arg(long)] + account: String, + + /// Mark keys belonging to this Quantus wallet + #[arg(long, short)] + wallet: Option, + + /// NEAR network: testnet or mainnet + #[arg(long, default_value = "testnet")] + network: String, + + /// Custom NEAR RPC URL (overrides --network) + #[arg(long)] + rpc_url: Option, + + /// Password for the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] + password: Option, + + /// Read password from file (for scripting) + #[arg(long)] + password_file: Option, + }, + + /// Transfer NEAR from an account controlled by the wallet's ML-DSA-65 key + Send { + /// Quantus wallet holding the account's ML-DSA-65 key + #[arg(long, short)] + wallet: String, + + /// NEAR account to send from + #[arg(long)] + account: String, + + /// Recipient NEAR account id + #[arg(long)] + to: String, + + /// Amount in NEAR (e.g. "1.5") + #[arg(long)] + amount: String, + + /// NEAR network: testnet or mainnet + #[arg(long, default_value = "testnet")] + network: String, + + /// Custom NEAR RPC URL (overrides --network) + #[arg(long)] + rpc_url: Option, + + /// Password for the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] + password: Option, + + /// Read password from file (for scripting) + #[arg(long)] + password_file: Option, + }, +} + +pub async fn handle_near_command(command: NearCommands) -> Result<()> { + match command { + NearCommands::ShowKey { wallet, password, password_file } => + handle_show_key(&wallet, password, password_file), + NearCommands::CreateAccount { + new_account, + wallet, + parent_credentials, + deposit, + network, + rpc_url, + password, + password_file, + } => + handle_create_account( + &new_account, + &wallet, + &parent_credentials, + &deposit, + &network, + rpc_url, + password, + password_file, + ) + .await, + NearCommands::Keys { account, wallet, network, rpc_url, password, password_file } => + handle_keys(&account, wallet, &network, rpc_url, password, password_file).await, + NearCommands::Send { + wallet, + account, + to, + amount, + network, + rpc_url, + password, + password_file, + } => + handle_send(&wallet, &account, &to, &amount, &network, rpc_url, password, password_file) + .await, + } +} + +/// Load a wallet and its key as a NEAR public key, refusing non-65 schemes. +fn load_ml_dsa_65_wallet( + 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 + ))); + } + let public = PublicKey::from_ml_dsa_65_bytes(&keypair.public_key)?; + Ok((keypair, public)) +} + +fn print_key(public: &PublicKey) { + log_print!("NEAR public key (use for AddKey / lookups):"); + log_print!(" {}", public.to_near_string().bright_cyan()); + log_print!("On-chain handle (what key lists show):"); + log_print!(" {}", public.handle_string().expect("ML-DSA-65 key has a handle").bright_cyan()); +} + +fn handle_show_key( + wallet: &str, + password: Option, + password_file: Option, +) -> Result<()> { + let (_, public) = load_ml_dsa_65_wallet(wallet, password, password_file)?; + print_key(&public); + Ok(()) +} + +fn explorer_tx_url(network: &str, tx_hash: &str) -> Option { + match network { + "testnet" => Some(format!("https://testnet.nearblocks.io/txns/{tx_hash}")), + "mainnet" => Some(format!("https://nearblocks.io/txns/{tx_hash}")), + _ => None, + } +} + +fn report_outcome(network: &str, outcome: &serde_json::Value) { + if let Some(hash) = outcome.pointer("/transaction/hash").and_then(|h| h.as_str()) { + log_print!(" Transaction: {}", hash.bright_cyan()); + if let Some(url) = explorer_tx_url(network, hash) { + log_print!(" Explorer: {url}"); + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn handle_create_account( + new_account: &str, + wallet: &str, + parent_credentials: &std::path::Path, + deposit: &str, + network: &str, + rpc_url: Option, + password: Option, + password_file: Option, +) -> Result<()> { + validate_account_id(new_account)?; + let (_, public) = load_ml_dsa_65_wallet(wallet, password, password_file)?; + let parent = load_credentials(parent_credentials)?; + + if !new_account.ends_with(&format!(".{}", parent.account_id)) { + return Err(QuantusError::Generic(format!( + "'{new_account}' is not a sub-account of '{}' — NEAR only lets an account create \ + accounts directly under its own name", + parent.account_id + ))); + } + + let deposit_yocto = crate::cli::send::parse_amount_with_decimals(deposit, NEAR_DECIMALS)?; + let client = NearRpcClient::for_network(network, rpc_url)?; + client.ensure_ml_dsa_support().await?; + + if client.account_exists(new_account).await? { + return Err(QuantusError::Generic(format!("account '{new_account}' already exists"))); + } + + let parent_key = parent.public_key.to_near_string(); + let access_key = client.view_access_key(&parent.account_id, &parent_key).await?; + + let tx = Transaction { + signer_id: parent.account_id.clone(), + public_key: parent.public_key.clone(), + nonce: access_key.nonce + 1, + receiver_id: new_account.to_string(), + block_hash: access_key.block_hash, + actions: vec![ + Action::CreateAccount, + Action::Transfer(TransferAction { deposit: deposit_yocto }), + Action::AddKey(AddKeyAction { + public_key: public.clone(), + access_key: AccessKey::full_access(), + }), + ], + }; + + log_print!("🌍 Creating {} on NEAR {network}", new_account.bright_cyan()); + log_print!( + " Parent: {} (signs creation, keeps no key on the new account)", + parent.account_id + ); + log_print!( + " Deposit: {} NEAR", + crate::cli::send::format_balance(deposit_yocto, NEAR_DECIMALS) + ); + log_print!(" Sole key: {}", public.handle_string().expect("65 key").bright_cyan()); + + let signed = sign_transaction_ed25519(tx, &parent.signing_key)?; + let outcome = client.send_tx(&signed).await?; + report_outcome(network, &outcome); + + // The parent chose the initial key list; prove from chain state that it + // contains exactly our key. + let keys = client.view_access_key_list(new_account).await?; + let our_handle = public.handle_string().expect("65 key"); + let sole_key = keys.len() == 1 && keys[0].public_key == our_handle && keys[0].full_access; + if !sole_key { + let listed: Vec<&str> = keys.iter().map(|k| k.public_key.as_str()).collect(); + return Err(QuantusError::Generic(format!( + "post-creation check failed: expected exactly one full-access key {our_handle}, chain \ + lists {listed:?}" + ))); + } + + log_success!( + "✅ {new_account} exists and is solely controlled by wallet '{wallet}' — its only access \ + key is {}", + our_handle.bright_cyan() + ); + log_print!("💡 Send from it with: quantus near send --wallet {wallet} --account {new_account} --to --amount "); + Ok(()) +} + +async fn handle_keys( + account: &str, + wallet: Option, + network: &str, + rpc_url: Option, + password: Option, + password_file: Option, +) -> Result<()> { + validate_account_id(account)?; + let our_handle = match wallet { + Some(wallet) => { + let (_, public) = load_ml_dsa_65_wallet(&wallet, password, password_file)?; + Some((wallet, public.handle_string().expect("65 key"))) + }, + None => None, + }; + + let client = NearRpcClient::for_network(network, rpc_url)?; + let keys = client.view_access_key_list(account).await?; + + log_print!("🔑 {} access key(s) on {}:", keys.len(), account.bright_cyan()); + for key in &keys { + let access = if key.full_access { "full-access" } else { "function-call" }; + let ours = match &our_handle { + Some((wallet, handle)) if *handle == key.public_key => + format!(" ← wallet '{wallet}'").bright_green().to_string(), + _ => String::new(), + }; + log_print!(" {} {access}{ours}", key.public_key); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_send( + wallet: &str, + account: &str, + to: &str, + amount: &str, + network: &str, + rpc_url: Option, + password: Option, + password_file: Option, +) -> Result<()> { + validate_account_id(account)?; + validate_account_id(to)?; + let (keypair, public) = load_ml_dsa_65_wallet(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)?; + client.ensure_ml_dsa_support().await?; + + // Nonce lookup by the full key also proves the key is on the account. + let full_key = public.to_near_string(); + let access_key = client.view_access_key(account, &full_key).await?; + log_verbose!("access key nonce: {}", access_key.nonce); + + let tx = Transaction { + signer_id: account.to_string(), + public_key: public, + nonce: access_key.nonce + 1, + receiver_id: to.to_string(), + block_hash: access_key.block_hash, + actions: vec![Action::Transfer(TransferAction { deposit: amount_yocto })], + }; + + log_print!( + "💸 {} NEAR: {} → {} (signed with ML-DSA-65 wallet '{}')", + crate::cli::send::format_balance(amount_yocto, NEAR_DECIMALS).bright_yellow(), + account.bright_cyan(), + to.bright_cyan(), + wallet + ); + + let pair = keypair.to_dilithium65_pair()?; + let signed = sign_transaction_ml_dsa_65(tx, &pair)?; + let outcome = client.send_tx(&signed).await?; + report_outcome(network, &outcome); + log_success!("✅ Transfer finalized"); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 76fd669..0bf1cd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod collect_rewards_lib; pub mod config; pub mod error; pub mod log; +pub mod near; pub mod qr; pub mod subsquid; pub mod version_check; diff --git a/src/main.rs b/src/main.rs index 6b59d5a..5e774ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod collect_rewards_lib; mod config; mod error; mod log; +mod near; mod qr; mod subsquid; mod version_check; diff --git a/src/near/mod.rs b/src/near/mod.rs new file mode 100644 index 0000000..9c45bfb --- /dev/null +++ b/src/near/mod.rs @@ -0,0 +1,13 @@ +//! NEAR protocol integration. +//! +//! NEAR accepts FIPS 204 ML-DSA-65 as a transaction-signature scheme and +//! 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`]). +//! +//! ML-DSA-87 has no NEAR equivalent; only ML-DSA-65 wallets can sign here. + +pub mod protocol; +pub mod rpc; +pub mod sign; diff --git a/src/near/protocol.rs b/src/near/protocol.rs new file mode 100644 index 0000000..603dbbf --- /dev/null +++ b/src/near/protocol.rs @@ -0,0 +1,367 @@ +//! NEAR wire types, borsh-encoded exactly as nearcore defines them. +//! +//! Layout is consensus-critical: enum variants are borsh u8 tags in +//! declaration order, so the unused variants below hold tag positions and +//! must stay. Mirrors `core/crypto/src/signature.rs` and +//! `core/primitives/src/transaction.rs` in nearcore (the unversioned +//! `TransactionV0` layout, which is what wallets produce). +//! +//! ML-DSA-65 keys ride in transactions as the full 1952-byte public key +//! (`ml-dsa-65:` text form). On-chain, NEAR stores only a SHA3-256 handle of +//! the key, and access-key lists return it under the `ml-dsa-65-hash:` text +//! form; [`PublicKey::handle_string`] computes it for reconciliation. + +use crate::error::{QuantusError, Result}; +use borsh::BorshSerialize; + +pub const ED25519_PUBLIC_KEY_LEN: usize = 32; +pub const SECP256K1_PUBLIC_KEY_LEN: usize = 64; +pub const ML_DSA_65_PUBLIC_KEY_LEN: usize = 1952; +pub const ED25519_SIGNATURE_LEN: usize = 64; +pub const ML_DSA_65_SIGNATURE_LEN: usize = 3309; + +/// Domain-separation tag nearcore hashes before the raw public key to form +/// the on-trie access-key handle (`near_crypto::hash_domain`). +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)] +pub enum PublicKey { + Ed25519([u8; ED25519_PUBLIC_KEY_LEN]), + Secp256k1(Box<[u8; SECP256K1_PUBLIC_KEY_LEN]>), + MlDsa65(Box<[u8; ML_DSA_65_PUBLIC_KEY_LEN]>), +} + +impl PublicKey { + pub fn from_ml_dsa_65_bytes(bytes: &[u8]) -> Result { + let key: [u8; ML_DSA_65_PUBLIC_KEY_LEN] = bytes.try_into().map_err(|_| { + QuantusError::Generic(format!( + "ML-DSA-65 public key must be {ML_DSA_65_PUBLIC_KEY_LEN} bytes, got {}", + bytes.len() + )) + })?; + Ok(PublicKey::MlDsa65(Box::new(key))) + } + + /// The `:` text form NEAR RPC and tooling use. + pub fn to_near_string(&self) -> String { + match self { + PublicKey::Ed25519(bytes) => format!("ed25519:{}", bs58::encode(bytes).into_string()), + PublicKey::Secp256k1(bytes) => + format!("secp256k1:{}", bs58::encode(bytes.as_ref()).into_string()), + PublicKey::MlDsa65(bytes) => + format!("ml-dsa-65:{}", bs58::encode(bytes.as_ref()).into_string()), + } + } + + /// Parse the `:` text form. + pub fn parse(s: &str) -> Result { + let (scheme, data) = s.split_once(':').ok_or_else(|| { + QuantusError::Generic(format!("public key '{s}' has no scheme prefix")) + })?; + let bytes = bs58::decode(data) + .into_vec() + .map_err(|e| QuantusError::Generic(format!("public key base58: {e}")))?; + match scheme { + "ed25519" => Ok(PublicKey::Ed25519(bytes.as_slice().try_into().map_err(|_| { + QuantusError::Generic(format!( + "ed25519 public key must be {ED25519_PUBLIC_KEY_LEN} bytes, got {}", + bytes.len() + )) + })?)), + "secp256k1" => { + let key: [u8; SECP256K1_PUBLIC_KEY_LEN] = + bytes.as_slice().try_into().map_err(|_| { + QuantusError::Generic(format!( + "secp256k1 public key must be {SECP256K1_PUBLIC_KEY_LEN} bytes, got {}", + bytes.len() + )) + })?; + Ok(PublicKey::Secp256k1(Box::new(key))) + }, + "ml-dsa-65" => Self::from_ml_dsa_65_bytes(&bytes), + other => Err(QuantusError::Generic(format!("unsupported key scheme '{other}'"))), + } + } + + /// SHA3-256 handle of an ML-DSA-65 key: what NEAR stores on-chain and + /// what `view_access_key_list` returns for these keys. + pub fn ml_dsa_65_handle(&self) -> Option<[u8; 32]> { + let PublicKey::MlDsa65(bytes) = self else { + return None; + }; + use sha3::{Digest, Sha3_256}; + let mut hasher = Sha3_256::new(); + hasher.update(ML_DSA_65_HANDLE_DOMAIN_TAG); + hasher.update(bytes.as_ref()); + Some(hasher.finalize().into()) + } + + /// The `ml-dsa-65-hash:` text form of [`Self::ml_dsa_65_handle`]. + pub fn handle_string(&self) -> Option { + self.ml_dsa_65_handle() + .map(|h| format!("ml-dsa-65-hash:{}", bs58::encode(h).into_string())) + } +} + +/// A transaction signature. Same tag space as [`PublicKey`]. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub enum Signature { + Ed25519([u8; ED25519_SIGNATURE_LEN]), + /// Tag position only; this CLI never signs secp256k1. + #[allow(dead_code)] + Secp256k1(Box<[u8; 65]>), + MlDsa65(Box<[u8; ML_DSA_65_SIGNATURE_LEN]>), +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct AccessKey { + /// Starting nonce for the key. 0 for a key on a brand-new account. + pub nonce: u64, + pub permission: AccessKeyPermission, +} + +impl AccessKey { + pub fn full_access() -> Self { + AccessKey { nonce: 0, permission: AccessKeyPermission::FullAccess } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub enum AccessKeyPermission { + /// Tag position only; this CLI adds full-access keys. + #[allow(dead_code)] + FunctionCall(FunctionCallPermission), + FullAccess, +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct FunctionCallPermission { + /// Allowance in yoctoNEAR the key may spend on gas; `None` = unlimited. + pub allowance: Option, + pub receiver_id: String, + pub method_names: Vec, +} + +/// 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)] +pub enum Action { + CreateAccount, + #[allow(dead_code)] + DeployContract(DeployContractAction), + #[allow(dead_code)] + FunctionCall(FunctionCallAction), + Transfer(TransferAction), + #[allow(dead_code)] + Stake(StakeAction), + AddKey(AddKeyAction), + #[allow(dead_code)] + DeleteKey(DeleteKeyAction), + #[allow(dead_code)] + DeleteAccount(DeleteAccountAction), +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct DeployContractAction { + pub code: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct FunctionCallAction { + pub method_name: String, + pub args: Vec, + pub gas: u64, + pub deposit: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct TransferAction { + /// Amount in yoctoNEAR (24 decimals). + pub deposit: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct StakeAction { + pub stake: u128, + pub public_key: PublicKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct AddKeyAction { + pub public_key: PublicKey, + pub access_key: AccessKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct DeleteKeyAction { + pub public_key: PublicKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct DeleteAccountAction { + pub beneficiary_id: String, +} + +/// The signable transaction body (nearcore `TransactionV0`). +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct Transaction { + pub signer_id: String, + /// Key the signer signs with; full ML-DSA-65 key, never the hash form. + pub public_key: PublicKey, + /// Access-key nonce + 1 at submission time. + pub nonce: u64, + pub receiver_id: String, + /// A recent block hash (transactions expire ~24h after it). + pub block_hash: [u8; 32], + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize)] +pub struct SignedTransaction { + pub transaction: Transaction, + pub signature: Signature, +} + +/// NEAR uses 24 decimals (yoctoNEAR). +pub const NEAR_DECIMALS: u8 = 24; + +/// 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()); + let valid_chars = account_id.split(['.', '_', '-']).all(|part| { + !part.is_empty() && part.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + }); + if !valid_len || !valid_chars { + return Err(QuantusError::Generic(format!( + "'{account_id}' is not a valid NEAR account id (2-64 chars, lowercase alphanumerics \ + separated by '.', '_' or '-')" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Hand-built borsh bytes pin the wire layout independent of the derive. + #[test] + fn transaction_borsh_layout_is_the_near_wire_format() { + let 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::CreateAccount, + Action::Transfer(TransferAction { deposit: 1_000_000 }), + Action::AddKey(AddKeyAction { + public_key: PublicKey::MlDsa65(Box::new([0x33; ML_DSA_65_PUBLIC_KEY_LEN])), + access_key: AccessKey::full_access(), + }), + ], + }; + + let mut expected: Vec = Vec::new(); + expected.extend(13u32.to_le_bytes()); // signer_id length + expected.extend(b"alice.testnet"); + expected.push(0); // PublicKey tag: ed25519 + expected.extend([0x11; 32]); + expected.extend(42u64.to_le_bytes()); // nonce + expected.extend(11u32.to_le_bytes()); // receiver_id length + expected.extend(b"bob.testnet"); + expected.extend([0x22; 32]); // block_hash (fixed array: no length prefix) + expected.extend(3u32.to_le_bytes()); // actions vec length + expected.push(0); // Action tag: CreateAccount (no payload) + expected.push(3); // Action tag: Transfer + expected.extend(1_000_000u128.to_le_bytes()); + expected.push(5); // Action tag: AddKey + expected.push(2); // PublicKey tag: ml-dsa-65 + expected.extend([0x33; ML_DSA_65_PUBLIC_KEY_LEN]); + expected.extend(0u64.to_le_bytes()); // AccessKey nonce + expected.push(1); // AccessKeyPermission tag: FullAccess + + assert_eq!(borsh::to_vec(&tx).unwrap(), expected); + + // Independently computed golden (Python hashlib over the same bytes). + assert_eq!( + hex::encode(crate::near::sign::transaction_hash(&tx).unwrap()), + "c2a1f5de00a9538f35cfc24316fc8748315b837299153eafc47abde7c187b92e" + ); + } + + #[test] + fn signed_transaction_appends_the_signature() { + let tx = Transaction { + signer_id: "a.testnet".to_string(), + public_key: PublicKey::Ed25519([0; 32]), + nonce: 1, + receiver_id: "b.testnet".to_string(), + block_hash: [0; 32], + actions: vec![Action::Transfer(TransferAction { deposit: 1 })], + }; + let tx_bytes = borsh::to_vec(&tx).unwrap(); + + let signed = SignedTransaction { + transaction: tx, + signature: Signature::MlDsa65(Box::new([0x44; ML_DSA_65_SIGNATURE_LEN])), + }; + let signed_bytes = borsh::to_vec(&signed).unwrap(); + + assert_eq!(&signed_bytes[..tx_bytes.len()], &tx_bytes[..]); + assert_eq!(signed_bytes[tx_bytes.len()], 2, "Signature tag: ml-dsa-65"); + assert_eq!(signed_bytes.len(), tx_bytes.len() + 1 + ML_DSA_65_SIGNATURE_LEN); + } + + #[test] + fn key_text_forms_roundtrip() { + let ml = PublicKey::MlDsa65(Box::new([0x42; ML_DSA_65_PUBLIC_KEY_LEN])); + let s = ml.to_near_string(); + assert!(s.starts_with("ml-dsa-65:")); + assert_eq!(PublicKey::parse(&s).unwrap(), ml); + + let ed = PublicKey::Ed25519([7; 32]); + let s = ed.to_near_string(); + assert!(s.starts_with("ed25519:")); + assert_eq!(PublicKey::parse(&s).unwrap(), ed); + + assert!(PublicKey::parse("ml-dsa-65-hash:11111111111111111111111111111111").is_err()); + assert!(PublicKey::parse("no-prefix").is_err()); + } + + #[test] + fn handle_is_domain_separated_sha3_of_the_pubkey() { + let key = PublicKey::MlDsa65(Box::new([0x42; ML_DSA_65_PUBLIC_KEY_LEN])); + let handle = key.ml_dsa_65_handle().unwrap(); + + // Independently computed golden (Python hashlib over tag ‖ pubkey). + assert_eq!( + hex::encode(handle), + "0ef8ccba4bb1a8859f1cc3d17c4d9d35712b8ddedd3875906559c50f7dd86505" + ); + + use sha3::{Digest, Sha3_256}; + let mut plain = Sha3_256::new(); + plain.update([0x42; ML_DSA_65_PUBLIC_KEY_LEN]); + let plain: [u8; 32] = plain.finalize().into(); + assert_ne!(handle, plain, "handle must hash the domain tag first"); + + let s = key.handle_string().unwrap(); + assert!(s.starts_with("ml-dsa-65-hash:")); + + assert!(PublicKey::Ed25519([0; 32]).handle_string().is_none()); + } + + #[test] + fn account_id_validation() { + assert!(validate_account_id("alice.testnet").is_ok()); + assert!(validate_account_id("vault.alice.near").is_ok()); + assert!(validate_account_id("a-b_c.d").is_ok()); + assert!(validate_account_id("a").is_err()); + assert!(validate_account_id("Alice.testnet").is_err()); + assert!(validate_account_id("double..dot").is_err()); + assert!(validate_account_id(&"x".repeat(65)).is_err()); + } +} diff --git a/src/near/rpc.rs b/src/near/rpc.rs new file mode 100644 index 0000000..d648d04 --- /dev/null +++ b/src/near/rpc.rs @@ -0,0 +1,318 @@ +//! Minimal NEAR JSON-RPC client: just the calls the `near` commands need. + +use crate::{ + error::{QuantusError, Result}, + near::protocol::SignedTransaction, +}; +use serde_json::{json, Value}; + +/// NEAR accepts ML-DSA-65 transactions and access keys from this protocol +/// version (the `PostQuantumSignatures` feature). +pub const MIN_ML_DSA_PROTOCOL_VERSION: u64 = 85; + +pub struct NearRpcClient { + url: String, + http: reqwest::Client, +} + +/// An access key's state, plus the queried block hash — recent enough to +/// anchor the next transaction. +pub struct AccessKeyView { + pub nonce: u64, + pub block_hash: [u8; 32], +} + +/// One `view_access_key_list` entry: the text-form key (`ed25519:…` or +/// `ml-dsa-65-hash:…`) and whether it is full-access. +pub struct AccessKeyListEntry { + pub public_key: String, + pub full_access: bool, +} + +impl NearRpcClient { + pub fn new(url: impl Into) -> Result { + let http = reqwest::Client::builder() + .build() + .map_err(|e| QuantusError::Generic(format!("HTTP client: {e}")))?; + Ok(Self { url: url.into(), http }) + } + + /// Resolve `--network`/`--rpc-url` to a client. An explicit URL wins. + pub fn for_network(network: &str, rpc_url: Option) -> Result { + let url = match (rpc_url, network) { + (Some(url), _) => url, + (None, "testnet") => "https://rpc.testnet.near.org".to_string(), + (None, "mainnet") => "https://rpc.mainnet.near.org".to_string(), + (None, other) => + return Err(QuantusError::Generic(format!( + "unknown network '{other}' — use testnet, mainnet, or --rpc-url" + ))), + }; + Self::new(url) + } + + async fn call(&self, method: &str, params: Value) -> Result { + let body = + json!({ "jsonrpc": "2.0", "id": "quantus-cli", "method": method, "params": params }); + let response = self + .http + .post(&self.url) + .json(&body) + .send() + .await + .map_err(|e| QuantusError::NetworkError(format!("NEAR RPC {method}: {e}")))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|e| QuantusError::NetworkError(format!("NEAR RPC {method}: {e}")))?; + if !status.is_success() { + return Err(QuantusError::NetworkError(format!( + "NEAR RPC {method} failed with {status}: {text}" + ))); + } + + let envelope: Value = serde_json::from_str(&text) + .map_err(|e| QuantusError::Generic(format!("NEAR RPC {method} response: {e}")))?; + if let Some(error) = envelope.get("error") { + return Err(QuantusError::Generic(format!("NEAR RPC {method} error: {error}"))); + } + Ok(envelope.get("result").cloned().unwrap_or(Value::Null)) + } + + pub async fn protocol_version(&self) -> Result { + let status = self.call("status", json!([])).await?; + status + .get("protocol_version") + .and_then(|v| v.as_u64()) + .ok_or_else(|| QuantusError::Generic("status response has no protocol_version".into())) + } + + /// Fail early on networks that predate ML-DSA-65 support. + pub async fn ensure_ml_dsa_support(&self) -> Result<()> { + let version = self.protocol_version().await?; + if version < MIN_ML_DSA_PROTOCOL_VERSION { + return Err(QuantusError::Generic(format!( + "this NEAR network runs protocol version {version}; ML-DSA-65 needs \ + {MIN_ML_DSA_PROTOCOL_VERSION}+" + ))); + } + Ok(()) + } + + /// Look up an access key by its full text form. Works with the full + /// `ml-dsa-65:` key — the node hashes it for the trie lookup. + pub async fn view_access_key( + &self, + account_id: &str, + public_key: &str, + ) -> Result { + let result = self + .call( + "query", + json!({ + "request_type": "view_access_key", + "finality": "final", + "account_id": account_id, + "public_key": public_key, + }), + ) + .await?; + + // Missing keys come back as a result-level error string, not an RPC error. + if let Some(error) = result.get("error").and_then(|e| e.as_str()) { + return Err(QuantusError::Generic(format!( + "access key lookup for {account_id}: {error}" + ))); + } + + let nonce = result + .get("nonce") + .and_then(|v| v.as_u64()) + .ok_or_else(|| QuantusError::Generic("view_access_key response has no nonce".into()))?; + let block_hash = decode_block_hash(&result)?; + Ok(AccessKeyView { nonce, block_hash }) + } + + pub async fn view_access_key_list(&self, account_id: &str) -> Result> { + let result = self + .call( + "query", + json!({ + "request_type": "view_access_key_list", + "finality": "final", + "account_id": account_id, + }), + ) + .await?; + if let Some(error) = result.get("error").and_then(|e| e.as_str()) { + return Err(QuantusError::Generic(format!("access key list for {account_id}: {error}"))); + } + + let keys = result + .get("keys") + .and_then(|k| k.as_array()) + .ok_or_else(|| QuantusError::Generic("view_access_key_list has no keys".into()))?; + keys.iter() + .map(|entry| { + let public_key = entry + .get("public_key") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + QuantusError::Generic("access key entry has no public_key".into()) + })? + .to_string(); + let full_access = entry + .pointer("/access_key/permission") + .map(|p| p == &json!("FullAccess")) + .unwrap_or(false); + Ok(AccessKeyListEntry { public_key, full_access }) + }) + .collect() + } + + /// Whether the account exists (`view_account` succeeds). + pub async fn account_exists(&self, account_id: &str) -> Result { + let result = self + .call( + "query", + json!({ + "request_type": "view_account", + "finality": "final", + "account_id": account_id, + }), + ) + .await; + match result { + Ok(value) => Ok(value.get("error").is_none()), + Err(e) => { + let text = e.to_string(); + if text.contains("does not exist") || text.contains("UNKNOWN_ACCOUNT") { + Ok(false) + } else { + Err(e) + } + }, + } + } + + /// Submit and wait for finality. Returns the execution outcome only if it + /// explicitly reports a finalized success; anything else is an error. + pub async fn send_tx(&self, signed: &SignedTransaction) -> Result { + let bytes = borsh::to_vec(signed) + .map_err(|e| QuantusError::Generic(format!("borsh-encoding transaction: {e}")))?; + use base64::Engine as _; + let signed_tx_base64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + + let result = self + .call("send_tx", json!({ "signed_tx_base64": signed_tx_base64, "wait_until": "FINAL" })) + .await?; + + check_send_tx_outcome(&result)?; + Ok(result) + } +} + +/// Accept only a finalized, successful `send_tx` outcome. A missing result, +/// missing/unfinished status, or non-FINAL execution status is an error even +/// when the RPC call itself returned 200. +fn check_send_tx_outcome(result: &Value) -> Result<()> { + if !result.is_object() { + return Err(QuantusError::Generic( + "send_tx returned no execution outcome; transaction state unknown".into(), + )); + } + if let Some(failure) = result.pointer("/status/Failure") { + return Err(QuantusError::Generic(format!("transaction failed on-chain: {failure}"))); + } + if result.pointer("/status/SuccessValue").is_none() { + let status = result.get("status").cloned().unwrap_or(Value::Null); + return Err(QuantusError::Generic(format!( + "transaction did not report a successful outcome (status: {status}); \ + check the explorer before retrying" + ))); + } + match result.get("final_execution_status").and_then(|v| v.as_str()) { + Some("FINAL") => Ok(()), + other => Err(QuantusError::Generic(format!( + "transaction succeeded but is not finalized (final_execution_status: {other:?}); \ + check the explorer before retrying" + ))), + } +} + +fn decode_block_hash(result: &Value) -> Result<[u8; 32]> { + let text = result + .get("block_hash") + .and_then(|v| v.as_str()) + .ok_or_else(|| QuantusError::Generic("response has no block_hash".into()))?; + let bytes = bs58::decode(text) + .into_vec() + .map_err(|e| QuantusError::Generic(format!("block_hash base58: {e}")))?; + bytes.as_slice().try_into().map_err(|_| { + QuantusError::Generic(format!("block_hash must be 32 bytes, got {}", bytes.len())) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn send_tx_outcome_accepts_finalized_success() { + let result = json!({ + "status": { "SuccessValue": "" }, + "final_execution_status": "FINAL", + "transaction": { "hash": "abc" }, + }); + assert!(check_send_tx_outcome(&result).is_ok()); + } + + #[test] + fn send_tx_outcome_rejects_missing_result() { + let err = check_send_tx_outcome(&Value::Null).unwrap_err(); + assert!(err.to_string().contains("no execution outcome"), "{err}"); + } + + #[test] + fn send_tx_outcome_rejects_failure() { + let result = json!({ + "status": { "Failure": { "ActionError": { "index": 0 } } }, + "final_execution_status": "FINAL", + }); + let err = check_send_tx_outcome(&result).unwrap_err(); + assert!(err.to_string().contains("failed on-chain"), "{err}"); + } + + #[test] + fn send_tx_outcome_rejects_missing_status() { + let result = json!({ "final_execution_status": "FINAL" }); + let err = check_send_tx_outcome(&result).unwrap_err(); + assert!(err.to_string().contains("did not report a successful outcome"), "{err}"); + } + + #[test] + fn send_tx_outcome_rejects_unstarted_status() { + let result = json!({ "status": "Started", "final_execution_status": "FINAL" }); + let err = check_send_tx_outcome(&result).unwrap_err(); + assert!(err.to_string().contains("did not report a successful outcome"), "{err}"); + } + + #[test] + fn send_tx_outcome_rejects_unfinalized_success() { + let result = json!({ + "status": { "SuccessValue": "" }, + "final_execution_status": "EXECUTED_OPTIMISTIC", + }); + let err = check_send_tx_outcome(&result).unwrap_err(); + assert!(err.to_string().contains("not finalized"), "{err}"); + } + + #[test] + fn send_tx_outcome_rejects_missing_final_execution_status() { + let result = json!({ "status": { "SuccessValue": "" } }); + let err = check_send_tx_outcome(&result).unwrap_err(); + assert!(err.to_string().contains("not finalized"), "{err}"); + } +} diff --git a/src/near/sign.rs b/src/near/sign.rs new file mode 100644 index 0000000..4ba4383 --- /dev/null +++ b/src/near/sign.rs @@ -0,0 +1,241 @@ +//! NEAR transaction hashing and signing. +//! +//! NEAR signs the SHA-256 of the borsh-encoded transaction body. For +//! ML-DSA-65 that hash is the message of a *pure* FIPS 204 signature with an +//! empty context — the `[0x00, 0x00]` context prefix — which is exactly what +//! [`crate::chain::signing::sign_ml_dsa_65`] produces when passed `None`. +//! The wire carries the raw 3309-byte signature, without the public key +//! suffix Quantus extrinsics use. + +use crate::{ + error::{QuantusError, Result}, + near::protocol::{ + PublicKey, Signature, SignedTransaction, Transaction, ML_DSA_65_SIGNATURE_LEN, + }, +}; +use qp_dilithium_crypto::types::Dilithium65Pair; +use sha2::{Digest, Sha256}; +use sp_core::Pair as _; +use std::path::Path; + +/// SHA-256 of the borsh transaction body: what NEAR signs and verifies, and +/// the transaction id the RPC reports (base58). +pub fn transaction_hash(tx: &Transaction) -> Result<[u8; 32]> { + let bytes = borsh::to_vec(tx) + .map_err(|e| QuantusError::Generic(format!("borsh-encoding transaction: {e}")))?; + Ok(Sha256::digest(&bytes).into()) +} + +/// Sign with the Quantus wallet's ML-DSA-65 key. `tx.public_key` must be this +/// key, or the chain would reject the signature against the declared key. +pub fn sign_transaction_ml_dsa_65( + tx: Transaction, + pair: &Dilithium65Pair, +) -> Result { + let expected = PublicKey::from_ml_dsa_65_bytes(pair.public().as_ref())?; + if tx.public_key != expected { + return Err(QuantusError::Generic( + "transaction.public_key is not the signing wallet's ML-DSA-65 key".to_string(), + )); + } + + let hash = transaction_hash(&tx)?; + let swp = crate::chain::signing::sign_ml_dsa_65(pair, &hash, None); + let signature: [u8; ML_DSA_65_SIGNATURE_LEN] = swp + .signature() + .as_ref() + .try_into() + .expect("ML-DSA-65 signature length is fixed"); + + Ok(SignedTransaction { transaction: tx, signature: Signature::MlDsa65(Box::new(signature)) }) +} + +/// Sign with a classical NEAR ed25519 key (the parent account in +/// `create-account`). +pub fn sign_transaction_ed25519( + tx: Transaction, + key: &ed25519_dalek::SigningKey, +) -> Result { + if tx.public_key != PublicKey::Ed25519(key.verifying_key().to_bytes()) { + return Err(QuantusError::Generic( + "transaction.public_key is not the signing ed25519 key".to_string(), + )); + } + + let hash = transaction_hash(&tx)?; + use ed25519_dalek::Signer; + let signature = key.sign(&hash); + Ok(SignedTransaction { transaction: tx, signature: Signature::Ed25519(signature.to_bytes()) }) +} + +/// A classical NEAR account credential, as written by near-cli to +/// `~/.near-credentials//.json`. +pub struct NearCredentials { + pub account_id: String, + pub public_key: PublicKey, + pub signing_key: ed25519_dalek::SigningKey, +} + +/// Load a near-cli credentials file: JSON with `account_id` and an +/// `ed25519:` private key under `private_key` (or `secret_key`). +/// The 64-byte keypair form carries the public half, which +/// `from_keypair_bytes` cross-checks against the secret. +pub fn load_credentials(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| { + QuantusError::Generic(format!("reading credentials {}: {e}", path.display())) + })?; + let json: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| QuantusError::Generic(format!("credentials JSON: {e}")))?; + + let account_id = json + .get("account_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| QuantusError::Generic("credentials file has no account_id".to_string()))? + .to_string(); + + let private_key = json + .get("private_key") + .or_else(|| json.get("secret_key")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + QuantusError::Generic("credentials file has no private_key/secret_key".to_string()) + })?; + + let encoded = private_key.strip_prefix("ed25519:").ok_or_else(|| { + QuantusError::Generic( + "only ed25519 parent credentials are supported (private_key must start with \ + 'ed25519:')" + .to_string(), + ) + })?; + let key_bytes = bs58::decode(encoded) + .into_vec() + .map_err(|e| QuantusError::Generic(format!("private key base58: {e}")))?; + + let signing_key = match key_bytes.len() { + 64 => { + let keypair: [u8; 64] = key_bytes.as_slice().try_into().expect("length checked"); + ed25519_dalek::SigningKey::from_keypair_bytes(&keypair).map_err(|e| { + QuantusError::Generic(format!( + "credentials private key is inconsistent (public half does not match): {e}" + )) + })? + }, + 32 => { + let seed: [u8; 32] = key_bytes.as_slice().try_into().expect("length checked"); + ed25519_dalek::SigningKey::from_bytes(&seed) + }, + other => + return Err(QuantusError::Generic(format!( + "ed25519 private key must be 32 or 64 bytes, got {other}" + ))), + }; + + let public_key = PublicKey::Ed25519(signing_key.verifying_key().to_bytes()); + if let Some(stated) = json.get("public_key").and_then(|v| v.as_str()) { + let stated = PublicKey::parse(stated)?; + if stated != public_key { + return Err(QuantusError::Generic( + "credentials public_key does not match the private key".to_string(), + )); + } + } + + Ok(NearCredentials { account_id, public_key, signing_key }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::near::protocol::{Action, TransferAction}; + + fn test_tx(public_key: PublicKey) -> Transaction { + Transaction { + signer_id: "alice.testnet".to_string(), + public_key, + nonce: 5, + receiver_id: "bob.testnet".to_string(), + block_hash: [9; 32], + actions: vec![Action::Transfer(TransferAction { deposit: 10u128.pow(24) })], + } + } + + #[test] + fn ml_dsa_65_signature_is_pure_fips204_over_the_tx_hash() { + let pair = Dilithium65Pair::from_seed(&[3u8; 32]).expect("valid seed"); + let public = PublicKey::from_ml_dsa_65_bytes(pair.public().as_ref()).unwrap(); + let tx = test_tx(public); + let hash = transaction_hash(&tx).unwrap(); + + let signed = sign_transaction_ml_dsa_65(tx, &pair).unwrap(); + let Signature::MlDsa65(sig) = &signed.signature else { + panic!("must sign as ml-dsa-65"); + }; + + // Empty-context pure ML-DSA over the 32-byte hash, verifiable with the + // upstream verifier exactly as NEAR's runtime does it. + let verifier = + qp_rusty_crystals_dilithium::ml_dsa_65::PublicKey::from_bytes(pair.public().as_ref()) + .unwrap(); + assert!(verifier.verify(&hash, sig.as_ref(), None)); + assert!(!verifier.verify(&hash, sig.as_ref(), Some(b"QUANTUS_EXTRINSIC"))); + + // Signing under the wrong declared key is refused. + let other = Dilithium65Pair::from_seed(&[4u8; 32]).expect("valid seed"); + let tx = test_tx(PublicKey::from_ml_dsa_65_bytes(other.public().as_ref()).unwrap()); + assert!(sign_transaction_ml_dsa_65(tx, &pair).is_err()); + } + + #[test] + fn ed25519_signature_verifies_over_the_tx_hash() { + let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]); + let tx = test_tx(PublicKey::Ed25519(key.verifying_key().to_bytes())); + let hash = transaction_hash(&tx).unwrap(); + + let signed = sign_transaction_ed25519(tx, &key).unwrap(); + let Signature::Ed25519(sig) = &signed.signature else { + panic!("must sign as ed25519"); + }; + + use ed25519_dalek::Verifier; + let sig = ed25519_dalek::Signature::from_bytes(sig); + assert!(key.verifying_key().verify(&hash, &sig).is_ok()); + } + + #[test] + fn credentials_file_roundtrip() { + let key = ed25519_dalek::SigningKey::from_bytes(&[1; 32]); + let mut keypair = key.to_bytes().to_vec(); + keypair.extend(key.verifying_key().to_bytes()); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("alice.testnet.json"); + std::fs::write( + &path, + serde_json::json!({ + "account_id": "alice.testnet", + "public_key": format!("ed25519:{}", bs58::encode(key.verifying_key().to_bytes()).into_string()), + "private_key": format!("ed25519:{}", bs58::encode(&keypair).into_string()), + }) + .to_string(), + ) + .unwrap(); + + let creds = load_credentials(&path).unwrap(); + assert_eq!(creds.account_id, "alice.testnet"); + assert_eq!(creds.public_key, PublicKey::Ed25519(key.verifying_key().to_bytes())); + + // A mismatched public_key field is refused. + std::fs::write( + &path, + serde_json::json!({ + "account_id": "alice.testnet", + "public_key": format!("ed25519:{}", bs58::encode([9u8; 32]).into_string()), + "private_key": format!("ed25519:{}", bs58::encode(&keypair).into_string()), + }) + .to_string(), + ) + .unwrap(); + assert!(load_credentials(&path).is_err()); + } +}