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

@@ -306,6 +306,9 @@ pub struct CreateEmailTrigger {
pub script_id: ScriptId,
pub inbound_secret_encrypted: Option<Vec<u8>>,
pub inbound_secret_nonce: Option<Vec<u8>>,
/// Envelope version of the sealed inbound secret (0 = legacy no-AAD, 1 =
/// AAD-bound to the sealing owner). `0` when there is no secret.
pub inbound_secret_version: i16,
pub registered_by_principal: AdminUserId,
}
@@ -358,6 +361,13 @@ pub struct EmailInboundTarget {
/// trigger (accepts any POST).
pub inbound_secret_encrypted: Option<Vec<u8>>,
pub inbound_secret_nonce: Option<Vec<u8>>,
/// Envelope version of the sealed secret (0 = legacy no-AAD, 1 = AAD-bound).
pub inbound_secret_version: i16,
/// §M5.5 / audit H-D1: the group this secret was sealed under, when this row
/// is a materialized copy of a group EMAIL TEMPLATE (`materialized_from ->
/// template.group_id`). `None` for a standalone per-app trigger (sealed under
/// the app). Drives the AAD owner at open time for a v1 secret.
pub sealing_group: Option<GroupId>,
}
/// One match for the dispatcher's "which KV triggers fire on this
@@ -820,6 +830,7 @@ pub(crate) async fn insert_email_trigger_tx(
registered_by: AdminUserId,
inbound_secret_encrypted: &[u8],
inbound_secret_nonce: &[u8],
inbound_secret_version: i16,
) -> Result<TriggerId, TriggerRepoError> {
let (owner_app_id, owner_group_id) = match owner {
ScriptOwner::App(a) => (Some(a.into_inner()), None),
@@ -840,12 +851,13 @@ pub(crate) async fn insert_email_trigger_tx(
.await?;
sqlx::query(
"INSERT INTO email_trigger_details \
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
VALUES ($1, $2, $3)",
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce, inbound_secret_version) \
VALUES ($1, $2, $3, $4)",
)
.bind(row.0)
.bind(inbound_secret_encrypted)
.bind(inbound_secret_nonce)
.bind(inbound_secret_version)
.execute(&mut **tx)
.await?;
Ok(row.0.into())
@@ -1306,12 +1318,13 @@ impl TriggerRepo for PostgresTriggerRepo {
sqlx::query(
"INSERT INTO email_trigger_details \
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
VALUES ($1, $2, $3)",
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce, inbound_secret_version) \
VALUES ($1, $2, $3, $4)",
)
.bind(parent.id)
.bind(req.inbound_secret_encrypted.as_deref())
.bind(req.inbound_secret_nonce.as_deref())
.bind(req.inbound_secret_version)
.execute(&mut *tx)
.await?;
@@ -1349,11 +1362,17 @@ impl TriggerRepo for PostgresTriggerRepo {
// row — only its materialized per-app copies are directly invocable
// (mirrors the cron-scheduler / queue-consumer guards). A template
// has no app to run under, so hitting its webhook URL must 404.
// §M5.5 / audit H-D1: `tmpl.group_id` (via `materialized_from`) is the
// SEALING owner for a materialized copy of a group email template — the
// AAD owner needed to open a v1 secret. NULL for a standalone app
// trigger (sealed under the app).
"SELECT t.app_id, t.script_id, t.enabled, t.dispatch_mode, \
t.registered_by_principal, \
d.inbound_secret_encrypted, d.inbound_secret_nonce \
d.inbound_secret_encrypted, d.inbound_secret_nonce, \
d.inbound_secret_version, tmpl.group_id AS sealing_group \
FROM triggers t \
JOIN email_trigger_details d ON d.trigger_id = t.id \
LEFT JOIN triggers tmpl ON tmpl.id = t.materialized_from \
WHERE t.id = $1 AND t.kind = 'email' AND t.app_id IS NOT NULL",
)
.bind(trigger_id.into_inner())
@@ -1367,6 +1386,8 @@ impl TriggerRepo for PostgresTriggerRepo {
registered_by_principal: r.registered_by_principal.into(),
inbound_secret_encrypted: r.inbound_secret_encrypted,
inbound_secret_nonce: r.inbound_secret_nonce,
inbound_secret_version: r.inbound_secret_version,
sealing_group: r.sealing_group.map(Into::into),
}))
}
@@ -2122,6 +2143,8 @@ struct EmailInboundRow {
registered_by_principal: Uuid,
inbound_secret_encrypted: Option<Vec<u8>>,
inbound_secret_nonce: Option<Vec<u8>>,
inbound_secret_version: i16,
sealing_group: Option<Uuid>,
}
#[derive(sqlx::FromRow)]