openvtc-core 0.4.0

OpenVTC Core Library
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
//! Vetting Tickets: how a vetter lets someone ask them for vetting (design §8).
//!
//! A vetter never takes unsolicited requests. They hand a ticket to a person
//! out of band — read aloud at a conference desk, pasted into a chat, shown as
//! a QR code — and only a request carrying a live ticket gets any answer.
//!
//! A ticket has two forms:
//!
//! - the **code**, `XXXX-XXXX` in Crockford base32. Forty bits, short enough to
//!   say, and therefore guessable in principle. A wrong code is never answered,
//!   so a guesser cannot even learn that the vetter exists, and wrong codes are
//!   throttled per sender and overall ([`GuessThrottle`]);
//! - the **scanned** form, a ticket id and a 32-byte secret. Nobody guesses
//!   that by accident, so a wrong secret is answered with `invalidTicket` and
//!   the applicant learns to ask for a fresh one.
//!
//! Tickets are client-local in V0. Moving them to the VTA
//! (`vetting/tickets/*`) is V1.

use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};
use chrono::{DateTime, Duration, Utc};
use rand::{RngCore, rngs::OsRng};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use vta_sdk::protocols::vetting::request::v0_1 as request;
use vta_sdk::protocols::vetting::{VETTING_REQUEST_ERR_INVALID_TICKET, VettingMethod};
use vta_sdk::vetting::ticket_uri::{self, TicketUri};

use crate::config::account::PersonaId;

/// Crockford base32: no `I`, `L`, `O` or `U`, so nothing is misheard.
pub const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";

/// How long a ticket lives unless the vetter says otherwise.
pub const DEFAULT_VALIDITY: Duration = Duration::days(14);

/// The window wrong codes are counted over.
pub const GUESS_WINDOW: Duration = Duration::hours(1);

/// Wrong codes one sender may send in [`GUESS_WINDOW`] before every further
/// code from them is ignored, right or wrong.
pub const MAX_WRONG_CODES_PER_SENDER: usize = 5;

/// Wrong codes from everyone together in [`GUESS_WINDOW`]. A sender who mints a
/// fresh DID per guess gets past the per-sender limit; this bounds them too.
/// Scanned tickets are unaffected, so a real applicant is never locked out.
pub const MAX_WRONG_CODES: usize = 60;

/// One ticket, as the vetter keeps it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ticket {
    /// `ticketId` in the scanned form.
    pub id: String,
    /// `XXXX-XXXX`.
    pub code: String,
    /// 32 bytes, base64url.
    pub secret: String,
    /// The community the ticket is for.
    pub community: String,
    /// The vetter's member persona in that community.
    pub persona: PersonaId,
    /// Methods the vetter offers on this ticket; empty means any.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub methods: Vec<VettingMethod>,
    /// Requests it can still admit. A conference-desk ticket has more than one.
    pub uses_left: u32,
    /// When it was made.
    pub created_at: DateTime<Utc>,
    /// After this it admits nothing.
    pub expires_at: DateTime<Utc>,
    /// The vetter's own note ("LPC desk", "for Alice").
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
}

impl Ticket {
    /// Mint a ticket. `uses` is at least one.
    #[must_use]
    pub fn issue(
        community: impl Into<String>,
        persona: PersonaId,
        methods: Vec<VettingMethod>,
        uses: u32,
        validity: Duration,
        now: DateTime<Utc>,
    ) -> Ticket {
        let mut code = [0u8; 5];
        OsRng.fill_bytes(&mut code);
        let mut secret = [0u8; 32];
        OsRng.fill_bytes(&mut secret);
        Ticket {
            id: format!("vt-{}", Uuid::new_v4().simple()),
            code: encode_code(code),
            secret: BASE64_URL_SAFE_NO_PAD.encode(secret),
            community: community.into(),
            persona,
            methods,
            uses_left: uses.max(1),
            created_at: now,
            expires_at: now + validity,
            label: None,
        }
    }

    /// It can still admit a request.
    #[must_use]
    pub fn is_live(&self, now: DateTime<Utc>) -> bool {
        self.uses_left > 0 && now < self.expires_at
    }

    /// It offers `method`, or the applicant expressed no preference.
    #[must_use]
    pub fn offers(&self, method: Option<VettingMethod>) -> bool {
        self.methods.is_empty() || method.is_none_or(|m| self.methods.contains(&m))
    }

    /// The spoken form, as an applicant presents it.
    ///
    /// # Errors
    ///
    /// The published ticket refuses a code that breaks its pattern. Every code
    /// [`Ticket::issue`] mints satisfies it, so this fails only for a ticket
    /// read from a config some other build wrote.
    pub fn code_presentation(&self) -> Result<request::Ticket, String> {
        let code = request::ShortCodeTicket::try_from(
            request::ShortCodeTicket::builder().code(self.code.clone()),
        )
        .map_err(|e| format!("ticket code: {e}"))?;
        Ok(request::Ticket::ShortCodeTicket(code))
    }

