Skip to content

04.3 Secret Management

Nikolay Vyahhi edited this page Feb 19, 2026 · 3 revisions

Secret Management

Relevant source files

The following files were used as context for generating this wiki page:

This document explains ZeroClaw's encrypted secret storage system, which protects API keys, bearer tokens, and other sensitive credentials in configuration files. The system uses ChaCha20-Poly1305 AEAD encryption with a locally-stored key to prevent plaintext exposure while maintaining usability for sovereign deployments.

For gateway authentication and pairing tokens, see Gateway Security. For environment variable credential resolution, see Environment Variables.


Overview

ZeroClaw's secret management system encrypts sensitive values in config.toml to prevent:

  • Plaintext exposure in version control
  • Accidental leakage via grep or git log
  • Known-plaintext attacks
  • Ciphertext tampering (via authenticated encryption)

The system is opt-in via secrets.encrypt = true (default) and backward-compatible with plaintext configurations for users who prefer transparency.

Sources: src/security/secrets.rs:1-22


Encryption Architecture

Cipher: ChaCha20-Poly1305 AEAD

ZeroClaw uses ChaCha20-Poly1305 for authenticated encryption with associated data (AEAD):

Property Value
Cipher ChaCha20 (stream cipher)
Authentication Poly1305 (MAC)
Key Size 256 bits (32 bytes)
Nonce Size 96 bits (12 bytes)
Tag Size 128 bits (16 bytes)

Ciphertext Format:

enc2:<hex(nonce ‖ ciphertext ‖ tag)>
     └─ 12 bytes ─┘└─ N bytes ──┘└─ 16 bytes ─┘

Each encryption generates a fresh random nonce using the OS CSPRNG (OsRng), ensuring identical plaintexts produce different ciphertexts.

Sources: src/security/secrets.rs:23-34, src/security/secrets.rs:53-76


SecretStore Architecture

graph TB
    subgraph "SecretStore Lifecycle"
        Init["SecretStore::new()"]
        CheckKey{"Key file exists?"}
        GenKey["generate_random_key()<br/>32 bytes from OsRng"]
        SaveKey["Write ~/.zeroclaw/.secret_key<br/>chmod 0600 (Unix)<br/>icacls (Windows)"]
        LoadKey["Load key from disk"]
        Ready["SecretStore Ready"]
        
        Init --> CheckKey
        CheckKey -->|No| GenKey
        GenKey --> SaveKey
        SaveKey --> Ready
        CheckKey -->|Yes| LoadKey
        LoadKey --> Ready
    end
    
    subgraph "Encryption Flow"
        Plaintext["Plaintext Secret"]
        GenNonce["ChaCha20Poly1305::generate_nonce()"]
        Encrypt["cipher.encrypt(nonce, plaintext)"]
        Prepend["nonce ‖ ciphertext"]
        HexEncode["hex_encode(blob)"]
        Prefix["Add 'enc2:' prefix"]
        Stored["enc2:aabbcc..."]
        
        Plaintext --> GenNonce
        GenNonce --> Encrypt
        Encrypt --> Prepend
        Prepend --> HexEncode
        HexEncode --> Prefix
        Prefix --> Stored
    end
    
    subgraph "Decryption Flow"
        Ciphertext["enc2:aabbcc..."]
        Strip["Strip prefix"]
        Decode["hex_decode()"]
        Split["Split nonce + ciphertext"]
        Decrypt["cipher.decrypt(nonce, ciphertext)"]
        Verify["Verify Poly1305 tag"]
        PlaintextOut["Plaintext"]
        
        Ciphertext --> Strip
        Strip --> Decode
        Decode --> Split
        Split --> Decrypt
        Decrypt --> Verify
        Verify --> PlaintextOut
    end
    
    Ready -.provides key.-> Encrypt
    Ready -.provides key.-> Decrypt
Loading

Sources: src/security/secrets.rs:35-51, src/security/secrets.rs:56-76, src/security/secrets.rs:127-148, src/security/secrets.rs:240-246


Key Management

Key File Location

The encryption key is stored at ~/.zeroclaw/.secret_key as a 64-character hex string (32 bytes).

Key Generation:

// Uses OS CSPRNG (getrandom) via ChaCha20Poly1305::generate_key
let key = ChaCha20Poly1305::generate_key(&mut OsRng).to_vec();

File Permissions:

  • Unix: chmod 0600 (owner read/write only)
  • Windows: icacls with inheritance removed, granting only current user full control

Sources: src/security/secrets.rs:170-226, src/security/secrets.rs:240-266


