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
- Verify the shared encryption key matches the backend environment the payload came from (same env/deployment, post-rotation).
- Obtain a fresh handoff payload and retry decryption immediately.
- Confirm the payload format is exactly IV 16 + tag 16 + ciphertext, base64 (the backend encryptMessageFromString layout).
- 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
- Pin the shared encryption key to the same backend environment that issued the handoff blob.
- Treat key rotation as a coordinated change: deploy the new secret on both sides before sending new blobs.
- After any backend crypto format change, verify with a round-trip test before shipping the client.
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
- encrypted payload too short
- invalid AES key: {e}
- encryption key must decode to 32 raw bytes (raw, base64, or
- [tunnel] no device keypair
- [tunnel] no session cipher — handshake incomplete
AI-assisted analysis of tinyhumansai/openhuman@a221052e0d (2026-08-16).
Data as JSON: /api/errors/7b9cb9624bf6ae35.
Report an issue: GitHub.