    /// The scanned form, as an applicant presents it.
    ///
    /// # Errors
    ///
    /// As [`Ticket::code_presentation`], for the ticket id and secret.
    pub fn scanned_presentation(&self) -> Result<request::Ticket, String> {
        let ticket = request::QrTicket::try_from(
            request::QrTicket::builder()
                .ticket_id(self.id.clone())
                .secret(self.secret.clone()),
        )
        .map_err(|e| format!("scanned ticket: {e}"))?;
        Ok(request::Ticket::QrTicket(ticket))
    }

    /// The ticket as a link — what its QR code carries. `vetter` is the DID of
    /// the persona the ticket admits requests to.
    ///
    /// The link carries the scanned form: full-entropy, so it is refused
    /// outright when wrong rather than silently throttled, and nobody guesses
    /// it. The short code stays for reading aloud.
    ///
    /// # Errors
    ///
    /// As [`Ticket::scanned_presentation`], or a ticket form the URI has no
    /// members for — `ticket_uri::encode` returns a `Result` on this line.
    pub fn uri(&self, vetter: &str) -> Result<String, String> {
        ticket_uri::encode(&TicketUri {
            community: self.community.clone(),
            vetter: vetter.to_string(),
            presentation: self.scanned_presentation()?,
        })
        .map_err(|e| e.to_string())
    }
}

/// Forty bits → `XXXX-XXXX`.
fn encode_code(bytes: [u8; 5]) -> String {
    let bits = bytes
        .iter()
        .fold(0u64, |acc, byte| (acc << 8) | u64::from(*byte));
    let mut out = String::with_capacity(9);
    for i in 0..8 {
        if i == 4 {
            out.push('-');
        }
        out.push(char::from(
            CROCKFORD[((bits >> (35 - 5 * i)) & 0x1f) as usize],
        ));
    }
    out
}

/// Read a code the way a person types it: any case, with or without the dash
/// or spaces, and with the letters Crockford reads as digits (`O` → `0`,
/// `I`/`L` → `1`). `None` if it cannot be a code at all.
#[must_use]
pub fn normalise_code(input: &str) -> Option<String> {
    let mut chars = String::with_capacity(8);
    for c in input.chars() {
        let c = match c.to_ascii_uppercase() {
            '-' | ' ' => continue,
            'O' => '0',
            'I' | 'L' => '1',
            other => other,
        };
        if !c.is_ascii() || !CROCKFORD.contains(&(c as u8)) {
            return None;
        }
        chars.push(c);
    }
    (chars.len() == 8).then(|| format!("{}-{}", &chars[..4], &chars[4..]))
}

/// Equal-length comparison that does not stop at the first difference.
fn constant_time_eq(a: &str, b: &str) -> bool {
    a.len() == b.len()
        && a.bytes()
            .zip(b.bytes())
            .fold(0u8, |acc, (x, y)| acc | (x ^ y))
            == 0
}

/// Recent wrong codes.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct GuessThrottle {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    wrong: Vec<WrongCode>,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct WrongCode {
    sender: String,
    at: DateTime<Utc>,
}

impl GuessThrottle {
    /// Nothing recorded.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.wrong.is_empty()
    }

    fn prune(&mut self, now: DateTime<Utc>) {
        self.wrong.retain(|w| now - w.at < GUESS_WINDOW);
    }

    fn allows(&self, sender: &str) -> bool {
        self.wrong.len() < MAX_WRONG_CODES
            && self.wrong.iter().filter(|w| w.sender == sender).count() < MAX_WRONG_CODES_PER_SENDER
    }

    fn record(&mut self, sender: &str, now: DateTime<Utc>) {
        self.wrong.push(WrongCode {
            sender: sender.to_string(),
            at: now,
        });
    }
}

/// What a presented ticket earns the request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Redemption {
    /// A live ticket matches. Nothing is consumed yet: the request may still
    /// be refused for another reason, and a refused request should not cost
    /// the applicant their ticket. Call [`consume`] once it is accepted.
    Matched {
        /// The matching ticket.
        ticket_id: String,
    },
    /// Say nothing at all.
    Silent,
    /// Refuse with this `vetting/request` error code.
    Refused(&'static str),
}

