mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
Add WASM sandbox secure API extension
Extends the WASM sandbox with HTTP API capabilities, secrets management, tool aliasing, and leak detection. Key security principle: WASM never sees credentials, injection happens at host boundary. New modules: - secrets: AES-256-GCM encrypted storage with HKDF key derivation - leak_detector: Aho-Corasick + regex pattern matching for secret exfiltration - capabilities: Extended capability system (HTTP, ToolInvoke, Secrets) - allowlist: HTTP endpoint validation with glob patterns - credential_injector: Host-boundary credential injection - rate_limiter: Sliding window per-tool rate limiting - storage: WASM binary storage with BLAKE3 integrity verification Leak detection happens at two points: 1. Before HTTP request (prevents exfiltration via URL/headers/body) 2. After response (prevents exposure in outputs returned to WASM) Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
45bbfa026d
commit
32bfd24154
@@ -0,0 +1,586 @@
|
||||
//! Secret storage with PostgreSQL persistence.
|
||||
//!
|
||||
//! Provides CRUD operations for encrypted secrets. The store handles:
|
||||
//! - Encryption/decryption via SecretsCrypto
|
||||
//! - Expiration checking
|
||||
//! - Usage tracking
|
||||
//! - Access control (which secrets a tool can use)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use deadpool_postgres::Pool;
|
||||
use secrecy::ExposeSecret;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::secrets::crypto::SecretsCrypto;
|
||||
use crate::secrets::types::{CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef};
|
||||
|
||||
/// Trait for secret storage operations.
|
||||
///
|
||||
/// Allows for different implementations (PostgreSQL, in-memory for testing).
|
||||
#[async_trait]
|
||||
pub trait SecretsStore: Send + Sync {
|
||||
/// Store a new secret.
|
||||
async fn create(
|
||||
&self,
|
||||
user_id: &str,
|
||||
params: CreateSecretParams,
|
||||
) -> Result<Secret, SecretError>;
|
||||
|
||||
/// Get a secret by name (encrypted form).
|
||||
async fn get(&self, user_id: &str, name: &str) -> Result<Secret, SecretError>;
|
||||
|
||||
/// Get and decrypt a secret.
|
||||
async fn get_decrypted(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<DecryptedSecret, SecretError>;
|
||||
|
||||
/// Check if a secret exists.
|
||||
async fn exists(&self, user_id: &str, name: &str) -> Result<bool, SecretError>;
|
||||
|
||||
/// List all secret references for a user (no values).
|
||||
async fn list(&self, user_id: &str) -> Result<Vec<SecretRef>, SecretError>;
|
||||
|
||||
/// Delete a secret.
|
||||
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, SecretError>;
|
||||
|
||||
/// Update secret usage tracking.
|
||||
async fn record_usage(&self, secret_id: Uuid) -> Result<(), SecretError>;
|
||||
|
||||
/// Check if a secret is accessible by a tool (based on allowed_secrets).
|
||||
async fn is_accessible(
|
||||
&self,
|
||||
user_id: &str,
|
||||
secret_name: &str,
|
||||
allowed_secrets: &[String],
|
||||
) -> Result<bool, SecretError>;
|
||||
}
|
||||
|
||||
/// PostgreSQL implementation of SecretsStore.
|
||||
pub struct PostgresSecretsStore {
|
||||
pool: Pool,
|
||||
crypto: Arc<SecretsCrypto>,
|
||||
}
|
||||
|
||||
impl PostgresSecretsStore {
|
||||
/// Create a new store with the given database pool and crypto instance.
|
||||
pub fn new(pool: Pool, crypto: Arc<SecretsCrypto>) -> Self {
|
||||
Self { pool, crypto }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsStore for PostgresSecretsStore {
|
||||
async fn create(
|
||||
&self,
|
||||
user_id: &str,
|
||||
params: CreateSecretParams,
|
||||
) -> Result<Secret, SecretError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
// Encrypt the secret value
|
||||
let plaintext = params.value.expose_secret().as_bytes();
|
||||
let (encrypted_value, key_salt) = self.crypto.encrypt(plaintext)?;
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
|
||||
let row = client
|
||||
.query_one(
|
||||
r#"
|
||||
INSERT INTO secrets (id, user_id, name, encrypted_value, key_salt, provider, expires_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
ON CONFLICT (user_id, name) DO UPDATE SET
|
||||
encrypted_value = EXCLUDED.encrypted_value,
|
||||
key_salt = EXCLUDED.key_salt,
|
||||
provider = EXCLUDED.provider,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, name, encrypted_value, key_salt, provider, expires_at,
|
||||
last_used_at, usage_count, created_at, updated_at
|
||||
"#,
|
||||
&[
|
||||
&id,
|
||||
&user_id,
|
||||
¶ms.name,
|
||||
&encrypted_value,
|
||||
&key_salt,
|
||||
¶ms.provider,
|
||||
¶ms.expires_at,
|
||||
&now,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
Ok(row_to_secret(&row))
|
||||
}
|
||||
|
||||
async fn get(&self, user_id: &str, name: &str) -> Result<Secret, SecretError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
let row = client
|
||||
.query_opt(
|
||||
r#"
|
||||
SELECT id, user_id, name, encrypted_value, key_salt, provider, expires_at,
|
||||
last_used_at, usage_count, created_at, updated_at
|
||||
FROM secrets
|
||||
WHERE user_id = $1 AND name = $2
|
||||
"#,
|
||||
&[&user_id, &name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let secret = row_to_secret(&r);
|
||||
|
||||
// Check expiration
|
||||
if let Some(expires_at) = secret.expires_at {
|
||||
if expires_at < Utc::now() {
|
||||
return Err(SecretError::Expired);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(secret)
|
||||
}
|
||||
None => Err(SecretError::NotFound(name.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_decrypted(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<DecryptedSecret, SecretError> {
|
||||
let secret = self.get(user_id, name).await?;
|
||||
self.crypto
|
||||
.decrypt(&secret.encrypted_value, &secret.key_salt)
|
||||
}
|
||||
|
||||
async fn exists(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
let row = client
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM secrets WHERE user_id = $1 AND name = $2)",
|
||||
&[&user_id, &name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
Ok(row.get(0))
|
||||
}
|
||||
|
||||
async fn list(&self, user_id: &str) -> Result<Vec<SecretRef>, SecretError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT name, provider FROM secrets WHERE user_id = $1 ORDER BY name",
|
||||
&[&user_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| SecretRef {
|
||||
name: r.get(0),
|
||||
provider: r.get(1),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
let result = client
|
||||
.execute(
|
||||
"DELETE FROM secrets WHERE user_id = $1 AND name = $2",
|
||||
&[&user_id, &name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
Ok(result > 0)
|
||||
}
|
||||
|
||||
async fn record_usage(&self, secret_id: Uuid) -> Result<(), SecretError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
client
|
||||
.execute(
|
||||
r#"
|
||||
UPDATE secrets
|
||||
SET last_used_at = NOW(), usage_count = usage_count + 1
|
||||
WHERE id = $1
|
||||
"#,
|
||||
&[&secret_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_accessible(
|
||||
&self,
|
||||
user_id: &str,
|
||||
secret_name: &str,
|
||||
allowed_secrets: &[String],
|
||||
) -> Result<bool, SecretError> {
|
||||
// First check if the secret exists
|
||||
if !self.exists(user_id, secret_name).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check if secret is in the allowed list
|
||||
// Supports glob patterns: "openai_*" matches "openai_api_key"
|
||||
for pattern in allowed_secrets {
|
||||
if pattern == secret_name {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Simple glob: * matches any suffix
|
||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
if secret_name.starts_with(prefix) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_secret(row: &tokio_postgres::Row) -> Secret {
|
||||
Secret {
|
||||
id: row.get("id"),
|
||||
user_id: row.get("user_id"),
|
||||
name: row.get("name"),
|
||||
encrypted_value: row.get("encrypted_value"),
|
||||
key_salt: row.get("key_salt"),
|
||||
provider: row.get("provider"),
|
||||
expires_at: row.get("expires_at"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
usage_count: row.get("usage_count"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory implementation for testing.
|
||||
#[cfg(test)]
|
||||
pub mod testing {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use secrecy::ExposeSecret;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::secrets::crypto::SecretsCrypto;
|
||||
use crate::secrets::store::SecretsStore;
|
||||
use crate::secrets::types::{
|
||||
CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef,
|
||||
};
|
||||
|
||||
pub struct InMemorySecretsStore {
|
||||
secrets: RwLock<HashMap<(String, String), Secret>>,
|
||||
crypto: Arc<SecretsCrypto>,
|
||||
}
|
||||
|
||||
impl InMemorySecretsStore {
|
||||
pub fn new(crypto: Arc<SecretsCrypto>) -> Self {
|
||||
Self {
|
||||
secrets: RwLock::new(HashMap::new()),
|
||||
crypto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsStore for InMemorySecretsStore {
|
||||
async fn create(
|
||||
&self,
|
||||
user_id: &str,
|
||||
params: CreateSecretParams,
|
||||
) -> Result<Secret, SecretError> {
|
||||
let plaintext = params.value.expose_secret().as_bytes();
|
||||
let (encrypted_value, key_salt) = self.crypto.encrypt(plaintext)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let secret = Secret {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user_id.to_string(),
|
||||
name: params.name.clone(),
|
||||
encrypted_value,
|
||||
key_salt,
|
||||
provider: params.provider,
|
||||
expires_at: params.expires_at,
|
||||
last_used_at: None,
|
||||
usage_count: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
self.secrets
|
||||
.write()
|
||||
.await
|
||||
.insert((user_id.to_string(), params.name), secret.clone());
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
async fn get(&self, user_id: &str, name: &str) -> Result<Secret, SecretError> {
|
||||
self.secrets
|
||||
.read()
|
||||
.await
|
||||
.get(&(user_id.to_string(), name.to_string()))
|
||||
.cloned()
|
||||
.ok_or_else(|| SecretError::NotFound(name.to_string()))
|
||||
}
|
||||
|
||||
async fn get_decrypted(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<DecryptedSecret, SecretError> {
|
||||
let secret = self.get(user_id, name).await?;
|
||||
self.crypto
|
||||
.decrypt(&secret.encrypted_value, &secret.key_salt)
|
||||
}
|
||||
|
||||
async fn exists(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
|
||||
Ok(self
|
||||
.secrets
|
||||
.read()
|
||||
.await
|
||||
.contains_key(&(user_id.to_string(), name.to_string())))
|
||||
}
|
||||
|
||||
async fn list(&self, user_id: &str) -> Result<Vec<SecretRef>, SecretError> {
|
||||
Ok(self
|
||||
.secrets
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|((uid, _), _)| uid == user_id)
|
||||
.map(|((_, _), s)| SecretRef {
|
||||
name: s.name.clone(),
|
||||
provider: s.provider.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
|
||||
Ok(self
|
||||
.secrets
|
||||
.write()
|
||||
.await
|
||||
.remove(&(user_id.to_string(), name.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn record_usage(&self, _secret_id: Uuid) -> Result<(), SecretError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_accessible(
|
||||
&self,
|
||||
user_id: &str,
|
||||
secret_name: &str,
|
||||
allowed_secrets: &[String],
|
||||
) -> Result<bool, SecretError> {
|
||||
if !self.exists(user_id, secret_name).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
for pattern in allowed_secrets {
|
||||
if pattern == secret_name {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
if secret_name.starts_with(prefix) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::secrets::crypto::SecretsCrypto;
|
||||
use crate::secrets::store::SecretsStore;
|
||||
use crate::secrets::store::testing::InMemorySecretsStore;
|
||||
use crate::secrets::types::CreateSecretParams;
|
||||
|
||||
fn test_store() -> InMemorySecretsStore {
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
InMemorySecretsStore::new(crypto)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_get() {
|
||||
let store = test_store();
|
||||
let params = CreateSecretParams::new("api_key", "sk-test-12345");
|
||||
|
||||
store.create("user1", params).await.unwrap();
|
||||
|
||||
let decrypted = store.get_decrypted("user1", "api_key").await.unwrap();
|
||||
assert_eq!(decrypted.expose(), "sk-test-12345");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exists() {
|
||||
let store = test_store();
|
||||
let params = CreateSecretParams::new("my_secret", "value");
|
||||
|
||||
assert!(!store.exists("user1", "my_secret").await.unwrap());
|
||||
store.create("user1", params).await.unwrap();
|
||||
assert!(store.exists("user1", "my_secret").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete() {
|
||||
let store = test_store();
|
||||
let params = CreateSecretParams::new("to_delete", "value");
|
||||
|
||||
store.create("user1", params).await.unwrap();
|
||||
assert!(store.exists("user1", "to_delete").await.unwrap());
|
||||
|
||||
store.delete("user1", "to_delete").await.unwrap();
|
||||
assert!(!store.exists("user1", "to_delete").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list() {
|
||||
let store = test_store();
|
||||
|
||||
store
|
||||
.create("user1", CreateSecretParams::new("key1", "v1"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("key2", "v2").with_provider("openai"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create("user2", CreateSecretParams::new("key3", "v3"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = store.list("user1").await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_is_accessible() {
|
||||
let store = test_store();
|
||||
store
|
||||
.create("user1", CreateSecretParams::new("openai_key", "sk-test"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create("user1", CreateSecretParams::new("stripe_key", "sk-live"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Exact match
|
||||
let allowed = vec!["openai_key".to_string()];
|
||||
assert!(
|
||||
store
|
||||
.is_accessible("user1", "openai_key", &allowed)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.is_accessible("user1", "stripe_key", &allowed)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
// Glob pattern
|
||||
let allowed = vec!["openai_*".to_string()];
|
||||
assert!(
|
||||
store
|
||||
.is_accessible("user1", "openai_key", &allowed)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.is_accessible("user1", "stripe_key", &allowed)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_isolation() {
|
||||
let store = test_store();
|
||||
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("shared_name", "user1_value"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create(
|
||||
"user2",
|
||||
CreateSecretParams::new("shared_name", "user2_value"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let v1 = store.get_decrypted("user1", "shared_name").await.unwrap();
|
||||
let v2 = store.get_decrypted("user2", "shared_name").await.unwrap();
|
||||
|
||||
assert_eq!(v1.expose(), "user1_value");
|
||||
assert_eq!(v2.expose(), "user2_value");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user