Skip to main content

gunnar_core/
secrets.rs

1//! **Where the server's secrets live** — the SSH host key and the issuer
2//! private key, in the control database beside the catalog and the identity
3//! table.
4//!
5//! # Why they moved (the second half of the certificate-identity plan)
6//!
7//! On 2026-08-09 the catalog, the identity table and the issuer *certificate*
8//! moved out of `control.git` and into `control.redb`. The secrets did not, and
9//! `gunnar-server`'s `stores.rs` said why in as many words: it would have been
10//! *"a second migration riding on the first"*. It also named the price it was
11//! paying to defer them:
12//!
13//! > *"It does mean the control **repository** is still opened, and therefore
14//! > still needs an installed storage engine — so it is opened **lazily**, on
15//! > the first call to `ServerStores::secrets`."*
16//!
17//! That lazy open is gone, and with it the last reason a `gunnar-server`
18//! process touches a storage engine in order to read its own host key. The
19//! sentence *"the catalog and the identity table are reachable in a process
20//! that carries no engine"* was true; **"the server is"** now is too.
21//!
22//! # What this module is NOT
23//!
24//! It is not crypto. [`crate::seal`] wraps the *value* — `SealedSecretStore` is
25//! a decorator over [`SecretStore`], the KEK is a file beside the database, and
26//! what reaches this module is already ciphertext. This module decides only
27//! **where sealed bytes are kept**. Sealed bytes in, sealed bytes out.
28//!
29//! # The table, and the one byte in front of every value
30//!
31//! ```text
32//!   secrets      key → tag ‖ value      tag 0x01 = a value, 0x00 = a tombstone
33//! ```
34//!
35//! A tombstone rather than a removed row, because of the migration below: the
36//! legacy Arrow log is only consulted when redb has **no row at all** for a
37//! key, so a `delete` that removed the row would let the next `get` fall
38//! through and resurrect the deleted secret out of the old file. A row that
39//! says *"deleted"* cannot do that. It also happens to be the honest record —
40//! the deletion is a fact, and one byte is what it costs to keep it.
41//!
42//! # What redb gives that the Arrow log had to build
43//!
44//! [`SecretStore::get_or_create`] must be atomic — *"the host key must never be
45//! regenerated by a race"* — and the Arrow log achieved that by taking its
46//! writer mutex **before** the existence check. Here the existence check and
47//! the insert are the same redb write transaction, and redb serialises write
48//! transactions internally. The atomicity is the store's, not a mutex this
49//! module holds.
50//!
51//! # 🔴 What redb does NOT give, recorded as a fact rather than glossed
52//!
53//! `arrow_log.rs` compacts a *superseded* secret out of its file immediately,
54//! and its comment says why: *"a rotated SSH host key must actually stop
55//! existing"*. redb is a copy-on-write B-tree. An `insert` over an existing key
56//! writes a new page and frees the old one; the freed page is reused by a later
57//! write, but until it is, **the previous ciphertext is still bytes in
58//! `control.redb`**.
59//!
60//! Two things bound that, and neither is an accident:
61//!
62//! * what is in those pages is **ciphertext**, because everything on the boot
63//!   path goes through [`crate::SealedSecretStore`] and the KEK is a separate
64//!   file (`secrets.kek`) that a database copy does not carry; and
65//! * it is bounded by page reuse rather than by time — it is residue, not an
66//!   archive: nothing indexes it and no read path can reach it.
67//!
68//! It is still a narrowing against the Arrow log's explicit fold, and a
69//! `gunnar control gc` that called `redb::Database::compact` is where it would
70//! be closed. **Not built here.** Named.
71//!
72//! # One process, one lock
73//!
74//! redb takes an exclusive file lock on `control.redb`, so a second gunnar
75//! process on the same data root cannot open it. That was already true of the
76//! catalog and the identity table, and it is what this change makes true of the
77//! host key as well. The bound does not get *tighter*: the Arrow log took an
78//! `O_EXCL` lock of its own on the same data root, so two servers already could
79//! not share it. What changes is that the two locks become one.
80
81use std::sync::Arc;
82
83use gunnar_store::{Result as StoreResult, SecretStore};
84use redb::ReadableTable;
85
86use crate::error::Result;
87
88/// Every secret this server keeps, one row each.
89const SECRETS: redb::TableDefinition<'static, &str, &[u8]> = redb::TableDefinition::new("secrets");
90
91/// A row that carries a value.
92const TAG_VALUE: u8 = 0x01;
93
94/// A row that records a deletion. See the module header.
95const TAG_TOMBSTONE: u8 = 0x00;
96
97/// Where the pre-2026-08-10 secret log lives, relative to the control
98/// directory, newest layout first.
99///
100/// Two entries because the layout moved once and an appliance may predate the
101/// move: `5f0fa37` (the `gunnar-server` split) put it at `gunnar-secrets/`, and
102/// the `FsStore` that came before that put it at `gunnar-store/`. Probing both
103/// costs two `Path::is_dir` calls on the boot path and is the difference
104/// between an old appliance keeping its host key and an old appliance minting a
105/// new one.
106pub const LEGACY_SECRET_DIRS: &[&str] = &["gunnar-secrets", "gunnar-store"];
107
108/// The file `ArrowLogStore` keeps its secrets in. Named here so the probe below
109/// checks for the log itself rather than for a directory that happens to exist.
110const LEGACY_SECRET_FILE: &str = "secrets.arrow";
111
112fn faulted(what: &str, err: impl std::fmt::Display) -> gunnar_store::Error {
113    gunnar_store::Error::backend(format!("{what}: {err}"))
114}
115
116/// Decode a stored row: `Some(bytes)` for a value, `None` for a tombstone.
117///
118/// A row that is neither is a fault and not an absence — a secret store that
119/// answered "no host key" because it could not parse its own row would mint a
120/// second host key, which is the one outcome this whole module exists to
121/// prevent.
122fn decode(key: &str, row: &[u8]) -> StoreResult<Option<Vec<u8>>> {
123    match row.split_first() {
124        Some((&TAG_VALUE, rest)) => Ok(Some(rest.to_vec())),
125        Some((&TAG_TOMBSTONE, _)) => Ok(None),
126        _ => Err(gunnar_store::Error::backend(format!(
127            "the control database's row for the secret {key} is {} bytes and does not begin with \
128             a known tag; refusing to read it as absent, because the caller's next move would be \
129             to mint a replacement",
130            row.len()
131        ))),
132    }
133}
134
135fn encode(value: &[u8]) -> Vec<u8> {
136    let mut row = Vec::with_capacity(1 + value.len());
137    row.push(TAG_VALUE);
138    row.extend_from_slice(value);
139    row
140}
141
142/// **The secret store in the control database.**
143///
144/// Cheap to clone; every clone is the same database, and the same one
145/// [`crate::RedbControlStore`] holds. It is deliberately not a second
146/// `redb::Database` over the same file — redb takes an exclusive lock, so a
147/// second open in the same process would fail, and the shared handle is what
148/// makes "one file, one lock" true.
149#[derive(Clone)]
150pub struct RedbSecretStore {
151    db: Arc<crate::control::ControlDb>,
152}
153
154impl std::fmt::Debug for RedbSecretStore {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.write_str("RedbSecretStore")
157    }
158}
159
160impl RedbSecretStore {
161    /// Build one over an already-open control database.
162    ///
163    /// `pub(crate)` on purpose: the only supported way to get one is from the
164    /// control store that owns the handle, so there is no path by which a
165    /// caller opens a second database over the same file.
166    pub(crate) fn new(db: Arc<crate::control::ControlDb>) -> Self {
167        RedbSecretStore { db }
168    }
169
170    /// Create the table if it has never been written.
171    ///
172    /// Same reason [`crate::RedbControlStore::prepared`] does it for its three:
173    /// redb refuses to *read* a table that does not exist, so a first `get`
174    /// would be a fault rather than an absence — and an absence is exactly what
175    /// a first boot has to be able to say.
176    pub(crate) fn prepare(db: &redb::Database) -> Result<()> {
177        let txn = db.begin_write().map_err(|err| {
178            crate::Error::ControlStore(format!("beginning the secrets table's creation: {err}"))
179        })?;
180        txn.open_table(SECRETS).map_err(|err| {
181            crate::Error::ControlStore(format!("creating the secrets table: {err}"))
182        })?;
183        txn.commit().map_err(|err| {
184            crate::Error::ControlStore(format!("committing the secrets table's creation: {err}"))
185        })?;
186        Ok(())
187    }
188
189    /// A raw row decoded: `Some(value)` or `None` for a tombstone.
190    pub(crate) fn decode_row_of(key: &str, row: &[u8]) -> StoreResult<Option<Vec<u8>>> {
191        decode(key, row)
192    }
193
194    /// Every row of the secrets table, raw (tag ‖ sealed bytes) — what
195    /// `ControlDb::suspend` copies into RAM. Sealed at rest, sealed in RAM.
196    pub(crate) fn rows(
197        txn: &redb::ReadTransaction,
198    ) -> Result<std::collections::BTreeMap<String, Vec<u8>>> {
199        let table = txn.open_table(SECRETS).map_err(|err| {
200            crate::Error::ControlStore(format!("opening the secrets table: {err}"))
201        })?;
202        let mut out = std::collections::BTreeMap::new();
203        for entry in table
204            .iter()
205            .map_err(|err| crate::Error::ControlStore(format!("scanning the secrets table: {err}")))?
206        {
207            let (k, v) = entry
208                .map_err(|err| crate::Error::ControlStore(format!("reading a secret row: {err}")))?;
209            out.insert(k.value().to_owned(), v.value().to_vec());
210        }
211        Ok(out)
212    }
213
214    /// Write a row, value or tombstone, in one transaction. Refused while the
215    /// store is off the stripe (`Error::OffStripe`, in the backend error's
216    /// words): nothing may be written to a file that is closed, and a secret
217    /// minted into RAM alone would be a secret the next boot does not have.
218    fn write(&self, key: &str, row: &[u8]) -> StoreResult<()> {
219        self.db
220            .write("a secret write", |txn| {
221                {
222                    let mut table = txn
223                        .open_table(SECRETS)
224                        .map_err(|err| faulted("opening the secrets table", err))?;
225                    table
226                        .insert(key, row)
227                        .map_err(|err| faulted(&format!("storing the secret {key}"), err))?;
228                }
229                Ok(crate::control::Txn::Commit(()))
230            })
231            .map_err(off_stripe)
232    }
233
234    /// Three-state read: a value, a tombstone, or no row at all.
235    ///
236    /// The third state is what the migration below needs and what
237    /// [`SecretStore::get`] cannot express — see [`MigratingSecretStore`].
238    fn row(&self, key: &str) -> StoreResult<Option<Option<Vec<u8>>>> {
239        self.db
240            .read(
241                "a secret read",
242                |txn| {
243                    let table = txn
244                        .open_table(SECRETS)
245                        .map_err(|err| faulted("opening the secrets table", err))?;
246                    let found = table
247                        .get(key)
248                        .map_err(|err| faulted(&format!("reading the secret {key}"), err))?;
249                    match found {
250                        None => Ok(None),
251                        Some(value) => decode(key, value.value()).map(Some).map_err(Into::into),
252                    }
253                },
254                |ram| match ram.secret_row(key) {
255                    None => Ok(None),
256                    Some(row) => decode(key, row).map(Some).map_err(Into::into),
257                },
258            )
259            .map_err(off_stripe)
260    }
261}
262
263/// A control-store error as the secret store's callers see it: the
264/// `OffStripe` refusal keeps its own words, everything else is a backend
265/// fault.
266fn off_stripe(err: crate::Error) -> gunnar_store::Error {
267    match err {
268        crate::Error::Store(inner) => inner,
269        other => gunnar_store::Error::backend(other.to_string()),
270    }
271}
272
273impl SecretStore for RedbSecretStore {
274    fn get(&self, key: &str) -> StoreResult<Option<Vec<u8>>> {
275        Ok(self.row(key)?.flatten())
276    }
277
278    fn put(&self, key: &str, value: &[u8]) -> StoreResult<()> {
279        self.write(key, &encode(value))
280    }
281
282    fn delete(&self, key: &str) -> StoreResult<()> {
283        self.write(key, &[TAG_TOMBSTONE])
284    }
285
286    fn get_or_create(
287        &self,
288        key: &str,
289        mk: &mut dyn FnMut() -> StoreResult<Vec<u8>>,
290    ) -> StoreResult<Vec<u8>> {
291        // Off the stripe: a value the copy holds is the answer; an absent one
292        // is a refusal, never a mint — a key minted into RAM would be a key
293        // the file never gets, which for the host key is the "REMOTE HOST
294        // IDENTIFICATION HAS CHANGED" this whole store exists to prevent.
295        if self.db.is_suspended() {
296            if let Some(bytes) = self.get(key)? {
297                return Ok(bytes);
298            }
299            return Err(off_stripe(crate::Error::OffStripe {
300                why: format!(
301                    "{}; the secret {key} is not in the RAM copy and will not be minted off the stripe",
302                    self.db.ram_copy().map(|r| r.why).unwrap_or_default()
303                ),
304            }));
305        }
306        // The existence check and the insert are ONE write transaction, and
307        // redb serialises write transactions. The loser of a race blocks in
308        // `begin_write` and then finds the winner's value, so `mk` runs once
309        // per absent key and never twice.
310        //
311        // `mk` runs while the write lock is held. That is deliberate and it is
312        // what makes the guarantee hold; it costs the microseconds it takes to
313        // mint an ed25519 key, on a path that runs at most once per key per
314        // installation.
315        self.db
316            .write("a secret get-or-create", |txn| {
317                let created = {
318                    let mut table = txn
319                        .open_table(SECRETS)
320                        .map_err(|err| faulted("opening the secrets table", err))?;
321                    let existing = table
322                        .get(key)
323                        .map_err(|err| faulted(&format!("reading the secret {key}"), err))?
324                        .map(|value| decode(key, value.value()))
325                        .transpose()?
326                        .flatten();
327                    match existing {
328                        Some(bytes) => {
329                            // Nothing to commit. Discarding the transaction is
330                            // cheaper than committing an empty one and cannot
331                            // fail.
332                            drop(table);
333                            return Ok(crate::control::Txn::Discard(bytes));
334                        }
335                        None => {
336                            // A failing `mk` drops the transaction, so a mint
337                            // that could not produce bytes leaves the key
338                            // absent rather than half-written.
339                            let value = mk()?;
340                            table
341                                .insert(key, encode(&value).as_slice())
342                                .map_err(|err| faulted(&format!("storing the secret {key}"), err))?;
343                            value
344                        }
345                    }
346                };
347                Ok(crate::control::Txn::Commit(created))
348            })
349            .map_err(off_stripe)
350    }
351}
352
353/// **Read the old place, write the new one.** The migration, and it is not a
354/// flag day.
355///
356/// ```text
357///   get(k)             redb row?  ── value ──▶ it
358///                          │
359///                       tombstone ──▶ None            (never falls through)
360///                          │
361///                       no row    ──▶ legacy Arrow log ──▶ adopt into redb
362///
363///   get_or_create(k)   redb row?  ── value ──▶ it
364///                          │
365///                       no row    ──▶ legacy? ── yes ──▶ adopt it, return it
366///                                        └──── no ───▶ mk(), store, return
367/// ```
368///
369/// # Why this is the shape, and what it costs to get it wrong
370///
371/// `serve.rs` calls `ensure_host_key` on **every boot**. The host key is the
372/// one secret whose whole purpose is to be the same bytes it was yesterday: a
373/// server that mints a fresh one gives every client
374/// `REMOTE HOST IDENTIFICATION HAS CHANGED`, which is indistinguishable from an
375/// attack. So the adoption of a legacy key happens **inside** the same
376/// `get_or_create` that would otherwise mint, not in a separate migration step
377/// that could be skipped, reordered or half-run:
378///
379/// * an appliance with a host key in the Arrow log boots, finds no redb row,
380///   reads the Arrow log, writes those exact bytes into redb and returns them —
381///   **same key, same fingerprint, no warning**;
382/// * the next boot finds the redb row and never opens the Arrow log at all;
383/// * a fresh installation has neither, mints once, and never grows an Arrow
384///   log in the first place;
385/// * a boot that is interrupted between reading the old value and committing
386///   the new one has changed nothing, and the next boot repeats the adoption.
387///
388/// **The Arrow log is never written and never deleted by this type.** It stays
389/// on disk, byte for byte, as the rollback: a binary from before this change,
390/// pointed at the same data root, still finds its host key exactly where it
391/// left it. Removing it is a later, separate decision.
392///
393/// # The legacy handle is `Option`, and absent is the normal case
394///
395/// It is `None` when there is no `secrets.arrow` under the control directory,
396/// which is every installation created after this change. A `None` legacy is
397/// not a degraded [`MigratingSecretStore`]; it is the steady state, and the
398/// type collapses to [`RedbSecretStore`] with one extra `Option` check.
399pub struct MigratingSecretStore {
400    new: RedbSecretStore,
401    legacy: Option<Arc<dyn SecretStore>>,
402}
403
404impl std::fmt::Debug for MigratingSecretStore {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        f.debug_struct("MigratingSecretStore")
407            .field("legacy", &self.legacy.is_some())
408            .finish()
409    }
410}
411
412impl MigratingSecretStore {
413    /// Wrap `new`, falling back to `legacy` for keys redb has never seen.
414    pub fn new(new: RedbSecretStore, legacy: Option<Arc<dyn SecretStore>>) -> Self {
415        MigratingSecretStore { new, legacy }
416    }
417
418    /// The legacy value for `key`, or `None` if there is no legacy store or it
419    /// does not hold one.
420    fn legacy_get(&self, key: &str) -> StoreResult<Option<Vec<u8>>> {
421        match &self.legacy {
422            None => Ok(None),
423            Some(store) => store.get(key),
424        }
425    }
426}
427
428impl SecretStore for MigratingSecretStore {
429    fn get(&self, key: &str) -> StoreResult<Option<Vec<u8>>> {
430        // A tombstone answers `None` here without consulting the legacy store:
431        // `row` distinguishes "deleted" from "never seen", and only the second
432        // one falls through.
433        if let Some(answer) = self.new.row(key)? {
434            return Ok(answer);
435        }
436        let Some(bytes) = self.legacy_get(key)? else {
437            return Ok(None);
438        };
439        // Adopt on read, so a plain `get` also finishes the migration. `put` is
440        // safe to race: two threads adopting the same legacy value write the
441        // same bytes.
442        tracing::info!(
443            key,
444            "adopting a secret out of the legacy Arrow log into the control database; the old \
445             log is left on disk untouched"
446        );
447        self.new.put(key, &bytes)?;
448        Ok(Some(bytes))
449    }
450
451    fn put(&self, key: &str, value: &[u8]) -> StoreResult<()> {
452        // New writes go to the new place only. The legacy log is never written
453        // again — a value in two places is a value that can disagree.
454        self.new.put(key, value)
455    }
456
457    fn delete(&self, key: &str) -> StoreResult<()> {
458        // A tombstone, not a removal: see the module header. This is why a
459        // delete does not resurrect out of the Arrow log on the next read.
460        self.new.delete(key)
461    }
462
463    fn get_or_create(
464        &self,
465        key: &str,
466        mk: &mut dyn FnMut() -> StoreResult<Vec<u8>>,
467    ) -> StoreResult<Vec<u8>> {
468        // **The whole migration, in one atomic step.** The legacy read is the
469        // first thing the mint closure tries, so it runs inside the same redb
470        // write transaction that decides whether to mint at all. There is no
471        // window in which one thread reads the legacy key and another mints a
472        // replacement.
473        let mut adopted = false;
474        let value = self.new.get_or_create(key, &mut || {
475            if let Some(bytes) = self.legacy_get(key)? {
476                adopted = true;
477                return Ok(bytes);
478            }
479            mk()
480        })?;
481        if adopted {
482            tracing::info!(
483                key,
484                "carried a secret forward from the legacy Arrow log into the control database \
485                 rather than minting a new one; the old log is left on disk untouched"
486            );
487        }
488        Ok(value)
489    }
490}
491
492/// **The legacy Arrow secret log under `control_dir`, if there is one.**
493///
494/// `Ok(None)` when no `secrets.arrow` exists under any known layout — the
495/// normal answer on every installation made after 2026-08-10, and the reason a
496/// fresh appliance never creates an Arrow log again.
497///
498/// It opens what is there and **creates nothing**. That is the difference
499/// between this and the `open_secret_log` it replaces, which did
500/// `create_dir_all` on the way past and therefore minted an empty Arrow log
501/// beside every repository the server ever opened.
502///
503/// No storage engine is involved: `ArrowLogStore` is a plain reader of a plain
504/// file in `gunnar-store`, and the engine was only ever in the path because the
505/// log was reached through `RepoStore::secrets()` on a repository the engine
506/// had opened. It never needed to be.
507pub fn legacy_secret_log(
508    control_dir: &std::path::Path,
509    hash_kind: gix_hash::Kind,
510) -> Result<Option<Arc<dyn SecretStore>>> {
511    let Some(dir) = LEGACY_SECRET_DIRS
512        .iter()
513        .map(|name| control_dir.join(name))
514        .find(|dir| dir.join(LEGACY_SECRET_FILE).is_file())
515    else {
516        return Ok(None);
517    };
518    let log = crate::arrow_log::ArrowLogStore::open(&dir, hash_kind).map_err(|err| {
519        crate::Error::ControlStore(format!(
520            "opening the legacy secret log at {} to carry its secrets into the control database: \
521             {err}. It is read here and never written; if a previous gunnar was killed, its \
522             O_EXCL lock may still be there",
523            dir.display()
524        ))
525    })?;
526    tracing::info!(
527        dir = %dir.display(),
528        "a legacy Arrow secret log is present; secrets not yet in the control database will be \
529         read from it once and carried forward"
530    );
531    Ok(Some(Arc::new(log.secrets()) as Arc<dyn SecretStore>))
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use gunnar_store::mem::MemSecretStore;
538    use gunnar_store::SecretStoreExt;
539
540    fn store() -> RedbSecretStore {
541        let db = redb::Database::builder()
542            .create_with_backend(redb::backends::InMemoryBackend::new())
543            .expect("in-memory control database");
544        RedbSecretStore::prepare(&db).expect("prepare");
545        RedbSecretStore::new(Arc::new(crate::control::ControlDb::in_memory_for_tests(db)))
546    }
547
548    #[test]
549    fn a_secret_round_trips_and_an_unwritten_one_is_absent() {
550        let store = store();
551        assert_eq!(store.get("ssh/host_ed25519").expect("get"), None);
552        store.put("ssh/host_ed25519", b"key").expect("put");
553        assert_eq!(
554            store.get("ssh/host_ed25519").expect("get").as_deref(),
555            Some(&b"key"[..])
556        );
557    }
558
559    /// A deleted secret reads as absent, and the *value* is gone from the row
560    /// rather than merely unreferenced.
561    #[test]
562    fn a_deleted_secret_is_absent_and_its_bytes_are_not_the_row() {
563        let store = store();
564        store.put("k", b"secret-bytes").expect("put");
565        store.delete("k").expect("delete");
566        assert_eq!(store.get("k").expect("get"), None);
567        // The row is a bare tombstone, so the value is not what a reader of the
568        // table would find.
569        assert_eq!(store.row("k").expect("row"), Some(None));
570        // Deleting what is not there is not an error.
571        store.delete("never-written").expect("delete absent");
572    }
573
574    #[test]
575    fn get_or_create_mints_once_and_then_returns_the_same_bytes() {
576        let store = store();
577        let mut calls = 0usize;
578        let first = store
579            .get_or_create("k", &mut || {
580                calls += 1;
581                Ok(b"minted".to_vec())
582            })
583            .expect("first");
584        let second = store
585            .get_or_create("k", &mut || {
586                calls += 1;
587                Ok(b"different".to_vec())
588            })
589            .expect("second");
590        assert_eq!(first, b"minted".to_vec());
591        assert_eq!(second, first, "a second call minted a new value");
592        assert_eq!(calls, 1);
593    }
594
595    /// A mint that fails must leave the key absent, not half-written.
596    #[test]
597    fn a_failed_mint_stores_nothing() {
598        let store = store();
599        let err = store
600            .get_or_create("k", &mut || {
601                Err(gunnar_store::Error::backend("the entropy source is gone"))
602            })
603            .expect_err("the mint failed");
604        assert!(format!("{err}").contains("entropy"), "{err}");
605        assert_eq!(store.get("k").expect("get"), None);
606    }
607
608    /// **The host-key migration, in the shape `serve.rs` runs it.**
609    ///
610    /// The red half is the whole point: the same call against a store with
611    /// *no* legacy behind it must mint different bytes. Without it, "the key
612    /// survived" is a claim a store that always returned the legacy value would
613    /// also satisfy.
614    #[test]
615    fn a_legacy_host_key_is_carried_forward_rather_than_reminted() {
616        let legacy = Arc::new(MemSecretStore::new());
617        legacy
618            .put("ssh/host_ed25519", b"the-live-host-key")
619            .expect("seed");
620
621        let new = store();
622        let migrating = MigratingSecretStore::new(new.clone(), Some(legacy.clone()));
623        let got = migrating
624            .get_or_create_with("ssh/host_ed25519", || Ok(b"A-BRAND-NEW-KEY".to_vec()))
625            .expect("first boot after the migration");
626        assert_eq!(
627            got,
628            b"the-live-host-key".to_vec(),
629            "the appliance minted a new host key instead of keeping the one it had"
630        );
631
632        // It landed in redb, so the second boot never touches the Arrow log.
633        assert_eq!(
634            new.get("ssh/host_ed25519").expect("get").as_deref(),
635            Some(&b"the-live-host-key"[..])
636        );
637        let without_legacy = MigratingSecretStore::new(new.clone(), None);
638        assert_eq!(
639            without_legacy
640                .get_or_create_with("ssh/host_ed25519", || Ok(b"A-BRAND-NEW-KEY".to_vec()))
641                .expect("second boot"),
642            b"the-live-host-key".to_vec()
643        );
644
645        // RED HALF: with nothing behind it and nothing in redb, the same call
646        // mints. If this passed too, the assertion above would be measuring
647        // nothing.
648        let fresh = MigratingSecretStore::new(store(), None);
649        assert_eq!(
650            fresh
651                .get_or_create_with("ssh/host_ed25519", || Ok(b"A-BRAND-NEW-KEY".to_vec()))
652                .expect("fresh install"),
653            b"A-BRAND-NEW-KEY".to_vec()
654        );
655    }
656
657    /// A plain `get` finishes the migration too, and the legacy log is not
658    /// written on the way past.
659    #[test]
660    fn a_read_adopts_and_does_not_write_the_legacy_log() {
661        let legacy = Arc::new(MemSecretStore::new());
662        legacy.put("pki/issuer_key", b"issuer").expect("seed");
663        let new = store();
664        let migrating = MigratingSecretStore::new(new.clone(), Some(legacy.clone()));
665
666        assert_eq!(
667            migrating.get("pki/issuer_key").expect("get").as_deref(),
668            Some(&b"issuer"[..])
669        );
670        assert_eq!(
671            new.get("pki/issuer_key").expect("get").as_deref(),
672            Some(&b"issuer"[..]),
673            "the read did not adopt the value into the control database"
674        );
675
676        // A later `put` goes to the new store only; the legacy log still holds
677        // what it held, which is what makes a rollback possible.
678        migrating.put("pki/issuer_key", b"rotated").expect("put");
679        assert_eq!(
680            legacy.get("pki/issuer_key").expect("get").as_deref(),
681            Some(&b"issuer"[..]),
682            "the legacy log was written; it must be left byte for byte as it was"
683        );
684        assert_eq!(
685            migrating.get("pki/issuer_key").expect("get").as_deref(),
686            Some(&b"rotated"[..])
687        );
688    }
689
690    /// **The resurrection bug the tombstone exists to prevent.**
691    ///
692    /// Delete a key that is still in the legacy log, then read it. Without a
693    /// tombstone the read falls through to the Arrow log and hands back the
694    /// secret that was just deleted.
695    #[test]
696    fn a_deleted_secret_does_not_come_back_out_of_the_legacy_log() {
697        let legacy = Arc::new(MemSecretStore::new());
698        legacy.put("k", b"old").expect("seed");
699        let migrating = MigratingSecretStore::new(store(), Some(legacy));
700        assert_eq!(
701            migrating.get("k").expect("get").as_deref(),
702            Some(&b"old"[..])
703        );
704        migrating.delete("k").expect("delete");
705        assert_eq!(
706            migrating.get("k").expect("get"),
707            None,
708            "a deleted secret was resurrected out of the legacy Arrow log"
709        );
710    }
711
712    /// Two callers racing on an absent key agree on one value, which is the
713    /// property `ensure_host_key` rests on.
714    ///
715    /// `std::thread::scope`, no pool, no rayon — and this is a race probe, not
716    /// a workload.
717    #[test]
718    fn get_or_create_is_atomic_across_threads() {
719        let store = store();
720        let mints = std::sync::atomic::AtomicUsize::new(0);
721        let values: Vec<Vec<u8>> = std::thread::scope(|scope| {
722            let handles: Vec<_> = (0..8)
723                .map(|_| {
724                    scope.spawn(|| {
725                        store
726                            .get_or_create("ssh/host_ed25519", &mut || {
727                                let n = mints.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
728                                Ok(format!("key-{n}").into_bytes())
729                            })
730                            .expect("get_or_create")
731                    })
732                })
733                .collect();
734            handles
735                .into_iter()
736                .map(|h| h.join().expect("join"))
737                .collect()
738        });
739        assert_eq!(
740            mints.load(std::sync::atomic::Ordering::SeqCst),
741            1,
742            "the host key was minted more than once by a race"
743        );
744        assert!(
745            values.windows(2).all(|w| w[0] == w[1]),
746            "racing callers were handed different host keys"
747        );
748    }
749
750    /// A row with an unknown tag is a fault, never an absence. An absence would
751    /// make the caller mint a replacement host key.
752    #[test]
753    fn an_unreadable_row_is_a_fault_and_not_an_absence() {
754        let err = decode("ssh/host_ed25519", &[0x7f, 1, 2, 3]).expect_err("unknown tag");
755        assert!(format!("{err}").contains("known tag"), "{err}");
756        assert!(
757            decode("k", &[]).is_err(),
758            "an empty row must not read as absent"
759        );
760    }
761}