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 .enc files are ciphertext + tag. No partial recovery.
  • Log leaks. Debug / Display on ApiKey print [REDACTED].
  • Accidental serialisation. ApiKey doesn’t implement Serialize. 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. SecretBox zeroes 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.