Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-core/src/seal.rs

//! 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)
    }
}