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

nordisk/monetize

crates/monetize-front/src/attach.rs

//! **The ONE monetize's side of an estate joining it** (f-attach-enrol; 5.4
//! G7 under the pull model, OWNER 13:35Z: ONE monetize at
//! monetize.ignalina.dk, every estate calls IN, monetize never dials one).
//!
//! * [`mint_token`] — the operator's (the door's `POST /attach/token`, operator
//!   certificate only): a one-shot token for ONE estate and product.
//! * [`routes`] / [`enrol_answer`] — `POST /attach/enrol`, public (the ONE
//!   box's edge): the estate's [`EnrolRequest`] verified, its token redeemed,
//!   its CSR signed under the attach CA, answered with the [`EnrolReply`]. The
//!   wire and its checks are Skidbladnir `plugin-door-wire::attach`; this is
//!   only where they are served.
//! * The estate is recorded at [`ESTATES_REL`]`/<estate>.json` — where
//!   monetize-server's guardian takes it (`fleet::take_joined`): the estate is
//!   DECLARED under its product (an estate is a row on the product's account,
//!   never a product block of its own) and the box that enrolled may pin its
//!   fleet key on its first signed call (TOFU inside the pin window counted
//!   from the enrolment). The request's `endpoint` and `estate_ca_pem` are
//!   kept for the operator's eyes only: nothing dials them.
//!
//! The CA and the token store live under the data root, 0600, minted once.

use std::path::PathBuf;
use std::time::Duration;

use axum::http::StatusCode;
use axum::routing::post;
use axum::extract::State;
use axum::{Json, Router};
use nordisk_pairing::mtls::InstallerCa;
use plugin_door_wire::attach::{self, EnrolReply, EnrolRequest, InstalledNotice, Refusal, Tokens};

/// **What the operator asks for**: `POST /attach/token` on the ONE box's door
/// (f-attach-enrol) — a one-shot token for ONE estate of ONE product.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AttachTokenAsk {
    pub estate: String,
    pub product: String,
    /// How long it may wait to be used; 0 = a day.
    #[serde(default)]
    pub ttl_secs: u64,
}

/// Where the attach CA is kept (cert, then key beside it).
pub const ATTACH_CA_REL: &str = "monetize/attach/ca.pem";
pub const ATTACH_CA_KEY_REL: &str = "monetize/attach/ca.key";
pub const TOKENS_REL: &str = "monetize/attach/tokens.json";
/// One record per estate that joined: `<estate>.json` — monetize-server's
/// `<data>/`[`monetize_server::fleet::JOINED_REL`], where its guardian takes it.
pub const ESTATES_REL: &str = "monetize/data/joined";

