Skip to main content

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}