diff --git a/Cargo.lock b/Cargo.lock index 85dfeab3e3..6f76c96052 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,18 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -403,6 +415,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "1.3.2" @@ -427,6 +445,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "blake3" version = "1.8.3" @@ -545,6 +572,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -796,6 +832,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1115,6 +1157,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling" version = "0.21.3" @@ -1200,6 +1269,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.5" @@ -1354,6 +1433,31 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1497,6 +1601,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.27" @@ -2181,11 +2291,14 @@ dependencies = [ "aes-gcm", "aho-corasick", "anyhow", + "argon2", "async-trait", "axum", "base64 0.22.1", "blake3", "bollard", + "borsh", + "bs58", "bytes", "chrono", "clap", @@ -2193,6 +2306,7 @@ dependencies = [ "deadpool-postgres", "dirs 6.0.0", "dotenvy", + "ed25519-dalek", "futures", "hkdf", "http-body-util", @@ -2234,6 +2348,7 @@ dependencies = [ "wasmtime", "wasmtime-wasi", "zbus", + "zeroize", ] [[package]] @@ -2774,6 +2889,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -2844,6 +2970,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3925,6 +4061,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3962,6 +4107,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sptr" version = "0.3.2" @@ -5946,6 +6101,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 6fc39003fd..26163100fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,13 @@ sha2 = "0.10" blake3 = "1" rand = "0.8" +# NEAR key management (ed25519 signing, borsh serialization, base58 encoding) +ed25519-dalek = { version = "2", features = ["rand_core", "zeroize"] } +borsh = { version = "1", features = ["derive"] } +bs58 = "0.5" +argon2 = "0.5" +zeroize = { version = "1", features = ["derive"] } + # Docker sandbox bollard = "0.18" diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 76cefdf90f..54f9c02209 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -21,6 +21,7 @@ use crate::context::JobContext; use crate::error::Error; use crate::extensions::ExtensionManager; use crate::history::Store; +use crate::keys::KeyManager; use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult}; use crate::safety::SafetyLayer; use crate::tools::ToolRegistry; @@ -64,6 +65,7 @@ pub struct AgentDeps { pub tools: Arc, pub workspace: Option>, pub extension_manager: Option>, + pub key_manager: Option>, } /// The main agent that coordinates all components. diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 8542021129..a26414fa8d 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -318,6 +318,7 @@ impl WsServerMessage { SseEvent::Status { .. } => "status", SseEvent::ApprovalNeeded { .. } => "approval_needed", SseEvent::Error { .. } => "error", + SseEvent::ToolResult { .. } => "tool_result", SseEvent::Heartbeat => "heartbeat", }; let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); diff --git a/src/cli/key.rs b/src/cli/key.rs new file mode 100644 index 0000000000..2f83570a26 --- /dev/null +++ b/src/cli/key.rs @@ -0,0 +1,745 @@ +//! NEAR key management CLI commands. + +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; + +use clap::Subcommand; +use tokio::fs; + +use crate::config::Config; +use crate::history::Store; +use crate::keys::KeyManager; +use crate::keys::policy::{ChainSigRule, FunctionCallRule, PolicyConfig, SignatureDomain}; +use crate::keys::types::{ + AccessKeyPermission, NearAccountId, NearNetwork, format_yocto, parse_near_amount, +}; +use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore}; + +/// Default policy config path. +fn default_policy_path() -> PathBuf { + dirs::home_dir() + .map(|h| h.join(".ironclaw").join("key_policy.json")) + .unwrap_or_else(|| PathBuf::from(".ironclaw/key_policy.json")) +} + +#[derive(Subcommand, Debug, Clone)] +pub enum KeyCommand { + /// Generate a new ed25519 keypair + Generate { + /// Label for the key (used to reference it later) + label: String, + + /// NEAR account ID this key belongs to + #[arg(long)] + account: String, + + /// Permission level: "full-access" or "function-call" + #[arg(long, default_value = "function-call")] + permission: String, + + /// Contract to scope function-call keys to + #[arg(long)] + receiver: Option, + + /// Comma-separated method names (empty = all methods on contract) + #[arg(long)] + methods: Option, + + /// Allowance in NEAR (e.g., "1.5") + #[arg(long)] + allowance: Option, + + /// Network: mainnet, testnet, or RPC URL + #[arg(long, default_value = "testnet")] + network: String, + }, + + /// Import an existing secret key + Import { + /// Label for the key + label: String, + + /// NEAR account ID + #[arg(long)] + account: String, + + /// Permission level + #[arg(long, default_value = "function-call")] + permission: String, + + /// Contract to scope function-call keys to + #[arg(long)] + receiver: Option, + + /// Comma-separated method names + #[arg(long)] + methods: Option, + + /// Allowance in NEAR + #[arg(long)] + allowance: Option, + + /// Network + #[arg(long, default_value = "testnet")] + network: String, + }, + + /// List all stored keys + List { + /// Show verbose details + #[arg(short, long)] + verbose: bool, + }, + + /// Show information about a key + Info { + /// Key label + label: String, + }, + + /// Remove a key + Remove { + /// Key label + label: String, + }, + + /// Export public key (NEVER exports private key) + Export { + /// Key label + label: String, + }, + + /// Manage transaction approval policy + #[command(subcommand)] + Policy(PolicyCommand), + + /// Create encrypted backup of all keys + Backup { + /// Output file path + #[arg(long)] + output: PathBuf, + + /// List keys in a backup without restoring (still needs passphrase) + #[arg(long)] + list: bool, + }, + + /// Restore keys from encrypted backup + Restore { + /// Backup file path + path: PathBuf, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum PolicyCommand { + /// Show current policy configuration + Show, + + /// Set auto-approve transfer limit + SetTransferLimit { + /// Max NEAR amount for auto-approved transfers (e.g., "1.5") + amount: String, + }, + + /// Whitelist an account for transfers + WhitelistAccount { + /// Account ID to whitelist + account: String, + + /// Max transfer amount in NEAR + #[arg(long)] + max_transfer: Option, + }, + + /// Whitelist a validator for staking + WhitelistValidator { + /// Validator account ID + validator: String, + + /// Max stake amount in NEAR + #[arg(long)] + max_stake: Option, + }, + + /// Add a function call rule for a contract + AddContractRule { + /// Contract account ID + contract: String, + + /// Comma-separated method names (empty = all) + #[arg(long)] + methods: Option, + + /// Max deposit in NEAR + #[arg(long, default_value = "0")] + max_deposit: String, + + /// Auto-approve matching calls + #[arg(long)] + auto_approve: bool, + }, + + /// Add a chain signature rule + AddChainSigRule { + /// Derivation path pattern (supports * glob) + path_pattern: String, + + /// Signature domain: secp256k1 or ed25519 + #[arg(long, default_value = "secp256k1")] + domain: String, + + /// Max payload size in bytes + #[arg(long, default_value = "4096")] + max_payload: usize, + + /// Auto-approve matching requests + #[arg(long)] + auto_approve: bool, + }, + + /// Set daily cumulative spend limit + SetDailyLimit { + /// Max NEAR amount per day + amount: String, + }, + + /// Set per-transaction auto-approve limit + SetTxLimit { + /// Max NEAR amount per transaction + amount: String, + }, +} + +/// Run a key management command. +pub async fn run_key_command(cmd: KeyCommand) -> anyhow::Result<()> { + match cmd { + KeyCommand::Generate { + label, + account, + permission, + receiver, + methods, + allowance, + network, + } => { + let manager = create_key_manager().await?; + let account_id = NearAccountId::new(&account)?; + let network: NearNetwork = network.parse()?; + let perm = parse_permission(&permission, receiver, methods, allowance)?; + + let metadata = manager + .generate_key(&label, &account_id, perm.clone(), network) + .await?; + + println!("Key generated successfully:"); + println!(" Label: {}", metadata.label); + println!(" Account: {}", metadata.account_id); + println!(" Public key: {}", metadata.public_key); + println!(" Permission: {}", perm); + println!(" Network: {}", metadata.network); + + if matches!(perm, AccessKeyPermission::FullAccess) { + println!(); + println!( + " WARNING: This is a FULL ACCESS key for {}.", + metadata.account_id + ); + println!(" If this is the ONLY full-access key for this account and you lose it,"); + println!(" the account becomes permanently inaccessible."); + println!(); + println!(" Create a backup: ironclaw key backup --output "); + } + + Ok(()) + } + + KeyCommand::Import { + label, + account, + permission, + receiver, + methods, + allowance, + network, + } => { + let manager = create_key_manager().await?; + let account_id = NearAccountId::new(&account)?; + let network: NearNetwork = network.parse()?; + let perm = parse_permission(&permission, receiver, methods, allowance)?; + + // Read secret key from stdin (hidden) + print!("Paste secret key (ed25519:...): "); + std::io::stdout().flush()?; + let secret_key = read_hidden_line()?; + println!(); + + if secret_key.is_empty() { + anyhow::bail!("No secret key provided"); + } + + let metadata = manager + .import_key(&label, &account_id, &secret_key, perm.clone(), network) + .await?; + + println!("Key imported successfully:"); + println!(" Label: {}", metadata.label); + println!(" Account: {}", metadata.account_id); + println!(" Public key: {}", metadata.public_key); + println!(" Permission: {}", perm); + + if matches!(perm, AccessKeyPermission::FullAccess) { + println!(); + println!(" WARNING: Full-access key imported. Back it up!"); + println!(" ironclaw key backup --output "); + } + + Ok(()) + } + + KeyCommand::List { verbose } => { + let manager = create_key_manager().await?; + let keys = manager.list_keys().await?; + + if keys.is_empty() { + println!("No keys stored."); + println!("Generate one: ironclaw key generate