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

nordisk/monetize

crates/monetize-server/src/site_ticket.rs

//! **Who a SITE's account is, on the ONE box** (STINKY ST-3/ST-4, owner 2026-10-11).
//!
//! One monetize sells one product for several sites (gunnar.rs and test2.gunnar.rs), each an
//! estate with its own main. A site's console calls with that estate's own bearer
//! ([`crate::auth::CallerEstate`]) and names its account by the PLAIN name its main knows;
//! the main mints the account's actor ticket with its OWN key. So, for a call from a site:
//!
//! * the tenant is that site's account: `@<estate>/<name>` on the ledger away from the
//!   product's home, plain at home ([`tenant_on`]) — a tenant of another estate is refused;
//! * the ticket is checked under THAT site's key (`[estates.<estate>] appliance_key`), for
//!   the plain name, single use ([`may_act`]) — never under another site's, and never under
//!   the product's one key, which is one site's at most.
//!
//! A call with no calling estate (the service bearer) is the product's ticket,
//! and so is a site call at the product's home when the site holds no key of its own.
//!
//! # The product's ticket is checked HERE, never in its guest (v2.0 P2; A28 H9)
//!
//! Op 41 (`may_act_for`) used to run inside the product's guest: Gunnar's guest read its block's
//! `appliance_key` through the `env` door, checked the ticket with
//! [`monetize_embed::verify_ticket`] and remembered the nonce through the `state` door; Holger's
//! guest answered the SDK's refusal. The T13 prep moved the check here ([`guest_may_act`]), on
//! the same inputs: the block's `appliance_key`, the product's `facts.tickets` (op 40, merged into
//! the facts), and the nonce ledger in the product's host memory (the key the Gunnar guest wrote,
//! `ticket.nonce.<n>`). It proved the answers equal to the signed guests' own, case by case; T13
//! then deleted op 41 and the product grants (`tests/the_host_checks_the_ticket_as_the_signed_guests_did.rs`
//! keeps the verdicts). A product whose block names no key honours no ticket: there is no guest
//! to ask.

use std::collections::BTreeMap;

use monetize_product::{ActorVerdict, ProductError, TenantId};
use monetize_product_wasm::GuestProduct;

use crate::state::AppState;

/// `hex` (64 characters, trimmed, any case) as an ed25519 public key, or `None`.
pub fn key_of(hex: &str) -> Option<monetize_embed::VerifyingKey> {
    let hex = hex.trim().to_ascii_lowercase();
    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    let mut bytes = [0u8; 32];
    for (i, pair) in hex.as_bytes().chunks(2).enumerate() {
        bytes[i] = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
    }
    monetize_embed::VerifyingKey::from_bytes(&bytes).ok()
}

/// **The ledger's name for `tenant` of `product`, called from `estate`.** No calling estate:
/// the name as given. From the product's home (or a product with no home): plain. From
/// another estate: `@<estate>/<name>`. A name already qualified must be the caller's own
/// estate's; another estate's account is refused (`Err`, the words for the caller).
pub fn tenant_on(s: &AppState, product: &str, estate: Option<&str>, tenant: &str) -> Result<String, String> {
    let Some(estate) = estate.map(str::trim).filter(|e| !e.is_empty()) else {
        return Ok(tenant.to_owned());
    };
    let home = s.registry.home_of(product);
    let at_home = home.as_deref() == Some(estate);
    let plain = match TenantId(tenant.to_owned()).estate() {
        Some((e, ns)) if e == estate => ns.to_owned(),
        Some(_) => return Err(format!("{tenant:?} is not an account of this site ({estate})")),
        None => tenant.to_owned(),
    };
    if plain.is_empty() || at_home {
        return Ok(plain);
    }
    Ok(TenantId::on_estate(estate, &plain).0)
}

/// The account's name as its OWN site's main knows it: `tenant` without `@<estate>/`.
pub fn plain_of<'a>(estate: &str, tenant: &'a str) -> &'a str {
    tenant.strip_prefix(monetize_product::ESTATE_MARK).and_then(|t| t.strip_prefix(estate)).and_then(|t| t.strip_prefix('/')).unwrap_or(tenant)
}

