cratefield-secrets 0.3.1

Cratefield harness secrets: envelope encryption over the Database port, two tiers, AAD-bound ciphertexts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
//! `cratefield-secrets` — the secrets store (issue #39,
//! `docs/SECRETS-DESIGN.md`, ADR 0102).
//!
//! Envelope encryption over the `Database` port, in two tiers. **Global**
//! secrets (tenant connection strings, platform keys) live in the control
//! database; **tenant** secrets live in that tenant's database and nowhere
//! else. Each store holds its own wrapped data key, which the KMS unwraps
//! and never stores (§3 of the design: a database dump is ciphertext plus
//! a blob nobody outside the KMS can open).
//!
//! Every ciphertext is bound to its row by the AEAD's additional data:
//! store, name, version and key id, length-prefixed (§5). A row copied to
//! another database, renamed, rolled back a version, or repointed at
//! another key **fails to decrypt** rather than quietly succeeding.
//!
//! What this crate deliberately does not do: rotation (#42) and the
//! append-only audit log (#41). [`Audit`] is the seam the latter fills;
//! until then every access is logged through `tracing` with no value in
//! it.

#![forbid(unsafe_code)]

pub mod audit;
mod rotate;
mod store;

use std::fmt;

use thiserror::Error;
use zeroize::Zeroizing;

pub use audit::{Anchor, ChainAudit, chain_sink, verify};
pub use rotate::{RewrapReport, RotationReport};
pub use store::{HarnessOnly, SecretStore, Secrets};

/// The store's schema, per dialect, applied the same way a module's is.
/// The tables are portable; only the append-only trigger differs, which
/// is what `Migrations`' two sets are for (ADR 0004).
///
/// The sets are `pub const` so a composition can embed them in its own
/// migration array the way the console re-ids its sub-schemas: the
/// control plane's dashboard module applies these tables under its own
/// ids, and sharing the constants is what keeps it from embedding a
/// second copy of the SQL that can drift.
pub const SQLITE_MIGRATIONS: [cratefield_core::SqlMigration; 4] = [
    cratefield_core::SqlMigration::new(
        "0001",
        "init",
        include_str!("../migrations/sqlite/0001_init.sql"),
    ),
    cratefield_core::SqlMigration::new(
        "0002",
        "audit",
        include_str!("../migrations/sqlite/0002_audit.sql"),
    ),
    cratefield_core::SqlMigration::new(
        "0003",
        "audit-store",
        include_str!("../migrations/sqlite/0003_audit_store.sql"),
    ),
    cratefield_core::SqlMigration::new(
        "0004",
        "store-attribution",
        include_str!("../migrations/sqlite/0004_store_attribution.sql"),
    ),
];
// The array is the apply order; this refuses a gap, a duplicate
// or an entry out of order at build time (issue #27).
const _: () = cratefield_core::assert_migration_set(&SQLITE_MIGRATIONS);
pub const POSTGRES_MIGRATIONS: [cratefield_core::SqlMigration; 4] = [
    cratefield_core::SqlMigration::new(
        "0001",
        "init",
        include_str!("../migrations/postgres/0001_init.sql"),
    ),
    cratefield_core::SqlMigration::new(
        "0002",
        "audit",
        include_str!("../migrations/postgres/0002_audit.sql"),
    ),
    cratefield_core::SqlMigration::new(
        "0003",
        "audit-store",
        include_str!("../migrations/postgres/0003_audit_store.sql"),
    ),
    cratefield_core::SqlMigration::new(
        "0004",
        "store-attribution",
        include_str!("../migrations/postgres/0004_store_attribution.sql"),
    ),
];
// The array is the apply order; this refuses a gap, a duplicate
// or an entry out of order at build time (issue #27).
const _: () = cratefield_core::assert_migration_set(&POSTGRES_MIGRATIONS);

/// The store's schema, per dialect, applied the same way a module's is
/// (see [`SQLITE_MIGRATIONS`] for why the sets are public).
#[must_use]
pub fn migrations() -> cratefield_core::Migrations {
    cratefield_core::Migrations {
        sqlite: &SQLITE_MIGRATIONS,
        postgres: &POSTGRES_MIGRATIONS,
    }
}

/// The AEAD this crate seals with, recorded on every key row so a store
/// can never mix ciphers within one key id (design §4).
pub const CIPHER: &str = "xchacha20poly1305";

