gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
//! Sealing secrets at rest.
//!
//! # The gap this closes
//!
//! `gunnar_store::SecretStore` held the SSH host key as plaintext bytes in
//! `secrets.arrow`. The cold tier format reserves `__gunnar_secrets__` for
//! *already-encrypted* material and `gunnar_store::FoldRequest` therefore
//! carries no secrets at all — the omission was deliberate, so that nobody
//! could close it by accident with a plaintext key. This module is the thing
//! that had to exist first: an encryption layer that makes a secret safe to sit
//! in a file whose whole purpose is to be copied elsewhere.
//!
//! # Where it sits
//!
//! [`SealedSecretStore`] is a **decorator over the trait**, not a fourth
//! backend. `mem`, `arrow_log` and `fs` all gain sealing from one
//! implementation, and there is exactly one place where a secret is encrypted
//! and one where it is decrypted (one implementation). A backend cannot forget to seal,
//! because the backend is not what seals.
//!
//! # The cipher, and why it is not a choice
//!
//! AES-256-GCM out of `aws-lc-rs`. gunnar already links aws-lc-rs statically —
//! russh pulls it, and the stripped appliance binary carries 123 `AWS_LC`
//! strings with zero `PT_INTERP` and zero `DT_NEEDED`. One appliance gets one
//! crypto backend; adding `ring` or a pure-Rust AEAD would mean two
//! implementations of AES in a binary that is meant to be auditable.
//!
//! # The wire format
//!
//! ```text
//! magic  "gsk1"      4 bytes   version, and a marker that tells a sealed value
//!                              from a legacy plaintext one
//! nonce              12 bytes  fresh from the system CSPRNG on every seal
//! ciphertext||tag    n+16      AES-256-GCM
//! ```
//!
//! The **AAD is the secret's key name**. A sealed `ssh/host_ed25519` therefore
//! cannot be lifted out of the log and replayed under a different key: the tag
//! check fails. Without that binding, an attacker who could write the secret log
//! (but not read the KEK) could swap a low-value secret into the host key slot.
//!
//! # Where the key-encrypting key lives — the actual hard question
//!
//! An appliance boots with no operator to type a passphrase. There are four
//! honest places a KEK can live and gunnar takes two of them:
//!
//! 1. **Beside the data, on the appliance's own disk** ([`Kek::from_file`], the
//!    default). A 32-byte file at mode `0600`, in the control directory,
//!    **outside** the Arrow secret log and outside anything a
//!    `gunnar_store::FoldRequest` can reach.
//! 2. **Handed in from outside the machine** ([`Kek::from_env`], `GUNNAR_SECRET_KEK`).
//!    Never written to disk. This is the seam for a KMS, a Kubernetes secret, or
//!    an initramfs that unwraps against a TPM before `exec`ing gunnar.
//! 3. An operator passphrase at boot — rejected, it contradicts unattended boot.
//! 4. A key derived from machine identity (machine-id, disk serial) — rejected,
//!    that is obfuscation wearing a KDF, and calling it encryption is worse than
//!    not encrypting.
//!
//! ## What (1) defeats, and what it does not
//!
//! Say it plainly, because a sealed secret whose unlock key sits beside it is
//! theatre if you claim more than this:
//!
//! **Defeats** — every path by which the *data* leaves the machine without the
//! disk: a cold-tier `.znippy` archive copied to object storage, a backup, an
//! archive handed to a customer, a `secrets.arrow` pulled out of a replica, a
//! log line or a `{:?}` that dumps stored bytes. Those are the realistic
//! exfiltration routes for an appliance whose *design goal* is that its archive
//! be portable, and against all of them the KEK stays behind.
//!
//! **Does not defeat** — an attacker with the appliance's disk, or root on the
//! running appliance, or its memory. The KEK is one `read(2)` away for anyone
//! who has those. Option (1) is **domain separation between the portable
//! artifact and the machine**, not confidentiality against physical possession.
//!
//! Anyone who needs the second property uses option (2), and then the KEK is
//! genuinely not on the disk at all. The design does not pretend option (1) is
//! option (2).
//!
//! # Migration
//!
//! A store written before this existed holds plaintext under the same keys.
//! [`SealedSecretStore::get`] returns such a value unchanged, with a warning, so
//! an in-place upgrade does not brick the host key; and
//! [`SealedSecretStore::get_or_create`] **re-seals it in place**, which closes
//! the migration by construction on the one path the host key actually takes at
//! boot rather than leaving it to a migration nobody runs.
//!
//! ## …and why accepting one has to be asked for
//!
//! Read that migration next to the threat two sections up — *"an attacker who
//! could write the secret log (but not read the KEK) could swap a low-value
//! secret into the host key slot"* — and it undoes it. The AAD binding stops a
//! sealed value being moved between slots, and a magic-less prefix walks past
//! the binding entirely: write `-----BEGIN OPENSSH PRIVATE KEY-----…` into
//! `ssh/host_ed25519`, and a store that accepts any unsealed value serves the
//! attacker's host key. No KEK needed. The warning that says so goes to the
//! journal, where nothing refuses anything.
//!
//! So accepting plaintext is a [`PlaintextPolicy`], it is
//! [`PlaintextPolicy::Refuse`] by default, and the one caller that turns it on
//! does so from a **fact about the disk**: `ServerStores` finds a legacy
//! `secrets.arrow` or it does not, and only on the machine that has one is a
//! plaintext value something gunnar itself could have written. On every
//! installation made after 2026-08-10 there is no such file, nothing under the
//! seal has ever been written in the clear, and an unsealed value is either
//! corruption or somebody else's key.

