crates/monetize-embed/src/ticket.rs
//! **The actor ticket: the appliance's word that this human may renew this
//! tenant** — minted by the product's appliance, verified here.
//!
//! # Which way this one points, and why that is the whole point
//!
//! Every other signed thing in this crate travels monetize → appliance: a
//! [`crate::signing::verify_fact`] is monetize's verdict about a tenant, and a
//! [`crate::signing::GoAhead`] is monetize's permission to grow a data set.
//! Both are verified on the appliance, against monetize's key.
//!
//! **A ticket travels the other way.** monetize authenticates exactly one
//! caller on its gRPC surface — a shared bearer, and an empty one means open
//! (`monetize-server/src/auth.rs`) — so `Purchase.Start` cannot tell the
//! product's console from the product's operator, and certainly cannot tell one
//! browser session from another. When a *customer* renews their own
//! subscription, the thing monetize needs and does not have is evidence that
//! the human driving the console holds the tenant they are paying for.
//!
//! The appliance is the one party that knows. It holds the identity table, the
//! keys and the grants; on gunnar a principal's id **is** the namespace it owns
//! (`AuthTable::owns_account`, a string equality with no row to get wrong). So
//! the appliance decides, and says so in a ticket monetize can check without
//! asking anybody.
//!
//! # Why monetize verifies a signature instead of calling the appliance back
//!
//! The obvious alternative — monetize asks the appliance "is this ticket
//! yours?" — needs no new key material at all, because a product plugin already
//! holds an authenticated client to its own appliance. It was rejected for one
//! reason, and it is the reason this whole path exists:
//!
//! > **It puts the appliance in the critical path of its own renewal.**
//!
//! A renewal is what a customer does when the box is full, suspended, or down.
//! A design in which the box must be reachable before it can be paid for cannot
//! serve the case it was built for. A signature is checked offline, so a
//! ticket minted while the box was up is still good when it is not.
//!
//! # What a ticket is NOT
//!
//! A ticket is **permission for one verb, never a widening of any other**. It
//! authorises `purpose: "renew"` on exactly the tenant it names. It cannot
//! authorise an order (new caps), it cannot name a second tenant, and its
//! absence changes nothing — a request with no ticket is the operator path,
//! unchanged.
//!
//! # Shape
//!
//! ```json
//! {"v":1,"issuer":"gunnar","product":"gunnar","tenant":"alice",
//! "purpose":"renew","nonce":"<one line, ≤256 B>",
//! "issued_unix_ms":<i64>,"expires_unix_ms":<i64>,
//! "signature":"<base64, standard alphabet, padded>"}
//! ```
//!
//! Ed25519 over [`ticket_message`]: the canonical JSON of every field except
//! `signature`. The same canonical form and the same base64 alphabet a
//! [`crate::signing::GoAhead`] uses, so there is one of each in this crate and
//! not two.
use ed25519_dalek::VerifyingKey;
use crate::signing::{base64_decode, canonical_json, check, SignatureError};
/// **Another period of what the tenant already holds.** Names no caps.
///
/// A word rather than a bool, and the reason is now visible rather than
/// hypothetical: [`PURPOSE_ORDER`] exists, and a verifier must be able to
/// refuse a renewal ticket presented on an order — and the reverse — instead of
/// waving either through because the signature was good. [`verify_ticket`]
/// takes the purpose it wants and compares.
pub const PURPOSE_RENEW: &str = "renew";
/// **An order: more room, at a cap the customer chose.**
///
/// The difference from [`PURPOSE_RENEW`] is not the word, it is
/// [`ActorTicket::caps`]. A renewal sells a known thing — another period of
/// what is already held — so the appliance vouching for the tenant is the whole
/// of what monetize needs. An order names an AMOUNT, and an amount the ticket
/// did not cover is an amount nobody vouched for: a ticket that said only "this
/// human may buy" would be a blank cheque, signed by the appliance, spendable
/// by whatever sent it for whatever it liked.
///
/// So an order ticket carries the caps it covers, [`verify_ticket`] compares
/// them against the order they arrived with, and a mismatch is **refused and
/// never ignored** — the same rule the tenant, product and purpose fields are
/// already held to.
pub const PURPOSE_ORDER: &str = "order";
/// **Reading back what this tenant has already been sold.** Names no caps, and
/// buys nothing.
///
/// It exists because the alternative was working and dishonest. A history read
/// was gated, for one commit, by a [`PURPOSE_RENEW`] ticket — strictly narrower
/// than what that ticket authorises, so nothing could be done with it that
/// could not have been done anyway, and therefore "safe". It was still wrong,
/// in two ways that are worth writing down because both are easy to wave away:
///
/// * **A ticket is single-use.** Spending a renewal ticket to LIST a history
/// burns the one the customer would have renewed with, so the console must
/// mint another — the semantics leaking out as an extra round trip and a
/// thing that looks like a bug to whoever meets it next.
/// * **It makes the appliance vouch for the wrong verb.** The whole defence
/// here is that the appliance vouches for something *specific*; a credential
/// that says "may renew" being accepted for "may read" is the first crack in
/// that, and the second one is always easier than the first.
///
/// A read is neither a renewal nor an order, so it says so.
pub const PURPOSE_READ: &str = "read";
/// **Ending one of this tenant's own OPEN orders** — `Purchase.Cancel` on an
/// order nobody has paid for, and `Purchase.Abandon` (the refund) on one whose
/// money was captured and never granted. Names no caps, and buys nothing.
///
/// Its own word for the reason [`PURPOSE_READ`] has one: the appliance vouches
/// for something SPECIFIC. A read ticket that could also close an order would
/// be the first crack in that, and an order ticket carries an amount no
/// cancellation names. One word for both closing verbs, because both are the
/// same act from the customer's side — "stop this order of mine" — and monetize,
/// not the customer, decides which of the two the ledger's state allows.
pub const PURPOSE_CANCEL: &str = "cancel";
/// **The gunnar.rs account that buys and owns a private Gunnar, at the shop**
/// (one shop, 2026-10-07): the SHOP's own gunnar vouches that the bearer is
/// the admin of that account (never a member of one), so the shop's monetize
/// holds an `EstateOrder.Start` for it and answers `EstateOrder.Ladder` /
/// takes `EstateOrder.ChangePlan` for the estates that account owns. Names no
/// caps: the shop prices the order itself.
pub const PURPOSE_ESTATE: &str = "estate";
/// The longest life a ticket may be minted with.
///
/// A ticket is fetched inside the request that spends it, so its life covers
/// one round trip and a clock disagreement, not a session. Ten minutes is two
/// orders of magnitude more than the path needs and still short enough that a
/// ticket captured off the wire is worthless by the time it is read out of a
/// log. [`verify_ticket`] refuses a longer one **even when it verifies**: the
/// cap is this crate's rule, not the issuer's, so an appliance that is talked
/// into minting an eternal ticket still cannot spend one here.
pub const MAX_LIFE_MS: i64 = 10 * 60 * 1000;
/// How far ahead of the verifier's clock a ticket may claim to have been
/// issued. Two boxes, two clocks; gunnar's own gRPC credential allows the same
/// order of slack.
pub const MAX_CLOCK_SKEW_MS: i64 = 60 * 1000;
/// **The appliance's word that one principal may act for one tenant.**
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct ActorTicket {
/// Format version. `1`, and a verifier refuses anything else rather than
/// guessing at a field it does not know.
pub v: u32,
/// Which appliance minted it, as a product id (`gunnar`). Decorative for a
/// verifier that holds one key — the key IS the issuer — and load-bearing
/// the day a monetize holds two.
pub issuer: String,
/// The product this ticket may be spent on, as `ProductInfo.id`. Checked
/// against the order, so a ticket from one product's appliance cannot buy
/// another product's period.
pub product: String,
/// The tenant the bearer may act for. On gunnar this is the namespace,
/// which is the principal's own id.
pub tenant: String,
/// The one verb this ticket authorises: [`PURPOSE_RENEW`] or
/// [`PURPOSE_ORDER`].
pub purpose: String,
/// **The caps this ticket covers**, for [`PURPOSE_ORDER`] — the target caps
/// the order will name, meter by meter.
///
/// **Empty for [`PURPOSE_RENEW`], and required to be**: a renewal names no
/// caps at all (they come off the tenant's stored fact), so a renewal
/// ticket carrying any is either a client that has confused the two paths
/// or an order wearing a renewal's word. [`verify_ticket`] refuses both.
///
/// A `BTreeMap` because it is serialised into the signed message and the
/// canonical form sorts keys anyway; the ordered map makes the Rust value
/// and the signed bytes agree by construction rather than by the
/// serialiser's mood.
#[serde(default)]
pub caps: std::collections::BTreeMap<String, u64>,
/// Single-use, chosen by the issuer. The verifier keeps a ring and refuses
/// a repeat — see [`SeenNonces`].
pub nonce: String,
pub issued_unix_ms: i64,
pub expires_unix_ms: i64,
/// Base64, standard alphabet, padded. Not part of [`ticket_message`].
#[serde(default)]
pub signature: String,
}
impl ActorTicket {
/// Build an unsigned ticket. The issuer signs [`ticket_message`] of it and
/// fills in [`ActorTicket::signature`].
///
/// Not a constructor that signs, because this crate holds no key: it is
/// compiled into products, and a product must be able to CHECK a ticket
/// without being able to MINT one. That asymmetry is the same one
/// [`crate::signing`] has for facts, and it is deliberate.
pub fn unsigned(
issuer: impl Into<String>,
product: impl Into<String>,
tenant: impl Into<String>,
purpose: impl Into<String>,
nonce: impl Into<String>,
issued_unix_ms: i64,
life_ms: i64,
) -> ActorTicket {
ActorTicket {
v: 1,
issuer: issuer.into(),
product: product.into(),
tenant: tenant.into(),
purpose: purpose.into(),
caps: std::collections::BTreeMap::new(),
nonce: nonce.into(),
issued_unix_ms,
expires_unix_ms: issued_unix_ms.saturating_add(life_ms),
signature: String::new(),
}
}
/// **The caps this order ticket covers.** Builder rather than an eighth
/// positional argument, because every renewal would otherwise pass an empty
/// map at a call site where the emptiness is the whole point and reads as
/// noise.
pub fn covering(mut self, caps: std::collections::BTreeMap<String, u64>) -> ActorTicket {
self.caps = caps;
self
}
}
/// The bytes a ticket's signature covers: every field except `signature`.
pub fn ticket_message(ticket: &ActorTicket) -> Vec<u8> {
let mut v = serde_json::to_value(ticket).expect("a ticket serializes");
v.as_object_mut()
.expect("a ticket is an object")
.remove("signature");
let mut s = String::new();
canonical_json(&v, &mut s);
s.into_bytes()
}
/// **Parse, verify and admit a ticket — or refuse it, saying which rule it
/// broke.**
///
/// Every argument is a thing the caller must supply rather than a default this
/// function could pick, because each one is a check that a verifier which
/// "just checked the signature" would have skipped:
///
/// * `key` — the appliance's Ed25519 verifying key. A ticket is only ever as
/// good as the key it is checked against, and that key is configuration.
/// * `product` / `tenant` — what the ORDER says. A valid ticket for another
/// tenant is the whole attack, and comparing it here means no caller can
/// forget to.
/// * `purpose` — [`PURPOSE_RENEW`] or [`PURPOSE_ORDER`]. Named by the caller so
/// a ticket minted for a verb this build has never heard of is refused, and
/// so a renewal ticket can never be spent on an order.
/// * `caps` — what the ORDER actually names. For [`PURPOSE_ORDER`] this must
/// equal the ticket's own caps exactly; for [`PURPOSE_RENEW`] both must be
/// empty. This is the argument that stops an order ticket being a blank
/// cheque: without it the appliance would be vouching for "this human may
/// buy", and the amount would be whatever the thing holding the ticket
/// decided to ask for.
/// * `now_unix_ms` — the verifier's clock, so expiry is not optional.
///
/// Order: structure, then signature, then the claims. The claims are only
/// compared once the signature says the appliance really wrote them, so a
/// refusal about a tenant or an expiry is a statement about a genuine ticket
/// and not about an attacker's JSON.
pub fn verify_ticket(
bytes: &[u8],
key: &VerifyingKey,
product: &str,
tenant: &str,
purpose: &str,
caps: &std::collections::BTreeMap<String, u64>,
now_unix_ms: i64,
) -> Result<ActorTicket, SignatureError> {
let ticket: ActorTicket =
serde_json::from_slice(bytes).map_err(|_| SignatureError::Malformed)?;
if ticket.v != 1
|| ticket.nonce.trim().is_empty()
|| ticket.nonce.len() > 256
|| ticket.tenant.trim().is_empty()
|| ticket.product.trim().is_empty()
{
return Err(SignatureError::Malformed);
}
let sig = base64_decode(&ticket.signature).ok_or(SignatureError::Malformed)?;
if !check(key, &ticket_message(&ticket), &sig)? {
return Err(SignatureError::Ticket(
"the signature does not verify against this appliance's key".to_owned(),
));
}
// ── The claims, now that they are known to be the appliance's ──────────
//
// `Ticket` carries one sentence and it is deliberately the same shape for
// every one of these: the CALLER decides what a customer is told. A
// verifier that returned "…for tenant beta, not alpha" and let that reach a
// browser would answer, for any name a stranger cares to type, whether a
// ticket for it exists — which is the enumeration the tenant path exists to
// avoid.
if ticket.purpose != purpose {
return Err(SignatureError::Ticket(format!(
"this ticket authorises {:?} and the call is {purpose:?}",
ticket.purpose
)));
}
if ticket.product != product {
return Err(SignatureError::Ticket(format!(
"this ticket was minted for product {:?}",
ticket.product
)));
}
if ticket.tenant != tenant {
return Err(SignatureError::Ticket(
"this ticket names another tenant".to_owned(),
));
}
// ── The amount, for an order ───────────────────────────────────────────
//
// **Refused, never ignored**, which is the rule every other field here is
// held to. A ticket whose caps do not match the order it arrived with is
// not a ticket for this order, and quietly running the order anyway would
// charge for something the appliance never vouched for.
if ticket.caps != *caps {
return Err(SignatureError::Ticket(format!(
"this ticket covers {} meter(s) and the order names {}",
ticket.caps.len(),
caps.len()
)));
}
// **The capless verbs name no caps on either side.** Stated separately from
// the comparison above, because `{} == {}` would pass an order-shaped
// renewal silently and the two mistakes have different remedies.
//
// A renewal sells another period of what is already held, and a read (or
// a cancellation) sells nothing at all; in both the caps come off the tenant's stored fact, so a
// ticket of either kind that CARRIES caps is an order wearing another
// verb's word, or a client that has confused two paths.
if (ticket.purpose == PURPOSE_RENEW
|| ticket.purpose == PURPOSE_READ
|| ticket.purpose == PURPOSE_CANCEL)
&& !ticket.caps.is_empty()
{
return Err(SignatureError::Ticket(format!(
"a {:?} ticket names no caps: the caps come off the tenant's stored fact, and only \
an order names an amount",
ticket.purpose
)));
}
if ticket.expires_unix_ms <= ticket.issued_unix_ms
|| ticket.expires_unix_ms.saturating_sub(ticket.issued_unix_ms) > MAX_LIFE_MS
{
return Err(SignatureError::Ticket(format!(
"a ticket may live at most {MAX_LIFE_MS} ms and this one claims {} ms",
ticket.expires_unix_ms.saturating_sub(ticket.issued_unix_ms)
)));
}
if now_unix_ms >= ticket.expires_unix_ms {
return Err(SignatureError::Ticket("this ticket has expired".to_owned()));
}
if ticket.issued_unix_ms.saturating_sub(now_unix_ms) > MAX_CLOCK_SKEW_MS {
return Err(SignatureError::Ticket(
"this ticket is issued further in the future than two clocks explain".to_owned(),
));
}
Ok(ticket)
}
/// **The nonces already spent, so a ticket is single use.**
///
/// A ring and not a set: the thing that writes to it is a verified ticket with
/// a bounded life, so what has to be remembered is one life's worth of them and
/// never the whole history. [`MAX_LIFE_MS`] is ten minutes; a box selling a
/// renewal a second for ten minutes fills six hundred slots.
///
/// **In memory, and that is a stated limit rather than an oversight.** A
/// monetize that restarts forgets, and a ticket replayed across that restart
/// would be admitted — within its ten-minute life, by somebody who had already
/// captured it, to buy the tenant it already names another period of the same
/// subscription. The exposure is one duplicate renewal of the attacker's own
/// account, and `Purchase.Start` is idempotent on its reference anyway
/// (`<product>/<tenant>/<date>`), so the second one is the same order. Making
/// it durable would mean a table, and a table is worth its cost when the thing
/// it prevents is worth more than a repeated no-op.
#[derive(Debug)]
pub struct SeenNonces {
ring: std::sync::Mutex<std::collections::VecDeque<String>>,
cap: usize,
}
impl Default for SeenNonces {
fn default() -> SeenNonces {
SeenNonces::with_capacity(4096)
}
}
impl SeenNonces {
pub fn with_capacity(cap: usize) -> SeenNonces {
SeenNonces {
ring: std::sync::Mutex::new(std::collections::VecDeque::with_capacity(cap.min(1024))),
cap: cap.max(1),
}
}
/// Record this nonce and say whether it was **new**. `false` means it has
/// been spent and the ticket must be refused.
pub fn admit(&self, nonce: &str) -> bool {
let mut ring = self.ring.lock().unwrap_or_else(|e| e.into_inner());
if ring.iter().any(|seen| seen == nonce) {
return false;
}
if ring.len() >= self.cap {
ring.pop_front();
}
ring.push_back(nonce.to_owned());
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
use crate::signing::base64_encode;
const NOW: i64 = 1_760_000_000_000;
fn appliance() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
/// Mint the way a real appliance does: build unsigned, sign the canonical
/// message, fill in the signature, serialise.
fn mint(key: &SigningKey, ticket: ActorTicket) -> Vec<u8> {
let mut ticket = ticket;
let sig = key.sign(&ticket_message(&ticket));
ticket.signature = base64_encode(&sig.to_bytes());
serde_json::to_vec(&ticket).expect("a ticket serialises")
}
/// No caps: what a renewal names, and what every renewal assertion passes.
fn none() -> std::collections::BTreeMap<String, u64> {
std::collections::BTreeMap::new()
}
/// The caps an order names. One meter, a number no other fixture uses.
fn ten_gib() -> std::collections::BTreeMap<String, u64> {
std::collections::BTreeMap::from([("pack_bytes".to_owned(), 10_737_418_240u64)])
}
fn order_for(tenant: &str) -> ActorTicket {
ActorTicket::unsigned(
"gunnar",
"gunnar",
tenant,
PURPOSE_ORDER,
"n-1",
NOW,
60_000,
)
.covering(ten_gib())
}
fn renew_for(tenant: &str) -> ActorTicket {
ActorTicket::unsigned(
"gunnar",
"gunnar",
tenant,
PURPOSE_RENEW,
"n-1",
NOW,
60_000,
)
}
#[test]
fn a_ticket_the_appliance_signed_is_admitted_for_the_tenant_it_names() {
let key = appliance();
let bytes = mint(&key, renew_for("alice"));
let ticket = verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_RENEW,
&none(),
NOW + 1_000,
)
.expect("the appliance's own ticket must verify");
assert_eq!(ticket.tenant, "alice");
assert_eq!(ticket.purpose, PURPOSE_RENEW);
}
/// ★ **The attack this whole mechanism exists to refuse.**
///
/// A perfectly valid ticket, signed by the real appliance, for the caller's
/// OWN tenant — spent on somebody else's. Nothing about the signature is
/// wrong; the ticket is genuine. What refuses it is that `verify_ticket`
/// compares the tenant in the signed claims against the tenant in the
/// ORDER, and takes both as arguments so that no call site can forget one.
#[test]
fn a_genuine_ticket_cannot_be_spent_on_another_tenant() {
let key = appliance();
let bytes = mint(&key, renew_for("alice"));
let refused = verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"beta",
PURPOSE_RENEW,
&none(),
NOW + 1_000,
)
.expect_err("alice's ticket must not renew beta");
let said = refused.to_string();
assert!(
!said.contains("alice"),
"the refusal names the ticket's own tenant to whoever asked about another: {said}"
);
}
/// A ticket from an appliance this monetize does not hold the key for.
#[test]
fn a_ticket_signed_by_another_key_is_refused() {
let bytes = mint(&SigningKey::from_bytes(&[9u8; 32]), renew_for("alice"));
assert!(verify_ticket(
&bytes,
&appliance().verifying_key(),
"gunnar",
"alice",
PURPOSE_RENEW,
&none(),
NOW + 1_000,
)
.is_err());
}
/// **The signature covers every field**, so editing one after the fact
/// breaks it — the tenant most of all.
#[test]
fn rewriting_the_tenant_after_signing_breaks_the_signature() {
let key = appliance();
let bytes = mint(&key, renew_for("alice"));
let mut ticket: ActorTicket = serde_json::from_slice(&bytes).expect("parses");
ticket.tenant = "beta".to_owned();
let forged = serde_json::to_vec(&ticket).expect("serialises");
assert!(verify_ticket(
&forged,
&key.verifying_key(),
"gunnar",
"beta",
PURPOSE_RENEW,
&none(),
NOW + 1_000,
)
.is_err());
}
/// A ticket for one product must not buy another product's period, even
/// from an appliance whose key this monetize holds.
#[test]
fn a_ticket_is_bound_to_its_product() {
let key = appliance();
let bytes = mint(&key, renew_for("alice"));
assert!(verify_ticket(
&bytes,
&key.verifying_key(),
"holger",
"alice",
PURPOSE_RENEW,
&none(),
NOW + 1_000,
)
.is_err());
}
/// The purpose is compared, so a ticket minted for a verb invented later
/// is refused by a build that predates it rather than waved through.
#[test]
fn a_ticket_for_another_purpose_is_refused() {
let key = appliance();
let bytes = mint(
&key,
ActorTicket::unsigned("gunnar", "gunnar", "alice", "order", "n-1", NOW, 60_000),
);
assert!(verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_RENEW,
&none(),
NOW + 1_000,
)
.is_err());
}
#[test]
fn an_expired_ticket_is_refused() {
let key = appliance();
let bytes = mint(&key, renew_for("alice"));
assert!(verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_RENEW,
&none(),
NOW + 60_001,
)
.is_err());
}
/// ★ **The cap is this crate's rule and not the issuer's.**
///
/// An appliance talked into minting a ticket that lives a year signs it
/// perfectly well, and every claim in it is genuine. It is still refused,
/// because the life a verifier will accept is not a thing the signer gets
/// to choose.
#[test]
fn a_ticket_that_outlives_the_cap_is_refused_even_though_it_verifies() {
let key = appliance();
let bytes = mint(
&key,
ActorTicket::unsigned(
"gunnar",
"gunnar",
"alice",
PURPOSE_RENEW,
"n-1",
NOW,
365 * 24 * 60 * 60 * 1000,
),
);
assert!(verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_RENEW,
&none(),
NOW + 1_000,
)
.is_err());
}
/// A ticket dated far enough ahead to outlive its own expiry check is
/// refused rather than trusted — otherwise an issuer with a wrong clock
/// mints something that never expires from the verifier's point of view.
#[test]
fn a_ticket_from_the_far_future_is_refused() {
let key = appliance();
let bytes = mint(&key, renew_for("alice"));
assert!(verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_RENEW,
&none(),
NOW - MAX_CLOCK_SKEW_MS - 1,
)
.is_err());
}
/// An order ticket verifies for the caps it names.
#[test]
fn an_order_ticket_is_admitted_for_the_caps_it_covers() {
let key = appliance();
let bytes = mint(&key, order_for("alice"));
let ticket = verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_ORDER,
&ten_gib(),
NOW + 1_000,
)
.expect("the appliance's own order ticket must verify");
assert_eq!(ticket.caps, ten_gib());
}
/// ★ **An order ticket is not a blank cheque.**
///
/// The ticket is genuine, signed by the real appliance, for the right
/// tenant, the right product and the right verb. Only the AMOUNT differs —
/// the order asks for a hundred times what the customer agreed to. Without
/// the caps in the signed message and compared here, the appliance would be
/// vouching for "this human may buy" and whatever held the ticket would
/// choose how much.
#[test]
fn an_order_ticket_cannot_be_spent_on_a_bigger_order() {
let key = appliance();
let bytes = mint(&key, order_for("alice"));
let hundredfold =
std::collections::BTreeMap::from([("pack_bytes".to_owned(), 1_073_741_824_000u64)]);
assert!(verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_ORDER,
&hundredfold,
NOW + 1_000,
)
.is_err());
}
/// An order ticket must not be spendable as a renewal, nor the reverse.
/// Both directions, because they fail for different reasons and a verifier
/// that got one right could still wave the other through.
#[test]
fn a_renewal_ticket_and_an_order_ticket_are_not_interchangeable() {
let key = appliance();
let renewal = mint(&key, renew_for("alice"));
assert!(
verify_ticket(
&renewal,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_ORDER,
&ten_gib(),
NOW + 1_000,
)
.is_err(),
"a renewal ticket bought room"
);
let order = mint(&key, order_for("alice"));
assert!(
verify_ticket(
&order,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_RENEW,
&none(),
NOW + 1_000,
)
.is_err(),
"an order ticket renewed a period"
);
}
/// A renewal ticket that carries caps is refused — an order wearing a
/// renewal's word, or a client that has confused the two paths.
#[test]
fn a_renewal_ticket_carrying_caps_is_refused() {
let key = appliance();
let bytes = mint(&key, renew_for("alice").covering(ten_gib()));
assert!(verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_RENEW,
&ten_gib(),
NOW + 1_000,
)
.is_err());
}
/// The signature covers the caps, so editing them after signing breaks it.
#[test]
fn rewriting_the_caps_after_signing_breaks_the_signature() {
let key = appliance();
let bytes = mint(&key, order_for("alice"));
let mut ticket: ActorTicket = serde_json::from_slice(&bytes).expect("parses");
ticket.caps = std::collections::BTreeMap::from([("pack_bytes".to_owned(), 1u64)]);
let forged = serde_json::to_vec(&ticket).expect("serialises");
assert!(verify_ticket(
&forged,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_ORDER,
&ticket.caps,
NOW + 1_000,
)
.is_err());
}
fn read_for(tenant: &str) -> ActorTicket {
ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_READ, "n-1", NOW, 60_000)
}
/// A read ticket verifies for a read, and names no caps.
#[test]
fn a_read_ticket_is_admitted_for_a_read() {
let key = appliance();
let bytes = mint(&key, read_for("alice"));
let ticket = verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_READ,
&none(),
NOW + 1_000,
)
.expect("the appliance's own read ticket must verify");
assert!(ticket.caps.is_empty());
}
/// ★ **A read ticket buys nothing, and a renewal ticket is not a read.**
///
/// The first half is what the verb is FOR: before `PURPOSE_READ` existed a
/// history read was gated by a renewal ticket, which worked and was a lie
/// of convenience — the appliance vouching for the wrong verb, and a
/// single-use credential burned listing a page. Now each says what it is,
/// and neither is spendable as the other.
///
/// Both directions, because they fail for different reasons and a verifier
/// that got one right could wave the other through.
#[test]
fn a_read_ticket_and_a_renewal_ticket_are_not_interchangeable() {
let key = appliance();
let read = mint(&key, read_for("alice"));
assert!(
verify_ticket(
&read,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_RENEW,
&none(),
NOW + 1_000,
)
.is_err(),
"a read ticket bought a period"
);
let renewal = mint(&key, renew_for("alice"));
assert!(
verify_ticket(
&renewal,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_READ,
&none(),
NOW + 1_000,
)
.is_err(),
"a renewal ticket was spent on a read — the lie of convenience this verb replaces"
);
}
/// A read ticket cannot buy room either, and an order ticket cannot read.
#[test]
fn a_read_ticket_and_an_order_ticket_are_not_interchangeable() {
let key = appliance();
let read = mint(&key, read_for("alice"));
assert!(verify_ticket(
&read,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_ORDER,
&ten_gib(),
NOW + 1_000,
)
.is_err());
let order = mint(&key, order_for("alice"));
assert!(verify_ticket(
&order,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_READ,
&none(),
NOW + 1_000,
)
.is_err());
}
/// A read ticket carrying caps is refused — a read buys nothing, so there
/// is no amount for it to name.
#[test]
fn a_read_ticket_carrying_caps_is_refused() {
let key = appliance();
let bytes = mint(&key, read_for("alice").covering(ten_gib()));
assert!(verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"alice",
PURPOSE_READ,
&ten_gib(),
NOW + 1_000,
)
.is_err());
}
/// A read ticket is still bound to its tenant: the verb being harmless does
/// not make the scope optional.
#[test]
fn a_read_ticket_cannot_read_another_tenant() {
let key = appliance();
let bytes = mint(&key, read_for("alice"));
let refused = verify_ticket(
&bytes,
&key.verifying_key(),
"gunnar",
"beta",
PURPOSE_READ,
&none(),
NOW + 1_000,
)
.expect_err("alice's read ticket must not read beta");
assert!(
!refused.to_string().contains("alice"),
"the refusal names the ticket's own tenant to whoever asked about another"
);
}
#[test]
fn a_nonce_is_admitted_once() {
let seen = SeenNonces::with_capacity(4);
assert!(seen.admit("n-1"));
assert!(!seen.admit("n-1"), "a nonce must be spendable once");
assert!(seen.admit("n-2"));
}
#[test]
fn the_nonce_ring_does_not_grow_without_bound() {
let seen = SeenNonces::with_capacity(2);
assert!(seen.admit("a"));
assert!(seen.admit("b"));
assert!(seen.admit("c"));
// `a` fell off the front, so it is admitted again. That is the ring's
// stated trade and the reason its capacity covers one ticket life.
assert!(seen.admit("a"));
assert!(!seen.admit("c"));
}
#[test]
fn rubbish_is_refused_without_panicking() {
let key = appliance().verifying_key();
for bytes in [&b""[..], b"{", b"{}", b"null", b"[1,2,3]"] {
assert!(
verify_ticket(bytes, &key, "gunnar", "alice", PURPOSE_RENEW, &none(), NOW).is_err(),
"{bytes:?} was not refused"
);
}
}
}
/// R5a: the ring runs on every box that admits a ticket. A panic that once held
/// it must not make every later sign-in panic; the ring's one change (pop, then
/// push) leaves a ring either way, and a spent nonce in it stays spent.
#[cfg(test)]
mod poisoned_ring_tests {
use super::*;
#[test]
fn a_poisoned_ring_still_admits_and_still_refuses_a_spent_nonce() {
let seen = SeenNonces::with_capacity(4);
assert!(seen.admit("n1"));
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _held = seen.ring.lock();
panic!("a panic while the ring is held");
}));
assert!(seen.ring.is_poisoned());
assert!(!seen.admit("n1"), "spent before the panic, spent after it");
assert!(seen.admit("n2"));
}
}