SecretStore Class Interface

classDiagram
    class SecretStore {
        -PathBuf key_path
        -bool enabled
        
        +new(zeroclaw_dir: &Path, enabled: bool) SecretStore
        +encrypt(plaintext: &str) Result~String~
        +decrypt(value: &str) Result~String~
        +decrypt_and_migrate(value: &str) Result~(String, Option~String~)~
        +is_encrypted(value: &str) bool
        +is_secure_encrypted(value: &str) bool
        +needs_migration(value: &str) bool
        -load_or_create_key() Result~Vec~u8~~
        -decrypt_chacha20(hex_str: &str) Result~String~
        -decrypt_legacy_xor(hex_str: &str) Result~String~
    }
    
    class SecretsConfig {
        +bool encrypt
    }
    
    class Config {
        +SecretsConfig secrets
        +Option~String~ api_key
        +ComposioConfig composio
        +BrowserConfig browser
        +load_or_init() Result~Config~
        +save() Result
    }
    
    Config --> SecretsConfig
    Config ..> SecretStore : uses for encryption
Loading

Core Methods

Method Purpose
encrypt(plaintext) Encrypts a secret and returns enc2:... format
decrypt(value) Decrypts enc2: or enc: prefixed values, or returns plaintext as-is
decrypt_and_migrate(value) Decrypts and returns upgraded enc2: value if legacy enc: was used
is_encrypted(value) Checks if value has enc2: or enc: prefix
needs_migration(value) Returns true for legacy enc: format

Sources: src/security/secrets.rs:35-51, src/security/secrets.rs:56-120, src/security/secrets.rs:161-168


Configuration Integration

SecretsConfig Schema

[secrets]
encrypt = true  # Default: true

When encrypt = true, the SecretStore is used to encrypt/decrypt secrets during config load/save operations.

Sources: src/config/schema.rs:627-640


Encrypted Fields in Config

The following configuration fields are automatically encrypted when secrets.encrypt = true:

Field Purpose
api_key Primary provider API key
composio.api_key Composio OAuth integration key
browser.computer_use.api_key Computer-use sidecar bearer token
web_search.brave_api_key Brave Search API key
gateway.paired_tokens Gateway bearer token hashes (SHA-256)
Channel-specific tokens Telegram, Discord, Slack, etc.

Example Encrypted Config:

api_key = "enc2:a1b2c3d4e5f6789..."
default_provider = "openrouter"

[composio]
enabled = true
api_key = "enc2:f1e2d3c4b5a6..."

[browser.computer_use]
api_key = "enc2:9876543210abcdef..."

Sources: src/config/schema.rs:56, src/config/schema.rs:603-607, src/config/schema.rs:644-689


Secret Encryption Flow

sequenceDiagram
    participant User
    participant Wizard as "Onboarding Wizard"
    participant Config
    participant SecretStore
    participant KeyFile as "~/.zeroclaw/.secret_key"
    participant ConfigFile as "~/.zeroclaw/config.toml"
    
    User->>Wizard: zeroclaw onboard --api-key sk-...
    Wizard->>Config: Create Config with api_key="sk-..."
    
    Config->>Config: save()
    Config->>SecretStore: new(~/.zeroclaw, enabled=true)
    SecretStore->>KeyFile: Check if exists
    
    alt Key file missing
        SecretStore->>SecretStore: generate_random_key()
        SecretStore->>KeyFile: Write 32 bytes hex + chmod 0600
    else Key file exists
        SecretStore->>KeyFile: Load key
    end
    
    Config->>SecretStore: encrypt("sk-...")
    SecretStore->>SecretStore: Generate 12-byte nonce
    SecretStore->>SecretStore: ChaCha20Poly1305::encrypt()
    SecretStore-->>Config: "enc2:a1b2c3d4..."
    
    Config->>ConfigFile: Write TOML with encrypted value
    ConfigFile-->>User: api_key = "enc2:a1b2c3d4..."
Loading

Sources: src/onboard/wizard.rs:61-196, src/security/secrets.rs:56-76, src/security/secrets.rs:170-226


Secret Decryption Flow