/// **May the bearer of `ticket` act for `tenant` (the ledger's name) of `product`, from
/// `estate`?** No calling estate: the product decides, as before. From a site: that site's
/// own key decides, for the plain name, once ([`crate::registry::Registry::spend_site_nonce`]);
/// a site with no key of its own is the product's to decide only when it is the product's
/// home (its one key is the home's); any other site is refused by name.
pub fn may_act(s: &AppState, product: monetize_product::Served<'_>, estate: Option<&str>, tenant: &str, purpose: &str, caps: &BTreeMap<String, u64>, ticket: &[u8]) -> Result<ActorVerdict, ProductError> {
    s.registry.ticket_checked(product.id());
    let Some(estate) = estate.map(str::trim).filter(|e| !e.is_empty()) else {
        return product_may_act(s, product, tenant, purpose, caps, ticket);
    };
    if let Some((e, _)) = TenantId(tenant.to_owned()).estate() {
        if e != estate {
            return Ok(ActorVerdict::Refused(format!("{tenant:?} is not an account of this site")));
        }
    }
    let plain = plain_of(estate, tenant);
    let Some(hex) = s.registry.appliance_key_of(estate) else {
        if s.registry.home_of(product.id()).as_deref() == Some(estate) {
            return product_may_act(s, product, plain, purpose, caps, ticket);
        }
        return Ok(ActorVerdict::Refused(format!(
            "this monetize holds no appliance key for the site {estate}, so it cannot check who you are; an operator sets [estates.{estate}] appliance_key"
        )));
    };
    let Some(key) = key_of(&hex) else {
        return Ok(ActorVerdict::Refused(format!("[estates.{estate}] appliance_key is not a usable ed25519 public key")));
    };
    let t = match monetize_embed::verify_ticket(ticket, &key, product.id(), plain, purpose, caps, s.now() as i64) {
        Ok(t) => t,
        Err(e) => return Ok(ActorVerdict::Refused(e.to_string())),
    };
    if !s.registry.spend_site_nonce(estate, &t.nonce) {
        return Ok(ActorVerdict::Refused("this ticket has already been spent; ask the appliance for another".to_owned()));
    }
    Ok(ActorVerdict::Allowed)
}

/// The SDK's own refusal for a product that checks no tickets
/// (`monetize_product_guest::Guest::may_act_for`'s default), word for word.
pub const CANNOT_VERIFY: &str = "this product cannot verify a tenant-presented ticket";

/// The refusal for a ticket whose nonce was honoured before.
pub const SPENT: &str = "this ticket has already been spent; ask the appliance for another";

/// **Who answers a product's ticket.**
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Route {
    /// Refused at once, with these words.
    Refuse(String),
    /// Checked here under this key (the block's `appliance_key`, trimmed).
    Check(String),
}

/// **The route for `product`'s ticket**: `tickets` is its facts' `tickets` (`tenant_renewal`),
/// `appliance_key` its block's line. Pure. No key, no ticket.
pub fn route(product: &str, tickets: bool, appliance_key: Option<&str>) -> Route {
    let key = appliance_key.map(str::trim).filter(|k| !k.is_empty());
    match (tickets, key) {
        (true, Some(k)) => Route::Check(k.to_owned()),
        (false, Some(_)) => Route::Refuse(CANNOT_VERIFY.to_owned()),
        (_, None) => Route::Refuse(format!(
            "{CANNOT_VERIFY}: this monetize holds no appliance key for {product}, so it cannot check who you are; an operator must \
             configure `appliance_key` in [products.{product}] before a tenant can renew themselves"
        )),
    }
}

/// **One ticket as a call presents it**: the tenant (the name the product's main knows), the
/// verb, the caps the call names, and the ticket's bytes.
#[derive(Debug, Clone, Copy)]
pub struct Ask<'a> {
    pub tenant: &'a str,
    pub purpose: &'a str,
    pub caps: &'a BTreeMap<String, u64>,
    pub ticket: &'a [u8],
}

