tinyhumansai/openhuman · error

invalid AES key: {e}

Error message

invalid AES key: {e}

What it means

Thrown when Aes256Gcm16::new_from_slice rejects the key slice while building the cipher inside decrypt_handoff_blob. For Aes256 the aes-gcm crate requires exactly 32 key bytes; any other length is an InvalidLength error surfaced as 'invalid AES key'. In this code path key_bytes_from_string has already guaranteed 32 bytes, so in practice this arm is a defensive guard that only fires if that upstream contract is broken.

Source

Thrown at src/api/rest.rs:1158

        anyhow::bail!("encrypted payload too short");
    }
    let iv = &combined[0..16];
    let tag = &combined[16..32];
    let ciphertext = &combined[32..];

    // aes-gcm expects ciphertext || tag
    let mut ct_with_tag = Vec::with_capacity(ciphertext.len() + tag.len());
    ct_with_tag.extend_from_slice(ciphertext);
    ct_with_tag.extend_from_slice(tag);

    use aes_gcm::aead::generic_array::typenum::U16;
    use aes_gcm::aead::{Aead, KeyInit};
    use aes_gcm::aes::Aes256;
    use aes_gcm::AesGcm;
    type Aes256Gcm16 = AesGcm<Aes256, U16>;

    let cipher =
        Aes256Gcm16::new_from_slice(&key).map_err(|e| anyhow::anyhow!("invalid AES key: {e}"))?;
    let nonce = aes_gcm::aead::generic_array::GenericArray::from_slice(iv);
    let plain = cipher
        .decrypt(nonce, ct_with_tag.as_ref())
        .map_err(|e| anyhow::anyhow!("AES-GCM decrypt failed: {e}"))?;

    String::from_utf8(plain).context("handoff plaintext is not UTF-8")
}

/// Decode the shared encryption key into 32 raw AES bytes.
///
/// Accepts, in order of preference:
/// 1. base64url without padding — the current backend format (e.g.
///    a 43-char alphanumeric string using `-` / `_`). This must be tried
///    BEFORE standard base64 because `-`/`_` are invalid in the standard
///    alphabet and would fail cleanly, whereas a standard-base64 string
///    never contains `-`/`_` so base64url_no_pad will still decode it
///    correctly as long as there's no padding.
/// 2. base64url with padding.

View on GitHub (pinned to a221052e0d)

Solutions

  1. Route every key through key_bytes_from_string (base64url-no-pad, base64 variants, or raw 32 chars) so the 32-byte invariant holds.
  2. If calling the cipher directly, assert key.len() == 32 before new_from_slice and fail with a clear message about the expected formats.
  3. Keep the rest_tests.rs key-format tests green when touching key decoding.

Example fix

// before
let cipher = Aes256Gcm16::new_from_slice(&key).map_err(|e| anyhow::anyhow!("invalid AES key: {e}"))?;

// after
anyhow::ensure!(key.len() == 32, "AES-256 key must be exactly 32 bytes, got {}", key.len());
let cipher = Aes256Gcm16::new_from_slice(&key).map_err(|e| anyhow::anyhow!("invalid AES key: {e}"))?
Defensive patterns

Strategy: validation

Validate before calling

// key_bytes_from_string already guarantees 32 bytes; if you hold a raw key, assert first:
anyhow::ensure!(key.len() == 32, "AES-256 key must be 32 raw bytes, got {}", key.len());
let cipher = Aes256Gcm16::new_from_slice(&key)?;

Try / catch

match Aes256Gcm16::new_from_slice(&key) {
    Ok(cipher) => cipher,
    Err(_) => return Err(anyhow::anyhow!("handoff key is not 32 bytes; check the shared secret format")),
}

Prevention

When it happens

Trigger: Constructing the cipher with a key whose byte length is not exactly 32. In the live path this requires key_bytes_from_string to return a non-32-byte vector, which its own bail at src/api/rest.rs:1213 prevents; it becomes reachable if someone bypasses key_bytes_from_string or edits its 32-byte assertion.

Common situations: Refactoring decrypt_handoff_blob to accept a raw key parameter directly, changing key_bytes_from_string to tolerate other lengths, or unit-testing the cipher step in isolation with an arbitrary-length key string.

Related errors


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