zeroclaw-labs/zeroclaw · critical

Key file must contain exactly 32 bytes

Error message

Key file must contain exactly 32 bytes (got {})

What it means

load_or_create_key validates that an existing master key file contains exactly 32 bytes, the ChaCha20 key size used by the secrets module. Wrong-length files are rejected rather than padded or truncated, to avoid silently deriving a weak or garbage key. Note the reported length tells you how big the file actually is.

Solutions

  1. If no secrets were encrypted yet, remove the invalid key file and let the loader atomically create a correct 32-byte one
  2. Regenerate a proper key: head -c 32 /dev/urandom > keyfile (chmod 600)
  3. If secrets already exist, restore the original 32-byte key from backup instead of regenerating — a new key makes existing secrets undecryptable

Example fix

# before
printf 'my-password' > ~/.zeroclaw/key   # 11 bytes, rejected
# after
head -c 32 /dev/urandom > ~/.zeroclaw/key && chmod 600 ~/.zeroclaw/key
Defensive patterns

Strategy: validation

Validate before calling

let meta = std::fs::metadata(&key_path)?;
if meta.len() != 32 {
    anyhow::bail!(
        "key file {} is {} bytes; expected 32 — restore the original key or remove it to regenerate (existing secrets would be lost)",
        key_path.display(),
        meta.len()
    );
}
// safe to call Secrets::with_key(...) now

Try / catch

on "Key file must contain exactly 32 bytes" — stop startup; if no secrets exist yet, delete the file and retry once; otherwise restore the correct 32-byte key from backup

Prevention

When it happens

Trigger: Hand-creating a key file containing a passphrase, hex text, or base64 instead of 32 raw bytes; a key file with a trailing newline; truncation or corruption of the key file; pointing the key path at the wrong file; writing a 64-character hex string as text (64 bytes).

Common situations: Operators generating keys with echo secret > keyfile instead of raw random bytes; editors appending newlines; copying keys between machines in a lossy way; leftover files from experiments at the configured key path.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/c0404b531c38f550. Report an issue: GitHub.

Appendix: source

Thrown at crates/zeroclaw-config/src/secrets.rs:574

/// Opening with `open_no_follow` binds the "not a symlink" check to the same
/// object we read from — there is no check-then-follow window.
fn read_key_file_no_follow(key_path: &Path) -> std::io::Result<String> {
    use std::io::Read; // function-scoped — avoids redundant module import
    let mut file = open_no_follow(key_path)?;
    let mut buf = String::new();
    file.read_to_string(&mut buf)?;
    Ok(buf)
}

/// Load the key from `key_path`, creating it if absent.
///
/// Reads go through a no-follow / reparse-point-verified handle.  Creation
/// uses atomic no-replace publication (write-to-temp then `hard_link` on Unix
/// / `MoveFileExW` on Windows) so concurrent readers never observe empty or
/// partial key material.
fn load_or_create_key(key_path: &Path) -> Result<Vec<u8>> {
    let validate_key = |bytes: Vec<u8>| {
        anyhow::ensure!(
            bytes.len() == 32,
            "Key file must contain exactly 32 bytes (got {})",
            bytes.len()
        );
        Ok(bytes)
    };

    match read_key_file_no_follow(key_path) {
        Ok(hex) => validate_key(hex_decode(hex.trim()).context("Secret key file is corrupt")?),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            let key = generate_random_key();
            match write_key_file_atomic_publish(key_path, &key) {
                Ok(()) => Ok(key),
                Err(write_err) => {
                    // Only recover if another process won the race.
                    // All other failures must propagate so we don't
                    // silently accept a bad key.
                    if !is_already_exists_error(&write_err) {

View on GitHub (pinned to 88bb9c8533)