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

nordisk/Skidbladnir

crates/installer/plugin-door-wire/src/attach.rs

//! **Attaching an estate to a CENTRAL monetize** (f-attach-enrol, owner
//! 2026-09-28): an estate laid on ANOTHER cloud — or on nobody's cloud at all —
//! has no monetize of its own; it attaches to the shared one at
//! `monetize.ignalina.dk` with a mutual-TLS client identity and the owner's
//! SSHSIG, through ONE enrolment.
//!
//! ```text
//!   central monetize (operator)          the attaching estate (its owner)
//!   ─────────────────────────            ─────────────────────────────────
//!   Tokens::mint(estate, product) ──────▶ one-shot token, out of band
//!                                         EnrolRequest::new(token, estate, product, box,
//!                                             csr (its own key), endpoint, owner key)
//!                                         .sign(owner's ssh key)   [SSHSIG, NAMESPACE]
//!   POST /attach/enrol  ◀──────────────── the request, over TLS (the central's cert)
//!   verify: shape, clock, SSHSIG, then
//!   Tokens::redeem (CAS: once, this estate, this product, in time)
//!   sign the CSR under the ATTACH CA: CN <estate>/<box>
//!   EnrolReply { cert, attach_ca, monetize_identity, monetize_pubkey,
//!                plugin_door, product } ──▶ the estate keeps its cert (its CLIENT identity
//!                                            toward the central) and trusts the attach CA
//!                                            for the central's calls INTO it (f-grpc-tls)
//! ```
//!
//! Product-neutral and dependency-light on purpose: the SERVER half runs in
//! the central monetize's front (monetize-front), the CLIENT half in any
//! product's kit (gunnar's, holger's). This crate is the one wire both speak.
//!
//! What is verified, in order, and refused by name ([`Refusal`]): the
//! version; the shape of every name (a DNS label for estate/box/product, a
//! `host:port` endpoint); the clock (±[`MAX_SKEW_MS`]); the owner's SSHSIG over
//! the canonical bytes ([`EnrolRequest::signed_bytes`]) under [`NAMESPACE`]
//! (a git commit signature or a gunnar/monetize call credential never verifies
//! here); then the token — once, for THIS estate and product, before it
//! expires. The token is stored as its blake3 only, so the central's file
//! never holds a usable token.

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

/// The sshsig namespace an enrolment is signed under.
pub const NAMESPACE: &str = "monetize-attach@nordisk";
/// The wire version.
pub const VERSION: u32 = 1;
/// How far the request's clock may be from the central's.
pub const MAX_SKEW_MS: i64 = 5 * 60 * 1000;
/// A token's default life.
pub const TOKEN_TTL_MS: i64 = 24 * 3600 * 1000;
/// The path the central serves the enrolment on.
pub const ENROL_PATH: &str = "/attach/enrol";

/// Why an enrolment was refused. Each is its own word, so the estate's kit can
/// say which thing to fix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
    Version(u32),
    Shape(String),
    Clock { skew_ms: i64 },
    Signature(String),
    TokenUnknown,
    TokenUsed,
    TokenExpired,
    TokenOtherEstate { minted_for: String },
    Store(String),
}

impl Refusal {
    /// `[attach-…]`, the clause a refusal is named by.
    pub fn clause(&self) -> &'static str {
        match self {
            Refusal::Version(_) => "attach-version",
            Refusal::Shape(_) => "attach-shape",
            Refusal::Clock { .. } => "attach-clock",
            Refusal::Signature(_) => "attach-signature",
            Refusal::TokenUnknown => "attach-token-unknown",
            Refusal::TokenUsed => "attach-token-used",
            Refusal::TokenExpired => "attach-token-expired",
            Refusal::TokenOtherEstate { .. } => "attach-token-other-estate",
            Refusal::Store(_) => "attach-store",
        }
    }
}

impl std::fmt::Display for Refusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let why = match self {
            Refusal::Version(v) => format!("wire version {v}; this central speaks {VERSION}"),
            Refusal::Shape(s) => s.clone(),
            Refusal::Clock { skew_ms } => format!("the request is {skew_ms} ms from the central's clock; the window is ±{MAX_SKEW_MS} ms"),
            Refusal::Signature(s) => format!("the owner's signature does not verify: {s}"),
            Refusal::TokenUnknown => "no such enrolment token".into(),
            Refusal::TokenUsed => "the enrolment token has already been used; ask the central's operator for a new one".into(),
            Refusal::TokenExpired => "the enrolment token has expired; ask the central's operator for a new one".into(),
            Refusal::TokenOtherEstate { minted_for } => format!("the enrolment token was minted for {minted_for}, not this estate"),
            Refusal::Store(s) => format!("the token store: {s}"),
        };
        write!(f, "REFUSED [{}] {why}", self.clause())
    }
}

