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/// **The outbound webhook signing key.** A fourth key, minted on first use and
75/// sealed under the KEK, whose PUBLIC half is what a webhook receiver pins to
76/// verify a delivery with `ssh-keygen -Y verify`. Its own key and not the
77/// issuer's or the log's: signing a delivery is a different job from issuing a
78/// certificate or attesting a push, and a key per job means a compromise of
79/// one is a compromise of one.
80pub const WEBHOOK_KEY: &str = "webhook/signing_ed25519";
81
82/// **The actor-ticket signing key.** A fifth key, minted on first use by the
83/// METERED appliance alone (`ServerStores::actor_ticket_key`) and sealed under
84/// the KEK like the rest. Its PUBLIC half is what the appliance advertises over
85/// `Billing.ApplianceIdentity` and what the other side of that seam pins to
86/// check a ticket the appliance minted.
87///
88/// # Why it is not the SSH host key
89///
90/// Reusing the host key would have cost nothing to write and is exactly the
91/// kind of thing that is fine until it is not. The host key's signatures are
92/// produced inside the SSH protocol's own framing and the `sshsig` envelope,
93/// both of which carry a domain separator; a raw Ed25519 signature over a JSON
94/// document carries none. One key signing in two formats, one of which has no
95/// domain separation at the signature layer, is a cross-protocol confusion
96/// waiting for the day somebody makes one format's bytes parseable as the
97/// other's. A key per job also means a compromise of one is a compromise of
98/// one — the same sentence the four keys above are each here for.
99///
100/// # Why it costs no installer pin
101///
102/// It is minted AT RUNTIME into the secret store this box already has, exactly
103/// as the webhook key is. The alternative that was weighed —
104/// shipping the key, or its configuration, in the image — would have put a new
105/// value into two separate manifests that pin the installer, and a key that can
106/// only be rolled by re-cutting an ISO is a key nobody rolls. Nothing about
107/// this one reaches the image.
108///
109/// The name says what it signs and not what it is for: a store this name lands
110/// in is present in every build, and only the metered one ever mints under it.
111pub const ACTOR_TICKET_KEY: &str = "actor/ticket_ed25519";
112
113/// **The forge-attestation key** (GHUNT-19): a sixth key, minted on first use
114/// (`ServerStores::forge_attest_key`) and sealed under the KEK like the rest.
115/// It signs a forge event a person made from an identity-core session that
116/// stepped up, which holds no key of theirs: gunnar's word that the session's
117/// fresh passkey touch stood behind the event, the actor named. Its own key and
118/// not the log's or the issuer's, so a compromise of one is a compromise of one,
119/// and it is never a principal's key in the identity table.
120pub const FORGE_ATTEST_KEY: &str = "forge/attest_ed25519";
121
122/// **The merge-signing key** (GUN-MERGE-SIGN, 2026-11-02): a seventh key, minted on
123/// first use (`ServerStores::merge_signing_key`) and sealed under the KEK like the
124/// rest, so the twin copies it and a restore brings the same key back. It signs the
125/// merge and squash commits gunnar writes (an SSH signature, namespace `git`), so a
126/// repository that requires signed commits takes them. Never a principal's key; it
127/// never leaves the box, only its public half is handed out.
128pub const MERGE_SIGNING_KEY: &str = "merge/signing_ed25519";
129
130/// Small, durable, mutable key/value storage for things that are not objects
131/// and not references.
132///
133/// The `mk` callback is `&mut dyn FnMut` rather than `impl FnOnce` so the trait
134/// stays object-safe. The server holds a `Arc<dyn SecretStore>` and picks the
135/// backend at boot. [`SecretStoreExt::get_or_create_with`] gives back the
136/// `FnOnce` ergonomics the spec writes, with no loss: the closure is still
137/// called at most once.
138///
139/// A `VaultSecretStore` is *noted, not built*. The trait is shaped so it
140/// can be added without a signature change.
141pub trait SecretStore: Send + Sync {
142 /// Read a secret.
143 fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
144
145 /// Write a secret, replacing any previous value.
146 fn put(&self, key: &str, value: &[u8]) -> Result<()>;
147
148 /// Remove a secret. Removing one that is not there is not an error.
149 fn delete(&self, key: &str) -> Result<()>;
150
151 /// Return the stored secret, or create it with `mk` and store it, atomically.
152 ///
153 /// If two threads (or two connections, or a boot racing a health check) call
154 /// this at once, exactly one `mk` runs and both callers get **the same
155 /// bytes**. The host key must never be regenerated by a race.
156 ///
157 /// `mk` is not called at all when the key already exists.
158 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>>;
159}
160
161impl<T: SecretStore + ?Sized> SecretStore for std::sync::Arc<T> {
162 fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
163 (**self).get(key)
164 }
165 fn put(&self, key: &str, value: &[u8]) -> Result<()> {
166 (**self).put(key, value)
167 }
168 fn delete(&self, key: &str) -> Result<()> {
169 (**self).delete(key)
170 }
171 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
172 (**self).get_or_create(key, mk)
173 }
174}
175
176impl<T: SecretStore + ?Sized> SecretStore for &T {
177 fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
178 (**self).get(key)
179 }
180 fn put(&self, key: &str, value: &[u8]) -> Result<()> {
181 (**self).put(key, value)
182 }
183 fn delete(&self, key: &str) -> Result<()> {
184 (**self).delete(key)
185 }
186 fn get_or_create(&self, key: &str, mk: &mut dyn FnMut() -> Result<Vec<u8>>) -> Result<Vec<u8>> {
187 (**self).get_or_create(key, mk)
188 }
189}
190
191/// The ergonomic half of [`SecretStore`], kept off the trait so the trait stays
192/// object-safe.
193pub trait SecretStoreExt: SecretStore {
194 /// [`SecretStore::get_or_create`] taking a `FnOnce`, as the spec writes it.
195 fn get_or_create_with<F>(&self, key: &str, mk: F) -> Result<Vec<u8>>
196 where
197 F: FnOnce() -> Result<Vec<u8>>,
198 {
199 let mut mk = Some(mk);
200 self.get_or_create(key, &mut || {
201 let mk = mk
202 .take()
203 .expect("SecretStore::get_or_create must call `mk` at most once");
204 mk()
205 })
206 }
207}
208
209impl<T: SecretStore + ?Sized> SecretStoreExt for T {}