mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +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,250 @@
|
||||
//! Cryptographic operations for secret storage.
|
||||
//!
|
||||
//! Uses AES-256-GCM for authenticated encryption with per-secret key derivation.
|
||||
//!
|
||||
//! # Key Derivation
|
||||
//!
|
||||
//! ```text
|
||||
//! master_key (from env) ─┬─► HKDF-SHA256 ─► derived_key (per secret)
|
||||
//! │
|
||||
//! per-secret salt ───────┘
|
||||
//! ```
|
||||
//!
|
||||
//! Each secret has its own randomly-generated salt, so even if two secrets
|
||||
//! have the same plaintext, they'll have different ciphertexts.
|
||||
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, KeyInit, Nonce,
|
||||
aead::{Aead, AeadCore, OsRng},
|
||||
};
|
||||
use hkdf::Hkdf;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::secrets::types::{DecryptedSecret, SecretError};
|
||||
|
||||
/// Size of the AES-256 key in bytes.
|
||||
const KEY_SIZE: usize = 32;
|
||||
|
||||
/// Size of the GCM nonce in bytes.
|
||||
const NONCE_SIZE: usize = 12;
|
||||
|
||||
/// Size of the per-secret salt for key derivation.
|
||||
const SALT_SIZE: usize = 32;
|
||||
|
||||
/// Size of the GCM authentication tag.
|
||||
const TAG_SIZE: usize = 16;
|
||||
|
||||
/// Cryptographic operations for secrets.
|
||||
///
|
||||
/// Holds the master key and provides encrypt/decrypt operations.
|
||||
/// The master key is kept in secure memory and zeroed on drop.
|
||||
pub struct SecretsCrypto {
|
||||
master_key: SecretString,
|
||||
}
|
||||
|
||||
impl SecretsCrypto {
|
||||
/// Create a new crypto instance from a master key.
|
||||
///
|
||||
/// The master key should be at least 32 bytes of high-entropy data,
|
||||
/// typically loaded from an environment variable or secure vault.
|
||||
pub fn new(master_key: SecretString) -> Result<Self, SecretError> {
|
||||
// Validate master key length
|
||||
if master_key.expose_secret().len() < KEY_SIZE {
|
||||
return Err(SecretError::InvalidMasterKey);
|
||||
}
|
||||
Ok(Self { master_key })
|
||||
}
|
||||
|
||||
/// Generate a random salt for a new secret.
|
||||
pub fn generate_salt() -> Vec<u8> {
|
||||
let mut salt = vec![0u8; SALT_SIZE];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut salt);
|
||||
salt
|
||||
}
|
||||
|
||||
/// Encrypt a secret value.
|
||||
///
|
||||
/// Returns (encrypted_value, salt) where:
|
||||
/// - encrypted_value = nonce || ciphertext || tag
|
||||
/// - salt = random bytes used for key derivation
|
||||
pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), SecretError> {
|
||||
let salt = Self::generate_salt();
|
||||
let derived_key = self.derive_key(&salt)?;
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&derived_key).map_err(|e| {
|
||||
SecretError::EncryptionFailed(format!("Failed to create cipher: {}", e))
|
||||
})?;
|
||||
|
||||
// Generate random nonce
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
|
||||
// Encrypt
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|e| SecretError::EncryptionFailed(format!("Encryption failed: {}", e)))?;
|
||||
|
||||
// Combine: nonce || ciphertext (which includes tag)
|
||||
let mut encrypted = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
|
||||
encrypted.extend_from_slice(&nonce);
|
||||
encrypted.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok((encrypted, salt))
|
||||
}
|
||||
|
||||
/// Decrypt a secret value.
|
||||
///
|
||||
/// Takes the encrypted_value (nonce || ciphertext || tag) and the salt
|
||||
/// that was used during encryption.
|
||||
pub fn decrypt(
|
||||
&self,
|
||||
encrypted_value: &[u8],
|
||||
salt: &[u8],
|
||||
) -> Result<DecryptedSecret, SecretError> {
|
||||
if encrypted_value.len() < NONCE_SIZE + TAG_SIZE {
|
||||
return Err(SecretError::DecryptionFailed(
|
||||
"Encrypted value too short".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let derived_key = self.derive_key(salt)?;
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&derived_key).map_err(|e| {
|
||||
SecretError::DecryptionFailed(format!("Failed to create cipher: {}", e))
|
||||
})?;
|
||||
|
||||
// Split: nonce || ciphertext
|
||||
let (nonce_bytes, ciphertext) = encrypted_value.split_at(NONCE_SIZE);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
// Decrypt
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| SecretError::DecryptionFailed(format!("Decryption failed: {}", e)))?;
|
||||
|
||||
DecryptedSecret::from_bytes(plaintext)
|
||||
}
|
||||
|
||||
/// Derive a per-secret key using HKDF-SHA256.
|
||||
fn derive_key(&self, salt: &[u8]) -> Result<[u8; KEY_SIZE], SecretError> {
|
||||
let master_bytes = self.master_key.expose_secret().as_bytes();
|
||||
|
||||
// HKDF extract + expand
|
||||
let hk = Hkdf::<Sha256>::new(Some(salt), master_bytes);
|
||||
|
||||
let mut derived = [0u8; KEY_SIZE];
|
||||
hk.expand(b"near-agent-secrets-v1", &mut derived)
|
||||
.map_err(|_| SecretError::EncryptionFailed("HKDF expansion failed".to_string()))?;
|
||||
|
||||
Ok(derived)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SecretsCrypto {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SecretsCrypto")
|
||||
.field("master_key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::secrets::crypto::SecretsCrypto;
|
||||
|
||||
fn test_crypto() -> SecretsCrypto {
|
||||
// 32-byte test key
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
SecretsCrypto::new(SecretString::from(key.to_string())).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = b"my_super_secret_api_key_12345";
|
||||
|
||||
let (encrypted, salt) = crypto.encrypt(plaintext).unwrap();
|
||||
|
||||
// Encrypted should be larger than plaintext (nonce + tag)
|
||||
assert!(encrypted.len() > plaintext.len());
|
||||
|
||||
let decrypted = crypto.decrypt(&encrypted, &salt).unwrap();
|
||||
assert_eq!(decrypted.expose().as_bytes(), plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_salts_different_ciphertext() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = b"same_secret";
|
||||
|
||||
let (encrypted1, salt1) = crypto.encrypt(plaintext).unwrap();
|
||||
let (encrypted2, salt2) = crypto.encrypt(plaintext).unwrap();
|
||||
|
||||
// Same plaintext, different salts = different ciphertext
|
||||
assert_ne!(salt1, salt2);
|
||||
assert_ne!(encrypted1, encrypted2);
|
||||
|
||||
// But both decrypt to the same value
|
||||
let decrypted1 = crypto.decrypt(&encrypted1, &salt1).unwrap();
|
||||
let decrypted2 = crypto.decrypt(&encrypted2, &salt2).unwrap();
|
||||
assert_eq!(decrypted1.expose(), decrypted2.expose());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_salt_fails() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = b"secret";
|
||||
|
||||
let (encrypted, _salt) = crypto.encrypt(plaintext).unwrap();
|
||||
let wrong_salt = SecretsCrypto::generate_salt();
|
||||
|
||||
let result = crypto.decrypt(&encrypted, &wrong_salt);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_ciphertext_fails() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = b"secret";
|
||||
|
||||
let (mut encrypted, salt) = crypto.encrypt(plaintext).unwrap();
|
||||
|
||||
// Tamper with the ciphertext
|
||||
if let Some(byte) = encrypted.last_mut() {
|
||||
*byte ^= 0xFF;
|
||||
}
|
||||
|
||||
let result = crypto.decrypt(&encrypted, &salt);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_key_too_short() {
|
||||
let short_key = "tooshort";
|
||||
let result = SecretsCrypto::new(SecretString::from(short_key.to_string()));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_plaintext() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = b"";
|
||||
|
||||
let (encrypted, salt) = crypto.encrypt(plaintext).unwrap();
|
||||
let decrypted = crypto.decrypt(&encrypted, &salt).unwrap();
|
||||
assert!(decrypted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_plaintext() {
|
||||
let crypto = test_crypto();
|
||||
// 1 MB of data
|
||||
let plaintext = vec![0x42u8; 1024 * 1024];
|
||||
|
||||
let (encrypted, salt) = crypto.encrypt(&plaintext).unwrap();
|
||||
let decrypted = crypto.decrypt(&encrypted, &salt).unwrap();
|
||||
assert_eq!(decrypted.expose().as_bytes(), plaintext.as_slice());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Secrets management for secure credential storage and injection.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - AES-256-GCM encrypted secret storage
|
||||
//! - Per-secret key derivation (HKDF-SHA256)
|
||||
//! - PostgreSQL persistence
|
||||
//! - Access control for WASM tools
|
||||
//!
|
||||
//! # Security Model
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
//! │ Secret Lifecycle │
|
||||
//! │ │
|
||||
//! │ User stores secret ──► Encrypt with AES-256-GCM ──► Store in PostgreSQL │
|
||||
//! │ (per-secret key via HKDF) │
|
||||
//! │ │
|
||||
//! │ WASM requests HTTP ──► Host checks allowlist ──► Decrypt secret ──► │
|
||||
//! │ & allowed_secrets (in memory only) │
|
||||
//! │ │ │
|
||||
//! │ ▼ │
|
||||
//! │ Inject into request ──► Execute HTTP call │
|
||||
//! │ (WASM never sees value) │
|
||||
//! │ │ │
|
||||
//! │ ▼ │
|
||||
//! │ Leak detector scans ──► Return response to WASM │
|
||||
//! │ response for secrets │
|
||||
//! └─────────────────────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use near_agent::secrets::{SecretsStore, PostgresSecretsStore, SecretsCrypto, CreateSecretParams};
|
||||
//! use secrecy::SecretString;
|
||||
//!
|
||||
//! // Initialize crypto with master key from environment
|
||||
//! let master_key = SecretString::from(std::env::var("SECRETS_MASTER_KEY")?);
|
||||
//! let crypto = Arc::new(SecretsCrypto::new(master_key)?);
|
||||
//!
|
||||
//! // Create store
|
||||
//! let store = PostgresSecretsStore::new(pool, crypto);
|
||||
//!
|
||||
//! // Store a secret
|
||||
//! store.create("user_123", CreateSecretParams::new("openai_key", "sk-...")).await?;
|
||||
//!
|
||||
//! // Check if secret exists (WASM can call this)
|
||||
//! let exists = store.exists("user_123", "openai_key").await?;
|
||||
//!
|
||||
//! // Decrypt for injection (host boundary only)
|
||||
//! let decrypted = store.get_decrypted("user_123", "openai_key").await?;
|
||||
//! ```
|
||||
|
||||
mod crypto;
|
||||
mod store;
|
||||
mod types;
|
||||
|
||||
pub use crypto::SecretsCrypto;
|
||||
pub use store::{PostgresSecretsStore, SecretsStore};
|
||||
pub use types::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, DecryptedSecret, Secret,
|
||||
SecretError, SecretRef,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub use store::testing::InMemorySecretsStore;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Secret types for credential management.
|
||||
//!
|
||||
//! WASM tools NEVER see plaintext secrets. This module provides types
|
||||
//! for secure storage and reference without exposing actual values.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A stored secret with encrypted value.
|
||||
///
|
||||
/// The plaintext is never stored; only the encrypted form exists in the database.
|
||||
#[derive(Clone)]
|
||||
pub struct Secret {
|
||||
pub id: Uuid,
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
/// AES-256-GCM encrypted value (nonce || ciphertext || tag).
|
||||
pub encrypted_value: Vec<u8>,
|
||||
/// Per-secret salt for key derivation.
|
||||
pub key_salt: Vec<u8>,
|
||||
/// Optional provider hint (e.g., "openai", "stripe").
|
||||
pub provider: Option<String>,
|
||||
/// When this secret expires (None = never).
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
/// Last time this secret was used for injection.
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
/// Total number of times this secret has been used.
|
||||
pub usage_count: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Secret")
|
||||
.field("id", &self.id)
|
||||
.field("user_id", &self.user_id)
|
||||
.field("name", &self.name)
|
||||
.field("encrypted_value", &"[REDACTED]")
|
||||
.field("key_salt", &"[REDACTED]")
|
||||
.field("provider", &self.provider)
|
||||
.field("expires_at", &self.expires_at)
|
||||
.field("last_used_at", &self.last_used_at)
|
||||
.field("usage_count", &self.usage_count)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to a secret by name, without exposing the value.
|
||||
///
|
||||
/// WASM tools receive these references and can check if secrets exist,
|
||||
/// but they cannot read the actual values.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecretRef {
|
||||
pub name: String,
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
impl SecretRef {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
|
||||
self.provider = Some(provider.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A decrypted secret value, held in secure memory.
|
||||
///
|
||||
/// This type:
|
||||
/// - Zeros memory on drop
|
||||
/// - Never appears in Debug output
|
||||
/// - Only exists briefly during credential injection
|
||||
pub struct DecryptedSecret {
|
||||
value: SecretString,
|
||||
}
|
||||
|
||||
impl DecryptedSecret {
|
||||
/// Create a new decrypted secret from raw bytes.
|
||||
///
|
||||
/// The bytes are converted to a UTF-8 string. For binary secrets,
|
||||
/// consider base64 encoding before storage.
|
||||
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, SecretError> {
|
||||
// Convert to string, then wrap in SecretString
|
||||
let s = String::from_utf8(bytes).map_err(|_| SecretError::InvalidUtf8)?;
|
||||
Ok(Self {
|
||||
value: SecretString::from(s),
|
||||
})
|
||||
}
|
||||
|
||||
/// Expose the secret value for injection.
|
||||
///
|
||||
/// This is the ONLY way to access the plaintext. Use sparingly
|
||||
/// and ensure the exposed value isn't logged or persisted.
|
||||
pub fn expose(&self) -> &str {
|
||||
self.value.expose_secret()
|
||||
}
|
||||
|
||||
/// Get the length of the secret without exposing it.
|
||||
pub fn len(&self) -> usize {
|
||||
self.value.expose_secret().len()
|
||||
}
|
||||
|
||||
/// Check if the secret is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DecryptedSecret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "DecryptedSecret([REDACTED, {} bytes])", self.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for DecryptedSecret {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
value: SecretString::from(self.value.expose_secret().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur during secret operations.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum SecretError {
|
||||
#[error("Secret not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Secret has expired")]
|
||||
Expired,
|
||||
|
||||
#[error("Decryption failed: {0}")]
|
||||
DecryptionFailed(String),
|
||||
|
||||
#[error("Encryption failed: {0}")]
|
||||
EncryptionFailed(String),
|
||||
|
||||
#[error("Invalid master key")]
|
||||
InvalidMasterKey,
|
||||
|
||||
#[error("Secret value is not valid UTF-8")]
|
||||
InvalidUtf8,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("Secret access denied for tool")]
|
||||
AccessDenied,
|
||||
}
|
||||
|
||||
/// Parameters for creating a new secret.
|
||||
#[derive(Debug)]
|
||||
pub struct CreateSecretParams {
|
||||
pub name: String,
|
||||
pub value: SecretString,
|
||||
pub provider: Option<String>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl CreateSecretParams {
|
||||
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
value: SecretString::from(value.into()),
|
||||
provider: None,
|
||||
expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
|
||||
self.provider = Some(provider.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_expiry(mut self, expires_at: DateTime<Utc>) -> Self {
|
||||
self.expires_at = Some(expires_at);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a credential should be injected in an HTTP request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum CredentialLocation {
|
||||
/// Inject as Authorization header (e.g., "Bearer {secret}")
|
||||
AuthorizationBearer,
|
||||
/// Inject as Authorization header with Basic auth
|
||||
AuthorizationBasic { username: String },
|
||||
/// Inject as a custom header
|
||||
Header {
|
||||
name: String,
|
||||
prefix: Option<String>,
|
||||
},
|
||||
/// Inject as a query parameter
|
||||
QueryParam { name: String },
|
||||
}
|
||||
|
||||
impl Default for CredentialLocation {
|
||||
fn default() -> Self {
|
||||
Self::AuthorizationBearer
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapping from a secret name to where it should be injected.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialMapping {
|
||||
/// Name of the secret to use.
|
||||
pub secret_name: String,
|
||||
/// Where to inject the credential.
|
||||
pub location: CredentialLocation,
|
||||
/// Host patterns this credential applies to (glob syntax).
|
||||
pub host_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
impl CredentialMapping {
|
||||
pub fn bearer(secret_name: impl Into<String>, host_pattern: impl Into<String>) -> Self {
|
||||
Self {
|
||||
secret_name: secret_name.into(),
|
||||
location: CredentialLocation::AuthorizationBearer,
|
||||
host_patterns: vec![host_pattern.into()],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header(
|
||||
secret_name: impl Into<String>,
|
||||
header_name: impl Into<String>,
|
||||
host_pattern: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
secret_name: secret_name.into(),
|
||||
location: CredentialLocation::Header {
|
||||
name: header_name.into(),
|
||||
prefix: None,
|
||||
},
|
||||
host_patterns: vec![host_pattern.into()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::secrets::types::{CreateSecretParams, DecryptedSecret, SecretRef};
|
||||
|
||||
#[test]
|
||||
fn test_secret_ref_creation() {
|
||||
let r = SecretRef::new("my_api_key").with_provider("openai");
|
||||
assert_eq!(r.name, "my_api_key");
|
||||
assert_eq!(r.provider, Some("openai".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypted_secret_redaction() {
|
||||
let secret = DecryptedSecret::from_bytes(b"super_secret_value".to_vec()).unwrap();
|
||||
let debug_str = format!("{:?}", secret);
|
||||
assert!(!debug_str.contains("super_secret_value"));
|
||||
assert!(debug_str.contains("REDACTED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypted_secret_expose() {
|
||||
let secret = DecryptedSecret::from_bytes(b"test_value".to_vec()).unwrap();
|
||||
assert_eq!(secret.expose(), "test_value");
|
||||
assert_eq!(secret.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_params() {
|
||||
let params = CreateSecretParams::new("key", "value").with_provider("stripe");
|
||||
assert_eq!(params.name, "key");
|
||||
assert_eq!(params.provider, Some("stripe".to_string()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user