choir_cli/verify.rs
1//! `SYNC.md`'s three checks over one served page, as a function (D17).
2//!
3//! Pure and node-free on purpose. The verification logic is the part
4//! worth being certain about, and a check that can only be exercised
5//! against a live node can only ever be exercised against a *correct*
6//! one — so the cases that matter, a flipped hash and a broken parent
7//! link, would never run. Mutations proved exactly that: removing the
8//! recomputation and removing the continuity check both left the
9//! end-to-end test green, because nothing ever handed it a bad page.
10//!
11//! # What each check establishes
12//!
13//! 1. **Continuity** — the page is a contiguous run and each entry's
14//! `parent` is the previous entry's `hash`. Catches a page that
15//! begins in the wrong place or has an entry dropped from the middle.
16//! 2. **Recomputation** — the `hash` the node claims is the hash of the
17//! entry it was attached to. Needs nothing but the page.
18//! 3. **Authorship** — the actor named actually signed it, *and* was
19//! still trusted at that position (D44). For an ed25519 entry this is
20//! the only check that needs things the page does not carry: the
21//! public key, and the revocation positions. An entry whose key the
22//! caller does not hold is reported **unverified**, never verified;
23//! an entry signed at or after its key's revocation is a **failure**,
24//! because that is a claim the log itself contradicts rather than one
25//! this client cannot check.
26//!
27//! A passkey entry carries its own credential key (D45) and so needs
28//! nothing external — but for the same reason it establishes only
29//! half of what the ed25519 path does. See
30//! [`crate::verify::Report::integrity_only`].
31//!
32//! # What still cannot be verified forever
33//!
34//! For an ed25519 entry the log records a key **id** — `hash(pubkey)` —
35//! never the public key itself. So those signatures are checkable only
36//! while somebody still holds the key material, and an operator who
37//! deletes a revoked key's line from the trusted-keys file makes that
38//! key's history permanently `unverified`. Revocation no longer causes
39//! that decay; deletion still does. Retaining revoked keys' public
40//! material is an operator responsibility nothing in the code can
41//! enforce.
42//!
43//! D45 fixed that for passkeys by a route not open to ed25519 keys: a
44//! WebAuthn signature is useless without the authenticator data anyway,
45//! so the entry already carried scheme-specific material and the
46//! credential key joined it. An ed25519 signature carries nothing, and
47//! adding the public key to every one of them would grow every entry to
48//! re-state what a one-line file already says.
49//!
50//! What no passkey entry can establish, before or after D45, is that the
51//! credential belonged to the channel. That binding lives in the
52//! accounts store, which is server state, is not in the backup set, and
53//! is deliberately erasable. An entry admitted on a credential now
54//! withdrawn looks exactly like one admitted on a credential still
55//! enrolled, and neither this client nor any other can tell them apart.
56//!
57//! D46 widens that split to every scheme, and it is a property given up
58//! on purpose. The channel an entry names is an opaque handle, so
59//! *verification* still needs nothing but the page and a key, while
60//! *attribution* — which person that handle was — needs the accounts
61//! store and gets no answer once the account is revoked. "These bytes
62//! are genuine" is permanent; "this was alice" is deletable. A handle
63//! this client cannot resolve is the expected state for a deleted
64//! account, not a fault in the log.
65
66use choir_hash::ContentHash;
67use choir_identity::{IdentityError, Registry};
68use choir_node::platform::hex_decode;
69use choir_oplog::{OpEntry, Witness};
70
71/// What one page's verification found.
72#[derive(Debug, Default)]
73pub struct Report {
74 /// Checks that failed. Non-empty means the page is not trustworthy.
75 pub failures: Vec<String>,
76 /// Things the caller should know that are not failures — above all,
77 /// signatures nobody could check.
78 pub notes: Vec<String>,
79 /// Signatures actually verified against a held key.
80 pub checked: usize,
81 /// Passkey signatures that verified against the credential key the
82 /// entry itself carries (D45): the bytes are intact and were signed
83 /// by the credential named, and nothing here anchors that credential
84 /// to a channel.
85 ///
86 /// Its own counter rather than a share of [`Report::checked`],
87 /// because the two answer different questions and one number that
88 /// means two things is the shape every wrong reading in this file
89 /// has taken. Half a check is worth reporting; it is not worth
90 /// reporting as a whole one.
91 pub integrity_only: usize,
92 /// Entries whose authorship could not be established either way.
93 pub unverified: usize,
94}
95
96/// Actor id (hex, as `author_key` carries it) → the log position its
97/// binding was revoked at, from `/api/view`'s `bindings[].revoked.at`.
98///
99/// Supplied by the caller rather than folded from the page, because a
100/// page is a window: the `RevokeKey` that matters may sit outside it, and
101/// a verifier that inferred "not revoked" from "no revocation in view"
102/// would be answering a question it cannot see.
103pub type Revocations = std::collections::BTreeMap<String, u64>;
104
105/// Runs the three checks over `entries`, in order.
106///
107/// Authorship is resolved **as of each entry's own seq** (D44): a
108/// signature made before its key was revoked stays valid forever, and one
109/// made at or after the revocation is a `failure`. Revocation is
110/// append-only and positional, so this verdict is stable — re-running it
111/// next year over the same page gives the same answer, which is the
112/// property that makes the log auditable rather than merely current.
113#[must_use]
114pub fn page(entries: &[serde_json::Value], registry: &Registry, revoked: &Revocations) -> Report {
115 let mut report = Report::default();
116 let mut previous: Option<(u64, String)> = None;
117 for entry in entries {
118 let seq = entry["seq"].as_u64().unwrap_or_default();
119 let claimed = entry["hash"].as_str().unwrap_or_default().to_string();
120
121 if let Some((last_seq, last_hash)) = &previous {
122 if seq != last_seq + 1 {
123 report.failures.push(format!(
124 "seq {seq}: follows {last_seq}, so an entry is missing"
125 ));
126 }
127 if entry["parent"].as_str() != Some(last_hash.as_str()) {
128 report.failures.push(format!(
129 "seq {seq}: parent is not the previous entry's hash"
130 ));
131 }
132 }
133
134 let rebuilt = rebuild(entry);
135 match &rebuilt {
136 Some(e) if e.content_hash().to_hex() == claimed => {}
137 Some(_) => report
138 .failures
139 .push(format!("seq {seq}: does not hash to the hash it claims")),
140 None => report.failures.push(format!(
141 "seq {seq}: cannot be rebuilt from the fields served"
142 )),
143 }
144
145 match (entry["author_key"].as_str(), &rebuilt) {
146 (None, _) => {
147 report.unverified += 1;
148 report
149 .notes
150 .push(format!("seq {seq}: unsigned, so authorship is unverified"));
151 }
152 (Some(key_id), Some(e)) => match e.author_sig.as_ref() {
153 None => report.unverified += 1,
154 // A passkey carries its own key (D45), so this branch
155 // answers a different question from the one below and
156 // has to report a different answer. It also consults no
157 // revocation table: `revoked` is keyed by ed25519 actor
158 // id and a passkey's `key_id` is a credential id, a
159 // different namespace — and withdrawing a credential is
160 // a store edit that leaves no positional record, so
161 // there is nothing here to look up rather than a lookup
162 // that happens to miss.
163 Some(sig) if sig.scheme_id() == choir_oplog::scheme::WEBAUTHN_ES256 => {
164 match choir_identity::verify_carried_webauthn(&e.signing_hash(), sig) {
165 Ok(_) => {
166 report.integrity_only += 1;
167 report.notes.push(format!(
168 "seq {seq}: signed by credential {key_id}, whose key this \
169 entry carries; the bytes are intact, and nothing in the log \
170 ties that credential to a channel"
171 ));
172 }
173 Err(IdentityError::UnknownKey(_)) => {
174 report.unverified += 1;
175 report.notes.push(format!(
176 "seq {seq}: passkey entry carries no credential key, so it \
177 was written before D45 and nothing can check it now"
178 ));
179 }
180 Err(error) => report.failures.push(format!(
181 "seq {seq}: passkey signature does not verify ({error:?})"
182 )),
183 }
184 }
185 Some(sig) => {
186 // The three outcomes are already distinct in the
187 // error type, and keeping them distinct is the whole
188 // honesty of this: a claim that failed, a claim
189 // never examined, and a claim checked. Collapsing
190 // the middle into either neighbour is how a verifier
191 // starts lying.
192 match registry.verify_signing_hash(&e.signing_hash(), sig) {
193 // The signature is good; whether the key was
194 // still trusted at this position is a second
195 // question, and the order matters. Asking about
196 // revocation first would report a forged
197 // signature from a revoked key as a revocation
198 // problem, which sends the reader to the wrong
199 // repair.
200 Ok(_) => match revoked.get(key_id) {
201 Some(at) if seq >= *at => report.failures.push(format!(
202 "seq {seq}: signed by {key_id}, revoked at seq {at}"
203 )),
204 _ => report.checked += 1,
205 },
206 Err(IdentityError::UnknownKey(_)) => {
207 report.unverified += 1;
208 report.notes.push(format!(
209 "seq {seq}: no key held for {key_id}, authorship unverified"
210 ));
211 }
212 Err(IdentityError::UnsupportedScheme(scheme)) => {
213 report.unverified += 1;
214 report.notes.push(format!(
215 "seq {seq}: signed with scheme {scheme}, which this client \
216 cannot check; authorship unverified"
217 ));
218 }
219 Err(error) => report
220 .failures
221 .push(format!("seq {seq}: signature does not verify ({error:?})")),
222 }
223 }
224 },
225 (Some(_), None) => report.unverified += 1,
226 }
227 previous = Some((seq, claimed));
228 }
229 report
230}
231
232/// Rebuilds the hashed form from the fields `/api/log` serves.
233///
234/// Every field of the canonical form is on the wire, which is what makes
235/// the chain checkable by someone who does not trust the node — so
236/// `None` means the node sent a page this client cannot verify, which is
237/// itself the finding.
238#[must_use]
239pub fn rebuild(entry: &serde_json::Value) -> Option<OpEntry> {
240 let author_sig = match entry["author_key"].as_str() {
241 None => None,
242 Some(key_id) => {
243 let signature = hex_decode(entry["author_sig_hex"].as_str()?)?;
244 Some(match entry["author_scheme"].as_u64() {
245 None => Witness::ed25519(key_id, signature),
246 Some(scheme) => Witness {
247 key_id: key_id.to_string(),
248 signature,
249 scheme: Some(u16::try_from(scheme).ok()?),
250 authenticator_data: entry["authenticator_data_hex"]
251 .as_str()
252 .and_then(hex_decode),
253 client_data_json: entry["client_data_json_hex"].as_str().and_then(hex_decode),
254 credential_key: entry["credential_key_hex"].as_str().and_then(hex_decode),
255 },
256 })
257 }
258 };
259 Some(OpEntry {
260 format_version: u16::try_from(entry["format_version"].as_u64()?).ok()?,
261 parent: match entry["parent"].as_str() {
262 None => None,
263 Some(hex) => Some(hash_from_hex(hex)?),
264 },
265 seq: entry["seq"].as_u64()?,
266 channel: entry["workspace"].as_str()?.to_string(),
267 payload: hex_decode(entry["payload_hex"].as_str()?)?,
268 witnesses: serde_json::from_value(entry["witnesses"].clone()).ok()?,
269 author_sig,
270 })
271}
272
273/// A `<codec>-<hex>` content hash, as every hash on the wire is spelled.
274fn hash_from_hex(text: &str) -> Option<ContentHash> {
275 let (codec, digest) = text.split_once('-')?;
276 Some(ContentHash {
277 codec: u8::from_str_radix(codec, 16).ok()?,
278 digest: hex_decode(digest)?,
279 })
280}
281
282#[cfg(test)]
283mod tests {
284 use super::Revocations;
285 use choir_identity::{ActorKey, Registry};
286 use choir_oplog::{OpEntry, FORMAT_VERSION};
287
288 /// A page of `n` chained, signed entries, in the shape `/api/log`
289 /// serves — built here rather than fetched, because the cases worth
290 /// checking are the ones a correct node never produces.
291 fn page(key: &ActorKey, n: u64) -> Vec<serde_json::Value> {
292 let mut out = Vec::new();
293 let mut parent: Option<choir_hash::ContentHash> = None;
294 for seq in 0..n {
295 let mut entry = OpEntry {
296 format_version: FORMAT_VERSION,
297 parent: parent.clone(),
298 seq,
299 channel: "agent".into(),
300 payload: format!("op {seq}").into_bytes(),
301 witnesses: Vec::new(),
302 author_sig: None,
303 };
304 key.sign_entry(&mut entry);
305 let hash = entry.content_hash();
306 out.push(serde_json::json!({
307 "seq": entry.seq,
308 "workspace": entry.channel,
309 "payload_hex": entry.payload.iter()
310 .map(|b| format!("{b:02x}")).collect::<String>(),
311 "author_key": entry.author_sig.as_ref().map(|w| w.key_id.clone()),
312 "author_sig_hex": entry.author_sig.as_ref()
313 .map(|w| w.signature.iter().map(|b| format!("{b:02x}")).collect::<String>()),
314 "hash": hash.to_hex(),
315 "parent": entry.parent.as_ref().map(choir_hash::ContentHash::to_hex),
316 "format_version": entry.format_version,
317 "witnesses": entry.witnesses,
318 }));
319 parent = Some(hash);
320 }
321 out
322 }
323
324 fn registry_for(key: &ActorKey) -> Registry {
325 let mut registry = Registry::new();
326 registry
327 .register(&key.public_key_bytes())
328 .expect("valid key");
329 registry
330 }
331
332 /// The control. Without it every assertion below could be passing
333 /// because verification refuses everything.
334 #[test]
335 fn a_good_page_verifies() {
336 let key = ActorKey::generate();
337 let report = super::page(&page(&key, 3), ®istry_for(&key), &Revocations::new());
338 assert!(report.failures.is_empty(), "{:?}", report.failures);
339 assert_eq!(report.checked, 3);
340 assert_eq!(report.unverified, 0);
341 }
342
343 /// A revocation map naming `key` as withdrawn at `at`.
344 fn revoked_at(key: &ActorKey, at: u64) -> Revocations {
345 let mut map = Revocations::new();
346 map.insert(key.actor_id().to_hex(), at);
347 map
348 }
349
350 /// The half the brief asks for by name: signing happened, then the
351 /// key was withdrawn, and the old entries do not decay. Without this
352 /// every key rotation would quietly unverify the history behind it.
353 #[test]
354 fn a_signature_made_before_its_key_was_revoked_still_verifies() {
355 let key = ActorKey::generate();
356 let report = super::page(&page(&key, 3), ®istry_for(&key), &revoked_at(&key, 3));
357 assert!(report.failures.is_empty(), "{:?}", report.failures);
358 assert_eq!(report.checked, 3);
359 }
360
361 /// The other half, and the one that has to be a *failure* rather than
362 /// a note. "Unverified" says this client could not check the claim;
363 /// here the claim was checked and the log itself contradicts it.
364 #[test]
365 fn a_signature_made_after_its_key_was_revoked_is_a_failure() {
366 let key = ActorKey::generate();
367 let report = super::page(&page(&key, 3), ®istry_for(&key), &revoked_at(&key, 1));
368 assert_eq!(report.checked, 1, "only seq 0 predates the revocation");
369 assert_eq!(report.unverified, 0, "these are refusals, not unknowns");
370 assert_eq!(report.failures.len(), 2, "{:?}", report.failures);
371 assert!(
372 report.failures[0].contains("revoked at seq 1"),
373 "{:?}",
374 report.failures
375 );
376 }
377
378 /// The boundary. A key revoked *at* seq N did not authorize the entry
379 /// sitting at seq N: the revocation is sequenced before it, and the
380 /// off-by-one in the other direction would license exactly one entry
381 /// nobody approved.
382 #[test]
383 fn the_entry_at_the_revocation_seq_is_already_too_late() {
384 let key = ActorKey::generate();
385 let report = super::page(&page(&key, 2), ®istry_for(&key), &revoked_at(&key, 1));
386 assert_eq!(report.checked, 1);
387 assert_eq!(report.failures.len(), 1, "{:?}", report.failures);
388 }
389
390 /// A node that lies about an entry's hash. Caught by recomputation
391 /// alone, with no key and no trust in the server.
392 #[test]
393 fn a_hash_that_is_not_the_hash_of_its_entry_is_caught() {
394 let key = ActorKey::generate();
395 let mut entries = page(&key, 3);
396 entries[1]["hash"] = serde_json::json!(
397 "1e-0000000000000000000000000000000000000000000000000000000000000000"
398 );
399 let report = super::page(&entries, ®istry_for(&key), &Revocations::new());
400 assert!(
401 report
402 .failures
403 .iter()
404 .any(|f| f.contains("does not hash to")),
405 "a forged hash passed: {report:?}"
406 );
407 }
408
409 /// A page with an entry dropped out of the middle: the parent link
410 /// no longer joins, which is the check that makes paging safe.
411 #[test]
412 fn an_entry_dropped_from_the_middle_breaks_the_chain() {
413 let key = ActorKey::generate();
414 let mut entries = page(&key, 3);
415 entries.remove(1);
416 let report = super::page(&entries, ®istry_for(&key), &Revocations::new());
417 assert!(
418 report.failures.iter().any(|f| f.contains("parent is not")),
419 "a gap passed: {report:?}"
420 );
421 assert!(
422 report
423 .failures
424 .iter()
425 .any(|f| f.contains("an entry is missing")),
426 "the sequence gap was not reported: {report:?}"
427 );
428 }
429
430 /// A signature lifted from one entry onto another. The bytes are a
431 /// real signature by a trusted key — only over different content.
432 #[test]
433 fn a_signature_moved_between_entries_does_not_verify() {
434 let key = ActorKey::generate();
435 let mut entries = page(&key, 3);
436 let lifted = entries[0]["author_sig_hex"].clone();
437 entries[2]["author_sig_hex"] = lifted;
438 let report = super::page(&entries, ®istry_for(&key), &Revocations::new());
439 assert!(
440 report
441 .failures
442 .iter()
443 .any(|f| f.contains("does not verify")),
444 "a replayed signature passed: {report:?}"
445 );
446 }
447
448 /// The honesty case: no key held means unverified, and it must never
449 /// be counted as checked.
450 #[test]
451 fn an_unheld_key_is_unverified_rather_than_verified() {
452 let key = ActorKey::generate();
453 let report = super::page(&page(&key, 2), &Registry::new(), &Revocations::new());
454 assert!(report.failures.is_empty(), "an unheld key is not a failure");
455 assert_eq!(report.checked, 0, "authorship was claimed without a key");
456 assert_eq!(report.unverified, 2);
457 assert!(report.notes.iter().any(|n| n.contains("no key held")));
458 }
459
460 /// Base64url, no padding — what a `clientDataJSON` challenge is
461 /// spelled in. Ten lines rather than a dependency, and self-checking:
462 /// get it wrong and every assertion below fails on a challenge
463 /// mismatch rather than passing quietly.
464 fn base64url(bytes: &[u8]) -> String {
465 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
466 let mut out = String::new();
467 for chunk in bytes.chunks(3) {
468 let mut buf = [0u8; 3];
469 buf[..chunk.len()].copy_from_slice(chunk);
470 let n = u32::from_be_bytes([0, buf[0], buf[1], buf[2]]);
471 for i in 0..chunk.len() + 1 {
472 out.push(ALPHABET[(n >> (18 - 6 * i) & 0x3f) as usize] as char);
473 }
474 }
475 out
476 }
477
478 /// A scratch directory this test alone owns. These run on parallel
479 /// threads in one process, so a shared name is two tests overwriting
480 /// each other's key files.
481 fn scratch(tag: &str) -> std::path::PathBuf {
482 let dir =
483 std::env::temp_dir().join(format!("choir-verify-d45-{}-{tag}", std::process::id()));
484 std::fs::remove_dir_all(&dir).ok();
485 std::fs::create_dir_all(&dir).expect("scratch dir");
486 dir
487 }
488
489 /// A P-256 credential: the private key file, and its public key as
490 /// SubjectPublicKeyInfo DER — the exact bytes `getPublicKey()`
491 /// returns and `Witness::credential_key` carries.
492 fn credential(dir: &std::path::Path, name: &str) -> (std::path::PathBuf, Vec<u8>) {
493 let secret = dir.join(format!("{name}.key"));
494 let spki = dir.join(format!("{name}.der"));
495 assert!(std::process::Command::new("openssl")
496 .args([
497 "ecparam",
498 "-name",
499 "prime256v1",
500 "-genkey",
501 "-noout",
502 "-out"
503 ])
504 .arg(&secret)
505 .output()
506 .expect("openssl runs")
507 .status
508 .success());
509 assert!(std::process::Command::new("openssl")
510 .args(["ec", "-in"])
511 .arg(&secret)
512 .args(["-pubout", "-outform", "DER", "-out"])
513 .arg(&spki)
514 .output()
515 .expect("openssl runs")
516 .status
517 .success());
518 (secret, std::fs::read(&spki).expect("spki"))
519 }
520
521 /// One entry signed the way an authenticator signs one, served in
522 /// `/api/log`'s shape.
523 ///
524 /// Built with real ECDSA rather than a fixture, because the claim
525 /// under test is that these bytes verify against nothing but
526 /// themselves — and a hand-written signature verifies against
527 /// nothing at all. `carried` is the key written into the witness,
528 /// which is a separate argument from the one that signs precisely so
529 /// a test can make them disagree.
530 fn passkey_entry(
531 dir: &std::path::Path,
532 secret: &std::path::Path,
533 carried: Option<Vec<u8>>,
534 ) -> serde_json::Value {
535 let channel = "bob";
536 let payload = b"an op bob approved".to_vec();
537 let signing = choir_oplog::signing_hash(channel, &payload);
538 let challenge = base64url(&choir_identity::webauthn_challenge(&signing));
539 let client_data =
540 format!(r#"{{"type":"webauthn.get","challenge":"{challenge}","origin":"x"}}"#)
541 .into_bytes();
542
543 let cd = dir.join("cd.json");
544 std::fs::write(&cd, &client_data).expect("write");
545 let hashed = std::process::Command::new("openssl")
546 .args(["dgst", "-sha256", "-binary"])
547 .arg(&cd)
548 .output()
549 .expect("openssl runs");
550 assert!(hashed.status.success(), "hash clientDataJSON");
551 let authenticator_data = vec![0x49u8; 37];
552 let mut message = authenticator_data.clone();
553 message.extend_from_slice(&hashed.stdout);
554
555 let msg = dir.join("assertion.bin");
556 let der = dir.join("assertion.der");
557 std::fs::write(&msg, &message).expect("write");
558 assert!(std::process::Command::new("openssl")
559 .args(["dgst", "-sha256", "-sign"])
560 .arg(secret)
561 .args(["-out"])
562 .arg(&der)
563 .arg(&msg)
564 .output()
565 .expect("openssl runs")
566 .status
567 .success());
568
569 let mut sig = choir_oplog::Witness::webauthn_es256(
570 "bobs-laptop",
571 std::fs::read(&der).expect("signature"),
572 authenticator_data,
573 client_data,
574 );
575 sig.credential_key = carried;
576 let entry = OpEntry {
577 format_version: FORMAT_VERSION,
578 parent: None,
579 seq: 0,
580 channel: channel.into(),
581 payload,
582 witnesses: Vec::new(),
583 author_sig: Some(sig),
584 };
585 let hex = |bytes: &[u8]| bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
586 let sig = entry.author_sig.as_ref().expect("signed");
587 let mut served = serde_json::json!({
588 "seq": entry.seq,
589 "workspace": entry.channel,
590 "payload_hex": hex(&entry.payload),
591 "author_key": sig.key_id,
592 "author_sig_hex": hex(&sig.signature),
593 "author_scheme": sig.scheme,
594 "authenticator_data_hex": sig.authenticator_data.as_deref().map(hex),
595 "client_data_json_hex": sig.client_data_json.as_deref().map(hex),
596 "hash": entry.content_hash().to_hex(),
597 "parent": serde_json::Value::Null,
598 "format_version": entry.format_version,
599 "witnesses": entry.witnesses,
600 });
601 if let Some(key) = sig.credential_key.as_deref() {
602 served["credential_key_hex"] = serde_json::json!(hex(key));
603 }
604 served
605 }
606
607 /// D45's whole point: a passkey-signed entry checks out with no
608 /// credential store, no registry and no node — which is the state a
609 /// Phase-4 restore leaves a reader in, because `pull_backup.sh` takes
610 /// the log and not the accounts file.
611 #[test]
612 fn a_passkey_entry_verifies_from_the_page_alone() {
613 let dir = scratch("carried");
614 let (secret, spki) = credential(&dir, "bob");
615 let entries = vec![passkey_entry(&dir, &secret, Some(spki))];
616
617 let report = super::page(&entries, &Registry::new(), &Revocations::new());
618 assert!(report.failures.is_empty(), "{:?}", report.failures);
619 assert_eq!(report.integrity_only, 1);
620 assert_eq!(report.unverified, 0);
621 // And never as a whole check: the credential arrived with the
622 // entry, so nothing vouched for it, and reporting this as
623 // `checked` would claim an anchor that does not exist.
624 assert_eq!(
625 report.checked, 0,
626 "an unanchored key was counted as checked"
627 );
628 assert!(report
629 .notes
630 .iter()
631 .any(|n| n.contains("nothing in the log ties that credential to a channel")));
632 std::fs::remove_dir_all(&dir).ok();
633 }
634
635 /// The substitution the field invites: swap in a different, perfectly
636 /// valid credential key. It cannot forge anything — it makes a good
637 /// entry stop verifying — and it must be reported as a failure and
638 /// not as something unverifiable.
639 ///
640 /// The served `hash` is rebuilt around the substitution, so the
641 /// recomputation check passes and this test can only be satisfied by
642 /// the signature check. Tampering without that step fails on the hash
643 /// and proves nothing about D45.
644 #[test]
645 fn a_substituted_credential_key_is_a_failure() {
646 let dir = scratch("swapped");
647 let (secret, _) = credential(&dir, "bob");
648 let (_, other) = credential(&dir, "someone-else");
649 let mut entry = passkey_entry(&dir, &secret, Some(other));
650 entry["hash"] = serde_json::json!(super::rebuild(&entry)
651 .expect("rebuildable")
652 .content_hash()
653 .to_hex());
654
655 let report = super::page(&[entry], &Registry::new(), &Revocations::new());
656 assert!(
657 report
658 .failures
659 .iter()
660 .any(|f| f.contains("passkey signature does not verify")),
661 "a swapped credential key passed: {report:?}"
662 );
663 assert_eq!(report.integrity_only, 0);
664 std::fs::remove_dir_all(&dir).ok();
665 }
666
667 /// Entries written before D45 carry no credential key, and there is
668 /// no way to obtain one now. Say so, and count it as unverified —
669 /// the failure bucket would accuse an honest node of lying about
670 /// history it wrote correctly under the older format.
671 #[test]
672 fn a_passkey_entry_written_before_d45_stays_unverified() {
673 let dir = scratch("pre-d45");
674 let (secret, _) = credential(&dir, "bob");
675 let entries = vec![passkey_entry(&dir, &secret, None)];
676
677 let report = super::page(&entries, &Registry::new(), &Revocations::new());
678 assert!(report.failures.is_empty(), "{:?}", report.failures);
679 assert_eq!(report.unverified, 1);
680 assert_eq!(report.integrity_only, 0);
681 assert!(report
682 .notes
683 .iter()
684 .any(|n| n.contains("written before D45")));
685 std::fs::remove_dir_all(&dir).ok();
686 }
687
688 /// The counters do not leak into each other. An ed25519 page was
689 /// `checked` before D45 and is `checked` after it, and adding a
690 /// second bucket must not quietly reclassify anything.
691 #[test]
692 fn an_ed25519_page_is_untouched_by_the_passkey_bucket() {
693 let key = ActorKey::generate();
694 let report = super::page(&page(&key, 3), ®istry_for(&key), &Revocations::new());
695 assert_eq!(report.checked, 3);
696 assert_eq!(report.integrity_only, 0);
697 }
698}