/// Check a presented ticket against `persona`'s tickets for `community`.
pub fn check(
    tickets: &[Ticket],
    throttle: &mut GuessThrottle,
    presented: &request::Ticket,
    sender: &str,
    community: &str,
    persona: PersonaId,
    now: DateTime<Utc>,
) -> Redemption {
    match presented {
        request::Ticket::ShortCodeTicket(spoken) => {
            let code = spoken.code.as_str();
            throttle.prune(now);
            if !throttle.allows(sender) {
                return Redemption::Silent;
            }
            let found = normalise_code(code).and_then(|code| {
                tickets.iter().find(|t| {
                    t.persona == persona
                        && t.community == community
                        && t.is_live(now)
                        && constant_time_eq(&t.code, &code)
                })
            });
            match found {
                Some(ticket) => Redemption::Matched {
                    ticket_id: ticket.id.clone(),
                },
                None => {
                    throttle.record(sender, now);
                    Redemption::Silent
                }
            }
        }
        request::Ticket::QrTicket(scanned) => {
            let (ticket_id, secret) = (scanned.ticket_id.as_str(), scanned.secret.as_str());
            match tickets
                .iter()
                .find(|t| t.id == ticket_id && t.persona == persona)
            {
                Some(ticket)
                    if ticket.community == community
                        && ticket.is_live(now)
                        && constant_time_eq(&ticket.secret, secret) =>
                {
                    Redemption::Matched {
                        ticket_id: ticket.id.clone(),
                    }
                }
                _ => Redemption::Refused(VETTING_REQUEST_ERR_INVALID_TICKET),
            }
        }
        // The published ticket is `#[non_exhaustive]`: a form added by a later
        // `vetting/request` is refused rather than silently admitted.
        _ => Redemption::Refused(VETTING_REQUEST_ERR_INVALID_TICKET),
    }
}

