Skip to main content

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), &registry_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), &registry_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), &registry_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), &registry_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, &registry_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), &registry_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, &registry_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, &registry_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, &registry_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), &registry_for(&key), &Revocations::new());
740        assert_eq!(report.checked, 3);
741        assert_eq!(report.integrity_only, 0);
742    }
743}