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