/// **May the bearer of the ticket act for its tenant of `product`?** Checked offline under
/// `key_hex` at `now_unix_ms`; `spend` remembers the nonce once (`Ok(true)` the first time).
/// Default deny: every refusal carries the words the person holding the ticket is shown.
pub fn check(product: &str, key_hex: &str, ask: Ask<'_>, now_unix_ms: i64, spend: impl FnOnce(&str) -> Result<bool, String>) -> ActorVerdict {
    let Some(key) = key_of(key_hex) else {
        return ActorVerdict::Refused(format!(
            "[products.{product}] appliance_key is not a usable ed25519 public key ({} characters), so no ticket \
             this appliance mints can be checked",
            key_hex.trim().len()
        ));
    };
    // `purpose` and `caps` are the REQUEST's, never read off the ticket: an order ticket covers
    // the named amount and nothing else.
    let t = match monetize_embed::verify_ticket(ask.ticket, &key, product, ask.tenant, ask.purpose, ask.caps, now_unix_ms) {
        Ok(t) => t,
        Err(e) => return ActorVerdict::Refused(e.to_string()),
    };
    match spend(&t.nonce) {
        Ok(true) => ActorVerdict::Allowed,
        Ok(false) => ActorVerdict::Refused(SPENT.to_owned()),
        Err(e) => ActorVerdict::Refused(format!(
            "this ticket cannot be honoured: the ticket ledger could not be written: {e}. A ticket that cannot be recorded as \
             spent could be spent again"
        )),
    }
}

/// **A guest product's ticket, checked by the host**: `product` is the served handle (the guest,
/// or the pulled wrapper around it), `guest` the module behind it. See the module's notes.
pub fn guest_may_act(guest: &GuestProduct, product: monetize_product::Served<'_>, ask: Ask<'_>, now_unix_ms: i64) -> Result<ActorVerdict, ProductError> {
    match route(product.id(), product.tenant_renewal(), guest.appliance_key()) {
        Route::Refuse(words) => Ok(ActorVerdict::Refused(words)),
        Route::Check(key) => Ok(check(product.id(), &key, ask, now_unix_ms, |nonce| guest.spend_ticket_nonce(nonce))),
    }
}

/// The product's own ticket (no site key decides it), always checked HERE (A32-13: the product
/// checks none). A guest-served product's life is judged on the clock the guest read
/// (`host_clock`, the wall clock), as the guest judged it; a product linked into this binary
/// (a test's) on the server's own clock, under its block's key or the one its home estate
/// delivered ([`crate::registry::Registry::deliver_product_key`]), its nonces spent in the
/// registry.
fn product_may_act(s: &AppState, product: monetize_product::Served<'_>, tenant: &str, purpose: &str, caps: &BTreeMap<String, u64>, ticket: &[u8]) -> Result<ActorVerdict, ProductError> {
    let ask = Ask { tenant, purpose, caps, ticket };
    match s.registry.guest(product.id()) {
        Some(guest) => guest_may_act(&guest, product, ask, monetize_product_wasm::doors::host_clock_unix_ms() as i64),
        None => Ok(match route(product.id(), product.tenant_renewal(), s.registry.product_ticket_key(product.id()).as_deref()) {
            Route::Refuse(words) => ActorVerdict::Refused(words),
            Route::Check(key) => check(product.id(), &key, ask, s.now() as i64, |nonce| Ok(s.registry.spend_product_nonce(product.id(), nonce))),
        }),
    }
}

#[cfg(test)]
mod host_ticket_tests {
    use super::*;
    use ed25519_dalek::{Signer as _, SigningKey};
    use monetize_embed::ticket::{ticket_message, ActorTicket};

    const NOW: i64 = 1_800_000_000_000;

    fn hex(k: &SigningKey) -> String {
        k.verifying_key().to_bytes().iter().map(|b| format!("{b:02x}")).collect()
    }

    fn mint(k: &SigningKey, tenant: &str, purpose: &str, nonce: &str, caps: BTreeMap<String, u64>) -> Vec<u8> {
        let mut t = ActorTicket::unsigned("gunnar", "gunnar", tenant, purpose, nonce, NOW - 1_000, 60_000).covering(caps);
        t.signature = monetize_embed::signing::base64_encode(&k.sign(&ticket_message(&t)).to_bytes());
        serde_json::to_vec(&t).unwrap()
    }

    /// A ledger as the product's host memory keeps it.
    #[derive(Default)]
    struct Ledger(std::sync::Mutex<std::collections::BTreeSet<String>>);
    impl Ledger {
        fn spend(&self, nonce: &str) -> Result<bool, String> {
            Ok(self.0.lock().unwrap().insert(nonce.to_owned()))
        }
    }