/// The store-scoping predicate every row-level query filters through
/// since store attribution (the row-level twin of the audit chain's
/// #142): this store's rows, and only this store's.
///
/// It does **not** carry the chain's `OR store = ''` compatibility
/// clause, and the difference is the difference between the two tables.
/// `harness_secret_audit` is append-only, so its past cannot be
/// rewritten and the clause can only ever let an old row still be read.
/// Rows here are soft-deleted, re-encrypted and re-keyed, so the same
/// clause would hand every store a *write* over every other store's
/// unstamped rows: a tenant's `delete` landing on the global store's
/// row, `active_key` serving another store's key, `rotate_dek` pulling
/// in rows it cannot decrypt and failing closed on their AAD. That is
/// precisely the store-blindness attribution exists to end.
///
/// Nothing is given up. No composition ever applied this schema before
/// attribution — the secrets layer was built, merged and never wired —
/// so no database holds a row written without a store. An unstamped row
/// would be one nobody can account for, and it is treated as nobody's:
/// invisible to every store until it is re-put under the one that owns
/// it. `tests/shared_database.rs` pins that.
///
/// Bind order matters: every query using it binds the predicate's `?`
/// after that query's own parameters, and this is the one place that
/// says so.
pub(crate) fn scoped_store() -> &'static str {
    "store = ?"
}

/// Which store a secret belongs to. Part of the AAD, so it is also what
/// stops a row being useful anywhere else.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreId {
    /// The control database: connection strings and platform keys. Not
    /// reachable from module code.
    Global,
    /// One tenant's database, by tenant id.
    Tenant(String),
}

impl StoreId {
    /// The bytes that go in the AAD.
    #[must_use]
    pub fn as_str(&self) -> &str {
        match self {
            StoreId::Global => "global",
            StoreId::Tenant(id) => id,
        }
    }
}

impl fmt::Display for StoreId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Who is asking. Every method takes one because the audit log needs one
/// and there is no anonymous access to a secret.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Actor(String);

impl Actor {
    /// Names the caller: a module name, an operator, a job.
    ///
    /// # Errors
    ///
    /// [`SecretsError::Invalid`] when empty: "who did this" with no
    /// answer is not an audit record.
    pub fn new(who: impl Into<String>) -> Result<Self, SecretsError> {
        let who = who.into();
        if who.trim().is_empty() {
            return Err(SecretsError::Invalid(
                "an actor cannot be empty: every access is attributed".to_owned(),
            ));
        }
        Ok(Self(who))
    }

    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// A secret's bytes. Zeroised on drop, prints as `[redacted]`, and
/// implements neither `Display`, `Serialize` nor `Clone`: a secret that
/// can be formatted, serialised or copied is a secret that ends up in a
/// log line, a JSON body, or a buffer nobody cleared.
///
/// Each absence below is paired with the same operation on a type that
/// does allow it, so a case that stopped compiling for the wrong reason —
/// a renamed import, a typo — would fail its pair and be noticed.
///
/// **Not `Display`.** `{}` is how a value reaches a log line by accident:
///
/// ```compile_fail
/// # use cratefield_secrets::SecretBytes;
/// let secret = SecretBytes::new(b"hunter2".to_vec());
/// println!("{secret}");
/// ```
///
/// ```
/// # use cratefield_secrets::SecretBytes;
/// let secret = SecretBytes::new(b"hunter2".to_vec());
/// // `Debug` is implemented, and redacts.
/// assert_eq!(format!("{secret:?}"), "SecretBytes([redacted])");
/// ```
///
/// **Not `Clone`.** A copy is a second buffer nobody is tracking:
///
/// ```compile_fail
/// # use cratefield_secrets::SecretBytes;
/// let secret = SecretBytes::new(b"hunter2".to_vec());
/// let _copy = secret.clone();
/// ```
///
/// ```
/// # use cratefield_secrets::SecretBytes;
/// let secret = SecretBytes::new(b"hunter2".to_vec());
/// // Moving is fine: there is still exactly one buffer.
/// let moved = secret;
/// assert_eq!(moved.expose(), b"hunter2");
/// ```
///
/// **Not `Serialize`.** Serialisation is how a secret reaches a JSON body:
///
/// ```compile_fail
/// # use cratefield_secrets::SecretBytes;
/// fn needs_serialize<T: serde::Serialize>(_value: &T) {}
/// let secret = SecretBytes::new(b"hunter2".to_vec());
/// needs_serialize(&secret);
/// ```
///
/// ```
/// fn needs_serialize<T: serde::Serialize>(_value: &T) {}
/// // The bound itself is satisfiable — it is `SecretBytes` that refuses it.
/// needs_serialize(&"an ordinary string");
/// ```
pub struct SecretBytes(Zeroizing<Vec<u8>>);

impl SecretBytes {
    #[must_use]
    pub fn new(bytes: Vec<u8>) -> Self {
        Self(Zeroizing::new(bytes))
    }