use std::path::{Path, PathBuf};

use aws_lc_rs::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
use aws_lc_rs::rand::{SecureRandom as _, SystemRandom};
use gunnar_store::{Error as StoreError, Result as StoreResult, SecretStore};
use zeroize::Zeroize as _;

/// Magic and version prefix on every sealed value.
///
/// Four bytes, and its job is to be distinguishable from an OpenSSH private key
/// (`-----BEGIN OPENSSH`) and from anything else a pre-sealing gunnar wrote, so
/// [`is_sealed`] can never mistake a legacy plaintext for ciphertext.
pub const SEAL_MAGIC: &[u8; 4] = b"gsk1";

/// Bytes of a KEK. AES-256, so 32.
pub const KEK_LEN: usize = 32;

/// The GCM authentication tag length, named rather than spelled `16` at the
/// three sites that need it.
const TAG_LEN: usize = 16;

/// The file the on-disk KEK is kept in, relative to the control directory.
pub const KEK_FILE: &str = "secrets.kek";

/// The environment variable that supplies a KEK from outside the machine.
///
/// 64 hex characters. When it is set, nothing writes a KEK to disk.
pub const KEK_ENV: &str = "GUNNAR_SECRET_KEK";

/// A key-encrypting key.
///
/// `Debug` prints nothing but the type name; 32 bytes of key material must not
/// be one `{:?}` away from a log file, exactly as
/// [`crate::host_key::HostKey`] is not.
#[derive(Clone)]
pub struct Kek([u8; KEK_LEN]);

impl std::fmt::Debug for Kek {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("Kek(<redacted>)")
    }
}

impl Drop for Kek {
    fn drop(&mut self) {
        // `zeroize`, not a hand-rolled `write_volatile`: the workspace forbids
        // `unsafe` (`-F unsafe-code`) and this crate is not the place to make an
        // exception for something a maintained crate already does correctly.
        // It costs no package — aws-lc-rs and curve25519-dalek both already
        // resolve it.
        self.0.zeroize();
    }
}

impl Kek {
    /// A KEK from raw bytes.
    pub fn from_bytes(bytes: [u8; KEK_LEN]) -> Self {
        Kek(bytes)
    }

    /// A fresh KEK from the system CSPRNG.
    pub fn generate() -> crate::Result<Self> {
        let mut bytes = [0u8; KEK_LEN];
        SystemRandom::new()
            .fill(&mut bytes)
            .map_err(|_| crate::Error::Key("the system CSPRNG refused to produce a KEK".into()))?;
        Ok(Kek(bytes))
    }

    /// A KEK from [`KEK_ENV`], if it is set.
    ///
    /// `Ok(None)` when the variable is absent — that is not an error, it is the
    /// normal case. `Err` when it is set and unusable, because silently falling
    /// back to an on-disk KEK after an operator explicitly supplied one would
    /// downgrade the threat model without saying so.
    pub fn from_env() -> crate::Result<Option<Self>> {
        let Ok(hex_text) = std::env::var(KEK_ENV) else {
            return Ok(None);
        };
        let hex_text = hex_text.trim();
        let raw = hex::decode(hex_text)
            .map_err(|err| crate::Error::Key(format!("{KEK_ENV} is not hexadecimal: {err}")))?;
        let bytes: [u8; KEK_LEN] = raw.as_slice().try_into().map_err(|_| {
            crate::Error::Key(format!(
                "{KEK_ENV} decoded to {} bytes; a KEK is {KEK_LEN}",
                raw.len()
            ))
        })?;
        tracing::info!("using the key-encrypting key from {KEK_ENV}; none is written to disk");
        Ok(Some(Kek(bytes)))
    }

