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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user