Compare commits

...
Author SHA1 Message Date
Illia PolosukhinandClaude Opus 4.6 2e62d71567 feat: Add NEAR key management with transaction signing and policy engine
Implements hybrid-custody NEAR key management where the agent holds scoped
function-call keys for routine operations while high-value operations require
explicit user approval through the existing channel approval flow.

Core infrastructure:
- Ed25519 key generation/import via ed25519-dalek (not near-crypto)
- AES-256-GCM encrypted storage via existing SecretsStore
- Hand-rolled borsh-serializable NEAR transaction types
- NEP-413 intent signing and MPC chain signature support
- Configurable policy engine with transaction analysis pipeline
- Daily spend tracking with automatic midnight UTC reset
- Encrypted backup/restore with Argon2id KDF
- CLI subcommands: generate, import, list, info, remove, export, policy, backup, restore
- NEAR ed25519 secret key leak detection (Critical/Block)
- WASM sign-payload host function (keys never enter WASM memory)
- KeyManager wired into AgentDeps for agent-wide access

Security invariants: private keys never reach the LLM or WASM boundary,
signing happens in host Rust code with Zeroize on drop, every transaction
is analyzed before signing, most-restrictive policy rule wins.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-09 16:24:47 -08:00
26 changed files with 5497 additions and 5 deletions
Generated
+169
View File
@@ -170,6 +170,18 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 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]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@@ -403,6 +415,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -427,6 +445,15 @@ dependencies = [
"wyz", "wyz",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "blake3" name = "blake3"
version = "1.8.3" version = "1.8.3"
@@ -545,6 +572,15 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.19.1"
@@ -796,6 +832,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.4.2" version = "0.4.2"
@@ -1115,6 +1157,33 @@ dependencies = [
"cipher", "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]] [[package]]
name = "darling" name = "darling"
version = "0.21.3" version = "0.21.3"
@@ -1200,6 +1269,16 @@ dependencies = [
"uuid", "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]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.5" version = "0.5.5"
@@ -1354,6 +1433,31 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 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]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -1497,6 +1601,12 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.27" version = "0.2.27"
@@ -2181,11 +2291,14 @@ dependencies = [
"aes-gcm", "aes-gcm",
"aho-corasick", "aho-corasick",
"anyhow", "anyhow",
"argon2",
"async-trait", "async-trait",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"blake3", "blake3",
"bollard", "bollard",
"borsh",
"bs58",
"bytes", "bytes",
"chrono", "chrono",
"clap", "clap",
@@ -2193,6 +2306,7 @@ dependencies = [
"deadpool-postgres", "deadpool-postgres",
"dirs 6.0.0", "dirs 6.0.0",
"dotenvy", "dotenvy",
"ed25519-dalek",
"futures", "futures",
"hkdf", "hkdf",
"http-body-util", "http-body-util",
@@ -2234,6 +2348,7 @@ dependencies = [
"wasmtime", "wasmtime",
"wasmtime-wasi", "wasmtime-wasi",
"zbus", "zbus",
"zeroize",
] ]
[[package]] [[package]]
@@ -2774,6 +2889,17 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@@ -2844,6 +2970,16 @@ dependencies = [
"futures-io", "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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
@@ -3925,6 +4061,15 @@ dependencies = [
"libc", "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]] [[package]]
name = "simdutf8" name = "simdutf8"
version = "0.1.5" version = "0.1.5"
@@ -3962,6 +4107,16 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "sptr" name = "sptr"
version = "0.3.2" version = "0.3.2"
@@ -5946,6 +6101,20 @@ name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 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]] [[package]]
name = "zerotrie" name = "zerotrie"
+7
View File
@@ -90,6 +90,13 @@ sha2 = "0.10"
blake3 = "1" blake3 = "1"
rand = "0.8" 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 # Docker sandbox
bollard = "0.18" bollard = "0.18"
+2
View File
@@ -21,6 +21,7 @@ use crate::context::JobContext;
use crate::error::Error; use crate::error::Error;
use crate::extensions::ExtensionManager; use crate::extensions::ExtensionManager;
use crate::history::Store; use crate::history::Store;
use crate::keys::KeyManager;
use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult}; use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult};
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
@@ -64,6 +65,7 @@ pub struct AgentDeps {
pub tools: Arc<ToolRegistry>, pub tools: Arc<ToolRegistry>,
pub workspace: Option<Arc<Workspace>>, pub workspace: Option<Arc<Workspace>>,
pub extension_manager: Option<Arc<ExtensionManager>>, pub extension_manager: Option<Arc<ExtensionManager>>,
pub key_manager: Option<Arc<KeyManager>>,
} }
/// The main agent that coordinates all components. /// The main agent that coordinates all components.
+1
View File
@@ -318,6 +318,7 @@ impl WsServerMessage {
SseEvent::Status { .. } => "status", SseEvent::Status { .. } => "status",
SseEvent::ApprovalNeeded { .. } => "approval_needed", SseEvent::ApprovalNeeded { .. } => "approval_needed",
SseEvent::Error { .. } => "error", SseEvent::Error { .. } => "error",
SseEvent::ToolResult { .. } => "tool_result",
SseEvent::Heartbeat => "heartbeat", SseEvent::Heartbeat => "heartbeat",
}; };
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
+745
View File
@@ -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<String>,
/// Comma-separated method names (empty = all methods on contract)
#[arg(long)]
methods: Option<String>,
/// Allowance in NEAR (e.g., "1.5")
#[arg(long)]
allowance: Option<String>,
/// 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<String>,
/// Comma-separated method names
#[arg(long)]
methods: Option<String>,
/// Allowance in NEAR
#[arg(long)]
allowance: Option<String>,
/// 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<String>,
},
/// Whitelist a validator for staking
WhitelistValidator {
/// Validator account ID
validator: String,
/// Max stake amount in NEAR
#[arg(long)]
max_stake: Option<String>,
},
/// Add a function call rule for a contract
AddContractRule {
/// Contract account ID
contract: String,
/// Comma-separated method names (empty = all)
#[arg(long)]
methods: Option<String>,
/// 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 <file>");
}
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 <file>");
}
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 <label> --account <id>");
return Ok(());
}
println!("Stored keys:");
println!();
for key in keys {
if verbose {
println!(" {} ({})", key.label, key.network);
println!(" Account: {}", key.account_id);
println!(" Public key: {}", key.public_key);
println!(" Permission: {}", key.permission);
println!(
" Created: {}",
key.created_at.format("%Y-%m-%d %H:%M UTC")
);
println!();
} else {
println!(
" {} | {} | {} | {}",
key.label, key.account_id, key.permission, key.network
);
}
}
Ok(())
}
KeyCommand::Info { label } => {
let manager = create_key_manager().await?;
let key = manager.get_key(&label).await?;
println!("Key: {}", key.label);
println!(" Account: {}", key.account_id);
println!(" Public key: {}", key.public_key);
println!(" Permission: {}", key.permission);
println!(" Network: {}", key.network);
println!(
" Created: {}",
key.created_at.format("%Y-%m-%d %H:%M UTC")
);
Ok(())
}
KeyCommand::Remove { label } => {
let manager = create_key_manager().await?;
manager.remove_key(&label).await?;
println!("Key '{}' removed.", label);
Ok(())
}
KeyCommand::Export { label } => {
let manager = create_key_manager().await?;
let pubkey = manager.export_public_key(&label).await?;
println!("{}", pubkey.to_near_format());
Ok(())
}
KeyCommand::Policy(policy_cmd) => run_policy_command(policy_cmd).await,
KeyCommand::Backup { output, list } => {
if list {
// List keys in backup
let data = fs::read(&output).await?;
print!("Backup passphrase: ");
std::io::stdout().flush()?;
let passphrase = read_hidden_line()?;
println!();
// We need to decrypt to list, so restore to a temp manager
// and just display, not actually import
let plaintext = crate::keys::decrypt_backup(&passphrase, &data)?;
let backup: serde_json::Value = serde_json::from_slice(&plaintext)?;
if let Some(keys) = backup.get("keys").and_then(|k| k.as_array()) {
println!("Keys in backup ({}):", output.display());
for key in keys {
let label = key.get("label").and_then(|l| l.as_str()).unwrap_or("?");
let account = key
.get("account_id")
.and_then(|a| a.as_str())
.unwrap_or("?");
println!(" {} ({})", label, account);
}
}
return Ok(());
}
let manager = create_key_manager().await?;
print!("Backup passphrase: ");
std::io::stdout().flush()?;
let passphrase = read_hidden_line()?;
println!();
print!("Confirm passphrase: ");
std::io::stdout().flush()?;
let confirm = read_hidden_line()?;
println!();
if passphrase != confirm {
anyhow::bail!("Passphrases do not match");
}
if passphrase.len() < 8 {
anyhow::bail!("Passphrase must be at least 8 characters");
}
let backup_data = manager.create_backup(&passphrase).await?;
fs::write(&output, &backup_data).await?;
println!(
"Backup created: {} ({} bytes)",
output.display(),
backup_data.len()
);
println!("Store this file securely. You'll need the passphrase to restore.");
Ok(())
}
KeyCommand::Restore { path } => {
let manager = create_key_manager().await?;
let data = fs::read(&path).await?;
print!("Backup passphrase: ");
std::io::stdout().flush()?;
let passphrase = read_hidden_line()?;
println!();
let restored = manager.restore_backup(&data, &passphrase).await?;
if restored.is_empty() {
println!("No new keys to restore (all already exist).");
} else {
println!("Restored {} keys:", restored.len());
for label in &restored {
println!(" {}", label);
}
}
Ok(())
}
}
}
async fn run_policy_command(cmd: PolicyCommand) -> anyhow::Result<()> {
let policy_path = default_policy_path();
match cmd {
PolicyCommand::Show => {
let policy = load_policy(&policy_path).await?;
let json = serde_json::to_string_pretty(&policy)?;
println!("{}", json);
Ok(())
}
PolicyCommand::SetTransferLimit { amount } => {
let yocto = parse_near_amount(&amount)?;
let mut policy = load_policy(&policy_path).await?;
policy.transfer_auto_approve_max_yocto = yocto;
save_policy(&policy_path, &policy).await?;
println!("Transfer auto-approve limit set to {}", format_yocto(yocto));
Ok(())
}
PolicyCommand::WhitelistAccount {
account,
max_transfer,
} => {
let mut policy = load_policy(&policy_path).await?;
if !policy.transfer_whitelist.contains(&account) {
policy.transfer_whitelist.push(account.clone());
}
if let Some(max) = max_transfer {
policy.transfer_whitelist_max_yocto = parse_near_amount(&max)?;
}
save_policy(&policy_path, &policy).await?;
println!("Account '{}' added to transfer whitelist", account);
Ok(())
}
PolicyCommand::WhitelistValidator {
validator,
max_stake,
} => {
let mut policy = load_policy(&policy_path).await?;
if !policy.stake_validator_whitelist.contains(&validator) {
policy.stake_validator_whitelist.push(validator.clone());
}
if let Some(max) = max_stake {
policy.stake_auto_approve_max_yocto = parse_near_amount(&max)?;
}
save_policy(&policy_path, &policy).await?;
println!("Validator '{}' added to staking whitelist", validator);
Ok(())
}
PolicyCommand::AddContractRule {
contract,
methods,
max_deposit,
auto_approve,
} => {
let mut policy = load_policy(&policy_path).await?;
let deposit = parse_near_amount(&max_deposit)?;
let method_list = methods
.map(|m| m.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
policy.function_call_rules.push(FunctionCallRule {
receiver_id: contract.clone(),
allowed_methods: method_list,
max_deposit_yocto: deposit,
max_gas: None,
auto_approve,
});
save_policy(&policy_path, &policy).await?;
println!(
"Contract rule added for '{}' (auto_approve={})",
contract, auto_approve
);
Ok(())
}
PolicyCommand::AddChainSigRule {
path_pattern,
domain,
max_payload,
auto_approve,
} => {
let domain = match domain.to_lowercase().as_str() {
"secp256k1" => SignatureDomain::Secp256k1,
"ed25519" => SignatureDomain::Ed25519,
other => anyhow::bail!("Unknown domain '{}', expected secp256k1 or ed25519", other),
};
let mut policy = load_policy(&policy_path).await?;
policy.chain_sig_rules.push(ChainSigRule {
allowed_paths: vec![path_pattern.clone()],
allowed_domains: vec![domain],
max_payload_bytes: max_payload,
auto_approve,
});
save_policy(&policy_path, &policy).await?;
println!(
"Chain signature rule added for '{}' (auto_approve={})",
path_pattern, auto_approve
);
Ok(())
}
PolicyCommand::SetDailyLimit { amount } => {
let yocto = parse_near_amount(&amount)?;
let mut policy = load_policy(&policy_path).await?;
policy.daily_spend_limit_yocto = Some(yocto);
save_policy(&policy_path, &policy).await?;
println!("Daily spend limit set to {}", format_yocto(yocto));
Ok(())
}
PolicyCommand::SetTxLimit { amount } => {
let yocto = parse_near_amount(&amount)?;
let mut policy = load_policy(&policy_path).await?;
policy.per_tx_auto_approve_max_yocto = yocto;
save_policy(&policy_path, &policy).await?;
println!(
"Per-transaction auto-approve limit set to {}",
format_yocto(yocto)
);
Ok(())
}
}
}
async fn load_policy(path: &PathBuf) -> anyhow::Result<PolicyConfig> {
if path.exists() {
let content = fs::read_to_string(path).await?;
Ok(serde_json::from_str(&content)?)
} else {
Ok(PolicyConfig::default())
}
}
async fn save_policy(path: &PathBuf, policy: &PolicyConfig) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(policy)?;
fs::write(path, content).await?;
Ok(())
}
fn parse_permission(
permission: &str,
receiver: Option<String>,
methods: Option<String>,
allowance: Option<String>,
) -> anyhow::Result<AccessKeyPermission> {
match permission {
"full-access" | "FullAccess" => Ok(AccessKeyPermission::FullAccess),
"function-call" | "FunctionCall" => {
let receiver_id = receiver
.ok_or_else(|| anyhow::anyhow!("--receiver required for function-call keys"))?;
let method_names = methods
.map(|m| m.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let allowance_yocto = allowance
.map(|a| parse_near_amount(&a))
.transpose()
.map_err(|e| anyhow::anyhow!("invalid allowance: {}", e))?;
Ok(AccessKeyPermission::FunctionCall {
allowance: allowance_yocto,
receiver_id,
method_names,
})
}
other => Err(anyhow::anyhow!(
"unknown permission '{}', expected full-access or function-call",
other
)),
}
}
/// Create a KeyManager with the default secrets store.
async fn create_key_manager() -> anyhow::Result<KeyManager> {
let config = Config::from_env()?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
)
})?;
let store = Store::new(&config.database).await?;
store.run_migrations().await?;
let crypto = SecretsCrypto::new(master_key.clone())?;
let secrets_store: Arc<dyn SecretsStore + Send + Sync> =
Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)));
let manager = KeyManager::new(secrets_store, "default".to_string());
// Load policy if it exists
let policy_path = default_policy_path();
if policy_path.exists() {
let content = fs::read_to_string(&policy_path).await?;
let policy: PolicyConfig = serde_json::from_str(&content)?;
Ok(manager.with_policy(policy))
} else {
Ok(manager)
}
}
/// Read a line of input with hidden characters.
fn read_hidden_line() -> anyhow::Result<String> {
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
terminal,
};
let mut input = String::new();
terminal::enable_raw_mode()?;
loop {
if let Event::Key(key_event) = event::read()? {
match key_event.code {
KeyCode::Enter => break,
KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
print!("\x08 \x08");
std::io::stdout().flush()?;
}
}
KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
terminal::disable_raw_mode()?;
return Err(anyhow::anyhow!("Interrupted"));
}
KeyCode::Char(c) => {
input.push(c);
print!("*");
std::io::stdout().flush()?;
}
_ => {}
}
}
}
terminal::disable_raw_mode()?;
Ok(input)
}
#[cfg(test)]
mod tests {
use crate::cli::key::parse_permission;
use crate::keys::types::AccessKeyPermission;
#[test]
fn test_parse_full_access() {
let perm = parse_permission("full-access", None, None, None).unwrap();
assert!(matches!(perm, AccessKeyPermission::FullAccess));
}
#[test]
fn test_parse_function_call() {
let perm = parse_permission(
"function-call",
Some("contract.near".to_string()),
Some("deposit,withdraw".to_string()),
Some("1.5".to_string()),
)
.unwrap();
match perm {
AccessKeyPermission::FunctionCall {
receiver_id,
method_names,
allowance,
} => {
assert_eq!(receiver_id, "contract.near");
assert_eq!(method_names, vec!["deposit", "withdraw"]);
assert!(allowance.is_some());
}
_ => panic!("expected FunctionCall"),
}
}
#[test]
fn test_parse_function_call_missing_receiver() {
let result = parse_permission("function-call", None, None, None);
assert!(result.is_err());
}
}
+6
View File
@@ -10,12 +10,14 @@
//! - Checking system health (`status`) //! - Checking system health (`status`)
mod config; mod config;
pub mod key;
mod mcp; mod mcp;
pub mod memory; pub mod memory;
pub mod status; pub mod status;
mod tool; mod tool;
pub use config::{ConfigCommand, run_config_command}; pub use config::{ConfigCommand, run_config_command};
pub use key::{KeyCommand, run_key_command};
pub use mcp::{McpCommand, run_mcp_command}; pub use mcp::{McpCommand, run_mcp_command};
pub use memory::{MemoryCommand, run_memory_command}; pub use memory::{MemoryCommand, run_memory_command};
pub use status::run_status_command; pub use status::run_status_command;
@@ -78,6 +80,10 @@ pub enum Command {
#[command(subcommand)] #[command(subcommand)]
Tool(ToolCommand), Tool(ToolCommand),
/// Manage NEAR blockchain keys
#[command(subcommand)]
Key(KeyCommand),
/// Manage MCP servers (hosted tool providers) /// Manage MCP servers (hosted tool providers)
#[command(subcommand)] #[command(subcommand)]
Mcp(McpCommand), Mcp(McpCommand),
+3
View File
@@ -39,6 +39,9 @@ pub enum Error {
#[error("Workspace error: {0}")] #[error("Workspace error: {0}")]
Workspace(#[from] WorkspaceError), Workspace(#[from] WorkspaceError),
#[error("Key management error: {0}")]
Key(#[from] crate::keys::KeyError),
} }
/// Configuration-related errors. /// Configuration-related errors.
+181
View File
@@ -0,0 +1,181 @@
//! Cross-chain signing via v1.signer MPC contract.
//!
//! Enables signing payloads for other chains (Ethereum, Bitcoin, etc.)
//! using NEAR's chain signatures MPC network.
use crate::keys::KeyError;
use crate::keys::policy::SignatureDomain;
use crate::keys::transaction::{Action, FunctionCall, MAX_GAS, ONE_YOCTO};
/// The chain signatures MPC contract on mainnet.
pub const CHAIN_SIGNATURES_CONTRACT_MAINNET: &str = "v1.signer";
/// The chain signatures MPC contract on testnet.
pub const CHAIN_SIGNATURES_CONTRACT_TESTNET: &str = "v1.signer-prod.testnet";
/// Build a FunctionCall action for requesting a chain signature.
pub fn build_chain_signature_action(
payload: &[u8],
derivation_path: &str,
_domain: SignatureDomain,
) -> Result<Action, KeyError> {
let args = serde_json::json!({
"request": {
"payload": payload.iter().map(|b| *b as u32).collect::<Vec<u32>>(),
"path": derivation_path,
"key_version": 0,
},
});
let args_bytes = serde_json::to_vec(&args).map_err(|e| KeyError::ChainSignatureError {
reason: format!("failed to serialize chain sig args: {}", e),
})?;
Ok(Action::FunctionCall(FunctionCall {
method_name: "sign".to_string(),
args: args_bytes,
gas: MAX_GAS,
deposit: ONE_YOCTO,
}))
}
/// Parse the result of a chain signature request from the transaction outcome.
pub fn parse_chain_signature_result(
outcome: &serde_json::Value,
) -> Result<ChainSignatureResult, KeyError> {
// The result is in the SuccessValue field, base64-encoded
let success_value = outcome
.get("SuccessValue")
.and_then(|v| v.as_str())
.ok_or_else(|| KeyError::ChainSignatureError {
reason: "no SuccessValue in chain signature outcome".to_string(),
})?;
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, success_value)
.map_err(|e| KeyError::ChainSignatureError {
reason: format!("failed to decode chain sig result: {}", e),
})?;
let result_str = String::from_utf8(decoded).map_err(|e| KeyError::ChainSignatureError {
reason: format!("chain sig result is not UTF-8: {}", e),
})?;
let result_json: serde_json::Value =
serde_json::from_str(&result_str).map_err(|e| KeyError::ChainSignatureError {
reason: format!("failed to parse chain sig result JSON: {}", e),
})?;
// Extract big_r and s components
let big_r = result_json
.get("big_r")
.and_then(|v| v.get("affine_point"))
.and_then(|v| v.as_str())
.ok_or_else(|| KeyError::ChainSignatureError {
reason: "missing big_r.affine_point in chain sig result".to_string(),
})?
.to_string();
let s = result_json
.get("s")
.and_then(|v| v.get("scalar"))
.and_then(|v| v.as_str())
.ok_or_else(|| KeyError::ChainSignatureError {
reason: "missing s.scalar in chain sig result".to_string(),
})?
.to_string();
let recovery_id = result_json
.get("recovery_id")
.and_then(|v| v.as_u64())
.map(|v| v as u8);
Ok(ChainSignatureResult {
big_r,
s,
recovery_id,
})
}
/// Result from a chain signature request.
#[derive(Debug, Clone)]
pub struct ChainSignatureResult {
/// The R component (affine point, hex-encoded).
pub big_r: String,
/// The s component (scalar, hex-encoded).
pub s: String,
/// Recovery ID for ECDSA (relevant for Ethereum).
pub recovery_id: Option<u8>,
}
/// Get the chain signatures contract address for a network.
pub fn chain_sig_contract(network: &crate::keys::types::NearNetwork) -> &str {
match network {
crate::keys::types::NearNetwork::Mainnet => CHAIN_SIGNATURES_CONTRACT_MAINNET,
crate::keys::types::NearNetwork::Testnet => CHAIN_SIGNATURES_CONTRACT_TESTNET,
crate::keys::types::NearNetwork::Custom(_) => CHAIN_SIGNATURES_CONTRACT_TESTNET,
}
}
#[cfg(test)]
mod tests {
use crate::keys::chain_signatures::{
build_chain_signature_action, chain_sig_contract, parse_chain_signature_result,
};
use crate::keys::policy::SignatureDomain;
use crate::keys::transaction::{Action, MAX_GAS, ONE_YOCTO};
use crate::keys::types::NearNetwork;
#[test]
fn test_build_chain_signature_action() {
let payload = vec![0u8; 32];
let action =
build_chain_signature_action(&payload, "ethereum-1", SignatureDomain::Secp256k1)
.unwrap();
match action {
Action::FunctionCall(fc) => {
assert_eq!(fc.method_name, "sign");
assert_eq!(fc.gas, MAX_GAS);
assert_eq!(fc.deposit, ONE_YOCTO);
// Verify args parse correctly
let args: serde_json::Value = serde_json::from_slice(&fc.args).unwrap();
assert!(args.get("request").is_some());
let path = args["request"]["path"].as_str().unwrap();
assert_eq!(path, "ethereum-1");
}
_ => panic!("expected FunctionCall action"),
}
}
#[test]
fn test_parse_chain_signature_result() {
let result_json = serde_json::json!({
"big_r": {"affine_point": "02abc123"},
"s": {"scalar": "def456"},
"recovery_id": 0
});
let result_str = serde_json::to_string(&result_json).unwrap();
let encoded = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
result_str.as_bytes(),
);
let outcome = serde_json::json!({"SuccessValue": encoded});
let result = parse_chain_signature_result(&outcome).unwrap();
assert_eq!(result.big_r, "02abc123");
assert_eq!(result.s, "def456");
assert_eq!(result.recovery_id, Some(0));
}
#[test]
fn test_chain_sig_contract_addresses() {
assert_eq!(chain_sig_contract(&NearNetwork::Mainnet), "v1.signer");
assert_eq!(
chain_sig_contract(&NearNetwork::Testnet),
"v1.signer-prod.testnet"
);
}
}
+58
View File
@@ -0,0 +1,58 @@
//! Error types for NEAR key management.
use crate::secrets::SecretError;
/// Errors from NEAR key operations.
#[derive(Debug, thiserror::Error)]
pub enum KeyError {
#[error("Key not found: {label}")]
NotFound { label: String },
#[error("Key already exists: {label}")]
AlreadyExists { label: String },
#[error("Invalid key format: {reason}")]
InvalidKeyFormat { reason: String },
#[error("Invalid account ID: {reason}")]
InvalidAccountId { reason: String },
#[error("Signing failed: {reason}")]
SigningFailed { reason: String },
#[error("Serialization failed: {0}")]
SerializationFailed(String),
#[error("Approval required: {operation}")]
ApprovalRequired { operation: String },
#[error("Policy denied: {reason}")]
PolicyDenied { reason: String },
#[error("RPC error: {reason}")]
RpcError { reason: String },
#[error("Stale nonce: cached {cached}, chain {chain}")]
StaleNonce { cached: u64, chain: u64 },
#[error("Insufficient allowance: needed {needed}, available {available}")]
InsufficientAllowance { needed: u128, available: u128 },
#[error("Permission denied: {reason}")]
PermissionDenied { reason: String },
#[error("Chain signature error: {reason}")]
ChainSignatureError { reason: String },
#[error("Backup error: {reason}")]
BackupError { reason: String },
#[error("Secret store error: {0}")]
SecretStore(#[from] SecretError),
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
+183
View File
@@ -0,0 +1,183 @@
//! NEP-413 intent construction and signing.
//!
//! Provides types and signing for NEAR intents following the NEP-413 standard.
//! Intents are signed messages that authorize actions on a verifying contract.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::keys::KeyError;
use crate::keys::signer::sign_hash;
use crate::keys::types::NearPublicKey;
use crate::secrets::SecretsStore;
/// NEP-413 intent message to be signed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntentMessage {
/// Account signing the intent.
pub signer_id: String,
/// Contract that will verify the signature.
pub verifying_contract: String,
/// Deadline (block height or timestamp) after which the intent expires.
pub deadline: String,
/// Unique nonce to prevent replay.
pub nonce: String,
/// List of intent actions.
pub intents: Vec<IntentAction>,
}
/// An action within an intent.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum IntentAction {
/// Token difference (swap, deposit, etc.)
TokenDiff { token: String, amount: String },
/// Add a public key to the account.
AddPublicKey { public_key: String },
/// Custom action with arbitrary data.
Custom {
action_type: String,
data: serde_json::Value,
},
}
/// A signed NEP-413 intent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedIntent {
pub standard: String,
pub payload: IntentMessage,
pub public_key: String,
pub signature: String,
}
/// Construct the NEP-413 signing payload.
///
/// The payload is: SHA-256(tag + message_json + nonce + recipient)
/// where tag is the NEP-413 tag prefix.
pub fn nep413_signing_payload(message: &IntentMessage) -> Result<[u8; 32], KeyError> {
let message_json = serde_json::to_string(message).map_err(|e| {
KeyError::SerializationFailed(format!("failed to serialize intent message: {}", e))
})?;
// NEP-413 tag
const NEP413_TAG: u32 = 2147484061; // (1 << 31) + 413
let mut hasher = Sha256::new();
hasher.update(NEP413_TAG.to_le_bytes());
hasher.update(message_json.as_bytes());
Ok(hasher.finalize().into())
}
/// Sign an intent message using a key from the secrets store.
pub async fn sign_intent(
secrets_store: &dyn SecretsStore,
user_id: &str,
label: &str,
public_key: &NearPublicKey,
intent: IntentMessage,
) -> Result<SignedIntent, KeyError> {
let hash = nep413_signing_payload(&intent)?;
let signature_bytes = sign_hash(secrets_store, user_id, label, &hash).await?;
// Base64-encode the signature
let signature =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, signature_bytes);
Ok(SignedIntent {
standard: "nep413".to_string(),
payload: intent,
public_key: public_key.to_near_format(),
signature,
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use ed25519_dalek::SigningKey;
use secrecy::SecretString;
use crate::keys::intents::{IntentAction, IntentMessage, nep413_signing_payload, sign_intent};
use crate::keys::signer::public_key_from_secret;
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto, SecretsStore};
fn test_store() -> Arc<InMemorySecretsStore> {
let key = "0123456789abcdef0123456789abcdef";
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
Arc::new(InMemorySecretsStore::new(crypto))
}
fn test_intent() -> IntentMessage {
IntentMessage {
signer_id: "alice.near".to_string(),
verifying_contract: "intents.near".to_string(),
deadline: "100000000".to_string(),
nonce: "unique-nonce-123".to_string(),
intents: vec![IntentAction::TokenDiff {
token: "wrap.near".to_string(),
amount: "1000000".to_string(),
}],
}
}
#[test]
fn test_nep413_payload_deterministic() {
let intent = test_intent();
let hash1 = nep413_signing_payload(&intent).unwrap();
let hash2 = nep413_signing_payload(&intent).unwrap();
assert_eq!(hash1, hash2);
}
#[test]
fn test_nep413_payload_different_nonces() {
let mut intent1 = test_intent();
let mut intent2 = test_intent();
intent1.nonce = "nonce-1".to_string();
intent2.nonce = "nonce-2".to_string();
let hash1 = nep413_signing_payload(&intent1).unwrap();
let hash2 = nep413_signing_payload(&intent2).unwrap();
assert_ne!(hash1, hash2);
}
#[tokio::test]
async fn test_sign_intent_roundtrip() {
let store = test_store();
// Generate a key
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
let mut combined = Vec::with_capacity(64);
combined.extend_from_slice(signing_key.as_bytes());
combined.extend_from_slice(verifying_key.as_bytes());
let secret = format!("ed25519:{}", bs58::encode(&combined).into_string());
store
.create(
"user1",
CreateSecretParams::new("near_key:intent-signer", &secret)
.with_provider("near_keys"),
)
.await
.unwrap();
let public_key = public_key_from_secret(&secret).unwrap();
let intent = test_intent();
let signed = sign_intent(
store.as_ref(),
"user1",
"intent-signer",
&public_key,
intent,
)
.await
.unwrap();
assert_eq!(signed.standard, "nep413");
assert_eq!(signed.public_key, public_key.to_near_format());
assert!(!signed.signature.is_empty());
}
}
+994
View File
@@ -0,0 +1,994 @@
//! NEAR key management for IronClaw.
//!
//! Manages NEAR Protocol blockchain keys so the agent can sign transactions,
//! intents, and cross-chain signature requests.
//!
//! # Security Model
//!
//! Hybrid custody: the agent holds scoped function-call keys for routine
//! operations. High-value operations require explicit user approval.
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────────────────┐
//! │ Key Management │
//! │ │
//! │ KeyManager ──► SecretsStore (AES-256-GCM encrypted private keys) │
//! │ │ │
//! │ ├──► Signer (ed25519 sign, Zeroize on drop) │
//! │ ├──► Policy (analyze transaction, evaluate rules, approve/deny) │
//! │ ├──► SpendTracker (daily cumulative spend) │
//! │ └──► RPC Client (nonce, submit, status) │
//! │ │
//! │ INVARIANT: Private keys NEVER reach the LLM or WASM boundary. │
//! └─────────────────────────────────────────────────────────────────────────┘
//! ```
pub mod chain_signatures;
mod error;
pub mod intents;
pub mod policy;
pub mod rpc;
pub mod signer;
pub mod spending;
pub mod transaction;
pub mod types;
pub use error::KeyError;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Utc;
use ed25519_dalek::SigningKey;
use tokio::fs;
use zeroize::Zeroize;
use crate::keys::policy::{
ChainSigAnalysis, PolicyConfig, PolicyDecision, SignatureDomain, analyze_transaction,
infer_target_chain,
};
use crate::keys::rpc::NearRpcClient;
use crate::keys::signer::{public_key_from_secret, sign_hash};
use crate::keys::spending::SpendTracker;
use crate::keys::transaction::{BlockHash, Signature, SignedTransaction, Transaction};
use crate::keys::types::{
AccessKeyPermission, KeyMetadata, KeyStore, KeyType, NearAccountId, NearNetwork, NearPublicKey,
};
use crate::secrets::{CreateSecretParams, SecretsStore};
/// Result of a signing operation.
#[derive(Debug)]
pub enum SignResult {
/// Transaction was signed (policy auto-approved).
Signed {
transaction: SignedTransaction,
analysis: policy::TransactionAnalysis,
},
/// User must approve before signing can proceed.
ApprovalRequired {
analysis: policy::TransactionAnalysis,
reasons: Vec<String>,
},
}
/// Central key management struct.
pub struct KeyManager {
secrets_store: Arc<dyn SecretsStore + Send + Sync>,
metadata_path: PathBuf,
policy: PolicyConfig,
spend_tracker: SpendTracker,
user_id: String,
}
impl KeyManager {
/// Create a new KeyManager.
pub fn new(secrets_store: Arc<dyn SecretsStore + Send + Sync>, user_id: String) -> Self {
Self {
secrets_store,
metadata_path: default_keys_path(),
policy: PolicyConfig::default(),
spend_tracker: SpendTracker::new(SpendTracker::default_path()),
user_id,
}
}
/// Set a custom metadata path (for testing).
pub fn with_metadata_path(mut self, path: PathBuf) -> Self {
self.metadata_path = path;
self
}
/// Set the policy config.
pub fn with_policy(mut self, policy: PolicyConfig) -> Self {
self.policy = policy;
self
}
/// Set a custom spend tracker (for testing).
pub fn with_spend_tracker(mut self, tracker: SpendTracker) -> Self {
self.spend_tracker = tracker;
self
}
/// Get a reference to the current policy config.
pub fn policy(&self) -> &PolicyConfig {
&self.policy
}
/// Get a mutable reference to the policy config.
pub fn policy_mut(&mut self) -> &mut PolicyConfig {
&mut self.policy
}
// -- Key lifecycle --
/// Generate a new ed25519 keypair and store it.
pub async fn generate_key(
&self,
label: &str,
account_id: &NearAccountId,
permission: AccessKeyPermission,
network: NearNetwork,
) -> Result<KeyMetadata, KeyError> {
// Check for duplicates
let store = self.load_store().await?;
if store.keys.contains_key(label) {
return Err(KeyError::AlreadyExists {
label: label.to_string(),
});
}
// Generate keypair
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
// Build NEAR-format secret: ed25519:<base58(seed + pubkey)>
let mut combined = Vec::with_capacity(64);
combined.extend_from_slice(signing_key.as_bytes());
combined.extend_from_slice(verifying_key.as_bytes());
let secret_key = format!("ed25519:{}", bs58::encode(&combined).into_string());
combined.zeroize();
// signing_key drops here (Zeroize on drop)
let public_key = NearPublicKey {
key_type: KeyType::Ed25519,
data: verifying_key.to_bytes(),
};
// Store private key in secrets store
let secret_name = format!("near_key:{}", label);
self.secrets_store
.create(
&self.user_id,
CreateSecretParams::new(&secret_name, &secret_key).with_provider("near_keys"),
)
.await?;
// Build metadata
let metadata = KeyMetadata {
label: label.to_string(),
account_id: account_id.to_string(),
public_key: public_key.to_near_format(),
permission,
network,
created_at: Utc::now(),
cached_nonce: None,
};
// Save metadata
let mut store = self.load_store().await?;
store.keys.insert(label.to_string(), metadata.clone());
self.save_store(&store).await?;
Ok(metadata)
}
/// Import an existing key from a NEAR-format secret key string.
pub async fn import_key(
&self,
label: &str,
account_id: &NearAccountId,
secret_key: &str,
permission: AccessKeyPermission,
network: NearNetwork,
) -> Result<KeyMetadata, KeyError> {
// Check for duplicates
let store = self.load_store().await?;
if store.keys.contains_key(label) {
return Err(KeyError::AlreadyExists {
label: label.to_string(),
});
}
// Validate and derive public key
let public_key = public_key_from_secret(secret_key)?;
// Store private key in secrets store
let secret_name = format!("near_key:{}", label);
self.secrets_store
.create(
&self.user_id,
CreateSecretParams::new(&secret_name, secret_key).with_provider("near_keys"),
)
.await?;
// Build metadata
let metadata = KeyMetadata {
label: label.to_string(),
account_id: account_id.to_string(),
public_key: public_key.to_near_format(),
permission,
network,
created_at: Utc::now(),
cached_nonce: None,
};
// Save metadata
let mut store = self.load_store().await?;
store.keys.insert(label.to_string(), metadata.clone());
self.save_store(&store).await?;
Ok(metadata)
}
/// List all stored keys (metadata only).
pub async fn list_keys(&self) -> Result<Vec<KeyMetadata>, KeyError> {
let store = self.load_store().await?;
let mut keys: Vec<KeyMetadata> = store.keys.values().cloned().collect();
keys.sort_by(|a, b| a.label.cmp(&b.label));
Ok(keys)
}
/// Get metadata for a specific key.
pub async fn get_key(&self, label: &str) -> Result<KeyMetadata, KeyError> {
let store = self.load_store().await?;
store
.keys
.get(label)
.cloned()
.ok_or_else(|| KeyError::NotFound {
label: label.to_string(),
})
}
/// Remove a key (deletes from secrets store and metadata).
pub async fn remove_key(&self, label: &str) -> Result<(), KeyError> {
let mut store = self.load_store().await?;
if store.keys.remove(label).is_none() {
return Err(KeyError::NotFound {
label: label.to_string(),
});
}
// Delete from secrets store
let secret_name = format!("near_key:{}", label);
let _ = self.secrets_store.delete(&self.user_id, &secret_name).await;
self.save_store(&store).await?;
Ok(())
}
/// Export the public key (NEVER the private key).
pub async fn export_public_key(&self, label: &str) -> Result<NearPublicKey, KeyError> {
let metadata = self.get_key(label).await?;
NearPublicKey::from_near_format(&metadata.public_key)
}
// -- Transaction signing --
/// Sign a transaction with policy enforcement.
pub async fn sign_transaction(
&self,
label: &str,
receiver_id: &NearAccountId,
actions: Vec<transaction::Action>,
) -> Result<SignResult, KeyError> {
let metadata = self.get_key(label).await?;
// Analyze
let analysis = analyze_transaction(
receiver_id.as_str(),
&actions,
&metadata.permission,
&self.policy,
);
// Check spend
let daily_spend = self.spend_tracker.get_daily_spend().await?;
// Evaluate policy
let decision = self
.policy
.evaluate(&analysis, &metadata.permission, daily_spend);
match decision {
PolicyDecision::Deny { reason } => Err(KeyError::PolicyDenied { reason }),
PolicyDecision::RequireApproval { reasons } => {
Ok(SignResult::ApprovalRequired { analysis, reasons })
}
PolicyDecision::AutoApprove => {
let signed = self
.build_and_sign(label, &metadata, receiver_id, actions)
.await?;
// Record spend
if analysis.total_value_yocto > 0 {
let _ = self
.spend_tracker
.record_spend(analysis.total_value_yocto, analysis.summary.clone(), None)
.await;
}
Ok(SignResult::Signed {
transaction: signed,
analysis,
})
}
}
}
/// Request a chain signature via MPC.
pub async fn request_chain_signature(
&self,
label: &str,
payload: &[u8],
derivation_path: &str,
domain: SignatureDomain,
) -> Result<SignResult, KeyError> {
let metadata = self.get_key(label).await?;
// Build chain sig analysis
let chain_sig = ChainSigAnalysis {
derivation_path: derivation_path.to_string(),
domain,
target_chain: infer_target_chain(derivation_path),
payload_size: payload.len(),
risk_level: policy::RiskLevel::Medium,
};
let daily_spend = self.spend_tracker.get_daily_spend().await?;
let decision = self.policy.evaluate_chain_sig(&chain_sig, daily_spend);
// Build the function call action
let action =
chain_signatures::build_chain_signature_action(payload, derivation_path, domain)?;
let contract = chain_signatures::chain_sig_contract(&metadata.network);
let contract_id = NearAccountId::new(contract)?;
// Analyze the underlying transaction too
let analysis = analyze_transaction(
contract,
&[action.clone()],
&metadata.permission,
&self.policy,
);
match decision {
PolicyDecision::Deny { reason } => Err(KeyError::PolicyDenied { reason }),
PolicyDecision::RequireApproval { reasons } => {
Ok(SignResult::ApprovalRequired { analysis, reasons })
}
PolicyDecision::AutoApprove => {
let signed = self
.build_and_sign(label, &metadata, &contract_id, vec![action])
.await?;
Ok(SignResult::Signed {
transaction: signed,
analysis,
})
}
}
}
/// Build and sign a transaction (internal, after policy check passes).
async fn build_and_sign(
&self,
label: &str,
metadata: &KeyMetadata,
receiver_id: &NearAccountId,
actions: Vec<transaction::Action>,
) -> Result<SignedTransaction, KeyError> {
let public_key = NearPublicKey::from_near_format(&metadata.public_key)?;
// Get nonce and block hash from RPC
let rpc = NearRpcClient::new(&metadata.network);
let access_key = rpc
.view_access_key(&metadata.account_id, &metadata.public_key)
.await?;
let nonce = access_key.nonce + 1;
let block_hash = BlockHash::from_base58(&access_key.block_hash)?;
let signer_id = NearAccountId::new(&metadata.account_id)?;
let tx = Transaction {
signer_id,
public_key,
nonce,
receiver_id: receiver_id.clone(),
block_hash,
actions,
};
// Hash and sign
let hash = tx.hash_for_signing()?;
let sig_bytes = sign_hash(self.secrets_store.as_ref(), &self.user_id, label, &hash).await?;
Ok(SignedTransaction {
transaction: tx,
signature: Signature {
key_type: KeyType::Ed25519,
data: sig_bytes,
},
})
}
// -- Backup / Restore --
/// Create an encrypted backup of all keys.
pub async fn create_backup(&self, passphrase: &str) -> Result<Vec<u8>, KeyError> {
let store = self.load_store().await?;
let mut entries = Vec::new();
for (label, metadata) in &store.keys {
let secret_name = format!("near_key:{}", label);
let decrypted = self
.secrets_store
.get_decrypted(&self.user_id, &secret_name)
.await
.map_err(|e| KeyError::BackupError {
reason: format!("failed to decrypt key '{}': {}", label, e),
})?;
entries.push(KeyBackupEntry {
label: label.clone(),
account_id: metadata.account_id.clone(),
secret_key_near_format: decrypted.expose().to_string(),
permission: metadata.permission.clone(),
network: metadata.network.clone(),
});
}
let backup = KeyBackup {
version: 1,
created_at: Utc::now(),
keys: entries,
};
let plaintext = serde_json::to_vec(&backup).map_err(|e| KeyError::BackupError {
reason: format!("failed to serialize backup: {}", e),
})?;
encrypt_backup(passphrase, &plaintext)
}
/// Restore keys from an encrypted backup.
pub async fn restore_backup(
&self,
backup_data: &[u8],
passphrase: &str,
) -> Result<Vec<String>, KeyError> {
let plaintext = decrypt_backup(passphrase, backup_data)?;
let backup: KeyBackup =
serde_json::from_slice(&plaintext).map_err(|e| KeyError::BackupError {
reason: format!("failed to parse backup: {}", e),
})?;
let mut restored = Vec::new();
for entry in backup.keys {
// Validate the key
let _ = public_key_from_secret(&entry.secret_key_near_format)?;
let account_id = NearAccountId::new(&entry.account_id)?;
// Import (skip if already exists)
match self
.import_key(
&entry.label,
&account_id,
&entry.secret_key_near_format,
entry.permission,
entry.network,
)
.await
{
Ok(_) => restored.push(entry.label),
Err(KeyError::AlreadyExists { .. }) => {
// Skip existing keys
}
Err(e) => return Err(e),
}
}
// Update backup timestamp
let mut store = self.load_store().await?;
store.last_backup_at = Some(Utc::now());
self.save_store(&store).await?;
Ok(restored)
}
// -- Internal helpers --
async fn load_store(&self) -> Result<KeyStore, KeyError> {
if !self.metadata_path.exists() {
return Ok(KeyStore::default());
}
let content = fs::read_to_string(&self.metadata_path).await?;
serde_json::from_str(&content).map_err(|e| {
KeyError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("corrupt keys.json: {}", e),
))
})
}
async fn save_store(&self, store: &KeyStore) -> Result<(), KeyError> {
if let Some(parent) = self.metadata_path.parent() {
fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(store).map_err(|e| {
KeyError::SerializationFailed(format!("failed to serialize key store: {}", e))
})?;
fs::write(&self.metadata_path, content).await?;
Ok(())
}
}
/// Default path for keys metadata.
fn default_keys_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".ironclaw").join("keys.json"))
.unwrap_or_else(|| PathBuf::from(".ironclaw/keys.json"))
}
// -- Backup encryption --
/// Backup file magic bytes.
const BACKUP_MAGIC: &[u8; 4] = b"ICLK";
const BACKUP_VERSION: u32 = 1;
const ARGON2_SALT_LEN: usize = 32;
const AES_NONCE_LEN: usize = 12;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct KeyBackup {
version: u32,
created_at: chrono::DateTime<Utc>,
keys: Vec<KeyBackupEntry>,
}
#[derive(Serialize, Deserialize)]
struct KeyBackupEntry {
label: String,
account_id: String,
secret_key_near_format: String,
permission: AccessKeyPermission,
network: NearNetwork,
}
fn encrypt_backup(passphrase: &str, plaintext: &[u8]) -> Result<Vec<u8>, KeyError> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use argon2::Argon2;
// Generate salt
let mut salt = [0u8; ARGON2_SALT_LEN];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
// Derive key with Argon2id
let mut derived_key = [0u8; 32];
Argon2::default()
.hash_password_into(passphrase.as_bytes(), &salt, &mut derived_key)
.map_err(|e| KeyError::BackupError {
reason: format!("Argon2 key derivation failed: {}", e),
})?;
// Encrypt with AES-256-GCM
let cipher = Aes256Gcm::new_from_slice(&derived_key).map_err(|e| KeyError::BackupError {
reason: format!("failed to create cipher: {}", e),
})?;
let mut nonce_bytes = [0u8; AES_NONCE_LEN];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| KeyError::BackupError {
reason: format!("encryption failed: {}", e),
})?;
// Assemble: magic + version + salt + nonce + ciphertext
let mut output = Vec::new();
output.extend_from_slice(BACKUP_MAGIC);
output.extend_from_slice(&BACKUP_VERSION.to_le_bytes());
output.extend_from_slice(&salt);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
derived_key.zeroize();
Ok(output)
}
pub(crate) fn decrypt_backup(passphrase: &str, data: &[u8]) -> Result<Vec<u8>, KeyError> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use argon2::Argon2;
let header_len = 4 + 4 + ARGON2_SALT_LEN + AES_NONCE_LEN;
if data.len() < header_len {
return Err(KeyError::BackupError {
reason: "backup file too short".to_string(),
});
}
// Check magic
if &data[..4] != BACKUP_MAGIC {
return Err(KeyError::BackupError {
reason: "not a valid IronClaw backup file".to_string(),
});
}
// Check version
let version = u32::from_le_bytes(data[4..8].try_into().unwrap());
if version != BACKUP_VERSION {
return Err(KeyError::BackupError {
reason: format!("unsupported backup version: {}", version),
});
}
let salt = &data[8..8 + ARGON2_SALT_LEN];
let nonce_bytes = &data[8 + ARGON2_SALT_LEN..header_len];
let ciphertext = &data[header_len..];
// Derive key
let mut derived_key = [0u8; 32];
Argon2::default()
.hash_password_into(passphrase.as_bytes(), salt, &mut derived_key)
.map_err(|e| KeyError::BackupError {
reason: format!("Argon2 key derivation failed: {}", e),
})?;
// Decrypt
let cipher = Aes256Gcm::new_from_slice(&derived_key).map_err(|e| KeyError::BackupError {
reason: format!("failed to create cipher: {}", e),
})?;
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| KeyError::BackupError {
reason: "decryption failed (wrong passphrase?)".to_string(),
})?;
derived_key.zeroize();
Ok(plaintext)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use secrecy::SecretString;
use tempfile::TempDir;
use crate::keys::spending::SpendTracker;
use crate::keys::transaction::{Action, ONE_NEAR, Transfer};
use crate::keys::types::{AccessKeyPermission, NearAccountId, NearNetwork};
use crate::keys::{KeyManager, SignResult};
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
fn test_manager(dir: &TempDir) -> KeyManager {
let key = "0123456789abcdef0123456789abcdef";
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
let store: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(InMemorySecretsStore::new(crypto));
KeyManager::new(store, "test_user".to_string())
.with_metadata_path(dir.path().join("keys.json"))
.with_spend_tracker(SpendTracker::new(dir.path().join("spend.json")))
}
#[tokio::test]
async fn test_generate_key() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
let metadata = manager
.generate_key(
"test-key",
&account,
AccessKeyPermission::FunctionCall {
allowance: None,
receiver_id: "intents.near".to_string(),
method_names: vec![],
},
NearNetwork::Testnet,
)
.await
.unwrap();
assert_eq!(metadata.label, "test-key");
assert_eq!(metadata.account_id, "alice.testnet");
assert!(metadata.public_key.starts_with("ed25519:"));
}
#[tokio::test]
async fn test_generate_duplicate_key_fails() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"dup",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let result = manager
.generate_key(
"dup",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await;
assert!(matches!(
result,
Err(crate::keys::KeyError::AlreadyExists { .. })
));
}
#[tokio::test]
async fn test_list_keys() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
assert_eq!(manager.list_keys().await.unwrap().len(), 0);
manager
.generate_key(
"key-1",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
manager
.generate_key(
"key-2",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let keys = manager.list_keys().await.unwrap();
assert_eq!(keys.len(), 2);
}
#[tokio::test]
async fn test_remove_key() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"to-remove",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
manager.remove_key("to-remove").await.unwrap();
assert!(manager.get_key("to-remove").await.is_err());
}
#[tokio::test]
async fn test_export_public_key() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
let metadata = manager
.generate_key(
"export-test",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let pubkey = manager.export_public_key("export-test").await.unwrap();
assert_eq!(pubkey.to_near_format(), metadata.public_key);
}
#[tokio::test]
async fn test_import_key() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("bob.testnet").unwrap();
// Generate a test secret key
let signing_key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
let mut combined = Vec::with_capacity(64);
combined.extend_from_slice(signing_key.as_bytes());
combined.extend_from_slice(verifying_key.as_bytes());
let secret = format!("ed25519:{}", bs58::encode(&combined).into_string());
let metadata = manager
.import_key(
"imported",
&account,
&secret,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
assert_eq!(metadata.label, "imported");
assert!(metadata.public_key.starts_with("ed25519:"));
}
#[tokio::test]
async fn test_backup_and_restore_roundtrip() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
// Generate a key
manager
.generate_key(
"backup-test",
&account,
AccessKeyPermission::FunctionCall {
allowance: None,
receiver_id: "contract.near".to_string(),
method_names: vec!["deposit".to_string()],
},
NearNetwork::Testnet,
)
.await
.unwrap();
// Create backup
let backup_data = manager.create_backup("test-passphrase").await.unwrap();
assert!(!backup_data.is_empty());
// Restore into a fresh manager
let dir2 = TempDir::new().unwrap();
let manager2 = test_manager(&dir2);
let restored = manager2
.restore_backup(&backup_data, "test-passphrase")
.await
.unwrap();
assert_eq!(restored, vec!["backup-test"]);
// Verify the restored key
let keys = manager2.list_keys().await.unwrap();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].label, "backup-test");
}
#[tokio::test]
async fn test_backup_wrong_passphrase() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"test",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let backup_data = manager.create_backup("correct").await.unwrap();
let dir2 = TempDir::new().unwrap();
let manager2 = test_manager(&dir2);
let result = manager2.restore_backup(&backup_data, "wrong").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_sign_transaction_policy_deny() {
let dir = TempDir::new().unwrap();
let key = "0123456789abcdef0123456789abcdef";
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
let store: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(InMemorySecretsStore::new(crypto));
let mut manager = KeyManager::new(store, "test_user".to_string())
.with_metadata_path(dir.path().join("keys.json"))
.with_spend_tracker(SpendTracker::new(dir.path().join("spend.json")));
// Deny full access operations
manager.policy_mut().deny_full_access_operations = true;
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"denied",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let receiver = NearAccountId::new("bob.testnet").unwrap();
let result = manager
.sign_transaction(
"denied",
&receiver,
vec![Action::Transfer(Transfer { deposit: 0 })],
)
.await;
assert!(matches!(
result,
Err(crate::keys::KeyError::PolicyDenied { .. })
));
}
#[tokio::test]
async fn test_sign_transaction_requires_approval() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"signer",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let receiver = NearAccountId::new("unknown.testnet").unwrap();
let result = manager
.sign_transaction(
"signer",
&receiver,
vec![Action::Transfer(Transfer {
deposit: 100 * ONE_NEAR,
})],
)
.await
.unwrap();
// Default policy requires approval for any transfer
assert!(matches!(result, SignResult::ApprovalRequired { .. }));
}
}
+917
View File
@@ -0,0 +1,917 @@
//! Transaction analysis and policy engine for NEAR key operations.
//!
//! Every transaction is decomposed into a `TransactionAnalysis` before any
//! signing happens. The policy engine then evaluates the analysis against
//! a configurable ruleset. Most restrictive rule always wins.
//!
//! # Pipeline
//!
//! ```text
//! Transaction -> analyze_transaction() -> TransactionAnalysis
//! |
//! PolicyConfig.evaluate() <-------+
//! |
//! PolicyDecision { AutoApprove | RequireApproval | Deny }
//! ```
use serde::{Deserialize, Serialize};
use crate::keys::transaction::{Action, ONE_NEAR};
use crate::keys::types::{AccessKeyPermission, format_yocto};
/// Risk level for a single action within a transaction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl std::fmt::Display for RiskLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RiskLevel::Low => write!(f, "LOW"),
RiskLevel::Medium => write!(f, "MEDIUM"),
RiskLevel::High => write!(f, "HIGH"),
RiskLevel::Critical => write!(f, "CRITICAL"),
}
}
}
/// Category of a transaction action for policy evaluation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActionCategory {
Transfer,
FunctionCall,
Stake,
AddKey { is_full_access: bool },
DeleteKey,
DeployContract,
CreateAccount,
DeleteAccount,
}
/// Analysis of a single action within a transaction.
#[derive(Debug, Clone)]
pub struct ActionAnalysis {
pub category: ActionCategory,
pub value_yocto: u128,
pub receiver: String,
pub method: Option<String>,
pub description: String,
pub risk_level: RiskLevel,
}
/// Complete analysis of a transaction.
#[derive(Debug, Clone)]
pub struct TransactionAnalysis {
pub actions: Vec<ActionAnalysis>,
pub total_value_yocto: u128,
pub receivers: Vec<String>,
pub uses_full_access_key: bool,
pub summary: String,
}
/// Analyze a transaction's actions for policy evaluation.
pub fn analyze_transaction(
receiver_id: &str,
actions: &[Action],
key_permission: &AccessKeyPermission,
policy: &PolicyConfig,
) -> TransactionAnalysis {
let uses_full_access_key = matches!(key_permission, AccessKeyPermission::FullAccess);
let mut action_analyses = Vec::new();
let mut total_value = 0u128;
for action in actions {
let analysis = analyze_action(action, receiver_id, policy);
total_value = total_value.saturating_add(analysis.value_yocto);
action_analyses.push(analysis);
}
let receivers: Vec<String> = action_analyses
.iter()
.map(|a| a.receiver.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let summary = build_summary(&action_analyses, total_value);
TransactionAnalysis {
actions: action_analyses,
total_value_yocto: total_value,
receivers,
uses_full_access_key,
summary,
}
}
fn analyze_action(action: &Action, receiver_id: &str, policy: &PolicyConfig) -> ActionAnalysis {
match action {
Action::Transfer(t) => {
let is_whitelisted = policy.transfer_whitelist.contains(&receiver_id.to_string());
let risk = if t.deposit == 0 || (t.deposit < ONE_NEAR && is_whitelisted) {
RiskLevel::Low
} else if t.deposit < policy.transfer_whitelist_max_yocto && is_whitelisted {
RiskLevel::Medium
} else {
RiskLevel::High
};
ActionAnalysis {
category: ActionCategory::Transfer,
value_yocto: t.deposit,
receiver: receiver_id.to_string(),
method: None,
description: format!("Transfer {} to {}", format_yocto(t.deposit), receiver_id),
risk_level: risk,
}
}
Action::FunctionCall(fc) => {
let has_matching_rule = policy
.function_call_rules
.iter()
.any(|r| r.receiver_id == receiver_id && fc.deposit <= r.max_deposit_yocto);
let risk = if fc.deposit == 0 && has_matching_rule {
RiskLevel::Low
} else if fc.deposit == 0 || has_matching_rule {
RiskLevel::Medium
} else {
RiskLevel::High
};
ActionAnalysis {
category: ActionCategory::FunctionCall,
value_yocto: fc.deposit,
receiver: receiver_id.to_string(),
method: Some(fc.method_name.clone()),
description: format!(
"FunctionCall {}::{}{}",
receiver_id,
fc.method_name,
if fc.deposit > 0 {
format!(" ({})", format_yocto(fc.deposit))
} else {
String::new()
}
),
risk_level: risk,
}
}
Action::Stake(s) => {
let risk = if policy
.stake_validator_whitelist
.contains(&receiver_id.to_string())
&& s.stake <= policy.stake_auto_approve_max_yocto
{
RiskLevel::Medium
} else {
RiskLevel::High
};
ActionAnalysis {
category: ActionCategory::Stake,
value_yocto: s.stake,
receiver: receiver_id.to_string(),
method: None,
description: format!("Stake {} with {}", format_yocto(s.stake), receiver_id),
risk_level: risk,
}
}
Action::AddKey(ak) => {
let is_full_access = borsh_permission_is_full_access(&ak.access_key.permission);
ActionAnalysis {
category: ActionCategory::AddKey { is_full_access },
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: if is_full_access {
format!("AddKey (FullAccess) to {}", receiver_id)
} else {
format!("AddKey (FunctionCall) to {}", receiver_id)
},
risk_level: if is_full_access {
RiskLevel::Critical
} else {
RiskLevel::High
},
}
}
Action::DeleteKey(_) => ActionAnalysis {
category: ActionCategory::DeleteKey,
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: format!("DeleteKey on {}", receiver_id),
risk_level: RiskLevel::High,
},
Action::DeployContract(_) => ActionAnalysis {
category: ActionCategory::DeployContract,
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: format!("DeployContract to {}", receiver_id),
risk_level: RiskLevel::Critical,
},
Action::CreateAccount => ActionAnalysis {
category: ActionCategory::CreateAccount,
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: format!("CreateAccount {}", receiver_id),
risk_level: RiskLevel::Medium,
},
Action::DeleteAccount(_) => ActionAnalysis {
category: ActionCategory::DeleteAccount,
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: format!("DeleteAccount {}", receiver_id),
risk_level: RiskLevel::Critical,
},
}
}
fn borsh_permission_is_full_access(
perm: &crate::keys::transaction::AccessKeyPermissionBorsh,
) -> bool {
matches!(
perm,
crate::keys::transaction::AccessKeyPermissionBorsh::FullAccess
)
}
fn build_summary(actions: &[ActionAnalysis], total_value: u128) -> String {
let mut lines = Vec::new();
for (i, a) in actions.iter().enumerate() {
lines.push(format!(" {}. {} [{}]", i + 1, a.description, a.risk_level));
}
if total_value > 0 {
lines.push(format!(" Total value: {}", format_yocto(total_value)));
}
lines.join("\n")
}
/// Policy decision after evaluating a transaction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyDecision {
/// Transaction can proceed without user interaction.
AutoApprove,
/// User must approve before signing.
RequireApproval { reasons: Vec<String> },
/// Transaction is denied by policy (not even user can override).
Deny { reason: String },
}
/// Configurable policy rules for transaction approval.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyConfig {
// Transfer rules
pub transfer_auto_approve_max_yocto: u128,
pub transfer_whitelist_max_yocto: u128,
pub transfer_whitelist: Vec<String>,
// Function call rules
pub function_call_rules: Vec<FunctionCallRule>,
// Staking rules
pub stake_validator_whitelist: Vec<String>,
pub stake_auto_approve_max_yocto: u128,
// Key management rules
pub allow_add_scoped_keys_to: Vec<String>,
// Chain signature rules
pub chain_sig_rules: Vec<ChainSigRule>,
// Global limits
pub daily_spend_limit_yocto: Option<u128>,
pub per_tx_auto_approve_max_yocto: u128,
// Blanket denials
pub deny_full_access_operations: bool,
pub deny_delete_account: bool,
}
impl Default for PolicyConfig {
fn default() -> Self {
Self {
transfer_auto_approve_max_yocto: 0,
transfer_whitelist_max_yocto: ONE_NEAR,
transfer_whitelist: Vec::new(),
function_call_rules: Vec::new(),
stake_validator_whitelist: Vec::new(),
stake_auto_approve_max_yocto: 0,
allow_add_scoped_keys_to: Vec::new(),
chain_sig_rules: Vec::new(),
daily_spend_limit_yocto: None,
per_tx_auto_approve_max_yocto: 0,
deny_full_access_operations: false,
deny_delete_account: true,
}
}
}
/// A function call rule for policy evaluation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallRule {
pub receiver_id: String,
/// Empty = all methods on this contract.
pub allowed_methods: Vec<String>,
pub max_deposit_yocto: u128,
pub max_gas: Option<u64>,
pub auto_approve: bool,
}
/// Signature domain for chain signatures.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SignatureDomain {
Secp256k1 = 0,
Ed25519 = 1,
}
/// A chain signature rule for policy evaluation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainSigRule {
pub allowed_paths: Vec<String>,
pub allowed_domains: Vec<SignatureDomain>,
pub max_payload_bytes: usize,
pub auto_approve: bool,
}
/// Analysis specific to chain signature requests.
#[derive(Debug, Clone)]
pub struct ChainSigAnalysis {
pub derivation_path: String,
pub domain: SignatureDomain,
pub target_chain: Option<String>,
pub payload_size: usize,
pub risk_level: RiskLevel,
}
impl PolicyConfig {
/// Evaluate a transaction analysis against this policy.
///
/// Returns the most restrictive decision across all actions.
pub fn evaluate(
&self,
analysis: &TransactionAnalysis,
key_permission: &AccessKeyPermission,
daily_spend: u128,
) -> PolicyDecision {
let mut reasons = Vec::new();
// Blanket denials first
if self.deny_full_access_operations && analysis.uses_full_access_key {
return PolicyDecision::Deny {
reason: "full-access key operations are denied by policy".to_string(),
};
}
for action in &analysis.actions {
if self.deny_delete_account && matches!(action.category, ActionCategory::DeleteAccount)
{
return PolicyDecision::Deny {
reason: "account deletion is denied by policy".to_string(),
};
}
}
// Daily spend limit
if let Some(limit) = self.daily_spend_limit_yocto {
if daily_spend.saturating_add(analysis.total_value_yocto) > limit {
reasons.push(format!(
"daily spend limit exceeded: {} + {} > {}",
format_yocto(daily_spend),
format_yocto(analysis.total_value_yocto),
format_yocto(limit)
));
}
}
// Per-transaction limit
if analysis.total_value_yocto > self.per_tx_auto_approve_max_yocto
&& self.per_tx_auto_approve_max_yocto > 0
{
reasons.push(format!(
"transaction value {} exceeds per-tx auto-approve limit {}",
format_yocto(analysis.total_value_yocto),
format_yocto(self.per_tx_auto_approve_max_yocto)
));
}
// Per-action evaluation
for action in &analysis.actions {
if let Some(reason) = self.evaluate_action(action, key_permission) {
reasons.push(reason);
}
}
if reasons.is_empty() {
PolicyDecision::AutoApprove
} else {
PolicyDecision::RequireApproval { reasons }
}
}
/// Evaluate a chain signature request.
pub fn evaluate_chain_sig(
&self,
chain_sig: &ChainSigAnalysis,
daily_spend: u128,
) -> PolicyDecision {
let mut reasons = Vec::new();
// Check daily limit (chain sigs don't have a value, but check anyway)
if let Some(limit) = self.daily_spend_limit_yocto {
if daily_spend > limit {
reasons.push("daily spend limit exceeded".to_string());
}
}
// Find matching chain sig rule
let matching_rule = self.chain_sig_rules.iter().find(|rule| {
rule.allowed_domains.contains(&chain_sig.domain)
&& chain_sig.payload_size <= rule.max_payload_bytes
&& rule
.allowed_paths
.iter()
.any(|pattern| glob_matches(pattern, &chain_sig.derivation_path))
});
match matching_rule {
Some(rule) if rule.auto_approve => PolicyDecision::AutoApprove,
Some(_) => {
reasons.push(format!(
"chain signature for path '{}' requires approval",
chain_sig.derivation_path
));
PolicyDecision::RequireApproval { reasons }
}
None => {
reasons.push(format!(
"no matching chain signature rule for path '{}'",
chain_sig.derivation_path
));
PolicyDecision::RequireApproval { reasons }
}
}
}
fn evaluate_action(
&self,
action: &ActionAnalysis,
key_permission: &AccessKeyPermission,
) -> Option<String> {
match &action.category {
ActionCategory::Transfer => {
// Auto-approve to whitelisted accounts under threshold
if self.transfer_whitelist.contains(&action.receiver)
&& action.value_yocto <= self.transfer_whitelist_max_yocto
{
return None;
}
// Auto-approve small transfers to anyone
if action.value_yocto <= self.transfer_auto_approve_max_yocto {
return None;
}
Some(format!(
"transfer {} to {} exceeds auto-approve threshold",
format_yocto(action.value_yocto),
action.receiver
))
}
ActionCategory::FunctionCall => {
// Check if key is already scoped to this receiver with zero deposit
if let AccessKeyPermission::FunctionCall {
receiver_id,
method_names,
..
} = key_permission
{
if receiver_id == &action.receiver
&& action.value_yocto == 0
&& (method_names.is_empty()
|| action
.method
.as_ref()
.map(|m| method_names.contains(m))
.unwrap_or(false))
{
return None;
}
}
// Check function call rules
if let Some(method) = &action.method {
for rule in &self.function_call_rules {
if rule.receiver_id == action.receiver
&& (rule.allowed_methods.is_empty()
|| rule.allowed_methods.contains(method))
&& action.value_yocto <= rule.max_deposit_yocto
&& rule.auto_approve
{
return None;
}
}
}
Some(format!(
"function call {} requires approval",
action.description
))
}
ActionCategory::Stake => {
if self.stake_validator_whitelist.contains(&action.receiver)
&& action.value_yocto <= self.stake_auto_approve_max_yocto
{
return None;
}
Some(format!("stake {} requires approval", action.description))
}
ActionCategory::AddKey { is_full_access } => {
if *is_full_access {
Some("adding full-access key requires approval".to_string())
} else {
Some("adding function-call key requires approval".to_string())
}
}
ActionCategory::DeleteKey
| ActionCategory::DeployContract
| ActionCategory::CreateAccount
| ActionCategory::DeleteAccount => {
Some(format!("{} requires approval", action.description))
}
}
}
}
/// Simple glob matching: supports `*` as wildcard for any suffix.
fn glob_matches(pattern: &str, value: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
value.starts_with(prefix)
} else {
pattern == value
}
}
/// Infer target chain from a derivation path.
pub fn infer_target_chain(derivation_path: &str) -> Option<String> {
let lower = derivation_path.to_lowercase();
if lower.starts_with("ethereum") || lower.starts_with("eth") {
Some("Ethereum".to_string())
} else if lower.starts_with("bitcoin") || lower.starts_with("btc") {
Some("Bitcoin".to_string())
} else if lower.starts_with("near") {
Some("NEAR".to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use crate::keys::policy::{
ChainSigAnalysis, ChainSigRule, FunctionCallRule, PolicyConfig, PolicyDecision, RiskLevel,
SignatureDomain, analyze_transaction, glob_matches, infer_target_chain,
};
use crate::keys::transaction::{Action, FunctionCall, ONE_NEAR, TGAS, Transfer};
use crate::keys::types::AccessKeyPermission;
fn default_policy() -> PolicyConfig {
PolicyConfig::default()
}
fn permissive_policy() -> PolicyConfig {
PolicyConfig {
transfer_auto_approve_max_yocto: ONE_NEAR,
transfer_whitelist_max_yocto: 10 * ONE_NEAR,
transfer_whitelist: vec!["bob.near".to_string()],
function_call_rules: vec![FunctionCallRule {
receiver_id: "intents.near".to_string(),
allowed_methods: vec!["execute_intents".to_string()],
max_deposit_yocto: 0,
max_gas: None,
auto_approve: true,
}],
per_tx_auto_approve_max_yocto: 5 * ONE_NEAR,
daily_spend_limit_yocto: Some(50 * ONE_NEAR),
..default_policy()
}
}
// -- Transfer tests --
#[test]
fn test_transfer_below_auto_approve() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: ONE_NEAR / 2,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("someone.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_transfer_above_threshold_requires_approval() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: 2 * ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("unknown.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
#[test]
fn test_transfer_to_whitelisted_account() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: 5 * ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_transfer_to_whitelisted_above_whitelist_limit() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: 15 * ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
// 15 NEAR > whitelist max (10 NEAR), and > per_tx limit (5 NEAR)
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Function call tests --
#[test]
fn test_function_call_matching_rule_auto_approve() {
let policy = permissive_policy();
let actions = vec![Action::FunctionCall(FunctionCall {
method_name: "execute_intents".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: 0,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("intents.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_function_call_scoped_key_auto_approve() {
let policy = default_policy();
let actions = vec![Action::FunctionCall(FunctionCall {
method_name: "deposit".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: 0,
})];
let perm = AccessKeyPermission::FunctionCall {
allowance: None,
receiver_id: "contract.near".to_string(),
method_names: vec!["deposit".to_string()],
};
let analysis = analyze_transaction("contract.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_function_call_no_rule_requires_approval() {
let policy = default_policy();
let actions = vec![Action::FunctionCall(FunctionCall {
method_name: "dangerous_method".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("unknown.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Blanket denial tests --
#[test]
fn test_deny_full_access_operations() {
let policy = PolicyConfig {
deny_full_access_operations: true,
..default_policy()
};
let actions = vec![Action::Transfer(Transfer { deposit: 0 })];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert!(matches!(decision, PolicyDecision::Deny { .. }));
}
#[test]
fn test_deny_delete_account() {
let policy = PolicyConfig {
deny_delete_account: true,
..default_policy()
};
let actions = vec![Action::DeleteAccount(
crate::keys::transaction::DeleteAccount {
beneficiary_id: crate::keys::types::NearAccountId::new("bob.near").unwrap(),
},
)];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("alice.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert!(matches!(decision, PolicyDecision::Deny { .. }));
}
// -- Daily spend limit tests --
#[test]
fn test_daily_spend_limit_under() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: ONE_NEAR / 2,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 10 * ONE_NEAR);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_daily_spend_limit_exceeded() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: ONE_NEAR / 2,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
// Current daily spend is 50 NEAR (at limit), adding 0.5 NEAR puts us over
let decision = policy.evaluate(&analysis, &perm, 50 * ONE_NEAR);
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Per-transaction limit tests --
#[test]
fn test_per_tx_limit() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: 6 * ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
// 6 NEAR > per_tx_auto_approve_max (5 NEAR)
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Most restrictive wins --
#[test]
fn test_mixed_actions_most_restrictive_wins() {
let policy = permissive_policy();
// One auto-approvable + one that requires approval
let actions = vec![
Action::FunctionCall(FunctionCall {
method_name: "execute_intents".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: 0,
}),
Action::Transfer(Transfer {
deposit: 100 * ONE_NEAR,
}),
];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("intents.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
// Transfer is too large, so the whole tx requires approval
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Transaction analysis tests --
#[test]
fn test_analysis_total_value() {
let policy = default_policy();
let actions = vec![
Action::Transfer(Transfer {
deposit: 2 * ONE_NEAR,
}),
Action::FunctionCall(FunctionCall {
method_name: "deposit".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: ONE_NEAR,
}),
];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
assert_eq!(analysis.total_value_yocto, 3 * ONE_NEAR);
assert_eq!(analysis.actions.len(), 2);
}
#[test]
fn test_analysis_risk_levels() {
let policy = default_policy();
let actions = vec![
Action::Transfer(Transfer { deposit: 0 }),
Action::DeleteAccount(crate::keys::transaction::DeleteAccount {
beneficiary_id: crate::keys::types::NearAccountId::new("bob.near").unwrap(),
}),
];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("alice.near", &actions, &perm, &policy);
assert_eq!(analysis.actions[0].risk_level, RiskLevel::Low);
assert_eq!(analysis.actions[1].risk_level, RiskLevel::Critical);
}
// -- Chain signature tests --
#[test]
fn test_chain_sig_no_rule_requires_approval() {
let policy = default_policy();
let chain_sig = ChainSigAnalysis {
derivation_path: "ethereum-1".to_string(),
domain: SignatureDomain::Secp256k1,
target_chain: Some("Ethereum".to_string()),
payload_size: 256,
risk_level: RiskLevel::Medium,
};
let decision = policy.evaluate_chain_sig(&chain_sig, 0);
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
#[test]
fn test_chain_sig_matching_rule_auto_approve() {
let policy = PolicyConfig {
chain_sig_rules: vec![ChainSigRule {
allowed_paths: vec!["ethereum-*".to_string()],
allowed_domains: vec![SignatureDomain::Secp256k1],
max_payload_bytes: 1024,
auto_approve: true,
}],
..default_policy()
};
let chain_sig = ChainSigAnalysis {
derivation_path: "ethereum-1".to_string(),
domain: SignatureDomain::Secp256k1,
target_chain: Some("Ethereum".to_string()),
payload_size: 256,
risk_level: RiskLevel::Medium,
};
let decision = policy.evaluate_chain_sig(&chain_sig, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
// -- Glob matching tests --
#[test]
fn test_glob_matches() {
assert!(glob_matches("ethereum-*", "ethereum-1"));
assert!(glob_matches("ethereum-*", "ethereum-mainnet"));
assert!(!glob_matches("ethereum-*", "bitcoin-0"));
assert!(glob_matches("exact-match", "exact-match"));
assert!(!glob_matches("exact-match", "other"));
}
// -- Infer target chain --
#[test]
fn test_infer_target_chain() {
assert_eq!(
infer_target_chain("ethereum-1"),
Some("Ethereum".to_string())
);
assert_eq!(
infer_target_chain("bitcoin/0/0"),
Some("Bitcoin".to_string())
);
assert_eq!(infer_target_chain("unknown-path"), None);
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Lightweight NEAR JSON-RPC client.
//!
//! Thin reqwest wrapper for the subset of NEAR RPC we need:
//! - view_access_key (nonce + block_hash for transaction building)
//! - send_transaction (submit signed transaction)
//! - tx_status (poll for result)
//! - view_account (check balance)
use serde::{Deserialize, Serialize};
use crate::keys::KeyError;
use crate::keys::types::NearNetwork;
/// NEAR RPC client.
#[derive(Debug, Clone)]
pub struct NearRpcClient {
client: reqwest::Client,
rpc_url: String,
}
impl NearRpcClient {
pub fn new(network: &NearNetwork) -> Self {
Self {
client: reqwest::Client::new(),
rpc_url: network.rpc_url().to_string(),
}
}
pub fn with_url(url: &str) -> Self {
Self {
client: reqwest::Client::new(),
rpc_url: url.to_string(),
}
}
/// Fetch access key info (nonce + block hash) for signing a transaction.
pub async fn view_access_key(
&self,
account_id: &str,
public_key: &str,
) -> Result<AccessKeyView, KeyError> {
let response: RpcResponse<AccessKeyView> = self
.call(
"query",
serde_json::json!({
"request_type": "view_access_key",
"finality": "final",
"account_id": account_id,
"public_key": public_key,
}),
)
.await?;
Ok(response.result)
}
/// Submit a signed transaction (fire and forget, returns tx hash).
pub async fn send_transaction_async(&self, signed_tx_base64: &str) -> Result<String, KeyError> {
let response: RpcResponse<serde_json::Value> = self
.call("broadcast_tx_async", serde_json::json!([signed_tx_base64]))
.await?;
response
.result
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| KeyError::RpcError {
reason: "unexpected response from broadcast_tx_async".to_string(),
})
}
/// Submit a signed transaction and wait for result.
pub async fn send_transaction(&self, signed_tx_base64: &str) -> Result<TxOutcome, KeyError> {
let response: RpcResponse<TxOutcome> = self
.call("broadcast_tx_commit", serde_json::json!([signed_tx_base64]))
.await?;
Ok(response.result)
}
/// Check transaction status.
pub async fn tx_status(&self, tx_hash: &str, sender_id: &str) -> Result<TxOutcome, KeyError> {
let response: RpcResponse<TxOutcome> = self
.call("tx", serde_json::json!([tx_hash, sender_id]))
.await?;
Ok(response.result)
}
/// View account information.
pub async fn view_account(&self, account_id: &str) -> Result<AccountView, KeyError> {
let response: RpcResponse<AccountView> = self
.call(
"query",
serde_json::json!({
"request_type": "view_account",
"finality": "final",
"account_id": account_id,
}),
)
.await?;
Ok(response.result)
}
/// Make a JSON-RPC 2.0 call.
async fn call<T: for<'de> Deserialize<'de>>(
&self,
method: &str,
params: serde_json::Value,
) -> Result<RpcResponse<T>, KeyError> {
let request = RpcRequest {
jsonrpc: "2.0",
id: "ironclaw",
method,
params,
};
let response = self
.client
.post(&self.rpc_url)
.json(&request)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(KeyError::RpcError {
reason: format!("HTTP {}: {}", status, truncate(&body, 200)),
});
}
let body = response.text().await?;
let parsed: serde_json::Value =
serde_json::from_str(&body).map_err(|e| KeyError::RpcError {
reason: format!("invalid JSON response: {}", e),
})?;
// Check for JSON-RPC error
if let Some(error) = parsed.get("error") {
let cause = error
.get("cause")
.and_then(|c| c.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("unknown");
let message = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown error");
return Err(KeyError::RpcError {
reason: format!("{}: {}", cause, message),
});
}
serde_json::from_value(parsed).map_err(|e| KeyError::RpcError {
reason: format!("failed to parse RPC response: {}", e),
})
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max])
}
}
/// JSON-RPC 2.0 request.
#[derive(Serialize)]
struct RpcRequest<'a> {
jsonrpc: &'a str,
id: &'a str,
method: &'a str,
params: serde_json::Value,
}
/// JSON-RPC 2.0 response.
#[derive(Deserialize)]
struct RpcResponse<T> {
result: T,
}
/// Access key view from RPC.
#[derive(Debug, Clone, Deserialize)]
pub struct AccessKeyView {
pub nonce: u64,
pub block_hash: String,
pub permission: serde_json::Value,
}
/// Transaction outcome from RPC.
#[derive(Debug, Clone, Deserialize)]
pub struct TxOutcome {
pub status: serde_json::Value,
pub transaction: Option<serde_json::Value>,
pub transaction_outcome: Option<serde_json::Value>,
pub receipts_outcome: Option<Vec<serde_json::Value>>,
}
impl TxOutcome {
/// Check if the transaction succeeded.
pub fn is_success(&self) -> bool {
if let Some(obj) = self.status.as_object() {
obj.contains_key("SuccessValue") || obj.contains_key("SuccessReceiptId")
} else {
false
}
}
/// Get the failure reason if the transaction failed.
pub fn failure_reason(&self) -> Option<String> {
if let Some(obj) = self.status.as_object() {
if let Some(failure) = obj.get("Failure") {
return Some(format!("{}", failure));
}
}
None
}
}
/// Account view from RPC.
#[derive(Debug, Clone, Deserialize)]
pub struct AccountView {
pub amount: String,
pub locked: String,
pub storage_usage: u64,
pub code_hash: String,
pub block_height: u64,
pub block_hash: String,
}
impl AccountView {
/// Parse the balance as u128 (yoctoNEAR).
pub fn balance_yocto(&self) -> Result<u128, KeyError> {
self.amount.parse::<u128>().map_err(|e| KeyError::RpcError {
reason: format!("failed to parse account balance '{}': {}", self.amount, e),
})
}
}
#[cfg(test)]
mod tests {
use crate::keys::rpc::{AccessKeyView, AccountView, TxOutcome};
#[test]
fn test_tx_outcome_success() {
let outcome = TxOutcome {
status: serde_json::json!({"SuccessValue": ""}),
transaction: None,
transaction_outcome: None,
receipts_outcome: None,
};
assert!(outcome.is_success());
assert!(outcome.failure_reason().is_none());
}
#[test]
fn test_tx_outcome_failure() {
let outcome = TxOutcome {
status: serde_json::json!({"Failure": {"ActionError": "..."}}),
transaction: None,
transaction_outcome: None,
receipts_outcome: None,
};
assert!(!outcome.is_success());
assert!(outcome.failure_reason().is_some());
}
#[test]
fn test_access_key_view_deserialize() {
let json = serde_json::json!({
"nonce": 42,
"block_hash": "11111111111111111111111111111111",
"permission": "FullAccess"
});
let view: AccessKeyView = serde_json::from_value(json).unwrap();
assert_eq!(view.nonce, 42);
}
#[test]
fn test_account_view_balance() {
let view = AccountView {
amount: "1000000000000000000000000".to_string(), // 1 NEAR
locked: "0".to_string(),
storage_usage: 100,
code_hash: "11111111111111111111111111111111".to_string(),
block_height: 1000,
block_hash: "11111111111111111111111111111111".to_string(),
};
assert_eq!(
view.balance_yocto().unwrap(),
1_000_000_000_000_000_000_000_000
);
}
}
+243
View File
@@ -0,0 +1,243 @@
//! Ed25519 signing for NEAR transactions.
//!
//! SECURITY: Private keys are held in memory for the absolute minimum time.
//! The flow is: decrypt -> construct SigningKey -> sign -> drop (Zeroize).
//! The `ed25519_dalek::SigningKey` implements Zeroize, so memory is zeroed on drop.
use ed25519_dalek::Signer;
use sha2::{Digest, Sha256};
use zeroize::Zeroize;
use crate::keys::KeyError;
use crate::keys::types::NearPublicKey;
use crate::secrets::SecretsStore;
/// Parse a NEAR-format secret key and extract the 32-byte ed25519 seed.
///
/// NEAR secret keys are formatted as `ed25519:<base58-encoded-64-bytes>`.
/// The 64 bytes are the seed (32) + public key (32) concatenated.
/// Some wallets store only the 32-byte seed with the same prefix.
fn parse_near_secret_key(near_format: &str) -> Result<[u8; 32], KeyError> {
let data_str =
near_format
.strip_prefix("ed25519:")
.ok_or_else(|| KeyError::InvalidKeyFormat {
reason: "secret key must start with 'ed25519:'".to_string(),
})?;
let mut bytes = bs58::decode(data_str)
.into_vec()
.map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid base58 in secret key: {}", e),
})?;
let seed = match bytes.len() {
64 => {
// Standard NEAR format: seed (32) + public key (32)
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes[..32]);
bytes.zeroize();
seed
}
32 => {
// Some wallets export just the seed
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
bytes.zeroize();
seed
}
other => {
bytes.zeroize();
return Err(KeyError::InvalidKeyFormat {
reason: format!("ed25519 secret key must be 32 or 64 bytes, got {}", other),
});
}
};
Ok(seed)
}
/// Derive the public key from a NEAR-format secret key string.
pub fn public_key_from_secret(near_format_secret: &str) -> Result<NearPublicKey, KeyError> {
let seed = parse_near_secret_key(near_format_secret)?;
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
// signing_key implements Zeroize on drop
Ok(NearPublicKey {
key_type: crate::keys::types::KeyType::Ed25519,
data: verifying_key.to_bytes(),
})
}
/// Sign a 32-byte SHA-256 hash using a key from the secrets store.
///
/// This is the core signing function. It:
/// 1. Decrypts the private key from the secrets store
/// 2. Parses the NEAR-format key to extract the ed25519 seed
/// 3. Constructs a SigningKey (implements Zeroize on drop)
/// 4. Signs the hash
/// 5. Drops the SigningKey (memory zeroed)
///
/// The plaintext key exists in memory for microseconds.
pub async fn sign_hash(
secrets_store: &dyn SecretsStore,
user_id: &str,
label: &str,
hash: &[u8; 32],
) -> Result<[u8; 64], KeyError> {
let secret_name = format!("near_key:{}", label);
let decrypted = secrets_store
.get_decrypted(user_id, &secret_name)
.await
.map_err(|e| KeyError::SigningFailed {
reason: format!("failed to decrypt key '{}': {}", label, e),
})?;
let mut seed = parse_near_secret_key(decrypted.expose())?;
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
seed.zeroize();
let signature = signing_key.sign(hash);
// signing_key drops here, Zeroize zeroes the key material
Ok(signature.to_bytes())
}
/// SHA-256 hash of data (used for transaction signing).
pub fn sha256_hash(data: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().into()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
use secrecy::SecretString;
use crate::keys::signer::{
parse_near_secret_key, public_key_from_secret, sha256_hash, sign_hash,
};
use crate::keys::types::KeyType;
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto, SecretsStore};
fn test_store() -> Arc<InMemorySecretsStore> {
let key = "0123456789abcdef0123456789abcdef";
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
Arc::new(InMemorySecretsStore::new(crypto))
}
/// Generate a test keypair and return (near_format_secret, near_format_public).
fn generate_test_keypair() -> (String, String) {
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
// NEAR format: ed25519:<base58(seed + pubkey)>
let mut combined = Vec::with_capacity(64);
combined.extend_from_slice(signing_key.as_bytes());
combined.extend_from_slice(verifying_key.as_bytes());
let secret = format!("ed25519:{}", bs58::encode(&combined).into_string());
let public = format!(
"ed25519:{}",
bs58::encode(verifying_key.as_bytes()).into_string()
);
(secret, public)
}
#[test]
fn test_parse_near_secret_key_64_bytes() {
let (secret, _) = generate_test_keypair();
let seed = parse_near_secret_key(&secret).unwrap();
assert_eq!(seed.len(), 32);
}
#[test]
fn test_parse_near_secret_key_32_bytes() {
// Some wallets export just the 32-byte seed
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let secret = format!(
"ed25519:{}",
bs58::encode(signing_key.as_bytes()).into_string()
);
let seed = parse_near_secret_key(&secret).unwrap();
assert_eq!(seed, *signing_key.as_bytes());
}
#[test]
fn test_parse_invalid_prefix() {
assert!(parse_near_secret_key("secp256k1:abc").is_err());
}
#[test]
fn test_public_key_from_secret() {
let (secret, expected_public) = generate_test_keypair();
let pubkey = public_key_from_secret(&secret).unwrap();
assert_eq!(pubkey.key_type, KeyType::Ed25519);
assert_eq!(pubkey.to_near_format(), expected_public);
}
#[test]
fn test_sign_and_verify_roundtrip() {
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
let message = b"test message for signing";
let hash = sha256_hash(message);
let signature = signing_key.sign(&hash);
// Verify
assert!(verifying_key.verify(&hash, &signature).is_ok());
}
#[tokio::test]
async fn test_sign_hash_from_store() {
let store = test_store();
let (secret, _public) = generate_test_keypair();
// Store the key
store
.create(
"user1",
CreateSecretParams::new("near_key:test-signer", &secret).with_provider("near_keys"),
)
.await
.unwrap();
// Sign
let hash = sha256_hash(b"test transaction data");
let sig_bytes = sign_hash(store.as_ref(), "user1", "test-signer", &hash)
.await
.unwrap();
// Verify using the public key derived from the secret
let pubkey = public_key_from_secret(&secret).unwrap();
let verifying_key = VerifyingKey::from_bytes(pubkey.as_bytes()).unwrap();
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
assert!(verifying_key.verify(&hash, &signature).is_ok());
}
#[tokio::test]
async fn test_sign_hash_key_not_found() {
let store = test_store();
let hash = [0u8; 32];
let result = sign_hash(store.as_ref(), "user1", "nonexistent", &hash).await;
assert!(result.is_err());
}
#[test]
fn test_sha256_hash() {
let hash = sha256_hash(b"hello");
let hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
// Known SHA-256 of "hello"
assert_eq!(
hex,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Daily spend tracking for rate-limiting value transfers.
//!
//! Tracks cumulative daily spend in yoctoNEAR to enforce `daily_spend_limit_yocto`.
//! Persisted to `~/.ironclaw/spend_tracking.json`. Resets automatically at midnight UTC.
use std::path::PathBuf;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::keys::KeyError;
use crate::keys::types::format_yocto;
/// Tracks daily cumulative spend for policy enforcement.
pub struct SpendTracker {
path: PathBuf,
}
impl SpendTracker {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
/// Default location: `~/.ironclaw/spend_tracking.json`
pub fn default_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".ironclaw").join("spend_tracking.json"))
.unwrap_or_else(|| PathBuf::from(".ironclaw/spend_tracking.json"))
}
/// Get today's cumulative spend in yoctoNEAR.
pub async fn get_daily_spend(&self) -> Result<u128, KeyError> {
let data = self.load().await?;
let today = Utc::now().date_naive();
Ok(data
.records
.iter()
.find(|r| r.date == today)
.map(|r| r.total_spent_yocto)
.unwrap_or(0))
}
/// Record a spend after successful transaction submission.
pub async fn record_spend(
&self,
value_yocto: u128,
description: String,
tx_hash: Option<String>,
) -> Result<(), KeyError> {
let mut data = self.load().await?;
let today = Utc::now().date_naive();
let record = data.records.iter_mut().find(|r| r.date == today);
let entry = SpendEntry {
timestamp: Utc::now(),
tx_hash,
value_yocto,
description,
};
if let Some(record) = record {
record.total_spent_yocto = record.total_spent_yocto.saturating_add(value_yocto);
record.transactions.push(entry);
} else {
data.records.push(SpendRecord {
date: today,
total_spent_yocto: value_yocto,
transactions: vec![entry],
});
}
// Keep only last 30 days of records
let cutoff = Utc::now().date_naive() - chrono::Duration::days(30);
data.records.retain(|r| r.date >= cutoff);
self.save(&data).await
}
/// Get spend history for the last N days.
pub async fn get_history(&self, days: u32) -> Result<Vec<SpendRecord>, KeyError> {
let data = self.load().await?;
let cutoff = Utc::now().date_naive() - chrono::Duration::days(days as i64);
Ok(data
.records
.into_iter()
.filter(|r| r.date >= cutoff)
.collect())
}
async fn load(&self) -> Result<SpendData, KeyError> {
if !self.path.exists() {
return Ok(SpendData::default());
}
let content = fs::read_to_string(&self.path).await?;
serde_json::from_str(&content).map_err(|e| {
KeyError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("corrupt spend tracking data: {}", e),
))
})
}
async fn save(&self, data: &SpendData) -> Result<(), KeyError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(data).map_err(|e| {
KeyError::SerializationFailed(format!("failed to serialize spend data: {}", e))
})?;
fs::write(&self.path, content).await?;
Ok(())
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct SpendData {
records: Vec<SpendRecord>,
}
/// A day's spend record with audit trail.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpendRecord {
pub date: NaiveDate,
pub total_spent_yocto: u128,
pub transactions: Vec<SpendEntry>,
}
impl std::fmt::Display for SpendRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: {} ({} txns)",
self.date,
format_yocto(self.total_spent_yocto),
self.transactions.len()
)
}
}
/// A single spend entry in the audit trail.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpendEntry {
pub timestamp: DateTime<Utc>,
pub tx_hash: Option<String>,
pub value_yocto: u128,
pub description: String,
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use crate::keys::spending::SpendTracker;
#[tokio::test]
async fn test_empty_spend() {
let dir = TempDir::new().unwrap();
let tracker = SpendTracker::new(dir.path().join("spend.json"));
assert_eq!(tracker.get_daily_spend().await.unwrap(), 0);
}
#[tokio::test]
async fn test_record_and_query_spend() {
let dir = TempDir::new().unwrap();
let tracker = SpendTracker::new(dir.path().join("spend.json"));
tracker
.record_spend(
1_000_000,
"test transfer".to_string(),
Some("hash1".to_string()),
)
.await
.unwrap();
assert_eq!(tracker.get_daily_spend().await.unwrap(), 1_000_000);
tracker
.record_spend(2_000_000, "another transfer".to_string(), None)
.await
.unwrap();
assert_eq!(tracker.get_daily_spend().await.unwrap(), 3_000_000);
}
#[tokio::test]
async fn test_get_history() {
let dir = TempDir::new().unwrap();
let tracker = SpendTracker::new(dir.path().join("spend.json"));
tracker
.record_spend(100, "test".to_string(), None)
.await
.unwrap();
let history = tracker.get_history(7).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].total_spent_yocto, 100);
assert_eq!(history[0].transactions.len(), 1);
}
}
+445
View File
@@ -0,0 +1,445 @@
//! Minimal NEAR transaction types with borsh serialization.
//!
//! Hand-rolled types that produce byte-identical borsh output to near-primitives,
//! without pulling in the massive nearcore dependency tree.
//!
//! # Serialization Format
//!
//! NEAR transactions are borsh-serialized, then SHA-256 hashed for signing.
//! The signed transaction includes the original transaction + ed25519 signature.
use borsh::BorshSerialize;
use crate::keys::signer::sha256_hash;
use crate::keys::types::{KeyType, NearAccountId, NearPublicKey};
/// A NEAR transaction ready for signing.
#[derive(Debug, Clone, BorshSerialize)]
pub struct Transaction {
pub signer_id: NearAccountId,
pub public_key: NearPublicKey,
pub nonce: u64,
pub receiver_id: NearAccountId,
pub block_hash: BlockHash,
pub actions: Vec<Action>,
}
impl Transaction {
/// Borsh-serialize and SHA-256 hash for signing.
pub fn hash_for_signing(&self) -> Result<[u8; 32], crate::keys::KeyError> {
let bytes = borsh::to_vec(self).map_err(|e| {
crate::keys::KeyError::SerializationFailed(format!(
"failed to serialize transaction: {}",
e
))
})?;
Ok(sha256_hash(&bytes))
}
}
/// A signed NEAR transaction with ed25519 signature.
#[derive(Debug, Clone)]
pub struct SignedTransaction {
pub transaction: Transaction,
pub signature: Signature,
}
impl SignedTransaction {
/// Encode as base64 for RPC submission.
pub fn to_base64(&self) -> Result<String, crate::keys::KeyError> {
let bytes = self.to_borsh()?;
Ok(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bytes,
))
}
/// Borsh-serialize the signed transaction.
pub fn to_borsh(&self) -> Result<Vec<u8>, crate::keys::KeyError> {
let mut buf = Vec::new();
borsh::BorshSerialize::serialize(&self.transaction, &mut buf).map_err(|e| {
crate::keys::KeyError::SerializationFailed(format!(
"failed to serialize signed transaction: {}",
e
))
})?;
borsh::BorshSerialize::serialize(&self.signature, &mut buf).map_err(|e| {
crate::keys::KeyError::SerializationFailed(format!(
"failed to serialize signature: {}",
e
))
})?;
Ok(buf)
}
/// Get the transaction hash (the hash that was signed).
pub fn tx_hash(&self) -> Result<[u8; 32], crate::keys::KeyError> {
self.transaction.hash_for_signing()
}
}
/// Block hash (32 bytes), used as recent block reference for transaction validity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlockHash(pub [u8; 32]);
impl BorshSerialize for BlockHash {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
writer.write_all(&self.0)
}
}
impl BlockHash {
pub fn from_base58(s: &str) -> Result<Self, crate::keys::KeyError> {
let bytes =
bs58::decode(s)
.into_vec()
.map_err(|e| crate::keys::KeyError::InvalidKeyFormat {
reason: format!("invalid base58 block hash: {}", e),
})?;
if bytes.len() != 32 {
return Err(crate::keys::KeyError::InvalidKeyFormat {
reason: format!("block hash must be 32 bytes, got {}", bytes.len()),
});
}
let mut hash = [0u8; 32];
hash.copy_from_slice(&bytes);
Ok(Self(hash))
}
}
/// Ed25519 signature (NEAR uses key_type prefix for borsh serialization).
#[derive(Debug, Clone)]
pub struct Signature {
pub key_type: KeyType,
pub data: [u8; 64],
}
impl BorshSerialize for Signature {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&self.key_type, writer)?;
writer.write_all(&self.data)?;
Ok(())
}
}
/// NEAR transaction action variants.
///
/// Only includes the variants we actually need for key management operations.
/// Borsh enum discriminants MUST match near-primitives exactly.
#[derive(Debug, Clone)]
pub enum Action {
CreateAccount, // 0
DeployContract(DeployContract), // 1
FunctionCall(FunctionCall), // 2
Transfer(Transfer), // 3
Stake(Stake), // 4
AddKey(AddKey), // 5
DeleteKey(DeleteKey), // 6
DeleteAccount(DeleteAccount), // 7
}
impl BorshSerialize for Action {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
match self {
Action::CreateAccount => {
BorshSerialize::serialize(&0u8, writer)?;
}
Action::DeployContract(v) => {
BorshSerialize::serialize(&1u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::FunctionCall(v) => {
BorshSerialize::serialize(&2u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::Transfer(v) => {
BorshSerialize::serialize(&3u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::Stake(v) => {
BorshSerialize::serialize(&4u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::AddKey(v) => {
BorshSerialize::serialize(&5u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::DeleteKey(v) => {
BorshSerialize::serialize(&6u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::DeleteAccount(v) => {
BorshSerialize::serialize(&7u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
}
Ok(())
}
}
/// Deploy contract action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct DeployContract {
pub code: Vec<u8>,
}
/// Function call action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct FunctionCall {
pub method_name: String,
pub args: Vec<u8>,
pub gas: u64,
pub deposit: u128,
}
/// Transfer action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct Transfer {
pub deposit: u128,
}
/// Stake action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct Stake {
pub stake: u128,
pub public_key: NearPublicKey,
}
/// Add key action.
#[derive(Debug, Clone)]
pub struct AddKey {
pub public_key: NearPublicKey,
pub access_key: AccessKeyBorsh,
}
impl BorshSerialize for AddKey {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&self.public_key, writer)?;
BorshSerialize::serialize(&self.access_key, writer)?;
Ok(())
}
}
/// Delete key action.
#[derive(Debug, Clone)]
pub struct DeleteKey {
pub public_key: NearPublicKey,
}
impl BorshSerialize for DeleteKey {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&self.public_key, writer)?;
Ok(())
}
}
/// Delete account action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct DeleteAccount {
pub beneficiary_id: NearAccountId,
}
/// Borsh-serializable access key (for AddKey actions).
#[derive(Debug, Clone)]
pub struct AccessKeyBorsh {
pub nonce: u64,
pub permission: AccessKeyPermissionBorsh,
}
impl BorshSerialize for AccessKeyBorsh {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&self.nonce, writer)?;
BorshSerialize::serialize(&self.permission, writer)?;
Ok(())
}
}
/// Borsh-serializable access key permission.
#[derive(Debug, Clone)]
pub enum AccessKeyPermissionBorsh {
FunctionCall(FunctionCallPermissionBorsh),
FullAccess,
}
impl BorshSerialize for AccessKeyPermissionBorsh {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
match self {
AccessKeyPermissionBorsh::FunctionCall(fc) => {
BorshSerialize::serialize(&0u8, writer)?;
BorshSerialize::serialize(fc, writer)?;
}
AccessKeyPermissionBorsh::FullAccess => {
BorshSerialize::serialize(&1u8, writer)?;
}
}
Ok(())
}
}
/// Borsh-serializable function call permission.
#[derive(Debug, Clone, BorshSerialize)]
pub struct FunctionCallPermissionBorsh {
/// Allowance in yoctoNEAR (None = unlimited within key scope).
pub allowance: Option<u128>,
pub receiver_id: String,
pub method_names: Vec<String>,
}
/// Standard gas amounts.
pub const TGAS: u64 = 1_000_000_000_000;
/// 300 TGas, the maximum per transaction.
pub const MAX_GAS: u64 = 300 * TGAS;
/// 1 yoctoNEAR, commonly used as a deposit to indicate "attached" value.
pub const ONE_YOCTO: u128 = 1;
/// 1 NEAR in yoctoNEAR.
pub const ONE_NEAR: u128 = 1_000_000_000_000_000_000_000_000;
#[cfg(test)]
mod tests {
use crate::keys::transaction::{
AccessKeyBorsh, AccessKeyPermissionBorsh, Action, BlockHash, FunctionCall,
FunctionCallPermissionBorsh, MAX_GAS, ONE_NEAR, ONE_YOCTO, Signature, TGAS, Transaction,
Transfer,
};
use crate::keys::types::{KeyType, NearAccountId, NearPublicKey};
fn test_public_key() -> NearPublicKey {
NearPublicKey::from_near_format("ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp")
.unwrap()
}
#[test]
fn test_transfer_action_borsh() {
let action = Action::Transfer(Transfer { deposit: ONE_NEAR });
let bytes = borsh::to_vec(&action).unwrap();
// Discriminant (1 byte) + u128 (16 bytes)
assert_eq!(bytes.len(), 1 + 16);
assert_eq!(bytes[0], 3); // Transfer = discriminant 3
}
#[test]
fn test_function_call_action_borsh() {
let action = Action::FunctionCall(FunctionCall {
method_name: "deposit".to_string(),
args: b"{}".to_vec(),
gas: 30 * TGAS,
deposit: ONE_YOCTO,
});
let bytes = borsh::to_vec(&action).unwrap();
assert_eq!(bytes[0], 2); // FunctionCall = discriminant 2
// Verify it serializes without error
assert!(bytes.len() > 1);
}
#[test]
fn test_transaction_hash_for_signing() {
let tx = Transaction {
signer_id: NearAccountId::new("alice.near").unwrap(),
public_key: test_public_key(),
nonce: 1,
receiver_id: NearAccountId::new("bob.near").unwrap(),
block_hash: BlockHash([0u8; 32]),
actions: vec![Action::Transfer(Transfer { deposit: ONE_NEAR })],
};
let hash = tx.hash_for_signing().unwrap();
assert_eq!(hash.len(), 32);
// Same transaction should produce same hash
let hash2 = tx.hash_for_signing().unwrap();
assert_eq!(hash, hash2);
}
#[test]
fn test_transaction_different_nonce_different_hash() {
let tx1 = Transaction {
signer_id: NearAccountId::new("alice.near").unwrap(),
public_key: test_public_key(),
nonce: 1,
receiver_id: NearAccountId::new("bob.near").unwrap(),
block_hash: BlockHash([0u8; 32]),
actions: vec![Action::Transfer(Transfer { deposit: ONE_NEAR })],
};
let tx2 = Transaction {
nonce: 2,
..tx1.clone()
};
assert_ne!(
tx1.hash_for_signing().unwrap(),
tx2.hash_for_signing().unwrap()
);
}
#[test]
fn test_signed_transaction_to_base64() {
let tx = Transaction {
signer_id: NearAccountId::new("alice.near").unwrap(),
public_key: test_public_key(),
nonce: 1,
receiver_id: NearAccountId::new("bob.near").unwrap(),
block_hash: BlockHash([0u8; 32]),
actions: vec![Action::Transfer(Transfer { deposit: ONE_NEAR })],
};
let signed = crate::keys::transaction::SignedTransaction {
transaction: tx,
signature: Signature {
key_type: KeyType::Ed25519,
data: [0u8; 64],
},
};
let b64 = signed.to_base64().unwrap();
assert!(!b64.is_empty());
// Should be valid base64
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64).unwrap();
assert!(!decoded.is_empty());
}
#[test]
fn test_block_hash_from_base58() {
let hash_str = "11111111111111111111111111111111"; // 32 zero bytes in base58
let hash = BlockHash::from_base58(hash_str).unwrap();
assert_eq!(hash.0, [0u8; 32]);
}
#[test]
fn test_access_key_borsh_full_access() {
let ak = AccessKeyBorsh {
nonce: 0,
permission: AccessKeyPermissionBorsh::FullAccess,
};
let bytes = borsh::to_vec(&ak).unwrap();
// u64 (8 bytes) + discriminant (1 byte)
assert_eq!(bytes.len(), 9);
}
#[test]
fn test_access_key_borsh_function_call() {
let ak = AccessKeyBorsh {
nonce: 0,
permission: AccessKeyPermissionBorsh::FunctionCall(FunctionCallPermissionBorsh {
allowance: Some(ONE_NEAR),
receiver_id: "contract.near".to_string(),
method_names: vec!["deposit".to_string()],
}),
};
let bytes = borsh::to_vec(&ak).unwrap();
assert!(!bytes.is_empty());
// First 8 bytes = nonce, then discriminant 0 for FunctionCall
assert_eq!(bytes[8], 0);
}
#[test]
fn test_gas_constants() {
assert_eq!(TGAS, 1_000_000_000_000);
assert_eq!(MAX_GAS, 300_000_000_000_000);
}
}
+563
View File
@@ -0,0 +1,563 @@
//! Core types for NEAR key management.
//!
//! Types for account IDs, public keys, access key permissions, network selection,
//! and key metadata. All types validate on construction to prevent invalid states.
//!
//! SECURITY: Debug impls on key-related types MUST redact secret material.
use std::fmt;
use std::str::FromStr;
use borsh::BorshSerialize;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::keys::KeyError;
/// NEAR account ID with validation.
///
/// Rules: 2-64 chars, lowercase alphanumeric + `.`, `-`, `_`.
/// No leading/trailing separators, no consecutive separators.
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NearAccountId(String);
impl NearAccountId {
pub fn new(id: &str) -> Result<Self, KeyError> {
Self::validate(id)?;
Ok(Self(id.to_string()))
}
fn validate(id: &str) -> Result<(), KeyError> {
if id.len() < 2 || id.len() > 64 {
return Err(KeyError::InvalidAccountId {
reason: format!("account ID must be 2-64 characters, got {}", id.len()),
});
}
let bytes = id.as_bytes();
// No leading/trailing separators
if matches!(bytes[0], b'.' | b'-' | b'_') {
return Err(KeyError::InvalidAccountId {
reason: "account ID must not start with a separator".to_string(),
});
}
if matches!(bytes[bytes.len() - 1], b'.' | b'-' | b'_') {
return Err(KeyError::InvalidAccountId {
reason: "account ID must not end with a separator".to_string(),
});
}
for ch in id.chars() {
if !matches!(ch, 'a'..='z' | '0'..='9' | '.' | '-' | '_') {
return Err(KeyError::InvalidAccountId {
reason: format!("invalid character '{}' in account ID", ch),
});
}
}
Ok(())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for NearAccountId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl fmt::Debug for NearAccountId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "NearAccountId({})", self.0)
}
}
impl FromStr for NearAccountId {
type Err = KeyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl BorshSerialize for NearAccountId {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
// NEAR protocol serializes account IDs as length-prefixed UTF-8 strings.
BorshSerialize::serialize(&self.0, writer)
}
}
/// Key type discriminant for borsh serialization (matches NEAR protocol).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum KeyType {
Ed25519 = 0,
}
impl BorshSerialize for KeyType {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&(*self as u8), writer)
}
}
/// NEAR public key with format parsing.
///
/// Parses the NEAR format: `ed25519:<base58-encoded-32-bytes>`
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NearPublicKey {
pub key_type: KeyType,
pub data: [u8; 32],
}
impl NearPublicKey {
/// Parse from NEAR format string: `ed25519:<base58>`
pub fn from_near_format(s: &str) -> Result<Self, KeyError> {
let s = s.trim();
let data_str = s
.strip_prefix("ed25519:")
.ok_or_else(|| KeyError::InvalidKeyFormat {
reason: "public key must start with 'ed25519:'".to_string(),
})?;
let bytes = bs58::decode(data_str)
.into_vec()
.map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid base58 in public key: {}", e),
})?;
if bytes.len() != 32 {
return Err(KeyError::InvalidKeyFormat {
reason: format!("ed25519 public key must be 32 bytes, got {}", bytes.len()),
});
}
let mut data = [0u8; 32];
data.copy_from_slice(&bytes);
Ok(Self {
key_type: KeyType::Ed25519,
data,
})
}
/// Format as NEAR string: `ed25519:<base58>`
pub fn to_near_format(&self) -> String {
format!("ed25519:{}", bs58::encode(&self.data).into_string())
}
/// Raw 32-byte key data.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.data
}
}
impl fmt::Display for NearPublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_near_format())
}
}
impl fmt::Debug for NearPublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let encoded = bs58::encode(&self.data).into_string();
let preview = if encoded.len() > 8 {
&encoded[..8]
} else {
&encoded
};
write!(f, "NearPublicKey(ed25519:{}...)", preview)
}
}
impl BorshSerialize for NearPublicKey {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
// NEAR protocol: key_type byte + 32 bytes of key data
BorshSerialize::serialize(&self.key_type, writer)?;
writer.write_all(&self.data)?;
Ok(())
}
}
/// Access key permission level.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccessKeyPermission {
FullAccess,
FunctionCall {
/// Max NEAR that can be spent (None = unlimited within key's scope).
allowance: Option<u128>,
/// Contract this key is scoped to.
receiver_id: String,
/// Allowed method names (empty = all methods on the contract).
method_names: Vec<String>,
},
}
impl fmt::Display for AccessKeyPermission {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AccessKeyPermission::FullAccess => write!(f, "FullAccess"),
AccessKeyPermission::FunctionCall {
receiver_id,
method_names,
allowance,
} => {
write!(f, "FunctionCall({}", receiver_id)?;
if !method_names.is_empty() {
write!(f, "::{}", method_names.join(","))?;
}
if let Some(a) = allowance {
write!(f, ", allowance={})", format_yocto(*a))?;
} else {
write!(f, ")")?;
}
Ok(())
}
}
}
}
/// NEAR network configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum NearNetwork {
Mainnet,
Testnet,
Custom(String),
}
impl NearNetwork {
pub fn rpc_url(&self) -> &str {
match self {
NearNetwork::Mainnet => "https://rpc.mainnet.near.org",
NearNetwork::Testnet => "https://rpc.testnet.near.org",
NearNetwork::Custom(url) => url.as_str(),
}
}
}
impl fmt::Display for NearNetwork {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NearNetwork::Mainnet => write!(f, "mainnet"),
NearNetwork::Testnet => write!(f, "testnet"),
NearNetwork::Custom(url) => write!(f, "custom({})", url),
}
}
}
impl FromStr for NearNetwork {
type Err = KeyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"mainnet" => Ok(NearNetwork::Mainnet),
"testnet" => Ok(NearNetwork::Testnet),
url if url.starts_with("http") => Ok(NearNetwork::Custom(url.to_string())),
other => Err(KeyError::InvalidKeyFormat {
reason: format!(
"unknown network '{}', expected mainnet, testnet, or an RPC URL",
other
),
}),
}
}
}
/// Metadata for a stored key (public info only, no secrets).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyMetadata {
pub label: String,
pub account_id: String,
pub public_key: String,
pub permission: AccessKeyPermission,
pub network: NearNetwork,
pub created_at: DateTime<Utc>,
/// Cached nonce for transaction building (avoids extra RPC round-trip).
pub cached_nonce: Option<u64>,
}
/// Top-level structure for ~/.ironclaw/keys.json
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct KeyStore {
pub keys: std::collections::HashMap<String, KeyMetadata>,
pub last_backup_at: Option<DateTime<Utc>>,
}
/// Format yoctoNEAR as human-readable NEAR amount.
pub fn format_yocto(yocto: u128) -> String {
const ONE_NEAR: u128 = 1_000_000_000_000_000_000_000_000;
const ONE_MILLI_NEAR: u128 = ONE_NEAR / 1000;
if yocto == 0 {
return "0 NEAR".to_string();
}
if yocto >= ONE_MILLI_NEAR {
let whole = yocto / ONE_NEAR;
let frac = (yocto % ONE_NEAR) / ONE_MILLI_NEAR; // 3 decimal places
if frac == 0 {
format!("{} NEAR", whole)
} else {
format!("{}.{:03} NEAR", whole, frac)
}
} else {
format!("{} yoctoNEAR", yocto)
}
}
/// Parse a NEAR amount string into yoctoNEAR.
///
/// Accepts: "1", "0.5", "1.5 NEAR", "100000 yoctoNEAR"
pub fn parse_near_amount(s: &str) -> Result<u128, KeyError> {
const ONE_NEAR: u128 = 1_000_000_000_000_000_000_000_000;
let s = s.trim();
// Check for explicit yoctoNEAR suffix
if let Some(yocto_str) = s
.strip_suffix("yoctoNEAR")
.or_else(|| s.strip_suffix("yocto"))
{
return yocto_str
.trim()
.parse::<u128>()
.map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid yoctoNEAR amount: {}", e),
});
}
// Strip optional "NEAR" suffix
let amount_str = s
.strip_suffix("NEAR")
.or_else(|| s.strip_suffix("near"))
.unwrap_or(s)
.trim();
// Parse as decimal NEAR
if let Some((whole_str, frac_str)) = amount_str.split_once('.') {
let whole: u128 = whole_str.parse().map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid NEAR amount: {}", e),
})?;
// Pad or truncate fractional part to 24 digits
let mut frac_padded = frac_str.to_string();
if frac_padded.len() > 24 {
frac_padded.truncate(24);
}
while frac_padded.len() < 24 {
frac_padded.push('0');
}
let frac: u128 = frac_padded
.parse()
.map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid NEAR fractional amount: {}", e),
})?;
Ok(whole * ONE_NEAR + frac)
} else {
let whole: u128 = amount_str.parse().map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid NEAR amount: {}", e),
})?;
Ok(whole * ONE_NEAR)
}
}
#[cfg(test)]
mod tests {
use crate::keys::types::{
AccessKeyPermission, KeyType, NearAccountId, NearNetwork, NearPublicKey, format_yocto,
parse_near_amount,
};
// -- NearAccountId tests --
#[test]
fn test_valid_account_ids() {
assert!(NearAccountId::new("alice.near").is_ok());
assert!(NearAccountId::new("bob.testnet").is_ok());
assert!(NearAccountId::new("system").is_ok());
assert!(NearAccountId::new("ab").is_ok()); // minimum 2 chars
assert!(NearAccountId::new("a0").is_ok());
assert!(NearAccountId::new("alice-bob.near").is_ok());
assert!(NearAccountId::new("alice_bob.near").is_ok());
// 64 chars max
let long_id = "a".repeat(64);
assert!(NearAccountId::new(&long_id).is_ok());
}
#[test]
fn test_invalid_account_ids() {
// Too short
assert!(NearAccountId::new("a").is_err());
// Too long
assert!(NearAccountId::new(&"a".repeat(65)).is_err());
// Uppercase
assert!(NearAccountId::new("Alice.near").is_err());
// Leading separator
assert!(NearAccountId::new(".alice").is_err());
assert!(NearAccountId::new("-alice").is_err());
// Trailing separator
assert!(NearAccountId::new("alice.").is_err());
// Invalid chars
assert!(NearAccountId::new("alice@near").is_err());
assert!(NearAccountId::new("alice near").is_err());
}
#[test]
fn test_account_id_display() {
let id = NearAccountId::new("alice.near").unwrap();
assert_eq!(id.to_string(), "alice.near");
assert_eq!(id.as_str(), "alice.near");
}
#[test]
fn test_account_id_from_str() {
let id: NearAccountId = "bob.testnet".parse().unwrap();
assert_eq!(id.as_str(), "bob.testnet");
}
// -- NearPublicKey tests --
#[test]
fn test_public_key_roundtrip() {
let key_str = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp";
let key = NearPublicKey::from_near_format(key_str).unwrap();
assert_eq!(key.key_type, KeyType::Ed25519);
assert_eq!(key.to_near_format(), key_str);
}
#[test]
fn test_public_key_invalid_prefix() {
assert!(NearPublicKey::from_near_format("secp256k1:abc").is_err());
assert!(NearPublicKey::from_near_format("abc123").is_err());
}
#[test]
fn test_public_key_invalid_base58() {
assert!(NearPublicKey::from_near_format("ed25519:not-valid-base58!!!").is_err());
}
#[test]
fn test_public_key_wrong_length() {
// Too short (only 16 bytes encoded)
assert!(NearPublicKey::from_near_format("ed25519:3gZNbFLLDt").is_err());
}
#[test]
fn test_public_key_debug_redacts() {
let key_str = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp";
let key = NearPublicKey::from_near_format(key_str).unwrap();
let debug = format!("{:?}", key);
// Should show first 8 chars of base58, not the whole thing
assert!(debug.contains("..."));
assert!(!debug.contains("6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp"));
}
// -- AccessKeyPermission tests --
#[test]
fn test_permission_display() {
assert_eq!(AccessKeyPermission::FullAccess.to_string(), "FullAccess");
let fc = AccessKeyPermission::FunctionCall {
allowance: None,
receiver_id: "intents.near".to_string(),
method_names: vec![],
};
assert_eq!(fc.to_string(), "FunctionCall(intents.near)");
let fc_methods = AccessKeyPermission::FunctionCall {
allowance: Some(1_000_000_000_000_000_000_000_000),
receiver_id: "contract.near".to_string(),
method_names: vec!["deposit".to_string(), "withdraw".to_string()],
};
assert!(fc_methods.to_string().contains("deposit,withdraw"));
assert!(fc_methods.to_string().contains("1 NEAR"));
}
// -- NearNetwork tests --
#[test]
fn test_network_rpc_urls() {
assert_eq!(
NearNetwork::Mainnet.rpc_url(),
"https://rpc.mainnet.near.org"
);
assert_eq!(
NearNetwork::Testnet.rpc_url(),
"https://rpc.testnet.near.org"
);
let custom = NearNetwork::Custom("https://custom.rpc.dev".to_string());
assert_eq!(custom.rpc_url(), "https://custom.rpc.dev");
}
#[test]
fn test_network_from_str() {
assert_eq!(
"mainnet".parse::<NearNetwork>().unwrap(),
NearNetwork::Mainnet
);
assert_eq!(
"testnet".parse::<NearNetwork>().unwrap(),
NearNetwork::Testnet
);
assert_eq!(
"https://custom.rpc".parse::<NearNetwork>().unwrap(),
NearNetwork::Custom("https://custom.rpc".to_string())
);
assert!("garbage".parse::<NearNetwork>().is_err());
}
// -- NEAR amount formatting/parsing --
#[test]
fn test_format_yocto() {
assert_eq!(format_yocto(0), "0 NEAR");
assert_eq!(format_yocto(1_000_000_000_000_000_000_000_000), "1 NEAR");
assert_eq!(
format_yocto(5_500_000_000_000_000_000_000_000),
"5.500 NEAR"
);
assert_eq!(format_yocto(1), "1 yoctoNEAR");
assert_eq!(format_yocto(500_000_000_000_000_000_000_000), "0.500 NEAR");
}
#[test]
fn test_parse_near_amount() {
assert_eq!(
parse_near_amount("1").unwrap(),
1_000_000_000_000_000_000_000_000
);
assert_eq!(
parse_near_amount("0.5").unwrap(),
500_000_000_000_000_000_000_000
);
assert_eq!(
parse_near_amount("1.5 NEAR").unwrap(),
1_500_000_000_000_000_000_000_000
);
assert_eq!(parse_near_amount("100 yoctoNEAR").unwrap(), 100);
assert_eq!(parse_near_amount("0").unwrap(), 0);
}
// -- Borsh serialization tests --
#[test]
fn test_account_id_borsh() {
let id = NearAccountId::new("alice.near").unwrap();
let bytes = borsh::to_vec(&id).unwrap();
// Length-prefixed string: 4 bytes length + 10 bytes "alice.near"
assert_eq!(bytes.len(), 4 + 10);
}
#[test]
fn test_public_key_borsh() {
let key_str = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp";
let key = NearPublicKey::from_near_format(key_str).unwrap();
let bytes = borsh::to_vec(&key).unwrap();
// 1 byte key_type + 32 bytes data
assert_eq!(bytes.len(), 33);
assert_eq!(bytes[0], 0); // Ed25519 = 0
}
}
+1
View File
@@ -48,6 +48,7 @@ pub mod estimation;
pub mod evaluation; pub mod evaluation;
pub mod extensions; pub mod extensions;
pub mod history; pub mod history;
pub mod keys;
pub mod llm; pub mod llm;
pub mod safety; pub mod safety;
pub mod sandbox; pub mod sandbox;
+19 -1
View File
@@ -17,12 +17,14 @@ use ironclaw::{
web::log_layer::{LogBroadcaster, WebLogLayer}, web::log_layer::{LogBroadcaster, WebLogLayer},
}, },
cli::{ cli::{
Cli, Command, run_mcp_command, run_memory_command, run_status_command, run_tool_command, Cli, Command, run_key_command, run_mcp_command, run_memory_command, run_status_command,
run_tool_command,
}, },
config::Config, config::Config,
context::ContextManager, context::ContextManager,
extensions::ExtensionManager, extensions::ExtensionManager,
history::Store, history::Store,
keys::KeyManager,
llm::{SessionConfig, create_llm_provider, create_session_manager}, llm::{SessionConfig, create_llm_provider, create_session_manager},
safety::SafetyLayer, safety::SafetyLayer,
secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore}, secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore},
@@ -52,6 +54,16 @@ async fn main() -> anyhow::Result<()> {
return run_tool_command(tool_cmd.clone()).await; return run_tool_command(tool_cmd.clone()).await;
} }
Some(Command::Key(key_cmd)) => {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.init();
return run_key_command(key_cmd.clone()).await;
}
Some(Command::Config(config_cmd)) => { Some(Command::Config(config_cmd)) => {
// Config commands don't need logging setup // Config commands don't need logging setup
return ironclaw::cli::run_config_command(config_cmd.clone()) return ironclaw::cli::run_config_command(config_cmd.clone())
@@ -318,6 +330,11 @@ async fn main() -> anyhow::Result<()> {
None None
}; };
// Create key manager if secrets store is available.
let key_manager: Option<Arc<KeyManager>> = secrets_store
.as_ref()
.map(|store| Arc::new(KeyManager::new(Arc::clone(store), "default".to_string())));
let mcp_session_manager = Arc::new(McpSessionManager::new()); let mcp_session_manager = Arc::new(McpSessionManager::new());
// Create WASM tool runtime (sync, just builds the wasmtime engine) // Create WASM tool runtime (sync, just builds the wasmtime engine)
@@ -746,6 +763,7 @@ async fn main() -> anyhow::Result<()> {
tools, tools,
workspace, workspace,
extension_manager, extension_manager,
key_manager,
}; };
let agent = Agent::new( let agent = Agent::new(
config.agent.clone(), config.agent.clone(),
+41
View File
@@ -511,6 +511,14 @@ fn default_patterns() -> Vec<LeakPattern> {
severity: LeakSeverity::High, severity: LeakSeverity::High,
action: LeakAction::Redact, action: LeakAction::Redact,
}, },
// NEAR ed25519 private keys (base58 encoded, ~88 chars after prefix).
// Public keys are shorter (~44 chars), so this pattern is specific to secrets.
LeakPattern {
name: "near_ed25519_secret_key".to_string(),
regex: Regex::new(r"ed25519:[1-9A-HJ-NP-Za-km-z]{80,90}").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// High entropy hex (potential secrets, warn only) // High entropy hex (potential secrets, warn only)
// Uses word boundary since look-around isn't supported in the regex crate. // Uses word boundary since look-around isn't supported in the regex crate.
// This catches standalone 64-char hex strings (like SHA256 hashes used as secrets). // This catches standalone 64-char hex strings (like SHA256 hashes used as secrets).
@@ -696,6 +704,39 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn test_detect_near_ed25519_secret_key() {
let detector = LeakDetector::new();
// A realistic NEAR secret key (88 base58 chars after prefix)
let content = "key: ed25519:3D4YudUahN1nawWogh9MFV2MXJBMHCS2RE1KU7rWAiMi3t12UiSnMYCJ7BFXbsFhKfNUWDj8CCEbifTByREAMkTi";
let result = detector.scan(content);
assert!(!result.is_clean());
assert!(result.should_block);
assert!(
result
.matches
.iter()
.any(|m| m.pattern_name == "near_ed25519_secret_key")
);
}
#[test]
fn test_near_public_key_not_blocked() {
let detector = LeakDetector::new();
// Public keys are ~44 base58 chars, should NOT match the 80-90 char pattern
let content = "pubkey: ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp";
let result = detector.scan(content);
// Should not match near_ed25519_secret_key pattern
assert!(
!result
.matches
.iter()
.any(|m| m.pattern_name == "near_ed25519_secret_key")
);
}
#[test] #[test]
fn test_scan_http_request_blocks_secret_in_body() { fn test_scan_http_request_blocks_secret_in_body() {
let detector = LeakDetector::new(); let detector = LeakDetector::new();
+79
View File
@@ -32,6 +32,8 @@ pub struct Capabilities {
pub tool_invoke: Option<ToolInvokeCapability>, pub tool_invoke: Option<ToolInvokeCapability>,
/// Check if secrets exist. /// Check if secrets exist.
pub secrets: Option<SecretsCapability>, pub secrets: Option<SecretsCapability>,
/// Sign payloads using managed NEAR keys.
pub signing: Option<SigningCapability>,
} }
impl Capabilities { impl Capabilities {
@@ -71,6 +73,21 @@ impl Capabilities {
}); });
self self
} }
/// Enable payload signing with the given key labels.
pub fn with_signing(
mut self,
allowed_labels: Vec<String>,
max_signs: u32,
signer: Option<Arc<dyn PayloadSigner>>,
) -> Self {
self.signing = Some(SigningCapability {
allowed_key_labels: allowed_labels,
max_signs_per_execution: max_signs,
signer,
});
self
}
} }
/// Workspace read capability configuration. /// Workspace read capability configuration.
@@ -301,6 +318,68 @@ impl SecretsCapability {
} }
} }
/// Signing capability: allows WASM tools to request payload signatures from managed keys.
///
/// The private keys NEVER enter WASM memory. The host performs the signing and
/// returns only the signature bytes.
#[derive(Clone)]
pub struct SigningCapability {
/// Key labels this tool is allowed to use for signing.
pub allowed_key_labels: Vec<String>,
/// Maximum number of sign operations per execution.
pub max_signs_per_execution: u32,
/// Implementation that performs the actual signing.
/// Injected at runtime. None means signing will always return an error.
pub signer: Option<Arc<dyn PayloadSigner>>,
}
impl std::fmt::Debug for SigningCapability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SigningCapability")
.field("allowed_key_labels", &self.allowed_key_labels)
.field("max_signs_per_execution", &self.max_signs_per_execution)
.field("signer", &self.signer.is_some())
.finish()
}
}
impl SigningCapability {
/// Check if a key label is allowed.
pub fn is_label_allowed(&self, label: &str) -> bool {
self.allowed_key_labels.iter().any(|l| l == label)
}
}
/// Result of a payload signing operation.
#[derive(Debug, Clone)]
pub struct SignPayloadResult {
/// Base64-encoded signature (set on success).
pub signature: Option<String>,
/// Error message (set on failure).
pub error: Option<String>,
/// Whether user approval is needed before signing can proceed.
pub approval_pending: bool,
}
/// Trait for performing payload signing from the host boundary.
///
/// This is intentionally synchronous because WASM host functions run in a
/// blocking context. Implementations that need async should use
/// `Handle::block_on()` internally.
pub trait PayloadSigner: Send + Sync {
/// Sign a payload using the specified key.
///
/// The payload is raw bytes (decoded from the base64 the WASM tool sent).
/// Returns a `SignPayloadResult` which may contain a signature, an error,
/// or an approval-pending flag.
fn sign_payload(
&self,
key_label: &str,
payload: &[u8],
context_json: &str,
) -> SignPayloadResult;
}
/// Rate limiting configuration. /// Rate limiting configuration.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RateLimitConfig { pub struct RateLimitConfig {
+95 -1
View File
@@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize};
use crate::secrets::{CredentialLocation, CredentialMapping}; use crate::secrets::{CredentialLocation, CredentialMapping};
use crate::tools::wasm::{ use crate::tools::wasm::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability, Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WorkspaceCapability, SigningCapability, ToolInvokeCapability, WorkspaceCapability,
}; };
/// Root schema for a capabilities JSON file. /// Root schema for a capabilities JSON file.
@@ -57,6 +57,10 @@ pub struct CapabilitiesFile {
#[serde(default)] #[serde(default)]
pub workspace: Option<WorkspaceCapabilitySchema>, pub workspace: Option<WorkspaceCapabilitySchema>,
/// Payload signing using managed NEAR keys.
#[serde(default)]
pub signing: Option<SigningCapabilitySchema>,
/// Authentication setup instructions. /// Authentication setup instructions.
/// Used by `ironclaw config` to guide users through auth setup. /// Used by `ironclaw config` to guide users through auth setup.
#[serde(default)] #[serde(default)]
@@ -106,6 +110,14 @@ impl CapabilitiesFile {
}); });
} }
if let Some(signing) = &self.signing {
caps.signing = Some(SigningCapability {
allowed_key_labels: signing.allowed_key_labels.clone(),
max_signs_per_execution: signing.max_signs_per_execution.unwrap_or(5),
signer: None, // Injected at runtime
});
}
caps caps
} }
} }
@@ -318,6 +330,32 @@ pub struct ToolInvokeCapabilitySchema {
pub rate_limit: Option<RateLimitSchema>, pub rate_limit: Option<RateLimitSchema>,
} }
/// Signing capability schema.
///
/// Allows WASM tools to request payload signatures from managed NEAR keys.
/// The private keys never enter WASM memory.
///
/// # Example
///
/// ```json
/// {
/// "signing": {
/// "allowed_key_labels": ["intents-signer"],
/// "max_signs_per_execution": 5
/// }
/// }
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SigningCapabilitySchema {
/// Key labels this tool is allowed to use for signing.
#[serde(default)]
pub allowed_key_labels: Vec<String>,
/// Maximum sign operations per execution (default: 5).
#[serde(default)]
pub max_signs_per_execution: Option<u32>,
}
/// Workspace read capability schema. /// Workspace read capability schema.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceCapabilitySchema { pub struct WorkspaceCapabilitySchema {
@@ -754,4 +792,60 @@ mod tests {
assert!(auth.display_name.is_none()); assert!(auth.display_name.is_none());
assert!(auth.setup_url.is_none()); assert!(auth.setup_url.is_none());
} }
#[test]
fn test_parse_signing_capability() {
let json = r#"{
"signing": {
"allowed_key_labels": ["intents-signer", "trading-key"],
"max_signs_per_execution": 10
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let signing = caps.signing.unwrap();
assert_eq!(
signing.allowed_key_labels,
vec!["intents-signer", "trading-key"]
);
assert_eq!(signing.max_signs_per_execution, Some(10));
}
#[test]
fn test_parse_signing_defaults() {
let json = r#"{
"signing": {
"allowed_key_labels": ["default"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let signing = caps.signing.as_ref().unwrap();
assert_eq!(signing.max_signs_per_execution, None);
// Should default to 5 when converted
let runtime_caps = caps.to_capabilities();
let runtime_signing = runtime_caps.signing.unwrap();
assert_eq!(runtime_signing.max_signs_per_execution, 5);
assert!(runtime_signing.is_label_allowed("default"));
assert!(!runtime_signing.is_label_allowed("other"));
}
#[test]
fn test_signing_to_capabilities() {
let json = r#"{
"signing": {
"allowed_key_labels": ["signer-1"],
"max_signs_per_execution": 3
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
let signing = caps.signing.unwrap();
assert_eq!(signing.allowed_key_labels, vec!["signer-1"]);
assert_eq!(signing.max_signs_per_execution, 3);
assert!(signing.signer.is_none()); // Injected at runtime
}
} }
+190
View File
@@ -67,6 +67,7 @@ pub struct LogEntry {
/// ///
/// This is the "VMLogic" equivalent, it tracks all side effects and enforces limits. /// This is the "VMLogic" equivalent, it tracks all side effects and enforces limits.
/// Extended in V2 to support HTTP requests, tool invocation, and secret checks. /// Extended in V2 to support HTTP requests, tool invocation, and secret checks.
/// Extended in V3 to support payload signing via managed NEAR keys.
pub struct HostState { pub struct HostState {
/// Collected log entries. /// Collected log entries.
logs: Vec<LogEntry>, logs: Vec<LogEntry>,
@@ -82,6 +83,8 @@ pub struct HostState {
http_request_count: u32, http_request_count: u32,
/// Tool invoke count for rate limiting within this execution. /// Tool invoke count for rate limiting within this execution.
tool_invoke_count: u32, tool_invoke_count: u32,
/// Signing request count for rate limiting within this execution.
sign_count: u32,
} }
impl std::fmt::Debug for HostState { impl std::fmt::Debug for HostState {
@@ -93,6 +96,7 @@ impl std::fmt::Debug for HostState {
.field("user_id", &self.user_id) .field("user_id", &self.user_id)
.field("http_request_count", &self.http_request_count) .field("http_request_count", &self.http_request_count)
.field("tool_invoke_count", &self.tool_invoke_count) .field("tool_invoke_count", &self.tool_invoke_count)
.field("sign_count", &self.sign_count)
.finish() .finish()
} }
} }
@@ -108,6 +112,7 @@ impl HostState {
user_id: None, user_id: None,
http_request_count: 0, http_request_count: 0,
tool_invoke_count: 0, tool_invoke_count: 0,
sign_count: 0,
} }
} }
@@ -121,6 +126,7 @@ impl HostState {
user_id: Some(user_id.into()), user_id: Some(user_id.into()),
http_request_count: 0, http_request_count: 0,
tool_invoke_count: 0, tool_invoke_count: 0,
sign_count: 0,
} }
} }
@@ -223,6 +229,87 @@ impl HostState {
} }
} }
/// Sign a payload using a managed NEAR key.
///
/// Checks signing capability, key label allowlist, and rate limit.
/// Delegates actual signing to the `PayloadSigner` if all checks pass.
///
/// Private keys NEVER enter WASM memory. Only the signature is returned.
pub fn sign_payload(
&mut self,
key_label: &str,
payload_base64: &str,
context_json: &str,
) -> crate::tools::wasm::capabilities::SignPayloadResult {
use crate::tools::wasm::capabilities::SignPayloadResult;
let capability = match &self.capabilities.signing {
Some(cap) => cap,
None => {
return SignPayloadResult {
signature: None,
error: Some("Signing capability not granted".to_string()),
approval_pending: false,
};
}
};
// Check key label is allowed
if !capability.is_label_allowed(key_label) {
return SignPayloadResult {
signature: None,
error: Some(format!(
"Key label '{}' not in allowed list for this tool",
key_label
)),
approval_pending: false,
};
}
// Check rate limit
self.sign_count += 1;
if self.sign_count > capability.max_signs_per_execution {
return SignPayloadResult {
signature: None,
error: Some(format!(
"Sign limit exceeded ({} per execution)",
capability.max_signs_per_execution
)),
approval_pending: false,
};
}
// Decode base64 payload
let payload_bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
payload_base64,
) {
Ok(bytes) => bytes,
Err(e) => {
return SignPayloadResult {
signature: None,
error: Some(format!("Invalid base64 payload: {}", e)),
approval_pending: false,
};
}
};
// Delegate to signer implementation
match &capability.signer {
Some(signer) => signer.sign_payload(key_label, &payload_bytes, context_json),
None => SignPayloadResult {
signature: None,
error: Some("No signing provider configured".to_string()),
approval_pending: false,
},
}
}
/// Get the sign count for this execution.
pub fn sign_count(&self) -> u32 {
self.sign_count
}
/// Get collected logs after execution. /// Get collected logs after execution.
pub fn take_logs(&mut self) -> Vec<LogEntry> { pub fn take_logs(&mut self) -> Vec<LogEntry> {
std::mem::take(&mut self.logs) std::mem::take(&mut self.logs)
@@ -603,4 +690,107 @@ mod tests {
let state = HostState::new_with_user(Capabilities::default(), "user123"); let state = HostState::new_with_user(Capabilities::default(), "user123");
assert_eq!(state.user_id(), Some("user123")); assert_eq!(state.user_id(), Some("user123"));
} }
#[test]
fn test_sign_payload_no_capability() {
let mut state = HostState::minimal();
let result = state.sign_payload("any-key", "AAAA", "{}");
assert!(result.error.is_some());
assert!(result.error.unwrap().contains("not granted"));
}
#[test]
fn test_sign_payload_label_not_allowed() {
let capabilities = Capabilities {
signing: Some(crate::tools::wasm::capabilities::SigningCapability {
allowed_key_labels: vec!["allowed-key".to_string()],
max_signs_per_execution: 5,
signer: None,
}),
..Default::default()
};
let mut state = HostState::new(capabilities);
let result = state.sign_payload("forbidden-key", "AAAA", "{}");
assert!(result.error.is_some());
assert!(result.error.unwrap().contains("not in allowed list"));
}
#[test]
fn test_sign_payload_rate_limit() {
let capabilities = Capabilities {
signing: Some(crate::tools::wasm::capabilities::SigningCapability {
allowed_key_labels: vec!["key".to_string()],
max_signs_per_execution: 2,
signer: None,
}),
..Default::default()
};
let mut state = HostState::new(capabilities);
// First two should hit "no signer" (not rate limit)
let r1 = state.sign_payload("key", "AAAA", "{}");
assert!(r1.error.as_deref().unwrap().contains("No signing provider"));
let r2 = state.sign_payload("key", "AAAA", "{}");
assert!(r2.error.as_deref().unwrap().contains("No signing provider"));
// Third should hit rate limit
let r3 = state.sign_payload("key", "AAAA", "{}");
assert!(r3.error.as_deref().unwrap().contains("limit exceeded"));
}
#[test]
fn test_sign_payload_invalid_base64() {
let capabilities = Capabilities {
signing: Some(crate::tools::wasm::capabilities::SigningCapability {
allowed_key_labels: vec!["key".to_string()],
max_signs_per_execution: 5,
signer: Some(Arc::new(MockSigner)),
}),
..Default::default()
};
let mut state = HostState::new(capabilities);
let result = state.sign_payload("key", "not-valid-base64!!!", "{}");
assert!(result.error.is_some());
assert!(result.error.unwrap().contains("Invalid base64"));
}
#[test]
fn test_sign_payload_with_mock_signer() {
let capabilities = Capabilities {
signing: Some(crate::tools::wasm::capabilities::SigningCapability {
allowed_key_labels: vec!["test-key".to_string()],
max_signs_per_execution: 5,
signer: Some(Arc::new(MockSigner)),
}),
..Default::default()
};
let mut state = HostState::new(capabilities);
// Encode some payload as base64
let payload =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"sign this");
let result = state.sign_payload("test-key", &payload, "{}");
assert!(result.signature.is_some());
assert!(result.error.is_none());
assert!(!result.approval_pending);
assert_eq!(result.signature.unwrap(), "mock-signature");
}
struct MockSigner;
impl crate::tools::wasm::capabilities::PayloadSigner for MockSigner {
fn sign_payload(
&self,
_key_label: &str,
_payload: &[u8],
_context_json: &str,
) -> crate::tools::wasm::capabilities::SignPayloadResult {
crate::tools::wasm::capabilities::SignPayloadResult {
signature: Some("mock-signature".to_string()),
error: None,
approval_pending: false,
}
}
}
} }
+4 -3
View File
@@ -98,8 +98,9 @@ pub use wrapper::WasmToolWrapper;
// Capabilities (V2) // Capabilities (V2)
pub use capabilities::{ pub use capabilities::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability, Capabilities, EndpointPattern, HttpCapability, PayloadSigner, RateLimitConfig,
ToolInvokeCapability, WorkspaceCapability, WorkspaceReader, SecretsCapability, SignPayloadResult, SigningCapability, ToolInvokeCapability,
WorkspaceCapability, WorkspaceReader,
}; };
// Security components (V2) // Security components (V2)
@@ -120,5 +121,5 @@ pub use loader::{DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, dis
// Capabilities schema (for parsing *.capabilities.json files) // Capabilities schema (for parsing *.capabilities.json files)
pub use capabilities_schema::{ pub use capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema, RateLimitSchema, AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema, RateLimitSchema,
ValidationEndpointSchema, SigningCapabilitySchema, ValidationEndpointSchema,
}; };
+20
View File
@@ -245,6 +245,26 @@ impl WasmToolWrapper {
WasmError::ConfigError(format!("Failed to add workspace-read function: {}", e)) WasmError::ConfigError(format!("Failed to add workspace-read function: {}", e))
})?; })?;
// host.sign-payload(key-label, payload, context-json) -> sign-result
// Returns a record { signature: option<string>, error: option<string>, approval-pending: bool }
linker
.root()
.func_wrap(
"sign-payload",
|mut ctx: wasmtime::StoreContextMut<'_, StoreData>,
(key_label, payload, context_json): (String, String, String)|
-> anyhow::Result<(Option<String>, Option<String>, bool)> {
let result =
ctx.data_mut()
.host_state
.sign_payload(&key_label, &payload, &context_json);
Ok((result.signature, result.error, result.approval_pending))
},
)
.map_err(|e| {
WasmError::ConfigError(format!("Failed to add sign-payload function: {}", e))
})?;
Ok(()) Ok(())
} }
} }
+26
View File
@@ -98,6 +98,32 @@ interface host {
/// ///
/// Returns true if the secret exists and is accessible to this tool. /// Returns true if the secret exists and is accessible to this tool.
secret-exists: func(name: string) -> bool; secret-exists: func(name: string) -> bool;
// ==================== Signing Capability ====================
/// Result of a payload signing request.
record sign-result {
/// Base64-encoded signature bytes (set on success).
signature: option<string>,
/// Error message (set on failure).
error: option<string>,
/// True if user approval is needed before signing can proceed.
approval-pending: bool,
}
/// Sign a payload using a NEAR key managed by the host (if capability granted).
///
/// Security:
/// - Private keys NEVER enter WASM memory; signing happens in host code only
/// - Only key labels declared in the tool's signing capability can be used
/// - Rate-limited per execution
/// - Subject to the host's transaction policy (may require user approval)
///
/// The payload should be base64-encoded bytes to sign.
/// The context-json is optional metadata about what's being signed (for policy display).
///
/// Returns sign-result with either signature or error/approval-pending.
sign-payload: func(key-label: string, payload: string, context-json: string) -> sign-result;
} }
/// Tool interface that sandboxed tools must implement. /// Tool interface that sandboxed tools must implement.