133 lines
7.5 KiB
Rust
133 lines
7.5 KiB
Rust
//! joule-moq's reading of rating claims against WAI's age-assurance corpus
|
|
//! (`wai/live-conformance/age/vectors.json`, age-assurance §12), as a third reader beside
|
|
//! `wai-rs` and the corpus's Python verifier.
|
|
//!
|
|
//! - every claim of a `ratings`, `serving` or `predicate` case reads here exactly where the
|
|
//! corpus has it read: a case refused at `["claim", i]` with `record_malformed`, `bad_key`
|
|
//! or `bad_signature` has its claim `i` refused with that code here, and every other claim
|
|
//! of the corpus reads, with the age and the signer the corpus states;
|
|
//! - every request of an accepted `serving` case gets the age the corpus states, from the
|
|
//! claims the relay holds when it serves it, through [`Claim::covers`], and the track's
|
|
//! digest is the corpus's; a request whose session presented a proof of a room key
|
|
//! (age-assurance §5.5) is checked as the relay checks it ([`grant_proof::check_proof`] with
|
|
//! [`grant_proof::NO_GRANT`]), with the room's authority and clock as the relay's room keys,
|
|
//! and gets the outcome the corpus states: served as the room's own service, served by its
|
|
//! threshold, or refused with the proof's reason.
|
|
//!
|
|
//! The corpus lives beside this crate in the monorepo. If it cannot be read the test fails;
|
|
//! it never passes on nothing.
|
|
|
|
use joule_moq::grant_proof;
|
|
use joule_moq::rating::{read, track_digest, Claim};
|
|
use joule_moq::{TrackName, TrackNamespace};
|
|
use serde_json::Value;
|
|
|
|
const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/live-conformance/age/vectors.json");
|
|
|
|
/// The reading refusals, the codes this crate reports (age-assurance §11).
|
|
const READING: [&str; 3] = ["record_malformed", "bad_key", "bad_signature"];
|
|
|
|
fn corpus() -> Value {
|
|
serde_json::from_str(&std::fs::read_to_string(CORPUS).expect("the age-assurance corpus")).expect("JSON")
|
|
}
|
|
|
|
fn unhex(s: &str) -> Vec<u8> {
|
|
assert!(s.len().is_multiple_of(2), "hexadecimal of whole bytes");
|
|
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hexadecimal")).collect()
|
|
}
|
|
|
|
fn key32(s: &str) -> [u8; 32] {
|
|
unhex(s).try_into().expect("32 bytes")
|
|
}
|
|
|
|
fn texts(c: &Value) -> Vec<String> {
|
|
c["claims"].as_array().map(|a| a.iter().map(|t| t.as_str().expect("a claim's text").to_string()).collect()).unwrap_or_default()
|
|
}
|
|
|
|
#[test]
|
|
fn every_claim_reads_as_the_corpus_has_it() {
|
|
let doc = corpus();
|
|
let (mut read_ok, mut refused) = (0, 0);
|
|
for c in doc["cases"].as_array().expect("cases") {
|
|
let kind = c["kind"].as_str().expect("a kind");
|
|
if !["ratings", "serving", "predicate"].contains(&kind) {
|
|
continue;
|
|
}
|
|
let name = c["name"].as_str().expect("a name");
|
|
let e = &c["expect"];
|
|
// The claim at which a reading refusal stops the case, and its code.
|
|
let stop = (e["outcome"] == "refuse" && e["at"][0] == "claim" && READING.contains(&e["reason"].as_str().unwrap_or(""))).then(|| (e["at"][1].as_u64().expect("an index") as usize, e["reason"].as_str().expect("a code")));
|
|
for (i, t) in texts(c).iter().enumerate() {
|
|
let got = read(t.as_bytes());
|
|
if let Some((at, code)) = stop
|
|
&& at == i
|
|
{
|
|
assert_eq!(got.map(|_| ()).map_err(|r| r.code()), Err(code), "{name}: claim {i}");
|
|
refused += 1;
|
|
break;
|
|
}
|
|
// A sink passes over a claim that does not read or verify (§6.2): the corpus
|
|
// states no place for it.
|
|
if kind == "predicate" {
|
|
read_ok += usize::from(got.is_ok());
|
|
continue;
|
|
}
|
|
let cl = got.unwrap_or_else(|r| panic!("{name}: claim {i} refused {} here, read by the corpus", r.code()));
|
|
read_ok += 1;
|
|
if kind == "ratings" && e["outcome"] == "accept" {
|
|
let f = &e["claims"][i];
|
|
assert_eq!(cl.age(), f["age"].as_u64().expect("an age"), "{name}: claim {i}'s age");
|
|
assert_eq!(joule_moq::rating::to_hex(&cl.signer), f["signer"].as_str().expect("a signer"), "{name}: claim {i}'s signer");
|
|
}
|
|
}
|
|
}
|
|
assert!(read_ok > 40 && refused > 10, "{read_ok} claims read, {refused} refused: the corpus was reached");
|
|
}
|
|
|
|
#[test]
|
|
fn every_request_of_a_serving_case_gets_its_age() {
|
|
let doc = corpus();
|
|
let mut requests = 0;
|
|
let mut services = 0;
|
|
// The relay's room keys: the room's authority and its clock.
|
|
let room_keys = [key32(doc["principals"]["authority"].as_str().expect("the authority")), key32(doc["principals"]["clock"].as_str().expect("the clock"))];
|
|
for c in doc["cases"].as_array().expect("cases").iter().filter(|c| c["kind"] == "serving" && c["expect"]["outcome"] == "accept") {
|
|
let name = c["name"].as_str().expect("a name");
|
|
let fields: Vec<String> = c["track"]["namespace"].as_array().expect("a namespace").iter().map(|f| f.as_str().expect("a field").to_string()).collect();
|
|
let ns = TrackNamespace::from_strs(fields.iter().map(String::as_str));
|
|
let track_name = TrackName::from_str(c["track"]["name"].as_str().expect("a name"));
|
|
let digest = track_digest(&ns, &track_name);
|
|
assert_eq!(joule_moq::rating::to_hex(&digest), c["expect"]["track"].as_str().expect("a digest"), "{name}: the track's digest");
|
|
let room = joule_moq::rating::room_of(&ns);
|
|
let claims: Vec<Claim> = texts(c).iter().map(|t| read(t.as_bytes()).expect("an accepted case's claim reads")).collect();
|
|
for (q, want) in c["requests"].as_array().expect("requests").iter().zip(c["expect"]["requests"].as_array().expect("outcomes")) {
|
|
let group = q["group"].as_u64().expect("a group");
|
|
let held = q["held"].as_array().expect("held").iter().map(|i| &claims[i.as_u64().expect("an index") as usize]);
|
|
let age = held.filter(|cl| cl.covers(&digest, room.as_ref(), group)).map(Claim::age).max().unwrap_or(0);
|
|
assert_eq!(age, want["age"].as_u64().expect("an age"), "{name}: a request at group {group}");
|
|
// A proof of a room key, checked as the relay checks it in SETUP (age-assurance §5.5).
|
|
let service = match q.get("service") {
|
|
None => Ok(false),
|
|
Some(s) => {
|
|
services += 1;
|
|
let proof = unhex(s["proof"].as_str().expect("a proof"));
|
|
let challenge = key32(s["challenge"].as_str().expect("a challenge"));
|
|
grant_proof::GrantProof::read(&proof)
|
|
.and_then(|p| grant_proof::check_proof(&grant_proof::NO_GRANT, &p.key, Some(&proof), &challenge, |_| false, s["context"].as_str().expect("a context")))
|
|
.map(|p| room_keys.contains(&p.key))
|
|
}
|
|
};
|
|
match service {
|
|
Err(r) => assert_eq!((want["outcome"].as_str(), want["reason"].as_str()), (Some("refuse"), Some(r.code())), "{name}: a request at group {group}"),
|
|
Ok(true) => assert_eq!((want["outcome"].as_str(), want["service"].as_bool()), (Some("serve"), Some(true)), "{name}: a request at group {group}"),
|
|
Ok(false) => {
|
|
let served = q["threshold"].as_u64().expect("a threshold") >= age;
|
|
assert_eq!(served, want["outcome"] == "serve", "{name}: a request at group {group}");
|
|
assert!(want.get("service").is_none(), "{name}: a request at group {group} is no room service");
|
|
}
|
|
}
|
|
requests += 1;
|
|
}
|
|
}
|
|
assert!(requests > 10 && services >= 10, "{requests} requests, {services} with a proof of a room key: the corpus was reached");
|
|
}
|