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/// **The forge-attestation key** (GHUNT-19): a sixth key, minted on first use
124/// (`ServerStores::forge_attest_key`) and sealed under the KEK like the rest.
125/// It signs a forge event a person made from an identity-core session that
126/// stepped up, which holds no key of theirs: gunnar's word that the session's
127/// fresh passkey touch stood behind the event, the actor named. Its own key and
128/// not the log's or the issuer's, so a compromise of one is a compromise of one,
129/// and it is never a principal's key in the identity table.
130pub const FORGE_ATTEST_KEY: &str = "forge/attest_ed25519";
131
132/// Small, durable, mutable key/value storage for things that are not objects
133/// and not references.
134///
135/// The `mk` callback is `&mut dyn FnMut` rather than `impl FnOnce` so the trait
136/// stays object-safe. The server holds a `Arc<dyn SecretStore>` and picks the
137/// backend at boot. [`SecretStoreExt::get_or_create_with`] gives back the
138/// `FnOnce` ergonomics the spec writes, with no loss: the closure is still
139/// called at most once.
140///
141/// A `VaultSecretStore` is *noted, not built*. The trait is shaped so it
142/// can be added without a signature change.
143pub trait SecretStore: Send + Sync {
144 /// Read a secret.
145 fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
146
147 /// Write a secret, replacing any previous value.
148 fn put(&self, key: &str, value: &[u8]) -> Result<()>;
149
150 /// Remove a secret. Removing one that is not there is not an error.
151 fn delete(&self, key: &str) -> Result<()>;
152
153 /// Return the stored secret, or create it with `mk` and store it, atomically.
154 ///
155 /// If two threads (or two connections, or a boot racing a health check) call
156 /// this at once, exactly one `mk` runs and both callers get **the same
157 /// bytes**. The host key must never be regenerated by a race.
158 ///
159 /// `mk` is not called at all when the key already exists.
160 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>>;
161}
162
163impl<T: SecretStore + ?Sized> SecretStore for std::sync::Arc<T> {
164 fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
165 (**self).get(key)
166 }
167 fn put(&self, key: &str, value: &[u8]) -> Result<()> {
168 (**self).put(key, value)
169 }
170 fn delete(&self, key: &str) -> Result<()> {
171 (**self).delete(key)
172 }
173 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
174 (**self).get_or_create(key, mk)
175 }
176}
177
178impl<T: SecretStore + ?Sized> SecretStore for &T {
179 fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
180 (**self).get(key)
181 }
182 fn put(&self, key: &str, value: &[u8]) -> Result<()> {
183 (**self).put(key, value)
184 }
185 fn delete(&self, key: &str) -> Result<()> {
186 (**self).delete(key)
187 }
188 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
189 (**self).get_or_create(key, mk)
190 }
191}
192
193/// The ergonomic half of [`SecretStore`], kept off the trait so the trait stays
194/// object-safe.
195pub trait SecretStoreExt: SecretStore {
196 /// [`SecretStore::get_or_create`] taking a `FnOnce`, as the spec writes it.
197 fn get_or_create_with<F>(&self, key: &str, mk: F) -> Result<Vec<u8>>
198 where
199 F: FnOnce() -> Result<Vec<u8>>,
200 {
201 let mut mk = Some(mk);
202 self.get_or_create(key, &mut || {
203 let mk = mk
204 .take()
205 .expect("SecretStore::get_or_create must call `mk` at most once");
206 mk()
207 })
208 }
209}
210
211impl<T: SecretStore + ?Sized> SecretStoreExt for T {}