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:
16
crates/manager-core/migrations/0069_email_secret_version.sql
Normal file
16
crates/manager-core/migrations/0069_email_secret_version.sql
Normal file
@@ -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;
|
||||
@@ -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 —
|
||||
|
||||
@@ -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<Sha256>;
|
||||
@@ -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<String, EmailInboundError> {
|
||||
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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<u8> {
|
||||
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>, [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<serde_json::Value, SecretsError> {
|
||||
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<dyn SecretsRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
|
||||
@@ -306,6 +306,9 @@ pub struct CreateEmailTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub inbound_secret_encrypted: Option<Vec<u8>>,
|
||||
pub inbound_secret_nonce: Option<Vec<u8>>,
|
||||
/// 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<Vec<u8>>,
|
||||
pub inbound_secret_nonce: Option<Vec<u8>>,
|
||||
/// 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<GroupId>,
|
||||
}
|
||||
|
||||
/// 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<TriggerId, TriggerRepoError> {
|
||||
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<Vec<u8>>,
|
||||
inbound_secret_nonce: Option<Vec<u8>>,
|
||||
inbound_secret_version: i16,
|
||||
sealing_group: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -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(
|
||||
|
||||
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