/// **An estate that joined, as recorded** at enrolment: the guardian reads
/// `estate`, `product`, `box_name` and `enrolled_unix_ms`; `endpoint` and
/// `estate_ca_pem` are informational (monetize never dials an estate).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Enrolled {
    pub estate: String,
    pub product: String,
    pub box_name: String,
    pub endpoint: String,
    pub estate_ca_pem: String,
    pub owner_key: String,
    pub enrolled_unix_ms: i64,
    /// **The boxes the enrolled owner said will call** ([`InstalledNotice`], after the
    /// estate's install): the guardian opens each role's pin window from
    /// [`Enrolled::installed_unix_ms`]. Empty until the owner says so.
    #[serde(default)]
    pub roles: Vec<String>,
    /// When the central took the owner's newest [`InstalledNotice`]; 0 = never.
    #[serde(default)]
    pub installed_unix_ms: i64,
    /// **The digest of the estate's own bearer** (monetize-server `fleet::bearer_digest`),
    /// minted at enrolment and shown once in the reply: what names the SITE an
    /// estate-facing call comes from. The bearer itself is never kept here.
    #[serde(default)]
    pub bearer_sha256: String,
    /// **Until when the bearer opens the estate-facing doors** (STINKY ST-13): set at
    /// enrolment ([`monetize_server::fleet::BEARER_LIFE_MS`] on); 0 from an older front (the
    /// guardian then counts the life from the enrolment). Rotation is a re-enrolment.
    #[serde(default)]
    pub bearer_until_unix_ms: u64,
    /// When the operator revoked the bearer ([`revoke`]); 0: never.
    #[serde(default)]
    pub bearer_revoked_unix_ms: u64,
    /// **The site's ticket key, delivered by the estate** (STINKY ST-3): its elected primary's
    /// `ticket_pubkey` (64 hex), carried by the owner's newest [`InstalledNotice`] that named
    /// one. The guardian hands it to the registry as `[estates.<estate>] appliance_key` unless
    /// an operator pinned another there. Empty: none delivered yet.
    #[serde(default)]
    pub appliance_key: String,
    /// **Whether the owner's newest notice carried the ticket key** (audit2 PIN-2): the kit's
    /// SECOND notice of one install (after the pairing elected the primary) — it lays no box,
    /// so the guardian re-opens no pinned role's window for it. A notice without one is an
    /// install or a recharge laying boxes.
    #[serde(default)]
    pub installed_with_ticket_key: bool,
    /// **Each box's own fleet key, as the owner's newest notice named it** (STINKY ST-14):
    /// role → 64 hex. The guardian pins exactly these (no window for them). Replaced by every
    /// notice: one without keys (an install laying new boxes) clears them.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub fleet_keys: std::collections::BTreeMap<String, String>,
}

/// The record of `estate`, when it enrolled.
pub fn enrolled(c: &Central, estate: &str) -> anyhow::Result<Option<Enrolled>> {
    match std::fs::read(c.root.join(ESTATES_REL).join(format!("{estate}.json"))) {
        Ok(b) => Ok(Some(serde_json::from_slice(&b)?)),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e.into()),
    }
}

/// **Revoke `estate`'s bearer** (STINKY ST-13; the operator, at the door's
/// `POST /attach/revoke`): its digest leaves the record and the revocation is stamped, so the
/// guardian's next take (two seconds) refuses it everywhere — no restart. A new bearer is a
/// new enrolment (a new one-shot token). An estate that never enrolled is refused by name.
pub fn revoke(c: &Central, estate: &str) -> anyhow::Result<String> {
    if estate.is_empty() || !estate.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.') || estate.starts_with('.') {
        anyhow::bail!("REFUSED [attach-revoke-estate] {estate:?} is not an estate's label");
    }
    let Some(mut rec) = enrolled(c, estate)? else {
        anyhow::bail!("REFUSED [attach-not-enrolled] {estate} never enrolled here: there is no bearer to revoke");
    };
    if rec.bearer_revoked_unix_ms > 0 && rec.bearer_sha256.is_empty() {
        return Ok(format!("{estate}'s bearer was already revoked at {}", rec.bearer_revoked_unix_ms));
    }
    rec.bearer_sha256 = String::new();
    rec.bearer_until_unix_ms = 0;
    rec.bearer_revoked_unix_ms = now_ms().max(1) as u64;
    let text = serde_json::to_string_pretty(&rec)?;
    crate::write_secret(&c.root, &format!("{ESTATES_REL}/{estate}.json"), &text)?;
    Ok(format!("{estate}'s bearer is revoked: it opens nothing from the guardian's next take; a new enrolment token gives the estate a new one"))
}

/// How long an attached estate's certificate, and the central's identity, live.
pub const VALID_FOR: Duration = Duration::from_secs(365 * 24 * 3600);

/// The central, as this module needs it.
#[derive(Clone, Debug)]
pub struct Central {
    /// The data root (the front's).
    pub root: PathBuf,
    /// Where the estate calls monetize, `host:port` (the ONE box's edge).
    pub plugin_door: String,
}

fn read(c: &Central, rel: &str) -> Option<String> {
    std::fs::read_to_string(c.root.join(rel)).ok()
}