sequenceDiagram
    participant User
    participant CLI as "zeroclaw agent"
    participant Config
    participant SecretStore
    participant KeyFile as "~/.zeroclaw/.secret_key"
    participant Provider
    
    User->>CLI: zeroclaw agent -m "Hello"
    CLI->>Config: load_or_init()
    Config->>ConfigFile: Read config.toml
    ConfigFile-->>Config: api_key = "enc2:a1b2c3d4..."
    
    Config->>SecretStore: new(~/.zeroclaw, enabled=true)
    SecretStore->>KeyFile: Load key
    
    Config->>SecretStore: decrypt("enc2:a1b2c3d4...")
    SecretStore->>SecretStore: Strip prefix, hex_decode()
    SecretStore->>SecretStore: Split nonce + ciphertext
    SecretStore->>SecretStore: ChaCha20Poly1305::decrypt()
    SecretStore->>SecretStore: Verify Poly1305 tag
    SecretStore-->>Config: "sk-..."
    
    Config->>Provider: create_provider(api_key="sk-...")
    Provider-->>CLI: Ready
Loading

Sources: src/security/secrets.rs:85-93, src/security/secrets.rs:127-148


Legacy Format Migration

enc: vs enc2: Prefixes

Prefix Algorithm Status
enc: XOR cipher (repeating key) Insecure, deprecated
enc2: ChaCha20-Poly1305 AEAD Current, secure

ZeroClaw supports decrypting legacy enc: values for backward compatibility, with automatic migration on next save.

Migration Process

flowchart TD
    Load["Config::load_or_init()"]
    CheckPrefix{"Check prefix"}
    
    Enc2["enc2: detected"]
    Enc["enc: detected (legacy)"]
    Plain["No prefix (plaintext)"]
    
    DecryptEnc2["decrypt_chacha20()"]
    DecryptXOR["decrypt_legacy_xor()"]
    Passthrough["Return as-is"]
    
    Warn["⚠️ Log migration warning"]
    ReEncrypt["encrypt() with ChaCha20"]
    SaveNew["Store enc2: value"]
    
    UsePlaintext["Use plaintext"]
    UseEnc2["Use plaintext"]
    UseEncrypted["Use plaintext"]
    
    Load --> CheckPrefix
    CheckPrefix -->|enc2:| Enc2
    CheckPrefix -->|enc:| Enc
    CheckPrefix -->|none| Plain
    
    Enc2 --> DecryptEnc2
    Enc --> Warn
    Warn --> DecryptXOR
    Plain --> Passthrough
    
    DecryptEnc2 --> UseEnc2
    DecryptXOR --> ReEncrypt
    Passthrough --> UsePlaintext
    
    ReEncrypt --> SaveNew
    SaveNew --> UseEncrypted
Loading

Migration Detection:

pub fn needs_migration(value: &str) -> bool {
    value.starts_with("enc:")
}

pub fn decrypt_and_migrate(&self, value: &str) -> Result<(String, Option<String>)> {
    if value.starts_with("enc:") {
        let plaintext = self.decrypt_legacy_xor(hex_str)?;
        let migrated = self.encrypt(&plaintext)?;  // Re-encrypt with enc2:
        Ok((plaintext, Some(migrated)))  // Caller persists new value
    } else {
        // Already enc2: or plaintext
        Ok((self.decrypt(value)?, None))
    }
}

Sources: src/security/secrets.rs:95-120, src/security/secrets.rs:122-126, src/security/secrets.rs:150-158, src/security/secrets.rs:228-238


Security Properties

Defense-in-Depth Guarantees

Threat Mitigation
Plaintext Exposure All secrets encrypted at rest in config files
Version Control Leaks No plaintext keys in config.toml (safe to commit)
Casual Grep Searching for sk-, OPENROUTER_API_KEY, etc. finds nothing
Known-Plaintext ChaCha20 is a stream cipher immune to KPA
Ciphertext Tampering Poly1305 MAC detects modifications before decryption
Timing Attacks Not applicable (no network authentication with these values)
Key Theft File permissions restrict access to current user only

Non-Goals

This system does not protect against:

  • Local root/admin access: Key file is readable by root/administrator
  • Memory dumps: Plaintext exists in process memory after decryption
  • Malicious processes: Any process running as the user can read the key file
  • Deleted file recovery: Key file may be recoverable from disk

For production deployments requiring stronger guarantees, consider external secret management systems (HashiCorp Vault, AWS Secrets Manager, etc.) and override credentials via environment variables.

Sources: src/security/secrets.rs:1-21


Onboarding Integration

The wizard automatically encrypts secrets during initial setup:

// From wizard.rs:86-90
let (composio_config, secrets_config) = setup_tool_mode()?;

// SecretsConfig::default() sets encrypt = true
impl Default for SecretsConfig {
    fn default() -> Self {
        Self { encrypt: true }
    }
}

Users can disable encryption with:

[secrets]
encrypt = false