    /// The bytes. Named `expose` so a reader has to notice.
    #[must_use]
    pub fn expose(&self) -> &[u8] {
        &self.0
    }

    /// The bytes as text, when the secret is one (an API key).
    ///
    /// # Errors
    ///
    /// [`SecretsError::Invalid`] when the value is not UTF-8.
    pub fn expose_str(&self) -> Result<&str, SecretsError> {
        std::str::from_utf8(&self.0)
            .map_err(|_| SecretsError::Invalid("this secret is not UTF-8".to_owned()))
    }
}

impl From<&str> for SecretBytes {
    fn from(value: &str) -> Self {
        Self::new(value.as_bytes().to_vec())
    }
}

impl fmt::Debug for SecretBytes {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("SecretBytes([redacted])")
    }
}

/// What `list` returns: names and versions, never values.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretMeta {
    pub name: String,
    pub version: u32,
    pub created_at: String,
    pub created_by: String,
    /// `true` once `delete` has soft-deleted every version.
    pub deleted: bool,
}

/// A secret's version. Monotonic per name, starting at 1.
pub type Version = u32;

#[derive(Debug, Error)]
pub enum SecretsError {
    /// The KMS could not wrap or unwrap. Carries the port's error so the
    /// caller can still tell retryable from not.
    #[error("the key could not be unwrapped: {0}")]
    Kms(#[from] cratefield_kms::KmsError),
    /// The database refused or was unreachable.
    #[error("the secrets database: {0}")]
    Database(#[from] cratefield_core::DbError),
    /// A ciphertext did not authenticate under its context. Either the
    /// row was altered, or it was moved between stores, renamed, rolled
    /// back, or repointed at another key.
    #[error(
        "secret `{name}` version {version} did not decrypt in store `{store}`: the row does not \
         match the context it was sealed with (moved, renamed, rolled back, or altered)"
    )]
    NotAuthentic {
        store: String,
        name: String,
        version: Version,
    },
    /// The store has no data key yet, its key row is gone (an
    /// offboarding shred), or the key id names a row stamped with
    /// another store, which this one cannot see. Every ciphertext in it
    /// is unreadable, which is a deliberate state, not a fault to paper
    /// over.
    #[error("store `{0}` has no usable data key: nothing in it can be decrypted")]
    NoKey(String),
    #[error("invalid input: {0}")]
    Invalid(String),
    /// The access could not be recorded, so it did not happen: the
    /// store refuses rather than serving a secret nobody can account
    /// for.
    #[error("the access could not be audited, so it was refused: {0}")]
    NotAudited(String),
    /// The audit chain does not verify. The `seq` is the first row whose
    /// hash does not follow from its predecessor.
    #[error("the audit chain for store `{store}` breaks at seq {seq}: {detail}")]
    ChainBroken {
        store: String,
        seq: i64,
        detail: String,
    },
}

/// Where the append-only audit log (#41) attaches. Every store method
/// calls this before returning, on success and on failure, so a refused
/// read is recorded as loudly as a successful one.
///
/// The event never carries a secret value; the type makes that hard by
/// not offering one.
#[async_trait::async_trait]
pub trait Audit: Send + Sync {
    /// Records one access. A sink that writes to a database is async,
    /// which is why this is: the store awaits it **before returning**,
    /// so a value never reaches a caller whose access went unrecorded.
    ///
    /// A sink that cannot record must say so. The store turns that into
    /// a refusal rather than serving the secret anyway: an unrecorded
    /// read is exactly what the log exists to make impossible.
    async fn record(&self, event: &AuditEvent<'_>) -> Result<(), SecretsError>;
}

/// One access, as the audit log will store it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
    Put,
    Get,
    List,
    Delete,
    /// The data key was replaced and every live secret re-encrypted.
    RotateDek,
    /// Every data key was re-wrapped under the master key's current
    /// material. No secret changed.
    Rewrap,
}

impl Access {
    /// The inverse of [`Access::as_str`], for reading a chain back.
    /// Deliberately not `FromStr`: an unknown action is `None` rather
    /// than an error type nobody would match on.
    #[must_use]
    pub fn parse(text: &str) -> Option<Self> {
        Some(match text {
            "put" => Access::Put,
            "get" => Access::Get,
            "list" => Access::List,
            "delete" => Access::Delete,
            "rotate_dek" => Access::RotateDek,
            "rewrap" => Access::Rewrap,
            _ => return None,
        })
    }

    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Access::Put => "put",
            Access::Get => "get",
            Access::List => "list",
            Access::Delete => "delete",
            Access::RotateDek => "rotate_dek",
            Access::Rewrap => "rewrap",
        }
    }
}

