gunnar-store 1.6.0

The gunnar server's storage seam: RepoStore (which repository), SecretStore (what has no git equivalent), and the one conformance suite both GitOps backends run
Documentation
//! Secrets: where the SSH host key and the issuer private key live when there
//! is no home directory (the secret-store seam).
//!
//! # The inventory is two keys, and the header used to claim a third
//!
//! Measured 2026-08-10 across every non-test caller in the tree:
//! [`SSH_HOST_KEY`] and [`ISSUER_PRIVATE_KEY`]. That is all of it. This header
//! said "the admin bootstrap material" until then, and it had not been true
//! since the bootstrap admin started leaving with a certificate instead of a
//! stored token.
//!
//! # Where an implementation of this lives now
//!
//! Not behind `RepoStore::secrets()`, which is deleted, and not in a storage
//! engine. The server's implementation is `gunnar_core::RedbSecretStore`, a
//! table in the same `control.redb` as the catalog and the identity table. This
//! crate keeps the **trait** — because it is the vocabulary
//! `gunnar_core::SealedSecretStore` decorates and the conformance suite checks —
//! plus [`crate::mem::MemSecretStore`] as the control and
//! [`crate::arrow_log::ArrowLogSecretStore`] as the legacy store the migration
//! reads out of.
//!
//! The trait stays here rather than moving to `gunnar-core` for one hard
//! reason and one soft one. The hard one: `arrow_log.rs` implements it, and
//! `gunnar-store` cannot depend on `gunnar-core` — that edge runs the other
//! way. The soft one: it is a contract with three implementations and a
//! conformance check, which is what this crate is for.

use crate::error::Result;

/// Key under which the server's persistent SSH host key is stored.
///
/// An appliance that regenerates this on every boot gives every client a
/// host-key-changed warning, which on an escape-hatch product is exactly the
/// wrong first impression. [`SecretStore::get_or_create`] exists to make that
/// impossible.
pub const SSH_HOST_KEY: &str = "ssh/host_ed25519";

/// Key under which **the server's X.509 issuer private key** is stored, PEM
/// encoded (the certificate-identity plan, `gunnar_core::issuer`).
///
/// A key that can mint any user is the one secret whose loss is total — an
/// attacker holding it does not break authentication, they issue themselves an
/// administrator. The control database holds the issuer *certificate*, which is
/// public; this names the half that is not.
///
/// Until 2026-08-10 that separation was also a separation of **files** — the
/// certificate in `control.redb`, the key in an Arrow log inside `control.git`.
/// It is not any more: both are in `control.redb`, in different tables. The
/// property that actually protects the key was never the file boundary, it is
/// the seal: the server writes this only through
/// `gunnar_core::SealedSecretStore`, so what reaches any backend is ciphertext
/// under a KEK that lives in `secrets.kek` beside the database and is not in
/// it. A copied `control.redb` still cannot issue a certificate.
pub const ISSUER_PRIVATE_KEY: &str = "pki/issuer_key";

/// Key under which **the transparency log's signing key** is stored, OpenSSH
/// private-key PEM (`gunnar_core::transparency`).
///
/// Its own key and not the issuer's, deliberately. The issuer signs
/// *certificates*, which say who somebody is; this signs *tree heads*, which say
/// what the log held. Sharing one key would mean that compromising the log's
/// signer also mints administrators — and it would make revoking one of the two
/// jobs impossible without revoking the other.
///
/// ed25519 and `sshsig`, because that is the one signature format in this
/// repository: commits, control-plane challenges and now tree heads. A client
/// can check a tree head with `ssh-keygen -Y verify` and no gunnar at all.
///
/// Sealed under the KEK like every other secret, so a copied `control.redb`
/// cannot sign a tree head that a verifier would believe.
pub const TRANSPARENCY_LOG_KEY: &str = "transparency/log_ed25519";

/// Key under which **the builder's provenance signing key** is stored, OpenSSH
/// private-key PEM (`gunnar_core::provenance`).
///
/// A third key, and a third job. The issuer says who somebody is, the
/// transparency key says what the log held, and this says what a build
/// produced. Three keys means a compromise of one is a compromise of one — and
/// it means the builder can be a different machine from the log, which is what
/// a runner on its own host needs.
pub const PROVENANCE_KEY: &str = "provenance/builder_ed25519";

/// **The outbound webhook signing key.** A fourth key, minted on first use and
/// sealed under the KEK, whose PUBLIC half is what a webhook receiver pins to
/// verify a delivery with `ssh-keygen -Y verify`. Its own key and not the
/// issuer's or the log's: signing a delivery is a different job from issuing a
/// certificate or attesting a build, and a key per job means a compromise of
/// one is a compromise of one.
pub const WEBHOOK_KEY: &str = "webhook/signing_ed25519";

