fix(audit-2026-06-11/H-D1): bind AES-GCM AAD on secrets + realtime signing key

At-rest secrets were AES-256-GCM sealed with no Associated
Authentication Data, so anyone with Postgres write access could
ciphertext-swap rows across apps (or rename via row edit) and the
decrypt would silently succeed under the wrong identity, returning
attacker-chosen plaintext. This breaks the cross-app isolation boundary
the moment DB write access is achieved.

Adds an AAD-bound envelope (v1) alongside the legacy no-AAD layout (v0),
discriminated by a per-row `version` column:

* shared::crypto — new encrypt_with_aad / decrypt_with_aad using
  aes_gcm::aead::Payload { msg, aad }. Originals retained for v0 reads.
  Tests: AAD round-trip, AAD-mismatch fails, empty-AAD round-trip.

* migration 0042 — adds `secrets.version SMALLINT NOT NULL DEFAULT 0`
  and `app_secrets.realtime_signing_key_version SMALLINT NOT NULL
  DEFAULT 0`. Existing rows stay v0; new writes are v1.

* secrets (SDK + admin API) — seal() now binds AAD =
  "secret:{app_id}:{name}" and emits v1; open() dispatches on version.
  StoredSecret gains a `version` field; SecretsRepo::set takes it.
  Both secrets_service::set and secrets_api::set_secret go through the
  v1 path. Tests prove a cross-app swap and a cross-name swap both
  surface Corrupted, and that a hand-built v0 row still decrypts.

* app_secrets (realtime signing key) — get_or_create_signing_key writes
  v1 with AAD = "app_secret:{app_id}:realtime_signing_key"; decode
  dispatches on version. Tests cover v0 decode, v1 round-trip, and v1
  decode-under-wrong-app failing.

* email-trigger inbound secret — kept on v0 (seal_legacy/open_legacy)
  and explicitly deferred: email_trigger_details has no version column
  and the trigger_id isn't known at seal time. The audit classes the
  email-trigger AAD gap as Medium; folded into v1.2's key-versioning
  pass per SECURITY_AUDIT.md.

* expected_schema.txt re-blessed by hand (no local Postgres) for the two
  new columns + migration 0042.

Also folds in a let-else clippy fix in auth_api.rs (login Argon2
semaphore acquire, from the H-B1 commit) and two cargo-fmt reflows.

No re-encryption sweep — v0 rows decrypt as-is; the sweep is deferred to
v1.2's key-versioning pass (audit "Notes on remediation methodology").

Audit ref: security_audit/03_crypto_secrets.md (H-D1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-12 17:15:09 +02:00
parent 34c63f31be
commit 513c4a2d3c
12 changed files with 515 additions and 62 deletions

View File

@@ -36,8 +36,7 @@ use serde_json::json;
use sha2::{Digest, Sha256};
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
use crate::secrets_repo::StoredSecret;
use crate::secrets_service::open;
use crate::secrets_service::open_legacy;
use crate::trigger_repo::TriggerRepo;
type HmacSha256 = Hmac<Sha256>;
@@ -278,18 +277,15 @@ async fn receive_inbound_email(
}
/// Decrypt the stored inbound secret back to its raw string. It was
/// sealed as a JSON string by the admin layer, so `open` yields a
/// sealed as a JSON string by the admin layer (v0, no AAD — see
/// secrets_service::seal_legacy), so `open_legacy` yields a
/// `Value::String`.
fn decrypt_secret(
master_key: &MasterKey,
ciphertext: &[u8],
nonce: &[u8],
) -> Result<String, EmailInboundError> {
let stored = StoredSecret {
encrypted_value: ciphertext.to_vec(),
nonce: nonce.to_vec(),
};
let value = open(master_key, &stored).map_err(|_| {
let value = open_legacy(master_key, ciphertext, nonce).map_err(|_| {
// Corrupted secret means we can't verify — fail closed (401).
EmailInboundError::Unauthorized
})?;
@@ -415,7 +411,7 @@ mod tests {
//! Postgres in `crates/picloud/tests/email_inbound.rs`.
use super::*;
use crate::secrets_service::seal;
use crate::secrets_service::seal_legacy;
use crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES;
fn sign(secret: &[u8], ts: i64, body: &[u8]) -> String {
@@ -514,7 +510,7 @@ mod tests {
#[test]
fn secret_round_trips_through_seal_open() {
let key = MasterKey::from_bytes([3u8; 32]);
let (ct, nonce) = seal(
let (ct, nonce) = seal_legacy(
&key,
&serde_json::Value::String("provider-secret".into()),
DEFAULT_SECRET_MAX_VALUE_BYTES,