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:
@@ -21,10 +21,17 @@ pub enum SecretsRepoError {
|
||||
|
||||
/// An encrypted secret as it lives on disk: ciphertext (auth tag
|
||||
/// appended) plus the nonce it was sealed with.
|
||||
///
|
||||
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
|
||||
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
|
||||
/// AES-GCM with AAD bound to `"secret:{app_id}:{name}"`. Migration
|
||||
/// `0042_secrets_envelope_version.sql` adds the column with a default
|
||||
/// of `0`, so existing rows keep working.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredSecret {
|
||||
pub encrypted_value: Vec<u8>,
|
||||
pub nonce: Vec<u8>,
|
||||
pub version: i16,
|
||||
}
|
||||
|
||||
/// Admin-surface metadata for one secret. Values are never returned —
|
||||
@@ -59,13 +66,15 @@ pub trait SecretsRepo: Send + Sync {
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError>;
|
||||
|
||||
/// Upsert (overwrite if present).
|
||||
/// Upsert (overwrite if present). `version` is the AES-GCM envelope
|
||||
/// discriminator from [`StoredSecret::version`].
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError>;
|
||||
|
||||
/// Delete; returns whether a row was present.
|
||||
@@ -129,16 +138,18 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
let row: Option<(Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
||||
"SELECT encrypted_value, nonce FROM secrets WHERE app_id = $1 AND name = $2",
|
||||
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(
|
||||
"SELECT encrypted_value, nonce, version FROM secrets \
|
||||
WHERE app_id = $1 AND name = $2",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(encrypted_value, nonce)| StoredSecret {
|
||||
Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret {
|
||||
encrypted_value,
|
||||
nonce,
|
||||
version,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -148,19 +159,22 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (app_id, name, encrypted_value, nonce) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
"INSERT INTO secrets (app_id, name, encrypted_value, nonce, version) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (app_id, name) DO UPDATE \
|
||||
SET encrypted_value = EXCLUDED.encrypted_value, \
|
||||
nonce = EXCLUDED.nonce, \
|
||||
version = EXCLUDED.version, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(encrypted_value)
|
||||
.bind(nonce)
|
||||
.bind(version)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user