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

@@ -1342,12 +1342,20 @@ impl ApplyService {
// app trigger resolves the app's secret; a group EMAIL TEMPLATE
// resolves the group's own secret once here, and materialization
// copies the sealed bytes verbatim to each descendant.
let (ct, nonce) = self
let (ct, nonce, version) = self
.resolve_and_seal(owner.secret_owner(), inbound_secret_ref)
.await?;
insert_email_trigger_tx(&mut *tx, owner.as_script_owner(), sid, actor, &ct, &nonce)
.await
.map_err(map_trig)?;
insert_email_trigger_tx(
&mut *tx,
owner.as_script_owner(),
sid,
actor,
&ct,
&nonce,
version,
)
.await
.map_err(map_trig)?;
} else {
let (dispatch, retry_max, backoff, base) = self.trigger_settings(bt);
let details = bundle_trigger_details(
@@ -3009,7 +3017,7 @@ impl ApplyService {
&self,
owner: crate::secrets_service::SecretOwner,
name: &str,
) -> Result<(Vec<u8>, Vec<u8>), ApplyError> {
) -> Result<(Vec<u8>, Vec<u8>, i16), ApplyError> {
let stored = self
.secrets
.get(owner, "*", name)
@@ -3033,13 +3041,18 @@ impl ApplyService {
)))
}
}
let (ct, nonce) = crate::secrets_service::seal_legacy(
// §M5.5 / audit H-D1: seal v1 with AAD bound to the SEALING owner (the
// app for a standalone trigger, the group for a template — stable across
// materialized copies). The email inbound path recovers the same owner to
// open it.
let (ct, nonce, version) = crate::secrets_service::seal_email(
&self.master_key,
owner,
&plaintext,
crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES,
)
.map_err(|e| ApplyError::Invalid(format!("could not seal secret `{name}`: {e}")))?;
Ok((ct, nonce.to_vec()))
Ok((ct, nonce.to_vec(), version))
}
/// Validate each bundle route's host against the app's domain claims —