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

nordisk/edda

crates/appliance-pid1/src/narration.rs

//! **The boot narration, handed over the moment there is a network**
//! (feature `net`): the kernel's last records, through the ONE secret door,
//! into the product's boot log — which the installer collects over its mutual
//! TLS (nordisk-pairing `bootlog`).
//!
//! Moved AS IS from gunnar-server's `pid1.rs` (gunnar `gunnar-live` 18a44e3,
//! 2026-09-27). The sink is the caller's now ([`ship_boot_narration`]'s `tap`).

// ── 3a. shipping the boot narration the moment there is a network ───────────

/// Where korp-start left the narration lines it could not send (it brings no
/// network up; this process does) — `korp_installer::bootlog::SPOOL_PATH`.
/// Deleted once shipped.
pub const NARRATION_SPOOL: &str = "/run/korp/netlog.pending";

/// How many trailing kernel log records ride along.
const KMSG_TAIL: usize = 64;

// ── the secret door, on the RELAY ────────────────────────────────────────────

/// **What this process may never put on the narration wire.** Returns the CLASS
/// when a line must be withheld — never the offending text, which is the point.
///
/// MEASURED 2026-09-20 on the estate's bring-up. Two shapes crossed this wire in
/// clear text, over unauthenticated UDP, to whoever held the address on the
/// kernel command line:
///
/// 1. `INSTALLER: wg-appliance identity: product=gunnar pubkey=z0jsFOWK…` — a
///    line korp-start narrated and this function relayed out of the spool.
/// 2. `kmsg … with environment: … KORP_INSTALLER_ROOT=/run/installer` — two
///    adjacent kernel records inside the 64-record tail. The values were
///    harmless; the SHAPE is not. Any bare `UPPERCASE=VALUE` on the kernel
///    command line becomes a kernel-logged environment record, and `korp.args`
///    carries the monetize verifying key and the clock key. In that run the
///    window happened to start at seq 367, so `Kernel command line:` (seq ~10)
///    fell outside it — BY LUCK, not by design. On a quieter kernel it ships.
///
/// korp-installer 0.1.13 closes (1) at its own source. This door exists anyway,
/// and not as belt-and-braces: **this process is a RELAY.** It ships a spool
/// written by whatever `korp-start` is on the installed disk, and that is an
/// older `korp-installer` on every appliance already in the field (the ISO pins
/// 0.1.7). A filter that trusted the writer would be a filter that trusts a
/// binary it did not build. And the kmsg tail has no author at all — the kernel
/// is not going to start redacting itself.
///
/// The classes are the SAME six the collector refuses
/// (`private-gunnar-ops/xtask/src/bootnarr.rs::secret_class`) and the same seven
/// korp-installer's `bootlog::secret_class` refuses, word for word and with the
/// same 40-character opaque run. Three doors, one vocabulary, so "the sink
/// withheld something" can never mean "an earlier door let it through".
fn narration_secret_class(line: &str) -> Option<&'static str> {
    let lower = line.to_ascii_lowercase();
    if lower.contains("kernel command line")
        || lower.contains("/proc/cmdline")
        || lower.contains("korp.args")
        || lower.contains("korp.log_udp=")
        || lower.contains("korp.env.")
        || lower.contains("clock_key=")
    {
        return Some("cmdline");
    }
    if lower.contains("token")
        || lower.contains("bearer ")
        || lower.contains("password")
        || lower.contains("passphrase")
        || lower.contains("secret")
    {
        return Some("token");
    }
    if lower.contains("private key")
        || lower.contains("begin openssh")
        || lower.contains("-----begin")
        || lower.contains("ssh-ed25519")
        || lower.contains("ssh-rsa")
        || lower.contains("ssh-dss")
        || lower.contains("ecdsa-sha2-")
        || lower.contains("signing.key")
        || lower.contains("time_ed25519")
    {
        return Some("key");
    }
    if lower.contains("pubkey") || lower.contains("public key") || lower.contains("sha256:") {
        return Some("pubkey");
    }
    if lower.contains(".env") {
        return Some("env");
    }
    if has_env_record(line) {
        return Some("envrecord");
    }
    if longest_opaque_run(line) >= NARRATION_OPAQUE_RUN_LIMIT {
        return Some("highentropy");
    }
    None
}

