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:
@@ -63,27 +63,51 @@ impl PostgresAppSecretsRepo {
|
||||
|
||||
fn decode(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
encrypted: Option<Vec<u8>>,
|
||||
nonce: Option<Vec<u8>>,
|
||||
version: i16,
|
||||
) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||
decode_signing_key(&self.master_key, encrypted, nonce)
|
||||
decode_signing_key(&self.master_key, app_id, encrypted, nonce, version)
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit 2026-06-11 H-D1 — AAD binding the realtime signing key to its
|
||||
/// owning app, so a Postgres-write attacker can't swap one app's
|
||||
/// ciphertext under another app's row.
|
||||
fn signing_key_aad(app_id: AppId) -> Vec<u8> {
|
||||
format!("app_secret:{app_id}:realtime_signing_key").into_bytes()
|
||||
}
|
||||
|
||||
/// Current AAD-bound envelope version for the realtime signing key.
|
||||
const SIGNING_KEY_ENVELOPE_V1: i16 = 1;
|
||||
|
||||
/// Resolve the signing key from a row's encrypted columns. The
|
||||
/// plaintext fallback that existed in v1.1.7 is gone — v1.1.8 F1
|
||||
/// drops the column outright (migration 0032 refuses to apply if any
|
||||
/// row still needs encryption, guaranteeing the read path can't see
|
||||
/// a row that only has a plaintext value).
|
||||
///
|
||||
/// Audit 2026-06-11 H-D1: dispatches on `version`. v0 = legacy no-AAD
|
||||
/// (pre-audit rows); v1 = AAD bound to `app_secret:{app_id}:realtime_signing_key`.
|
||||
fn decode_signing_key(
|
||||
master_key: &MasterKey,
|
||||
app_id: AppId,
|
||||
encrypted: Option<Vec<u8>>,
|
||||
nonce: Option<Vec<u8>>,
|
||||
version: i16,
|
||||
) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||
match (encrypted, nonce) {
|
||||
(Some(ct), Some(n)) => {
|
||||
let key = crypto::decrypt(&ct, &n, master_key.as_bytes())
|
||||
.map_err(|_| AppSecretsRepoError::Crypto)?;
|
||||
let key = match version {
|
||||
0 => crypto::decrypt(&ct, &n, master_key.as_bytes()),
|
||||
SIGNING_KEY_ENVELOPE_V1 => {
|
||||
let aad = signing_key_aad(app_id);
|
||||
crypto::decrypt_with_aad(&ct, &n, &aad, master_key.as_bytes())
|
||||
}
|
||||
_ => return Err(AppSecretsRepoError::Crypto),
|
||||
}
|
||||
.map_err(|_| AppSecretsRepoError::Crypto)?;
|
||||
Ok(Some(key))
|
||||
}
|
||||
_ => Ok(None),
|
||||
@@ -98,43 +122,49 @@ impl AppSecretsRepo for PostgresAppSecretsRepo {
|
||||
) -> Result<Vec<u8>, AppSecretsRepoError> {
|
||||
let mut fresh = vec![0u8; SIGNING_KEY_LEN];
|
||||
rand::thread_rng().fill_bytes(&mut fresh);
|
||||
let enc = crypto::encrypt(&fresh, self.master_key.as_bytes());
|
||||
// Audit 2026-06-11 H-D1 — new keys are AAD-bound (v1).
|
||||
let aad = signing_key_aad(app_id);
|
||||
let enc = crypto::encrypt_with_aad(&fresh, &aad, self.master_key.as_bytes());
|
||||
|
||||
// Insert-if-absent (encrypted-only). The racing-creator's insert
|
||||
// is a no-op; the SELECT always returns the winning row.
|
||||
sqlx::query(
|
||||
"INSERT INTO app_secrets \
|
||||
(app_id, realtime_signing_key_encrypted, realtime_signing_key_nonce) \
|
||||
VALUES ($1, $2, $3) ON CONFLICT (app_id) DO NOTHING",
|
||||
(app_id, realtime_signing_key_encrypted, realtime_signing_key_nonce, \
|
||||
realtime_signing_key_version) \
|
||||
VALUES ($1, $2, $3, $4) ON CONFLICT (app_id) DO NOTHING",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(&enc.ciphertext)
|
||||
.bind(&enc.nonce[..])
|
||||
.bind(SIGNING_KEY_ENVELOPE_V1)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let row: (Option<Vec<u8>>, Option<Vec<u8>>) = sqlx::query_as(
|
||||
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce \
|
||||
let row: (Option<Vec<u8>>, Option<Vec<u8>>, i16) = sqlx::query_as(
|
||||
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
|
||||
realtime_signing_key_version \
|
||||
FROM app_secrets WHERE app_id = $1",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
// A row exists by construction, so a key must decode.
|
||||
self.decode(row.0, row.1)?
|
||||
self.decode(app_id, row.0, row.1, row.2)?
|
||||
.ok_or(AppSecretsRepoError::Crypto)
|
||||
}
|
||||
|
||||
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||
let row: Option<(Option<Vec<u8>>, Option<Vec<u8>>)> = sqlx::query_as(
|
||||
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce \
|
||||
let row: Option<(Option<Vec<u8>>, Option<Vec<u8>>, i16)> = sqlx::query_as(
|
||||
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
|
||||
realtime_signing_key_version \
|
||||
FROM app_secrets WHERE app_id = $1",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
match row {
|
||||
Some((e, n)) => self.decode(e, n),
|
||||
Some((e, n, v)) => self.decode(app_id, e, n, v),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -148,18 +178,68 @@ mod tests {
|
||||
MasterKey::from_bytes([9u8; 32])
|
||||
}
|
||||
|
||||
fn app() -> AppId {
|
||||
AppId::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_decodes() {
|
||||
fn legacy_v0_decodes() {
|
||||
let mk = key();
|
||||
let secret = vec![1u8, 2, 3, 4];
|
||||
let enc = crypto::encrypt(&secret, mk.as_bytes());
|
||||
let got = decode_signing_key(&mk, Some(enc.ciphertext), Some(enc.nonce.to_vec())).unwrap();
|
||||
let got = decode_signing_key(
|
||||
&mk,
|
||||
app(),
|
||||
Some(enc.ciphertext),
|
||||
Some(enc.nonce.to_vec()),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, Some(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_aad_round_trips() {
|
||||
let mk = key();
|
||||
let a = app();
|
||||
let secret = vec![7u8; 32];
|
||||
let aad = signing_key_aad(a);
|
||||
let enc = crypto::encrypt_with_aad(&secret, &aad, mk.as_bytes());
|
||||
let got = decode_signing_key(
|
||||
&mk,
|
||||
a,
|
||||
Some(enc.ciphertext),
|
||||
Some(enc.nonce.to_vec()),
|
||||
SIGNING_KEY_ENVELOPE_V1,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(got, Some(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_decode_under_wrong_app_fails() {
|
||||
// Audit 2026-06-11 H-D1 — a row swapped to a different app_id
|
||||
// can't be decrypted: the AAD no longer matches.
|
||||
let mk = key();
|
||||
let a = app();
|
||||
let b = app();
|
||||
let secret = vec![7u8; 32];
|
||||
let aad = signing_key_aad(a);
|
||||
let enc = crypto::encrypt_with_aad(&secret, &aad, mk.as_bytes());
|
||||
let err = decode_signing_key(
|
||||
&mk,
|
||||
b,
|
||||
Some(enc.ciphertext),
|
||||
Some(enc.nonce.to_vec()),
|
||||
SIGNING_KEY_ENVELOPE_V1,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppSecretsRepoError::Crypto));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_columns_is_none() {
|
||||
let got = decode_signing_key(&key(), None, None).unwrap();
|
||||
let got = decode_signing_key(&key(), app(), None, None, 0).unwrap();
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
@@ -168,8 +248,14 @@ mod tests {
|
||||
let secret = vec![3u8; 32];
|
||||
let enc = crypto::encrypt(&secret, key().as_bytes());
|
||||
let other = MasterKey::from_bytes([1u8; 32]);
|
||||
let err =
|
||||
decode_signing_key(&other, Some(enc.ciphertext), Some(enc.nonce.to_vec())).unwrap_err();
|
||||
let err = decode_signing_key(
|
||||
&other,
|
||||
app(),
|
||||
Some(enc.ciphertext),
|
||||
Some(enc.nonce.to_vec()),
|
||||
0,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppSecretsRepoError::Crypto));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user