Skip to main content

cratefield_secrets/
store.rs

1//! The two-tier store (issue #39). [`Secrets`] owns the KMS and hands
2//! out a [`SecretStore`] bound to one database and one [`StoreId`].
3//!
4//! `Secrets::global()` is `pub(crate)`-adjacent by construction: it takes
5//! a token type that only the harness can make, so module code cannot
6//! reach the control database's store even though the method is public.
7
8use std::sync::Arc;
9
10use chacha20poly1305::aead::{Aead, KeyInit, Payload};
11use cratefield_core::{Database, Row, Statement};
12use cratefield_kms::{Dek, Kms};
13use sea_query::Value as SeaValue;
14
15use crate::{
16    Access, Actor, Audit, AuditEvent, CIPHER, SecretBytes, SecretMeta, SecretsError, StoreId,
17    TracingAudit, Version, aad, scoped_store,
18};
19
20const NONCE_LEN: usize = 24;
21
22/// Proof the caller is the harness itself and not a module. Only the
23/// harness can construct one, so [`Secrets::global`] is unreachable from
24/// module code however public it looks: a module has no way to make the
25/// argument.
26///
27/// This is the compile-time half of the two-tier rule. The runtime half
28/// is that a `ModuleContext` never carries a `Secrets`.
29///
30/// The type is nameable outside the crate — a signature that mentions it
31/// has to be writable — but not constructible. Its field is private and
32/// it has no public constructor:
33///
34/// ```compile_fail
35/// use cratefield_secrets::HarnessOnly;
36/// // The tuple field is private: module code cannot build the proof.
37/// let _proof = HarnessOnly(());
38/// ```
39///
40/// ```compile_fail
41/// use cratefield_secrets::Secrets;
42/// # use std::sync::Arc;
43/// # fn kms() -> Arc<dyn cratefield_kms::Kms> { unimplemented!() }
44/// // Nor mint one through the harness's own constructor, which is
45/// // `pub(crate)` precisely so this does not compile.
46/// let _proof = Secrets::harness_only();
47/// ```
48///
49/// Naming the type is fine, which is what keeps the signature writable:
50///
51/// ```
52/// use cratefield_secrets::HarnessOnly;
53/// fn takes_proof(_proof: HarnessOnly) {}
54/// ```
55pub struct HarnessOnly(());
56
57/// Owns the KMS and the audit sink; hands out per-store handles.
58pub struct Secrets {
59    kms: Arc<dyn Kms>,
60    audit: Arc<dyn Audit>,
61}
62
63impl Secrets {
64    /// Wires the KMS. The default audit sink logs one line per access
65    /// through `tracing`; #41 replaces it with the append-only log.
66    #[must_use]
67    pub fn new(kms: Arc<dyn Kms>) -> Self {
68        Self {
69            kms,
70            audit: Arc::new(TracingAudit),
71        }
72    }
73
74    #[must_use]
75    pub fn with_audit(mut self, audit: Arc<dyn Audit>) -> Self {
76        self.audit = audit;
77        self
78    }
79
80    /// The control database's store: connection strings and platform
81    /// keys. Needs a [`HarnessOnly`], which module code cannot build.
82    ///
83    /// The proof is the whole gate, so the call is unreachable without
84    /// one — a module holding a `Secrets` and a `Database` still cannot
85    /// open the global store:
86    ///
87    /// ```compile_fail
88    /// # use std::sync::Arc;
89    /// # use cratefield_core::Database;
90    /// # use cratefield_secrets::Secrets;
91    /// fn reach_the_control_database(secrets: &Secrets, db: Arc<dyn Database>) {
92    ///     // No second argument can be constructed here.
93    ///     let _global = secrets.global(db);
94    /// }
95    /// ```
96    ///
97    /// The tenant store, which is what a module is actually given, needs
98    /// no proof at all:
99    ///
100    /// ```
101    /// # use std::sync::Arc;
102    /// # use cratefield_core::Database;
103    /// # use cratefield_secrets::Secrets;
104    /// fn a_module_reaches_its_own_store(secrets: &Secrets, db: Arc<dyn Database>) {
105    ///     let _mine = secrets.tenant("acme", db).expect("an ordinary tenant id");
106    /// }
107    /// ```
108    #[must_use]
109    pub fn global(&self, db: Arc<dyn Database>, _proof: HarnessOnly) -> SecretStore {
110        self.store(StoreId::Global, db)
111    }
112
113    /// One tenant's store, in that tenant's own database. This is what a
114    /// module is given.
115    ///
116    /// The id becomes the row stamp and the AAD verbatim, so it is
117    /// checked here rather than taken on trust.
118    ///
119    /// # Errors
120    ///
121    /// [`SecretsError::Invalid`] when the id is `"global"`: that names
122    /// the control plane's store, which only [`Secrets::global`]'s proof
123    /// may open — a tenant store stamped `global` would be
124    /// indistinguishable from it, and the proof would gate nothing. Also
125    /// when the id is empty or whitespace: a store stamped with nothing
126    /// is nobody's, invisible to every store. Surrounding whitespace is
127    /// preserved, not trimmed — `" acme "` is stamped verbatim and
128    /// names a different store than `acme`.
129    pub fn tenant(&self, tenant: &str, db: Arc<dyn Database>) -> Result<SecretStore, SecretsError> {
130        if tenant == "global" {
131            return Err(SecretsError::Invalid(
132                "`global` names the control plane's store, which only the harness may open"
133                    .to_owned(),
134            ));
135        }
136        if tenant.trim().is_empty() {
137            return Err(SecretsError::Invalid(
138                "a tenant id cannot be blank: a store stamped with nothing is nobody's".to_owned(),
139            ));
140        }
141        Ok(self.store(StoreId::Tenant(tenant.to_owned()), db))
142    }
143
144    /// The control database's global store, for the control plane's own
145    /// composition — the secrets-manager screen. Gated behind the
146    /// `control-plane` cargo feature, which no venture composition
147    /// enables: within this workspace that is the same fence
148    /// [`Secrets::global`](Self::global)'s [`HarnessOnly`] proof draws
149    /// (module code cannot construct the token), stated once more at the
150    /// dependency layer — a module crate cannot reach this method without
151    /// pulling in a feature named for exactly the power it grants.
152    ///
153    /// The honest limitation, recorded here rather than hidden: a caller
154    /// that enables the feature and holds a `Secrets` can open a
155    /// "global" store in any database it hands over, tiering be damned.
156    /// That caller has asked for control-plane powers by name; the
157    /// published crate's default build still cannot.
158    #[cfg(feature = "control-plane")]
159    #[must_use]
160    pub fn control_plane_global(&self, db: Arc<dyn Database>) -> SecretStore {
161        self.store(StoreId::Global, db)
162    }
163
164    fn store(&self, id: StoreId, db: Arc<dyn Database>) -> SecretStore {
165        SecretStore {
166            id,
167            db,
168            kms: Arc::clone(&self.kms),
169            audit: Arc::clone(&self.audit),
170        }
171    }
172
173    /// The harness's own proof token, for wiring the control store.
174    ///
175    /// `pub(crate)`, and that is the whole point: while this was `pub`,
176    /// any caller could mint the proof [`Secrets::global`] demands and
177    /// reach the control database — the two-tier rule's only
178    /// compile-time guarantee, undone by its own escape hatch.
179    ///
180    /// `allow(dead_code)` because the one production caller does not
181    /// exist yet: nothing wires the control database until the boot
182    /// reconciliation of #23/#29, and the alternative — leaving the
183    /// constructor `pub` so it looks used — is the bug this replaced.
184    /// The unit test below is its only caller today.
185    #[must_use]
186    #[allow(
187        dead_code,
188        reason = "the harness wiring that calls this lands with #23/#29"
189    )]
190    pub(crate) fn harness_only() -> HarnessOnly {
191        HarnessOnly(())
192    }
193}
194
195/// One store: one database, one [`StoreId`], one data key.
196pub struct SecretStore {
197    id: StoreId,
198    db: Arc<dyn Database>,
199    kms: Arc<dyn Kms>,
200    audit: Arc<dyn Audit>,
201}
202
203impl SecretStore {
204    #[must_use]
205    pub fn id(&self) -> &StoreId {
206        &self.id
207    }
208
209    pub(crate) fn db(&self) -> &Arc<dyn Database> {
210        &self.db
211    }
212
213    pub(crate) fn kms(&self) -> &Arc<dyn Kms> {
214        &self.kms
215    }
216
217    /// Seals `plaintext` for one row, returning its nonce and
218    /// ciphertext. Shared by `put` and by rotation so both bind the same
219    /// context.
220    pub(crate) fn seal(
221        &self,
222        name: &str,
223        version: Version,
224        key_id: &str,
225        dek: &Dek,
226        plaintext: &[u8],
227    ) -> Result<(Vec<u8>, Vec<u8>), SecretsError> {
228        let mut nonce = [0_u8; NONCE_LEN];
229        getrandom::fill(&mut nonce)
230            .map_err(|err| SecretsError::Invalid(format!("the OS random source failed: {err}")))?;
231        let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(dek.expose())
232            .map_err(|_| SecretsError::NoKey(self.id.to_string()))?;
233        let ciphertext = cipher
234            .encrypt(
235                (&nonce).into(),
236                Payload {
237                    msg: plaintext,
238                    aad: &aad(&self.id, name, version, key_id),
239                },
240            )
241            .map_err(|_| SecretsError::Invalid("the secret could not be sealed".to_owned()))?;
242        Ok((nonce.to_vec(), ciphertext))
243    }
244
245    /// Opens one named version under a key the caller already holds.
246    /// Used by rotation, which reads under the outgoing key.
247    pub(crate) async fn open_version(
248        &self,
249        name: &str,
250        version: Version,
251        key_id: &str,
252        dek: &Dek,
253    ) -> Result<SecretBytes, SecretsError> {
254        let rows = self
255            .db
256            .query(&Statement::with_values(
257                format!(
258                    "SELECT nonce, ciphertext FROM harness_secrets \
259                     WHERE name = ? AND version = ? AND {}",
260                    scoped_store()
261                ),
262                vec![
263                    text(name),
264                    SeaValue::BigInt(Some(i64::from(version))),
265                    text(self.id.as_str()),
266                ],
267            ))
268            .await?;
269        let row = rows.first().ok_or_else(|| SecretsError::NotAuthentic {
270            store: self.id.to_string(),
271            name: name.to_owned(),
272            version,
273        })?;
274        let nonce: Vec<u8> = row.get("nonce").unwrap_or_default();
275        let ciphertext: Vec<u8> = row.get("ciphertext").unwrap_or_default();
276        let nonce: [u8; NONCE_LEN] = nonce.try_into().map_err(|_| SecretsError::NotAuthentic {
277            store: self.id.to_string(),
278            name: name.to_owned(),
279            version,
280        })?;
281        let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(dek.expose())
282            .map_err(|_| SecretsError::NoKey(self.id.to_string()))?;
283        let plaintext = cipher
284            .decrypt(
285                (&nonce).into(),
286                Payload {
287                    msg: &ciphertext,
288                    aad: &aad(&self.id, name, version, key_id),
289                },
290            )
291            .map_err(|_| SecretsError::NotAuthentic {
292                store: self.id.to_string(),
293                name: name.to_owned(),
294                version,
295            })?;
296        Ok(SecretBytes::new(plaintext))
297    }
298
299    /// A fresh data key plus the statement that records it, so a caller
300    /// can install it inside a batch with whatever else must happen at
301    /// the same moment.
302    pub(crate) async fn prepare_key(
303        &self,
304        state: &str,
305    ) -> Result<(String, Dek, Statement), SecretsError> {
306        let dek = Dek::generate()?;
307        let wrapped = self.kms.wrap(&dek).await?;
308        let key_id = new_key_id()?;
309        let insert = Statement::with_values(
310            "INSERT INTO harness_secret_keys \
311             (key_id, kms_provider, kms_key_ref, wrapped_dek, cipher, state, created_at, \
312              store) \
313             VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
314            vec![
315                text(&key_id),
316                text(self.kms.provider()),
317                text(self.kms.key_ref()),
318                bytes(wrapped),
319                text(CIPHER),
320                text(state),
321                text(&now()),
322                text(self.id.as_str()),
323            ],
324        );
325        Ok((key_id, dek, insert))
326    }
327
328    /// An audit event for an operation that is about the store rather
329    /// than one secret.
330    pub(crate) async fn audit_action(
331        &self,
332        access: Access,
333        actor: &Actor,
334        allowed: bool,
335    ) -> Result<(), SecretsError> {
336        self.audit(access, actor, None, None, allowed).await
337    }
338
339    /// Writes a new version of `name` and returns it. Versions are
340    /// monotonic per name and start at 1; a value is never overwritten,
341    /// because the previous version is what a rollback needs and what
342    /// the audit log refers to.
343    ///
344    /// # Errors
345    ///
346    /// [`SecretsError`], and the attempt is audited either way.
347    pub async fn put(
348        &self,
349        name: &str,
350        value: &SecretBytes,
351        actor: &Actor,
352    ) -> Result<Version, SecretsError> {
353        let result = self.put_inner(name, value).await;
354        self.audit(
355            Access::Put,
356            actor,
357            Some(name),
358            result.as_ref().ok().copied(),
359            result.is_ok(),
360        )
361        .await?;
362        result
363    }
364
365    async fn put_inner(&self, name: &str, value: &SecretBytes) -> Result<Version, SecretsError> {
366        validate_name(name)?;
367        let (key_id, dek) = self.active_key().await?;
368        let version = self.next_version(name).await?;
369
370        let mut nonce = [0_u8; NONCE_LEN];
371        getrandom::fill(&mut nonce)
372            .map_err(|err| SecretsError::Invalid(format!("the OS random source failed: {err}")))?;
373        let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(dek.expose())
374            .map_err(|_| SecretsError::NoKey(self.id.to_string()))?;
375        let ciphertext = cipher
376            .encrypt(
377                (&nonce).into(),
378                Payload {
379                    msg: value.expose(),
380                    aad: &aad(&self.id, name, version, &key_id),
381                },
382            )
383            .map_err(|_| SecretsError::Invalid("the secret could not be sealed".to_owned()))?;
384
385        self.db
386            .execute(&Statement::with_values(
387                "INSERT INTO harness_secrets \
388                 (name, version, key_id, nonce, ciphertext, created_at, created_by, store) \
389                 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
390                vec![
391                    text(name),
392                    SeaValue::BigInt(Some(i64::from(version))),
393                    text(&key_id),
394                    bytes(nonce.to_vec()),
395                    bytes(ciphertext),
396                    text(&now()),
397                    text("pending"),
398                    text(self.id.as_str()),
399                ],
400            ))
401            .await?;
402        Ok(version)
403    }
404
405    /// The latest version of `name` that has not been deleted, or
406    /// `None`.
407    ///
408    /// # Errors
409    ///
410    /// [`SecretsError::NotAuthentic`] when the row does not match the
411    /// context it was sealed with, and otherwise as [`SecretStore::put`].
412    pub async fn get(
413        &self,
414        name: &str,
415        actor: &Actor,
416    ) -> Result<Option<SecretBytes>, SecretsError> {
417        let result = self.get_inner(name).await;
418        let version = result
419            .as_ref()
420            .ok()
421            .and_then(|found| found.as_ref().map(|(v, _)| *v));
422        self.audit(Access::Get, actor, Some(name), version, result.is_ok())
423            .await?;
424        result.map(|found| found.map(|(_, value)| value))
425    }
426
427    async fn get_inner(&self, name: &str) -> Result<Option<(Version, SecretBytes)>, SecretsError> {
428        validate_name(name)?;
429        let rows = self
430            .db
431            .query(&Statement::with_values(
432                format!(
433                    "SELECT version, key_id, nonce, ciphertext FROM harness_secrets \
434                     WHERE name = ? AND deleted_at IS NULL AND {} \
435                     ORDER BY version DESC LIMIT 1",
436                    scoped_store()
437                ),
438                vec![text(name), text(self.id.as_str())],
439            ))
440            .await?;
441        let Some(row) = rows.first() else {
442            return Ok(None);
443        };
444        let version = version_of(row)?;
445        let key_id: String = row
446            .get("key_id")
447            .ok_or_else(|| SecretsError::Invalid("a secret row has no key id".to_owned()))?;
448        let nonce: Vec<u8> = row
449            .get("nonce")
450            .ok_or_else(|| SecretsError::Invalid("a secret row has no nonce".to_owned()))?;
451        let ciphertext: Vec<u8> = row
452            .get("ciphertext")
453            .ok_or_else(|| SecretsError::Invalid("a secret row has no ciphertext".to_owned()))?;
454        if nonce.len() != NONCE_LEN {
455            return Err(SecretsError::NotAuthentic {
456                store: self.id.to_string(),
457                name: name.to_owned(),
458                version,
459            });
460        }
461
462        let dek = self.key(&key_id).await?;
463        let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(dek.expose())
464            .map_err(|_| SecretsError::NoKey(self.id.to_string()))?;
465        let nonce: [u8; NONCE_LEN] = nonce.try_into().expect("length checked");
466        let plaintext = cipher
467            .decrypt(
468                (&nonce).into(),
469                Payload {
470                    msg: &ciphertext,
471                    aad: &aad(&self.id, name, version, &key_id),
472                },
473            )
474            .map_err(|_| SecretsError::NotAuthentic {
475                store: self.id.to_string(),
476                name: name.to_owned(),
477                version,
478            })?;
479        Ok(Some((version, SecretBytes::new(plaintext))))
480    }
481
482    /// Every secret's latest version: names and versions, never values.
483    ///
484    /// # Errors
485    ///
486    /// As [`SecretStore::put`].
487    pub async fn list(&self, actor: &Actor) -> Result<Vec<SecretMeta>, SecretsError> {
488        let result = self.list_inner().await;
489        self.audit(Access::List, actor, None, None, result.is_ok())
490            .await?;
491        result
492    }
493
494    async fn list_inner(&self) -> Result<Vec<SecretMeta>, SecretsError> {
495        let rows = self
496            .db
497            .query(&Statement::with_values(
498                format!(
499                    "SELECT name, version, created_at, created_by, deleted_at \
500                     FROM harness_secrets WHERE {} ORDER BY name ASC, version DESC",
501                    scoped_store()
502                ),
503                vec![text(self.id.as_str())],
504            ))
505            .await?;
506        let mut out: Vec<SecretMeta> = Vec::new();
507        for row in &rows.rows {
508            let name: String = row
509                .get("name")
510                .ok_or_else(|| SecretsError::Invalid("a secret row has no name".to_owned()))?;
511            if out.iter().any(|seen| seen.name == name) {
512                continue; // ordered by version desc: the first is the latest
513            }
514            out.push(SecretMeta {
515                version: version_of(row)?,
516                created_at: row.get("created_at").unwrap_or_default(),
517                created_by: row.get("created_by").unwrap_or_default(),
518                deleted: row.get::<String>("deleted_at").is_some(),
519                name,
520            });
521        }
522        Ok(out)
523    }
524
525    /// Soft-deletes every version of `name`. The rows stay, so the audit
526    /// trail and any restore keep something to refer to; the value is no
527    /// longer readable through [`SecretStore::get`].
528    ///
529    /// # Errors
530    ///
531    /// As [`SecretStore::put`].
532    pub async fn delete(&self, name: &str, actor: &Actor) -> Result<(), SecretsError> {
533        let result = self.delete_inner(name).await;
534        self.audit(Access::Delete, actor, Some(name), None, result.is_ok())
535            .await?;
536        result
537    }
538
539    async fn delete_inner(&self, name: &str) -> Result<(), SecretsError> {
540        validate_name(name)?;
541        self.db
542            .execute(&Statement::with_values(
543                format!(
544                    "UPDATE harness_secrets SET deleted_at = ? \
545                     WHERE name = ? AND deleted_at IS NULL AND {}",
546                    scoped_store()
547                ),
548                vec![text(&now()), text(name), text(self.id.as_str())],
549            ))
550            .await?;
551        Ok(())
552    }
553
554    /// This store's active data key, provisioning one on first use.
555    pub(crate) async fn active_key(&self) -> Result<(String, Dek), SecretsError> {
556        let rows = self
557            .db
558            .query(&Statement::with_values(
559                format!(
560                    "SELECT key_id, wrapped_dek FROM harness_secret_keys \
561                     WHERE state = 'active' AND {} ORDER BY created_at DESC LIMIT 1",
562                    scoped_store()
563                ),
564                vec![text(self.id.as_str())],
565            ))
566            .await?;
567        if let Some(row) = rows.first() {
568            let key_id: String = row
569                .get("key_id")
570                .ok_or_else(|| SecretsError::NoKey(self.id.to_string()))?;
571            let wrapped: Vec<u8> = row
572                .get("wrapped_dek")
573                .ok_or_else(|| SecretsError::NoKey(self.id.to_string()))?;
574            let dek = self.kms.unwrap(&wrapped).await?;
575            return Ok((key_id, dek));
576        }
577        self.provision().await
578    }
579
580    /// One named key, for decrypting a version sealed under it.
581    pub(crate) async fn key(&self, key_id: &str) -> Result<Dek, SecretsError> {
582        let rows = self
583            .db
584            .query(&Statement::with_values(
585                format!(
586                    "SELECT wrapped_dek FROM harness_secret_keys \
587                     WHERE key_id = ? AND {}",
588                    scoped_store()
589                ),
590                vec![text(key_id), text(self.id.as_str())],
591            ))
592            .await?;
593        let wrapped: Vec<u8> = rows
594            .first()
595            .and_then(|row| row.get("wrapped_dek"))
596            .ok_or_else(|| SecretsError::NoKey(self.id.to_string()))?;
597        Ok(self.kms.unwrap(&wrapped).await?)
598    }
599
600    /// Generates this store's first data key, wraps it under the KMS and
601    /// records the wrapped blob here. The KMS holds no per-store state
602    /// (design §3).
603    async fn provision(&self) -> Result<(String, Dek), SecretsError> {
604        let (key_id, dek, insert) = self.prepare_key("active").await?;
605        self.db.execute(&insert).await?;
606        Ok((key_id, dek))
607    }
608
609    async fn next_version(&self, name: &str) -> Result<Version, SecretsError> {
610        let rows = self
611            .db
612            .query(&Statement::with_values(
613                format!(
614                    "SELECT version FROM harness_secrets \
615                     WHERE name = ? AND {} ORDER BY version DESC LIMIT 1",
616                    scoped_store()
617                ),
618                vec![text(name), text(self.id.as_str())],
619            ))
620            .await?;
621        match rows.first() {
622            Some(row) => Ok(version_of(row)?.saturating_add(1)),
623            None => Ok(1),
624        }
625    }
626
627    /// Records the access. A sink that cannot record turns the call into
628    /// [`SecretsError::NotAudited`]: an unrecorded read is what the log
629    /// exists to make impossible, so it is a refusal rather than a
630    /// warning.
631    async fn audit(
632        &self,
633        access: Access,
634        actor: &Actor,
635        name: Option<&str>,
636        version: Option<Version>,
637        allowed: bool,
638    ) -> Result<(), SecretsError> {
639        self.audit
640            .record(&AuditEvent {
641                store: &self.id,
642                access,
643                actor,
644                name,
645                version,
646                allowed,
647                request_id: None,
648            })
649            .await
650    }
651}
652
653fn validate_name(name: &str) -> Result<(), SecretsError> {
654    if name.trim().is_empty() {
655        return Err(SecretsError::Invalid(
656            "a secret name cannot be empty".to_owned(),
657        ));
658    }
659    if name.len() > 200 {
660        return Err(SecretsError::Invalid(
661            "a secret name is at most 200 bytes".to_owned(),
662        ));
663    }
664    if let Some(frag) = cratefield_core::card_data_hit(name) {
665        return Err(SecretsError::Invalid(format!(
666            "a secret name must not look like card data (`{frag}`): card details belong in \
667             Stripe, not here. Store Stripe's own secrets (webhook signing secret, API key) \
668             under a plain name instead"
669        )));
670    }
671    Ok(())
672}
673
674fn version_of(row: &Row) -> Result<Version, SecretsError> {
675    let raw: i64 = row
676        .get("version")
677        .ok_or_else(|| SecretsError::Invalid("a secret row has no version".to_owned()))?;
678    u32::try_from(raw).map_err(|_| SecretsError::Invalid("a version is out of range".to_owned()))
679}
680
681fn text(value: &str) -> SeaValue {
682    SeaValue::String(Some(Box::new(value.to_owned())))
683}
684
685fn bytes(value: Vec<u8>) -> SeaValue {
686    SeaValue::Bytes(Some(Box::new(value)))
687}
688
689fn now() -> String {
690    time::OffsetDateTime::now_utc()
691        .format(&time::format_description::well_known::Rfc3339)
692        .unwrap_or_default()
693}
694
695/// A key id that is unique without needing a sequence: the time it was
696/// made plus 8 random bytes.
697fn new_key_id() -> Result<String, SecretsError> {
698    let mut random = [0_u8; 8];
699    getrandom::fill(&mut random)
700        .map_err(|err| SecretsError::Invalid(format!("the OS random source failed: {err}")))?;
701    let mut id = String::with_capacity(24);
702    id.push_str("dek_");
703    for byte in random {
704        use std::fmt::Write as _;
705        let _ = write!(id, "{byte:02x}");
706    }
707    Ok(id)
708}
709
710#[cfg(test)]
711mod tests {
712    use super::{HarnessOnly, Secrets};
713    use crate::{Actor, SecretBytes, StoreId};
714    use cratefield_adapter_sqlite::SqliteDatabase;
715    use cratefield_core::Database;
716    use cratefield_kms::{Dek, Kms, LocalFileKms};
717    use std::sync::Arc;
718
719    fn kms() -> Arc<dyn Kms> {
720        let kek = Dek::generate().expect("rng");
721        Arc::new(LocalFileKms::from_key(kek, "test-kek", "test").expect("not production"))
722    }
723
724    fn migrated_db() -> Arc<dyn Database> {
725        let db = SqliteDatabase::in_memory().expect("in-memory db");
726        db.apply_migrations("secrets", crate::migrations().sqlite)
727            .expect("schema applies");
728        Arc::new(db)
729    }
730
731    /// The control store is what resolves a tenant's `db_ref` at boot, and
732    /// no test reached it before: every other test uses `tenant`, because
733    /// that is all module code can open. Proving it round-trips is also
734    /// what keeps [`Secrets::harness_only`] live — the proof token exists
735    /// for exactly one caller, and this is the only one until
736    /// `cratefield-core` wires the control database.
737    #[pollster::test]
738    async fn the_global_store_round_trips_for_a_caller_holding_the_proof() {
739        let secrets = Secrets::new(kms());
740        let db = migrated_db();
741        let proof: HarnessOnly = Secrets::harness_only();
742        let global = secrets.global(Arc::clone(&db), proof);
743        assert_eq!(global.id(), &StoreId::Global);
744
745        let actor = Actor::new("harness-boot").expect("named");
746        let version = global
747            .put(
748                "tenants/acme/db_ref",
749                &SecretBytes::from("postgres://acme"),
750                &actor,
751            )
752            .await
753            .expect("the control store accepts a write");
754        assert_eq!(version, 1);
755
756        let found = global
757            .get("tenants/acme/db_ref", &actor)
758            .await
759            .expect("readable")
760            .expect("present");
761        assert_eq!(found.expose(), b"postgres://acme");
762
763        // And it is a genuinely separate store: the same name in a
764        // tenant's database is a different secret, not this one.
765        let tenant = secrets
766            .tenant("acme", migrated_db())
767            .expect("the tenant store opens");
768        assert!(
769            tenant
770                .get("tenants/acme/db_ref", &actor)
771                .await
772                .expect("readable")
773                .is_none(),
774            "the global store's rows must not be visible to a tenant"
775        );
776    }
777}