/// **What the attaching estate sends.** Everything but `owner_sig` is signed.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EnrolRequest {
    pub v: u32,
    /// The one-shot token the central's operator handed out (`att1.<id>.<secret>`).
    pub token: String,
    /// The estate's name (`acme-gunnar`), a DNS label.
    pub estate: String,
    /// The product it runs (`gunnar`, `holger`).
    pub product: String,
    /// The box that will call the central (`main`).
    pub box_name: String,
    /// Its own key's signing request, PEM — the key never leaves the estate.
    pub csr_pem: String,
    /// Where the central reaches the estate's control plane, `host:port`.
    pub endpoint: String,
    /// The estate owner's OpenSSH public key (`ssh-ed25519 AAAA… comment`).
    pub owner_key: String,
    /// **The CA the estate's control plane serves under** (gunnar's own
    /// issuer), PEM — what the central verifies the estate's gRPC server by
    /// when it dials in (its product block's `tls_ca`). Signed with the rest.
    #[serde(default)]
    pub estate_ca_pem: String,
    pub nonce: String,
    pub at_unix_ms: i64,
    /// The armored SSHSIG over [`EnrolRequest::signed_bytes`].
    #[serde(default)]
    pub owner_sig: String,
}

/// **What the central answers.**
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EnrolReply {
    /// The estate's client certificate (CN `<estate>/<box>`), PEM.
    pub cert_pem: String,
    /// The attach CA: what the central's own client identity is issued by —
    /// the estate trusts it for calls INTO its control plane.
    pub attach_ca_pem: String,
    /// The principal name the central calls the estate as (narrowed: never a
    /// machine admin — f-principal-reach).
    pub monetize_identity: String,
    /// **The central's SSH identity** (OpenSSH public key line) it signs its
    /// calls with: the estate enrols it as `principal.<monetize_identity> =
    /// "machine billing <key>"`.
    #[serde(default)]
    pub monetize_identity_key: String,
    /// Hex of the central's fact-signing Ed25519 public key.
    pub monetize_pubkey: String,
    /// Where the estate's plugin registers (`host:port`).
    pub plugin_door: String,
    pub product: String,
    /// **The estate's own bearer at the central** (ONE monetize, several sites of one
    /// product, owner 2026-10-11): what the estate's console presents to the central's
    /// estate-facing services, so the central knows WHICH site a call comes from (its
    /// Stripe account, its pages). Minted at enrolment, shown ONCE; the central keeps its
    /// blake3 only. Empty from a central that mints none.
    #[serde(default)]
    pub estate_bearer: String,
}

fn label_ok(s: &str) -> bool {
    !s.is_empty() && s.len() <= 63 && !s.starts_with('-') && !s.ends_with('-') && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}

