Compare commits

..
Author SHA1 Message Date
Illia PolosukhinandClaude Opus 4.6 810ba58fd2 feat: Add Okta SSO WASM tool for profile management and app catalog
Sandboxed WASM tool that integrates with Okta's Management API and
MyAccount API. Supports user profile CRUD, listing all SSO app
chiclets, searching apps by name, retrieving SSO launch links, and
fetching org info. Uses OAuth2 with PKCE against the Org Authorization
Server, with the domain stored in workspace at okta/domain.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-09 23:31:29 -08:00
31 changed files with 667 additions and 5497 deletions
Generated
-169
View File
@@ -170,18 +170,6 @@ 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"
@@ -415,12 +403,6 @@ 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"
@@ -445,15 +427,6 @@ 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"
@@ -572,15 +545,6 @@ 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"
@@ -832,12 +796,6 @@ 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"
@@ -1157,33 +1115,6 @@ 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"
@@ -1269,16 +1200,6 @@ 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"
@@ -1433,31 +1354,6 @@ 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"
@@ -1601,12 +1497,6 @@ 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"
@@ -2291,14 +2181,11 @@ 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",
@@ -2306,7 +2193,6 @@ 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",
@@ -2348,7 +2234,6 @@ dependencies = [
"wasmtime", "wasmtime",
"wasmtime-wasi", "wasmtime-wasi",
"zbus", "zbus",
"zeroize",
] ]
[[package]] [[package]]
@@ -2889,17 +2774,6 @@ 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"
@@ -2970,16 +2844,6 @@ 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"
@@ -4061,15 +3925,6 @@ 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"
@@ -4107,16 +3962,6 @@ 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"
@@ -6101,20 +5946,6 @@ 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,13 +90,6 @@ 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,7 +21,6 @@ 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;
@@ -65,7 +64,6 @@ 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,7 +318,6 @@ 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
@@ -1,745 +0,0 @@
//! 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,14 +10,12 @@
//! - 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;
@@ -80,10 +78,6 @@ 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,9 +39,6 @@ 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
@@ -1,181 +0,0 @@
//! 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
@@ -1,58 +0,0 @@
//! 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
@@ -1,183 +0,0 @@
//! 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
@@ -1,994 +0,0 @@
//! 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
@@ -1,917 +0,0 @@
//! 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
@@ -1,297 +0,0 @@
//! 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
@@ -1,243 +0,0 @@
//! 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
@@ -1,208 +0,0 @@
//! 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
@@ -1,445 +0,0 @@
//! 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
@@ -1,563 +0,0 @@
//! 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,7 +48,6 @@ 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;
+1 -19
View File
@@ -17,14 +17,12 @@ use ironclaw::{
web::log_layer::{LogBroadcaster, WebLogLayer}, web::log_layer::{LogBroadcaster, WebLogLayer},
}, },
cli::{ cli::{
Cli, Command, run_key_command, run_mcp_command, run_memory_command, run_status_command, Cli, Command, run_mcp_command, run_memory_command, run_status_command, run_tool_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},
@@ -54,16 +52,6 @@ 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())
@@ -330,11 +318,6 @@ 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)
@@ -763,7 +746,6 @@ 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,14 +511,6 @@ 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).
@@ -704,39 +696,6 @@ 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,8 +32,6 @@ 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 {
@@ -73,21 +71,6 @@ 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.
@@ -318,68 +301,6 @@ 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 {
+1 -95
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,
SigningCapability, ToolInvokeCapability, WorkspaceCapability, ToolInvokeCapability, WorkspaceCapability,
}; };
/// Root schema for a capabilities JSON file. /// Root schema for a capabilities JSON file.
@@ -57,10 +57,6 @@ 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)]
@@ -110,14 +106,6 @@ 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
} }
} }
@@ -330,32 +318,6 @@ 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 {
@@ -792,60 +754,4 @@ 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,7 +67,6 @@ 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>,
@@ -83,8 +82,6 @@ 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 {
@@ -96,7 +93,6 @@ 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()
} }
} }
@@ -112,7 +108,6 @@ 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,
} }
} }
@@ -126,7 +121,6 @@ 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,
} }
} }
@@ -229,87 +223,6 @@ 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)
@@ -690,107 +603,4 @@ 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,
}
}
}
} }
+3 -4
View File
@@ -98,9 +98,8 @@ pub use wrapper::WasmToolWrapper;
// Capabilities (V2) // Capabilities (V2)
pub use capabilities::{ pub use capabilities::{
Capabilities, EndpointPattern, HttpCapability, PayloadSigner, RateLimitConfig, Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
SecretsCapability, SignPayloadResult, SigningCapability, ToolInvokeCapability, ToolInvokeCapability, WorkspaceCapability, WorkspaceReader,
WorkspaceCapability, WorkspaceReader,
}; };
// Security components (V2) // Security components (V2)
@@ -121,5 +120,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,
SigningCapabilitySchema, ValidationEndpointSchema, ValidationEndpointSchema,
}; };
-20
View File
@@ -245,26 +245,6 @@ 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(())
} }
} }
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "okta-tool"
version = "0.1.0"
edition = "2021"
description = "Okta SSO tool for IronClaw (WASM component) — user profile, app catalog, and SSO launch links"
license = "MIT OR Apache-2.0"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "=0.36"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
@@ -0,0 +1,93 @@
{
"http": {
"allowlist": [
{
"host": "*.okta.com",
"path_prefix": "/api/v1/",
"methods": ["GET", "POST", "PUT"]
},
{
"host": "*.okta.com",
"path_prefix": "/idp/myaccount/",
"methods": ["GET", "PUT"]
},
{
"host": "*.okta.com",
"path_prefix": "/oauth2/v1/",
"methods": ["POST"]
},
{
"host": "*.oktapreview.com",
"path_prefix": "/api/v1/",
"methods": ["GET", "POST", "PUT"]
},
{
"host": "*.oktapreview.com",
"path_prefix": "/idp/myaccount/",
"methods": ["GET", "PUT"]
},
{
"host": "*.oktapreview.com",
"path_prefix": "/oauth2/v1/",
"methods": ["POST"]
},
{
"host": "*.okta-emea.com",
"path_prefix": "/api/v1/",
"methods": ["GET", "POST", "PUT"]
},
{
"host": "*.okta-emea.com",
"path_prefix": "/idp/myaccount/",
"methods": ["GET", "PUT"]
},
{
"host": "*.okta-emea.com",
"path_prefix": "/oauth2/v1/",
"methods": ["POST"]
}
],
"credentials": {
"okta_oauth_token": {
"secret_name": "okta_oauth_token",
"location": { "type": "bearer" },
"host_patterns": ["*.okta.com", "*.oktapreview.com", "*.okta-emea.com"]
}
},
"rate_limit": {
"requests_per_minute": 30,
"requests_per_hour": 500
},
"timeout_secs": 30
},
"workspace": {
"allowed_prefixes": ["okta/"]
},
"secrets": {
"allowed_names": ["okta_oauth_token"]
},
"auth": {
"secret_name": "okta_oauth_token",
"display_name": "Okta",
"oauth": {
"authorization_url": "https://{okta_domain}/oauth2/v1/authorize",
"token_url": "https://{okta_domain}/oauth2/v1/token",
"client_id_env": "OKTA_OAUTH_CLIENT_ID",
"client_secret_env": "OKTA_OAUTH_CLIENT_SECRET",
"scopes": [
"openid",
"profile",
"email",
"offline_access",
"okta.users.read.self",
"okta.users.manage.self",
"okta.apps.read"
],
"use_pkce": true
},
"instructions": "1. In your Okta Admin Console, go to Applications > Create App Integration\n2. Select 'OIDC - OpenID Connect', then 'Web Application'\n3. Set Sign-in redirect URI to http://localhost:9876/callback (through :9886)\n4. Under Okta API Scopes, grant: okta.users.read.self, okta.users.manage.self, okta.apps.read\n5. Copy the Client ID and Client Secret\n6. IMPORTANT: You must use the Org Authorization Server (not a custom one)\n7. Store your Okta domain in workspace at 'okta/domain' (e.g., 'mycompany.okta.com')\n8. For custom domains, add them to okta-tool.capabilities.json allowlist",
"setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/",
"token_hint": "OAuth2 access token (JWT)",
"env_var": "OKTA_OAUTH_TOKEN"
}
}
+281
View File
@@ -0,0 +1,281 @@
use crate::near::agent::host;
use crate::types::*;
const WORKSPACE_DOMAIN_PATH: &str = "okta/domain";
/// Read the configured Okta domain from workspace, or return a helpful error.
fn get_domain() -> Result<String, String> {
host::workspace_read(WORKSPACE_DOMAIN_PATH).ok_or_else(|| {
"Okta domain not configured. Write your Okta domain to workspace path 'okta/domain' \
using the memory_write tool (e.g., memory_write with path='okta/domain' and \
content='mycompany.okta.com')."
.to_string()
})
}
/// Build the base URL for the Okta Management API.
fn management_base(domain: &str) -> String {
format!("https://{}/api/v1", domain)
}
/// Make an Okta API call.
fn okta_api_call(method: &str, url: &str, body: Option<&str>) -> Result<String, String> {
let headers = if body.is_some() {
r#"{"Content-Type": "application/json", "Accept": "application/json"}"#
} else {
r#"{"Accept": "application/json"}"#
};
let body_bytes = body.map(|b| b.as_bytes().to_vec());
host::log(
host::LogLevel::Debug,
&format!("Okta API: {} {}", method, url),
);
let response = host::http_request(method, url, headers, body_bytes.as_deref())?;
if response.status < 200 || response.status >= 300 {
let body_text = String::from_utf8_lossy(&response.body);
// Try to extract Okta's error summary for a better message.
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body_text) {
if let Some(summary) = parsed["errorSummary"].as_str() {
return Err(format!("Okta API error ({}): {}", response.status, summary));
}
}
return Err(format!(
"Okta API returned status {}: {}",
response.status, body_text
));
}
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e))
}
// ---------------------------------------------------------------------------
// Action implementations
// ---------------------------------------------------------------------------
/// GET /api/v1/users/me
pub fn get_profile() -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me", management_base(&domain));
let response = okta_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let profile = parse_user_profile(&parsed)?;
serde_json::to_string(&profile).map_err(|e| e.to_string())
}
/// POST /api/v1/users/me (partial update via Management API)
pub fn update_profile(fields: &serde_json::Value) -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me", management_base(&domain));
// Wrap fields under "profile" key for Okta's expected format.
let payload = serde_json::json!({ "profile": fields });
let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?;
let response = okta_api_call("POST", &url, Some(&body))?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let profile = parse_user_profile(&parsed)?;
let result = UpdateProfileResult {
success: true,
profile,
};
serde_json::to_string(&result).map_err(|e| e.to_string())
}
/// GET /api/v1/users/me/appLinks
pub fn list_apps() -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me/appLinks", management_base(&domain));
let response = okta_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let apps = parse_app_links(&parsed)?;
let count = apps.len();
let result = ListAppsResult { apps, count };
serde_json::to_string(&result).map_err(|e| e.to_string())
}
/// Search apps by label (case-insensitive substring match).
pub fn search_apps(query: &str) -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me/appLinks", management_base(&domain));
let response = okta_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let all_apps = parse_app_links(&parsed)?;
let query_lower = query.to_lowercase();
let apps: Vec<AppLink> = all_apps
.into_iter()
.filter(|app| {
app.label.to_lowercase().contains(&query_lower)
|| app.app_name.to_lowercase().contains(&query_lower)
})
.collect();
let count = apps.len();
let result = ListAppsResult { apps, count };
serde_json::to_string(&result).map_err(|e| e.to_string())
}
/// Find an app by ID or label and return its SSO launch link.
pub fn get_app_sso_link(app: &str) -> Result<String, String> {
let domain = get_domain()?;
let url = format!("{}/users/me/appLinks", management_base(&domain));
let response = okta_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let all_apps = parse_app_links(&parsed)?;
let app_lower = app.to_lowercase();
// Try exact ID match first, then case-insensitive label match.
let found = all_apps
.iter()
.find(|a| a.app_instance_id == app)
.or_else(|| {
all_apps
.iter()
.find(|a| a.label.to_lowercase() == app_lower)
})
.or_else(|| {
all_apps
.iter()
.find(|a| a.label.to_lowercase().contains(&app_lower))
});
match found {
Some(app_link) => {
let result = AppSsoLinkResult {
label: app_link.label.clone(),
link_url: app_link.link_url.clone(),
app_instance_id: app_link.app_instance_id.clone(),
app_name: app_link.app_name.clone(),
};
serde_json::to_string(&result).map_err(|e| e.to_string())
}
None => {
let available: Vec<String> = all_apps.iter().map(|a| a.label.clone()).collect();
Err(format!(
"App '{}' not found. Available apps: {}",
app,
available.join(", ")
))
}
}
}
/// GET /idp/myaccount/organization
pub fn get_org_info() -> Result<String, String> {
let domain = get_domain()?;
let url = format!("https://{}/idp/myaccount/organization", domain);
// MyAccount API requires the okta-version header.
let response = okta_api_call_with_headers(
"GET",
&url,
None,
r#"{"Accept": "application/json; okta-version=1.0.0"}"#,
)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
let result = OrgInfo {
id: parsed["id"].as_str().unwrap_or("").to_string(),
name: parsed["name"].as_str().unwrap_or("").to_string(),
subdomain: parsed["subdomain"].as_str().map(|s| s.to_string()),
website: parsed["website"].as_str().map(|s| s.to_string()),
support_phone: parsed["supportPhoneNumber"].as_str().map(|s| s.to_string()),
technical_contact: parsed["technicalContact"].as_str().map(|s| s.to_string()),
};
serde_json::to_string(&result).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Like `okta_api_call` but with custom headers (for MyAccount API versioning).
fn okta_api_call_with_headers(
method: &str,
url: &str,
body: Option<&str>,
headers: &str,
) -> Result<String, String> {
let body_bytes = body.map(|b| b.as_bytes().to_vec());
host::log(
host::LogLevel::Debug,
&format!("Okta API: {} {}", method, url),
);
let response = host::http_request(method, url, headers, body_bytes.as_deref())?;
if response.status < 200 || response.status >= 300 {
let body_text = String::from_utf8_lossy(&response.body);
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body_text) {
if let Some(summary) = parsed["errorSummary"].as_str() {
return Err(format!("Okta API error ({}): {}", response.status, summary));
}
}
return Err(format!(
"Okta API returned status {}: {}",
response.status, body_text
));
}
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e))
}
fn parse_user_profile(v: &serde_json::Value) -> Result<UserProfile, String> {
let p = &v["profile"];
Ok(UserProfile {
id: v["id"].as_str().unwrap_or("").to_string(),
status: v["status"].as_str().unwrap_or("").to_string(),
first_name: p["firstName"].as_str().unwrap_or("").to_string(),
last_name: p["lastName"].as_str().unwrap_or("").to_string(),
email: p["email"].as_str().unwrap_or("").to_string(),
login: p["login"].as_str().unwrap_or("").to_string(),
mobile_phone: p["mobilePhone"].as_str().map(|s| s.to_string()),
display_name: p["displayName"].as_str().map(|s| s.to_string()),
nick_name: p["nickName"].as_str().map(|s| s.to_string()),
title: p["title"].as_str().map(|s| s.to_string()),
department: p["department"].as_str().map(|s| s.to_string()),
organization: p["organization"].as_str().map(|s| s.to_string()),
timezone: p["timezone"].as_str().map(|s| s.to_string()),
locale: p["locale"].as_str().map(|s| s.to_string()),
})
}
fn parse_app_links(v: &serde_json::Value) -> Result<Vec<AppLink>, String> {
let arr = v
.as_array()
.ok_or_else(|| "Expected array of app links from Okta".to_string())?;
Ok(arr
.iter()
.map(|a| AppLink {
app_instance_id: a["appInstanceId"].as_str().unwrap_or("").to_string(),
label: a["label"].as_str().unwrap_or("").to_string(),
link_url: a["linkUrl"].as_str().unwrap_or("").to_string(),
logo_url: a["logoUrl"].as_str().map(|s| s.to_string()),
app_name: a["appName"].as_str().unwrap_or("").to_string(),
hidden: a["hidden"].as_bool().unwrap_or(false),
})
.collect())
}
+148
View File
@@ -0,0 +1,148 @@
//! Okta WASM Tool for IronClaw.
//!
//! Provides user profile management, SSO app catalog browsing, and
//! launch links for all applications under Okta single sign-on.
//!
//! # Setup
//!
//! 1. Configure OAuth2 with PKCE (see capabilities.json instructions)
//! 2. Write your Okta domain to workspace: `memory_write(path="okta/domain", content="mycompany.okta.com")`
//! 3. All actions read the domain from workspace automatically
//!
//! # Capabilities Required
//!
//! - HTTP: `*.okta.com/api/v1/*`, `*.okta.com/idp/myaccount/*` (GET, POST, PUT)
//! - Secrets: `okta_oauth_token` (injected as Bearer token)
//! - Workspace: `okta/` prefix (read-only, for domain config)
//!
//! # Supported Actions
//!
//! - `get_profile`: Fetch the current user's profile
//! - `update_profile`: Update profile fields
//! - `list_apps`: List all SSO apps assigned to the user
//! - `search_apps`: Search apps by name
//! - `get_app_sso_link`: Get the SSO launch URL for a specific app
//! - `get_org_info`: Get organization details
mod api;
mod types;
use types::OktaAction;
wit_bindgen::generate!({
world: "sandboxed-tool",
path: "../../wit/tool.wit",
});
struct OktaTool;
impl exports::near::agent::tool::Guest for OktaTool {
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
match execute_inner(&req.params) {
Ok(result) => exports::near::agent::tool::Response {
output: Some(result),
error: None,
},
Err(e) => exports::near::agent::tool::Response {
output: None,
error: Some(e),
},
}
}
fn schema() -> String {
r#"{
"type": "object",
"required": ["action"],
"oneOf": [
{
"properties": {
"action": { "const": "get_profile" }
},
"required": ["action"]
},
{
"properties": {
"action": { "const": "update_profile" },
"fields": {
"type": "object",
"description": "Profile fields to update. Common: firstName, lastName, email, mobilePhone, displayName, nickName, title, department, organization"
}
},
"required": ["action", "fields"]
},
{
"properties": {
"action": { "const": "list_apps" }
},
"required": ["action"]
},
{
"properties": {
"action": { "const": "search_apps" },
"query": {
"type": "string",
"description": "Case-insensitive search query to match against app labels and names"
}
},
"required": ["action", "query"]
},
{
"properties": {
"action": { "const": "get_app_sso_link" },
"app": {
"type": "string",
"description": "App instance ID (e.g., '0oa1xxx') or app label (e.g., 'Google Workspace')"
}
},
"required": ["action", "app"]
},
{
"properties": {
"action": { "const": "get_org_info" }
},
"required": ["action"]
}
]
}"#
.to_string()
}
fn description() -> String {
"Okta SSO tool for managing your profile and accessing all applications under \
single sign-on. Supports viewing/updating your Okta profile, listing all assigned \
SSO apps, searching apps by name, and getting direct SSO launch links. Requires \
Okta domain in workspace at 'okta/domain' and an OAuth token with \
okta.users.read.self, okta.users.manage.self, and okta.apps.read scopes."
.to_string()
}
}
fn execute_inner(params: &str) -> Result<String, String> {
if !crate::near::agent::host::secret_exists("okta_oauth_token") {
return Err(
"Okta OAuth token not configured. Please add the 'okta_oauth_token' secret \
via OAuth2 flow or set the OKTA_OAUTH_TOKEN environment variable."
.to_string(),
);
}
let action: OktaAction =
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?;
crate::near::agent::host::log(
crate::near::agent::host::LogLevel::Info,
&format!("Executing Okta action: {:?}", action),
);
match action {
OktaAction::GetProfile => api::get_profile(),
OktaAction::UpdateProfile { fields } => api::update_profile(&fields),
OktaAction::ListApps => api::list_apps(),
OktaAction::SearchApps { query } => api::search_apps(&query),
OktaAction::GetAppSsoLink { app } => api::get_app_sso_link(&app),
OktaAction::GetOrgInfo => api::get_org_info(),
}
}
export!(OktaTool);
+119
View File
@@ -0,0 +1,119 @@
use serde::{Deserialize, Serialize};
/// Input parameters for the Okta tool.
///
/// Actions map to Okta Management API (/api/v1/) and MyAccount API (/idp/myaccount/).
/// The tool reads the Okta domain from workspace at `okta/domain`.
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum OktaAction {
/// Get the current user's Okta profile.
GetProfile,
/// Update fields on the current user's profile (partial update).
UpdateProfile {
/// Key-value pairs of profile fields to update.
/// Common fields: firstName, lastName, email, mobilePhone, displayName,
/// nickName, title, department, organization.
fields: serde_json::Value,
},
/// List all SSO applications assigned to the current user.
ListApps,
/// Search assigned apps by name (case-insensitive substring match).
SearchApps {
/// Search query to match against app labels.
query: String,
},
/// Get the SSO launch link for a specific app by its instance ID or label.
GetAppSsoLink {
/// App instance ID (e.g., "0oa1xxx") or app label to search for.
app: String,
},
/// Get information about the Okta organization.
GetOrgInfo,
}
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
/// User profile from Okta.
#[derive(Debug, Serialize)]
pub struct UserProfile {
pub id: String,
pub status: String,
pub first_name: String,
pub last_name: String,
pub email: String,
pub login: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nick_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub department: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub organization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
}
/// Result of a profile update.
#[derive(Debug, Serialize)]
pub struct UpdateProfileResult {
pub success: bool,
pub profile: UserProfile,
}
/// An SSO app link (chiclet) assigned to the user.
#[derive(Debug, Serialize)]
pub struct AppLink {
pub app_instance_id: String,
pub label: String,
pub link_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_url: Option<String>,
pub app_name: String,
pub hidden: bool,
}
/// Result of listing or searching apps.
#[derive(Debug, Serialize)]
pub struct ListAppsResult {
pub apps: Vec<AppLink>,
pub count: usize,
}
/// SSO launch link for a specific app.
#[derive(Debug, Serialize)]
pub struct AppSsoLinkResult {
pub label: String,
pub link_url: String,
pub app_instance_id: String,
pub app_name: String,
}
/// Okta organization info.
#[derive(Debug, Serialize)]
pub struct OrgInfo {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub subdomain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub support_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub technical_contact: Option<String>,
}
-26
View File
@@ -98,32 +98,6 @@ 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.