tinyhumansai/openhuman · error

encryption key must decode to 32 raw bytes (raw, base64, or

Error message

encryption key must decode to 32 raw bytes (raw, base64, or base64url accepted; got len={})

What it means

Thrown by key_bytes_from_string (src/api/rest.rs:1186) when no accepted decoding yields exactly 32 bytes. The function tries the raw string itself, then URL_SAFE_NO_PAD, URL_SAFE, STANDARD, and STANDARD_NO_PAD in order; each successful decode must be exactly 32 bytes. The bail reports the trimmed input length to hint at which format was expected.

Source

Thrown at src/api/rest.rs:1213

    // `base64::Engine` has generic methods and therefore isn't
    // dyn-compatible, so we unroll the attempts instead of looping over
    // a slice of trait objects.
    macro_rules! try_decode {
        ($engine:expr) => {
            if let Ok(decoded) = $engine.decode(trimmed) {
                if decoded.len() == 32 {
                    return Ok(decoded);
                }
            }
        };
    }
    try_decode!(URL_SAFE_NO_PAD);
    try_decode!(URL_SAFE);
    try_decode!(STANDARD);
    try_decode!(STANDARD_NO_PAD);

    anyhow::bail!(
        "encryption key must decode to 32 raw bytes (raw, base64, or base64url accepted; got len={})",
        trimmed.len()
    );
}

#[cfg(test)]
#[path = "rest_tests.rs"]
mod key_bytes_from_string_tests;

View on GitHub (pinned to a221052e0d)

Solutions

  1. Generate a proper 32-byte key and pass it base64url-no-pad (43 chars) — the backend's current format — or as a raw 32-character string.
  2. If the key is hex (64 chars), convert it to 32 raw bytes and re-encode as base64url before passing.
  3. Check the decoded length in a scratch script: every engine that decodes must land on exactly 32 bytes.
  4. Match the format the other side (backend encryptMessageFromString) expects; do not invent a passphrase.

Example fix

# before (passphrase / hex key)
OPENHUMAN_HANDOFF_KEY="my-secret-password"
OPENHUMAN_HANDOFF_KEY="a1b2..."  # 64 hex chars -> decodes to 48 bytes, fails

# after (32 random bytes, base64url no padding -> 43 chars)
openssl rand -raw 32 | basenc --base64url -w0 | tr -d '='
Defensive patterns

Strategy: validation

Validate before calling

use base64::engine::general_purpose::{STANDARD, URL_SAFE, URL_SAFE_NO_PAD, STANDARD_NO_PAD};
use base64::engine::Engine;

fn key_decodes_to_32_bytes(key: &str) -> bool {
    let k = key.trim();
    k.len() == 32
        || [URL_SAFE_NO_PAD.decode(k), URL_SAFE.decode(k), STANDARD.decode(k), STANDARD_NO_PAD.decode(k)]
            .iter()
            .any(|r| r.as_ref().map(|b| b.len() == 32).unwrap_or(false))
}

if !key_decodes_to_32_bytes(&configured_key) {
    anyhow::bail!("handoff key misconfigured: must be raw 32 chars or base64 of 32 bytes");
}

Try / catch

match key_bytes_from_string(key_str) {
    Ok(bytes) => bytes,
    Err(e) => {
        log::warn!("[handoff] rejecting configured encryption key: {e}");
        return Err(e.context("configure a 32-byte key (base64url-no-pad, 43 chars, or raw 32 chars)"));
    }
}

Prevention

When it happens

Trigger: Passing a human-rememberable passphrase instead of a real key; a hex-encoded 64-char key (valid base64 characters but decodes to 48 bytes, so every engine fails the 32-byte check); a base64 key for a non-256-bit cipher (16 or 24 bytes after decode); a key with a stray character or wrong length; an empty/blank string.

Common situations: An operator puts a password into the handoff-encryption env var rather than the generated 32-byte secret; the backend sends the key in a format this decoder predates; whitespace is trimmed automatically, so the usual culprit is genuinely wrong key material, not formatting.

Related errors


AI-assisted analysis of tinyhumansai/openhuman@a221052e0d (2026-08-16). Data as JSON: /api/errors/6ba54be704342363. Report an issue: GitHub.