cratefield_secrets/lib.rs
1//! `cratefield-secrets` — the secrets store (issue #39,
2//! `docs/SECRETS-DESIGN.md`, ADR 0102).
3//!
4//! Envelope encryption over the `Database` port, in two tiers. **Global**
5//! secrets (tenant connection strings, platform keys) live in the control
6//! database; **tenant** secrets live in that tenant's database and nowhere
7//! else. Each store holds its own wrapped data key, which the KMS unwraps
8//! and never stores (§3 of the design: a database dump is ciphertext plus
9//! a blob nobody outside the KMS can open).
10//!
11//! Every ciphertext is bound to its row by the AEAD's additional data:
12//! store, name, version and key id, length-prefixed (§5). A row copied to
13//! another database, renamed, rolled back a version, or repointed at
14//! another key **fails to decrypt** rather than quietly succeeding.
15//!
16//! What this crate deliberately does not do: rotation (#42) and the
17//! append-only audit log (#41). [`Audit`] is the seam the latter fills;
18//! until then every access is logged through `tracing` with no value in
19//! it.
20
21#![forbid(unsafe_code)]
22
23pub mod audit;
24mod rotate;
25mod store;
26
27use std::fmt;
28
29use thiserror::Error;
30use zeroize::Zeroizing;
31
32pub use audit::{Anchor, ChainAudit, chain_sink, verify};
33pub use rotate::{RewrapReport, RotationReport};
34pub use store::{HarnessOnly, SecretStore, Secrets};
35
36/// The store's schema, per dialect, applied the same way a module's is.
37/// The tables are portable; only the append-only trigger differs, which
38/// is what `Migrations`' two sets are for (ADR 0004).
39///
40/// The sets are `pub const` so a composition can embed them in its own
41/// migration array the way the console re-ids its sub-schemas: the
42/// control plane's dashboard module applies these tables under its own
43/// ids, and sharing the constants is what keeps it from embedding a
44/// second copy of the SQL that can drift.
45pub const SQLITE_MIGRATIONS: [cratefield_core::SqlMigration; 4] = [
46 cratefield_core::SqlMigration::new(
47 "0001",
48 "init",
49 include_str!("../migrations/sqlite/0001_init.sql"),
50 ),
51 cratefield_core::SqlMigration::new(
52 "0002",
53 "audit",
54 include_str!("../migrations/sqlite/0002_audit.sql"),
55 ),
56 cratefield_core::SqlMigration::new(
57 "0003",
58 "audit-store",
59 include_str!("../migrations/sqlite/0003_audit_store.sql"),
60 ),
61 cratefield_core::SqlMigration::new(
62 "0004",
63 "store-attribution",
64 include_str!("../migrations/sqlite/0004_store_attribution.sql"),
65 ),
66];
67// The array is the apply order; this refuses a gap, a duplicate
68// or an entry out of order at build time (issue #27).
69const _: () = cratefield_core::assert_migration_set(&SQLITE_MIGRATIONS);
70pub const POSTGRES_MIGRATIONS: [cratefield_core::SqlMigration; 4] = [
71 cratefield_core::SqlMigration::new(
72 "0001",
73 "init",
74 include_str!("../migrations/postgres/0001_init.sql"),
75 ),
76 cratefield_core::SqlMigration::new(
77 "0002",
78 "audit",
79 include_str!("../migrations/postgres/0002_audit.sql"),
80 ),
81 cratefield_core::SqlMigration::new(
82 "0003",
83 "audit-store",
84 include_str!("../migrations/postgres/0003_audit_store.sql"),
85 ),
86 cratefield_core::SqlMigration::new(
87 "0004",
88 "store-attribution",
89 include_str!("../migrations/postgres/0004_store_attribution.sql"),
90 ),
91];
92// The array is the apply order; this refuses a gap, a duplicate
93// or an entry out of order at build time (issue #27).
94const _: () = cratefield_core::assert_migration_set(&POSTGRES_MIGRATIONS);
95
96/// The store's schema, per dialect, applied the same way a module's is
97/// (see [`SQLITE_MIGRATIONS`] for why the sets are public).
98#[must_use]
99pub fn migrations() -> cratefield_core::Migrations {
100 cratefield_core::Migrations {
101 sqlite: &SQLITE_MIGRATIONS,
102 postgres: &POSTGRES_MIGRATIONS,
103 }
104}
105
106/// The AEAD this crate seals with, recorded on every key row so a store
107/// can never mix ciphers within one key id (design §4).
108pub const CIPHER: &str = "xchacha20poly1305";
109
110/// The store-scoping predicate every row-level query filters through
111/// since store attribution (the row-level twin of the audit chain's
112/// #142): this store's rows, and only this store's.
113///
114/// It does **not** carry the chain's `OR store = ''` compatibility
115/// clause, and the difference is the difference between the two tables.
116/// `harness_secret_audit` is append-only, so its past cannot be
117/// rewritten and the clause can only ever let an old row still be read.
118/// Rows here are soft-deleted, re-encrypted and re-keyed, so the same
119/// clause would hand every store a *write* over every other store's
120/// unstamped rows: a tenant's `delete` landing on the global store's
121/// row, `active_key` serving another store's key, `rotate_dek` pulling
122/// in rows it cannot decrypt and failing closed on their AAD. That is
123/// precisely the store-blindness attribution exists to end.
124///
125/// Nothing is given up. No composition ever applied this schema before
126/// attribution — the secrets layer was built, merged and never wired —
127/// so no database holds a row written without a store. An unstamped row
128/// would be one nobody can account for, and it is treated as nobody's:
129/// invisible to every store until it is re-put under the one that owns
130/// it. `tests/shared_database.rs` pins that.
131///
132/// Bind order matters: every query using it binds the predicate's `?`
133/// after that query's own parameters, and this is the one place that
134/// says so.
135pub(crate) fn scoped_store() -> &'static str {
136 "store = ?"
137}
138
139/// Which store a secret belongs to. Part of the AAD, so it is also what
140/// stops a row being useful anywhere else.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum StoreId {
143 /// The control database: connection strings and platform keys. Not
144 /// reachable from module code.
145 Global,
146 /// One tenant's database, by tenant id.
147 Tenant(String),
148}
149
150impl StoreId {
151 /// The bytes that go in the AAD.
152 #[must_use]
153 pub fn as_str(&self) -> &str {
154 match self {
155 StoreId::Global => "global",
156 StoreId::Tenant(id) => id,
157 }
158 }
159}
160
161impl fmt::Display for StoreId {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163 f.write_str(self.as_str())
164 }
165}
166
167/// Who is asking. Every method takes one because the audit log needs one
168/// and there is no anonymous access to a secret.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Actor(String);
171
172impl Actor {
173 /// Names the caller: a module name, an operator, a job.
174 ///
175 /// # Errors
176 ///
177 /// [`SecretsError::Invalid`] when empty: "who did this" with no
178 /// answer is not an audit record.
179 pub fn new(who: impl Into<String>) -> Result<Self, SecretsError> {
180 let who = who.into();
181 if who.trim().is_empty() {
182 return Err(SecretsError::Invalid(
183 "an actor cannot be empty: every access is attributed".to_owned(),
184 ));
185 }
186 Ok(Self(who))
187 }
188
189 #[must_use]
190 pub fn as_str(&self) -> &str {
191 &self.0
192 }
193}
194
195/// A secret's bytes. Zeroised on drop, prints as `[redacted]`, and
196/// implements neither `Display`, `Serialize` nor `Clone`: a secret that
197/// can be formatted, serialised or copied is a secret that ends up in a
198/// log line, a JSON body, or a buffer nobody cleared.
199///
200/// Each absence below is paired with the same operation on a type that
201/// does allow it, so a case that stopped compiling for the wrong reason —
202/// a renamed import, a typo — would fail its pair and be noticed.
203///
204/// **Not `Display`.** `{}` is how a value reaches a log line by accident:
205///
206/// ```compile_fail
207/// # use cratefield_secrets::SecretBytes;
208/// let secret = SecretBytes::new(b"hunter2".to_vec());
209/// println!("{secret}");
210/// ```
211///
212/// ```
213/// # use cratefield_secrets::SecretBytes;
214/// let secret = SecretBytes::new(b"hunter2".to_vec());
215/// // `Debug` is implemented, and redacts.
216/// assert_eq!(format!("{secret:?}"), "SecretBytes([redacted])");
217/// ```
218///
219/// **Not `Clone`.** A copy is a second buffer nobody is tracking:
220///
221/// ```compile_fail
222/// # use cratefield_secrets::SecretBytes;
223/// let secret = SecretBytes::new(b"hunter2".to_vec());
224/// let _copy = secret.clone();
225/// ```
226///
227/// ```
228/// # use cratefield_secrets::SecretBytes;
229/// let secret = SecretBytes::new(b"hunter2".to_vec());
230/// // Moving is fine: there is still exactly one buffer.
231/// let moved = secret;
232/// assert_eq!(moved.expose(), b"hunter2");
233/// ```
234///
235/// **Not `Serialize`.** Serialisation is how a secret reaches a JSON body:
236///
237/// ```compile_fail
238/// # use cratefield_secrets::SecretBytes;
239/// fn needs_serialize<T: serde::Serialize>(_value: &T) {}
240/// let secret = SecretBytes::new(b"hunter2".to_vec());
241/// needs_serialize(&secret);
242/// ```
243///
244/// ```
245/// fn needs_serialize<T: serde::Serialize>(_value: &T) {}
246/// // The bound itself is satisfiable — it is `SecretBytes` that refuses it.
247/// needs_serialize(&"an ordinary string");
248/// ```
249pub struct SecretBytes(Zeroizing<Vec<u8>>);
250
251impl SecretBytes {
252 #[must_use]
253 pub fn new(bytes: Vec<u8>) -> Self {
254 Self(Zeroizing::new(bytes))
255 }
256
257 /// The bytes. Named `expose` so a reader has to notice.
258 #[must_use]
259 pub fn expose(&self) -> &[u8] {
260 &self.0
261 }
262
263 /// The bytes as text, when the secret is one (an API key).
264 ///
265 /// # Errors
266 ///
267 /// [`SecretsError::Invalid`] when the value is not UTF-8.
268 pub fn expose_str(&self) -> Result<&str, SecretsError> {
269 std::str::from_utf8(&self.0)
270 .map_err(|_| SecretsError::Invalid("this secret is not UTF-8".to_owned()))
271 }
272}
273
274impl From<&str> for SecretBytes {
275 fn from(value: &str) -> Self {
276 Self::new(value.as_bytes().to_vec())
277 }
278}
279
280impl fmt::Debug for SecretBytes {
281 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282 f.write_str("SecretBytes([redacted])")
283 }
284}
285
286/// What `list` returns: names and versions, never values.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct SecretMeta {
289 pub name: String,
290 pub version: u32,
291 pub created_at: String,
292 pub created_by: String,
293 /// `true` once `delete` has soft-deleted every version.
294 pub deleted: bool,
295}
296
297/// A secret's version. Monotonic per name, starting at 1.
298pub type Version = u32;
299
300#[derive(Debug, Error)]
301pub enum SecretsError {
302 /// The KMS could not wrap or unwrap. Carries the port's error so the
303 /// caller can still tell retryable from not.
304 #[error("the key could not be unwrapped: {0}")]
305 Kms(#[from] cratefield_kms::KmsError),
306 /// The database refused or was unreachable.
307 #[error("the secrets database: {0}")]
308 Database(#[from] cratefield_core::DbError),
309 /// A ciphertext did not authenticate under its context. Either the
310 /// row was altered, or it was moved between stores, renamed, rolled
311 /// back, or repointed at another key.
312 #[error(
313 "secret `{name}` version {version} did not decrypt in store `{store}`: the row does not \
314 match the context it was sealed with (moved, renamed, rolled back, or altered)"
315 )]
316 NotAuthentic {
317 store: String,
318 name: String,
319 version: Version,
320 },
321 /// The store has no data key yet, its key row is gone (an
322 /// offboarding shred), or the key id names a row stamped with
323 /// another store, which this one cannot see. Every ciphertext in it
324 /// is unreadable, which is a deliberate state, not a fault to paper
325 /// over.
326 #[error("store `{0}` has no usable data key: nothing in it can be decrypted")]
327 NoKey(String),
328 #[error("invalid input: {0}")]
329 Invalid(String),
330 /// The access could not be recorded, so it did not happen: the
331 /// store refuses rather than serving a secret nobody can account
332 /// for.
333 #[error("the access could not be audited, so it was refused: {0}")]
334 NotAudited(String),
335 /// The audit chain does not verify. The `seq` is the first row whose
336 /// hash does not follow from its predecessor.
337 #[error("the audit chain for store `{store}` breaks at seq {seq}: {detail}")]
338 ChainBroken {
339 store: String,
340 seq: i64,
341 detail: String,
342 },
343}
344
345/// Where the append-only audit log (#41) attaches. Every store method
346/// calls this before returning, on success and on failure, so a refused
347/// read is recorded as loudly as a successful one.
348///
349/// The event never carries a secret value; the type makes that hard by
350/// not offering one.
351#[async_trait::async_trait]
352pub trait Audit: Send + Sync {
353 /// Records one access. A sink that writes to a database is async,
354 /// which is why this is: the store awaits it **before returning**,
355 /// so a value never reaches a caller whose access went unrecorded.
356 ///
357 /// A sink that cannot record must say so. The store turns that into
358 /// a refusal rather than serving the secret anyway: an unrecorded
359 /// read is exactly what the log exists to make impossible.
360 async fn record(&self, event: &AuditEvent<'_>) -> Result<(), SecretsError>;
361}
362
363/// One access, as the audit log will store it.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum Access {
366 Put,
367 Get,
368 List,
369 Delete,
370 /// The data key was replaced and every live secret re-encrypted.
371 RotateDek,
372 /// Every data key was re-wrapped under the master key's current
373 /// material. No secret changed.
374 Rewrap,
375}
376
377impl Access {
378 /// The inverse of [`Access::as_str`], for reading a chain back.
379 /// Deliberately not `FromStr`: an unknown action is `None` rather
380 /// than an error type nobody would match on.
381 #[must_use]
382 pub fn parse(text: &str) -> Option<Self> {
383 Some(match text {
384 "put" => Access::Put,
385 "get" => Access::Get,
386 "list" => Access::List,
387 "delete" => Access::Delete,
388 "rotate_dek" => Access::RotateDek,
389 "rewrap" => Access::Rewrap,
390 _ => return None,
391 })
392 }
393
394 #[must_use]
395 pub fn as_str(self) -> &'static str {
396 match self {
397 Access::Put => "put",
398 Access::Get => "get",
399 Access::List => "list",
400 Access::Delete => "delete",
401 Access::RotateDek => "rotate_dek",
402 Access::Rewrap => "rewrap",
403 }
404 }
405}
406
407/// What the audit log records. No value, ever.
408#[derive(Debug)]
409pub struct AuditEvent<'a> {
410 pub store: &'a StoreId,
411 pub access: Access,
412 pub actor: &'a Actor,
413 /// Absent for `list`, which is about the store rather than one name.
414 pub name: Option<&'a str>,
415 pub version: Option<Version>,
416 /// `false` when the call returned an error; the log records the
417 /// attempt either way.
418 pub allowed: bool,
419 /// The request this access belongs to, when there is one, so an
420 /// audit row can be tied to a log line (`cratefield_core::Scope`).
421 pub request_id: Option<&'a str>,
422}
423
424/// The default audit sink until #41 lands: one structured `tracing` line
425/// per access, with no value in it.
426pub struct TracingAudit;
427
428#[async_trait::async_trait]
429impl Audit for TracingAudit {
430 async fn record(&self, event: &AuditEvent<'_>) -> Result<(), SecretsError> {
431 tracing::info!(
432 store = %event.store,
433 access = event.access.as_str(),
434 actor = event.actor.as_str(),
435 name = event.name.unwrap_or("-"),
436 version = event.version.unwrap_or(0),
437 allowed = event.allowed,
438 "secret access"
439 );
440 // The audit trail is an accountability control, not a nicety, so
441 // the same record rides the forwarder (issue #441): on wasm the
442 // tracing event goes nowhere, and an access nobody can read is an
443 // audit nobody answers for.
444 cratefield_core::forward_control_event(
445 cratefield_core::ControlLevel::Info,
446 &format!(
447 "secret access: store {} {} actor {} name {} version {} allowed {}",
448 event.store,
449 event.access.as_str(),
450 event.actor.as_str(),
451 event.name.unwrap_or("-"),
452 event.version.unwrap_or(0),
453 event.allowed
454 ),
455 );
456 Ok(())
457 }
458}
459
460/// The additional authenticated data every ciphertext is sealed with
461/// (`docs/SECRETS-DESIGN.md` §5): a domain-separation prefix, then each
462/// field with an explicit little-endian length prefix so the encoding is
463/// injective and no two distinct contexts can share an AAD.
464///
465/// Ported unchanged from the #38 spike, whose tests prove each failure
466/// mode against every candidate cipher.
467#[must_use]
468pub fn aad(store: &StoreId, name: &str, version: Version, key_id: &str) -> Vec<u8> {
469 fn push(out: &mut Vec<u8>, bytes: &[u8]) {
470 out.extend_from_slice(&u32::try_from(bytes.len()).unwrap_or(u32::MAX).to_le_bytes());
471 out.extend_from_slice(bytes);
472 }
473 let mut out = Vec::with_capacity(64);
474 out.extend_from_slice(b"FZ-SECRETS-AAD-v1");
475 push(&mut out, store.as_str().as_bytes());
476 push(&mut out, name.as_bytes());
477 push(&mut out, &version.to_le_bytes());
478 push(&mut out, key_id.as_bytes());
479 out
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 #[test]
487 fn a_secret_does_not_print_itself() {
488 let secret = SecretBytes::from("sk_live_do_not_log_me");
489 let shown = format!("{secret:?}");
490 assert_eq!(shown, "SecretBytes([redacted])");
491 assert!(!shown.contains("sk_live"), "{shown}");
492 }
493
494 #[test]
495 fn an_actor_is_never_empty() {
496 assert!(Actor::new("").is_err());
497 assert!(Actor::new(" ").is_err());
498 assert_eq!(Actor::new("waitlist").expect("ok").as_str(), "waitlist");
499 }
500
501 /// The property the whole design rests on: distinct contexts cannot
502 /// share an AAD, so a row is only valid where it was written.
503 #[test]
504 fn the_aad_is_injective_across_every_field() {
505 let base = aad(&StoreId::Global, "resend/api_key", 1, "k1");
506 let variants = [
507 aad(&StoreId::Tenant("t1".into()), "resend/api_key", 1, "k1"),
508 aad(&StoreId::Global, "resend/api_keys", 1, "k1"),
509 aad(&StoreId::Global, "resend/api_key", 2, "k1"),
510 aad(&StoreId::Global, "resend/api_key", 1, "k2"),
511 ];
512 for variant in &variants {
513 assert_ne!(&base, variant, "two contexts produced the same AAD");
514 }
515 assert_eq!(base, aad(&StoreId::Global, "resend/api_key", 1, "k1"));
516 }
517
518 /// Length prefixes are what make it injective: without them
519 /// `("ab", "c")` and `("a", "bc")` would collide.
520 #[test]
521 fn field_boundaries_cannot_be_slid() {
522 let left = aad(&StoreId::Tenant("ab".into()), "c", 1, "k");
523 let right = aad(&StoreId::Tenant("a".into()), "bc", 1, "k");
524 assert_ne!(left, right);
525 }
526}