/// **The actor-ticket signing key.** A fifth key, minted on first use by the
/// METERED appliance alone (`ServerStores::actor_ticket_key`) and sealed under
/// the KEK like the rest. Its PUBLIC half is what the appliance advertises over
/// `Billing.ApplianceIdentity` and what the other side of that seam pins to
/// check a ticket the appliance minted.
///
/// # Why it is not the SSH host key
///
/// Reusing the host key would have cost nothing to write and is exactly the
/// kind of thing that is fine until it is not. The host key's signatures are
/// produced inside the SSH protocol's own framing and the `sshsig` envelope,
/// both of which carry a domain separator; a raw Ed25519 signature over a JSON
/// document carries none. One key signing in two formats, one of which has no
/// domain separation at the signature layer, is a cross-protocol confusion
/// waiting for the day somebody makes one format's bytes parseable as the
/// other's. A key per job also means a compromise of one is a compromise of
/// one — the same sentence the four keys above are each here for.
///
/// # Why it costs no installer pin
///
/// It is minted AT RUNTIME into the secret store this box already has, exactly
/// as the webhook and provenance keys are. The alternative that was weighed —
/// shipping the key, or its configuration, in the image — would have put a new
/// value into two separate manifests that pin the installer, and a key that can
/// only be rolled by re-cutting an ISO is a key nobody rolls. Nothing about
/// this one reaches the image.
///
/// The name says what it signs and not what it is for: a store this name lands
/// in is present in every build, and only the metered one ever mints under it.
pub const ACTOR_TICKET_KEY: &str = "actor/ticket_ed25519";

/// **The forge-attestation key** (GHUNT-19): a sixth key, minted on first use
/// (`ServerStores::forge_attest_key`) and sealed under the KEK like the rest.
/// It signs a forge event a person made from an identity-core session that
/// stepped up, which holds no key of theirs: gunnar's word that the session's
/// fresh passkey touch stood behind the event, the actor named. Its own key and
/// not the log's or the issuer's, so a compromise of one is a compromise of one,
/// and it is never a principal's key in the identity table.
pub const FORGE_ATTEST_KEY: &str = "forge/attest_ed25519";

/// Small, durable, mutable key/value storage for things that are not objects
/// and not references.
///
/// The `mk` callback is `&mut dyn FnMut` rather than `impl FnOnce` so the trait
/// stays object-safe. The server holds a `Arc<dyn SecretStore>` and picks the
/// backend at boot. [`SecretStoreExt::get_or_create_with`] gives back the
/// `FnOnce` ergonomics the spec writes, with no loss: the closure is still
/// called at most once.
///
/// A `VaultSecretStore` is *noted, not built*. The trait is shaped so it
/// can be added without a signature change.
pub trait SecretStore: Send + Sync {
    /// Read a secret.
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;

    /// Write a secret, replacing any previous value.
    fn put(&self, key: &str, value: &[u8]) -> Result<()>;

    /// Remove a secret. Removing one that is not there is not an error.
    fn delete(&self, key: &str) -> Result<()>;

    /// Return the stored secret, or create it with `mk` and store it, atomically.
    ///
    /// If two threads (or two connections, or a boot racing a health check) call
    /// this at once, exactly one `mk` runs and both callers get **the same
    /// bytes**. The host key must never be regenerated by a race.
    ///
    /// `mk` is not called at all when the key already exists.
    fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>>;
}

impl<T: SecretStore + ?Sized> SecretStore for std::sync::Arc<T> {
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
        (**self).get(key)
    }
    fn put(&self, key: &str, value: &[u8]) -> Result<()> {
        (**self).put(key, value)
    }
    fn delete(&self, key: &str) -> Result<()> {
        (**self).delete(key)
    }
    fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
        (**self).get_or_create(key, mk)
    }
}

impl<T: SecretStore + ?Sized> SecretStore for &T {
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
        (**self).get(key)
    }
    fn put(&self, key: &str, value: &[u8]) -> Result<()> {
        (**self).put(key, value)
    }
    fn delete(&self, key: &str) -> Result<()> {
        (**self).delete(key)
    }
    fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
        (**self).get_or_create(key, mk)
    }
}

/// The ergonomic half of [`SecretStore`], kept off the trait so the trait stays
/// object-safe.
pub trait SecretStoreExt: SecretStore {
    /// [`SecretStore::get_or_create`] taking a `FnOnce`, as the spec writes it.
    fn get_or_create_with<F>(&self, key: &str, mk: F) -> Result<Vec<u8>>
    where
        F: FnOnce() -> Result<Vec<u8>>,
    {
        let mut mk = Some(mk);
        self.get_or_create(key, &mut || {
            let mk = mk
                .take()
                .expect("SecretStore::get_or_create must call `mk` at most once");
            mk()
        })
    }
}

impl<T: SecretStore + ?Sized> SecretStoreExt for T {}