/// The run length at which an unlabelled string is treated as key material.
/// The collector's number and korp-installer's, kept in lockstep on purpose.
const NARRATION_OPAQUE_RUN_LIMIT: usize = 40;

/// A bare `UPPERCASE=VALUE` assignment anywhere in the line — the env-record
/// shape, which is the one nobody labelled. The name must start with a letter,
/// be at least three characters and hold only upper-case letters, digits and
/// underscores; the value must be non-empty and glued to the `=`, so prose with
/// a spaced `=` (`PCR 7 = 0a1b`) is not mistaken for a record.
///
/// This is what makes the fix independent of where the ring buffer starts. The
/// `Kernel command line:` record is caught by NAME above; the env records it
/// turns into are caught by SHAPE here. Neither test asks what seq the window
/// began at, which was the whole defect in the run that was measured.
fn has_env_record(line: &str) -> bool {
    line.split_whitespace().any(|tok| {
        let Some((name, value)) = tok.split_once('=') else {
            return false;
        };
        !value.is_empty()
            && name.len() >= 3
            && name.starts_with(|c: char| c.is_ascii_uppercase())
            && name.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
    })
}

/// The longest unbroken run of the base64/hex alphabet — a key with its label
/// removed still looks like this, and nothing a progress line says does.
fn longest_opaque_run(line: &str) -> usize {
    let mut best = 0usize;
    let mut run = 0usize;
    for c in line.chars() {
        if c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' {
            run += 1;
            best = best.max(run);
        } else {
            run = 0;
        }
    }
    best
}

/// What goes on the wire in place of a withheld line: the CLASS, and where the
/// operator can still read the real thing. Never a fragment of the original — a
/// redaction that shows the first eight characters is not a redaction.
fn narration_withheld_line(class: &str) -> String {
    format!(
        "[withheld: {class}] a narration line of class {class} was NOT sent — read it on the \
         box's console or in BOOTLOG.TXT on its ESP"
    )
}

/// **THE ONE DOOR onto the boot log the installer collects.** Every line
/// [`ship_boot_narration`] hands over goes through here: this process's own
/// stamped lines and every kmsg record. Returns what may be kept — the line
/// itself, or the withheld notice naming its class — and whether it was
/// withheld. (Until 2026-09-24 this was the door onto the UDP narration wire;
/// the wire is retired, the door is not: the installer's own filter is the
/// second one, not the only one.)
pub fn narration_door(line: &str) -> (String, bool) {
    match narration_secret_class(line) {
        Some(class) => (narration_withheld_line(class), true),
        None => (line.to_string(), false),
    }
}

/// **Hand the kernel's story to the boot log, now that the network is up.**
///
/// The UDP narration (`korp.log_udp`) is RETIRED (owner 2026-09-24): the
/// installer collects this box's whole boot log over its own mutual TLS
/// (nordisk-pairing's bootstrap listener), and that log begins with the ESP's
/// `BOOTLOG.TXT` — where korp-start already wrote every line it spooled for
/// the wire. So the spool is deleted unread, and what this adds is the one
/// thing no file holds: the last [`KMSG_TAIL`] kernel records, through
/// [`narration_door`], between two stamped lines, each handed to `tap` (the
/// product's boot log: gunnar's `logqueue::tap_line`, holger's
/// `BootLog::push`). Never fatal.
pub fn ship_boot_narration(network_note: &str, tap: &dyn Fn(&str)) {
    let _ = std::fs::remove_file(NARRATION_SPOOL);
    let stamp = |msg: &str| format!("app @{}s {msg}", uptime_secs().unwrap_or_default());
    let keep = |line: &str| tap(&narration_door(line).0);
    let tail = kmsg_tail(KMSG_TAIL);
    keep(&stamp(&format!("{network_note}; the kernel's last {} records follow", tail.len())));
    let mut withheld = 0usize;
    for record in &tail {
        let (line, held) = narration_door(&format!("kmsg {record}"));
        tap(&line);
        withheld += usize::from(held);
    }
    keep(&stamp(&format!("end of hand-off ({} kernel records, {withheld} line(s) withheld by class)", tail.len())));
}