fn endpoint_ok(s: &str) -> bool {
    match s.rsplit_once(':') {
        Some((host, port)) => !host.is_empty() && port.parse::<u16>().map(|p| p > 0).unwrap_or(false) && host.split('.').all(|l| !l.is_empty() && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')),
        None => false,
    }
}

impl EnrolRequest {
    /// **The canonical bytes the owner signs**: the fields in a fixed order,
    /// one per line, each as JSON (so a newline inside a value cannot forge a
    /// line boundary).
    pub fn signed_bytes(&self) -> Vec<u8> {
        let q = |s: &str| serde_json::to_string(s).unwrap_or_default();
        format!(
            "monetize-attach v{}\ntoken {}\nestate {}\nproduct {}\nbox {}\ncsr {}\nendpoint {}\nowner {}\nestate-ca {}\nnonce {}\nat {}\n",
            self.v,
            q(&self.token),
            q(&self.estate),
            q(&self.product),
            q(&self.box_name),
            q(&self.csr_pem),
            q(&self.endpoint),
            q(&self.owner_key),
            q(&self.estate_ca_pem),
            q(&self.nonce),
            self.at_unix_ms
        )
        .into_bytes()
    }

    /// Sign with the owner's key (the kit's side).
    pub fn sign(mut self, owner: &ssh_key::PrivateKey) -> Result<EnrolRequest, String> {
        let sig = owner.sign(NAMESPACE, ssh_key::HashAlg::Sha512, &self.signed_bytes()).map_err(|e| format!("signing the enrolment: {e}"))?;
        self.owner_sig = sig.to_pem(ssh_key::LineEnding::LF).map_err(|e| format!("armoring the signature: {e}"))?;
        Ok(self)
    }

    /// **Everything but the token**, in order (the central's side).
    pub fn verify(&self, now_unix_ms: i64) -> Result<(), Refusal> {
        if self.v != VERSION {
            return Err(Refusal::Version(self.v));
        }
        for (what, v) in [("estate", &self.estate), ("product", &self.product), ("box_name", &self.box_name)] {
            if !label_ok(v) {
                return Err(Refusal::Shape(format!("{what} = {v:?} is not a lower-case DNS label")));
            }
        }
        if !endpoint_ok(&self.endpoint) {
            return Err(Refusal::Shape(format!("endpoint = {:?} is not host:port", self.endpoint)));
        }
        if !self.csr_pem.contains("BEGIN CERTIFICATE REQUEST") {
            return Err(Refusal::Shape("csr_pem is not a PEM certificate signing request".into()));
        }
        if self.nonce.len() < 16 {
            return Err(Refusal::Shape("nonce is shorter than 16 characters".into()));
        }
        let skew = now_unix_ms - self.at_unix_ms;
        if skew.abs() > MAX_SKEW_MS {
            return Err(Refusal::Clock { skew_ms: skew });
        }
        let key = ssh_key::PublicKey::from_openssh(self.owner_key.trim()).map_err(|e| Refusal::Signature(format!("owner_key is not an OpenSSH public key: {e}")))?;
        let sig: ssh_key::SshSig = self.owner_sig.parse().map_err(|e| Refusal::Signature(format!("owner_sig is not an armored SSHSIG: {e}")))?;
        key.verify(NAMESPACE, &self.signed_bytes(), &sig).map_err(|e| Refusal::Signature(e.to_string()))
    }
}

/// The path an enrolled estate's owner tells the central its boxes are laid on.
pub const INSTALLED_PATH: &str = "/attach/installed";
/// The most roles one [`InstalledNotice`] names.
pub const MAX_ROLES: usize = 8;

/// **An attached estate's boxes are laid** (5.4 ONE monetize, the attached twin of a sold
/// estate's install PASS): the enrolled OWNER names the roles whose boxes will call the central
/// over the pull channel, and the central opens each role's key-pin window from the moment it
/// takes the notice — a box pins its own key on its first signed call inside it (TOFU, G-D5),
/// and the first pin closes it. The boxes' keys never leave the boxes.
///
/// Signed under [`NAMESPACE`] with the key the estate ENROLLED with; the central verifies it
/// against the key it recorded at enrolment ([`InstalledNotice::verify`]), never one the notice
/// brings. Its bytes begin `monetize-attach-installed`, so an enrolment's signature is never
/// one of these, nor the other way round.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct InstalledNotice {
    pub v: u32,
    pub estate: String,
    pub product: String,
    /// The boxes that will call (`main`, `twin`), each a DNS label.
    pub roles: Vec<String>,
    pub nonce: String,
    pub at_unix_ms: i64,
    /// **The site's ticket key** (STINKY ST-3, 2026-10-11): the estate's elected primary's
    /// `ticket_pubkey` — the ed25519 PUBLIC key, 64 lower-case hex, its main signs its
    /// accounts' actor tickets with. The central keeps it as that estate's appliance key, so
    /// the estate itself delivers it and nobody hand-fills it. Empty: the notice names none
    /// (sent before the install elected a primary, or by an older kit).
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub ticket_key: String,
    /// **Each box's own fleet key** (STINKY ST-14, 2026-10-12): role → the ed25519 PUBLIC key,
    /// 64 lower-case hex, the box signs its Fleet calls with (its installer offer's
    /// `fleet_pubkey`). The central pins exactly these keys — no first-call-wins window for a
    /// box the owner named with its key. Empty: none named (an older kit): the central opens
    /// the role's pin window as before.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub fleet_keys: std::collections::BTreeMap<String, String>,
    /// The armored SSHSIG over [`InstalledNotice::signed_bytes`].
    #[serde(default)]
    pub owner_sig: String,
}

