gunnar_store/secret.rs
1//! Secrets: where the SSH host key and the issuer private key live when there
2//! is no home directory (the secret-store seam).
3//!
4//! # The inventory is two keys, and the header used to claim a third
5//!
6//! Measured 2026-08-10 across every non-test caller in the tree:
7//! [`SSH_HOST_KEY`] and [`ISSUER_PRIVATE_KEY`]. That is all of it. This header
8//! said "the admin bootstrap material" until then, and it had not been true
9//! since the bootstrap admin started leaving with a certificate instead of a
10//! stored token.
11//!
12//! # Where an implementation of this lives now
13//!
14//! Not behind `RepoStore::secrets()`, which is deleted, and not in a storage
15//! engine. The server's implementation is `gunnar_core::RedbSecretStore`, a
16//! table in the same `control.redb` as the catalog and the identity table. This
17//! crate keeps the **trait** — because it is the vocabulary
18//! `gunnar_core::SealedSecretStore` decorates and the conformance suite checks —
19//! plus [`crate::mem::MemSecretStore`] as the control and
20//! [`crate::arrow_log::ArrowLogSecretStore`] as the legacy store the migration
21//! reads out of.
22//!
23//! The trait stays here rather than moving to `gunnar-core` for one hard
24//! reason and one soft one. The hard one: `arrow_log.rs` implements it, and
25//! `gunnar-store` cannot depend on `gunnar-core` — that edge runs the other
26//! way. The soft one: it is a contract with three implementations and a
27//! conformance check, which is what this crate is for.
28
29use crate::error::Result;
30
31/// Key under which the server's persistent SSH host key is stored.
32///
33/// An appliance that regenerates this on every boot gives every client a
34/// host-key-changed warning, which on an escape-hatch product is exactly the
35/// wrong first impression. [`SecretStore::get_or_create`] exists to make that
36/// impossible.
37pub const SSH_HOST_KEY: &str = "ssh/host_ed25519";
38
39/// Key under which **the server's X.509 issuer private key** is stored, PEM
40/// encoded (the certificate-identity plan, `gunnar_core::issuer`).
41///
42/// A key that can mint any user is the one secret whose loss is total — an
43/// attacker holding it does not break authentication, they issue themselves an
44/// administrator. The control database holds the issuer *certificate*, which is
45/// public; this names the half that is not.
46///
47/// Until 2026-08-10 that separation was also a separation of **files** — the
48/// certificate in `control.redb`, the key in an Arrow log inside `control.git`.
49/// It is not any more: both are in `control.redb`, in different tables. The
50/// property that actually protects the key was never the file boundary, it is
51/// the seal: the server writes this only through
52/// `gunnar_core::SealedSecretStore`, so what reaches any backend is ciphertext
53/// under a KEK that lives in `secrets.kek` beside the database and is not in
54/// it. A copied `control.redb` still cannot issue a certificate.
55pub const ISSUER_PRIVATE_KEY: &str = "pki/issuer_key";
56
57/// Key under which **the transparency log's signing key** is stored, OpenSSH
58/// private-key PEM (`gunnar_core::transparency`).
59///
60/// Its own key and not the issuer's, deliberately. The issuer signs
61/// *certificates*, which say who somebody is; this signs *tree heads*, which say
62/// what the log held. Sharing one key would mean that compromising the log's
63/// signer also mints administrators — and it would make revoking one of the two
64/// jobs impossible without revoking the other.
65///
66/// ed25519 and `sshsig`, because that is the one signature format in this
67/// repository: commits, control-plane challenges and now tree heads. A client
68/// can check a tree head with `ssh-keygen -Y verify` and no gunnar at all.
69///
70/// Sealed under the KEK like every other secret, so a copied `control.redb`
71/// cannot sign a tree head that a verifier would believe.
72pub const TRANSPARENCY_LOG_KEY: &str = "transparency/log_ed25519";
73
74/// Key under which **the builder's provenance signing key** is stored, OpenSSH
75/// private-key PEM (`gunnar_core::provenance`).
76///
77/// A third key, and a third job. The issuer says who somebody is, the
78/// transparency key says what the log held, and this says what a build
79/// produced. Three keys means a compromise of one is a compromise of one — and
80/// it means the builder can be a different machine from the log, which is what
81/// a runner on its own host needs.
82pub const PROVENANCE_KEY: &str = "provenance/builder_ed25519";
83
84/// **The outbound webhook signing key.** A fourth key, minted on first use and
85/// sealed under the KEK, whose PUBLIC half is what a webhook receiver pins to
86/// verify a delivery with `ssh-keygen -Y verify`. Its own key and not the
87/// issuer's or the log's: signing a delivery is a different job from issuing a
88/// certificate or attesting a build, and a key per job means a compromise of
89/// one is a compromise of one.
90pub const WEBHOOK_KEY: &str = "webhook/signing_ed25519";
91
92/// **The actor-ticket signing key.** A fifth key, minted on first use by the
93/// METERED appliance alone (`ServerStores::actor_ticket_key`) and sealed under
94/// the KEK like the rest. Its PUBLIC half is what the appliance advertises over
95/// `Billing.ApplianceIdentity` and what the other side of that seam pins to
96/// check a ticket the appliance minted.
97///
98/// # Why it is not the SSH host key
99///
100/// Reusing the host key would have cost nothing to write and is exactly the
101/// kind of thing that is fine until it is not. The host key's signatures are
102/// produced inside the SSH protocol's own framing and the `sshsig` envelope,
103/// both of which carry a domain separator; a raw Ed25519 signature over a JSON
104/// document carries none. One key signing in two formats, one of which has no
105/// domain separation at the signature layer, is a cross-protocol confusion
106/// waiting for the day somebody makes one format's bytes parseable as the
107/// other's. A key per job also means a compromise of one is a compromise of
108/// one — the same sentence the four keys above are each here for.
109///
110/// # Why it costs no installer pin
111///
112/// It is minted AT RUNTIME into the secret store this box already has, exactly
113/// as the webhook and provenance keys are. The alternative that was weighed —
114/// shipping the key, or its configuration, in the image — would have put a new
115/// value into two separate manifests that pin the installer, and a key that can
116/// only be rolled by re-cutting an ISO is a key nobody rolls. Nothing about
117/// this one reaches the image.
118///
119/// The name says what it signs and not what it is for: a store this name lands
120/// in is present in every build, and only the metered one ever mints under it.
121pub const ACTOR_TICKET_KEY: &str = "actor/ticket_ed25519";
122
123/// Small, durable, mutable key/value storage for things that are not objects
124/// and not references.
125///
126/// The `mk` callback is `&mut dyn FnMut` rather than `impl FnOnce` so the trait
127/// stays object-safe. The server holds a `Arc<dyn SecretStore>` and picks the
128/// backend at boot. [`SecretStoreExt::get_or_create_with`] gives back the
129/// `FnOnce` ergonomics the spec writes, with no loss: the closure is still
130/// called at most once.
131///
132/// A `VaultSecretStore` is *noted, not built*. The trait is shaped so it
133/// can be added without a signature change.
134pub trait SecretStore: Send + Sync {
135 /// Read a secret.
136 fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
137
138 /// Write a secret, replacing any previous value.
139 fn put(&self, key: &str, value: &[u8]) -> Result<()>;
140
141 /// Remove a secret. Removing one that is not there is not an error.
142 fn delete(&self, key: &str) -> Result<()>;
143
144 /// Return the stored secret, or create it with `mk` and store it, atomically.
145 ///
146 /// If two threads (or two connections, or a boot racing a health check) call
147 /// this at once, exactly one `mk` runs and both callers get **the same
148 /// bytes**. The host key must never be regenerated by a race.
149 ///
150 /// `mk` is not called at all when the key already exists.
151 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>>;
152}
153
154impl<T: SecretStore + ?Sized> SecretStore for std::sync::Arc<T> {
155 fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
156 (**self).get(key)
157 }
158 fn put(&self, key: &str, value: &[u8]) -> Result<()> {
159 (**self).put(key, value)
160 }
161 fn delete(&self, key: &str) -> Result<()> {
162 (**self).delete(key)
163 }
164 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
165 (**self).get_or_create(key, mk)
166 }
167}
168
169impl<T: SecretStore + ?Sized> SecretStore for &T {
170 fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
171 (**self).get(key)
172 }
173 fn put(&self, key: &str, value: &[u8]) -> Result<()> {
174 (**self).put(key, value)
175 }
176 fn delete(&self, key: &str) -> Result<()> {
177 (**self).delete(key)
178 }
179 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
180 (**self).get_or_create(key, mk)
181 }
182}
183
184/// The ergonomic half of [`SecretStore`], kept off the trait so the trait stays
185/// object-safe.
186pub trait SecretStoreExt: SecretStore {
187 /// [`SecretStore::get_or_create`] taking a `FnOnce`, as the spec writes it.
188 fn get_or_create_with<F>(&self, key: &str, mk: F) -> Result<Vec<u8>>
189 where
190 F: FnOnce() -> Result<Vec<u8>>,
191 {
192 let mut mk = Some(mk);
193 self.get_or_create(key, &mut || {
194 let mk = mk
195 .take()
196 .expect("SecretStore::get_or_create must call `mk` at most once");
197 mk()
198 })
199 }
200}
201
202impl<T: SecretStore + ?Sized> SecretStoreExt for T {}