oxedyne/fe2o3/fe2o3_net/tests/presentation.rs
48.9 KiB, 1 run
created by r1870400018:61160, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! `presentation` from outside: a fixture signed by a real WebCrypto Ed25519 |
| 2 | //! key in headless Chromium (`tools/presentation_fixture.cjs`), the refusal |
| 3 | //! each attack earns, and the module's silence about any network by name. |
| 4 | //! |
| 5 | //! The verifier's guards are proven load-bearing inside the module, where a |
| 6 | //! test can switch one off; see `src/presentation/verify.rs`. |
| 7 | |
| 8 | use oxedyne_fe2o3_core::prelude::*; |
| 9 | use oxedyne_fe2o3_crypto::{ |
| 10 | linkring::{ |
| 11 | self, |
| 12 | Ring, |
| 13 | SecretKey, |
| 14 | }, |
| 15 | sign::SignatureScheme, |
| 16 | }; |
| 17 | use oxedyne_fe2o3_hash::sha256; |
| 18 | use oxedyne_fe2o3_iop_crypto::{ |
| 19 | keys::KeyManager, |
| 20 | sign::Signer, |
| 21 | }; |
| 22 | use oxedyne_fe2o3_jdat::prelude::*; |
| 23 | use oxedyne_fe2o3_net::presentation::{ |
| 24 | shape::{ |
| 25 | self, |
| 26 | Accept, |
| 27 | Head, |
| 28 | Invoice, |
| 29 | Presentation, |
| 30 | Proof, |
| 31 | Request, |
| 32 | Settlement, |
| 33 | Status, |
| 34 | Subject, |
| 35 | HEAD_LEAD, |
| 36 | T_G, |
| 37 | }, |
| 38 | verify::{ |
| 39 | Lookup, |
| 40 | Refusal, |
| 41 | Verdict, |
| 42 | Verified, |
| 43 | Verifier, |
| 44 | }, |
| 45 | }; |
| 46 | use oxedyne_fe2o3_text::base64; |
| 47 | |
| 48 | use std::{ |
| 49 | cell::RefCell, |
| 50 | collections::HashMap, |
| 51 | sync::Arc, |
| 52 | }; |
| 53 | |
| 54 | const NOW: u64 = 1_800_000_000; |
| 55 | const APP: &str = "https://app.example"; |
| 56 | const OTHER:&str = "https://other.example"; |
| 57 | |
| 58 | // ── Fixtures ──────────────────────────────────────────────────────────────── |
| 59 | |
| 60 | struct Ed { |
| 61 | scheme: SignatureScheme, |
| 62 | public: [u8; 32], |
| 63 | } |
| 64 | |
| 65 | impl Ed { |
| 66 | fn new() -> Outcome<Self> { |
| 67 | let scheme = SignatureScheme::new_ed25519(); |
| 68 | let mut public = [0u8; 32]; |
| 69 | match res!(scheme.get_public_key()) { |
| 70 | Some(pk) => public.copy_from_slice(pk), |
| 71 | None => return Err(err!("A new Ed25519 key has no public half."; Test, Missing)), |
| 72 | } |
| 73 | Ok(Self { scheme, public }) |
| 74 | } |
| 75 | |
| 76 | fn sign(&self, msg: &[u8]) -> Outcome<[u8; 64]> { |
| 77 | let sig = res!(self.scheme.sign(msg)); |
| 78 | let mut out = [0u8; 64]; |
| 79 | out.copy_from_slice(&sig); |
| 80 | Ok(out) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | #[derive(Default)] |
| 85 | struct Book { |
| 86 | heads: RefCell<HashMap<[u8; 32], Head>>, |
| 87 | rings: RefCell<HashMap<[u8; 32], Arc<Ring>>>, // by head id |
| 88 | statuses: RefCell<HashMap<String, Status>>, |
| 89 | } |
| 90 | |
| 91 | impl Lookup for Book { |
| 92 | fn head(&self, id: &[u8; 32]) -> Outcome<Option<Head>> { |
| 93 | Ok(self.heads.borrow().get(id).cloned()) |
| 94 | } |
| 95 | fn ring(&self, head: &Head) -> Outcome<Option<Arc<Ring>>> { |
| 96 | Ok(self.rings.borrow().get(&head.id).cloned()) |
| 97 | } |
| 98 | fn status(&self, id: &str) -> Outcome<Option<Status>> { |
| 99 | Ok(self.statuses.borrow().get(id).cloned()) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | fn ring_of(keys: &[SecretKey]) -> Outcome<Arc<Ring>> { |
| 104 | let mut list: Vec<[u8; 32]> = keys.iter().map(|k| k.public_key()).collect(); |
| 105 | list.sort(); // The ring list is sorted ascending by its keys' bytes. |
| 106 | Ok(Arc::new(res!(Ring::from_keys(&list)))) |
| 107 | } |
| 108 | |
| 109 | /// A head at `ts` over `ring`, signed by `signer` and filed in `book`. |
| 110 | fn mint(book: &Book, signer: &Ed, ring: &Arc<Ring>, ts: u64) -> Outcome<Head> { |
| 111 | let mut head = Head { |
| 112 | id: [0u8; 32], |
| 113 | epoch: 1, |
| 114 | prev: None, |
| 115 | ts, |
| 116 | salt: [9u8; 16], |
| 117 | members: ring.len() as u64, |
| 118 | ring_n: ring.len() as u64, |
| 119 | ring_digest: *ring.digest(), |
| 120 | signer: signer.public, |
| 121 | sig: [0u8; 64], |
| 122 | }; |
| 123 | head.sig = res!(signer.sign(&res!(head.signed_bytes()))); |
| 124 | head.id = res!(head.compute_id()); |
| 125 | book.rings.borrow_mut().insert(head.id, ring.clone()); |
| 126 | book.heads.borrow_mut().insert(head.id, head.clone()); |
| 127 | Ok(head) |
| 128 | } |
| 129 | |
| 130 | struct World { |
| 131 | peer: Ed, |
| 132 | member: Ed, // a name's key |
| 133 | keys: Vec<SecretKey>, |
| 134 | ring: Arc<Ring>, |
| 135 | head: Head, |
| 136 | } |
| 137 | |
| 138 | fn world() -> Outcome<(World, Book)> { |
| 139 | let book = Book::default(); |
| 140 | let peer = res!(Ed::new()); |
| 141 | let member = res!(Ed::new()); |
| 142 | let mut keys = Vec::new(); |
| 143 | for _ in 0..7 { |
| 144 | keys.push(res!(SecretKey::random())); |
| 145 | } |
| 146 | let ring = res!(ring_of(&keys)); |
| 147 | let head = res!(mint(&book, &peer, &ring, NOW - 20)); |
| 148 | book.statuses.borrow_mut().insert( |
| 149 | shape::key_id(&member.public), Status { key: member.public, live: true }); |
| 150 | Ok((World { peer, member, keys, ring, head }, book)) |
| 151 | } |
| 152 | |
| 153 | fn verifier(w: &World, book: Book) -> Outcome<Verifier<Book>> { |
| 154 | Verifier::new(APP, vec![w.peer.public], book, 2) |
| 155 | } |
| 156 | |
| 157 | /// A named presentation answering `req`, signed by `member`. |
| 158 | fn named(req: &Request, member: &Ed, head: &Head) -> Outcome<Presentation> { |
| 159 | let mut p = Presentation { |
| 160 | rp_id: req.rp_id.clone(), |
| 161 | nonce: req.nonce, |
| 162 | subject: Subject::Named { id: shape::key_id(&member.public), key: member.public }, |
| 163 | predicates: req.predicates.clone(), |
| 164 | head: head.id, |
| 165 | ts: NOW, |
| 166 | sig: [0u8; 64], |
| 167 | }; |
| 168 | p.sig = res!(member.sign(&res!(p.signed_bytes()))); |
| 169 | Ok(p) |
| 170 | } |
| 171 | |
| 172 | /// A pairwise presentation answering `req`: pseudonym key `sub`, ring key |
| 173 | /// `key`, proof over `ring` under `rp_id`'s scope. |
| 174 | fn pairwise( |
| 175 | req: &Request, |
| 176 | sub: &Ed, |
| 177 | key: &SecretKey, |
| 178 | ring: &Ring, |
| 179 | head: &Head, |
| 180 | rp_id: &str, |
| 181 | ) |
| 182 | -> Outcome<Presentation> |
| 183 | { |
| 184 | let scope = shape::scope(rp_id); |
| 185 | let mut p = Presentation { |
| 186 | rp_id: req.rp_id.clone(), |
| 187 | nonce: req.nonce, |
| 188 | subject: Subject::Pairwise { |
| 189 | key: sub.public, |
| 190 | tag: res!(linkring::tag(key, &scope)), |
| 191 | proof: Proof { alg: linkring::ALG.to_string(), body: Vec::new() }, |
| 192 | }, |
| 193 | predicates: req.predicates.clone(), |
| 194 | head: head.id, |
| 195 | ts: NOW, |
| 196 | sig: [0u8; 64], |
| 197 | }; |
| 198 | res!(prove(&mut p, sub, key, ring, &scope)); |
| 199 | Ok(p) |
| 200 | } |
| 201 | |
| 202 | /// Makes the ring proof and the signature afresh over `p` as it now stands. |
| 203 | fn prove(p: &mut Presentation, sub: &Ed, key: &SecretKey, ring: &Ring, scope: &[u8]) -> Outcome<()> { |
| 204 | let msg = res!(p.signed_bytes()); |
| 205 | let (tag, body) = res!(linkring::sign(ring, key, scope, &msg)); |
| 206 | if let Subject::Pairwise { tag: t, proof, .. } = &mut p.subject { |
| 207 | req!(*t, tag, "the proof's tag is the tag the body carries"); |
| 208 | proof.body = body; |
| 209 | } |
| 210 | p.sig = res!(sub.sign(&msg)); |
| 211 | Ok(()) |
| 212 | } |
| 213 | |
| 214 | /// Signs `p` again with `key`, after a change to what the signature covers. |
| 215 | fn resign(p: &mut Presentation, key: &Ed) -> Outcome<()> { |
| 216 | p.sig = res!(key.sign(&res!(p.signed_bytes()))); |
| 217 | Ok(()) |
| 218 | } |
| 219 | |
| 220 | fn body(p: &Presentation) -> Outcome<Vec<u8>> { |
| 221 | Ok(res!(p.to_json()).into_bytes()) |
| 222 | } |
| 223 | |
| 224 | fn refusal(v: Outcome<Verdict>) -> Outcome<Option<Refusal>> { |
| 225 | Ok(res!(v).refusal()) |
| 226 | } |
| 227 | |
| 228 | // ── Acceptance ────────────────────────────────────────────────────────────── |
| 229 | |
| 230 | #[test] |
| 231 | fn a_named_presentation_verifies() -> Outcome<()> { |
| 232 | let (w, book) = res!(world()); |
| 233 | let v = res!(verifier(&w, book)); |
| 234 | let req = res!(v.issue(b"session", Accept::PairwiseOrNamed, &["adult"], None, None, NOW)); |
| 235 | let p = res!(named(&req, &w.member, &w.head)); |
| 236 | match res!(v.verify(b"session", &res!(body(&p)), NOW)) { |
| 237 | Verdict::Accepted(Verified::Named { id, key, predicates }) => { |
| 238 | req!(id, shape::key_id(&w.member.public)); |
| 239 | req!(key, w.member.public); |
| 240 | req!(predicates, vec!["adult".to_string()]); |
| 241 | }, |
| 242 | other => return Err(err!("A sound named presentation earned {:?}.", other; Test)), |
| 243 | } |
| 244 | Ok(()) |
| 245 | } |
| 246 | |
| 247 | /// Chromium made both keys, signed the head and the presentation over its own |
| 248 | /// canonical JSON, and serialised the presentation with its members in the |
| 249 | /// order it built them. The verifier accepts it only by rebuilding the |
| 250 | /// browser's bytes and verifying the browser's Ed25519 signatures. |
| 251 | #[test] |
| 252 | fn a_named_presentation_signed_in_webcrypto_verifies() -> Outcome<()> { |
| 253 | let fx = res!(Dat::decode_string(include_str!("data/presentation_webcrypto.json"))); |
| 254 | let now = res!(fx.map_get_u64(&dat!("now"))); |
| 255 | let req = res!(Request::from_dat(res!(fx.map_get_must(&dat!("request"))))); |
| 256 | let head = res!(Head::from_dat(res!(fx.map_get_must(&dat!("head"))))); |
| 257 | let status = res!(Status::from_dat(res!(fx.map_get_must(&dat!("status"))))); |
| 258 | let text = res!(fx.map_get_string(&dat!("presentation"))); |
| 259 | let p = res!(Presentation::parse(&text)); |
| 260 | req!(res!(fx.map_get_string(&dat!("rp_id"))), APP.to_string()); |
| 261 | req!(head.ring_n, 0, "a named presentation needs no ring"); |
| 262 | |
| 263 | let book = Book::default(); |
| 264 | book.heads.borrow_mut().insert(head.id, head.clone()); |
| 265 | let id = match &p.subject { |
| 266 | Subject::Named { id, .. } => id.clone(), |
| 267 | other => return Err(err!("The fixture is named, not {:?}.", other; Test)), |
| 268 | }; |
| 269 | book.statuses.borrow_mut().insert(id.clone(), status); |
| 270 | let v = res!(Verifier::new(APP, vec![head.signer], book, 1)); |
| 271 | match res!(v.check_issued(&req, &p, now)) { |
| 272 | Verdict::Accepted(Verified::Named { id: got, .. }) => req!(got, id), |
| 273 | other => return Err(err!("The WebCrypto presentation earned {:?}.", other; Test)), |
| 274 | } |
| 275 | |
| 276 | // One byte of the browser's signature changed, and it no longer verifies. |
| 277 | let mut bad = p.clone(); |
| 278 | bad.sig[10] ^= 0x01; |
| 279 | let got = res!(refusal(v.check_issued(&req, &bad, now))); |
| 280 | req!(got, Some(Refusal::BadSignature)); |
| 281 | Ok(()) |
| 282 | } |
| 283 | |
| 284 | /// A pairwise presentation over the whole ring at the head verifies, and its |
| 285 | /// tag is the member's `linkring` tag under this origin's scope, whatever |
| 286 | /// pseudonym key presents it. |
| 287 | #[test] |
| 288 | fn a_pairwise_presentation_over_the_whole_ring_verifies() -> Outcome<()> { |
| 289 | let (w, book) = res!(world()); |
| 290 | let v = res!(verifier(&w, book)); |
| 291 | let want = res!(linkring::tag(&w.keys[3], &shape::scope(APP))); |
| 292 | for _ in 0..2 { |
| 293 | let sub = res!(Ed::new()); |
| 294 | let req = res!(v.issue(b"session", Accept::Pairwise, &[], None, None, NOW)); |
| 295 | let p = res!(pairwise(&req, &sub, &w.keys[3], &w.ring, &w.head, APP)); |
| 296 | match res!(v.verify(b"session", &res!(body(&p)), NOW)) { |
| 297 | Verdict::Accepted(Verified::Pairwise { key, tag, .. }) => { |
| 298 | req!(key, sub.public); |
| 299 | req!(tag, want, "one human, one tag at one relying party"); |
| 300 | }, |
| 301 | other => return Err(err!("A sound pairwise presentation earned {:?}.", other; Test)), |
| 302 | } |
| 303 | } |
| 304 | Ok(()) |
| 305 | } |
| 306 | |
| 307 | // ── Audience, nonce and freshness ─────────────────────────────────────────── |
| 308 | |
| 309 | /// A presentation made for one relying party is refused at another, whether |
| 310 | /// it arrives as it was made or with its origin rewritten. |
| 311 | #[test] |
| 312 | fn a_cross_origin_replay_is_refused() -> Outcome<()> { |
| 313 | let (w, book) = res!(world()); |
| 314 | let other_book = Book::default(); |
| 315 | other_book.heads.borrow_mut().insert(w.head.id, w.head.clone()); |
| 316 | other_book.rings.borrow_mut().insert(w.head.id, w.ring.clone()); |
| 317 | other_book.statuses.borrow_mut().insert( |
| 318 | shape::key_id(&w.member.public), Status { key: w.member.public, live: true }); |
| 319 | let a = res!(verifier(&w, book)); |
| 320 | let b = res!(Verifier::new(OTHER, vec![w.peer.public], other_book, 1)); |
| 321 | |
| 322 | let req = res!(a.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 323 | let p = res!(named(&req, &w.member, &w.head)); |
| 324 | let got = res!(refusal(b.verify(b"s", &res!(body(&p)), NOW))); |
| 325 | req!(got, Some(Refusal::WrongAudience)); |
| 326 | let mut moved = p.clone(); |
| 327 | moved.rp_id = OTHER.to_string(); |
| 328 | let got = res!(refusal(b.verify(b"s", &res!(body(&moved)), NOW))); |
| 329 | req!(got, Some(Refusal::UnknownNonce)); |
| 330 | |
| 331 | // The same for a pairwise presentation, which carries A's tag. |
| 332 | let sub = res!(Ed::new()); |
| 333 | let req = res!(a.issue(b"s", Accept::Pairwise, &[], None, None, NOW)); |
| 334 | let p = res!(pairwise(&req, &sub, &w.keys[0], &w.ring, &w.head, APP)); |
| 335 | let got = res!(refusal(b.verify(b"s", &res!(body(&p)), NOW))); |
| 336 | req!(got, Some(Refusal::WrongAudience)); |
| 337 | |
| 338 | // And A still accepts what was made for it. |
| 339 | let got = res!(refusal(a.verify(b"s", &res!(body(&p)), NOW))); |
| 340 | req!(got, None::<Refusal>); |
| 341 | Ok(()) |
| 342 | } |
| 343 | |
| 344 | #[test] |
| 345 | fn an_unknown_nonce_is_refused() -> Outcome<()> { |
| 346 | let (w, book) = res!(world()); |
| 347 | let v = res!(verifier(&w, book)); |
| 348 | // Never issued. |
| 349 | let fake = Request { |
| 350 | rp_id: APP.to_string(), |
| 351 | nonce: [5u8; 32], |
| 352 | accept: Accept::PairwiseOrNamed, |
| 353 | predicates: vec![], |
| 354 | invoice: None, |
| 355 | return_to: None, |
| 356 | exp: NOW + T_G, |
| 357 | }; |
| 358 | let p = res!(named(&fake, &w.member, &w.head)); |
| 359 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 360 | req!(got, Some(Refusal::UnknownNonce)); |
| 361 | // Issued to another session. |
| 362 | let req = res!(v.issue(b"alice", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 363 | let p = res!(named(&req, &w.member, &w.head)); |
| 364 | let got = res!(refusal(v.verify(b"mallory", &res!(body(&p)), NOW))); |
| 365 | req!(got, Some(Refusal::UnknownNonce)); |
| 366 | // Past its request's expiry, though the presentation itself is fresh. |
| 367 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW - T_G - 1)); |
| 368 | let p = res!(named(&req, &w.member, &w.head)); |
| 369 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 370 | req!(got, Some(Refusal::UnknownNonce)); |
| 371 | Ok(()) |
| 372 | } |
| 373 | |
| 374 | /// A nonce is spent by the first presentation to reach it, even one that |
| 375 | /// then fails, so nothing presented a second time is accepted. |
| 376 | #[test] |
| 377 | fn a_nonce_spent_twice_is_refused() -> Outcome<()> { |
| 378 | let (w, book) = res!(world()); |
| 379 | let v = res!(verifier(&w, book)); |
| 380 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 381 | let p = res!(named(&req, &w.member, &w.head)); |
| 382 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 383 | req!(got, None::<Refusal>); |
| 384 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 385 | req!(got, Some(Refusal::Replayed)); |
| 386 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW + 100))); |
| 387 | req!(got, Some(Refusal::Replayed)); |
| 388 | |
| 389 | // Spent by a first attempt that failed on its signature. |
| 390 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 391 | let p = res!(named(&req, &w.member, &w.head)); |
| 392 | let mut spoilt = p.clone(); |
| 393 | spoilt.sig[0] ^= 0x01; |
| 394 | let got = res!(refusal(v.verify(b"s", &res!(body(&spoilt)), NOW))); |
| 395 | req!(got, Some(Refusal::BadSignature)); |
| 396 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 397 | req!(got, Some(Refusal::Replayed)); |
| 398 | Ok(()) |
| 399 | } |
| 400 | |
| 401 | /// A nonce stays spent for as long as its challenge stands, whatever the |
| 402 | /// caller's clock does in between. Here the clock steps back 100 s between the |
| 403 | /// issue and the first showing, and the replay comes 250 s after the issue, |
| 404 | /// while the challenge still stands. |
| 405 | #[test] |
| 406 | fn a_replay_is_refused_after_the_clock_steps_back() -> Outcome<()> { |
| 407 | let (w, book) = res!(world()); |
| 408 | let v = res!(verifier(&w, book)); |
| 409 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 410 | let p = res!(named(&req, &w.member, &w.head)); |
| 411 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW - 100))); |
| 412 | req!(got, None::<Refusal>, "the first showing passes"); |
| 413 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW + 250))); |
| 414 | req!(got, Some(Refusal::Replayed)); |
| 415 | Ok(()) |
| 416 | } |
| 417 | |
| 418 | /// A presentation shown in the wrong session is refused without spending the |
| 419 | /// nonce, so whoever intercepts one cannot spoil it for the session it was |
| 420 | /// made for. |
| 421 | #[test] |
| 422 | fn a_showing_in_another_session_spends_nothing() -> Outcome<()> { |
| 423 | let (w, book) = res!(world()); |
| 424 | let v = res!(verifier(&w, book)); |
| 425 | let req = res!(v.issue(b"alice", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 426 | let p = res!(named(&req, &w.member, &w.head)); |
| 427 | let got = res!(refusal(v.verify(b"mallory", &res!(body(&p)), NOW))); |
| 428 | req!(got, Some(Refusal::UnknownNonce)); |
| 429 | let got = res!(refusal(v.verify(b"alice", &res!(body(&p)), NOW))); |
| 430 | req!(got, None::<Refusal>); |
| 431 | Ok(()) |
| 432 | } |
| 433 | |
| 434 | /// An empty session is refused at issue and at verification, since it would |
| 435 | /// bind a challenge to every browser that sends none. The refusal spends |
| 436 | /// nothing. |
| 437 | #[test] |
| 438 | fn an_empty_session_is_refused() -> Outcome<()> { |
| 439 | let (w, book) = res!(world()); |
| 440 | let v = res!(verifier(&w, book)); |
| 441 | let issued = v.issue(b"", Accept::PairwiseOrNamed, &[], None, None, NOW).is_err(); |
| 442 | req!(issued, true, "an issue to an empty session"); |
| 443 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 444 | let p = res!(named(&req, &w.member, &w.head)); |
| 445 | let verified = v.verify(b"", &res!(body(&p)), NOW).is_err(); |
| 446 | req!(verified, true, "a verification in an empty session"); |
| 447 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 448 | req!(got, None::<Refusal>, "the session it was issued to"); |
| 449 | Ok(()) |
| 450 | } |
| 451 | |
| 452 | /// A session that fills the challenge store does not shut another out: the |
| 453 | /// challenge nearest its lapse makes room, and it alone is lost. |
| 454 | #[test] |
| 455 | fn a_full_challenge_store_still_serves_another_session() -> Outcome<()> { |
| 456 | let (w, book) = res!(world()); |
| 457 | let v = res!(verifier(&w, book)).with_max_issued(4); |
| 458 | let mut flood = Vec::new(); |
| 459 | for i in 0..4 { |
| 460 | flood.push(res!(v.issue(b"flood", Accept::PairwiseOrNamed, &[], None, None, NOW - 4 + i))); |
| 461 | } |
| 462 | let req = res!(v.issue(b"alice", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 463 | let p = res!(named(&req, &w.member, &w.head)); |
| 464 | let got = res!(refusal(v.verify(b"alice", &res!(body(&p)), NOW))); |
| 465 | req!(got, None::<Refusal>, "the other session"); |
| 466 | let p = res!(named(&flood[0], &w.member, &w.head)); |
| 467 | let got = res!(refusal(v.verify(b"flood", &res!(body(&p)), NOW))); |
| 468 | req!(got, Some(Refusal::UnknownNonce), "the challenge that made room"); |
| 469 | let p = res!(named(&flood[1], &w.member, &w.head)); |
| 470 | let got = res!(refusal(v.verify(b"flood", &res!(body(&p)), NOW))); |
| 471 | req!(got, None::<Refusal>, "the flood's later challenges still stand"); |
| 472 | Ok(()) |
| 473 | } |
| 474 | |
| 475 | /// One session holds a bounded number of challenges: another beyond the bound |
| 476 | /// lets that session's own soonest to lapse go, and no other session's. |
| 477 | #[test] |
| 478 | fn a_session_holds_a_bounded_number_of_challenges() -> Outcome<()> { |
| 479 | let (w, book) = res!(world()); |
| 480 | let v = res!(verifier(&w, book)).with_max_per_session(2); |
| 481 | let other = res!(v.issue(b"other", Accept::PairwiseOrNamed, &[], None, None, NOW - 5)); |
| 482 | let mut own = Vec::new(); |
| 483 | for i in 0..3 { |
| 484 | own.push(res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW - 2 + i))); |
| 485 | } |
| 486 | for (what, session, req, want) in [ |
| 487 | ("the session's first, let go", b"s".as_slice(), &own[0], Some(Refusal::UnknownNonce)), |
| 488 | ("the session's second", b"s".as_slice(), &own[1], None), |
| 489 | ("the session's third", b"s".as_slice(), &own[2], None), |
| 490 | ("the other session's, older", b"other".as_slice(), &other, None), |
| 491 | ] { |
| 492 | let p = res!(named(req, &w.member, &w.head)); |
| 493 | let got = res!(refusal(v.verify(session, &res!(body(&p)), NOW))); |
| 494 | req!(got, want, "{}", what); |
| 495 | } |
| 496 | Ok(()) |
| 497 | } |
| 498 | |
| 499 | /// A caller that keeps its own store of challenges still has the lapse of the |
| 500 | /// request asserted, as well as its audience and nonce. |
| 501 | #[test] |
| 502 | fn check_issued_refuses_a_lapsed_request() -> Outcome<()> { |
| 503 | let (w, book) = res!(world()); |
| 504 | let v = res!(verifier(&w, book)); |
| 505 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW - T_G - 1)); |
| 506 | let p = res!(named(&req, &w.member, &w.head)); |
| 507 | let got = res!(refusal(v.check_issued(&req, &p, NOW))); |
| 508 | req!(got, Some(Refusal::UnknownNonce), "lapsed at {}", req.exp); |
| 509 | let got = res!(refusal(v.check_issued(&req, &p, req.exp))); |
| 510 | req!(got, None::<Refusal>, "still standing at {}", req.exp); |
| 511 | Ok(()) |
| 512 | } |
| 513 | |
| 514 | /// A presentation whose `ts` is more than T_G from now is stale, on either |
| 515 | /// side, and both bounds are exact. Each is made against a head no later than |
| 516 | /// HEAD_LEAD after its `ts`, so only the presentation's own clock is at issue. |
| 517 | #[test] |
| 518 | fn a_stale_presentation_is_refused() -> Outcome<()> { |
| 519 | let (w, book) = res!(world()); |
| 520 | let v = res!(verifier(&w, book)); |
| 521 | for (ts, want) in [ |
| 522 | (NOW - T_G - 1, Some(Refusal::Stale)), |
| 523 | (NOW - T_G, None), |
| 524 | (NOW + T_G, None), |
| 525 | (NOW + T_G + 1, Some(Refusal::Stale)), |
| 526 | ] { |
| 527 | let head = res!(mint(v.lookup(), &w.peer, &w.ring, ts.min(NOW))); |
| 528 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 529 | let mut p = res!(named(&req, &w.member, &head)); |
| 530 | p.ts = ts; |
| 531 | res!(resign(&mut p, &w.member)); |
| 532 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 533 | req!(got, want, "ts {}", ts); |
| 534 | } |
| 535 | Ok(()) |
| 536 | } |
| 537 | |
| 538 | /// A head older than T_G is stale, and so is one minted more than HEAD_LEAD |
| 539 | /// after the presentation that names it; both bounds are exact. |
| 540 | #[test] |
| 541 | fn a_stale_head_is_refused() -> Outcome<()> { |
| 542 | let (w, book) = res!(world()); |
| 543 | let v = res!(verifier(&w, book)); |
| 544 | for (head_ts, want) in [ |
| 545 | (NOW - T_G - 1, Some(Refusal::StaleHead)), |
| 546 | (NOW - T_G, None), |
| 547 | (NOW + HEAD_LEAD + 1, Some(Refusal::StaleHead)), |
| 548 | (NOW + HEAD_LEAD, None), |
| 549 | ] { |
| 550 | let head = res!(mint(v.lookup(), &w.peer, &w.ring, head_ts)); |
| 551 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 552 | let p = res!(named(&req, &w.member, &head)); |
| 553 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 554 | req!(got, want, "head at {}", head_ts); |
| 555 | } |
| 556 | Ok(()) |
| 557 | } |
| 558 | |
| 559 | // ── Modes and predicates ──────────────────────────────────────────────────── |
| 560 | |
| 561 | #[test] |
| 562 | fn the_request_decides_mode_and_predicates() -> Outcome<()> { |
| 563 | let (w, book) = res!(world()); |
| 564 | let v = res!(verifier(&w, book)); |
| 565 | // A request that accepts pairwise only. |
| 566 | let req = res!(v.issue(b"s", Accept::Pairwise, &[], None, None, NOW)); |
| 567 | let p = res!(named(&req, &w.member, &w.head)); |
| 568 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 569 | req!(got, Some(Refusal::ModeNotAccepted)); |
| 570 | // A predicate the request did not ask for. |
| 571 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &["adult"], None, None, NOW)); |
| 572 | let mut p = res!(named(&req, &w.member, &w.head)); |
| 573 | p.predicates = vec!["adult".to_string(), "established".to_string()]; |
| 574 | res!(resign(&mut p, &w.member)); |
| 575 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 576 | req!(got, Some(Refusal::PredicateNotOffered)); |
| 577 | // Fewer than were asked is the member's choice. |
| 578 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &["adult"], None, None, NOW)); |
| 579 | let mut p = res!(named(&req, &w.member, &w.head)); |
| 580 | p.predicates = vec![]; |
| 581 | res!(resign(&mut p, &w.member)); |
| 582 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 583 | req!(got, None::<Refusal>); |
| 584 | Ok(()) |
| 585 | } |
| 586 | |
| 587 | // ── Tampering ─────────────────────────────────────────────────────────────── |
| 588 | |
| 589 | /// Each part of a pairwise presentation changed, by a member who can re-sign |
| 590 | /// with the pseudonym key but cannot remake another's ring proof, is refused. |
| 591 | #[test] |
| 592 | fn each_pairwise_tamper_is_refused() -> Outcome<()> { |
| 593 | let (w, book) = res!(world()); |
| 594 | let v = res!(verifier(&w, book)); |
| 595 | let sub = res!(Ed::new()); |
| 596 | let fresh = |v: &Verifier<Book>| v.issue(b"s", Accept::Pairwise, &[], None, None, NOW); |
| 597 | |
| 598 | // Another member's tag, re-signed. |
| 599 | let req = res!(fresh(&v)); |
| 600 | let mut p = res!(pairwise(&req, &sub, &w.keys[1], &w.ring, &w.head, APP)); |
| 601 | if let Subject::Pairwise { tag, .. } = &mut p.subject { |
| 602 | *tag = res!(linkring::tag(&w.keys[2], &shape::scope(APP))); |
| 603 | } |
| 604 | res!(resign(&mut p, &sub)); |
| 605 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 606 | req!(got, Some(Refusal::BadProof), "tag"); |
| 607 | |
| 608 | // One bit of the proof body. |
| 609 | let req = res!(fresh(&v)); |
| 610 | let mut p = res!(pairwise(&req, &sub, &w.keys[1], &w.ring, &w.head, APP)); |
| 611 | if let Subject::Pairwise { proof, .. } = &mut p.subject { |
| 612 | proof.body[40] ^= 0x01; |
| 613 | } |
| 614 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 615 | req!(got, Some(Refusal::BadProof), "body"); |
| 616 | |
| 617 | // An algorithm this verifier does not hold. |
| 618 | let req = res!(fresh(&v)); |
| 619 | let mut p = res!(pairwise(&req, &sub, &w.keys[1], &w.ring, &w.head, APP)); |
| 620 | if let Subject::Pairwise { proof, .. } = &mut p.subject { |
| 621 | proof.alg = "linkring/2".to_string(); |
| 622 | } |
| 623 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 624 | req!(got, Some(Refusal::BadProof), "alg"); |
| 625 | |
| 626 | // The message: a proof over one body, the signature over another. |
| 627 | let req = res!(fresh(&v)); |
| 628 | let mut p = res!(pairwise(&req, &sub, &w.keys[1], &w.ring, &w.head, APP)); |
| 629 | p.ts = NOW - 1; |
| 630 | res!(resign(&mut p, &sub)); |
| 631 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 632 | req!(got, Some(Refusal::BadProof), "message"); |
| 633 | |
| 634 | // The scope: a proof and tag made under another origin, presented here. |
| 635 | let req = res!(fresh(&v)); |
| 636 | let p = res!(pairwise(&req, &sub, &w.keys[1], &w.ring, &w.head, OTHER)); |
| 637 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 638 | req!(got, Some(Refusal::BadProof), "scope"); |
| 639 | |
| 640 | // The ring: a proof over a ring with one key dropped, which is not the |
| 641 | // whole ring at the head. |
| 642 | let req = res!(fresh(&v)); |
| 643 | let short = res!(ring_of(&w.keys[..6])); |
| 644 | let p = res!(pairwise(&req, &sub, &w.keys[1], &short, &w.head, APP)); |
| 645 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 646 | req!(got, Some(Refusal::BadProof), "ring"); |
| 647 | |
| 648 | // A ring served for the head that is not the head's: the attacker's own |
| 649 | // two keys, with a sound proof over them. Only the check of the ring |
| 650 | // against the head's length and digest stands in the way. |
| 651 | let req = res!(fresh(&v)); |
| 652 | let own = vec![res!(SecretKey::random()), res!(SecretKey::random())]; |
| 653 | let own_ring = res!(ring_of(&own)); |
| 654 | let p = res!(pairwise(&req, &sub, &own[0], &own_ring, &w.head, APP)); |
| 655 | v.lookup().rings.borrow_mut().insert(w.head.id, own_ring.clone()); |
| 656 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 657 | req!(got, Some(Refusal::BadProof), "served ring"); |
| 658 | v.lookup().rings.borrow_mut().insert(w.head.id, w.ring.clone()); |
| 659 | |
| 660 | // No ring at all for the head. |
| 661 | let req = res!(fresh(&v)); |
| 662 | let p = res!(pairwise(&req, &sub, &w.keys[1], &w.ring, &w.head, APP)); |
| 663 | v.lookup().rings.borrow_mut().remove(&w.head.id); |
| 664 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 665 | req!(got, Some(Refusal::BadProof), "no ring"); |
| 666 | Ok(()) |
| 667 | } |
| 668 | |
| 669 | /// A pseudonym key of small order, whose "signature" anyone can make, is |
| 670 | /// refused however sound the ring proof beside it. Accepting it would let |
| 671 | /// anyone answer a later challenge made to that key alone. |
| 672 | #[test] |
| 673 | fn a_small_order_pseudonym_key_is_refused() -> Outcome<()> { |
| 674 | let (w, book) = res!(world()); |
| 675 | let v = res!(verifier(&w, book)); |
| 676 | let req = res!(v.issue(b"s", Accept::Pairwise, &[], None, None, NOW)); |
| 677 | let sub = res!(Ed::new()); |
| 678 | let mut p = res!(pairwise(&req, &sub, &w.keys[4], &w.ring, &w.head, APP)); |
| 679 | let mut identity = [0u8; 32]; |
| 680 | identity[0] = 0x01; |
| 681 | if let Subject::Pairwise { key, .. } = &mut p.subject { |
| 682 | *key = identity; |
| 683 | } |
| 684 | // Remake the proof over the new body, then forge the Ed25519 half. |
| 685 | let scope = shape::scope(APP); |
| 686 | let msg = res!(p.signed_bytes()); |
| 687 | let (_, proof_body) = res!(linkring::sign(&w.ring, &w.keys[4], &scope, &msg)); |
| 688 | if let Subject::Pairwise { proof, .. } = &mut p.subject { |
| 689 | proof.body = proof_body; |
| 690 | } |
| 691 | let mut forged = [0u8; 64]; |
| 692 | forged[0] = 0x01; |
| 693 | p.sig = forged; |
| 694 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 695 | req!(got, Some(Refusal::BadSignature)); |
| 696 | Ok(()) |
| 697 | } |
| 698 | |
| 699 | #[test] |
| 700 | fn each_named_tamper_is_refused() -> Outcome<()> { |
| 701 | let (w, book) = res!(world()); |
| 702 | let v = res!(verifier(&w, book)); |
| 703 | let fresh = |v: &Verifier<Book>| v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW); |
| 704 | |
| 705 | // A name that is not its key's id, re-signed. |
| 706 | let req = res!(fresh(&v)); |
| 707 | let mut p = res!(named(&req, &w.member, &w.head)); |
| 708 | if let Subject::Named { id, .. } = &mut p.subject { |
| 709 | *id = "0123456789".to_string(); |
| 710 | } |
| 711 | res!(resign(&mut p, &w.member)); |
| 712 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 713 | req!(got, Some(Refusal::BadSignature), "sub"); |
| 714 | |
| 715 | // Signed by a key other than the one presented. |
| 716 | let req = res!(fresh(&v)); |
| 717 | let mut p = res!(named(&req, &w.member, &w.head)); |
| 718 | let other = res!(Ed::new()); |
| 719 | res!(resign(&mut p, &other)); |
| 720 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 721 | req!(got, Some(Refusal::BadSignature), "key"); |
| 722 | |
| 723 | // A name no longer live, one that holds another key now, and one unknown. |
| 724 | let id = shape::key_id(&w.member.public); |
| 725 | for status in [ |
| 726 | Some(Status { key: w.member.public, live: false }), |
| 727 | Some(Status { key: other.public, live: true }), |
| 728 | None, |
| 729 | ] { |
| 730 | match &status { |
| 731 | Some(s) => { v.lookup().statuses.borrow_mut().insert(id.clone(), s.clone()); }, |
| 732 | None => { v.lookup().statuses.borrow_mut().remove(&id); }, |
| 733 | } |
| 734 | let req = res!(fresh(&v)); |
| 735 | let p = res!(named(&req, &w.member, &w.head)); |
| 736 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 737 | req!(got, Some(Refusal::NotLive), |
| 738 | "status {:?}", status); |
| 739 | } |
| 740 | Ok(()) |
| 741 | } |
| 742 | |
| 743 | #[test] |
| 744 | fn each_head_tamper_is_refused() -> Outcome<()> { |
| 745 | let (w, book) = res!(world()); |
| 746 | let v = res!(verifier(&w, book)); |
| 747 | let fresh = |v: &Verifier<Book>| v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW); |
| 748 | |
| 749 | // A head whose content no longer hashes to its id. |
| 750 | let mut altered = w.head.clone(); |
| 751 | altered.members += 1; |
| 752 | v.lookup().heads.borrow_mut().insert(w.head.id, altered); |
| 753 | let req = res!(fresh(&v)); |
| 754 | let p = res!(named(&req, &w.member, &w.head)); |
| 755 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 756 | req!(got, Some(Refusal::UnknownHead), "content"); |
| 757 | |
| 758 | // A head whose signature fails. |
| 759 | let mut spoilt = w.head.clone(); |
| 760 | spoilt.sig[5] ^= 0x01; |
| 761 | v.lookup().heads.borrow_mut().insert(w.head.id, spoilt); |
| 762 | let req = res!(fresh(&v)); |
| 763 | let p = res!(named(&req, &w.member, &w.head)); |
| 764 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 765 | req!(got, Some(Refusal::UnknownHead), "sig"); |
| 766 | |
| 767 | // A head minted by a key outside the issuers. |
| 768 | let forger = res!(Ed::new()); |
| 769 | let forged = res!(mint(v.lookup(), &forger, &w.ring, NOW - 20)); |
| 770 | let req = res!(fresh(&v)); |
| 771 | let p = res!(named(&req, &w.member, &forged)); |
| 772 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 773 | req!(got, Some(Refusal::UnknownHead), "issuer"); |
| 774 | |
| 775 | // A head nobody can fetch. |
| 776 | v.lookup().heads.borrow_mut().remove(&w.head.id); |
| 777 | let req = res!(fresh(&v)); |
| 778 | let p = res!(named(&req, &w.member, &w.head)); |
| 779 | let got = res!(refusal(v.verify(b"s", &res!(body(&p)), NOW))); |
| 780 | req!(got, Some(Refusal::UnknownHead), "missing"); |
| 781 | Ok(()) |
| 782 | } |
| 783 | |
| 784 | // ── Shapes ────────────────────────────────────────────────────────────────── |
| 785 | |
| 786 | /// Every departure from the shape is `malformed`, whatever else is right. |
| 787 | #[test] |
| 788 | fn malformed_presentations_are_refused() -> Outcome<()> { |
| 789 | let (w, book) = res!(world()); |
| 790 | let v = res!(verifier(&w, book)); |
| 791 | let sub = res!(Ed::new()); |
| 792 | let named_req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &[], None, None, NOW)); |
| 793 | let named_text = res!(res!(named(&named_req, &w.member, &w.head)).to_json()); |
| 794 | let pair_req = res!(v.issue(b"s", Accept::Pairwise, &[], None, None, NOW)); |
| 795 | let pair_text = res!(res!(pairwise(&pair_req, &sub, &w.keys[0], &w.ring, &w.head, APP)).to_json()); |
| 796 | let pub_b64 = base64::encode_url(&w.member.public); |
| 797 | |
| 798 | let edits: Vec<(&str, String)> = vec![ |
| 799 | ("not JSON", "{\"v\":".to_string()), |
| 800 | ("not an object", "[1,2,3]".to_string()), |
| 801 | ("another version", named_text.replace("\"present/1\"", "\"present/2\"")), |
| 802 | ("another mode", named_text.replace("\"named\"", "\"public\"")), |
| 803 | ("an unknown member", named_text.replace("{", "{\"extra\":1,")), |
| 804 | ("a missing member", named_text.replace(&fmt!("\"pub\":\"{}\",", pub_b64), "")), |
| 805 | ("padded base64url", named_text.replace(&fmt!("\"{}\"", pub_b64), &fmt!("\"{}=\"", pub_b64))), |
| 806 | ("a short key", named_text.replace(&fmt!("\"{}\"", pub_b64), "\"AAAA\"")), |
| 807 | ("a float ts", named_text.replace(&fmt!("\"ts\":{}", NOW), &fmt!("\"ts\":{}.5", NOW))), |
| 808 | ("a negative ts", named_text.replace(&fmt!("\"ts\":{}", NOW), "\"ts\":-1")), |
| 809 | ("a ts beyond 2^53", named_text.replace(&fmt!("\"ts\":{}", NOW), "\"ts\":9007199254740992")), |
| 810 | ("a string ts", named_text.replace(&fmt!("\"ts\":{}", NOW), &fmt!("\"ts\":\"{}\"", NOW))), |
| 811 | ("a repeated word", named_text.replace("\"predicates\":[]", "\"predicates\":[\"adult\",\"adult\"]")), |
| 812 | ("a word not a word", named_text.replace("\"predicates\":[]", "\"predicates\":[\"Adult\"]")), |
| 813 | ("a named sub in hex caps", |
| 814 | named_text.replace(&shape::key_id(&w.member.public), &shape::key_id(&w.member.public).to_uppercase())), |
| 815 | ("a named proof", named_text.replace("{", "{\"proof\":{\"alg\":\"linkring/1\",\"body\":\"AA\"},")), |
| 816 | ("a pairwise pub", pair_text.replace("{", &fmt!("{{\"pub\":\"{}\",", pub_b64))), |
| 817 | ("a proof member extra", pair_text.replace("\"alg\":", "\"x\":1,\"alg\":")), |
| 818 | ("a duplicate member", named_text.replace("{", &fmt!("{{\"ts\":{},", NOW))), |
| 819 | // The forms JDAT reads and JSON does not. |
| 820 | ("a typed integer", named_text.replace(&fmt!("\"ts\":{}", NOW), &fmt!("\"ts\":(u64|{})", NOW))), |
| 821 | ("a hex integer", named_text.replace(&fmt!("\"ts\":{}", NOW), &fmt!("\"ts\":0x{:x}", NOW))), |
| 822 | ("a typed string", named_text.replace("\"v\":\"present/1\"", "\"v\":(str|\"present/1\")")), |
| 823 | ("an unquoted word", named_text.replace("\"mode\":\"named\"", "\"mode\":named")), |
| 824 | ("a single-quoted string", named_text.replace("\"mode\":\"named\"", "\"mode\":'named'")), |
| 825 | ("digits split by a space", named_text.replace(&fmt!("\"ts\":{}", NOW), "\"ts\":1800 000000")), |
| 826 | ("a leading zero", named_text.replace(&fmt!("\"ts\":{}", NOW), &fmt!("\"ts\":0{}", NOW))), |
| 827 | ("a trailing comma", fmt!("{},}}", &named_text[..named_text.len() - 1])), |
| 828 | ("a second value after", fmt!("{} {{}}", named_text)), |
| 829 | ]; |
| 830 | let mut wrong = Vec::new(); |
| 831 | for (what, text) in edits { |
| 832 | let got = res!(refusal(v.verify(b"s", text.as_bytes(), NOW))); |
| 833 | if got != Some(Refusal::Malformed) { |
| 834 | wrong.push(fmt!("{} ({:?})", what, got)); |
| 835 | } |
| 836 | } |
| 837 | req!(wrong, Vec::<String>::new(), "every case must be malformed"); |
| 838 | let got = res!(refusal(v.verify(b"s", &[0xff, 0xfe, 0x00], NOW))); |
| 839 | req!(got, Some(Refusal::Malformed), "not UTF-8"); |
| 840 | Ok(()) |
| 841 | } |
| 842 | |
| 843 | /// The signed bytes are the canonical JSON the contract describes, written out |
| 844 | /// here by hand rather than taken from the encoder that makes them. |
| 845 | #[test] |
| 846 | fn the_signed_bytes_are_the_canonical_json() -> Outcome<()> { |
| 847 | let p = Presentation { |
| 848 | rp_id: APP.to_string(), |
| 849 | nonce: [0u8; 32], |
| 850 | subject: Subject::Named { id: "0123456789".to_string(), key: [0xffu8; 32] }, |
| 851 | predicates: vec!["adult".to_string()], |
| 852 | head: [0xfbu8; 32], |
| 853 | ts: 1_700_000_000, |
| 854 | sig: [0u8; 64], |
| 855 | }; |
| 856 | let want = "{\"head\":\"-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_s\",\"mode\":\"named\",\ |
| 857 | \"nonce\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"predicates\":[\"adult\"],\ |
| 858 | \"pub\":\"__________________________________________8\",\ |
| 859 | \"rp_id\":\"https://app.example\",\"sub\":\"0123456789\",\"ts\":1700000000,\ |
| 860 | \"v\":\"present/1\"}"; |
| 861 | req!(String::from_utf8_lossy(&res!(p.signed_bytes())).to_string(), want.to_string()); |
| 862 | |
| 863 | // A head's id is SHA-256 over exactly its signed bytes, prev a present null. |
| 864 | let head = Head { |
| 865 | id: [0u8; 32], |
| 866 | epoch: 3, |
| 867 | prev: None, |
| 868 | ts: 5, |
| 869 | salt: [0u8; 16], |
| 870 | members: 2, |
| 871 | ring_n: 1, |
| 872 | ring_digest: [0u8; 32], |
| 873 | signer: [0u8; 32], |
| 874 | sig: [0u8; 64], |
| 875 | }; |
| 876 | let want = "{\"epoch\":3,\"members\":2,\"prev\":null,\ |
| 877 | \"ring_digest\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"ring_n\":1,\ |
| 878 | \"salt\":\"AAAAAAAAAAAAAAAAAAAAAA\",\ |
| 879 | \"signer\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"ts\":5,\"v\":\"head/1\"}"; |
| 880 | req!(String::from_utf8_lossy(&res!(head.signed_bytes())).to_string(), want.to_string()); |
| 881 | req!(res!(head.compute_id()), sha256::digest(want.as_bytes())); |
| 882 | Ok(()) |
| 883 | } |
| 884 | |
| 885 | /// The other signed bodies, each written out from the contract by hand: a |
| 886 | /// pairwise presentation, which signs `sub` and `tag` and neither `proof` nor |
| 887 | /// `sig`; a settlement; and a head with `prev` set, whose id is checked against |
| 888 | /// a SHA-256 taken outside this crate. |
| 889 | #[test] |
| 890 | fn the_other_signed_bytes_are_the_canonical_json() -> Outcome<()> { |
| 891 | let p = Presentation { |
| 892 | rp_id: APP.to_string(), |
| 893 | nonce: [0x01u8; 32], |
| 894 | subject: Subject::Pairwise { |
| 895 | key: [0xffu8; 32], |
| 896 | tag: [0xfbu8; 32], |
| 897 | proof: Proof { alg: linkring::ALG.to_string(), body: vec![1, 2, 3] }, |
| 898 | }, |
| 899 | predicates: vec!["adult".to_string()], |
| 900 | head: [0u8; 32], |
| 901 | ts: 1_700_000_000, |
| 902 | sig: [7u8; 64], |
| 903 | }; |
| 904 | let want = "{\"head\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"mode\":\"pairwise\",\ |
| 905 | \"nonce\":\"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE\",\"predicates\":[\"adult\"],\ |
| 906 | \"rp_id\":\"https://app.example\",\"sub\":\"__________________________________________8\",\ |
| 907 | \"tag\":\"-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_s\",\"ts\":1700000000,\ |
| 908 | \"v\":\"present/1\"}"; |
| 909 | req!(String::from_utf8_lossy(&res!(p.signed_bytes())).to_string(), want.to_string(), "pairwise"); |
| 910 | |
| 911 | let settlement = Settlement { |
| 912 | invoice: [0x11u8; 32], |
| 913 | entry: [0x22u8; 32], |
| 914 | amount: 500, |
| 915 | ts: 1_700_000_060, |
| 916 | signer: [0x33u8; 32], |
| 917 | sig: [7u8; 64], |
| 918 | }; |
| 919 | let want = "{\"entry\":\"IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI\",\ |
| 920 | \"invoice\":\"ERERERERERERERERERERERERERERERERERERERERERE\",\"oxes\":500,\ |
| 921 | \"signer\":\"MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM\",\"ts\":1700000060,\ |
| 922 | \"v\":\"settle/1\"}"; |
| 923 | req!(String::from_utf8_lossy(&res!(settlement.signed_bytes())).to_string(), want.to_string(), |
| 924 | "settlement"); |
| 925 | |
| 926 | let head = Head { |
| 927 | id: [0u8; 32], |
| 928 | epoch: 4, |
| 929 | prev: Some([0x44u8; 32]), |
| 930 | ts: 6, |
| 931 | salt: [0u8; 16], |
| 932 | members: 3, |
| 933 | ring_n: 2, |
| 934 | ring_digest: [0x11u8; 32], |
| 935 | signer: [0x33u8; 32], |
| 936 | sig: [7u8; 64], |
| 937 | }; |
| 938 | let want = "{\"epoch\":4,\"members\":3,\"prev\":\"REREREREREREREREREREREREREREREREREREREREREQ\",\ |
| 939 | \"ring_digest\":\"ERERERERERERERERERERERERERERERERERERERERERE\",\"ring_n\":2,\ |
| 940 | \"salt\":\"AAAAAAAAAAAAAAAAAAAAAA\",\ |
| 941 | \"signer\":\"MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM\",\"ts\":6,\"v\":\"head/1\"}"; |
| 942 | req!(String::from_utf8_lossy(&res!(head.signed_bytes())).to_string(), want.to_string(), "head"); |
| 943 | // sha256sum of those bytes, taken outside this crate. |
| 944 | let id: [u8; 32] = [ |
| 945 | 0x5f, 0x01, 0x5f, 0x92, 0x74, 0x49, 0x63, 0x3e, 0x07, 0x9e, 0xae, 0x70, 0x29, 0xda, 0x00, 0x6b, |
| 946 | 0xe4, 0x99, 0x35, 0xb3, 0x0e, 0x6c, 0x9d, 0x15, 0x90, 0xa7, 0x89, 0x99, 0x17, 0xc0, 0x94, 0x21, |
| 947 | ]; |
| 948 | req!(res!(head.compute_id()), id, "head id"); |
| 949 | Ok(()) |
| 950 | } |
| 951 | |
| 952 | #[test] |
| 953 | fn a_key_id_is_the_head_of_its_sha256() -> Outcome<()> { |
| 954 | // SHA-256("abc") begins ba7816bf8f (FIPS 180-4). |
| 955 | req!(shape::key_id(b"abc"), "ba7816bf8f".to_string()); |
| 956 | req!(shape::is_key_id("ba7816bf8f"), true); |
| 957 | for bad in ["BA7816BF8F", "ba7816bf8", "ba7816bf8f0", "ba7816bf8g"] { |
| 958 | req!(shape::is_key_id(bad), false, "{}", bad); |
| 959 | } |
| 960 | Ok(()) |
| 961 | } |
| 962 | |
| 963 | #[test] |
| 964 | fn origins_are_read_as_serialised() -> Outcome<()> { |
| 965 | for good in [ |
| 966 | "https://app.example", |
| 967 | "https://a.b-c.example:8443", |
| 968 | "https://127.0.0.1", |
| 969 | "http://localhost", |
| 970 | "http://localhost:8080", |
| 971 | "http://127.0.0.1:3000", |
| 972 | "https://xn--bcher-kva.example", |
| 973 | "https://192.168.0.1", // a dotted quad |
| 974 | "https://0.0.0.0", |
| 975 | "https://example.1a", // a last label that is not a number |
| 976 | "https://1a.example", |
| 977 | ] { |
| 978 | if shape::check_origin(good).is_err() { |
| 979 | return Err(err!("'{}' was refused.", good; Test)); |
| 980 | } |
| 981 | } |
| 982 | for bad in [ |
| 983 | "https://App.example", // upper case |
| 984 | "https://app.example/", // trailing slash |
| 985 | "https://app.example/path", // path |
| 986 | "https://app.example:443", // default port |
| 987 | "http://app.example", // plain http off localhost |
| 988 | "http://localhost:80", // default port |
| 989 | "ftp://app.example", // scheme |
| 990 | "https://user@app.example", // user information |
| 991 | "https://app.example:0", // port zero |
| 992 | "https://app.example:08443", // leading zero |
| 993 | "https://app.example:65536", // port too large |
| 994 | "https://app..example", // empty label |
| 995 | "https://-app.example", // hyphen at a label's edge |
| 996 | "https://[::1]", // IPv6 literal |
| 997 | "https://app.example?x=1", // query |
| 998 | "https://1234", // an IPv4 host not written as a dotted quad |
| 999 | "https://127.1", |
| 1000 | "https://0x7f.1", |
| 1001 | "https://0x7f.0.0.1", |
| 1002 | "https://1.2.3", |
| 1003 | "https://01.2.3.4", // a leading zero, read as octal |
| 1004 | "https://256.1.1.1", // a part beyond 255 |
| 1005 | "https://1.2.3.4.5", |
| 1006 | "https://app.1", // a numeric last label on a name |
| 1007 | "https://app.0x1f", |
| 1008 | "app.example", // no scheme |
| 1009 | "https://", // no host |
| 1010 | ] { |
| 1011 | if shape::check_origin(bad).is_ok() { |
| 1012 | return Err(err!("'{}' was accepted.", bad; Test)); |
| 1013 | } |
| 1014 | } |
| 1015 | Ok(()) |
| 1016 | } |
| 1017 | |
| 1018 | /// A request goes out and comes back as itself; the reserved members are |
| 1019 | /// ignored and any other is refused. |
| 1020 | #[test] |
| 1021 | fn requests_round_trip_and_read_strictly() -> Outcome<()> { |
| 1022 | let (w, book) = res!(world()); |
| 1023 | let v = res!(verifier(&w, book)); |
| 1024 | let invoice = Invoice { |
| 1025 | rp_id: APP.to_string(), |
| 1026 | payee: "0123456789".to_string(), |
| 1027 | account: "abcdef0123".to_string(), |
| 1028 | amount: 4_294_967_296, |
| 1029 | memo: "One book".to_string(), |
| 1030 | nonce: [3u8; 16], |
| 1031 | expires: NOW + 600, |
| 1032 | ts: NOW, |
| 1033 | }; |
| 1034 | let req = res!(v.issue(b"s", Accept::PairwiseOrNamed, &["adult"], Some(invoice.clone()), |
| 1035 | Some("https://app.example/back?x=1"), NOW)); |
| 1036 | let text = res!(req.to_json()); |
| 1037 | req!(res!(Request::parse(&text)), req.clone()); |
| 1038 | |
| 1039 | let reserved = text.replacen("{", "{\"org\":\"a\",\"osig\":\"b\",", 1); |
| 1040 | req!(res!(Request::parse(&reserved)), req.clone(), "org and osig are ignored"); |
| 1041 | for (what, bad) in [ |
| 1042 | ("an unknown member", text.replacen("{", "{\"x\":1,", 1)), |
| 1043 | ("modes without pairwise", text.replace("[\"pairwise\",\"named\"]", "[\"named\"]")), |
| 1044 | ("modes out of order", text.replace("[\"pairwise\",\"named\"]", "[\"named\",\"pairwise\"]")), |
| 1045 | ("a return URL elsewhere", text.replace("https://app.example/back", "https://evil.example/back")), |
| 1046 | ] { |
| 1047 | req!(Request::parse(&bad).is_err(), true, "{}", what); |
| 1048 | } |
| 1049 | // Issuing checks the same rules. |
| 1050 | let bad = v.issue(b"s", Accept::Pairwise, &["Adult"], None, None, NOW).is_err(); |
| 1051 | req!(bad, true, "a bad word"); |
| 1052 | let bad = v.issue(b"s", Accept::Pairwise, &[], None, Some("https://app.example#frag"), NOW).is_err(); |
| 1053 | req!(bad, |
| 1054 | true, "a return URL with a fragment"); |
| 1055 | let mut foreign = invoice.clone(); |
| 1056 | foreign.rp_id = OTHER.to_string(); |
| 1057 | let bad = v.issue(b"s", Accept::Pairwise, &[], Some(foreign), None, NOW).is_err(); |
| 1058 | req!(bad, true, |
| 1059 | "another origin's invoice"); |
| 1060 | Ok(()) |
| 1061 | } |
| 1062 | |
| 1063 | #[test] |
| 1064 | fn invoices_are_checked_and_named_by_their_hash() -> Outcome<()> { |
| 1065 | let invoice = Invoice { |
| 1066 | rp_id: APP.to_string(), |
| 1067 | payee: "0123456789".to_string(), |
| 1068 | account: "abcdef0123".to_string(), |
| 1069 | amount: 10, |
| 1070 | memo: "Tea".to_string(), |
| 1071 | nonce: [0u8; 16], |
| 1072 | expires: 1_000 + 3_600, |
| 1073 | ts: 1_000, |
| 1074 | }; |
| 1075 | res!(invoice.check()); |
| 1076 | let want = "{\"account\":\"abcdef0123\",\"expires\":4600,\"memo\":\"Tea\",\ |
| 1077 | \"nonce\":\"AAAAAAAAAAAAAAAAAAAAAA\",\"oxes\":10,\"payee\":\"0123456789\",\ |
| 1078 | \"rp_id\":\"https://app.example\",\"ts\":1000,\"v\":\"invoice/1\"}"; |
| 1079 | req!(res!(invoice.to_json()), want.to_string()); |
| 1080 | req!(res!(invoice.id()), sha256::digest(want.as_bytes())); |
| 1081 | req!(res!(Invoice::parse(want)), invoice.clone()); |
| 1082 | |
| 1083 | let cases: Vec<(&str, Invoice)> = vec![ |
| 1084 | ("no amount", Invoice { amount: 0, ..invoice.clone() }), |
| 1085 | ("a long memo", Invoice { memo: "m".repeat(65), ..invoice.clone() }), |
| 1086 | ("a control in the memo", Invoice { memo: "a\tb".to_string(), ..invoice.clone() }), |
| 1087 | ("a payee not an id", Invoice { payee: "someone".to_string(), ..invoice.clone() }), |
| 1088 | ("too long a life", Invoice { expires: 1_000 + 3_601, ..invoice.clone() }), |
| 1089 | ("expiring before issue", Invoice { expires: 999, ..invoice.clone() }), |
| 1090 | ("a bad origin", Invoice { rp_id: "https://app.example/".to_string(), ..invoice.clone() }), |
| 1091 | ]; |
| 1092 | for (what, bad) in cases { |
| 1093 | req!(bad.check().is_err(), true, "{}", what); |
| 1094 | } |
| 1095 | // A memo of 64 characters after trimming is within the limit. |
| 1096 | res!(Invoice { memo: fmt!(" {} ", "m".repeat(64)), ..invoice.clone() }.check()); |
| 1097 | Ok(()) |
| 1098 | } |
| 1099 | |
| 1100 | #[test] |
| 1101 | fn a_settlement_verifies_and_each_fault_is_refused() -> Outcome<()> { |
| 1102 | let (w, book) = res!(world()); |
| 1103 | let v = res!(verifier(&w, book)); |
| 1104 | let invoice = Invoice { |
| 1105 | rp_id: APP.to_string(), |
| 1106 | payee: "0123456789".to_string(), |
| 1107 | account: "abcdef0123".to_string(), |
| 1108 | amount: 500, |
| 1109 | memo: String::new(), |
| 1110 | nonce: [1u8; 16], |
| 1111 | expires: NOW + 3_600, |
| 1112 | ts: NOW, |
| 1113 | }; |
| 1114 | let settle = |signer: &Ed, invoice_id: [u8; 32], amount: u64, ts: u64| -> Outcome<Settlement> { |
| 1115 | let mut s = Settlement { |
| 1116 | invoice: invoice_id, |
| 1117 | entry: [2u8; 32], |
| 1118 | amount, |
| 1119 | ts, |
| 1120 | signer: signer.public, |
| 1121 | sig: [0u8; 64], |
| 1122 | }; |
| 1123 | s.sig = res!(signer.sign(&res!(s.signed_bytes()))); |
| 1124 | Ok(s) |
| 1125 | }; |
| 1126 | let id = res!(invoice.id()); |
| 1127 | let good = res!(settle(&w.peer, id, 500, NOW + 60)); |
| 1128 | res!(v.verify_settlement(&good, &invoice)); |
| 1129 | req!(res!(Settlement::parse(&res!(good.to_json()))), good.clone()); |
| 1130 | |
| 1131 | let forger = res!(Ed::new()); |
| 1132 | let mut spoilt = good.clone(); |
| 1133 | spoilt.sig[3] ^= 0x01; |
| 1134 | let mut other_invoice = invoice.clone(); |
| 1135 | other_invoice.rp_id = OTHER.to_string(); |
| 1136 | for (what, s, inv) in [ |
| 1137 | ("an outside signer", res!(settle(&forger, id, 500, NOW + 60)), &invoice), |
| 1138 | ("a bad signature", spoilt, &invoice), |
| 1139 | ("another invoice", res!(settle(&w.peer, [9u8; 32], 500, NOW + 60)), &invoice), |
| 1140 | ("another amount", res!(settle(&w.peer, id, 499, NOW + 60)), &invoice), |
| 1141 | ("after expiry", res!(settle(&w.peer, id, 500, NOW + 3_601)), &invoice), |
| 1142 | ("before issue", res!(settle(&w.peer, id, 500, NOW - 1)), &invoice), |
| 1143 | ("another's invoice", good.clone(), &other_invoice), |
| 1144 | ] { |
| 1145 | req!(v.verify_settlement(&s, inv).is_err(), true, "{}", what); |
| 1146 | } |
| 1147 | Ok(()) |
| 1148 | } |
| 1149 | |
| 1150 | // ── Vendor neutrality ─────────────────────────────────────────────────────── |
| 1151 | |
| 1152 | /// The module serves any network of confirmed humans, so no source file in it |
| 1153 | /// names one, or one's vocabulary. |
| 1154 | #[test] |
| 1155 | fn the_presentation_module_names_no_network() -> Outcome<()> { |
| 1156 | let sources = [ |
| 1157 | ("mod.rs", include_str!("../src/presentation/mod.rs")), |
| 1158 | ("shape.rs", include_str!("../src/presentation/shape.rs")), |
| 1159 | ("verify.rs", include_str!("../src/presentation/verify.rs")), |
| 1160 | ]; |
| 1161 | let banned = ["oxe".to_string() + "gen", "oxe".to_string() + "nym", "ox".to_string() + "id", |
| 1162 | "hu".to_string() + "ser"]; |
| 1163 | for (name, text) in sources { |
| 1164 | let lower = text.to_lowercase(); |
| 1165 | for word in banned.iter() { |
| 1166 | req!(lower.contains(word.as_str()), false, "src/presentation/{} says '{}'", name, word); |
| 1167 | } |
| 1168 | } |
| 1169 | Ok(()) |
| 1170 | } |