{"record":{"id":"6591461b07bb3c5c","repo":"zeroclaw-labs/zeroclaw","slug":"otp-secret-is-empty","errorCode":null,"errorMessage":"OTP secret is empty","messagePattern":"OTP secret is empty","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/security/otp.rs","lineNumber":234,"sourceCode":"fn decode_base32_secret(raw: &str) -> Result<Vec<u8>> {\n    fn decode_char(ch: char) -> Option<u8> {\n        match ch {\n            'A'..='Z' => Some((ch as u8) - b'A'),\n            '2'..='7' => Some((ch as u8) - b'2' + 26),\n            _ => None,\n        }\n    }\n\n    let mut cleaned = raw\n        .chars()\n        .filter(|ch| !matches!(ch, ' ' | '\\t' | '\\n' | '\\r' | '-'))\n        .collect::<String>()\n        .to_ascii_uppercase();\n    while cleaned.ends_with('=') {\n        cleaned.pop();\n    }\n    if cleaned.is_empty() {\n        anyhow::bail!(\"OTP secret is empty\");\n    }\n\n    let mut output = Vec::new();\n    let mut buffer = 0u32;\n    let mut bits_left = 0u8;\n\n    for ch in cleaned.chars() {\n        let value = decode_char(ch)\n            .with_context(|| format!(\"OTP secret contains invalid base32 character '{ch}'\"))?;\n        buffer = (buffer << 5) | u32::from(value);\n        bits_left += 5;\n\n        if bits_left >= 8 {\n            let byte = ((buffer >> (bits_left - 8)) & 0xff) as u8;\n            output.push(byte);\n            bits_left -= 8;\n        }\n    }","sourceCodeStart":216,"sourceCodeEnd":252,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/security/otp.rs#L216-L252","documentation":"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.","triggerScenarios":"OtpConfig secret is \"\", \"====\", or only spaces/tabs — typically an unset env var interpolated into config, or a placeholder value shipped in a config template.","commonSituations":"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.","solutions":["Set a real base32 secret (generate one with your authenticator or openssl rand -base32) in the OTP config","Check how the secret is injected — unset env vars often render as empty strings","Add a startup assertion that the secret is non-empty when OTP is enabled"],"exampleFix":"# before\notp_secret = \"\"\n\n# after (32 chars = 160 bits, RFC 4226 minimum)\notp_secret = \"JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP\"","handlingStrategy":"validation","validationCode":"fn otp_secret_ok(raw: &str) -> bool {\n    let cleaned: String = raw\n        .chars()\n        .filter(|c| !matches!(c, ' ' | '\\t' | '\\n' | '\\r' | '-'))\n        .collect::<String>()\n        .trim_end_matches('=')\n        .to_ascii_uppercase();\n    !cleaned.is_empty() && cleaned.len() >= 32\n}","typeGuard":null,"tryCatchPattern":"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.","preventionTips":["Fail config load, not first use, when the OTP secret is empty","Generate secrets with a tool (openssl rand -base32) and store them verbatim","Never ship placeholder secrets in templates"],"tags":["otp","totp","base32","config","rust"],"backgroundTag":"empty-base32-secret","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}