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:
16
crates/manager-core/migrations/0069_email_secret_version.sql
Normal file
16
crates/manager-core/migrations/0069_email_secret_version.sql
Normal 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;
|
||||
Reference in New Issue
Block a user