Files
PiCloud/crates/manager-core/src/secrets_repo.rs
MechaCat02 513c4a2d3c 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>
2026-06-12 17:15:09 +02:00

261 lines
7.7 KiB
Rust

//! Low-level Postgres CRUD over `secrets`. Storage-only: it moves
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
//! encoding, authorization, name validation, and the value-size cap all
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::AppId;
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum SecretsRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("invalid pagination cursor")]
InvalidCursor,
}
/// An encrypted secret as it lives on disk: ciphertext (auth tag
/// appended) plus the nonce it was sealed with.
///
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
/// AES-GCM with AAD bound to `"secret:{app_id}:{name}"`. Migration
/// `0042_secrets_envelope_version.sql` adds the column with a default
/// of `0`, so existing rows keep working.
#[derive(Debug, Clone)]
pub struct StoredSecret {
pub encrypted_value: Vec<u8>,
pub nonce: Vec<u8>,
pub version: i16,
}
/// Admin-surface metadata for one secret. Values are never returned —
/// only the name and the last-modified timestamp.
#[derive(Debug, Clone)]
pub struct SecretMeta {
pub name: String,
pub updated_at: DateTime<Utc>,
}
/// One page of names (SDK `list`).
#[derive(Debug, Clone)]
pub struct SecretsNamePage {
pub names: Vec<String>,
pub next_cursor: Option<String>,
}
/// One page of name + updated_at (admin `GET`).
#[derive(Debug, Clone)]
pub struct SecretsMetaPage {
pub items: Vec<SecretMeta>,
pub next_cursor: Option<String>,
}
/// Repo surface. Exposed as a trait so the service unit tests can
/// substitute an in-memory backing without Postgres.
#[async_trait]
pub trait SecretsRepo: Send + Sync {
async fn get(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError>;
/// Upsert (overwrite if present). `version` is the AES-GCM envelope
/// discriminator from [`StoredSecret::version`].
async fn set(
&self,
app_id: AppId,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
version: i16,
) -> Result<(), SecretsRepoError>;
/// Delete; returns whether a row was present.
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError>;
/// Names only — the SDK `list` surface.
async fn list_names(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError>;
/// Name + updated_at — the admin `GET` surface.
async fn list_meta(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError>;
}
pub struct PostgresSecretsRepo {
pool: PgPool,
}
impl PostgresSecretsRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
const SECRETS_LIST_MAX_LIMIT: u32 = 1_000;
const SECRETS_LIST_DEFAULT_LIMIT: u32 = 100;
fn clamp_limit(limit: u32) -> u32 {
if limit == 0 {
SECRETS_LIST_DEFAULT_LIMIT
} else {
limit.min(SECRETS_LIST_MAX_LIMIT)
}
}
/// Opaque keyset cursor: base64url of the last `name` returned.
pub(crate) fn encode_cursor(last_name: &str) -> String {
URL_SAFE_NO_PAD.encode(last_name.as_bytes())
}
pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| SecretsRepoError::InvalidCursor)?;
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
}
#[async_trait]
impl SecretsRepo for PostgresSecretsRepo {
async fn get(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<StoredSecret>, SecretsRepoError> {
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(
"SELECT encrypted_value, nonce, version FROM secrets \
WHERE app_id = $1 AND name = $2",
)
.bind(app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret {
encrypted_value,
nonce,
version,
}))
}
async fn set(
&self,
app_id: AppId,
name: &str,
encrypted_value: &[u8],
nonce: &[u8],
version: i16,
) -> Result<(), SecretsRepoError> {
sqlx::query(
"INSERT INTO secrets (app_id, name, encrypted_value, nonce, version) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (app_id, name) DO UPDATE \
SET encrypted_value = EXCLUDED.encrypted_value, \
nonce = EXCLUDED.nonce, \
version = EXCLUDED.version, \
updated_at = NOW()",
)
.bind(app_id.into_inner())
.bind(name)
.bind(encrypted_value)
.bind(nonce)
.bind(version)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2")
.bind(app_id.into_inner())
.bind(name)
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
async fn list_names(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsNamePage, SecretsRepoError> {
let limit = clamp_limit(limit);
let last_name = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT name FROM secrets \
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3",
)
.bind(app_id.into_inner())
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
let next_cursor = if names.len() > limit as usize {
names.truncate(limit as usize);
names.last().map(|n| encode_cursor(n))
} else {
None
};
Ok(SecretsNamePage { names, next_cursor })
}
async fn list_meta(
&self,
app_id: AppId,
cursor: Option<&str>,
limit: u32,
) -> Result<SecretsMetaPage, SecretsRepoError> {
let limit = clamp_limit(limit);
let last_name = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<(String, DateTime<Utc>)> = sqlx::query_as(
"SELECT name, updated_at FROM secrets \
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
ORDER BY name ASC LIMIT $3",
)
.bind(app_id.into_inner())
.bind(last_name.as_deref())
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut items: Vec<SecretMeta> = rows
.into_iter()
.map(|(name, updated_at)| SecretMeta { name, updated_at })
.collect();
let next_cursor = if items.len() > limit as usize {
items.truncate(limit as usize);
items.last().map(|m| encode_cursor(&m.name))
} else {
None
};
Ok(SecretsMetaPage { items, next_cursor })
}
}