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"),
);
}
}