nordisk-pairing 0.5.29

The boxes of an estate find each other: a seed each box is installed with, a one-time join against the front, a tunnel (WireGuard by default) they meet inside, roles decided once, a pairing loop each box runs itself, and the signal it reports. Shared by gunnar and holger; the laptop's xtask only lays machines, writes seeds and reads the signal. Design: .nornir/pairing-design.md (2026-09-24).
Documentation
//! **A box keeps the certificate its installer renews** (Holger's lane E2 on Gunnar's
//! lifecycle pattern; shared here since P4c, A28 M11 — Gunnar's box writes the same two
//! steps inline in its own `Vm`, T16).
//!
//! The shared box half ([`super::Installed`], and the front's `frontside::Front`) keeps the
//! certificate the installer signed at the join and nothing after it: asked to renew, it
//! answers `REFUSED [renew-not-kept]`. [`Renewing`] is the box as the installer sees it, with
//! the two renewal steps that half leaves out:
//!
//! * **`renewed`** — the installer re-signed this box's OWN key for a new lifetime
//!   (`installer::renew`, at two thirds of the old one's life). The door already checked it
//!   (`mtls::renewal_refusal`: the same key, the same name, an end no earlier). It is written
//!   atomically over the old one, and the listener shows it from the next connection. Until
//!   that write the old certificate is served and stays valid.
//! * **`rebootstrapped`** — the fresh bootstrap pair handed with each renewal is kept in the
//!   seed on disk, so a box booted again from its medium joins after the medium's own
//!   bootstrap has ended. A seed of another estate is refused by name.
//!
//! Everything else is the wrapped box, unchanged. No I/O but the box's own files.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::bootstrap::{SecretOffer, Vm};
use crate::join::{JoinReply, JoinRequest};
use crate::mtls::Issued;
use crate::seed::Seed;
use crate::signal::Signal;

/// **The box, keeping what its installer renews.** `seed_rel` is where the box's
/// seed lives under `root` (`pairing/seed.toml` on both Holger media).
pub struct Renewing<V: Vm + ?Sized> {
    inner: Arc<V>,
    root: PathBuf,
    seed_rel: &'static str,
    estate: String,
    /// What the product adds to the pair status the installer reads (lane D10: a box's
    /// server start, the front's certify attempts). `None`: the wrapped box's status.
    status_of: Option<StatusOf>,
}

/// The product's hand on the pair status: the wrapped box's signal in, the one said out.
pub type StatusOf = Arc<dyn Fn(Signal) -> Signal + Send + Sync>;

impl<V: Vm + ?Sized> Renewing<V> {
    pub fn new(inner: Arc<V>, root: &Path, seed_rel: &'static str, seed: &Seed) -> Renewing<V> {
        Renewing { inner, root: root.to_path_buf(), seed_rel, estate: seed.estate.clone(), status_of: None }
    }

    /// The pair status through `f` (lane D10).
    pub fn with_status(mut self, f: StatusOf) -> Renewing<V> {
        self.status_of = Some(f);
        self
    }
}

/// Pure: **the seed on disk with the fresh bootstrap pair** — only the pair changes.
/// A seed of another estate is refused by name (it is not this box's).
pub fn rebootstrapped_seed(on_disk: &str, estate: &str, boot: &Issued) -> Result<String, String> {
    let mut seed = Seed::parse(on_disk).map_err(|e| format!("REFUSED [rebootstrap-seed] the seed on disk: {e}"))?;
    if seed.estate != estate {
        return Err(format!("REFUSED [rebootstrap-seed] the seed on disk is of `{}`, this box is of `{estate}`", seed.estate));
    }
    seed.bootstrap_cert_pem = boot.cert_pem.clone();
    seed.bootstrap_key_pem = boot.key_pem.clone();
    seed.check().map_err(|e| format!("REFUSED [rebootstrap-seed] the seed with the fresh bootstrap: {e}"))?;
    seed.render().map_err(|e| format!("REFUSED [rebootstrap-seed] the seed with the fresh bootstrap: {e}"))
}