/// Seconds since boot, off `/proc/uptime` — the one clock every phase shares.
fn uptime_secs() -> Option<String> {
    let s = std::fs::read_to_string("/proc/uptime").ok()?;
    Some(s.split_whitespace().next()?.to_string())
}

/// The last `n` records of the kernel log, read non-blocking off `/dev/kmsg`
/// (one `read` per record, `EAGAIN` at the end). Each record is the raw
/// `prio,seq,usec,flags;text` line with its continuation lines dropped. Empty
/// when there is no `/dev/kmsg` (a container) or the kernel has no PRINTK.
fn kmsg_tail(n: usize) -> Vec<String> {
    use std::io::Read as _;
    use std::os::unix::fs::OpenOptionsExt as _;
    let mut out: std::collections::VecDeque<String> =
        std::collections::VecDeque::with_capacity(n + 1);
    let Ok(mut f) = std::fs::OpenOptions::new()
        .read(true)
        .custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32)
        .open("/dev/kmsg")
    else {
        return Vec::new();
    };
    let mut buf = vec![0u8; 8192];
    // A hard ceiling on iterations: the ring can hold a great deal, and this
    // runs on the boot path.
    for _ in 0..8192 {
        match f.read(&mut buf) {
            Ok(0) => break,
            Ok(len) => {
                let text = String::from_utf8_lossy(&buf[..len]);
                let first = text.lines().next().unwrap_or_default().to_string();
                if out.len() == n {
                    out.pop_front();
                }
                out.push_back(first);
            }
            // EPIPE = we fell behind the ring's overwrite; skip on. EAGAIN = the end.
            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => continue,
            Err(_) => break,
        }
    }
    out.into_iter().collect()
}

#[cfg(test)]
mod narration_secret_door {
    use super::*;

    /// **One line of every class, and the property that matters.**
    ///
    /// Not "the string is gone from this run" — that is a coincidence, and a
    /// coincidence is exactly what made the measured run look safe: the kmsg
    /// window happened to start at seq 367, so `Kernel command line:` fell
    /// outside it. The claim here is that a line of each class CANNOT reach the
    /// wire, by either route this process ships on — the relayed korp-start
    /// spool, or the kernel tail — whatever the ring buffer's offsets are.
    const SECRET_LINES: [(&str, &str); 13] = [
        // (1) measured on the estate's wire, relayed out of korp-start's spool.
        ("pubkey", "start +812ms INSTALLER: wg-appliance identity: product=gunnar pubkey=z0jsFOWKQSl1YFMO2mmKS6bNZ4/hg5FHDHdRrh3wzjo="),
        // (2) measured on the same wire: the env-record shape, harmless value.
        ("envrecord", "kmsg 6,368,90210,-;with environment: HOME=/ TERM=linux KORP_INSTALLER_ROOT=/run/installer"),
        // (2') the same shape with the value that would actually have hurt.
        ("cmdline", "kmsg 6,10,1234,-;Kernel command line: console=tty0 korp.args=--monetize-pubkey MCowBQYDK2VwAyEA clock_key=SHA256:xyz"),
        ("envrecord", "kmsg 6,369,90211,-;with environment: MONETIZE_VERIFY_KEY=MCowBQYDK2VwAyEA"),
        ("cmdline", "app @4s read /proc/cmdline"),
        ("cmdline", "app @4s korp.env.GUNNAR_DATA_ROOT applied"),
        ("token", "app @4s GUNNAR_UI_ADMIN_TOKEN loaded"),
        ("token", "app @4s Authorization: Bearer eyJhbGciOiJIUzI1NiJ9"),
        ("key", "start +9ms -----BEGIN OPENSSH PRIVATE KEY-----"),
        ("key", "app @5s ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample operator@box"),
        ("key", "app @5s /etc/gunnar-clock/time_ed25519 loaded"),
        ("env", "app @5s read /etc/monetize/monetize.env"),
        ("highentropy", "app @6s staged MCowBQYDK2VwAyEAaBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789"),
    ];

