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

@@ -26,7 +26,7 @@ use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::secrets_service::seal_legacy;
use crate::secrets_service::{seal_email, SecretOwner};
use crate::trigger_config::{BackoffShape, TriggerConfig};
use crate::trigger_repo::{
CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateEmailTrigger,
@@ -610,13 +610,12 @@ async fn create_email_trigger(
))
}
};
// 64 KB cap is irrelevant for a signing secret, but `seal_legacy`
// takes one; reuse the secrets default. Audit 2026-06-11 H-D1: the
// email-trigger secret is still sealed v0 (no AAD) — the AAD upgrade
// for this surface is deferred to v1.2 since email_trigger_details
// has no version column yet. See secrets_service::seal_legacy.
let (ct, nonce) = seal_legacy(
// 64 KB cap is irrelevant for a signing secret, but `seal_email` takes one;
// reuse the secrets default. §M5.5 / audit H-D1: sealed v1 with AAD bound to
// the app owner (this is a standalone per-app trigger — never materialized).
let (ct, nonce, version) = seal_email(
&s.master_key,
SecretOwner::App(app_id),
&serde_json::Value::String(secret),
crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES,
)
@@ -628,6 +627,7 @@ async fn create_email_trigger(
script_id: input.script_id,
inbound_secret_encrypted,
inbound_secret_nonce,
inbound_secret_version: version,
registered_by_principal: principal.user_id,
};
let created = s.triggers.create_email_trigger(app_id, req).await?;
@@ -1043,6 +1043,8 @@ mod tests {
registered_by_principal: t.registered_by_principal,
inbound_secret_encrypted: None,
inbound_secret_nonce: None,
inbound_secret_version: 0,
sealing_group: None,
}))
}
async fn create_cron_trigger(