-
Notifications
You must be signed in to change notification settings - Fork 4.4k
04.3 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.
ZeroClaw's secret management system encrypts sensitive values in config.toml to prevent:
- Plaintext exposure in version control
- Accidental leakage via
greporgit 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
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
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
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
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:
icaclswith inheritance removed, granting only current user full control
Sources: src/security/secrets.rs:170-226, src/security/secrets.rs:240-266
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
| 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
[secrets]
encrypt = true # Default: trueWhen encrypt = true, the SecretStore is used to encrypt/decrypt secrets during config load/save operations.
Sources: src/config/schema.rs:627-640
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
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..."
Sources: src/onboard/wizard.rs:61-196, src/security/secrets.rs:56-76, src/security/secrets.rs:170-226
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
Sources: src/security/secrets.rs:85-93, src/security/secrets.rs:127-148
| 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.
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
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
| 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 |
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
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 = falseAfter changing this setting, existing enc2: values remain encrypted. To decrypt them, either:
- Manually remove the
enc2:prefix and paste plaintext - 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
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...let encrypted = "enc2:a1b2c3d4e5f6789...";
let plaintext = store.decrypt(encrypted)?;
// Use plaintext for API callif 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 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
ZeroClaw does not currently support automatic key rotation. To rotate the encryption key:
-
Backup: Copy
~/.zeroclaw/.secret_keyto a safe location -
Export plaintext: Set
secrets.encrypt = falseand runzeroclaw config showto view decrypted values -
Delete key: Remove
~/.zeroclaw/.secret_key -
Re-encrypt: Set
secrets.encrypt = trueand runzeroclaw onboard --repair-secrets(or manually editconfig.toml) - 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
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
#[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).
#[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
| 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.