{"record":{"id":"5e6b05303bfbf33f","repo":"zeroclaw-labs/zeroclaw","slug":"otp-secret-did-not-decode-to-any-bytes","errorCode":null,"errorMessage":"OTP secret did not decode to any bytes","messagePattern":"OTP secret did not decode to any bytes","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/security/otp.rs","lineNumber":255,"sourceCode":"    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    }\n\n    if output.is_empty() {\n        anyhow::bail!(\"OTP secret did not decode to any bytes\");\n    }\n    Ok(output)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::tempdir;\n\n    fn test_config() -> OtpConfig {\n        OtpConfig {\n            enabled: true,\n            token_ttl_secs: 30,\n            cache_valid_secs: 120,\n            ..OtpConfig::default()\n        }\n    }\n","sourceCodeStart":237,"sourceCodeEnd":273,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/security/otp.rs#L237-L273","documentation":"The cleaned secret is non-empty but decodes to zero bytes (otp.rs:254-256). Base32 packs 5 bits per character and bytes are emitted only at 8-bit boundaries, so a 1-character secret produces no bytes. Real TOTP secrets are 32 chars (160 bits), so this almost always means a truncated paste.","triggerScenarios":"OtpConfig secret with exactly one base32 character after cleaning (e.g. \"a\" or \"7=\"); the secret was cut off during copy-paste or templating.","commonSituations":"Secret truncated by a length limit or a stray newline; manual retyping of a QR-derived secret dropped characters; a templating system mangled the value.","solutions":["Re-copy the full secret from the enrollment source (QR code / otpauth:// URL)","Validate the secret decodes to at least 20 bytes (160 bits per RFC 4226) before enabling OTP","Store secrets verbatim — avoid transforms that can truncate them"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"fn otp_secret_decodes(raw: &str) -> bool {\n    let cleaned: String = raw.chars().filter(|c| c.is_ascii_alphanumeric()).collect();\n    // 2 base32 chars -> 10 bits -> at least 1 byte; real secrets are 32+ chars\n    cleaned.len() >= 32 && cleaned.chars().all(|c| c.is_ascii_alphanumeric())\n}","typeGuard":null,"tryCatchPattern":"Catch from_config errors and report a config-invalid message with the secret's length (never its value) so the operator sees the truncation immediately.","preventionTips":["Validate secret length (>= 32 base32 chars / 160 bits) at config load","Copy secrets with a password manager or file transfer, not by eye","Round-trip check: decode then re-encode to confirm the secret survived storage"],"tags":["otp","totp","base32","config","rust"],"backgroundTag":"invalid-base32-secret","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}