impl InstalledNotice {
    /// The canonical bytes the owner signs (each value as JSON, one per line).
    pub fn signed_bytes(&self) -> Vec<u8> {
        let q = |s: &str| serde_json::to_string(s).unwrap_or_default();
        let mut text = format!(
            "monetize-attach-installed v{}\nestate {}\nproduct {}\nroles {}\nnonce {}\nat {}\n",
            self.v,
            q(&self.estate),
            q(&self.product),
            serde_json::to_string(&self.roles).unwrap_or_default(),
            q(&self.nonce),
            self.at_unix_ms
        );
        // Signed only when carried: a notice with none signs the bytes it always did.
        if !self.ticket_key.is_empty() {
            text.push_str(&format!("ticket_key {}\n", q(&self.ticket_key)));
        }
        // ST-14: each box's fleet key, in role order; none named, none signed (the old bytes).
        for (role, key) in &self.fleet_keys {
            text.push_str(&format!("fleet {} {}\n", q(role), q(key)));
        }
        text.into_bytes()
    }

    /// Sign with the owner's key (the kit's side).
    pub fn sign(mut self, owner: &ssh_key::PrivateKey) -> Result<InstalledNotice, String> {
        let sig = owner.sign(NAMESPACE, ssh_key::HashAlg::Sha512, &self.signed_bytes()).map_err(|e| format!("signing the notice: {e}"))?;
        self.owner_sig = sig.to_pem(ssh_key::LineEnding::LF).map_err(|e| format!("armoring the signature: {e}"))?;
        Ok(self)
    }

    /// **The central's side**: shape, clock, and the SSHSIG against `enrolled_owner_key` — the
    /// OpenSSH key the central recorded when this estate enrolled.
    pub fn verify(&self, enrolled_owner_key: &str, now_unix_ms: i64) -> Result<(), Refusal> {
        if self.v != VERSION {
            return Err(Refusal::Version(self.v));
        }
        for (what, v) in [("estate", &self.estate), ("product", &self.product)] {
            if !label_ok(v) {
                return Err(Refusal::Shape(format!("{what} = {v:?} is not a lower-case DNS label")));
            }
        }
        if self.roles.is_empty() || self.roles.len() > MAX_ROLES {
            return Err(Refusal::Shape(format!("roles names {} box(es); a notice names 1 to {MAX_ROLES}", self.roles.len())));
        }
        if let Some(r) = self.roles.iter().find(|r| !label_ok(r)) {
            return Err(Refusal::Shape(format!("role {r:?} is not a lower-case DNS label")));
        }
        if self.nonce.len() < 16 {
            return Err(Refusal::Shape("nonce is shorter than 16 characters".into()));
        }
        if !self.ticket_key.is_empty() && !ticket_key_ok(&self.ticket_key) {
            return Err(Refusal::Shape("ticket_key is not an ed25519 public key (64 lower-case hex, the primary's `ticket_pubkey`)".into()));
        }
        for (role, k) in &self.fleet_keys {
            if !self.roles.contains(role) {
                return Err(Refusal::Shape(format!("fleet key for {role:?}, a box the notice does not name")));
            }
            if !ticket_key_ok(k) {
                return Err(Refusal::Shape(format!("the fleet key of {role:?} is not an ed25519 public key (64 lower-case hex, the box's `fleet_pubkey`)")));
            }
        }
        let skew = now_unix_ms - self.at_unix_ms;
        if skew.abs() > MAX_SKEW_MS {
            return Err(Refusal::Clock { skew_ms: skew });
        }
        let key = ssh_key::PublicKey::from_openssh(enrolled_owner_key.trim()).map_err(|e| Refusal::Signature(format!("the enrolled owner key is not an OpenSSH public key: {e}")))?;
        let sig: ssh_key::SshSig = self.owner_sig.parse().map_err(|e| Refusal::Signature(format!("owner_sig is not an armored SSHSIG: {e}")))?;
        key.verify(NAMESPACE, &self.signed_bytes(), &sig).map_err(|e| Refusal::Signature(e.to_string()))
    }
}

/// A site's ticket key as the notice carries it: 64 lower-case hex (an ed25519 public key).
pub fn ticket_key_ok(k: &str) -> bool {
    k.len() == 64 && k.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}

/// One minted token, as the central stores it: the blake3 of its secret.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TokenRow {
    pub secret_blake3: String,
    pub estate: String,
    pub product: String,
    pub expires_unix_ms: i64,
    pub used_unix_ms: Option<i64>,
}

/// **The central's one-shot tokens**, a JSON file written whole and renamed
/// into place (a crash leaves the old file or the new one, never half).
pub struct Tokens {
    path: PathBuf,
    lock: std::sync::Mutex<()>,
}

impl Tokens {
    pub fn at(path: impl Into<PathBuf>) -> Tokens {
        Tokens { path: path.into(), lock: std::sync::Mutex::new(()) }
    }