/// What the audit log records. No value, ever.
#[derive(Debug)]
pub struct AuditEvent<'a> {
    pub store: &'a StoreId,
    pub access: Access,
    pub actor: &'a Actor,
    /// Absent for `list`, which is about the store rather than one name.
    pub name: Option<&'a str>,
    pub version: Option<Version>,
    /// `false` when the call returned an error; the log records the
    /// attempt either way.
    pub allowed: bool,
    /// The request this access belongs to, when there is one, so an
    /// audit row can be tied to a log line (`cratefield_core::Scope`).
    pub request_id: Option<&'a str>,
}

/// The default audit sink until #41 lands: one structured `tracing` line
/// per access, with no value in it.
pub struct TracingAudit;

#[async_trait::async_trait]
impl Audit for TracingAudit {
    async fn record(&self, event: &AuditEvent<'_>) -> Result<(), SecretsError> {
        tracing::info!(
            store = %event.store,
            access = event.access.as_str(),
            actor = event.actor.as_str(),
            name = event.name.unwrap_or("-"),
            version = event.version.unwrap_or(0),
            allowed = event.allowed,
            "secret access"
        );
        // The audit trail is an accountability control, not a nicety, so
        // the same record rides the forwarder (issue #441): on wasm the
        // tracing event goes nowhere, and an access nobody can read is an
        // audit nobody answers for.
        cratefield_core::forward_control_event(
            cratefield_core::ControlLevel::Info,
            &format!(
                "secret access: store {} {} actor {} name {} version {} allowed {}",
                event.store,
                event.access.as_str(),
                event.actor.as_str(),
                event.name.unwrap_or("-"),
                event.version.unwrap_or(0),
                event.allowed
            ),
        );
        Ok(())
    }
}

/// The additional authenticated data every ciphertext is sealed with
/// (`docs/SECRETS-DESIGN.md` §5): a domain-separation prefix, then each
/// field with an explicit little-endian length prefix so the encoding is
/// injective and no two distinct contexts can share an AAD.
///
/// Ported unchanged from the #38 spike, whose tests prove each failure
/// mode against every candidate cipher.
#[must_use]
pub fn aad(store: &StoreId, name: &str, version: Version, key_id: &str) -> Vec<u8> {
    fn push(out: &mut Vec<u8>, bytes: &[u8]) {
        out.extend_from_slice(&u32::try_from(bytes.len()).unwrap_or(u32::MAX).to_le_bytes());
        out.extend_from_slice(bytes);
    }
    let mut out = Vec::with_capacity(64);
    out.extend_from_slice(b"FZ-SECRETS-AAD-v1");
    push(&mut out, store.as_str().as_bytes());
    push(&mut out, name.as_bytes());
    push(&mut out, &version.to_le_bytes());
    push(&mut out, key_id.as_bytes());
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_secret_does_not_print_itself() {
        let secret = SecretBytes::from("sk_live_do_not_log_me");
        let shown = format!("{secret:?}");
        assert_eq!(shown, "SecretBytes([redacted])");
        assert!(!shown.contains("sk_live"), "{shown}");
    }

    #[test]
    fn an_actor_is_never_empty() {
        assert!(Actor::new("").is_err());
        assert!(Actor::new("   ").is_err());
        assert_eq!(Actor::new("waitlist").expect("ok").as_str(), "waitlist");
    }

    /// The property the whole design rests on: distinct contexts cannot
    /// share an AAD, so a row is only valid where it was written.
    #[test]
    fn the_aad_is_injective_across_every_field() {
        let base = aad(&StoreId::Global, "resend/api_key", 1, "k1");
        let variants = [
            aad(&StoreId::Tenant("t1".into()), "resend/api_key", 1, "k1"),
            aad(&StoreId::Global, "resend/api_keys", 1, "k1"),
            aad(&StoreId::Global, "resend/api_key", 2, "k1"),
            aad(&StoreId::Global, "resend/api_key", 1, "k2"),
        ];
        for variant in &variants {
            assert_ne!(&base, variant, "two contexts produced the same AAD");
        }
        assert_eq!(base, aad(&StoreId::Global, "resend/api_key", 1, "k1"));
    }

    /// Length prefixes are what make it injective: without them
    /// `("ab", "c")` and `("a", "bc")` would collide.
    #[test]
    fn field_boundaries_cannot_be_slid() {
        let left = aad(&StoreId::Tenant("ab".into()), "c", 1, "k");
        let right = aad(&StoreId::Tenant("a".into()), "bc", 1, "k");
        assert_ne!(left, right);
    }
}