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