crates/appliance-grow/src/go_ahead.rs
//! **The go-ahead** — the signed fact a growth is started on (D3, T14 in
//! `DATA-SET-GROWTH-FLOW.md`). Moved AS IS out of gunnar-server's
//! `src/grow/go_ahead.rs` (gunnar 18a44e3).
//!
//! An appliance never dials monetize (the shop-presence contract), so whatever
//! approves a growth — an operator at the CLI, monetize after its € gate —
//! reaches the box as bytes on its `GrowStart`. The box verifies the
//! STRUCTURE here, once, and hands the WHO to a pluggable
//! [`GoAheadPolicy`]:
//!
//! ```text
//! {
//! "v": 1,
//! "signer": "operator" | "monetize",
//! "target_sectors": <u64>, // must equal the request's
//! "nonce": "<opaque, non-empty>", // spent once per box (the ledger beside the journal)
//! "issued_unix_ms": <i64>, // the policy bounds its age
//! "signature": "<base64>" // whatever the signer's policy verifies; may be empty
//! }
//! ```
//!
//! [`OperatorPolicy`] is the default and accepts an `operator` go-ahead
//! without a signature — the RPC that carries it is already an admin door,
//! which is the second column of D3 (the cap is checked by whoever calls
//! `Start`). A `monetize` go-ahead is REFUSED by it: a metered build plugs
//! `MonetizePolicy` (feature `monetize`), which verifies `signature` under
//! monetize's Ed25519 key over the canonical bytes (the same
//! `monetize_embed::signing` pair the entitlement facts use), through
//! [`GrowMachine::with_policy`](crate::GrowMachine::with_policy).
//! The structure and the nonce ledger are shared by every policy, so the
//! replay and the target check cannot be forgotten by one of them.
//!
//! # That plug did not exist until 2026-09-18, and front-driven growth was dead on it
//!
//! AUDITED 2026-09-18 (gunnar): the paragraph above was a description of
//! intent. The server built the machine with `GrowMachine::new`, which
//! installs [`OperatorPolicy`], and nothing anywhere implemented
//! [`GoAheadPolicy`] for monetize — so monetize's orchestrator reached
//! `phase::STARTING`, called `GrowStart` with its own signed go-ahead and was
//! refused by name, AFTER it had reserved the spend and flushed.
//! `MonetizePolicy` is that missing half. It is `cfg(feature = "monetize")`,
//! like every other line of a product's money half: the community binary
//! contains no monetize verifier at all, which a symbol table is the proof of.
use serde::Deserialize;
/// The oldest go-ahead the operator policy takes, ms. A stale one is a
/// script re-run by mistake, not an approval.
pub const MAX_GO_AHEAD_AGE_MS: i64 = 24 * 3600 * 1000;
/// A go-ahead, parsed.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct GoAhead {
pub v: u32,
pub signer: String,
pub target_sectors: u64,
pub nonce: String,
pub issued_unix_ms: i64,
#[serde(default)]
pub signature: String,
}
impl GoAhead {
/// Parse and check the structure: version 1, a known signer, a
/// non-empty nonce. Everything else is the policy's.
pub fn parse(bytes: &[u8]) -> Result<GoAhead, String> {
if bytes.is_empty() {
return Err("the go-ahead is empty".into());
}
let go: GoAhead = serde_json::from_slice(bytes).map_err(|e| format!("the go-ahead is not the JSON object this box expects ({e}); see appliance_grow::go_ahead"))?;
if go.v != 1 {
return Err(format!("go-ahead version {} is not 1", go.v));
}
if !matches!(go.signer.as_str(), "operator" | "monetize") {
return Err(format!("go-ahead signer {:?} is neither `operator` nor `monetize`", go.signer));
}
if go.nonce.trim().is_empty() || go.nonce.len() > 256 || go.nonce.contains(['\n', '\r']) {
return Err("go-ahead nonce must be one non-empty line of at most 256 bytes".into());
}
Ok(go)
}
/// The bytes an operator go-ahead is minted as (an operator's own `grow start`).
pub fn operator(target_sectors: u64, nonce: &str, issued_unix_ms: i64) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"v": 1,
"signer": "operator",
"target_sectors": target_sectors,
"nonce": nonce,
"issued_unix_ms": issued_unix_ms,
"signature": "",
}))
.expect("a JSON object of scalars encodes")
}
}
/// **Who may say go.** Pluggable; see the module docs.
pub trait GoAheadPolicy: Send + Sync + 'static {
/// Accept or refuse, by name.
fn verify(&self, go: &GoAhead, now_unix_ms: i64) -> Result<(), String>;
/// For the log.
fn name(&self) -> &'static str;
}
/// The default: an `operator` go-ahead carried on a admin RPC is the
/// approval; `monetize` is refused until a policy that can verify it is
/// plugged in.
#[derive(Debug, Clone)]
pub struct OperatorPolicy {
pub max_age_ms: i64,
}
impl Default for OperatorPolicy {
fn default() -> Self {
OperatorPolicy { max_age_ms: MAX_GO_AHEAD_AGE_MS }
}
}
impl GoAheadPolicy for OperatorPolicy {
fn verify(&self, go: &GoAhead, now_unix_ms: i64) -> Result<(), String> {
match go.signer.as_str() {
"operator" => {}
other => {
return Err(format!(
"a `{other}` go-ahead cannot be verified by this box: no policy for that signer is plugged in (the default policy takes `operator` go-aheads on a admin call)"
))
}
}
bound_age(go, now_unix_ms, self.max_age_ms)
}
fn name(&self) -> &'static str {
"operator"
}
}
/// **The age window, one implementation.** Whoever signed it, a go-ahead is an
/// approval of a growth NOW: too old is a script re-run by mistake, and dated
/// ahead is a clock nobody has looked at. Lifted out of [`OperatorPolicy`]
/// unchanged (same two sentences, same numbers) when [`MonetizePolicy`] was
/// added, so the two policies cannot drift into two different windows — the
/// way `fact_message` is one canonical form for both halves of the
/// entitlement path.
fn bound_age(go: &GoAhead, now_unix_ms: i64, max_age_ms: i64) -> Result<(), String> {
let age = now_unix_ms - go.issued_unix_ms;
if age > max_age_ms {
return Err(format!("the go-ahead was issued {} s ago, past the {} s ceiling; mint a fresh one", age / 1000, max_age_ms / 1000));
}
if age < -5 * 60 * 1000 {
return Err(format!("the go-ahead is dated {} s in the future; whose clock is wrong?", -age / 1000));
}
Ok(())
}
/// **The metered appliance's policy: monetize's Ed25519 signature, under the
/// key the box was already installed with.** Feature `monetize` only — see the
/// module docs for the audit that found this half missing.
///
/// # Where the key comes from: the one the box already holds
///
/// No new config knob. A metered product is started with the monetize public
/// key it verifies every `EntitlementFact` with (gunnar's `--monetize-pubkey`,
/// holger's `metered` key), and the same 32 bytes verify the go-ahead: monetize
/// signs it with the SAME signing key it signs facts with
/// (`monetize::sign::Signer::sign_go_ahead`), and `monetize_embed::signing`
/// documents that pairing on its own `GoAhead` — so a box that can be billed
/// can, by construction, check who told it to grow. A second key would have to
/// be distributed, rotated and kept in step with the first.
///
/// # An `operator` go-ahead is still taken, and that is not a weakening
///
/// This policy delegates `operator` to an [`OperatorPolicy`] it owns rather
/// than refusing it. `GrowStart` is an admin RPC on every build,
/// so the operator arm here is exactly the door a metered box has today; the
/// hosted tenant does not hold that capability and never reaches it. Refusing
/// it instead would take away the owner's own way to grow a metered appliance
/// when monetize is unreachable — a capability removed, not a hole closed.
#[cfg(feature = "monetize")]
pub struct MonetizePolicy {
/// `None` while the key is AWAITED (the installer feeds it): a `monetize`
/// go-ahead is refused by name until [`MonetizePolicy::set_key`] applies it
/// in place — no restart (owner 2026-09-25).
key: std::sync::RwLock<Option<monetize_embed::VerifyingKey>>,
/// The `operator` arm, unchanged; see the type docs.
operator: OperatorPolicy,
pub max_age_ms: i64,
}
#[cfg(feature = "monetize")]
impl std::fmt::Debug for MonetizePolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MonetizePolicy").field("max_age_ms", &self.max_age_ms).finish()
}
}
#[cfg(feature = "monetize")]
impl MonetizePolicy {
/// Over the 32 bytes `--monetize-pubkey` resolved to. Refuses at BOOT if
/// they are not a point on the curve, in the same voice as
/// the entitlement door: a metered box whose growth door could never
/// open must say so while somebody is watching the console, not on the
/// first growth six months later.
pub fn new(monetize_pubkey: &[u8; 32]) -> Result<Self, String> {
let key = monetize_embed::VerifyingKey::from_bytes(monetize_pubkey)
.map_err(|e| format!("the monetize public key this box was started with is not a valid Ed25519 point ({e}); no `monetize` go-ahead could ever verify under it"))?;
Ok(MonetizePolicy { key: std::sync::RwLock::new(Some(key)), operator: OperatorPolicy::default(), max_age_ms: MAX_GO_AHEAD_AGE_MS })
}
/// A metered box whose monetize key the installer feeds and has not yet:
/// the `operator` door as always, `monetize` refused by name until the key
/// arrives.
pub fn awaiting() -> Self {
MonetizePolicy { key: std::sync::RwLock::new(None), operator: OperatorPolicy::default(), max_age_ms: MAX_GO_AHEAD_AGE_MS }
}
/// **The key, applied in place** when it arrives while running.
pub fn set_key(&self, monetize_pubkey: &[u8; 32]) -> Result<(), String> {
let key = monetize_embed::VerifyingKey::from_bytes(monetize_pubkey)
.map_err(|e| format!("the fed monetize public key is not a valid Ed25519 point ({e})"))?;
*self.key.write().unwrap_or_else(|p| p.into_inner()) = Some(key);
Ok(())
}
}
#[cfg(feature = "monetize")]
impl GoAheadPolicy for MonetizePolicy {
fn verify(&self, go: &GoAhead, now_unix_ms: i64) -> Result<(), String> {
match go.signer.as_str() {
"operator" => return self.operator.verify(go, now_unix_ms),
"monetize" => {}
other => {
// `GoAhead::parse` has already refused anything but these two
// words, so this arm is reached only by a caller holding the
// struct directly. It refuses by name rather than falling
// through to a signature check that would be meaningless.
return Err(format!("go-ahead signer {other:?} is neither `operator` nor `monetize`; this box verifies a `monetize` signature and takes an `operator` go-ahead on a admin call, and nothing else"));
}
}
bound_age(go, now_unix_ms, self.max_age_ms)?;
// The signature covers `monetize_embed::signing::go_ahead_message` —
// the canonical JSON of the six fields MINUS `signature`. The box parsed
// the wire bytes once, into its own `GoAhead` (the structure check every
// policy shares), so the same six fields are handed back to
// monetize-embed's own verifier here instead of a canonicaliser being
// written a second time in this crate: the sorted-key form is a
// function of the values, so the round trip through serde cannot change
// what is checked, and there is no second encoding for a forger to aim
// at (the argument a product's entitlement verifier makes about
// `verify_fact`).
let wire = monetize_embed::signing::GoAhead {
v: go.v,
signer: go.signer.clone(),
target_sectors: go.target_sectors,
nonce: go.nonce.clone(),
issued_unix_ms: go.issued_unix_ms,
signature: go.signature.clone(),
};
let bytes = serde_json::to_vec(&wire).expect("a go-ahead of scalars encodes");
let key = self.key.read().unwrap_or_else(|p| p.into_inner()).clone().ok_or_else(|| {
format!("the `monetize` go-ahead for nonce {:?} cannot be checked: this box has no monetize key yet (the installer feeds it)", go.nonce)
})?;
monetize_embed::signing::verify_go_ahead(&bytes, &key).map(|_| ()).map_err(|e| {
format!(
"the `monetize` go-ahead for nonce {:?} does not verify under the monetize public key this box was started with ({e}); it was not signed by the monetize this appliance was installed against, or it was altered on the way",
go.nonce
)
})
}
fn name(&self) -> &'static str {
"monetize"
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The structure is checked once, for every policy: version, signer,
/// nonce, target — and the operator policy's age window.
#[test]
fn a_go_ahead_is_parsed_strictly_and_the_operator_policy_bounds_its_age() {
let now = 1_800_000_000_000;
let ok = GoAhead::operator(4096, "n-1", now - 1000);
let go = GoAhead::parse(&ok).expect("parses");
assert_eq!((go.v, go.signer.as_str(), go.target_sectors, go.nonce.as_str()), (1, "operator", 4096, "n-1"));
let p = OperatorPolicy::default();
p.verify(&go, now).expect("fresh operator go-ahead");
assert!(GoAhead::parse(b"").unwrap_err().contains("empty"));
assert!(GoAhead::parse(b"not json").unwrap_err().contains("not the JSON"));
assert!(GoAhead::parse(br#"{"v":2,"signer":"operator","target_sectors":1,"nonce":"x","issued_unix_ms":1}"#).unwrap_err().contains("version"));
assert!(GoAhead::parse(br#"{"v":1,"signer":"root","target_sectors":1,"nonce":"x","issued_unix_ms":1}"#).unwrap_err().contains("signer"));
assert!(GoAhead::parse(br#"{"v":1,"signer":"operator","target_sectors":1,"nonce":"","issued_unix_ms":1}"#).unwrap_err().contains("nonce"));
assert!(GoAhead::parse(br#"{"v":1,"signer":"operator","target_sectors":1,"nonce":"a\nb","issued_unix_ms":1}"#).unwrap_err().contains("nonce"));
let stale = GoAhead::parse(&GoAhead::operator(1, "n-2", now - MAX_GO_AHEAD_AGE_MS - 1)).unwrap();
assert!(p.verify(&stale, now).unwrap_err().contains("past the"));
let future = GoAhead::parse(&GoAhead::operator(1, "n-3", now + 10 * 60 * 1000)).unwrap();
assert!(p.verify(&future, now).unwrap_err().contains("future"));
let monetize = GoAhead::parse(br#"{"v":1,"signer":"monetize","target_sectors":1,"nonce":"m","issued_unix_ms":1,"signature":"AAAA"}"#).unwrap();
assert!(p.verify(&monetize, now).unwrap_err().contains("no policy for that signer"));
}
}
/// The metered half's policy. `monetize-embed` and `ed25519-dalek` are
/// unconditional dev-dependencies of this crate, so these sign a go-ahead the
/// way `monetize::sign::Signer::sign_go_ahead` does — over
/// `monetize_embed::signing::go_ahead_message`, the ONE canonical form —
/// rather than against a stand-in of its own (the argument
/// entitlement tests make for facts).
#[cfg(all(test, feature = "monetize"))]
pub(super) mod metered_tests {
use super::*;
use ed25519_dalek::{Signer as _, SigningKey};
use monetize_embed::signing::{base64_encode, go_ahead_message, GO_AHEAD_SIGNER_MONETIZE};
fn key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
/// Exactly what monetize puts on the wire.
pub(crate) fn signed(k: &SigningKey, target_sectors: u64, nonce: &str, issued_unix_ms: i64) -> Vec<u8> {
let mut go = monetize_embed::signing::GoAhead {
v: 1,
signer: GO_AHEAD_SIGNER_MONETIZE.into(),
target_sectors,
nonce: nonce.into(),
issued_unix_ms,
signature: String::new(),
};
go.signature = base64_encode(&k.sign(&go_ahead_message(&go)).to_bytes());
serde_json::to_vec(&go).expect("encodes")
}
/// **The half the 2026-09-18 audit found missing**: a go-ahead monetize
/// signed verifies under the box's `--monetize-pubkey`, and a signature
/// that is forged, moved, tampered with or simply absent is refused BY
/// NAME — never silently taken.
#[test]
fn a_monetize_signed_go_ahead_verifies_and_a_bad_signature_is_refused_by_name() {
let now = 1_800_000_000_000;
let monetize = key(7);
let p = MonetizePolicy::new(&monetize.verifying_key().to_bytes()).expect("a real key");
assert_eq!(p.name(), "monetize");
let go = GoAhead::parse(&signed(&monetize, 4096, "m-1", now - 1000)).expect("parses");
assert_eq!((go.signer.as_str(), go.target_sectors), ("monetize", 4096));
p.verify(&go, now).expect("monetize's own signature verifies");
// Signed by somebody else's key: the box was installed against ONE monetize.
let impostor = GoAhead::parse(&signed(&key(9), 4096, "m-2", now - 1000)).unwrap();
let err = p.verify(&impostor, now).unwrap_err();
assert!(err.contains("does not verify") && err.contains("m-2"), "{err}");
// A field changed after signing: the signature covers all six.
let mut tampered = go.clone();
tampered.target_sectors = 8192;
assert!(p.verify(&tampered, now).unwrap_err().contains("does not verify"));
let mut moved = go.clone();
moved.nonce = "m-3".into();
assert!(p.verify(&moved, now).unwrap_err().contains("does not verify"));
// No signature at all — the shape an `operator` go-ahead has, worn by
// a `monetize` one. It is not waved through.
let mut bare = go.clone();
bare.signature = String::new();
assert!(p.verify(&bare, now).unwrap_err().contains("does not verify"));
let mut junk = go.clone();
junk.signature = "!!!not base64!!!".into();
assert!(p.verify(&junk, now).unwrap_err().contains("does not verify"));
// The age window is the same one, and it is checked before the key.
let stale = GoAhead::parse(&signed(&monetize, 4096, "m-4", now - MAX_GO_AHEAD_AGE_MS - 1)).unwrap();
assert!(p.verify(&stale, now).unwrap_err().contains("past the"));
let ahead = GoAhead::parse(&signed(&monetize, 4096, "m-5", now + 10 * 60 * 1000)).unwrap();
assert!(p.verify(&ahead, now).unwrap_err().contains("future"));
}
/// **A wrong signer is refused by name at both doors**, and neither door
/// was moved: the metered policy still takes the operator's go-ahead
/// (`GrowStart` is an admin door on every build), and
/// [`OperatorPolicy`] still refuses monetize — nothing about the community
/// build changed.
#[test]
fn a_wrong_signer_is_refused_by_name_and_neither_the_operator_nor_the_community_door_moved() {
let now = 1_800_000_000_000;
let monetize = key(7);
let p = MonetizePolicy::new(&monetize.verifying_key().to_bytes()).unwrap();
// A third word never gets as far as a signature check.
let err = GoAhead::parse(br#"{"v":1,"signer":"root","target_sectors":1,"nonce":"x","issued_unix_ms":1,"signature":"AAAA"}"#).unwrap_err();
assert!(err.contains("signer") && err.contains("root"), "{err}");
let held = GoAhead { v: 1, signer: "root".into(), target_sectors: 1, nonce: "x".into(), issued_unix_ms: now, signature: String::new() };
assert!(p.verify(&held, now).unwrap_err().contains("neither `operator` nor `monetize`"));
// The operator arm, unchanged, under the metered policy…
let op = GoAhead::parse(&GoAhead::operator(4096, "op-1", now - 1000)).unwrap();
p.verify(&op, now).expect("a metered box still takes its owner's go-ahead");
assert!(p.verify(&GoAhead::parse(&GoAhead::operator(1, "op-2", 1)).unwrap(), now).unwrap_err().contains("past the"));
// …and the community policy, which has no key and says so.
let mon = GoAhead::parse(&signed(&monetize, 4096, "m-9", now - 1000)).unwrap();
let err = OperatorPolicy::default().verify(&mon, now).unwrap_err();
assert!(err.contains("no policy for that signer"), "{err}");
// A key that is not a point on the curve is a BOOT refusal, not a
// growth-time surprise. `[0xdd; 32]` is one such encoding (measured
// against ed25519-dalek 2 by scanning every repeated byte; `[0xff; 32]`
// is NOT — dalek masks the sign bit and reduces, so the all-ones
// spelling decompresses, and a "clearly wrong" key is not the same
// thing as an invalid one).
assert!(MonetizePolicy::new(&[0xdd; 32]).unwrap_err().contains("not a valid Ed25519 point"));
}
/// **The fed key applies in place** (owner 2026-09-25: avoid restarts): a
/// policy built before the key arrived refuses a `monetize` go-ahead by
/// name, keeps the operator door, and once the key is set the SAME policy
/// accepts a go-ahead signed with it — no restart.
#[cfg(feature = "monetize")]
#[test]
fn a_go_ahead_signed_with_a_key_fed_while_running_is_accepted_by_the_same_policy() {
let now = 1_800_000_000_000;
let monetize = key(7);
let p = MonetizePolicy::awaiting();
let go = GoAhead::parse(&signed(&monetize, 4096, "m-live", now - 1000)).unwrap();
assert!(p.verify(&go, now).unwrap_err().contains("no monetize key yet"));
p.verify(&GoAhead::parse(&GoAhead::operator(4096, "op-live", now - 1000)).unwrap(), now).expect("the operator door is there keyless");
p.set_key(&monetize.verifying_key().to_bytes()).unwrap();
p.verify(&go, now).expect("the same policy, the fed key, no restart");
let impostor = GoAhead::parse(&signed(&key(9), 4096, "m-other", now - 1000)).unwrap();
assert!(p.verify(&impostor, now).unwrap_err().contains("does not verify"));
assert!(p.set_key(&[0xdd; 32]).unwrap_err().contains("not a valid Ed25519 point"));
}
}