    /// The other direction. A door that refuses everything is a wall, and this
    /// channel is the only window into a box with no console — it was just made
    /// to work and it has to keep working.
    const BENIGN_LINES: [&str; 9] = [
        "start +12ms INSTALLER: laying the ESP",
        "start +800ms INSTALLER: identity: wg-appliance keypair present and sealed (product=gunnar, 2 PCR bindings)",
        "start +900ms INSTALLER: exec: handing over to gunnar with 3 argument(s)",
        "app @4s lease 10.0.0.7/24 via eth0; shipped 41 spooled korp-start lines; kernel tail follows",
        "kmsg 6,367,90200,-;Run /init as init process",
        "kmsg 6,370,90300,-;EXT4-fs (vda3): mounted filesystem",
        "kmsg 4,120,5000,-;virtio_net virtio0 eth0: renamed from eth0",
        "kmsg 6,400,99000,-;random: crng init done",
        "app @6s end of hand-off (64 kernel records, 0 line(s) withheld by class)",
    ];

    fn round_trip(line: &str) -> (String, bool) {
        narration_door(line)
    }

    /// THE PROPERTY, forwards: no line of any class reaches the wire, and what
    /// arrives names the class and carries no fragment of the original.
    #[test]
    fn no_line_of_any_secret_class_can_reach_the_wire() {
        for (class, secret) in SECRET_LINES {
            assert_eq!(narration_secret_class(secret), Some(class), "classified: {secret:?}");
            let (got, held) = round_trip(secret);
            assert!(held, "withheld: {secret:?}");
            assert!(got.contains(&format!("[withheld: {class}]")), "names the class: {got:?}");
            for word in secret.split_whitespace().filter(|w| w.len() >= 12) {
                assert!(!got.contains(word), "leaked {word:?} in {got:?}");
            }
        }
    }

    /// THE PROPERTY, backwards: ordinary boot progress and ordinary kernel
    /// records still go, byte for byte.
    #[test]
    fn ordinary_progress_and_kernel_records_still_reach_the_wire_unchanged() {
        for benign in BENIGN_LINES {
            assert_eq!(narration_secret_class(benign), None, "must not be withheld: {benign:?}");
            let (got, held) = round_trip(benign);
            assert!(!held, "not withheld: {benign:?}");
            assert_eq!(got, benign, "verbatim");
        }
    }

    /// **The fix must not depend on where the ring buffer starts.** The measured
    /// run was safe by luck: its 64-record window began at seq 367, past the
    /// kernel's `Kernel command line:` at seq ~10. Slide a window of 64 over a
    /// synthetic ring that holds BOTH the cmdline record and the env records, at
    /// every offset, and no window may leak at any of them.
    #[test]
    fn no_window_onto_the_ring_can_leak_whatever_offset_it_starts_at() {
        let mut ring: Vec<String> = Vec::new();
        for seq in 0..400u32 {
            ring.push(match seq {
                10 => "6,10,1234,-;Kernel command line: console=tty0 korp.args=--monetize-pubkey MCowBQYDK2VwAyEA clock_key=SHA256:xyz".to_string(),
                367 => "6,367,90200,-;Run /init as init process".to_string(),
                368 => "6,368,90210,-;with environment: HOME=/ TERM=linux KORP_INSTALLER_ROOT=/run/installer MONETIZE_VERIFY_KEY=MCowBQYDK2VwAyEA".to_string(),
                s => format!("6,{s},{},-;virtio_blk vda: [vda] {s} 512-byte logical blocks", s * 100),
            });
        }
        let poison = ["korp.args", "clock_key=", "MCowBQYDK2VwAyEA", "KORP_INSTALLER_ROOT", "MONETIZE_VERIFY_KEY"];
        let mut windows_that_saw_a_secret = 0usize;
        for start in 0..=(ring.len() - KMSG_TAIL) {
            let mut kept = Vec::new();
            let mut saw = false;
            for record in &ring[start..start + KMSG_TAIL] {
                let line = format!("kmsg {record}");
                if poison.iter().any(|p| line.contains(p)) {
                    saw = true;
                }
                kept.push(narration_door(&line).0);
            }
            if saw {
                windows_that_saw_a_secret += 1;
            }
            for got in &kept {
                for p in poison {
                    assert!(!got.contains(p), "window at {start} leaked {p:?}: {got:?}");
                }
            }
        }
        // And the test must actually have been exercised: some windows DID hold
        // the poisoned records, or this would prove nothing at all.
        assert_eq!(
            windows_that_saw_a_secret, 43,
            "11 windows hold the cmdline record at seq 10, 32 hold the env records at 367/368"
        );
    }

