Skip to main content

Overview

IronClaw stores all credentials (API keys, tokens, passwords) in AES-256-GCM encrypted form. Secrets are never exposed to WASM tools; instead, they are injected at the host boundary during HTTP requests.

Security Model

┌─────────────────────────────────────────────────────────────────────────────┐
│                              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                               │
└─────────────────────────────────────────────────────────────────────────────┘

Encryption

Master Key

The master key encrypts all secrets. It can come from:
  1. OS Keychain (recommended): Auto-generated and stored securely in your system keychain
  2. Environment variable: Set SECRETS_MASTER_KEY for CI/Docker deployments
The master key must be at least 32 bytes. If it’s lost, all secrets become unrecoverable.

Key Derivation (HKDF-SHA256)

Each secret gets its own derived key using HKDF:
master_key (from env/keychain) ─┬─▶ HKDF-SHA256 ─▶ derived_key (per secret)

per-secret salt (random 32B) ────┘
Benefits:
  • Same plaintext → different ciphertext (due to unique salt)
  • Key rotation doesn’t require re-encrypting all secrets
  • Salt is stored alongside the encrypted value

Authenticated Encryption (AES-256-GCM)

Secrets are encrypted with AES-256 in GCM (Galois/Counter Mode):
// Encryption
let (encrypted_value, salt) = crypto.encrypt(plaintext)?;
// encrypted_value = nonce (12B) || ciphertext || auth_tag (16B)

// Decryption
let decrypted = crypto.decrypt(&encrypted_value, &salt)?;
Security Properties:
  • Confidentiality: Ciphertext reveals nothing about plaintext
  • Integrity: Tampering is detected via authentication tag
  • Nonce uniqueness: Each encryption uses a fresh random nonce
GCM mode provides authenticated encryption, meaning any modification to the ciphertext will be detected during decryption.

Secret Storage

Database Schema

Secrets are stored in PostgreSQL (or libSQL for embedded deployments):
CREATE TABLE secrets (
    id UUID PRIMARY KEY,
    user_id TEXT NOT NULL,
    name TEXT NOT NULL,
    encrypted_value BYTEA NOT NULL,
    key_salt BYTEA NOT NULL,
    provider TEXT,
    expires_at TIMESTAMPTZ,
    last_used_at TIMESTAMPTZ,
    usage_count BIGINT DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL,
    UNIQUE(user_id, name)
);

Field Descriptions

FieldPurpose
encrypted_valueNonce + ciphertext + auth tag
key_saltSalt for HKDF key derivation
providerOptional: which service this secret is for
expires_atOptional: expiration timestamp
last_used_atLast injection timestamp (for audit)
usage_countNumber of times injected (for monitoring)
Secret names are case-insensitive and unique per user. Storing a secret with the same name replaces the previous value.

Credential Injection

When Secrets Are Decrypted

Secrets are decrypted only when:
  1. A WASM tool makes an HTTP request
  2. The target host matches a credential mapping
  3. The secret name is in the tool’s allowed_secrets list
Secrets are never decrypted for:
  • Listing secrets (only names and metadata)
  • Checking existence (secret_exists host function)
  • Tool output or logs

Injection Locations

Secrets can be injected into different parts of HTTP requests:

Authorization Bearer

GET /v1/chat/completions HTTP/1.1
Host: api.openai.com
Authorization: Bearer sk-proj-abc123...
Configuration:
{
  "secret_name": "openai_key",
  "location": "authorization_bearer",
  "host_patterns": ["api.openai.com"]
}

Authorization Basic

GET /api/data HTTP/1.1
Host: api.service.com
Authorization: Basic dXNlcjpwYXNzd29yZA==
Configuration:
{
  "secret_name": "service_password",
  "location": {
    "authorization_basic": {
      "username": "myuser"
    }
  },
  "host_patterns": ["api.service.com"]
}

Custom Header

GET /search HTTP/1.1
Host: api.example.com
X-API-Key: secret123
Configuration:
{
  "secret_name": "example_api_key",
  "location": {
    "header": {
      "name": "X-API-Key",
      "prefix": null
    }
  },
  "host_patterns": ["*.example.com"]
}

Query Parameter

GET /api/weather?api_key=secret123 HTTP/1.1
Host: api.weather.com
Configuration:
{
  "secret_name": "weather_key",
  "location": {
    "query_param": {
      "name": "api_key"
    }
  },
  "host_patterns": ["api.weather.com"]
}
Query parameters are visible in logs and browser history. Prefer header-based authentication when possible.

Credential Registry

The SharedCredentialRegistry aggregates mappings from all installed tools:
pub struct SharedCredentialRegistry {
    mappings: RwLock<Vec<CredentialMapping>>,
}

// When a tool registers
registry.add_mappings(tool_capabilities.credentials);