    /// The KEK stored at `path`, creating one on first boot.
    ///
    /// Created at mode `0600` and fsynced, then the parent directory is fsynced,
    /// so a KEK that a later boot cannot read is not a way to lose every secret
    /// on the machine.
    pub fn from_file(path: &Path) -> crate::Result<Self> {
        match std::fs::read(path) {
            Ok(raw) => {
                let bytes: [u8; KEK_LEN] = raw.as_slice().try_into().map_err(|_| {
                    crate::Error::Key(format!(
                        "the KEK at {} is {} bytes; a KEK is {KEK_LEN}. Refusing to \
                         guess, because guessing here means every secret on this \
                         machine becomes unreadable",
                        path.display(),
                        raw.len()
                    ))
                })?;
                Ok(Kek(bytes))
            }
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                let kek = Kek::generate()?;
                write_kek_file(path, &kek.0)?;
                tracing::info!(
                    path = %path.display(),
                    "no key-encrypting key found; minted one and sealed the secret store"
                );
                Ok(kek)
            }
            Err(err) => Err(crate::Error::Key(format!(
                "could not read the KEK at {}: {err}",
                path.display()
            ))),
        }
    }

    /// [`Kek::from_env`] if the environment supplies one, else
    /// [`Kek::from_file`] under `control_dir`.
    ///
    /// This is the one call a server should make: the precedence is decided
    /// here, once, rather than at every boot site.
    pub fn for_control_dir(control_dir: &Path) -> crate::Result<Self> {
        if let Some(kek) = Kek::from_env()? {
            return Ok(kek);
        }
        Kek::from_file(&kek_path(control_dir))
    }

    /// Hex, for handing a generated KEK to an operator to put in a vault.
    ///
    /// Deliberately a named method and not `Display`: printing key material must
    /// be something a caller asked for by name.
    pub fn to_hex(&self) -> String {
        hex::encode(self.0)
    }

    fn aead(&self) -> crate::Result<LessSafeKey> {
        UnboundKey::new(&AES_256_GCM, &self.0)
            .map(LessSafeKey::new)
            .map_err(|_| crate::Error::Key("aws-lc-rs rejected the KEK for AES-256-GCM".into()))
    }
}

/// Where [`Kek::for_control_dir`] keeps the on-disk KEK.
pub fn kek_path(control_dir: &Path) -> PathBuf {
    control_dir.join(KEK_FILE)
}

fn write_kek_file(path: &Path, bytes: &[u8; KEK_LEN]) -> crate::Result<()> {
    use std::io::Write as _;

    let io = |err: std::io::Error| {
        crate::Error::Key(format!(
            "could not write the KEK at {}: {err}",
            path.display()
        ))
    };
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(io)?;
    }
    let mut options = std::fs::OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt as _;
        options.mode(0o600);
    }
    let mut file = options.open(path).map_err(io)?;
    file.write_all(bytes).map_err(io)?;
    file.sync_all().map_err(io)?;
    drop(file);
    if let Some(parent) = path.parent() {
        // The rename-durability half: a KEK whose directory entry did not reach
        // the disk is a KEK that does not exist after a power cut, and every
        // secret sealed under it is gone.
        if let Ok(dir) = std::fs::File::open(parent) {
            let _ = dir.sync_all();
        }
    }
    Ok(())
}

/// Whether `value` carries the sealed-value magic.
///
/// Public because the migration question — *is this store already sealed?* — is
/// one an operator tool legitimately asks.
pub fn is_sealed(value: &[u8]) -> bool {
    value.len() >= SEAL_MAGIC.len() + NONCE_LEN + TAG_LEN && value.starts_with(SEAL_MAGIC)
}