/// Spend one use of the ticket. `false` if it is gone.
pub fn consume(tickets: &mut [Ticket], ticket_id: &str) -> bool {
    match tickets
        .iter_mut()
        .find(|t| t.id == ticket_id && t.uses_left > 0)
    {
        Some(ticket) => {
            ticket.uses_left -= 1;
            true
        }
        None => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use vta_sdk::protocols::vetting::check_request;

    const COMMUNITY: &str = "did:web:vtc.example";

    fn ticket(persona: PersonaId, now: DateTime<Utc>) -> Ticket {
        Ticket::issue(COMMUNITY, persona, vec![], 1, DEFAULT_VALIDITY, now)
    }

    /// A spoken ticket carrying `code`, which the published type refuses unless
    /// it is Crockford base32 in upper case.
    fn code_ticket(code: &str) -> Result<request::Ticket, String> {
        request::ShortCodeTicket::try_from(request::ShortCodeTicket::builder().code(code))
            .map(request::Ticket::ShortCodeTicket)
            .map_err(|e| e.to_string())
    }

    #[test]
    fn both_forms_satisfy_the_request_schema() {
        let t = ticket(PersonaId::new(), Utc::now());
        for presented in [
            t.code_presentation().unwrap(),
            t.scanned_presentation().unwrap(),
        ] {
            let body = request::Payload::try_from(
                request::Payload::builder()
                    .community(COMMUNITY)
                    .join_did("did:key:zApplicant")
                    .ticket(Some(presented)),
            )
            .unwrap();
            check_request(&body, "did:key:zApplicant").unwrap();
        }
    }

    /// The published code is upper-case Crockford base32. A code as a person
    /// typed it is normalised before it is put on the wire; the type refuses it
    /// otherwise, where the hand-written ticket carried whatever it was given.
    #[test]
    fn a_spoken_ticket_carries_the_code_in_the_published_form() {
        let t = ticket(PersonaId::new(), Utc::now());
        assert!(code_ticket(&t.code).is_ok());
        assert!(code_ticket(&t.code.to_lowercase()).is_err());
        assert!(code_ticket("K7QF2M9X").is_err(), "the dash is part of it");
    }

    #[test]
    fn a_ticket_link_carries_the_scanned_form() {
        let t = ticket(PersonaId::new(), Utc::now());
        let decoded = ticket_uri::decode(&t.uri("did:key:zVetter").unwrap()).unwrap();
        assert_eq!(decoded.community, COMMUNITY);
        assert_eq!(decoded.vetter, "did:key:zVetter");
        // The published ticket has no `PartialEq`; its members are compared.
        let request::Ticket::QrTicket(scanned) = &decoded.presentation else {
            panic!("a link carries the scanned form");
        };
        assert_eq!(scanned.ticket_id.as_str(), t.id);
        assert_eq!(scanned.secret.as_str(), t.secret);
    }

    #[test]
    fn a_code_is_read_the_way_people_type_it() {
        assert_eq!(normalise_code("k7qf 2m9x").as_deref(), Some("K7QF-2M9X"));
        assert_eq!(normalise_code("K7QF-2M9X").as_deref(), Some("K7QF-2M9X"));
        assert_eq!(normalise_code("o1il-0000").as_deref(), Some("0111-0000"));
        assert_eq!(normalise_code("K7QF-2M9"), None);
        assert_eq!(normalise_code("K7QF-2M9U"), None, "U is not Crockford");
    }

    #[test]
    fn the_right_code_matches_and_is_spent_only_when_consumed() {
        let persona = PersonaId::new();
        let now = Utc::now();
        let mut tickets = vec![ticket(persona, now)];
        let mut throttle = GuessThrottle::default();
        let presented = code_ticket(&tickets[0].code).unwrap();
        let r = check(
            &tickets,
            &mut throttle,
            &presented,
            "did:key:zA",
            COMMUNITY,
            persona,
            now,
        );
        let Redemption::Matched { ticket_id } = r else {
            panic!("expected a match, got {r:?}");
        };
        assert_eq!(tickets[0].uses_left, 1);
        assert!(consume(&mut tickets, &ticket_id));
        assert_eq!(
            check(
                &tickets,
                &mut throttle,
                &presented,
                "did:key:zA",
                COMMUNITY,
                persona,
                now
            ),
            Redemption::Silent,
            "a spent ticket admits nothing"
        );
    }

    #[test]
    fn wrong_codes_get_silence_and_then_nothing_at_all() {
        let persona = PersonaId::new();
        let now = Utc::now();
        let tickets = vec![ticket(persona, now)];
        let mut throttle = GuessThrottle::default();
        let wrong = code_ticket("0000-0000").unwrap();
        for _ in 0..MAX_WRONG_CODES_PER_SENDER {
            assert_eq!(
                check(
                    &tickets,
                    &mut throttle,
                    &wrong,
                    "did:key:zGuesser",
                    COMMUNITY,
                    persona,
                    now
                ),
                Redemption::Silent
            );
        }
        // The throttled sender now gets silence even for the right code…
        let right = tickets[0].code_presentation().unwrap();
        assert_eq!(
            check(
                &tickets,
                &mut throttle,
                &right,
                "did:key:zGuesser",
                COMMUNITY,
                persona,
                now
            ),
            Redemption::Silent
        );
        // …while someone else is unaffected, and the window passes.
        assert!(matches!(
            check(
                &tickets,
                &mut throttle,
                &right,
                "did:key:zApplicant",
                COMMUNITY,
                persona,
                now
            ),
            Redemption::Matched { .. }
        ));
        assert!(matches!(
            check(
                &tickets,
                &mut throttle,
                &right,
                "did:key:zGuesser",
                COMMUNITY,
                persona,
                now + GUESS_WINDOW
            ),
            Redemption::Matched { .. }
        ));
    }

    #[test]
    fn a_wrong_secret_is_refused_rather_than_ignored() {
        let persona = PersonaId::new();
        let now = Utc::now();
        let tickets = vec![ticket(persona, now)];
        let mut throttle = GuessThrottle::default();
        let presented = request::Ticket::QrTicket(
            request::QrTicket::try_from(
                request::QrTicket::builder()
                    .ticket_id(tickets[0].id.clone())
                    .secret("A".repeat(43)),
            )
            .unwrap(),
        );
        assert_eq!(
            check(
                &tickets,
                &mut throttle,
                &presented,
                "did:key:zA",
                COMMUNITY,
                persona,
                now
            ),
            Redemption::Refused(VETTING_REQUEST_ERR_INVALID_TICKET)
        );
        assert!(throttle.is_empty(), "scanned tickets are not guesses");
    }

    #[test]
    fn a_ticket_admits_only_its_own_community_persona_and_window() {
        let persona = PersonaId::new();
        let now = Utc::now();
        let tickets = vec![ticket(persona, now)];
        let right = tickets[0].scanned_presentation().unwrap();
        let mut throttle = GuessThrottle::default();
        let refused = Redemption::Refused(VETTING_REQUEST_ERR_INVALID_TICKET);
        let run = |throttle: &mut GuessThrottle, community, persona, at| {
            check(
                &tickets,
                throttle,
                &right,
                "did:key:zA",
                community,
                persona,
                at,
            )
        };
        assert_eq!(run(&mut throttle, "did:web:other", persona, now), refused);
        assert_eq!(
            run(&mut throttle, COMMUNITY, PersonaId::new(), now),
            refused
        );
        assert_eq!(
            run(&mut throttle, COMMUNITY, persona, now + DEFAULT_VALIDITY),
            refused
        );
        assert!(matches!(
            run(&mut throttle, COMMUNITY, persona, now),
            Redemption::Matched { .. }
        ));
    }

    #[test]
    fn a_ticket_can_restrict_the_method() {
        let mut t = ticket(PersonaId::new(), Utc::now());
        assert!(t.offers(Some(VettingMethod::Video)));
        t.methods = vec![VettingMethod::InPerson];
        assert!(t.offers(None));
        assert!(t.offers(Some(VettingMethod::InPerson)));
        assert!(!t.offers(Some(VettingMethod::Video)));
    }
}