Skip to main content

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), &registry_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), &registry_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), &registry_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), &registry_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, &registry_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, &registry_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, &registry_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), &registry_for(&key), &Revocations::new());
695        assert_eq!(report.checked, 3);
696        assert_eq!(report.integrity_only, 0);
697    }
698}