/// Encrypt `plaintext` for `key_name` under `kek`.
///
/// The key name is the AEAD's associated data, so the result is bound to the
/// slot it was written for.
pub fn seal(kek: &Kek, key_name: &str, plaintext: &[u8]) -> crate::Result<Vec<u8>> {
    let aead = kek.aead()?;
    let mut nonce = [0u8; NONCE_LEN];
    SystemRandom::new()
        .fill(&mut nonce)
        .map_err(|_| crate::Error::Key("the system CSPRNG refused to produce a nonce".into()))?;

    let mut body = plaintext.to_vec();
    aead.seal_in_place_append_tag(
        Nonce::assume_unique_for_key(nonce),
        Aad::from(key_name.as_bytes()),
        &mut body,
    )
    .map_err(|_| crate::Error::Key(format!("could not seal the secret {key_name}")))?;

    let mut out = Vec::with_capacity(SEAL_MAGIC.len() + NONCE_LEN + body.len());
    out.extend_from_slice(SEAL_MAGIC);
    out.extend_from_slice(&nonce);
    out.extend_from_slice(&body);
    Ok(out)
}

/// Decrypt a value produced by [`seal`] for the same `key_name`.
///
/// Fails on a wrong KEK, a wrong key name, or any modified byte — all three are
/// the same GCM tag check, which is the point of using an AEAD rather than a
/// bare cipher.
pub fn unseal(kek: &Kek, key_name: &str, sealed: &[u8]) -> crate::Result<Vec<u8>> {
    if !is_sealed(sealed) {
        return Err(crate::Error::Key(format!(
            "the stored value for {key_name} is not a sealed secret"
        )));
    }
    let aead = kek.aead()?;
    let nonce_start = SEAL_MAGIC.len();
    let body_start = nonce_start + NONCE_LEN;
    let mut nonce = [0u8; NONCE_LEN];
    nonce.copy_from_slice(&sealed[nonce_start..body_start]);

    let mut body = sealed[body_start..].to_vec();
    let plaintext = aead
        .open_in_place(
            Nonce::assume_unique_for_key(nonce),
            Aad::from(key_name.as_bytes()),
            &mut body,
        )
        .map_err(|_| {
            crate::Error::Key(format!(
                "the secret {key_name} did not authenticate: wrong key-encrypting key, wrong \
                 secret name, or modified bytes"
            ))
        })?
        .to_vec();
    Ok(plaintext)
}

/// A [`SecretStore`] that encrypts everything on the way in and decrypts on the
/// way out.
///
/// Wraps any backend. The backend never sees a plaintext secret, so the bytes in
/// `secrets.arrow` — and therefore the bytes any `__gunnar_secrets__` entry
/// would ever carry — are ciphertext.
pub struct SealedSecretStore<S> {
    inner: S,
    kek: Kek,
    plaintext: PlaintextPolicy,
}

/// **What the store does with a value that carries no [`SEAL_MAGIC`].**
///
/// A stored value is either something this decorator sealed or something else,
/// and "something else" has exactly two origins: a gunnar old enough to have
/// written the clear, or a writer who is not gunnar. The KEK tells them apart
/// for every sealed value and tells them apart for no unsealed one — so the
/// answer cannot come from the value, and has to come from the caller, who
/// knows whether this machine has a pre-sealing store on it at all.
///
/// [`Refuse`](PlaintextPolicy::Refuse) is the default for the ordinary reason a
/// default is chosen: the machine that needs the other answer is the rare one,
/// and it is the one that can prove it needs it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlaintextPolicy {
    /// An unsealed value is an error. Nothing under this store's keys was ever
    /// written in the clear, so a value without the magic was not put there by
    /// anything holding the KEK.
    #[default]
    Refuse,
    /// An unsealed value is a pre-sealing gunnar's, is read through, and is
    /// re-sealed by the next `get_or_create`. Only for a store that is
    /// demonstrably backed by one — see [`SealedSecretStore::migrating`].
    SealOnRead,
}

impl<S> std::fmt::Debug for SealedSecretStore<S> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("SealedSecretStore")
    }
}

impl<S: SecretStore> SealedSecretStore<S> {
    /// Seal `inner` under `kek`, refusing anything it reads back unsealed.
    pub fn new(inner: S, kek: Kek) -> Self {
        Self {
            inner,
            kek,
            plaintext: PlaintextPolicy::Refuse,
        }
    }

