feat: Add NEAR key management with transaction signing and policy engine

Implements hybrid-custody NEAR key management where the agent holds scoped
function-call keys for routine operations while high-value operations require
explicit user approval through the existing channel approval flow.

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

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

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-09 16:24:47 -08:00
co-authored by Claude Opus 4.6
parent a35db4d32d
commit 2e62d71567
26 changed files with 5497 additions and 5 deletions
+79
View File
@@ -32,6 +32,8 @@ pub struct Capabilities {
pub tool_invoke: Option<ToolInvokeCapability>,
/// Check if secrets exist.
pub secrets: Option<SecretsCapability>,
/// Sign payloads using managed NEAR keys.
pub signing: Option<SigningCapability>,
}
impl Capabilities {
@@ -71,6 +73,21 @@ impl Capabilities {
});
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.
@@ -301,6 +318,68 @@ impl SecretsCapability {
}
}
/// Signing capability: allows WASM tools to request payload signatures from managed keys.
///
/// The private keys NEVER enter WASM memory. The host performs the signing and
/// returns only the signature bytes.
#[derive(Clone)]
pub struct SigningCapability {
/// Key labels this tool is allowed to use for signing.
pub allowed_key_labels: Vec<String>,
/// Maximum number of sign operations per execution.
pub max_signs_per_execution: u32,
/// Implementation that performs the actual signing.
/// Injected at runtime. None means signing will always return an error.
pub signer: Option<Arc<dyn PayloadSigner>>,
}
impl std::fmt::Debug for SigningCapability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SigningCapability")
.field("allowed_key_labels", &self.allowed_key_labels)
.field("max_signs_per_execution", &self.max_signs_per_execution)
.field("signer", &self.signer.is_some())
.finish()
}
}
impl SigningCapability {
/// Check if a key label is allowed.
pub fn is_label_allowed(&self, label: &str) -> bool {
self.allowed_key_labels.iter().any(|l| l == label)
}
}
/// Result of a payload signing operation.
#[derive(Debug, Clone)]
pub struct SignPayloadResult {
/// Base64-encoded signature (set on success).
pub signature: Option<String>,
/// Error message (set on failure).
pub error: Option<String>,
/// Whether user approval is needed before signing can proceed.
pub approval_pending: bool,
}
/// Trait for performing payload signing from the host boundary.
///
/// This is intentionally synchronous because WASM host functions run in a
/// blocking context. Implementations that need async should use
/// `Handle::block_on()` internally.
pub trait PayloadSigner: Send + Sync {
/// Sign a payload using the specified key.
///
/// The payload is raw bytes (decoded from the base64 the WASM tool sent).
/// Returns a `SignPayloadResult` which may contain a signature, an error,
/// or an approval-pending flag.
fn sign_payload(
&self,
key_label: &str,
payload: &[u8],
context_json: &str,
) -> SignPayloadResult;
}
/// Rate limiting configuration.
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
+95 -1
View File
@@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize};
use crate::secrets::{CredentialLocation, CredentialMapping};
use crate::tools::wasm::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WorkspaceCapability,
SigningCapability, ToolInvokeCapability, WorkspaceCapability,
};
/// Root schema for a capabilities JSON file.
@@ -57,6 +57,10 @@ pub struct CapabilitiesFile {
#[serde(default)]
pub workspace: Option<WorkspaceCapabilitySchema>,
/// Payload signing using managed NEAR keys.
#[serde(default)]
pub signing: Option<SigningCapabilitySchema>,
/// Authentication setup instructions.
/// Used by `ironclaw config` to guide users through auth setup.
#[serde(default)]
@@ -106,6 +110,14 @@ impl CapabilitiesFile {
});
}
if let Some(signing) = &self.signing {
caps.signing = Some(SigningCapability {
allowed_key_labels: signing.allowed_key_labels.clone(),
max_signs_per_execution: signing.max_signs_per_execution.unwrap_or(5),
signer: None, // Injected at runtime
});
}
caps
}
}
@@ -318,6 +330,32 @@ pub struct ToolInvokeCapabilitySchema {
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.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceCapabilitySchema {
@@ -754,4 +792,60 @@ mod tests {
assert!(auth.display_name.is_none());
assert!(auth.setup_url.is_none());
}
#[test]
fn test_parse_signing_capability() {
let json = r#"{
"signing": {
"allowed_key_labels": ["intents-signer", "trading-key"],
"max_signs_per_execution": 10
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let signing = caps.signing.unwrap();
assert_eq!(
signing.allowed_key_labels,
vec!["intents-signer", "trading-key"]
);
assert_eq!(signing.max_signs_per_execution, Some(10));
}
#[test]
fn test_parse_signing_defaults() {
let json = r#"{
"signing": {
"allowed_key_labels": ["default"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let signing = caps.signing.as_ref().unwrap();
assert_eq!(signing.max_signs_per_execution, None);
// Should default to 5 when converted
let runtime_caps = caps.to_capabilities();
let runtime_signing = runtime_caps.signing.unwrap();
assert_eq!(runtime_signing.max_signs_per_execution, 5);
assert!(runtime_signing.is_label_allowed("default"));
assert!(!runtime_signing.is_label_allowed("other"));
}
#[test]
fn test_signing_to_capabilities() {
let json = r#"{
"signing": {
"allowed_key_labels": ["signer-1"],
"max_signs_per_execution": 3
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
let signing = caps.signing.unwrap();
assert_eq!(signing.allowed_key_labels, vec!["signer-1"]);
assert_eq!(signing.max_signs_per_execution, 3);
assert!(signing.signer.is_none()); // Injected at runtime
}
}
+190
View File
@@ -67,6 +67,7 @@ pub struct LogEntry {
///
/// This is the "VMLogic" equivalent, it tracks all side effects and enforces limits.
/// 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 {
/// Collected log entries.
logs: Vec<LogEntry>,
@@ -82,6 +83,8 @@ pub struct HostState {
http_request_count: u32,
/// Tool invoke count for rate limiting within this execution.
tool_invoke_count: u32,
/// Signing request count for rate limiting within this execution.
sign_count: u32,
}
impl std::fmt::Debug for HostState {
@@ -93,6 +96,7 @@ impl std::fmt::Debug for HostState {
.field("user_id", &self.user_id)
.field("http_request_count", &self.http_request_count)
.field("tool_invoke_count", &self.tool_invoke_count)
.field("sign_count", &self.sign_count)
.finish()
}
}
@@ -108,6 +112,7 @@ impl HostState {
user_id: None,
http_request_count: 0,
tool_invoke_count: 0,
sign_count: 0,
}
}
@@ -121,6 +126,7 @@ impl HostState {
user_id: Some(user_id.into()),
http_request_count: 0,
tool_invoke_count: 0,
sign_count: 0,
}
}
@@ -223,6 +229,87 @@ impl HostState {
}
}
/// Sign a payload using a managed NEAR key.
///
/// Checks signing capability, key label allowlist, and rate limit.
/// Delegates actual signing to the `PayloadSigner` if all checks pass.
///
/// Private keys NEVER enter WASM memory. Only the signature is returned.
pub fn sign_payload(
&mut self,
key_label: &str,
payload_base64: &str,
context_json: &str,
) -> crate::tools::wasm::capabilities::SignPayloadResult {
use crate::tools::wasm::capabilities::SignPayloadResult;
let capability = match &self.capabilities.signing {
Some(cap) => cap,
None => {
return SignPayloadResult {
signature: None,
error: Some("Signing capability not granted".to_string()),
approval_pending: false,
};
}
};
// Check key label is allowed
if !capability.is_label_allowed(key_label) {
return SignPayloadResult {
signature: None,
error: Some(format!(
"Key label '{}' not in allowed list for this tool",
key_label
)),
approval_pending: false,
};
}
// Check rate limit
self.sign_count += 1;
if self.sign_count > capability.max_signs_per_execution {
return SignPayloadResult {
signature: None,
error: Some(format!(
"Sign limit exceeded ({} per execution)",
capability.max_signs_per_execution
)),
approval_pending: false,
};
}
// Decode base64 payload
let payload_bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
payload_base64,
) {
Ok(bytes) => bytes,
Err(e) => {
return SignPayloadResult {
signature: None,
error: Some(format!("Invalid base64 payload: {}", e)),
approval_pending: false,
};
}
};
// Delegate to signer implementation
match &capability.signer {
Some(signer) => signer.sign_payload(key_label, &payload_bytes, context_json),
None => SignPayloadResult {
signature: None,
error: Some("No signing provider configured".to_string()),
approval_pending: false,
},
}
}
/// Get the sign count for this execution.
pub fn sign_count(&self) -> u32 {
self.sign_count
}
/// Get collected logs after execution.
pub fn take_logs(&mut self) -> Vec<LogEntry> {
std::mem::take(&mut self.logs)
@@ -603,4 +690,107 @@ mod tests {
let state = HostState::new_with_user(Capabilities::default(), "user123");
assert_eq!(state.user_id(), Some("user123"));
}
#[test]
fn test_sign_payload_no_capability() {
let mut state = HostState::minimal();
let result = state.sign_payload("any-key", "AAAA", "{}");
assert!(result.error.is_some());
assert!(result.error.unwrap().contains("not granted"));
}
#[test]
fn test_sign_payload_label_not_allowed() {
let capabilities = Capabilities {
signing: Some(crate::tools::wasm::capabilities::SigningCapability {
allowed_key_labels: vec!["allowed-key".to_string()],
max_signs_per_execution: 5,
signer: None,
}),
..Default::default()
};
let mut state = HostState::new(capabilities);
let result = state.sign_payload("forbidden-key", "AAAA", "{}");
assert!(result.error.is_some());
assert!(result.error.unwrap().contains("not in allowed list"));
}
#[test]
fn test_sign_payload_rate_limit() {
let capabilities = Capabilities {
signing: Some(crate::tools::wasm::capabilities::SigningCapability {
allowed_key_labels: vec!["key".to_string()],
max_signs_per_execution: 2,
signer: None,
}),
..Default::default()
};
let mut state = HostState::new(capabilities);
// First two should hit "no signer" (not rate limit)
let r1 = state.sign_payload("key", "AAAA", "{}");
assert!(r1.error.as_deref().unwrap().contains("No signing provider"));
let r2 = state.sign_payload("key", "AAAA", "{}");
assert!(r2.error.as_deref().unwrap().contains("No signing provider"));
// Third should hit rate limit
let r3 = state.sign_payload("key", "AAAA", "{}");
assert!(r3.error.as_deref().unwrap().contains("limit exceeded"));
}
#[test]
fn test_sign_payload_invalid_base64() {
let capabilities = Capabilities {
signing: Some(crate::tools::wasm::capabilities::SigningCapability {
allowed_key_labels: vec!["key".to_string()],
max_signs_per_execution: 5,
signer: Some(Arc::new(MockSigner)),
}),
..Default::default()
};
let mut state = HostState::new(capabilities);
let result = state.sign_payload("key", "not-valid-base64!!!", "{}");
assert!(result.error.is_some());
assert!(result.error.unwrap().contains("Invalid base64"));
}
#[test]
fn test_sign_payload_with_mock_signer() {
let capabilities = Capabilities {
signing: Some(crate::tools::wasm::capabilities::SigningCapability {
allowed_key_labels: vec!["test-key".to_string()],
max_signs_per_execution: 5,
signer: Some(Arc::new(MockSigner)),
}),
..Default::default()
};
let mut state = HostState::new(capabilities);
// Encode some payload as base64
let payload =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"sign this");
let result = state.sign_payload("test-key", &payload, "{}");
assert!(result.signature.is_some());
assert!(result.error.is_none());
assert!(!result.approval_pending);
assert_eq!(result.signature.unwrap(), "mock-signature");
}
struct MockSigner;
impl crate::tools::wasm::capabilities::PayloadSigner for MockSigner {
fn sign_payload(
&self,
_key_label: &str,
_payload: &[u8],
_context_json: &str,
) -> crate::tools::wasm::capabilities::SignPayloadResult {
crate::tools::wasm::capabilities::SignPayloadResult {
signature: Some("mock-signature".to_string()),
error: None,
approval_pending: false,
}
}
}
}
+4 -3
View File
@@ -98,8 +98,9 @@ pub use wrapper::WasmToolWrapper;
// Capabilities (V2)
pub use capabilities::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WorkspaceCapability, WorkspaceReader,
Capabilities, EndpointPattern, HttpCapability, PayloadSigner, RateLimitConfig,
SecretsCapability, SignPayloadResult, SigningCapability, ToolInvokeCapability,
WorkspaceCapability, WorkspaceReader,
};
// Security components (V2)
@@ -120,5 +121,5 @@ pub use loader::{DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, dis
// Capabilities schema (for parsing *.capabilities.json files)
pub use capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema, RateLimitSchema,
ValidationEndpointSchema,
SigningCapabilitySchema, ValidationEndpointSchema,
};
+20
View File
@@ -245,6 +245,26 @@ impl WasmToolWrapper {
WasmError::ConfigError(format!("Failed to add workspace-read function: {}", e))
})?;
// 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(())
}
}