monetize_embed/ticket.rs
1//! **The actor ticket: the appliance's word that this human may renew this
2//! tenant** — minted by the product's appliance, verified here.
3//!
4//! # Which way this one points, and why that is the whole point
5//!
6//! Every other signed thing in this crate travels monetize → appliance: a
7//! [`crate::signing::verify_fact`] is monetize's verdict about a tenant, and a
8//! [`crate::signing::GoAhead`] is monetize's permission to grow a data set.
9//! Both are verified on the appliance, against monetize's key.
10//!
11//! **A ticket travels the other way.** monetize authenticates exactly one
12//! caller on its gRPC surface — a shared bearer, and an empty one means open
13//! (`monetize-server/src/auth.rs`) — so `Purchase.Start` cannot tell the
14//! product's console from the product's operator, and certainly cannot tell one
15//! browser session from another. When a *customer* renews their own
16//! subscription, the thing monetize needs and does not have is evidence that
17//! the human driving the console holds the tenant they are paying for.
18//!
19//! The appliance is the one party that knows. It holds the identity table, the
20//! keys and the grants; on gunnar a principal's id **is** the namespace it owns
21//! (`AuthTable::owns_account`, a string equality with no row to get wrong). So
22//! the appliance decides, and says so in a ticket monetize can check without
23//! asking anybody.
24//!
25//! # Why monetize verifies a signature instead of calling the appliance back
26//!
27//! The obvious alternative — monetize asks the appliance "is this ticket
28//! yours?" — needs no new key material at all, because a product plugin already
29//! holds an authenticated client to its own appliance. It was rejected for one
30//! reason, and it is the reason this whole path exists:
31//!
32//! > **It puts the appliance in the critical path of its own renewal.**
33//!
34//! A renewal is what a customer does when the box is full, suspended, or down.
35//! A design in which the box must be reachable before it can be paid for cannot
36//! serve the case it was built for. A signature is checked offline, so a
37//! ticket minted while the box was up is still good when it is not.
38//!
39//! # What a ticket is NOT
40//!
41//! A ticket is **permission for one verb, never a widening of any other**. It
42//! authorises `purpose: "renew"` on exactly the tenant it names. It cannot
43//! authorise an order (new caps), it cannot name a second tenant, and its
44//! absence changes nothing — a request with no ticket is the operator path,
45//! unchanged.
46//!
47//! # Shape
48//!
49//! ```json
50//! {"v":1,"issuer":"gunnar","product":"gunnar","tenant":"alice",
51//! "purpose":"renew","nonce":"<one line, ≤256 B>",
52//! "issued_unix_ms":<i64>,"expires_unix_ms":<i64>,
53//! "signature":"<base64, standard alphabet, padded>"}
54//! ```
55//!
56//! Ed25519 over [`ticket_message`]: the canonical JSON of every field except
57//! `signature`. The same canonical form and the same base64 alphabet a
58//! [`crate::signing::GoAhead`] uses, so there is one of each in this crate and
59//! not two.
60
61use ed25519_dalek::VerifyingKey;
62
63use crate::signing::{base64_decode, canonical_json, check, SignatureError};
64
65/// **Another period of what the tenant already holds.** Names no caps.
66///
67/// A word rather than a bool, and the reason is now visible rather than
68/// hypothetical: [`PURPOSE_ORDER`] exists, and a verifier must be able to
69/// refuse a renewal ticket presented on an order — and the reverse — instead of
70/// waving either through because the signature was good. [`verify_ticket`]
71/// takes the purpose it wants and compares.
72pub const PURPOSE_RENEW: &str = "renew";
73
74/// **An order: more room, at a cap the customer chose.**
75///
76/// The difference from [`PURPOSE_RENEW`] is not the word, it is
77/// [`ActorTicket::caps`]. A renewal sells a known thing — another period of
78/// what is already held — so the appliance vouching for the tenant is the whole
79/// of what monetize needs. An order names an AMOUNT, and an amount the ticket
80/// did not cover is an amount nobody vouched for: a ticket that said only "this
81/// human may buy" would be a blank cheque, signed by the appliance, spendable
82/// by whatever sent it for whatever it liked.
83///
84/// So an order ticket carries the caps it covers, [`verify_ticket`] compares
85/// them against the order they arrived with, and a mismatch is **refused and
86/// never ignored** — the same rule the tenant, product and purpose fields are
87/// already held to.
88pub const PURPOSE_ORDER: &str = "order";
89
90/// **Reading back what this tenant has already been sold.** Names no caps, and
91/// buys nothing.
92///
93/// It exists because the alternative was working and dishonest. A history read
94/// was gated, for one commit, by a [`PURPOSE_RENEW`] ticket — strictly narrower
95/// than what that ticket authorises, so nothing could be done with it that
96/// could not have been done anyway, and therefore "safe". It was still wrong,
97/// in two ways that are worth writing down because both are easy to wave away:
98///
99/// * **A ticket is single-use.** Spending a renewal ticket to LIST a history
100/// burns the one the customer would have renewed with, so the console must
101/// mint another — the semantics leaking out as an extra round trip and a
102/// thing that looks like a bug to whoever meets it next.
103/// * **It makes the appliance vouch for the wrong verb.** The whole defence
104/// here is that the appliance vouches for something *specific*; a credential
105/// that says "may renew" being accepted for "may read" is the first crack in
106/// that, and the second one is always easier than the first.
107///
108/// A read is neither a renewal nor an order, so it says so.
109pub const PURPOSE_READ: &str = "read";
110
111/// **Ending one of this tenant's own OPEN orders** — `Purchase.Cancel` on an
112/// order nobody has paid for, and `Purchase.Abandon` (the refund) on one whose
113/// money was captured and never granted. Names no caps, and buys nothing.
114///
115/// Its own word for the reason [`PURPOSE_READ`] has one: the appliance vouches
116/// for something SPECIFIC. A read ticket that could also close an order would
117/// be the first crack in that, and an order ticket carries an amount no
118/// cancellation names. One word for both closing verbs, because both are the
119/// same act from the customer's side — "stop this order of mine" — and monetize,
120/// not the customer, decides which of the two the ledger's state allows.
121pub const PURPOSE_CANCEL: &str = "cancel";
122
123/// **The gunnar.rs account that buys and owns a private Gunnar, at the shop**
124/// (one shop, 2026-10-07): the SHOP's own gunnar vouches that the bearer is
125/// the admin of that account (never a member of one), so the shop's monetize
126/// holds an `EstateOrder.Start` for it and answers `EstateOrder.Ladder` /
127/// takes `EstateOrder.ChangePlan` for the estates that account owns. Names no
128/// caps: the shop prices the order itself.
129pub const PURPOSE_ESTATE: &str = "estate";
130
131/// The longest life a ticket may be minted with.
132///
133/// A ticket is fetched inside the request that spends it, so its life covers
134/// one round trip and a clock disagreement, not a session. Ten minutes is two
135/// orders of magnitude more than the path needs and still short enough that a
136/// ticket captured off the wire is worthless by the time it is read out of a
137/// log. [`verify_ticket`] refuses a longer one **even when it verifies**: the
138/// cap is this crate's rule, not the issuer's, so an appliance that is talked
139/// into minting an eternal ticket still cannot spend one here.
140pub const MAX_LIFE_MS: i64 = 10 * 60 * 1000;
141
142/// How far ahead of the verifier's clock a ticket may claim to have been
143/// issued. Two boxes, two clocks; gunnar's own gRPC credential allows the same
144/// order of slack.
145pub const MAX_CLOCK_SKEW_MS: i64 = 60 * 1000;
146
147/// **The appliance's word that one principal may act for one tenant.**
148#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
149pub struct ActorTicket {
150 /// Format version. `1`, and a verifier refuses anything else rather than
151 /// guessing at a field it does not know.
152 pub v: u32,
153 /// Which appliance minted it, as a product id (`gunnar`). Decorative for a
154 /// verifier that holds one key — the key IS the issuer — and load-bearing
155 /// the day a monetize holds two.
156 pub issuer: String,
157 /// The product this ticket may be spent on, as `ProductInfo.id`. Checked
158 /// against the order, so a ticket from one product's appliance cannot buy
159 /// another product's period.
160 pub product: String,
161 /// The tenant the bearer may act for. On gunnar this is the namespace,
162 /// which is the principal's own id.
163 pub tenant: String,
164 /// The one verb this ticket authorises: [`PURPOSE_RENEW`] or
165 /// [`PURPOSE_ORDER`].
166 pub purpose: String,
167 /// **The caps this ticket covers**, for [`PURPOSE_ORDER`] — the target caps
168 /// the order will name, meter by meter.
169 ///
170 /// **Empty for [`PURPOSE_RENEW`], and required to be**: a renewal names no
171 /// caps at all (they come off the tenant's stored fact), so a renewal
172 /// ticket carrying any is either a client that has confused the two paths
173 /// or an order wearing a renewal's word. [`verify_ticket`] refuses both.
174 ///
175 /// A `BTreeMap` because it is serialised into the signed message and the
176 /// canonical form sorts keys anyway; the ordered map makes the Rust value
177 /// and the signed bytes agree by construction rather than by the
178 /// serialiser's mood.
179 #[serde(default)]
180 pub caps: std::collections::BTreeMap<String, u64>,
181 /// Single-use, chosen by the issuer. The verifier keeps a ring and refuses
182 /// a repeat — see [`SeenNonces`].
183 pub nonce: String,
184 pub issued_unix_ms: i64,
185 pub expires_unix_ms: i64,
186 /// Base64, standard alphabet, padded. Not part of [`ticket_message`].
187 #[serde(default)]
188 pub signature: String,
189}
190
191impl ActorTicket {
192 /// Build an unsigned ticket. The issuer signs [`ticket_message`] of it and
193 /// fills in [`ActorTicket::signature`].
194 ///
195 /// Not a constructor that signs, because this crate holds no key: it is
196 /// compiled into products, and a product must be able to CHECK a ticket
197 /// without being able to MINT one. That asymmetry is the same one
198 /// [`crate::signing`] has for facts, and it is deliberate.
199 pub fn unsigned(
200 issuer: impl Into<String>,
201 product: impl Into<String>,
202 tenant: impl Into<String>,
203 purpose: impl Into<String>,
204 nonce: impl Into<String>,
205 issued_unix_ms: i64,
206 life_ms: i64,
207 ) -> ActorTicket {
208 ActorTicket {
209 v: 1,
210 issuer: issuer.into(),
211 product: product.into(),
212 tenant: tenant.into(),
213 purpose: purpose.into(),
214 caps: std::collections::BTreeMap::new(),
215 nonce: nonce.into(),
216 issued_unix_ms,
217 expires_unix_ms: issued_unix_ms.saturating_add(life_ms),
218 signature: String::new(),
219 }
220 }
221
222 /// **The caps this order ticket covers.** Builder rather than an eighth
223 /// positional argument, because every renewal would otherwise pass an empty
224 /// map at a call site where the emptiness is the whole point and reads as
225 /// noise.
226 pub fn covering(mut self, caps: std::collections::BTreeMap<String, u64>) -> ActorTicket {
227 self.caps = caps;
228 self
229 }
230}
231
232/// The bytes a ticket's signature covers: every field except `signature`.
233pub fn ticket_message(ticket: &ActorTicket) -> Vec<u8> {
234 let mut v = serde_json::to_value(ticket).expect("a ticket serializes");
235 v.as_object_mut()
236 .expect("a ticket is an object")
237 .remove("signature");
238 let mut s = String::new();
239 canonical_json(&v, &mut s);
240 s.into_bytes()
241}
242
243/// **Parse, verify and admit a ticket — or refuse it, saying which rule it
244/// broke.**
245///
246/// Every argument is a thing the caller must supply rather than a default this
247/// function could pick, because each one is a check that a verifier which
248/// "just checked the signature" would have skipped:
249///
250/// * `key` — the appliance's Ed25519 verifying key. A ticket is only ever as
251/// good as the key it is checked against, and that key is configuration.
252/// * `product` / `tenant` — what the ORDER says. A valid ticket for another
253/// tenant is the whole attack, and comparing it here means no caller can
254/// forget to.
255/// * `purpose` — [`PURPOSE_RENEW`] or [`PURPOSE_ORDER`]. Named by the caller so
256/// a ticket minted for a verb this build has never heard of is refused, and
257/// so a renewal ticket can never be spent on an order.
258/// * `caps` — what the ORDER actually names. For [`PURPOSE_ORDER`] this must
259/// equal the ticket's own caps exactly; for [`PURPOSE_RENEW`] both must be
260/// empty. This is the argument that stops an order ticket being a blank
261/// cheque: without it the appliance would be vouching for "this human may
262/// buy", and the amount would be whatever the thing holding the ticket
263/// decided to ask for.
264/// * `now_unix_ms` — the verifier's clock, so expiry is not optional.
265///
266/// Order: structure, then signature, then the claims. The claims are only
267/// compared once the signature says the appliance really wrote them, so a
268/// refusal about a tenant or an expiry is a statement about a genuine ticket
269/// and not about an attacker's JSON.
270pub fn verify_ticket(
271 bytes: &[u8],
272 key: &VerifyingKey,
273 product: &str,
274 tenant: &str,
275 purpose: &str,
276 caps: &std::collections::BTreeMap<String, u64>,
277 now_unix_ms: i64,
278) -> Result<ActorTicket, SignatureError> {
279 let ticket: ActorTicket =
280 serde_json::from_slice(bytes).map_err(|_| SignatureError::Malformed)?;
281 if ticket.v != 1
282 || ticket.nonce.trim().is_empty()
283 || ticket.nonce.len() > 256
284 || ticket.tenant.trim().is_empty()
285 || ticket.product.trim().is_empty()
286 {
287 return Err(SignatureError::Malformed);
288 }
289 let sig = base64_decode(&ticket.signature).ok_or(SignatureError::Malformed)?;
290 if !check(key, &ticket_message(&ticket), &sig)? {
291 return Err(SignatureError::Ticket(
292 "the signature does not verify against this appliance's key".to_owned(),
293 ));
294 }
295 // ── The claims, now that they are known to be the appliance's ──────────
296 //
297 // `Ticket` carries one sentence and it is deliberately the same shape for
298 // every one of these: the CALLER decides what a customer is told. A
299 // verifier that returned "…for tenant beta, not alpha" and let that reach a
300 // browser would answer, for any name a stranger cares to type, whether a
301 // ticket for it exists — which is the enumeration the tenant path exists to
302 // avoid.
303 if ticket.purpose != purpose {
304 return Err(SignatureError::Ticket(format!(
305 "this ticket authorises {:?} and the call is {purpose:?}",
306 ticket.purpose
307 )));
308 }
309 if ticket.product != product {
310 return Err(SignatureError::Ticket(format!(
311 "this ticket was minted for product {:?}",
312 ticket.product
313 )));
314 }
315 if ticket.tenant != tenant {
316 return Err(SignatureError::Ticket(
317 "this ticket names another tenant".to_owned(),
318 ));
319 }
320 // ── The amount, for an order ───────────────────────────────────────────
321 //
322 // **Refused, never ignored**, which is the rule every other field here is
323 // held to. A ticket whose caps do not match the order it arrived with is
324 // not a ticket for this order, and quietly running the order anyway would
325 // charge for something the appliance never vouched for.
326 if ticket.caps != *caps {
327 return Err(SignatureError::Ticket(format!(
328 "this ticket covers {} meter(s) and the order names {}",
329 ticket.caps.len(),
330 caps.len()
331 )));
332 }
333 // **The capless verbs name no caps on either side.** Stated separately from
334 // the comparison above, because `{} == {}` would pass an order-shaped
335 // renewal silently and the two mistakes have different remedies.
336 //
337 // A renewal sells another period of what is already held, and a read (or
338 // a cancellation) sells nothing at all; in both the caps come off the tenant's stored fact, so a
339 // ticket of either kind that CARRIES caps is an order wearing another
340 // verb's word, or a client that has confused two paths.
341 if (ticket.purpose == PURPOSE_RENEW || ticket.purpose == PURPOSE_READ || ticket.purpose == PURPOSE_CANCEL)
342 && !ticket.caps.is_empty()
343 {
344 return Err(SignatureError::Ticket(format!(
345 "a {:?} ticket names no caps: the caps come off the tenant's stored fact, and only \
346 an order names an amount",
347 ticket.purpose
348 )));
349 }
350 if ticket.expires_unix_ms <= ticket.issued_unix_ms
351 || ticket.expires_unix_ms.saturating_sub(ticket.issued_unix_ms) > MAX_LIFE_MS
352 {
353 return Err(SignatureError::Ticket(format!(
354 "a ticket may live at most {MAX_LIFE_MS} ms and this one claims {} ms",
355 ticket.expires_unix_ms.saturating_sub(ticket.issued_unix_ms)
356 )));
357 }
358 if now_unix_ms >= ticket.expires_unix_ms {
359 return Err(SignatureError::Ticket("this ticket has expired".to_owned()));
360 }
361 if ticket.issued_unix_ms.saturating_sub(now_unix_ms) > MAX_CLOCK_SKEW_MS {
362 return Err(SignatureError::Ticket(
363 "this ticket is issued further in the future than two clocks explain".to_owned(),
364 ));
365 }
366 Ok(ticket)
367}
368
369/// **The nonces already spent, so a ticket is single use.**
370///
371/// A ring and not a set: the thing that writes to it is a verified ticket with
372/// a bounded life, so what has to be remembered is one life's worth of them and
373/// never the whole history. [`MAX_LIFE_MS`] is ten minutes; a box selling a
374/// renewal a second for ten minutes fills six hundred slots.
375///
376/// **In memory, and that is a stated limit rather than an oversight.** A
377/// monetize that restarts forgets, and a ticket replayed across that restart
378/// would be admitted — within its ten-minute life, by somebody who had already
379/// captured it, to buy the tenant it already names another period of the same
380/// subscription. The exposure is one duplicate renewal of the attacker's own
381/// account, and `Purchase.Start` is idempotent on its reference anyway
382/// (`<product>/<tenant>/<date>`), so the second one is the same order. Making
383/// it durable would mean a table, and a table is worth its cost when the thing
384/// it prevents is worth more than a repeated no-op.
385#[derive(Debug)]
386pub struct SeenNonces {
387 ring: std::sync::Mutex<std::collections::VecDeque<String>>,
388 cap: usize,
389}
390
391impl Default for SeenNonces {
392 fn default() -> SeenNonces {
393 SeenNonces::with_capacity(4096)
394 }
395}
396
397impl SeenNonces {
398 pub fn with_capacity(cap: usize) -> SeenNonces {
399 SeenNonces {
400 ring: std::sync::Mutex::new(std::collections::VecDeque::with_capacity(cap.min(1024))),
401 cap: cap.max(1),
402 }
403 }
404
405 /// Record this nonce and say whether it was **new**. `false` means it has
406 /// been spent and the ticket must be refused.
407 pub fn admit(&self, nonce: &str) -> bool {
408 let mut ring = self.ring.lock().unwrap_or_else(|e| e.into_inner());
409 if ring.iter().any(|seen| seen == nonce) {
410 return false;
411 }
412 if ring.len() >= self.cap {
413 ring.pop_front();
414 }
415 ring.push_back(nonce.to_owned());
416 true
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 use ed25519_dalek::{Signer, SigningKey};
425
426 use crate::signing::base64_encode;
427
428 const NOW: i64 = 1_760_000_000_000;
429
430 fn appliance() -> SigningKey {
431 SigningKey::from_bytes(&[7u8; 32])
432 }
433
434 /// Mint the way a real appliance does: build unsigned, sign the canonical
435 /// message, fill in the signature, serialise.
436 fn mint(key: &SigningKey, ticket: ActorTicket) -> Vec<u8> {
437 let mut ticket = ticket;
438 let sig = key.sign(&ticket_message(&ticket));
439 ticket.signature = base64_encode(&sig.to_bytes());
440 serde_json::to_vec(&ticket).expect("a ticket serialises")
441 }
442
443 /// No caps: what a renewal names, and what every renewal assertion passes.
444 fn none() -> std::collections::BTreeMap<String, u64> {
445 std::collections::BTreeMap::new()
446 }
447
448 /// The caps an order names. One meter, a number no other fixture uses.
449 fn ten_gib() -> std::collections::BTreeMap<String, u64> {
450 std::collections::BTreeMap::from([("pack_bytes".to_owned(), 10_737_418_240u64)])
451 }
452
453 fn order_for(tenant: &str) -> ActorTicket {
454 ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_ORDER, "n-1", NOW, 60_000)
455 .covering(ten_gib())
456 }
457
458 fn renew_for(tenant: &str) -> ActorTicket {
459 ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_RENEW, "n-1", NOW, 60_000)
460 }
461
462 #[test]
463 fn a_ticket_the_appliance_signed_is_admitted_for_the_tenant_it_names() {
464 let key = appliance();
465 let bytes = mint(&key, renew_for("alice"));
466
467 let ticket = verify_ticket(
468 &bytes,
469 &key.verifying_key(),
470 "gunnar",
471 "alice",
472 PURPOSE_RENEW,
473 &none(),
474 NOW + 1_000,
475 )
476 .expect("the appliance's own ticket must verify");
477
478 assert_eq!(ticket.tenant, "alice");
479 assert_eq!(ticket.purpose, PURPOSE_RENEW);
480 }
481
482 /// ★ **The attack this whole mechanism exists to refuse.**
483 ///
484 /// A perfectly valid ticket, signed by the real appliance, for the caller's
485 /// OWN tenant — spent on somebody else's. Nothing about the signature is
486 /// wrong; the ticket is genuine. What refuses it is that `verify_ticket`
487 /// compares the tenant in the signed claims against the tenant in the
488 /// ORDER, and takes both as arguments so that no call site can forget one.
489 #[test]
490 fn a_genuine_ticket_cannot_be_spent_on_another_tenant() {
491 let key = appliance();
492 let bytes = mint(&key, renew_for("alice"));
493
494 let refused = verify_ticket(
495 &bytes,
496 &key.verifying_key(),
497 "gunnar",
498 "beta",
499 PURPOSE_RENEW,
500 &none(),
501 NOW + 1_000,
502 )
503 .expect_err("alice's ticket must not renew beta");
504
505 let said = refused.to_string();
506 assert!(
507 !said.contains("alice"),
508 "the refusal names the ticket's own tenant to whoever asked about another: {said}"
509 );
510 }
511
512 /// A ticket from an appliance this monetize does not hold the key for.
513 #[test]
514 fn a_ticket_signed_by_another_key_is_refused() {
515 let bytes = mint(&SigningKey::from_bytes(&[9u8; 32]), renew_for("alice"));
516
517 assert!(verify_ticket(
518 &bytes,
519 &appliance().verifying_key(),
520 "gunnar",
521 "alice",
522 PURPOSE_RENEW,
523 &none(),
524 NOW + 1_000,
525 )
526 .is_err());
527 }
528
529 /// **The signature covers every field**, so editing one after the fact
530 /// breaks it — the tenant most of all.
531 #[test]
532 fn rewriting_the_tenant_after_signing_breaks_the_signature() {
533 let key = appliance();
534 let bytes = mint(&key, renew_for("alice"));
535 let mut ticket: ActorTicket = serde_json::from_slice(&bytes).expect("parses");
536 ticket.tenant = "beta".to_owned();
537 let forged = serde_json::to_vec(&ticket).expect("serialises");
538
539 assert!(verify_ticket(
540 &forged,
541 &key.verifying_key(),
542 "gunnar",
543 "beta",
544 PURPOSE_RENEW,
545 &none(),
546 NOW + 1_000,
547 )
548 .is_err());
549 }
550
551 /// A ticket for one product must not buy another product's period, even
552 /// from an appliance whose key this monetize holds.
553 #[test]
554 fn a_ticket_is_bound_to_its_product() {
555 let key = appliance();
556 let bytes = mint(&key, renew_for("alice"));
557
558 assert!(verify_ticket(
559 &bytes,
560 &key.verifying_key(),
561 "holger",
562 "alice",
563 PURPOSE_RENEW,
564 &none(),
565 NOW + 1_000,
566 )
567 .is_err());
568 }
569
570 /// The purpose is compared, so a ticket minted for a verb invented later
571 /// is refused by a build that predates it rather than waved through.
572 #[test]
573 fn a_ticket_for_another_purpose_is_refused() {
574 let key = appliance();
575 let bytes = mint(
576 &key,
577 ActorTicket::unsigned("gunnar", "gunnar", "alice", "order", "n-1", NOW, 60_000),
578 );
579
580 assert!(verify_ticket(
581 &bytes,
582 &key.verifying_key(),
583 "gunnar",
584 "alice",
585 PURPOSE_RENEW,
586 &none(),
587 NOW + 1_000,
588 )
589 .is_err());
590 }
591
592 #[test]
593 fn an_expired_ticket_is_refused() {
594 let key = appliance();
595 let bytes = mint(&key, renew_for("alice"));
596
597 assert!(verify_ticket(
598 &bytes,
599 &key.verifying_key(),
600 "gunnar",
601 "alice",
602 PURPOSE_RENEW,
603 &none(),
604 NOW + 60_001,
605 )
606 .is_err());
607 }
608
609 /// ★ **The cap is this crate's rule and not the issuer's.**
610 ///
611 /// An appliance talked into minting a ticket that lives a year signs it
612 /// perfectly well, and every claim in it is genuine. It is still refused,
613 /// because the life a verifier will accept is not a thing the signer gets
614 /// to choose.
615 #[test]
616 fn a_ticket_that_outlives_the_cap_is_refused_even_though_it_verifies() {
617 let key = appliance();
618 let bytes = mint(
619 &key,
620 ActorTicket::unsigned(
621 "gunnar",
622 "gunnar",
623 "alice",
624 PURPOSE_RENEW,
625 "n-1",
626 NOW,
627 365 * 24 * 60 * 60 * 1000,
628 ),
629 );
630
631 assert!(verify_ticket(
632 &bytes,
633 &key.verifying_key(),
634 "gunnar",
635 "alice",
636 PURPOSE_RENEW,
637 &none(),
638 NOW + 1_000,
639 )
640 .is_err());
641 }
642
643 /// A ticket dated far enough ahead to outlive its own expiry check is
644 /// refused rather than trusted — otherwise an issuer with a wrong clock
645 /// mints something that never expires from the verifier's point of view.
646 #[test]
647 fn a_ticket_from_the_far_future_is_refused() {
648 let key = appliance();
649 let bytes = mint(&key, renew_for("alice"));
650
651 assert!(verify_ticket(
652 &bytes,
653 &key.verifying_key(),
654 "gunnar",
655 "alice",
656 PURPOSE_RENEW,
657 &none(),
658 NOW - MAX_CLOCK_SKEW_MS - 1,
659 )
660 .is_err());
661 }
662
663 /// An order ticket verifies for the caps it names.
664 #[test]
665 fn an_order_ticket_is_admitted_for_the_caps_it_covers() {
666 let key = appliance();
667 let bytes = mint(&key, order_for("alice"));
668
669 let ticket = verify_ticket(
670 &bytes,
671 &key.verifying_key(),
672 "gunnar",
673 "alice",
674 PURPOSE_ORDER,
675 &ten_gib(),
676 NOW + 1_000,
677 )
678 .expect("the appliance's own order ticket must verify");
679
680 assert_eq!(ticket.caps, ten_gib());
681 }
682
683 /// ★ **An order ticket is not a blank cheque.**
684 ///
685 /// The ticket is genuine, signed by the real appliance, for the right
686 /// tenant, the right product and the right verb. Only the AMOUNT differs —
687 /// the order asks for a hundred times what the customer agreed to. Without
688 /// the caps in the signed message and compared here, the appliance would be
689 /// vouching for "this human may buy" and whatever held the ticket would
690 /// choose how much.
691 #[test]
692 fn an_order_ticket_cannot_be_spent_on_a_bigger_order() {
693 let key = appliance();
694 let bytes = mint(&key, order_for("alice"));
695 let hundredfold = std::collections::BTreeMap::from([(
696 "pack_bytes".to_owned(),
697 1_073_741_824_000u64,
698 )]);
699
700 assert!(verify_ticket(
701 &bytes,
702 &key.verifying_key(),
703 "gunnar",
704 "alice",
705 PURPOSE_ORDER,
706 &hundredfold,
707 NOW + 1_000,
708 )
709 .is_err());
710 }
711
712 /// An order ticket must not be spendable as a renewal, nor the reverse.
713 /// Both directions, because they fail for different reasons and a verifier
714 /// that got one right could still wave the other through.
715 #[test]
716 fn a_renewal_ticket_and_an_order_ticket_are_not_interchangeable() {
717 let key = appliance();
718
719 let renewal = mint(&key, renew_for("alice"));
720 assert!(
721 verify_ticket(
722 &renewal,
723 &key.verifying_key(),
724 "gunnar",
725 "alice",
726 PURPOSE_ORDER,
727 &ten_gib(),
728 NOW + 1_000,
729 )
730 .is_err(),
731 "a renewal ticket bought room"
732 );
733
734 let order = mint(&key, order_for("alice"));
735 assert!(
736 verify_ticket(
737 &order,
738 &key.verifying_key(),
739 "gunnar",
740 "alice",
741 PURPOSE_RENEW,
742 &none(),
743 NOW + 1_000,
744 )
745 .is_err(),
746 "an order ticket renewed a period"
747 );
748 }
749
750 /// A renewal ticket that carries caps is refused — an order wearing a
751 /// renewal's word, or a client that has confused the two paths.
752 #[test]
753 fn a_renewal_ticket_carrying_caps_is_refused() {
754 let key = appliance();
755 let bytes = mint(&key, renew_for("alice").covering(ten_gib()));
756
757 assert!(verify_ticket(
758 &bytes,
759 &key.verifying_key(),
760 "gunnar",
761 "alice",
762 PURPOSE_RENEW,
763 &ten_gib(),
764 NOW + 1_000,
765 )
766 .is_err());
767 }
768
769 /// The signature covers the caps, so editing them after signing breaks it.
770 #[test]
771 fn rewriting_the_caps_after_signing_breaks_the_signature() {
772 let key = appliance();
773 let bytes = mint(&key, order_for("alice"));
774 let mut ticket: ActorTicket = serde_json::from_slice(&bytes).expect("parses");
775 ticket.caps = std::collections::BTreeMap::from([("pack_bytes".to_owned(), 1u64)]);
776 let forged = serde_json::to_vec(&ticket).expect("serialises");
777
778 assert!(verify_ticket(
779 &forged,
780 &key.verifying_key(),
781 "gunnar",
782 "alice",
783 PURPOSE_ORDER,
784 &ticket.caps,
785 NOW + 1_000,
786 )
787 .is_err());
788 }
789
790 fn read_for(tenant: &str) -> ActorTicket {
791 ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_READ, "n-1", NOW, 60_000)
792 }
793
794 /// A read ticket verifies for a read, and names no caps.
795 #[test]
796 fn a_read_ticket_is_admitted_for_a_read() {
797 let key = appliance();
798 let bytes = mint(&key, read_for("alice"));
799
800 let ticket = verify_ticket(
801 &bytes,
802 &key.verifying_key(),
803 "gunnar",
804 "alice",
805 PURPOSE_READ,
806 &none(),
807 NOW + 1_000,
808 )
809 .expect("the appliance's own read ticket must verify");
810
811 assert!(ticket.caps.is_empty());
812 }
813
814 /// ★ **A read ticket buys nothing, and a renewal ticket is not a read.**
815 ///
816 /// The first half is what the verb is FOR: before `PURPOSE_READ` existed a
817 /// history read was gated by a renewal ticket, which worked and was a lie
818 /// of convenience — the appliance vouching for the wrong verb, and a
819 /// single-use credential burned listing a page. Now each says what it is,
820 /// and neither is spendable as the other.
821 ///
822 /// Both directions, because they fail for different reasons and a verifier
823 /// that got one right could wave the other through.
824 #[test]
825 fn a_read_ticket_and_a_renewal_ticket_are_not_interchangeable() {
826 let key = appliance();
827
828 let read = mint(&key, read_for("alice"));
829 assert!(
830 verify_ticket(
831 &read,
832 &key.verifying_key(),
833 "gunnar",
834 "alice",
835 PURPOSE_RENEW,
836 &none(),
837 NOW + 1_000,
838 )
839 .is_err(),
840 "a read ticket bought a period"
841 );
842
843 let renewal = mint(&key, renew_for("alice"));
844 assert!(
845 verify_ticket(
846 &renewal,
847 &key.verifying_key(),
848 "gunnar",
849 "alice",
850 PURPOSE_READ,
851 &none(),
852 NOW + 1_000,
853 )
854 .is_err(),
855 "a renewal ticket was spent on a read — the lie of convenience this verb replaces"
856 );
857 }
858
859 /// A read ticket cannot buy room either, and an order ticket cannot read.
860 #[test]
861 fn a_read_ticket_and_an_order_ticket_are_not_interchangeable() {
862 let key = appliance();
863
864 let read = mint(&key, read_for("alice"));
865 assert!(verify_ticket(
866 &read,
867 &key.verifying_key(),
868 "gunnar",
869 "alice",
870 PURPOSE_ORDER,
871 &ten_gib(),
872 NOW + 1_000,
873 )
874 .is_err());
875
876 let order = mint(&key, order_for("alice"));
877 assert!(verify_ticket(
878 &order,
879 &key.verifying_key(),
880 "gunnar",
881 "alice",
882 PURPOSE_READ,
883 &none(),
884 NOW + 1_000,
885 )
886 .is_err());
887 }
888
889 /// A read ticket carrying caps is refused — a read buys nothing, so there
890 /// is no amount for it to name.
891 #[test]
892 fn a_read_ticket_carrying_caps_is_refused() {
893 let key = appliance();
894 let bytes = mint(&key, read_for("alice").covering(ten_gib()));
895
896 assert!(verify_ticket(
897 &bytes,
898 &key.verifying_key(),
899 "gunnar",
900 "alice",
901 PURPOSE_READ,
902 &ten_gib(),
903 NOW + 1_000,
904 )
905 .is_err());
906 }
907
908 /// A read ticket is still bound to its tenant: the verb being harmless does
909 /// not make the scope optional.
910 #[test]
911 fn a_read_ticket_cannot_read_another_tenant() {
912 let key = appliance();
913 let bytes = mint(&key, read_for("alice"));
914
915 let refused = verify_ticket(
916 &bytes,
917 &key.verifying_key(),
918 "gunnar",
919 "beta",
920 PURPOSE_READ,
921 &none(),
922 NOW + 1_000,
923 )
924 .expect_err("alice's read ticket must not read beta");
925
926 assert!(
927 !refused.to_string().contains("alice"),
928 "the refusal names the ticket's own tenant to whoever asked about another"
929 );
930 }
931
932 #[test]
933 fn a_nonce_is_admitted_once() {
934 let seen = SeenNonces::with_capacity(4);
935 assert!(seen.admit("n-1"));
936 assert!(!seen.admit("n-1"), "a nonce must be spendable once");
937 assert!(seen.admit("n-2"));
938 }
939
940 #[test]
941 fn the_nonce_ring_does_not_grow_without_bound() {
942 let seen = SeenNonces::with_capacity(2);
943 assert!(seen.admit("a"));
944 assert!(seen.admit("b"));
945 assert!(seen.admit("c"));
946 // `a` fell off the front, so it is admitted again. That is the ring's
947 // stated trade and the reason its capacity covers one ticket life.
948 assert!(seen.admit("a"));
949 assert!(!seen.admit("c"));
950 }
951
952 #[test]
953 fn rubbish_is_refused_without_panicking() {
954 let key = appliance().verifying_key();
955 for bytes in [&b""[..], b"{", b"{}", b"null", b"[1,2,3]"] {
956 assert!(
957 verify_ticket(bytes, &key, "gunnar", "alice", PURPOSE_RENEW, &none(), NOW).is_err(),
958 "{bytes:?} was not refused"
959 );
960 }
961 }
962}
963
964/// R5a: the ring runs on every box that admits a ticket. A panic that once held
965/// it must not make every later sign-in panic; the ring's one change (pop, then
966/// push) leaves a ring either way, and a spent nonce in it stays spent.
967#[cfg(test)]
968mod poisoned_ring_tests {
969 use super::*;
970
971 #[test]
972 fn a_poisoned_ring_still_admits_and_still_refuses_a_spent_nonce() {
973 let seen = SeenNonces::with_capacity(4);
974 assert!(seen.admit("n1"));
975 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
976 let _held = seen.ring.lock();
977 panic!("a panic while the ring is held");
978 }));
979 assert!(seen.ring.is_poisoned());
980 assert!(!seen.admit("n1"), "spent before the panic, spent after it");
981 assert!(seen.admit("n2"));
982 }
983}