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:
Illia Polosukhin
2026-02-02 23:22:52 -08:00
co-authored by Claude Opus 4.5
parent 45bbfa026d
commit 32bfd24154
21 changed files with 5115 additions and 66 deletions
+250
View File
@@ -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());
}
}
+66
View File
@@ -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;
+586
View File
@@ -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,
&params.name,
&encrypted_value,
&key_salt,
&params.provider,
&params.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");
}
}
+281
View File
@@ -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()));
}
}