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:
@@ -0,0 +1,27 @@
|
||||
-- Audit 2026-06-11 H-D1: AES-GCM envelope versioning + AAD binding.
|
||||
--
|
||||
-- Pre-2026-06-11 the per-app secret store, per-app realtime signing
|
||||
-- key, and email-trigger inbound HMAC secret were all AES-256-GCM
|
||||
-- sealed with no Associated Authentication Data. 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.
|
||||
--
|
||||
-- New writes use `crypto::encrypt_with_aad` with a stable identity
|
||||
-- string ("secret:{app_id}:{name}" / "app_secret:{app_id}:realtime_signing_key")
|
||||
-- as AAD, so any cross-row swap fails the GCM auth tag.
|
||||
--
|
||||
-- This migration introduces a per-row `version` column. v0 = legacy
|
||||
-- (no AAD); v1 = AAD-bound. Reads dispatch on the column; new writes
|
||||
-- always emit v1. Existing v0 rows continue to decrypt; the
|
||||
-- re-encryption sweep is deferred to v1.2's planned key-versioning
|
||||
-- pass (see SECURITY_AUDIT.md "Notes on remediation methodology").
|
||||
--
|
||||
-- email_trigger_details.inbound_secret_encrypted retains a v0-only
|
||||
-- path for now (audit-classified Medium; deferred).
|
||||
|
||||
ALTER TABLE secrets
|
||||
ADD COLUMN version SMALLINT NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE app_secrets
|
||||
ADD COLUMN realtime_signing_key_version SMALLINT NOT NULL DEFAULT 0;
|
||||
Reference in New Issue
Block a user