Skip to main content

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