    #[test]
    fn the_route_is_the_guests_choice_made_on_the_host() {
        let key = "ab".repeat(32);
        assert_eq!(route("gunnar", true, Some(&format!("  {key} "))), Route::Check(key.clone()));
        // T13: no key, no ticket; there is no guest to ask (op 41 and the Lake's plain ticket are gone).
        for blank in [None, Some("  ")] {
            match route("lake", true, blank) {
                Route::Refuse(w) => assert!(w.contains("this monetize holds no appliance key for lake") && w.contains("[products.lake]"), "{w}"),
                other => panic!("{blank:?}: {other:?}"),
            }
        }
        assert_eq!(route("holger", false, Some(&key)), Route::Refuse(CANNOT_VERIFY.into()), "Holger's guest refused with the SDK's words");
        match route("gunnar", false, None) {
            Route::Refuse(w) => {
                assert!(w.starts_with(CANNOT_VERIFY), "{w}");
                assert!(w.contains("this monetize holds no appliance key for gunnar, so it cannot check who you are; an operator must configure `appliance_key` in [products.gunnar] before a tenant can renew themselves"), "{w}");
            }
            other => panic!("{other:?}"),
        }
    }

    #[test]
    fn a_genuine_ticket_passes_once_and_every_broken_one_is_refused() {
        let l = Ledger::default();
        let k = SigningKey::from_bytes(&[7u8; 32]);
        let other = SigningKey::from_bytes(&[8u8; 32]);
        let caps: BTreeMap<String, u64> = [("storage_bytes".to_string(), 10u64)].into();
        let go = |tenant: &str, purpose: &str, caps: &BTreeMap<String, u64>, ticket: &[u8], now: i64| check("gunnar", &hex(&k), Ask { tenant, purpose, caps, ticket }, now, |n| l.spend(n));
        let t = mint(&k, "alice", "order", "n-1", caps.clone());
        assert_eq!(go("alice", "order", &caps, &t, NOW), ActorVerdict::Allowed);
        assert_eq!(go("alice", "order", &caps, &t, NOW), ActorVerdict::Refused(SPENT.into()));
        let refused = |v: ActorVerdict| matches!(v, ActorVerdict::Refused(_));
        assert!(refused(go("alice", "order", &caps, &mint(&other, "alice", "order", "n-2", caps.clone()), NOW)), "another key");
        assert!(refused(go("bob", "order", &caps, &mint(&k, "alice", "order", "n-3", caps.clone()), NOW)), "another tenant");
        assert!(refused(go("alice", "renew", &caps, &mint(&k, "alice", "order", "n-4", caps.clone()), NOW)), "another verb");
        assert!(refused(go("alice", "order", &BTreeMap::new(), &mint(&k, "alice", "order", "n-5", caps.clone()), NOW)), "another amount");
        assert!(refused(go("alice", "order", &caps, &mint(&k, "alice", "order", "n-6", caps.clone()), NOW + 3_600_000)), "expired");
        assert!(refused(go("alice", "order", &caps, b"not a ticket", NOW)), "garbage");
        let t7 = mint(&k, "alice", "order", "n-7", caps.clone());
        assert!(refused(check("holger", &hex(&k), Ask { tenant: "alice", purpose: "order", caps: &caps, ticket: &t7 }, NOW, |n| l.spend(n))), "another product");
        match check("gunnar", "zz", Ask { tenant: "alice", purpose: "order", caps: &caps, ticket: &t7 }, NOW, |n| l.spend(n)) {
            ActorVerdict::Refused(w) => assert!(w.contains("[products.gunnar] appliance_key is not a usable ed25519 public key (2 characters)"), "{w}"),
            v => panic!("{v:?}"),
        }
        match check("gunnar", &hex(&k), Ask { tenant: "alice", purpose: "order", caps: &caps, ticket: &mint(&k, "alice", "order", "n-8", caps.clone()) }, NOW, |_| Err("full".into())) {
            ActorVerdict::Refused(w) => assert!(w.contains("cannot be honoured") && w.contains("full"), "{w}"),
            v => panic!("{v:?}"),
        }
    }
}