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))
}