monetize-embed 0.1.1

The thin client a monetized product compiles in: an Ed25519-verified entitlement cache with nanosecond verdicts that keeps answering while the licence server is unreachable. No network, no ledger — the product feeds it signed facts and asks.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
//! **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";

/// 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()
        )));
    }
    // A renewal names 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.
    if ticket.purpose == PURPOSE_RENEW && !ticket.caps.is_empty() {
        return Err(SignatureError::Ticket(
            "a renewal ticket names no caps: a renewal sells another period of what is already \
             held, and the caps come off the stored fact"
                .to_owned(),
        ));
    }
    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().expect("the nonce ring is not poisoned");
        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());
    }

    #[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"
            );
        }
    }
}