After changing this setting, existing enc2: values remain encrypted. To decrypt them, either:

  1. Manually remove the enc2: prefix and paste plaintext
  2. Run zeroclaw onboard --repair-secrets (if implemented)

Sources: src/onboard/wizard.rs:86-90, src/config/schema.rs:636-640, src/config/schema.rs:627-634


Usage Examples

Encrypting a New Secret

use zeroclaw::security::SecretStore;

let store = SecretStore::new(zeroclaw_dir.as_path(), true);
let encrypted = store.encrypt("sk-my-api-key-12345")?;
println!("Encrypted: {}", encrypted);
// Output: enc2:a1b2c3d4e5f6789...

Decrypting at Runtime

let encrypted = "enc2:a1b2c3d4e5f6789...";
let plaintext = store.decrypt(encrypted)?;
// Use plaintext for API call

Checking for Legacy Format

if SecretStore::needs_migration(&config.api_key) {
    println!("⚠️  Config uses insecure enc: format. Migrating...");
    let (plaintext, new_value) = store.decrypt_and_migrate(&config.api_key)?;
    if let Some(upgraded) = new_value {
        config.api_key = upgraded;
        config.save()?;
    }
}

Sources: src/security/secrets.rs:56-76, src/security/secrets.rs:85-93, src/security/secrets.rs:95-120


Gateway Token Storage

Gateway paired tokens are stored as SHA-256 hashes (not ChaCha20-encrypted) to prevent plaintext leakage in config:

[gateway]
paired_tokens = [
    "a3b4c5d6e7f8...",  # SHA-256 hash of zc_<64-hex-chars>
]

The PairingGuard compares bearer token hashes for authentication without storing plaintext.

For details on gateway pairing flow, see Gateway Security.

Sources: src/security/pairing.rs:26-36, src/security/pairing.rs:190-193, src/config/schema.rs:518-521


Key Rotation

ZeroClaw does not currently support automatic key rotation. To rotate the encryption key:

  1. Backup: Copy ~/.zeroclaw/.secret_key to a safe location
  2. Export plaintext: Set secrets.encrypt = false and run zeroclaw config show to view decrypted values
  3. Delete key: Remove ~/.zeroclaw/.secret_key
  4. Re-encrypt: Set secrets.encrypt = true and run zeroclaw onboard --repair-secrets (or manually edit config.toml)
  5. Verify: Restart ZeroClaw and confirm authentication works

Note: Gateway paired tokens (SHA-256 hashes) are unaffected by key rotation.

Sources: src/security/secrets.rs:170-226


Testing

The SecretStore includes comprehensive test coverage:

Test Purpose
encrypt_decrypt_roundtrip Verify encryption → decryption produces original plaintext
encrypting_same_value_produces_different_ciphertext Ensure nonce randomness
tampered_ciphertext_detected Verify Poly1305 authentication tag
wrong_key_detected Ensure cross-store decryption fails
decrypt_and_migrate_upgrades_legacy_xor Test legacy format migration
migration_produces_different_ciphertext_each_time Verify migrated values use fresh nonces

Sources: src/security/secrets.rs:283-710


File Permissions Security

Unix (Linux, macOS)

#[cfg(unix)]
{
    use std::os::unix::fs::PermissionsExt;
    fs::set_permissions(&self.key_path, fs::Permissions::from_mode(0o600))?;
}

Sets -rw------- (owner read/write only).

Windows

#[cfg(windows)]
{
    let username = std::env::var("USERNAME").unwrap_or_default();
    std::process::Command::new("icacls")
        .arg(&self.key_path)
        .args(["/inheritance:r", "/grant:r"])
        .arg(format!("{}:F", username))
        .output()?;
}

Removes inheritance and grants only current user full control.

Sources: src/security/secrets.rs:184-223, src/security/secrets.rs:258-266


Summary

Component Location Purpose
SecretStore src/security/secrets.rs Main encryption/decryption implementation
SecretsConfig src/config/schema.rs:627-640 TOML schema for [secrets] section
Key File ~/.zeroclaw/.secret_key 256-bit ChaCha20 key (hex-encoded)
Encrypted Format enc2:<hex> Current secure format (ChaCha20-Poly1305)
Legacy Format enc: Deprecated XOR cipher (auto-migrated)
Gateway Tokens src/security/pairing.rs SHA-256 hashed bearer tokens

ZeroClaw's secret management provides defense-in-depth for local deployments while maintaining backward compatibility and supporting plaintext mode for sovereign users who prefer transparency over encryption at rest.


Clone this wiki locally