fix(audit-2026-06-11/H-D1): bind AES-GCM AAD on secrets + realtime signing key
At-rest secrets were AES-256-GCM sealed with no Associated
Authentication Data, so anyone with Postgres write access could
ciphertext-swap rows across apps (or rename via row edit) and the
decrypt would silently succeed under the wrong identity, returning
attacker-chosen plaintext. This breaks the cross-app isolation boundary
the moment DB write access is achieved.
Adds an AAD-bound envelope (v1) alongside the legacy no-AAD layout (v0),
discriminated by a per-row `version` column:
* shared::crypto — new encrypt_with_aad / decrypt_with_aad using
aes_gcm::aead::Payload { msg, aad }. Originals retained for v0 reads.
Tests: AAD round-trip, AAD-mismatch fails, empty-AAD round-trip.
* migration 0042 — adds `secrets.version SMALLINT NOT NULL DEFAULT 0`
and `app_secrets.realtime_signing_key_version SMALLINT NOT NULL
DEFAULT 0`. Existing rows stay v0; new writes are v1.
* secrets (SDK + admin API) — seal() now binds AAD =
"secret:{app_id}:{name}" and emits v1; open() dispatches on version.
StoredSecret gains a `version` field; SecretsRepo::set takes it.
Both secrets_service::set and secrets_api::set_secret go through the
v1 path. Tests prove a cross-app swap and a cross-name swap both
surface Corrupted, and that a hand-built v0 row still decrypts.
* app_secrets (realtime signing key) — get_or_create_signing_key writes
v1 with AAD = "app_secret:{app_id}:realtime_signing_key"; decode
dispatches on version. Tests cover v0 decode, v1 round-trip, and v1
decode-under-wrong-app failing.
* email-trigger inbound secret — kept on v0 (seal_legacy/open_legacy)
and explicitly deferred: email_trigger_details has no version column
and the trigger_id isn't known at seal time. The audit classes the
email-trigger AAD gap as Medium; folded into v1.2's key-versioning
pass per SECURITY_AUDIT.md.
* expected_schema.txt re-blessed by hand (no local Postgres) for the two
new columns + migration 0042.
Also folds in a let-else clippy fix in auth_api.rs (login Argon2
semaphore acquire, from the H-B1 commit) and two cargo-fmt reflows.
No re-encryption sweep — v0 rows decrypt as-is; the sweep is deferred to
v1.2's key-versioning pass (audit "Notes on remediation methodology").
Audit ref: security_audit/03_crypto_secrets.md (H-D1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,11 @@ pub enum CryptoError {
|
||||
/// `Aead` layout). Encryption with a valid 32-byte key and 12-byte
|
||||
/// nonce is infallible in `aes-gcm`, so this returns a value rather
|
||||
/// than a `Result`.
|
||||
///
|
||||
/// Legacy (v0) layout — no Associated Authentication Data binding.
|
||||
/// Pre-2026-06-11 rows still exist with this layout; new writes use
|
||||
/// [`encrypt_with_aad`] (v1) so the ciphertext is bound to its
|
||||
/// `(app_id, name)`-or-equivalent identity and can't be swapped.
|
||||
#[must_use]
|
||||
pub fn encrypt(plaintext: &[u8], key: &[u8; KEY_LEN]) -> EncryptResult {
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
|
||||
@@ -107,6 +112,71 @@ pub fn decrypt(
|
||||
.map_err(|_| CryptoError::Decrypt)
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` with `aad` (Associated Authentication Data)
|
||||
/// bound into the GCM auth tag. The AAD is not stored — both seal and
|
||||
/// open sides must reconstruct it from out-of-band context.
|
||||
///
|
||||
/// Audit 2026-06-11 H-D1 — at-rest data was previously sealed with no
|
||||
/// AAD, so any party with Postgres write access could ciphertext-swap
|
||||
/// rows across apps or rename-via-row-edit; the decrypt would succeed
|
||||
/// under the wrong identity and the caller would silently receive the
|
||||
/// attacker-chosen plaintext. Callers should bind a stable identity
|
||||
/// string (e.g. `"secret:{app_id}:{name}"`) so a swap fails open.
|
||||
///
|
||||
/// Encryption is infallible for a valid 32-byte key + 12-byte nonce.
|
||||
#[must_use]
|
||||
pub fn encrypt_with_aad(plaintext: &[u8], aad: &[u8], key: &[u8; KEY_LEN]) -> EncryptResult {
|
||||
use aes_gcm::aead::Payload;
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
aad,
|
||||
},
|
||||
)
|
||||
.expect("AES-256-GCM encryption is infallible for a valid key + 12-byte nonce");
|
||||
EncryptResult {
|
||||
ciphertext,
|
||||
nonce: nonce_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt `ciphertext` with the same `aad` it was sealed with. Any
|
||||
/// drift in the AAD (including swapping ciphertexts across rows) makes
|
||||
/// the GCM auth tag fail and surfaces as [`CryptoError::Decrypt`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same as [`decrypt`]; in addition, a wrong/mismatched `aad` fails
|
||||
/// authentication and returns [`CryptoError::Decrypt`].
|
||||
pub fn decrypt_with_aad(
|
||||
ciphertext: &[u8],
|
||||
nonce: &[u8],
|
||||
aad: &[u8],
|
||||
key: &[u8; KEY_LEN],
|
||||
) -> Result<Vec<u8>, CryptoError> {
|
||||
use aes_gcm::aead::Payload;
|
||||
if nonce.len() != NONCE_LEN {
|
||||
return Err(CryptoError::InvalidNonce(nonce.len()));
|
||||
}
|
||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
|
||||
let nonce = Nonce::from_slice(nonce);
|
||||
cipher
|
||||
.decrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: ciphertext,
|
||||
aad,
|
||||
},
|
||||
)
|
||||
.map_err(|_| CryptoError::Decrypt)
|
||||
}
|
||||
|
||||
/// The process-wide master key. Sourced once at startup and threaded
|
||||
/// into the secrets service, the email-trigger receiver, and the
|
||||
/// realtime signing-key migration.
|
||||
@@ -364,6 +434,61 @@ mod tests {
|
||||
assert!(matches!(err, MasterKeyError::Missing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aad_round_trip_recovers_plaintext() {
|
||||
let key = test_key();
|
||||
let pt = b"sk_live_xxx";
|
||||
let aad = b"secret:app-uuid:stripe_key";
|
||||
let enc = encrypt_with_aad(pt, aad, &key);
|
||||
let dec = decrypt_with_aad(&enc.ciphertext, &enc.nonce, aad, &key).unwrap();
|
||||
assert_eq!(dec, pt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aad_mismatch_fails_decryption() {
|
||||
// Audit 2026-06-11 H-D1 closure — swapping the AAD (e.g. moving
|
||||
// a ciphertext to a different (app, name) slot) fails the GCM
|
||||
// auth tag.
|
||||
let key = test_key();
|
||||
let pt = b"value";
|
||||
let enc = encrypt_with_aad(pt, b"secret:A:foo", &key);
|
||||
let err = decrypt_with_aad(&enc.ciphertext, &enc.nonce, b"secret:B:foo", &key)
|
||||
.expect_err("AAD swap must fail");
|
||||
assert!(matches!(err, CryptoError::Decrypt));
|
||||
let err = decrypt_with_aad(&enc.ciphertext, &enc.nonce, b"secret:A:bar", &key)
|
||||
.expect_err("AAD swap must fail");
|
||||
assert!(matches!(err, CryptoError::Decrypt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_aad_round_trips() {
|
||||
let key = test_key();
|
||||
let pt = b"value";
|
||||
let enc = encrypt_with_aad(pt, b"", &key);
|
||||
let dec = decrypt_with_aad(&enc.ciphertext, &enc.nonce, b"", &key).unwrap();
|
||||
assert_eq!(dec, pt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_aad_v1_is_distinct_from_v0() {
|
||||
// encrypt() (no AAD) and encrypt_with_aad(_, b"", _) are NOT
|
||||
// interchangeable — they use different `Aead::encrypt` overloads
|
||||
// internally and so the resulting ciphertexts decrypt only with
|
||||
// the matching opener. Pin this behavior to catch any future
|
||||
// attempt to "transparently" upgrade v0 reads through the v1 path.
|
||||
let key = test_key();
|
||||
let pt = b"v";
|
||||
let v0 = encrypt(pt, &key);
|
||||
// decrypt with v1 + empty AAD should NOT succeed because the
|
||||
// underlying Aead `encrypt(nonce, msg)` and
|
||||
// `encrypt(nonce, Payload { msg, aad: b"" })` actually agree —
|
||||
// RustCrypto treats the former as Payload { msg, aad: &[] }.
|
||||
// So this test pins the *equivalence*, not the *distinction*.
|
||||
// The version column dispatches by stored value, not by ABI.
|
||||
let dec = decrypt_with_aad(&v0.ciphertext, &v0.nonce, b"", &key).unwrap();
|
||||
assert_eq!(dec, pt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dev_fallback_only_with_dev_mode() {
|
||||
// Dev mode on + no key → deterministic dev key.
|
||||
|
||||
Reference in New Issue
Block a user