/// **The attach CA**, minted once and kept.
pub fn attach_ca(c: &Central) -> anyhow::Result<InstallerCa> {
    if let (Some(cert_pem), Some(key_pem)) = (read(c, ATTACH_CA_REL), read(c, ATTACH_CA_KEY_REL)) {
        return Ok(InstallerCa { cert_pem, key_pem });
    }
    let ca = InstallerCa::mint("monetize attach", Duration::from_secs(10 * 365 * 24 * 3600)).map_err(|e| anyhow::anyhow!("{e}"))?;
    crate::write_secret(&c.root, ATTACH_CA_KEY_REL, &ca.key_pem)?;
    crate::write_secret(&c.root, ATTACH_CA_REL, &ca.cert_pem)?;
    Ok(ca)
}

fn tokens(c: &Central) -> Tokens {
    Tokens::at(c.root.join(TOKENS_REL))
}

fn now_ms() -> i64 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
}

/// **Mint a one-shot enrolment token** (the operator's).
pub fn mint_token(c: &Central, estate: &str, product: &str, ttl: Duration) -> Result<String, Refusal> {
    let mut entropy = [0u8; 32];
    rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut entropy);
    tokens(c).mint(estate, product, now_ms(), ttl.as_millis() as i64, entropy)
}

/// The key `product`'s facts are signed with, hex: derived from the central's
/// root (minted once), one key per product (G8) — an estate trusts its own
/// product's facts and no other's. A product pinned to an older key
/// (`[products.<id>] signing_key`) is not looked up here: such an estate was
/// installed holding its key and does not enrol.
fn monetize_pubkey(c: &Central, product: &str) -> anyhow::Result<String> {
    let root = crate::intake::signer(&c.root)?;
    Ok(monetize::Keyring::derive(&root, product).public_key().iter().map(|b| format!("{b:02x}")).collect())
}

async fn post_enrol(State(c): State<Central>, body: axum::body::Bytes) -> (StatusCode, Json<serde_json::Value>) {
    enrol_answer(c, body).await
}

/// **The most an attach door reads** (P5-SEC): an enrolment is a CSR, two keys and a few
/// names, an install notice a few roles and keys, a few KB each. Anything over this is refused
/// 413 (`attach-too-large`) before it is parsed, on the edge and the door alike; axum's own
/// 2 MiB default is the outer bound, this the door's own.
pub const ATTACH_BODY_MAX: usize = 64 * 1024;

/// The [`ATTACH_BODY_MAX`] refusal, or `None` for a body within it.
fn too_large(body: &[u8]) -> Option<(StatusCode, Json<serde_json::Value>)> {
    (body.len() > ATTACH_BODY_MAX).then(|| {
        tracing::warn!(bytes = body.len(), "attach: REFUSED [attach-too-large] a body over the door's cap");
        (
            StatusCode::PAYLOAD_TOO_LARGE,
            Json(serde_json::json!({ "refused": "attach-too-large", "why": format!("{} bytes; an attach door reads at most {ATTACH_BODY_MAX}", body.len()) })),
        )
    })
}

