API keys are the things you most want to keep out of your logs, your crash dumps, your tracing spans, your CI artifacts, and your disk images. openclaw-rs treats them as if they’re radioactive.
The shape of the store
~/.openclaw/credentials/
├── anthropic.enc # AES-256-GCM ciphertext + nonce + tag
├── openai.enc
└── master.salt # Argon2id salt
File permissions: 0600 on POSIX (owner read/write only). On Windows, ACLs that limit to the user SID.
Key derivation: Argon2id
The user provides a master password (during openclaw onboard or via a process-supplied secret). We never store it.
To derive the AES-256 key:
let salt = SaltString::generate(&mut OsRng);
let params = Params::new(
65536, // memory cost (KiB) — 64 MiB
3, // time cost — 3 iterations
4, // parallelism — 4 lanes
Some(32), // output length — 32 bytes (256 bits)
)?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let hash = argon.hash_password(master_password.as_bytes(), &salt)?;
let key: [u8; 32] = hash.hash.unwrap().as_bytes().try_into()?;
Argon2id is the OWASP-recommended password KDF. 64 MiB / 3 iterations / 4 lanes gives strong resistance to GPU and ASIC attacks within reasonable user-experience latency (≈ 200 ms on a modern laptop).
Encryption: AES-256-GCM
GCM is AEAD — authenticated encryption with associated data. The output bundles the ciphertext with a 16-byte authentication tag. Tamper with the ciphertext and decryption fails before plaintext is exposed.
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::{Aead, OsRng, rand_core::RngCore}};
let cipher = Aes256Gcm::new_from_slice(&key)?;
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes())?;
// Persist: nonce || ciphertext || tag
let mut record = Vec::with_capacity(12 + ciphertext.len());
record.extend_from_slice(&nonce_bytes);
record.extend_from_slice(&ciphertext);
fs::write(path, &record).await?;
fs::set_permissions(path, Permissions::from_mode(0o600)).await?;
A fresh nonce per record means we never reuse one with the same key. (GCM is catastrophically broken if you reuse nonce-key pairs.)
Reading it back
let record = fs::read(path).await?;
let (nonce_bytes, sealed) = record.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher.decrypt(nonce, sealed)?;
// plaintext is consumed immediately; never returned to logs.
cipher.decrypt returns Err on tag mismatch — corruption or tampering is detected, not silently ignored.
The ApiKey wrapper
Decrypted bytes never touch a plain String. They go straight into ApiKey:
pub struct ApiKey(SecretBox<str>);
impl ApiKey {
pub fn new(s: String) -> Self {
Self(SecretBox::new(s.into_boxed_str()))
}
pub fn expose_secret(&self) -> &str {
self.0.expose_secret()
}
}
impl fmt::Debug for ApiKey { fn fmt(...) { write!(f, "ApiKey([REDACTED])") } }
impl fmt::Display for ApiKey { fn fmt(...) { write!(f, "[REDACTED]") } }
impl Drop for ApiKey { /* SecretBox zeroes on drop */ }
You can pass ApiKey into a tracing span, format-print it in an error, ship it across the Send boundary — and the textual representation is always [REDACTED]. The only way to get the bytes is .expose_secret(), and the linter flags every call site.
End-to-end
// onboard: user types master password once
let store = CredentialStore::open(path, &master_password)?;
// store: encrypt + persist
store.put("anthropic", &ApiKey::new("sk-ant-...".to_string()))?;
// retrieve: decrypt + wrap
let key: ApiKey = store.get("anthropic")?;
let provider = AnthropicProvider::new(key);
// use: explicit expose at the HTTP boundary only
let resp = client
.post(url)
.header("x-api-key", provider.api_key().expose_secret())
.send()
.await?;
What this defends against
- Disk theft. Without the master password, the
.encfiles are ciphertext + tag. No partial recovery. - Log leaks.
Debug/DisplayonApiKeyprint[REDACTED]. - Accidental serialisation.
ApiKeydoesn’t implementSerialize. You can’t send it over JSON-RPC by accident. - Tampering. GCM’s auth tag means any byte flip in the ciphertext fails the decrypt.
- Process inspection mid-runtime.
SecretBoxzeroes the buffer on drop.
What this doesn’t defend against
- Compromised host with active session. If an attacker has root on the host while openclaw-rs is running and the master password is in memory, they can read the decrypted keys. That’s an OS-level threat, not a crypto-level one.
- Coercion of the user. If you give the password to someone, the encryption is irrelevant.
- A key-logged master password. Same.
The credential store is a defence against passive disk-level attacks and accidental leaks. For active threats, the rest of the runtime (sandbox, audit log, rate limit) is what helps.
Rotation
openclaw configure --section auth --rotate re-prompts the master password and re-encrypts every record with a freshly-derived key and salt. The old .enc files are overwritten. The cost is a single prompt per host.
Why pure Rust crypto
aes-gcm and argon2 are pure-Rust crates from RustCrypto — no openssl, no libsodium, no C dependency. Cross-compilation stays trivial. Audit surface stays small. Reproducibility stays high.
This is also why rustls is the default HTTPS stack and secrecy is the default secret wrapper. The fewer non-Rust dependencies the security boundary touches, the smaller the threat surface.