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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user