The crypto foundation for group-owned secrets (the §5.3 'single hardest
correctness detail'), done first and proven before the storage/resolution
layer.
- SecretOwner{App(AppId)|Group(GroupId)}; secret_aad/seal/open take the
owner. The app AAD is byte-identical to the pre-Phase-3
'secret:{app_id}:{name}', so every existing v1 row decrypts unchanged;
group secrets use a disjoint 'secret:group:{group_id}:{name}' namespace
(the 'group:' infix separates app/group AAD even for equal UUIDs).
- All callers (SDK get/set, secrets_api, apply email-secret read) wrapped
in SecretOwner::App — behavior identical for app secrets.
- New audit test aad_distinguishes_app_and_group_owner proves the two
namespaces don't collide (both directions, even reusing the UUID); the
existing cross-app/cross-name swap audit tests stay green.
16 secrets_service tests pass; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
828 lines
27 KiB
Rust
828 lines
27 KiB
Rust
//! `SecretsServiceImpl` — wires the `SecretsRepo` underneath the
|
|
//! `picloud_shared::SecretsService` trait that scripts see via the Rhai
|
|
//! bridge.
|
|
//!
|
|
//! Layers added here (vs the raw repo):
|
|
//!
|
|
//! 1. Name validation (non-empty, ≤255 bytes) at the SDK boundary.
|
|
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
|
|
//! `authz::require(...)`; when it's `None` (public unauthenticated
|
|
//! HTTP) we skip the check. Cross-app isolation is unaffected — every
|
|
//! query is keyed by `cx.app_id`, never an argument.
|
|
//! 3. **JSON ⇄ ciphertext**: `set` serializes the value to JSON bytes,
|
|
//! enforces the per-secret size cap, and AES-256-GCM-seals it; `get`
|
|
//! decrypts and deserializes back to the same JSON shape (a String
|
|
//! round-trips to a String, not a JSON-quoted `"\"…\""`).
|
|
//!
|
|
//! Deliberately **no `ServiceEvent` emission** — secret writes do not
|
|
//! fire triggers (footgun avoidance; see `docs/sdk-shape.md` + the
|
|
//! v1.1.7 brief §2).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use picloud_shared::{
|
|
crypto, validate_secret_name, AppId, GroupId, MasterKey, SdkCallCx, SecretsError,
|
|
SecretsListPage, SecretsService,
|
|
};
|
|
|
|
use crate::authz::{self, AuthzRepo, Capability};
|
|
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
|
|
|
|
/// Current AES-GCM envelope version for the per-app secret store.
|
|
/// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1.
|
|
pub const SECRET_ENVELOPE_V1: i16 = 1;
|
|
|
|
/// Who owns a secret (Phase 3). A secret is owned by exactly one app OR
|
|
/// one group; the owner is bound into the AES-GCM AAD so a cross-owner
|
|
/// ciphertext swap fails decryption.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SecretOwner {
|
|
App(AppId),
|
|
Group(GroupId),
|
|
}
|
|
|
|
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a cross-row
|
|
/// swap (moving one owner's ciphertext under another's slot) fails
|
|
/// decryption. The **app** form is byte-identical to the pre-Phase-3
|
|
/// `secret:{app_id}:{name}`, so every existing v1 row keeps decrypting
|
|
/// unchanged; group secrets use a distinct `secret:group:{group_id}:{name}`
|
|
/// namespace (the `group:` infix keeps app and group AAD disjoint even if a
|
|
/// group UUID happened to equal an app UUID).
|
|
fn secret_aad(owner: SecretOwner, name: &str) -> Vec<u8> {
|
|
match owner {
|
|
SecretOwner::App(app_id) => format!("secret:{app_id}:{name}"),
|
|
SecretOwner::Group(group_id) => format!("secret:group:{group_id}:{name}"),
|
|
}
|
|
.into_bytes()
|
|
}
|
|
|
|
/// Default per-secret plaintext cap (64 KB). Override with
|
|
/// `PICLOUD_SECRET_MAX_VALUE_BYTES`.
|
|
pub const DEFAULT_SECRET_MAX_VALUE_BYTES: usize = 64 * 1024;
|
|
|
|
/// Process config for the secrets service.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct SecretsConfig {
|
|
/// Maximum size of the JSON-encoded plaintext, in bytes.
|
|
pub max_value_bytes: usize,
|
|
}
|
|
|
|
impl SecretsConfig {
|
|
#[must_use]
|
|
pub const fn conservative() -> Self {
|
|
Self {
|
|
max_value_bytes: DEFAULT_SECRET_MAX_VALUE_BYTES,
|
|
}
|
|
}
|
|
|
|
/// Read `PICLOUD_SECRET_MAX_VALUE_BYTES`; invalid values are ignored
|
|
/// with a warning (keeps the conservative default).
|
|
#[must_use]
|
|
pub fn from_env() -> Self {
|
|
let mut c = Self::conservative();
|
|
if let Ok(v) = std::env::var("PICLOUD_SECRET_MAX_VALUE_BYTES") {
|
|
match v.trim().parse::<usize>() {
|
|
Ok(n) if n > 0 => c.max_value_bytes = n,
|
|
_ => tracing::warn!(
|
|
value = %v,
|
|
"ignoring invalid PICLOUD_SECRET_MAX_VALUE_BYTES (want a positive integer)"
|
|
),
|
|
}
|
|
}
|
|
c
|
|
}
|
|
}
|
|
|
|
impl Default for SecretsConfig {
|
|
fn default() -> Self {
|
|
Self::conservative()
|
|
}
|
|
}
|
|
|
|
/// Serialize + size-check + encrypt a value into `(ciphertext, nonce,
|
|
/// version)`. New writes are envelope version 1 with AAD bound to
|
|
/// `"secret:{app_id}:{name}"`.
|
|
///
|
|
/// Audit 2026-06-11 H-D1.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`SecretsError::TooLarge`] when the encoded plaintext exceeds
|
|
/// `max_value_bytes`; [`SecretsError::Backend`] on a serialization
|
|
/// failure (should not happen for a `serde_json::Value`).
|
|
pub fn seal(
|
|
master_key: &MasterKey,
|
|
owner: SecretOwner,
|
|
name: &str,
|
|
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 = secret_aad(owner, name);
|
|
let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes());
|
|
Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
|
|
}
|
|
|
|
/// Decrypt + deserialize a stored secret back to its JSON value.
|
|
/// Dispatches on `stored.version`: v0 uses the legacy no-AAD layout
|
|
/// (pre-2026-06-11 rows that haven't been rewritten yet); v1 binds
|
|
/// AAD = `"secret:{app_id}:{name}"`, so a cross-row swap fails the
|
|
/// GCM auth tag.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
|
|
pub fn open(
|
|
master_key: &MasterKey,
|
|
owner: SecretOwner,
|
|
name: &str,
|
|
stored: &StoredSecret,
|
|
) -> Result<serde_json::Value, SecretsError> {
|
|
let plaintext = match stored.version {
|
|
0 => crypto::decrypt(
|
|
&stored.encrypted_value,
|
|
&stored.nonce,
|
|
master_key.as_bytes(),
|
|
),
|
|
SECRET_ENVELOPE_V1 => {
|
|
let aad = secret_aad(owner, name);
|
|
crypto::decrypt_with_aad(
|
|
&stored.encrypted_value,
|
|
&stored.nonce,
|
|
&aad,
|
|
master_key.as_bytes(),
|
|
)
|
|
}
|
|
_ => return Err(SecretsError::Corrupted),
|
|
}
|
|
.map_err(|_| SecretsError::Corrupted)?;
|
|
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.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`SecretsError::TooLarge`] / [`SecretsError::Backend`] as for [`seal`].
|
|
pub fn seal_legacy(
|
|
master_key: &MasterKey,
|
|
value: &serde_json::Value,
|
|
max_value_bytes: usize,
|
|
) -> Result<(Vec<u8>, [u8; crypto::NONCE_LEN]), 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 enc = crypto::encrypt(&plaintext, master_key.as_bytes());
|
|
Ok((enc.ciphertext, enc.nonce))
|
|
}
|
|
|
|
/// Legacy v0 open (no AAD) paired with [`seal_legacy`]. See that
|
|
/// function for why the email-trigger path is still v0.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
|
|
pub fn open_legacy(
|
|
master_key: &MasterKey,
|
|
ciphertext: &[u8],
|
|
nonce: &[u8],
|
|
) -> Result<serde_json::Value, SecretsError> {
|
|
let plaintext = 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>,
|
|
master_key: MasterKey,
|
|
max_value_bytes: usize,
|
|
}
|
|
|
|
impl SecretsServiceImpl {
|
|
#[must_use]
|
|
pub fn new(
|
|
repo: Arc<dyn SecretsRepo>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
master_key: MasterKey,
|
|
config: SecretsConfig,
|
|
) -> Self {
|
|
Self {
|
|
repo,
|
|
authz,
|
|
master_key,
|
|
max_value_bytes: config.max_value_bytes,
|
|
}
|
|
}
|
|
|
|
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
|
|
if let Some(ref principal) = cx.principal {
|
|
authz::require(
|
|
&*self.authz,
|
|
principal,
|
|
Capability::AppSecretsRead(cx.app_id),
|
|
)
|
|
.await
|
|
.map_err(|_| SecretsError::Forbidden)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
|
|
if let Some(ref principal) = cx.principal {
|
|
authz::require(
|
|
&*self.authz,
|
|
principal,
|
|
Capability::AppSecretsWrite(cx.app_id),
|
|
)
|
|
.await
|
|
.map_err(|_| SecretsError::Forbidden)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl From<SecretsRepoError> for SecretsError {
|
|
fn from(e: SecretsRepoError) -> Self {
|
|
Self::Backend(e.to_string())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SecretsService for SecretsServiceImpl {
|
|
async fn get(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
name: &str,
|
|
) -> Result<Option<serde_json::Value>, SecretsError> {
|
|
validate_secret_name(name)?;
|
|
self.check_read(cx).await?;
|
|
let Some(stored) = self.repo.get(cx.app_id, name).await? else {
|
|
return Ok(None);
|
|
};
|
|
match open(&self.master_key, SecretOwner::App(cx.app_id), name, &stored) {
|
|
Ok(value) => Ok(Some(value)),
|
|
Err(e) => {
|
|
// A decrypt failure is operationally significant — surface
|
|
// the affected (app_id, name) so an operator can find the
|
|
// bad row, but never log the ciphertext or key material.
|
|
tracing::error!(
|
|
app_id = %cx.app_id,
|
|
secret = %name,
|
|
"secret could not be decrypted (corrupted row or master-key mismatch)"
|
|
);
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn set(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
name: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<(), SecretsError> {
|
|
validate_secret_name(name)?;
|
|
self.check_write(cx).await?;
|
|
let (ciphertext, nonce, version) = seal(
|
|
&self.master_key,
|
|
SecretOwner::App(cx.app_id),
|
|
name,
|
|
&value,
|
|
self.max_value_bytes,
|
|
)?;
|
|
self.repo
|
|
.set(cx.app_id, name, &ciphertext, &nonce, version)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
|
validate_secret_name(name)?;
|
|
self.check_write(cx).await?;
|
|
Ok(self.repo.delete(cx.app_id, name).await?)
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<SecretsListPage, SecretsError> {
|
|
self.check_read(cx).await?;
|
|
let page = self.repo.list_names(cx.app_id, cursor, limit).await?;
|
|
Ok(SecretsListPage {
|
|
names: page.names,
|
|
next_cursor: page.next_cursor,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Tests — in-memory SecretsRepo so unit tests don't need Postgres.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::authz::{AuthzError, AuthzRepo};
|
|
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
|
|
use async_trait::async_trait;
|
|
use picloud_shared::{
|
|
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
|
UserId,
|
|
};
|
|
use std::collections::BTreeMap;
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Default)]
|
|
struct InMemorySecretsRepo {
|
|
data: Mutex<BTreeMap<(AppId, String), StoredSecret>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SecretsRepo for InMemorySecretsRepo {
|
|
async fn get(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.get(&(app_id, name.to_string()))
|
|
.cloned())
|
|
}
|
|
async fn set(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
encrypted_value: &[u8],
|
|
nonce: &[u8],
|
|
version: i16,
|
|
) -> Result<(), SecretsRepoError> {
|
|
self.data.lock().await.insert(
|
|
(app_id, name.to_string()),
|
|
StoredSecret {
|
|
encrypted_value: encrypted_value.to_vec(),
|
|
nonce: nonce.to_vec(),
|
|
version,
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.remove(&(app_id, name.to_string()))
|
|
.is_some())
|
|
}
|
|
async fn list_names(
|
|
&self,
|
|
app_id: AppId,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<SecretsNamePage, SecretsRepoError> {
|
|
let data = self.data.lock().await;
|
|
let last = cursor.map(std::string::ToString::to_string);
|
|
let mut names: Vec<String> = data
|
|
.iter()
|
|
.filter(|((a, _), _)| *a == app_id)
|
|
.map(|((_, n), _)| n.clone())
|
|
.filter(|n| last.as_ref().is_none_or(|l| n > l))
|
|
.collect();
|
|
names.sort();
|
|
let take = (limit as usize).max(1);
|
|
let next_cursor = if names.len() > take {
|
|
names.truncate(take);
|
|
names.last().cloned()
|
|
} else {
|
|
None
|
|
};
|
|
Ok(SecretsNamePage { names, next_cursor })
|
|
}
|
|
async fn list_meta(
|
|
&self,
|
|
_app_id: AppId,
|
|
_cursor: Option<&str>,
|
|
_limit: u32,
|
|
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
|
unimplemented!("admin-only; not exercised in service tests")
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct DenyingAuthzRepo;
|
|
#[async_trait]
|
|
impl AuthzRepo for DenyingAuthzRepo {
|
|
async fn membership(
|
|
&self,
|
|
_user_id: UserId,
|
|
_app_id: AppId,
|
|
) -> Result<Option<AppRole>, AuthzError> {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
fn key() -> MasterKey {
|
|
MasterKey::from_bytes([0x5au8; 32])
|
|
}
|
|
|
|
fn svc() -> SecretsServiceImpl {
|
|
SecretsServiceImpl::new(
|
|
Arc::new(InMemorySecretsRepo::default()),
|
|
Arc::new(DenyingAuthzRepo),
|
|
key(),
|
|
SecretsConfig::conservative(),
|
|
)
|
|
}
|
|
|
|
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
|
SdkCallCx {
|
|
app_id,
|
|
script_id: ScriptId::new(),
|
|
principal,
|
|
execution_id: ExecutionId::new(),
|
|
request_id: RequestId::new(),
|
|
trigger_depth: 0,
|
|
root_execution_id: ExecutionId::new(),
|
|
is_dead_letter_handler: false,
|
|
event: None,
|
|
}
|
|
}
|
|
|
|
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
|
cx_with(app_id, None)
|
|
}
|
|
|
|
fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
|
|
cx_with(
|
|
app_id,
|
|
Some(Principal {
|
|
user_id: AdminUserId::new(),
|
|
instance_role: InstanceRole::Member,
|
|
scopes: None,
|
|
app_binding: None,
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn owner_cx(app_id: AppId) -> SdkCallCx {
|
|
cx_with(
|
|
app_id,
|
|
Some(Principal {
|
|
user_id: AdminUserId::new(),
|
|
instance_role: InstanceRole::Owner,
|
|
scopes: None,
|
|
app_binding: None,
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_get_delete_round_trip() {
|
|
let s = svc();
|
|
let cx = anon_cx(AppId::new());
|
|
s.set(&cx, "stripe_key", serde_json::json!("sk_live_xxx"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.get(&cx, "stripe_key").await.unwrap(),
|
|
Some(serde_json::json!("sk_live_xxx"))
|
|
);
|
|
assert!(s.delete(&cx, "stripe_key").await.unwrap());
|
|
assert_eq!(s.get(&cx, "stripe_key").await.unwrap(), None);
|
|
// Idempotent delete.
|
|
assert!(!s.delete(&cx, "stripe_key").await.unwrap());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_missing_returns_none() {
|
|
let s = svc();
|
|
let cx = anon_cx(AppId::new());
|
|
assert_eq!(s.get(&cx, "nope").await.unwrap(), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_name_rejected() {
|
|
let s = svc();
|
|
let cx = anon_cx(AppId::new());
|
|
let err = s.set(&cx, "", serde_json::json!("x")).await.unwrap_err();
|
|
assert!(matches!(err, SecretsError::InvalidName(_)));
|
|
let err = s.get(&cx, "").await.unwrap_err();
|
|
assert!(matches!(err, SecretsError::InvalidName(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn name_length_capped() {
|
|
let s = svc();
|
|
let cx = anon_cx(AppId::new());
|
|
let long = "a".repeat(256);
|
|
let err = s.set(&cx, &long, serde_json::json!(1)).await.unwrap_err();
|
|
assert!(matches!(err, SecretsError::InvalidName(_)));
|
|
// Exactly 255 is allowed.
|
|
let ok = "b".repeat(255);
|
|
s.set(&cx, &ok, serde_json::json!(1)).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn value_over_cap_rejected() {
|
|
let s = SecretsServiceImpl::new(
|
|
Arc::new(InMemorySecretsRepo::default()),
|
|
Arc::new(DenyingAuthzRepo),
|
|
key(),
|
|
SecretsConfig {
|
|
max_value_bytes: 16,
|
|
},
|
|
);
|
|
let cx = anon_cx(AppId::new());
|
|
let big = serde_json::json!("x".repeat(64));
|
|
let err = s.set(&cx, "k", big).await.unwrap_err();
|
|
assert!(matches!(err, SecretsError::TooLarge { limit: 16, .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cross_app_isolation() {
|
|
let s = svc();
|
|
let a = AppId::new();
|
|
let b = AppId::new();
|
|
s.set(&anon_cx(a), "shared", serde_json::json!("from-a"))
|
|
.await
|
|
.unwrap();
|
|
s.set(&anon_cx(b), "shared", serde_json::json!("from-b"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.get(&anon_cx(a), "shared").await.unwrap(),
|
|
Some(serde_json::json!("from-a"))
|
|
);
|
|
assert_eq!(
|
|
s.get(&anon_cx(b), "shared").await.unwrap(),
|
|
Some(serde_json::json!("from-b"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn anonymous_skips_authz() {
|
|
let s = svc();
|
|
// DenyingAuthzRepo would deny an authed principal; anon skips it.
|
|
s.set(&anon_cx(AppId::new()), "k", serde_json::json!(1))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn authed_member_without_role_forbidden() {
|
|
let s = svc();
|
|
let err = s
|
|
.set(&member_no_role_cx(AppId::new()), "k", serde_json::json!(1))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, SecretsError::Forbidden));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn owner_can_write() {
|
|
let s = svc();
|
|
s.set(&owner_cx(AppId::new()), "k", serde_json::json!(1))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// Type round-trip: a String comes back a String, a Map a Map, an
|
|
/// Array an Array — the JSON encoding is transparent.
|
|
#[tokio::test]
|
|
async fn type_round_trip_preserves_shape() {
|
|
let s = svc();
|
|
let cx = anon_cx(AppId::new());
|
|
|
|
s.set(&cx, "str", serde_json::json!("sk_live_xxx"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.get(&cx, "str").await.unwrap(),
|
|
Some(serde_json::json!("sk_live_xxx"))
|
|
);
|
|
|
|
let map = serde_json::json!({ "client_id": "abc", "client_secret": "xyz" });
|
|
s.set(&cx, "oauth", map.clone()).await.unwrap();
|
|
assert_eq!(s.get(&cx, "oauth").await.unwrap(), Some(map));
|
|
|
|
let arr = serde_json::json!([1, 2, 3]);
|
|
s.set(&cx, "arr", arr.clone()).await.unwrap();
|
|
assert_eq!(s.get(&cx, "arr").await.unwrap(), Some(arr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn corrupted_ciphertext_surfaces_error() {
|
|
let repo = Arc::new(InMemorySecretsRepo::default());
|
|
let s = SecretsServiceImpl::new(
|
|
repo.clone(),
|
|
Arc::new(DenyingAuthzRepo),
|
|
key(),
|
|
SecretsConfig::conservative(),
|
|
);
|
|
let app = AppId::new();
|
|
s.set(&anon_cx(app), "k", serde_json::json!("v"))
|
|
.await
|
|
.unwrap();
|
|
// Corrupt the stored ciphertext directly.
|
|
repo.data
|
|
.lock()
|
|
.await
|
|
.get_mut(&(app, "k".to_string()))
|
|
.unwrap()
|
|
.encrypted_value[0] ^= 0xff;
|
|
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
|
|
assert!(matches!(err, SecretsError::Corrupted));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn aad_blocks_cross_app_ciphertext_swap() {
|
|
// Audit 2026-06-11 H-D1 closure. Seal a secret under app A,
|
|
// then move its stored row verbatim into app B's slot (the
|
|
// exact attack a Postgres-write adversary would run). The v1
|
|
// AAD = "secret:{app_id}:{name}" no longer matches, so B's read
|
|
// surfaces Corrupted instead of A's plaintext.
|
|
let repo = Arc::new(InMemorySecretsRepo::default());
|
|
let s = SecretsServiceImpl::new(
|
|
repo.clone(),
|
|
Arc::new(DenyingAuthzRepo),
|
|
key(),
|
|
SecretsConfig::conservative(),
|
|
);
|
|
let a = AppId::new();
|
|
let b = AppId::new();
|
|
s.set(&anon_cx(a), "k", serde_json::json!("a-plaintext"))
|
|
.await
|
|
.unwrap();
|
|
// Copy A's row into B's slot, same name.
|
|
let stolen = repo
|
|
.data
|
|
.lock()
|
|
.await
|
|
.get(&(a, "k".to_string()))
|
|
.cloned()
|
|
.unwrap();
|
|
repo.data.lock().await.insert((b, "k".to_string()), stolen);
|
|
let err = s.get(&anon_cx(b), "k").await.unwrap_err();
|
|
assert!(
|
|
matches!(err, SecretsError::Corrupted),
|
|
"cross-app swap must fail AAD, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn aad_distinguishes_app_and_group_owner() {
|
|
// Phase 3: app and group secrets live in disjoint AAD namespaces
|
|
// (`secret:{app}` vs `secret:group:{group}`). A ciphertext sealed
|
|
// for a group must not open as an app secret (and vice-versa),
|
|
// even if the raw UUIDs were equal — the `group:` infix separates
|
|
// them. Exercise the free seal/open functions directly.
|
|
let k = key();
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
let value = serde_json::json!("shared-config");
|
|
|
|
// Seal under the GROUP owner.
|
|
let (ct, nonce, version) =
|
|
seal(&k, SecretOwner::Group(group), "db_url", &value, 4096).unwrap();
|
|
let stored = StoredSecret {
|
|
encrypted_value: ct,
|
|
nonce: nonce.to_vec(),
|
|
version,
|
|
};
|
|
// Opens fine as the same group owner.
|
|
assert_eq!(
|
|
open(&k, SecretOwner::Group(group), "db_url", &stored).unwrap(),
|
|
value
|
|
);
|
|
// Fails as an app owner (AAD mismatch) — even reusing the UUID.
|
|
let app_from_group = AppId::from(group.into_inner());
|
|
assert!(matches!(
|
|
open(&k, SecretOwner::App(app_from_group), "db_url", &stored),
|
|
Err(SecretsError::Corrupted)
|
|
));
|
|
// And the symmetric direction: an app-sealed row won't open as a group.
|
|
let (ct2, nonce2, v2) = seal(&k, SecretOwner::App(app), "db_url", &value, 4096).unwrap();
|
|
let stored2 = StoredSecret {
|
|
encrypted_value: ct2,
|
|
nonce: nonce2.to_vec(),
|
|
version: v2,
|
|
};
|
|
assert!(matches!(
|
|
open(
|
|
&k,
|
|
SecretOwner::Group(GroupId::from(app.into_inner())),
|
|
"db_url",
|
|
&stored2
|
|
),
|
|
Err(SecretsError::Corrupted)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn aad_blocks_cross_name_ciphertext_swap() {
|
|
// Same app, different name → AAD mismatch.
|
|
let repo = Arc::new(InMemorySecretsRepo::default());
|
|
let s = SecretsServiceImpl::new(
|
|
repo.clone(),
|
|
Arc::new(DenyingAuthzRepo),
|
|
key(),
|
|
SecretsConfig::conservative(),
|
|
);
|
|
let a = AppId::new();
|
|
s.set(&anon_cx(a), "real", serde_json::json!("v"))
|
|
.await
|
|
.unwrap();
|
|
let stolen = repo
|
|
.data
|
|
.lock()
|
|
.await
|
|
.get(&(a, "real".to_string()))
|
|
.cloned()
|
|
.unwrap();
|
|
repo.data
|
|
.lock()
|
|
.await
|
|
.insert((a, "renamed".to_string()), stolen);
|
|
let err = s.get(&anon_cx(a), "renamed").await.unwrap_err();
|
|
assert!(matches!(err, SecretsError::Corrupted));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_v0_row_still_decrypts() {
|
|
// A pre-audit row (version 0, no AAD) must keep decrypting after
|
|
// the migration: only new writes are v1.
|
|
let repo = Arc::new(InMemorySecretsRepo::default());
|
|
let s = SecretsServiceImpl::new(
|
|
repo.clone(),
|
|
Arc::new(DenyingAuthzRepo),
|
|
key(),
|
|
SecretsConfig::conservative(),
|
|
);
|
|
let app = AppId::new();
|
|
// Hand-seal a v0 row exactly as the pre-audit code would have.
|
|
let (ct, nonce) = seal_legacy(
|
|
&key(),
|
|
&serde_json::json!("legacy"),
|
|
DEFAULT_SECRET_MAX_VALUE_BYTES,
|
|
)
|
|
.unwrap();
|
|
repo.data.lock().await.insert(
|
|
(app, "old".to_string()),
|
|
StoredSecret {
|
|
encrypted_value: ct,
|
|
nonce: nonce.to_vec(),
|
|
version: 0,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
s.get(&anon_cx(app), "old").await.unwrap(),
|
|
Some(serde_json::json!("legacy"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_returns_names_paginated() {
|
|
let s = svc();
|
|
let cx = anon_cx(AppId::new());
|
|
for i in 0..5 {
|
|
s.set(&cx, &format!("k{i:02}"), serde_json::json!(i))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let p1 = s.list(&cx, None, 2).await.unwrap();
|
|
assert_eq!(p1.names, vec!["k00".to_string(), "k01".to_string()]);
|
|
assert!(p1.next_cursor.is_some());
|
|
let p2 = s.list(&cx, p1.next_cursor.as_deref(), 10).await.unwrap();
|
|
assert_eq!(
|
|
p2.names,
|
|
vec!["k02".to_string(), "k03".to_string(), "k04".to_string()]
|
|
);
|
|
assert!(p2.next_cursor.is_none());
|
|
}
|
|
}
|