From 1a69778c0c163b91621c4756387f9dff4a07aa60 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 11 Jul 2026 14:58:35 +0200 Subject: [PATCH] feat(secrets): AAD-bind email-trigger inbound secrets v0->v1 (Track A M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migrations/0069_email_secret_version.sql | 16 ++ crates/manager-core/src/apply_service.rs | 27 ++- crates/manager-core/src/email_inbound_api.rs | 56 ++++- crates/manager-core/src/materialize.rs | 15 +- crates/manager-core/src/secrets_service.rs | 81 +++++++- crates/manager-core/src/trigger_repo.rs | 33 ++- crates/manager-core/src/triggers_api.rs | 16 +- crates/manager-core/tests/email_secret_aad.rs | 191 ++++++++++++++++++ crates/manager-core/tests/expected_schema.txt | 2 + 9 files changed, 396 insertions(+), 41 deletions(-) create mode 100644 crates/manager-core/migrations/0069_email_secret_version.sql create mode 100644 crates/manager-core/tests/email_secret_aad.rs diff --git a/crates/manager-core/migrations/0069_email_secret_version.sql b/crates/manager-core/migrations/0069_email_secret_version.sql new file mode 100644 index 0000000..ee47fa0 --- /dev/null +++ b/crates/manager-core/migrations/0069_email_secret_version.sql @@ -0,0 +1,16 @@ +-- §M5.5 / audit 2026-06-11 H-D1: AAD-bind the email-trigger inbound secret. +-- +-- `email_trigger_details.inbound_secret_encrypted` was sealed v0 (no AAD, bound +-- only to the master key) because this table had no version column — so a +-- ciphertext could in principle be relocated between rows. The per-app `secrets` +-- table gained AAD versioning in 0042; this brings the same to email secrets. +-- +-- The AAD binds to the SEALING OWNER (the group for a template, the app for a +-- standalone trigger) — 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 app (all share the group AAD). At open time the sealing +-- owner is recovered from `materialized_from -> template.group_id` (else the +-- app). v0 rows keep their exact pre-audit no-AAD read path (default 0). + +ALTER TABLE email_trigger_details + ADD COLUMN inbound_secret_version SMALLINT NOT NULL DEFAULT 0; diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 50da079..12f0d3e 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -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, Vec), ApplyError> { + ) -> Result<(Vec, Vec, 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 — diff --git a/crates/manager-core/src/email_inbound_api.rs b/crates/manager-core/src/email_inbound_api.rs index 05ab52e..a289731 100644 --- a/crates/manager-core/src/email_inbound_api.rs +++ b/crates/manager-core/src/email_inbound_api.rs @@ -36,7 +36,7 @@ use serde_json::json; use sha2::{Digest, Sha256}; use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind}; -use crate::secrets_service::open_legacy; +use crate::secrets_service::{open_email, SecretOwner}; use crate::trigger_repo::TriggerRepo; type HmacSha256 = Hmac; @@ -244,7 +244,20 @@ async fn receive_inbound_email( s.bad_sig_limiter.record_failure(app_id, trigger_id); return Err(EmailInboundError::Unauthorized); }; - let secret = decrypt_secret(&s.master_key, ct, nonce)?; + // §M5.5 / audit H-D1: recover the SEALING owner for a v1 (AAD-bound) secret — + // the source-template group for a materialized copy, else this app. v0 rows + // ignore the owner (legacy no-AAD path). + let sealing_owner = match target.sealing_group { + Some(group_id) => SecretOwner::Group(group_id), + None => SecretOwner::App(app_id), + }; + let secret = decrypt_secret( + &s.master_key, + sealing_owner, + ct, + nonce, + target.inbound_secret_version, + )?; if let Err(err) = verify_signature(&headers, &body, secret.as_bytes(), &s.nonce_dedup) { s.bad_sig_limiter.record_failure(app_id, trigger_id); return Err(err); @@ -286,16 +299,18 @@ async fn receive_inbound_email( Ok(StatusCode::ACCEPTED) } -/// Decrypt the stored inbound secret back to its raw string. It was -/// sealed as a JSON string by the admin layer (v0, no AAD — see -/// secrets_service::seal_legacy), so `open_legacy` yields a +/// Decrypt the stored inbound secret back to its raw string. Sealed as a JSON +/// string by the admin/apply layer; `open_email` dispatches on `version` (v0 = +/// legacy no-AAD, v1 = AAD bound to the sealing `owner`), yielding a /// `Value::String`. fn decrypt_secret( master_key: &MasterKey, + owner: SecretOwner, ciphertext: &[u8], nonce: &[u8], + version: i16, ) -> Result { - let value = open_legacy(master_key, ciphertext, nonce).map_err(|_| { + let value = open_email(master_key, owner, ciphertext, nonce, version).map_err(|_| { // Corrupted secret means we can't verify — fail closed (401). EmailInboundError::Unauthorized })?; @@ -421,8 +436,9 @@ mod tests { //! Postgres in `crates/picloud/tests/email_inbound.rs`. use super::*; - use crate::secrets_service::seal_legacy; use crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES; + use crate::secrets_service::{seal_email, seal_legacy}; + use picloud_shared::{AppId, GroupId}; fn sign(secret: &[u8], ts: i64, body: &[u8]) -> String { let mut mac = HmacSha256::new_from_slice(secret).unwrap(); @@ -520,14 +536,36 @@ mod tests { #[test] fn secret_round_trips_through_seal_open() { let key = MasterKey::from_bytes([3u8; 32]); - let (ct, nonce) = seal_legacy( + // v0 (legacy no-AAD) still opens — backward compatibility for pre-M3 rows. + let (ct0, nonce0) = seal_legacy( &key, &serde_json::Value::String("provider-secret".into()), DEFAULT_SECRET_MAX_VALUE_BYTES, ) .unwrap(); - let recovered = decrypt_secret(&key, &ct, &nonce).unwrap(); + let owner = SecretOwner::App(AppId::from(uuid::Uuid::from_u128(1))); + let recovered = decrypt_secret(&key, owner, &ct0, &nonce0, 0).unwrap(); assert_eq!(recovered, "provider-secret"); + + // v1 (AAD bound to the sealing owner) round-trips under the SAME owner. + let (ct1, nonce1, ver) = seal_email( + &key, + owner, + &serde_json::Value::String("provider-secret".into()), + DEFAULT_SECRET_MAX_VALUE_BYTES, + ) + .unwrap(); + assert_eq!(ver, 1); + assert_eq!( + decrypt_secret(&key, owner, &ct1, &nonce1, ver).unwrap(), + "provider-secret" + ); + // A DIFFERENT owner (cross-tenant relocation) fails the GCM tag → 401. + let other = SecretOwner::Group(GroupId::from(uuid::Uuid::from_u128(2))); + assert!( + decrypt_secret(&key, other, &ct1, &nonce1, ver).is_err(), + "a v1 secret must not open under a different sealing owner" + ); let body = br#"{"from":"x@y.com"}"#; let ts = now_ts(); let sig = sign(recovered.as_bytes(), ts, body); diff --git a/crates/manager-core/src/materialize.rs b/crates/manager-core/src/materialize.rs index 9f43339..088f5da 100644 --- a/crates/manager-core/src/materialize.rs +++ b/crates/manager-core/src/materialize.rs @@ -226,14 +226,17 @@ async fn materialize_one( "email" => { // §M5.5: the group template sealed its inbound HMAC secret against // the GROUP's own store once at apply (shared-group-secret model). - // The secret is v0 (no AAD) — bound only to the master key, not the - // owning row — so a verbatim byte-copy is a valid per-app secret; no - // master key is needed here. Each copy has its own trigger_id (its - // own inbound webhook URL). + // The secret's AAD (§M5.5 / audit H-D1) is bound to the GROUP owner, + // NOT this copy's row, so a verbatim byte-copy stays openable — the + // inbound path recovers the group via `materialized_from`. Copy the + // version verbatim too. Each copy has its own trigger_id (its own + // inbound webhook URL). sqlx::query( "INSERT INTO email_trigger_details \ - (trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \ - SELECT $1, inbound_secret_encrypted, inbound_secret_nonce \ + (trigger_id, inbound_secret_encrypted, inbound_secret_nonce, \ + inbound_secret_version) \ + SELECT $1, inbound_secret_encrypted, inbound_secret_nonce, \ + inbound_secret_version \ FROM email_trigger_details WHERE trigger_id = $2", ) .bind(new_id.0) diff --git a/crates/manager-core/src/secrets_service.rs b/crates/manager-core/src/secrets_service.rs index 670f13a..b36a4a2 100644 --- a/crates/manager-core/src/secrets_service.rs +++ b/crates/manager-core/src/secrets_service.rs @@ -167,13 +167,11 @@ pub fn open( serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted) } -/// Legacy v0 seal (no AAD, no version) used by the email-trigger -/// inbound-secret path. The `email_trigger_details` table has no -/// `version` column yet, so the AAD-binding upgrade for that surface is -/// **deferred to v1.2's key-versioning pass** (audit 2026-06-11 H-D1 -/// classifies the email-trigger AAD gap as Medium). Both this and -/// [`open_legacy`] preserve the exact pre-audit wire format so existing -/// email-trigger secrets keep decrypting. +/// Legacy v0 seal (no AAD, no version). The email-trigger path now seals v1 via +/// [`seal_email`] (Track A M3 / audit 2026-06-11 H-D1 closed — `email_trigger_details` +/// gained an `inbound_secret_version` column in 0069). This and [`open_legacy`] +/// remain to READ pre-M3 v0 rows that haven't been re-applied, preserving the +/// exact pre-audit wire format so existing email-trigger secrets keep decrypting. /// /// # Errors /// @@ -211,6 +209,75 @@ pub fn open_legacy( serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted) } +/// §M5.5 / audit 2026-06-11 H-D1 — AAD for an email-trigger inbound secret. +/// Binds to the SEALING OWNER only (no per-row trigger id / name), so a group +/// template's sealed bytes stay valid when the materializer copies them verbatim +/// to each descendant (all share the group AAD). The `email:` prefix keeps this +/// namespace disjoint from the per-app `secrets` table's `secret:` AAD, so an +/// email ciphertext can't be swapped in for a same-owner secrets-table entry. +/// A swap ACROSS owners (app↔app, group↔group) fails the GCM tag — the +/// cross-tenant relocation the audit flags. (A swap between two email triggers +/// of the SAME owner is not distinguished — an accepted, low-severity residual.) +fn email_secret_aad(owner: SecretOwner) -> Vec { + match owner { + SecretOwner::App(app_id) => format!("email:{app_id}"), + SecretOwner::Group(group_id) => format!("email:group:{group_id}"), + } + .into_bytes() +} + +/// Seal an email-trigger inbound secret at envelope v1 (AAD = the sealing +/// owner). Returns `(ciphertext, nonce, version)`. The value is a JSON string +/// (the HMAC key). Replaces the email path's former [`seal_legacy`] (v0). +/// +/// # Errors +/// +/// [`SecretsError::TooLarge`] / [`SecretsError::Backend`] as for [`seal`]. +pub fn seal_email( + master_key: &MasterKey, + owner: SecretOwner, + value: &serde_json::Value, + max_value_bytes: usize, +) -> Result<(Vec, [u8; crypto::NONCE_LEN], i16), SecretsError> { + let plaintext = serde_json::to_vec(value) + .map_err(|e| SecretsError::Backend(format!("encode secret value: {e}")))?; + if plaintext.len() > max_value_bytes { + return Err(SecretsError::TooLarge { + limit: max_value_bytes, + actual: plaintext.len(), + }); + } + let aad = email_secret_aad(owner); + let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes()); + Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1)) +} + +/// Open an email-trigger inbound secret, dispatching on `version`: v0 is the +/// legacy no-AAD layout ([`open_legacy`], owner ignored); v1 binds AAD to the +/// sealing `owner`. The caller recovers the sealing owner (a materialized copy's +/// source-template group, else the app) before calling. +/// +/// # Errors +/// +/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails. +pub fn open_email( + master_key: &MasterKey, + owner: SecretOwner, + ciphertext: &[u8], + nonce: &[u8], + version: i16, +) -> Result { + let plaintext = match version { + SECRET_ENVELOPE_V1 => { + let aad = email_secret_aad(owner); + crypto::decrypt_with_aad(ciphertext, nonce, &aad, master_key.as_bytes()) + } + _ => crypto::decrypt(ciphertext, nonce, master_key.as_bytes()), + } + .map_err(|_| SecretsError::Corrupted)?; + serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted) +} + pub struct SecretsServiceImpl { repo: Arc, authz: Arc, diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs index 32448c3..bee6435 100644 --- a/crates/manager-core/src/trigger_repo.rs +++ b/crates/manager-core/src/trigger_repo.rs @@ -306,6 +306,9 @@ pub struct CreateEmailTrigger { pub script_id: ScriptId, pub inbound_secret_encrypted: Option>, pub inbound_secret_nonce: Option>, + /// 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>, pub inbound_secret_nonce: Option>, + /// 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, } /// 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 { 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>, inbound_secret_nonce: Option>, + inbound_secret_version: i16, + sealing_group: Option, } #[derive(sqlx::FromRow)] diff --git a/crates/manager-core/src/triggers_api.rs b/crates/manager-core/src/triggers_api.rs index a6e98d7..ddb3474 100644 --- a/crates/manager-core/src/triggers_api.rs +++ b/crates/manager-core/src/triggers_api.rs @@ -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( diff --git a/crates/manager-core/tests/email_secret_aad.rs b/crates/manager-core/tests/email_secret_aad.rs new file mode 100644 index 0000000..8adb615 --- /dev/null +++ b/crates/manager-core/tests/email_secret_aad.rs @@ -0,0 +1,191 @@ +//! §M5.5 / audit 2026-06-11 H-D1: a MATERIALIZED group email-template copy +//! decrypts its inbound secret under the recovered GROUP AAD. +//! +//! This pins M3's novel path: a group email template seals its inbound secret v1 +//! with AAD bound to the GROUP; the materializer copies the sealed bytes + +//! version verbatim into a per-app copy (app_id set, `materialized_from` = the +//! template); at inbound time `email_inbound_target` must recover the sealing +//! group via `materialized_from` so `open_email` opens the group-sealed bytes. +//! Sealing under the app instead (this copy's own owner) would fail the GCM tag. +//! +//! Skips cleanly when `DATABASE_URL` is unset. + +#![allow(clippy::too_many_lines)] + +use picloud_manager_core::secrets_service::{open_email, seal_email, SecretOwner}; +use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo}; +use picloud_shared::{GroupId, MasterKey}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use uuid::Uuid; + +async fn pool_or_skip() -> Option { + let Ok(url) = std::env::var("DATABASE_URL") else { + eprintln!("email_secret_aad: DATABASE_URL unset — skipping"); + return None; + }; + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .expect("connect"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrate"); + Some(pool) +} + +fn uniq(p: &str) -> String { + format!("{p}-{}", &Uuid::new_v4().to_string()[..8]) +} + +#[tokio::test] +async fn materialized_email_copy_decrypts_under_the_group_aad() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let key = MasterKey::from_bytes([7u8; 32]); + + // A group + a descendant app + a group-owned handler script. + let (gid,): (Uuid,) = + sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id") + .bind(uniq("esa-g")) + .fetch_one(&pool) + .await + .unwrap(); + let (app_id,): (Uuid,) = + sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id") + .bind(uniq("esa-a")) + .bind(gid) + .fetch_one(&pool) + .await + .unwrap(); + let (admin_id,): (Uuid,) = sqlx::query_as( + "INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id", + ) + .bind(uniq("esa-u")) + .fetch_one(&pool) + .await + .unwrap(); + let (script_id,): (Uuid,) = sqlx::query_as( + "INSERT INTO scripts (group_id, name, source) \ + VALUES ($1, $2, 'log::info(\"x\")') RETURNING id", + ) + .bind(gid) + .bind(uniq("inbound")) + .fetch_one(&pool) + .await + .unwrap(); + + // Seal the inbound secret v1 under the GROUP (the shared-group-secret model), + // and insert the group EMAIL TEMPLATE (group_id set, app_id NULL). + let secret = serde_json::Value::String("group-hmac-key".into()); + let (ct, nonce, version) = + seal_email(&key, SecretOwner::Group(GroupId::from(gid)), &secret, 65536).unwrap(); + assert_eq!(version, 1); + let (tmpl_id,): (Uuid,) = sqlx::query_as( + "INSERT INTO triggers (group_id, script_id, kind, enabled, dispatch_mode, \ + retry_max_attempts, retry_backoff, retry_base_ms, registered_by_principal) \ + VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id", + ) + .bind(gid) + .bind(script_id) + .bind(admin_id) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO email_trigger_details \ + (trigger_id, inbound_secret_encrypted, inbound_secret_nonce, inbound_secret_version) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(tmpl_id) + .bind(&ct) + .bind(nonce.to_vec()) + .bind(version) + .execute(&pool) + .await + .unwrap(); + + // Materialize a per-app copy: app-owned, `materialized_from` = the template, + // sealed bytes + version copied VERBATIM (no reseal — the AAD is the group's). + let (copy_id,): (Uuid,) = sqlx::query_as( + "INSERT INTO triggers (app_id, script_id, kind, enabled, dispatch_mode, \ + retry_max_attempts, retry_backoff, retry_base_ms, registered_by_principal, \ + materialized_from) \ + VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id", + ) + .bind(app_id) + .bind(script_id) + .bind(admin_id) + .bind(tmpl_id) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO email_trigger_details \ + (trigger_id, inbound_secret_encrypted, inbound_secret_nonce, inbound_secret_version) \ + SELECT $1, inbound_secret_encrypted, inbound_secret_nonce, inbound_secret_version \ + FROM email_trigger_details WHERE trigger_id = $2", + ) + .bind(copy_id) + .bind(tmpl_id) + .execute(&pool) + .await + .unwrap(); + + // The inbound target for the COPY recovers the sealing group + version, and + // the group-sealed bytes decrypt under Group(gid) — the exact path the + // webhook receiver takes. + let repo = PostgresTriggerRepo::new(pool.clone()); + let target = repo + .email_inbound_target(copy_id.into()) + .await + .unwrap() + .expect("materialized copy is a valid inbound target"); + assert_eq!( + target.sealing_group.map(GroupId::into_inner), + Some(gid), + "the copy recovers its template's group as the sealing owner" + ); + assert_eq!(target.inbound_secret_version, 1); + let owner = SecretOwner::Group(target.sealing_group.unwrap()); + let recovered = open_email( + &key, + owner, + target.inbound_secret_encrypted.as_ref().unwrap(), + target.inbound_secret_nonce.as_ref().unwrap(), + target.inbound_secret_version, + ) + .expect("group-sealed bytes decrypt under the recovered group AAD"); + assert_eq!(recovered, secret); + + // Opening under the COPY's OWN app owner (the wrong owner) must fail — proof + // the AAD binds to the group, not the per-app row. + assert!( + open_email( + &key, + SecretOwner::App(app_id.into()), + target.inbound_secret_encrypted.as_ref().unwrap(), + target.inbound_secret_nonce.as_ref().unwrap(), + target.inbound_secret_version, + ) + .is_err(), + "the group-sealed secret must NOT open under the copy's app owner" + ); + + // Cleanup (triggers/details/scripts cascade on group+app delete). + let _ = sqlx::query("DELETE FROM apps WHERE id = $1") + .bind(app_id) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM groups WHERE id = $1") + .bind(gid) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1") + .bind(admin_id) + .execute(&pool) + .await; +} diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index 30664af..18fac45 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -181,6 +181,7 @@ table: email_trigger_details trigger_id: uuid NOT NULL inbound_secret_encrypted: bytea NULL inbound_secret_nonce: bytea NULL + inbound_secret_version: smallint NOT NULL default=0 table: execution_logs id: uuid NOT NULL default=gen_random_uuid() @@ -1033,3 +1034,4 @@ constraints on vars: 0066: projects 0067: project environments 0068: group dead letters + 0069: email secret version