feat(secrets): AAD-bind email-trigger inbound secrets v0->v1 (Track A M3)

email_trigger_details.inbound_secret_encrypted was sealed v0 (no AAD, bound only
to the master key) because the table had no version column — a ciphertext could
in principle be relocated between rows (audit 2026-06-11 H-D1, Medium). Bring the
AAD versioning the `secrets` table gained in 0042 to email secrets.

- Migration 0069: `email_trigger_details.inbound_secret_version SMALLINT DEFAULT 0`.
- secrets_service: `seal_email`/`open_email` seal v1 with AAD bound to the
  SEALING OWNER (`email:{app}` / `email:group:{group}`) — deliberately NOT the
  per-row trigger_id, so a group template's sealed bytes stay valid when the
  materializer copies them verbatim to each descendant (all share the group AAD).
  v0 rows keep their exact legacy no-AAD read path.
- Both email-trigger create paths (declarative apply resolve_and_seal +
  triggers_api create_email_trigger) seal v1 under the trigger's owner; the
  version threads through CreateEmailTrigger + insert_email_trigger_tx.
- materialize copies inbound_secret_version verbatim with the bytes.
- email_inbound_target recovers the sealing owner (materialized_from ->
  template.group_id, else the app) so receive_inbound_email opens a v1 secret
  under the right AAD.

Cross-app/tenant relocation now fails the GCM tag; a same-owner swap is not
distinguished (accepted low-severity residual). Pinned by
email_secret_aad::materialized_email_copy_decrypts_under_the_group_aad (the
novel materialized-copy path) + the extended secret_round_trips_through_seal_open
lib test. Schema golden reblessed; materialization + email e2e regressions green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 14:58:35 +02:00
parent fd4336e883
commit 1a69778c0c
9 changed files with 396 additions and 41 deletions

View File

@@ -0,0 +1,16 @@
-- §M5.5 / audit 2026-06-11 H-D1: AAD-bind the email-trigger inbound secret.
--
-- `email_trigger_details.inbound_secret_encrypted` was sealed v0 (no AAD, bound
-- only to the master key) because this table had no version column — so a
-- ciphertext could in principle be relocated between rows. The per-app `secrets`
-- table gained AAD versioning in 0042; this brings the same to email secrets.
--
-- The AAD binds to the SEALING OWNER (the group for a template, the app for a
-- standalone trigger) — deliberately NOT the per-row trigger_id, so a group
-- template's sealed bytes stay valid when the materializer copies them verbatim
-- to each descendant app (all share the group AAD). At open time the sealing
-- owner is recovered from `materialized_from -> template.group_id` (else the
-- app). v0 rows keep their exact pre-audit no-AAD read path (default 0).
ALTER TABLE email_trigger_details
ADD COLUMN inbound_secret_version SMALLINT NOT NULL DEFAULT 0;