//! §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 { picloud_test_support::abort_if_db_required("email_secret_aad"); 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; }