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:
191
crates/manager-core/tests/email_secret_aad.rs
Normal file
191
crates/manager-core/tests/email_secret_aad.rs
Normal file
@@ -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<PgPool> {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user