// When making an HTTP request
let credentials = registry.find_for_host("api.openai.com");
Key Features:
  • Append-only: Mappings accumulate as tools are installed
  • Thread-safe: Uses RwLock for concurrent access
  • Host matching: Supports exact and wildcard patterns
  • Cleanup: Mappings removed when tools are unregistered

Leak Detection

Scanning Pipeline

Secrets are scanned at two points:
  1. Before outbound requests: Prevents exfiltration via URL, headers, or body
  2. After responses/outputs: Prevents accidental exposure in logs or returned data
// Before HTTP request
leak_detector.scan_http_request(url, headers, body)?;

// After tool output
let cleaned = leak_detector.scan_and_clean(output)?;

Detection Patterns

The leak detector recognizes common secret formats:
PatternExampleAction
OpenAI API keysk-proj-abc123...Block
Anthropic API keysk-ant-api03-abc...Block
AWS Access KeyAKIAIOSFODNN7EXAMPLEBlock
GitHub Tokenghp_xxxxxxxxxxxx...Block
Stripe Keysk_live_abc123...Block
Bearer TokenBearer eyJhbGc...Redact
High-entropy hex64-char hex stringWarn
The leak detector uses Aho-Corasick for fast multi-pattern matching plus regex for complex patterns. See leak_detector.rs for the full list.

Actions on Detection

  • Block: Reject the entire request/output
  • Redact: Replace secret with [REDACTED]
  • Warn: Log a warning but allow

Masked Previews

When a leak is detected, the log shows a masked preview:
mask_secret("sk-proj-test1234567890abcdefghij")
// "sk-p********ghij"
Shows first 4 and last 4 characters with the middle masked.

Access Control

Allowed Secrets List

Each tool declares which secrets it can use:
{
  "secrets": {
    "allowed_names": ["openai_key", "anthropic_*"]
  }
}
Glob Patterns:
  • openai_key → exact match
  • openai_* → matches openai_key, openai_org, etc.

Permission Check

if !allowed_secrets.contains(&mapping.secret_name) {
    return Err(InjectionError::AccessDenied);
}
If a tool tries to use a secret not in its allowlist, injection fails with AccessDenied.

Usage Tracking

Audit Trail

Every time a secret is injected, the database records:
UPDATE secrets
SET last_used_at = NOW(), usage_count = usage_count + 1
WHERE id = $1
Use Cases:
  • Detect unused secrets (candidates for removal)
  • Monitor excessive usage (potential compromise)
  • Audit which tools accessed which secrets

Example Queries

-- Secrets not used in 90 days
SELECT name, provider, last_used_at
FROM secrets
WHERE last_used_at < NOW() - INTERVAL '90 days'
OR last_used_at IS NULL;

-- Most frequently used secrets
SELECT name, provider, usage_count
FROM secrets
ORDER BY usage_count DESC
LIMIT 10;

Secret Expiration

Setting Expiration

CreateSecretParams::new("temp_token", "value")
    .with_expiry(Utc::now() + Duration::hours(24))
When retrieving an expired secret:
let result = store.get("user1", "temp_token").await;
assert!(matches!(result, Err(SecretError::Expired)));
Expired secrets remain in the database but are inaccessible. Delete them manually to free storage.

Best Practices

For Users

  1. Use short-lived credentials: Set expiration dates when possible
  2. Rotate regularly: Update secrets every 90 days minimum
  3. Monitor usage: Check last_used_at for suspicious activity
  4. Secure master key: Never commit SECRETS_MASTER_KEY to version control
  5. Backup carefully: Encrypted secrets are useless without the master key

For Developers

  1. Minimize scope: Only request secrets your tool actually needs
  2. Use credential mappings: Let IronClaw handle injection instead of requesting secrets directly
  3. Log carefully: Never log decrypted secret values
  4. Handle errors gracefully: Don’t expose secret names in user-facing errors
  5. Test with dummy secrets: Use fake credentials for development

For System Administrators

  1. Use keychain on workstations: More secure than environment variables
  2. Use environment variables in CI/Docker: Pass via secure secrets managers
  3. Enable encryption at rest: Encrypt database backups
  4. Audit access: Log all secret retrievals for forensics
  5. Implement key rotation: Have a plan for rotating the master key

Configuration

Master Key from Environment

export SECRETS_MASTER_KEY="0123456789abcdef0123456789abcdef"
ironclaw
The master key must be exactly 32 bytes (for AES-256). Use a cryptographically secure random generator.

Master Key from Keychain

On first run, IronClaw will:
  1. Generate a random 32-byte key
  2. Store it in your OS keychain (Keychain Access on macOS, Credential Manager on Windows, Secret Service on Linux)
  3. Load it automatically on subsequent runs

In-Memory Store (Testing)

For tests or ephemeral deployments:
let crypto = Arc::new(SecretsCrypto::new(test_key).unwrap());
let store = InMemorySecretsStore::new(crypto);
Secrets are lost when the process exits.

Build docs developers (and LLMs) love