zeroclaw-labs/zeroclaw · error

OTP secret is empty

Error message

OTP secret is empty

What it means

OtpConfig::from_config decodes the base32 secret via decode_base32_secret, which strips whitespace, '-', and trailing '=' padding, then rejects the result if nothing remains (otp.rs:225-235). The configured secret therefore consists only of padding/separators or is empty.

Source

Thrown at crates/zeroclaw-runtime/src/security/otp.rs:234

fn decode_base32_secret(raw: &str) -> Result<Vec<u8>> {
    fn decode_char(ch: char) -> Option<u8> {
        match ch {
            'A'..='Z' => Some((ch as u8) - b'A'),
            '2'..='7' => Some((ch as u8) - b'2' + 26),
            _ => None,
        }
    }

    let mut cleaned = raw
        .chars()
        .filter(|ch| !matches!(ch, ' ' | '\t' | '\n' | '\r' | '-'))
        .collect::<String>()
        .to_ascii_uppercase();
    while cleaned.ends_with('=') {
        cleaned.pop();
    }
    if cleaned.is_empty() {
        anyhow::bail!("OTP secret is empty");
    }

    let mut output = Vec::new();
    let mut buffer = 0u32;
    let mut bits_left = 0u8;

    for ch in cleaned.chars() {
        let value = decode_char(ch)
            .with_context(|| format!("OTP secret contains invalid base32 character '{ch}'"))?;
        buffer = (buffer << 5) | u32::from(value);
        bits_left += 5;

        if bits_left >= 8 {
            let byte = ((buffer >> (bits_left - 8)) & 0xff) as u8;
            output.push(byte);
            bits_left -= 8;
        }
    }

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Set a real base32 secret (generate one with your authenticator or openssl rand -base32) in the OTP config
  2. Check how the secret is injected — unset env vars often render as empty strings
  3. Add a startup assertion that the secret is non-empty when OTP is enabled

Example fix

# before
otp_secret = ""

# after (32 chars = 160 bits, RFC 4226 minimum)
otp_secret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
Defensive patterns

Strategy: validation

Validate before calling

fn otp_secret_ok(raw: &str) -> bool {
    let cleaned: String = raw
        .chars()
        .filter(|c| !matches!(c, ' ' | '\t' | '\n' | '\r' | '-'))
        .collect::<String>()
        .trim_end_matches('=')
        .to_ascii_uppercase();
    !cleaned.is_empty() && cleaned.len() >= 32
}

Try / catch

If from_config fails with 'OTP secret is empty', refuse startup with a clear config-error message naming the missing key — an empty secret must never fall back to a default.

Prevention

When it happens

Trigger: OtpConfig secret is "", "====", or only spaces/tabs — typically an unset env var interpolated into config, or a placeholder value shipped in a config template.

Common situations: OTP enabled in an environment where the secret env var was never set; .env file not loaded in the deployment; secret field emptied during a config edit.

Related errors


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