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;
pub const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
pub const DEFAULT_VALIDITY: Duration = Duration::days(14);
pub const GUESS_WINDOW: Duration = Duration::hours(1);
pub const MAX_WRONG_CODES_PER_SENDER: usize = 5;
pub const MAX_WRONG_CODES: usize = 60;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ticket {
pub id: String,
pub code: String,
pub secret: String,
pub community: String,
pub persona: PersonaId,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub methods: Vec<VettingMethod>,
pub uses_left: u32,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl Ticket {
#[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,
}
}
#[must_use]
pub fn is_live(&self, now: DateTime<Utc>) -> bool {
self.uses_left > 0 && now < self.expires_at
}
#[must_use]
pub fn offers(&self, method: Option<VettingMethod>) -> bool {
self.methods.is_empty() || method.is_none_or(|m| self.methods.contains(&m))
}
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))
}
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))
}
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())
}
}
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
}
#[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..]))
}
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
}
#[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 {
#[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,
});
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Redemption {
Matched {
ticket_id: String,
},
Silent,
Refused(&'static str),
}
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),
}
}
_ => Redemption::Refused(VETTING_REQUEST_ERR_INVALID_TICKET),
}
}
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)
}
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();
}
}
#[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");
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
);
}
let right = tickets[0].code_presentation().unwrap();
assert_eq!(
check(
&tickets,
&mut throttle,
&right,
"did:key:zGuesser",
COMMUNITY,
persona,
now
),
Redemption::Silent
);
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)));
}
}