    /// Seal `inner` under `kek`, and read a pre-sealing gunnar's plaintext
    /// through so an in-place upgrade keeps its host key.
    ///
    /// **Only for a store that really is backed by one.** The caller is
    /// asserting a fact about the disk — on the server that is
    /// `legacy_secret_log` having found a `secrets.arrow`, which is the same
    /// file the read-old/write-new [`crate::MigratingSecretStore`] is reading
    /// through. On an installation with no such file this constructor claims a
    /// migration that cannot be happening, and every value it then accepts
    /// unsealed was written by something that did not hold the KEK.
    pub fn migrating(inner: S, kek: Kek) -> Self {
        Self {
            inner,
            kek,
            plaintext: PlaintextPolicy::SealOnRead,
        }
    }

    /// What this store does with an unsealed value.
    pub fn plaintext_policy(&self) -> PlaintextPolicy {
        self.plaintext
    }

    /// The backend, for tests that need to look at what was actually stored.
    pub fn inner(&self) -> &S {
        &self.inner
    }

    fn to_store_err(err: crate::Error) -> StoreError {
        StoreError::backend(err)
    }

    /// Decrypt a value read from the backend, passing a legacy plaintext
    /// through only if this store was told to expect one.
    fn open_value(&self, key: &str, raw: Vec<u8>) -> StoreResult<Vec<u8>> {
        if is_sealed(&raw) {
            return unseal(&self.kek, key, &raw).map_err(Self::to_store_err);
        }
        self.accept_plaintext(key)?;
        tracing::warn!(
            key,
            "read an UNSEALED secret written before sealing existed; it will be sealed on the \
             next get_or_create for this key"
        );
        Ok(raw)
    }

    /// The refusal, in one place because `get` and `get_or_create` must give
    /// the same answer — a store that refuses a read but adopts the same bytes
    /// on the boot path refuses nothing.
    fn accept_plaintext(&self, key: &str) -> StoreResult<()> {
        match self.plaintext {
            PlaintextPolicy::SealOnRead => Ok(()),
            PlaintextPolicy::Refuse => Err(Self::to_store_err(crate::Error::Key(format!(
                "the stored value for {key} carries no seal. Nothing holding the \
                 key-encrypting key wrote it: this store seals on the way in, and there is no \
                 pre-sealing secret log on this machine for it to have come from. Refusing to \
                 use it — an unsealed value in a sealed slot is what an attacker who can write \
                 the store but not read the KEK would leave behind"
            )))),
        }
    }
}

impl<S: SecretStore> SecretStore for SealedSecretStore<S> {
    fn get(&self, key: &str) -> StoreResult<Option<Vec<u8>>> {
        match self.inner.get(key)? {
            Some(raw) => self.open_value(key, raw).map(Some),
            None => Ok(None),
        }
    }

    fn put(&self, key: &str, value: &[u8]) -> StoreResult<()> {
        let sealed = seal(&self.kek, key, value).map_err(Self::to_store_err)?;
        self.inner.put(key, &sealed)
    }

    fn delete(&self, key: &str) -> StoreResult<()> {
        self.inner.delete(key)
    }

    fn get_or_create(
        &self,
        key: &str,
        mk: &mut dyn FnMut() -> StoreResult<Vec<u8>>,
    ) -> StoreResult<Vec<u8>> {
        // The atomicity that matters — exactly one `mk` across racing callers —
        // belongs to the backend and must not be reimplemented here, so this
        // delegates and seals *inside* the callback. Two threads racing on an
        // absent key therefore still mint once, and the loser gets the winner's
        // ciphertext back and decrypts it to the winner's plaintext.
        let stored = self.inner.get_or_create(key, &mut || {
            let plaintext = mk()?;
            seal(&self.kek, key, &plaintext).map_err(Self::to_store_err)
        })?;

        if is_sealed(&stored) {
            return unseal(&self.kek, key, &stored).map_err(Self::to_store_err);
        }

        // A legacy plaintext was already there. Seal it in place — this is the
        // migration, and it runs on the boot path the host key takes rather than
        // in a tool somebody has to remember. Same gate as the read: sealing an
        // injected plaintext in place would not launder it into a secret this
        // machine chose, it would make it permanent.
        self.accept_plaintext(key)?;
        tracing::warn!(
            key,
            "sealing a secret that was stored in the clear by an earlier gunnar"
        );
        let sealed = seal(&self.kek, key, &stored).map_err(Self::to_store_err)?;
        self.inner.put(key, &sealed)?;
        Ok(stored)
    }
}