    /// The env-record shape, pinned on its own: caught wherever it sits, and
    /// prose with a spaced `=` is not mistaken for a record.
    #[test]
    fn the_bare_uppercase_assignment_is_a_class_of_its_own() {
        assert_eq!(narration_secret_class("kmsg with environment: KORP_INSTALLER_ROOT=/run/installer"), Some("envrecord"));
        assert_eq!(narration_secret_class("app @4s PCR 7 = 0a1b"), None, "prose, not a record");
        assert_eq!(narration_secret_class("app @4s laid 3 partitions, mode=fresh"), None, "lower-case name");
        assert_eq!(narration_secret_class("kmsg 6,1,0,-;Linux version 6.12.0"), None);
    }

    /// **The narration wire CANNOT carry the SSH host-key fingerprint, and no
    /// spelling of it gets through.** Pinned because it is the reason the
    /// fingerprint went on the startup banner (`serve::host_key_banner_line`,
    /// mirrored to `/dev/tty0`) instead of on this wire.
    ///
    /// The wire itself is available at that moment: `narrate::install` runs at
    /// the top of `serve::run`, the DHCP lease is held and the backlog flushed
    /// before `Server::start` mints or loads the key. What refuses is this
    /// door — and it refuses on the OWNER'S RULE, stated in nordisk-pairing's
    /// redactor (Skidbladnir `crates/installer/nordisk-pairing/src/redact.rs`;
    /// it lived in the ops repo's `bootnarr` until that file was removed there):
    /// "`pubkey` — a PUBLIC verifying key. It discloses nothing; it is withheld
    /// anyway, on the owner's rule, because it is not progress." The collector
    /// at the far end refuses the same shapes with the same words, and
    /// `korp-installer`'s `bootlog` makes three.
    ///
    /// To put a fingerprint on this wire, all three vocabularies would have to
    /// gain the SAME named exemption in the SAME change, and the owner would
    /// have to reverse that rule. Not something a line of narration may decide
    /// quietly — the whole value of these three doors is that they agree.
    #[test]
    fn the_narration_wire_withholds_a_host_key_fingerprint_however_it_is_spelled() {
        // Labelled, as the banner spells it.
        assert_eq!(
            narration_secret_class(
                "STAGE listener +900ms sshkey SHA256:z/fSv0Z0RZS+Lccbc6ZoOWwt/fbj1VFJGSBKQbo4icE"
            ),
            Some("pubkey"),
        );
        // The `SHA256:` label stripped off, which is the obvious way round.
        // The body is 43 base64 characters and the opaque-run rule is 40.
        assert_eq!(
            narration_secret_class("STAGE listener +900ms host key z/fSv0Z0RZS+Lccbc6ZoOWwt/fbj1VFJGSBKQbo4icE"),
            Some("highentropy"),
        );
        // And the words for it are caught too, so "publish the public key" is
        // not a way round either.
        assert_eq!(
            narration_secret_class("STAGE listener +900ms the public key this box serves"),
            Some("pubkey"),
        );
    }
}