tinyhumansai/openhuman · error

AES-GCM decrypt failed: {e}

Error message

AES-GCM decrypt failed: {e}

What it means

Thrown when the AES-256-GCM cipher rejects the decrypted blob: aes-gcm's decrypt returns aead::Error when authentication fails, meaning the tag does not verify for this key, IV, and ciphertext. The message is generic on purpose because GCM cannot distinguish wrong key from tampered ciphertext from wrong IV/tag ordering.

Source

Thrown at src/api/rest.rs:1162

    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.
/// 3. Standard base64 with padding (legacy backend format).
/// 4. Standard base64 without padding.
/// 5. A raw 32-byte ASCII key (len == 32, used as-is).
///

View on GitHub (pinned to a221052e0d)

Solutions

  1. Verify the shared encryption key matches the backend environment the payload came from (same env/deployment, post-rotation).
  2. Obtain a fresh handoff payload and retry decryption immediately.
  3. Confirm the payload format is exactly IV 16 + tag 16 + ciphertext, base64 (the backend encryptMessageFromString layout).
  4. Reproduce with a known-good round-trip (encrypt with the same key, decrypt) to isolate whether key or payload is at fault.

Example fix

// before
let plain = cipher.decrypt(nonce, ct_with_tag.as_ref()).map_err(|e| anyhow::anyhow!("AES-GCM decrypt failed: {e}"))?;

// after
let plain = cipher.decrypt(nonce, ct_with_tag.as_ref()).map_err(|_| {
    anyhow::anyhow!("AES-GCM decrypt failed: wrong key or corrupted handoff payload; re-check the shared secret and re-fetch the blob")
})?;
Defensive patterns

Strategy: fallback

Try / catch

match decrypt_handoff_blob(&blob, &key) {
    Ok(plain) => { /* proceed */ }
    Err(e) if e.to_string().contains("AES-GCM decrypt failed") => {
        // wrong key or corrupted blob: re-fetch a fresh handoff payload once,
        // and surface a clear 'credentials handoff failed, re-connect' error to the user
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Decrypting a handoff blob with a key that is not the one that encrypted it (e.g. local core pointed at a different backend environment), a payload that was truncated or byte-altered after encryption, or a blob produced by a sender that orders the parts differently than IV(16) + tag(16) + ciphertext.

Common situations: Environment mismatch: the shared handoff secret configured locally differs from the backend's (staging vs production secret, rotated key, key from another deployment). Also corrupted payloads from copy/paste or transport, and version skew after the backend changes its encryption format.

Related errors


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