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

nordisk/gunnar

crates/gunnar-server/src/grow/go_ahead.rs

//! **The go-ahead** — the signed fact a growth is started on (D3, T14 in
//! `DATA-SET-GROWTH-FLOW.md`).
//!
//! gunnar never dials monetize (the shop-presence contract), so whatever
//! approves a growth — an operator at the CLI, monetize after its € gate —
//! reaches gunnar as bytes on `Admin.GrowStart`. gunnar 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 gunnar-admin,
//! 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`], 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`](super::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: the paragraph above was a description of intent.
//! `serve.rs` built the machine with `GrowMachine::new`, which installs
//! [`OperatorPolicy`], and nothing anywhere implemented [`GoAheadPolicy`] for
//! monetize — so monetize's orchestrator reached `phase::STARTING`, called
//! `Admin.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 = "metered")`, like every other line of
//! the money half (`crate::entitlement`, `Entitlement`/`Billing` in
//! `gunnar_grpc`): the community binary contains no monetize verifier at all,
//! which a symbol table is the proof of, and its behaviour is untouched.

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,
    /// ★ **Non-empty: grow WITHOUT a caught-up standby, and why** (cov-13, 2026-10-06) —
    /// the approver's EMERGENCY (monetize's ALERT words: the set at ≥ 90 % and the twin
    /// silent past its bound or not catching up under load). The drain still looks, and
    /// a standby that is behind is said instead of obeyed.
    ///
    /// OUTSIDE the signature: `monetize_embed::signing::go_ahead_message` covers the six
    /// fields above and [`MonetizePolicy`] re-encodes exactly those, so this key changes
    /// nothing that is verified. That is acceptable for what it is: it relaxes a SAFETY
    /// precondition (ruling 2's re-lay needs a current twin), never money, and it arrives
    /// on `Admin.GrowStart`, a gunnar-admin call.
    #[serde(default)]
    pub alone: 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 gunnar_server::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 (`gunnar control 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 gunnar-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 gunnar-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.** `metered` 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 gunnar is started with `--monetize-pubkey`
/// (`gunnar_common::cli::ServeArgs`, 64 hex inline or a file — inline on the
/// appliance, whose only writable channel is the kernel cmdline), it is
/// resolved once into [`crate::entitlement::MeteredConfig::monetize_pubkey`],
/// and the same 32 bytes already verify every `EntitlementFact` that reaches
/// `Entitlement.Set`. monetize signs the go-ahead with the SAME signing key it
/// signs facts with (`monetize::sign::Signer::sign_go_ahead`), and
/// `monetize_embed::signing` documents that pairing in the doc comment 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, and a box that had only one of the two would
/// be a support ticket nobody can read off the console.
///
/// # 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. `Admin.GrowStart` is a gunnar-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 = "metered")]
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 = "metered")]
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 = "metered")]
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
    /// `EntitlementSlot::open`: 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 = "metered")]
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 gunnar-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`. gunnar 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 `crate::entitlement`'s module docs make 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 gunnar's own (the argument
/// `tests/entitlement.rs` makes for facts).
#[cfg(all(test, feature = "metered"))]
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
    /// (`Admin.GrowStart` is gunnar-admin 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(),
            alone: 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 = "metered")]
    #[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"));
    }
}