/// **One enrolment, answered** — `POST /attach/enrol`'s whole handler, for a
/// door that mounts it behind its own gate (gunnar-front's plugin door, the
/// operator's certificate only: f-attach-enrol, release-3 2026-09-29 — no box
/// served this route before). The request is verified, its token redeemed, its
/// CSR signed under the attach CA and the estate recorded; the answer is the
/// [`EnrolReply`] or `{refused, why}` with the refusal's clause.
pub async fn enrol_answer(c: Central, body: axum::body::Bytes) -> (StatusCode, Json<serde_json::Value>) {
    if let Some(refused) = too_large(&body) {
        return refused;
    }
    let req: EnrolRequest = match serde_json::from_slice(&body) {
        Ok(r) => r,
        Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "refused": "attach-not-a-request", "why": e.to_string() }))),
    };
    // ST-14: the log names the ESTATE that enrolled (never the product as the estate).
    let estate = req.estate.clone();
    let out = tokio::task::spawn_blocking(move || -> Result<EnrolReply, (StatusCode, String, String)> {
        let internal = |e: anyhow::Error| (StatusCode::INTERNAL_SERVER_ERROR, "attach-central".to_string(), e.to_string());
        let ca = attach_ca(&c).map_err(internal)?;
        let pubkey = monetize_pubkey(&c, &req.product).map_err(internal)?;
        // The estate's OWN bearer: shown once in the reply, kept here as its digest only.
        let mut raw = [0u8; 32];
        rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut raw);
        let bearer: String = raw.iter().map(|b| format!("{b:02x}")).collect();
        let record = Enrolled { estate: req.estate.clone(), product: req.product.clone(), box_name: req.box_name.clone(), endpoint: req.endpoint.clone(), estate_ca_pem: req.estate_ca_pem.clone(), owner_key: req.owner_key.clone(), enrolled_unix_ms: now_ms(), roles: Vec::new(), installed_unix_ms: 0, bearer_sha256: monetize_server::fleet::bearer_digest(&bearer), bearer_until_unix_ms: (now_ms().max(0) as u64).saturating_add(monetize_server::fleet::BEARER_LIFE_MS), bearer_revoked_unix_ms: 0, appliance_key: String::new(), installed_with_ticket_key: false, fleet_keys: Default::default() };
        let host = req.endpoint.rsplit_once(':').map(|(h, _)| h.to_string()).unwrap_or_default();
        attach::enrol(
            &req,
            &tokens(&c),
            now_ms(),
            |csr, cn| ca.sign_attached(csr, cn, &host, VALID_FOR).map_err(|e| e.to_string()),
            |cert_pem| EnrolReply { cert_pem, attach_ca_pem: ca.cert_pem.clone(), // ST-15 (no legacy): the dial-in model's SSH identity is gone; the wire's two
            // fields (plugin-door-wire) stay empty until the wire drops them.
            monetize_identity: String::new(), monetize_identity_key: String::new(), monetize_pubkey: pubkey, plugin_door: c.plugin_door.clone(), product: req.product.clone(), estate_bearer: bearer.clone() },
        )
        .and_then(|reply| {
            // Recorded AFTER the token is spent and the certificate signed:
            // where the guardian takes it (declared, its box pins on first call).
            let text = serde_json::to_string_pretty(&record).map_err(|e| Refusal::Store(e.to_string()))?;
            crate::write_secret(&c.root, &format!("{ESTATES_REL}/{}.json", record.estate), &text).map_err(|e| Refusal::Store(e.to_string()))?;
            Ok(reply)
        })
        .map_err(|r| {
            let code = match r {
                Refusal::Shape(_) | Refusal::Version(_) => StatusCode::BAD_REQUEST,
                Refusal::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
                _ => StatusCode::FORBIDDEN,
            };
            (code, r.clause().to_string(), r.to_string())
        })
    })
    .await;
    match out {
        Ok(Ok(reply)) => {
            tracing::info!(estate = %estate, product = %reply.product, "attach: an estate enrolled (its earlier bearer, if any, is replaced)");
            (StatusCode::OK, Json(serde_json::to_value(&reply).unwrap_or_default()))
        }
        Ok(Err((code, clause, why))) => {
            tracing::warn!(estate = %estate, clause = %clause, "attach: enrolment REFUSED — {why}");
            (code, Json(serde_json::json!({ "refused": clause, "why": why })))
        }
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "refused": "attach-central", "why": e.to_string() }))),
    }
}

async fn post_installed(State(c): State<Central>, body: axum::body::Bytes) -> (StatusCode, Json<serde_json::Value>) {
    installed_answer(c, body).await
}

