mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 08:59:31 +00:00
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:
co-authored by
Claude Opus 4.6
parent
a35db4d32d
commit
2e62d71567
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user