    fn load(&self) -> Result<BTreeMap<String, TokenRow>, Refusal> {
        match std::fs::read(&self.path) {
            Ok(b) => serde_json::from_slice(&b).map_err(|e| Refusal::Store(format!("{} will not parse: {e}", self.path.display()))),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
            Err(e) => Err(Refusal::Store(format!("{}: {e}", self.path.display()))),
        }
    }

    fn store(&self, rows: &BTreeMap<String, TokenRow>) -> Result<(), Refusal> {
        let dir = self.path.parent().unwrap_or(Path::new("."));
        std::fs::create_dir_all(dir).map_err(|e| Refusal::Store(e.to_string()))?;
        let tmp = self.path.with_extension("tmp");
        let bytes = serde_json::to_vec_pretty(rows).map_err(|e| Refusal::Store(e.to_string()))?;
        {
            use std::io::Write as _;
            let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).open(&tmp).map_err(|e| Refusal::Store(e.to_string()))?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt as _;
                let _ = f.set_permissions(std::fs::Permissions::from_mode(0o600));
            }
            f.write_all(&bytes).and_then(|_| f.sync_all()).map_err(|e| Refusal::Store(e.to_string()))?;
        }
        std::fs::rename(&tmp, &self.path).map_err(|e| Refusal::Store(e.to_string()))
    }

    /// **Mint a token** for `estate` running `product`, valid `ttl_ms`. The
    /// answer is the only copy of the secret.
    pub fn mint(&self, estate: &str, product: &str, now_unix_ms: i64, ttl_ms: i64, entropy: [u8; 32]) -> Result<String, Refusal> {
        if !label_ok(estate) || !label_ok(product) {
            return Err(Refusal::Shape(format!("{estate:?}/{product:?}: lower-case DNS labels only")));
        }
        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
        let mut rows = self.load()?;
        let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
        let id = hex(&entropy[..8]);
        let secret = hex(&entropy[8..]);
        rows.insert(
            id.clone(),
            TokenRow { secret_blake3: blake3::hash(secret.as_bytes()).to_hex().to_string(), estate: estate.into(), product: product.into(), expires_unix_ms: now_unix_ms + ttl_ms, used_unix_ms: None },
        );
        self.store(&rows)?;
        Ok(format!("att1.{id}.{secret}"))
    }

    /// **Redeem**: once, for this estate and product, in time. The row is
    /// marked used in the same locked read-modify-write, so two enrolments
    /// racing on one token cannot both pass.
    pub fn redeem(&self, token: &str, estate: &str, product: &str, now_unix_ms: i64) -> Result<(), Refusal> {
        let mut parts = token.trim().splitn(3, '.');
        let (Some("att1"), Some(id), Some(secret)) = (parts.next(), parts.next(), parts.next()) else { return Err(Refusal::TokenUnknown) };
        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
        let mut rows = self.load()?;
        let row = rows.get_mut(id).ok_or(Refusal::TokenUnknown)?;
        let given = blake3::hash(secret.as_bytes()).to_hex().to_string();
        // Constant-time: blake3's Hash equality is.
        if blake3::Hash::from_hex(&row.secret_blake3).ok() != blake3::Hash::from_hex(&given).ok() {
            return Err(Refusal::TokenUnknown);
        }
        if row.used_unix_ms.is_some() {
            return Err(Refusal::TokenUsed);
        }
        if now_unix_ms > row.expires_unix_ms {
            return Err(Refusal::TokenExpired);
        }
        if row.estate != estate || row.product != product {
            return Err(Refusal::TokenOtherEstate { minted_for: format!("{}/{}", row.estate, row.product) });
        }
        row.used_unix_ms = Some(now_unix_ms);
        self.store(&rows)
    }
}

/// **The whole central side, minus the network**: verify, redeem, sign.
/// `sign_csr(csr_pem, cn)` is the attach CA's (nordisk-pairing
/// `InstallerCa::sign_attached`).
pub fn enrol(
    req: &EnrolRequest,
    tokens: &Tokens,
    now_unix_ms: i64,
    sign_csr: impl FnOnce(&str, &str) -> Result<String, String>,
    reply: impl FnOnce(String) -> EnrolReply,
) -> Result<EnrolReply, Refusal> {
    req.verify(now_unix_ms)?;
    tokens.redeem(&req.token, &req.estate, &req.product, now_unix_ms)?;
    let cn = format!("{}/{}", req.estate, req.box_name);
    let cert = sign_csr(&req.csr_pem, &cn).map_err(Refusal::Shape)?;
    Ok(reply(cert))
}