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