/// **An attached estate's boxes are laid** — `POST /attach/installed`'s whole handler (the
/// [`InstalledNotice`]): verified against the OWNER KEY recorded at enrolment (never one the
/// notice brings), newer than the last one taken, then the record names the roles and the
/// moment, where monetize-server's guardian opens each role's pin window from it.
pub async fn installed_answer(c: Central, body: axum::body::Bytes) -> (StatusCode, Json<serde_json::Value>) {
    if let Some(refused) = too_large(&body) {
        return refused;
    }
    let notice: InstalledNotice = match serde_json::from_slice(&body) {
        Ok(n) => n,
        Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "refused": "attach-not-a-notice", "why": e.to_string() }))),
    };
    let out = tokio::task::spawn_blocking(move || -> Result<Enrolled, (StatusCode, String, String)> {
        let forbidden = |clause: &str, why: String| (StatusCode::FORBIDDEN, clause.to_string(), why);
        // The estate's name is checked before it names a file.
        notice.verify("", now_ms()).or_else(|r| if matches!(r, Refusal::Signature(_)) { Ok(()) } else { Err(r) }).map_err(|r| (StatusCode::BAD_REQUEST, r.clause().to_string(), r.to_string()))?;
        let mut rec = enrolled(&c, &notice.estate)
            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, "attach-central".to_string(), e.to_string()))?
            .ok_or_else(|| forbidden("attach-not-enrolled", format!("{} never enrolled here: only an enrolled estate's owner names its boxes", notice.estate)))?;
        if rec.product != notice.product {
            return Err(forbidden("attach-other-product", format!("{} enrolled as a {:?} estate, not {:?}", notice.estate, rec.product, notice.product)));
        }
        notice.verify(&rec.owner_key, now_ms()).map_err(|r| forbidden(r.clause(), r.to_string()))?;
        if notice.at_unix_ms <= rec.installed_unix_ms {
            return Err(forbidden("attach-replayed", format!("a notice of {} is no newer than the one taken at {}", notice.at_unix_ms, rec.installed_unix_ms)));
        }
        rec.roles = notice.roles.clone();
        rec.installed_unix_ms = now_ms().max(notice.at_unix_ms);
        rec.installed_with_ticket_key = !notice.ticket_key.trim().is_empty();
        // ST-14: each box's own fleet key as the owner named it; a notice naming none (an
        // install laying new boxes) leaves no stale key behind.
        rec.fleet_keys = notice.fleet_keys.clone();
        // ST-3: the site's ticket key, delivered by its owner; a notice with none keeps it.
        if !notice.ticket_key.is_empty() {
            rec.appliance_key = notice.ticket_key.clone();
        }
        let text = serde_json::to_string_pretty(&rec).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, "attach-store".to_string(), e.to_string()))?;
        crate::write_secret(&c.root, &format!("{ESTATES_REL}/{}.json", rec.estate), &text).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, "attach-store".to_string(), e.to_string()))?;
        Ok(rec)
    })
    .await;
    match out {
        Ok(Ok(rec)) => {
            tracing::info!(estate = %rec.estate, roles = ?rec.roles, ticket_key = !rec.appliance_key.is_empty(), "attach: the owner named the boxes that will call; each may pin its key on its first call");
            (StatusCode::OK, Json(serde_json::json!({ "estate": rec.estate, "roles": rec.roles, "installed_unix_ms": rec.installed_unix_ms, "ticket_key": rec.appliance_key })))
        }
        Ok(Err((code, clause, why))) => {
            tracing::warn!(clause = %clause, "attach: install notice REFUSED — {why}");
            (code, Json(serde_json::json!({ "refused": clause, "why": why })))
        }
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "refused": "attach-central", "why": e.to_string() }))),
    }
}

/// `POST /attach/enrol` and `POST /attach/installed` — public; the front serves them on monetize's host.
pub fn routes(c: Central) -> Router {
    Router::new().route(attach::ENROL_PATH, post(post_enrol)).route(attach::INSTALLED_PATH, post(post_installed)).with_state(c)
}

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

    #[test]
    fn too_large_refuses_only_past_the_cap_by_name() {
        assert!(too_large(&[]).is_none());
        assert!(too_large(&vec![b' '; ATTACH_BODY_MAX]).is_none(), "at the cap is read");
        let (code, Json(v)) = too_large(&vec![b' '; ATTACH_BODY_MAX + 1]).expect("one over is refused");
        assert_eq!(code, StatusCode::PAYLOAD_TOO_LARGE);
        assert_eq!(v["refused"], "attach-too-large");
    }
}