impl<V: Vm + ?Sized> Vm for Renewing<V> {
    fn hello(&self) -> Result<JoinRequest, String> {
        self.inner.hello()
    }
    fn fed(&self, reply: &JoinReply) -> Result<(), String> {
        self.inner.fed(reply)
    }
    fn status(&self) -> Signal {
        let s = self.inner.status();
        match &self.status_of {
            Some(f) => f(s),
            None => s,
        }
    }
    fn close(&self) -> Result<(), String> {
        self.inner.close()
    }
    fn identity(&self) -> Issued {
        self.inner.identity()
    }
    fn offer(&self) -> BTreeMap<String, String> {
        self.inner.offer()
    }
    fn secret_offer(&self) -> SecretOffer {
        self.inner.secret_offer()
    }
    fn refed(&self, reply: &JoinReply) -> Result<(), String> {
        self.inner.refed(reply)
    }
    fn live_running(&self) -> Result<crate::live::Running, String> {
        self.inner.live_running()
    }
    fn live_stage(&self, stage: &crate::live::Stage) -> Result<String, String> {
        self.inner.live_stage(stage)
    }
    fn live_stage_declared(&self, stage: &crate::live::Stage, declared: &crate::live::Declared) -> Result<String, String> {
        self.inner.live_stage_declared(stage, declared)
    }

    /// The renewed certificate over the old one, atomically: the listener reads it
    /// from the next connection. A box that holds no certificate of its own yet (it
    /// never joined) has nothing to renew.
    fn renewed(&self, cert_pem: &str) -> Result<(), String> {
        let own = self.root.join(crate::boxside::OWN_CERT_REL);
        if !std::fs::read_to_string(&own).is_ok_and(|c| c.contains("BEGIN CERTIFICATE")) {
            return Err("REFUSED [renew-not-joined] this box holds no certificate of its own yet; it is renewed after its join".into());
        }
        crate::boxside::write_atomic(&self.root, crate::boxside::OWN_CERT_REL, cert_pem.as_bytes(), true).map_err(|e| format!("{}: {e}", own.display()))?;
        eprintln!("pairing: this box's own certificate was RENEWED (the same key, a new lifetime)");
        Ok(())
    }

    /// The fresh bootstrap pair into the seed on disk (`bootstrap_refusal` already
    /// found a bootstrap certificate with its own key).
    fn rebootstrapped(&self, boot: &Issued) -> Result<(), String> {
        let path = self.root.join(self.seed_rel);
        let on_disk = std::fs::read_to_string(&path).map_err(|e| format!("REFUSED [rebootstrap-seed] {}: {e}", path.display()))?;
        let rendered = rebootstrapped_seed(&on_disk, &self.estate, boot)?;
        crate::boxside::write_atomic(&self.root, self.seed_rel, rendered.as_bytes(), true).map_err(|e| format!("{}: {e}", path.display()))?;
        eprintln!("pairing: this box keeps a FRESH bootstrap in its seed for its next join");
        Ok(())
    }
}

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

    fn seed(estate: &str, ca: &crate::mtls::InstallerCa) -> Seed {
        let boot = ca.issue_bootstrap(std::time::Duration::from_secs(3600)).unwrap();
        Seed {
            estate: estate.into(),
            tunnel: crate::seed::TunnelKind::Wireguard,
            front: Default::default(),
            kind: crate::seed::Kind::Holger,
            installer_ca_pem: ca.cert_pem.clone(),
            bootstrap_cert_pem: boot.cert_pem,
            bootstrap_key_pem: boot.key_pem,
            peers_expected: 3,
            mullvad: None,
            tailscale: None,
            installer_link: Some(crate::seed::LinkSeed { installer_pubkey: "A".repeat(43) + "=", vm_private: "B".repeat(43) + "=", port: crate::installer_link::LINK_PORT, psk: None }),
        }
    }

    /// ★ Only the bootstrap pair changes; a seed of another estate is refused by name.
    #[test]
    fn the_seed_keeps_everything_but_its_fresh_bootstrap_and_refuses_another_estate() {
        let ca = crate::mtls::InstallerCa::mint("acme.mock.invalid", std::time::Duration::from_secs(86_400)).unwrap();
        let on_disk = seed("acme.mock.invalid", &ca).render().unwrap();
        let fresh = ca.issue_bootstrap(std::time::Duration::from_secs(7200)).unwrap();
        let after = Seed::parse(&rebootstrapped_seed(&on_disk, "acme.mock.invalid", &fresh).unwrap()).unwrap();
        assert_eq!((after.bootstrap_cert_pem.as_str(), after.bootstrap_key_pem.as_str()), (fresh.cert_pem.as_str(), fresh.key_pem.as_str()));
        assert_eq!((after.estate.as_str(), after.installer_ca_pem.as_str(), after.peers_expected), ("acme.mock.invalid", ca.cert_pem.as_str(), 3));
        let other = rebootstrapped_seed(&on_disk, "other.mock.invalid", &fresh).unwrap_err();
        assert!(other.starts_with("REFUSED [rebootstrap-seed]") && other.contains("acme.mock.invalid"), "{other}");
        assert!(rebootstrapped_seed("not a seed", "acme.mock.invalid", &fresh).unwrap_err().starts_with("REFUSED [rebootstrap-seed]"));
    }
}