Skip to main content

atproto_devtool/commands/test/labeler/
crypto.rs

1//! Cryptographic signature verification stage for the labeler conformance suite.
2//!
3//! This stage verifies that labels published by a labeler are correctly signed
4//! using the labeler's declared signing key. It implements DRISL-CBOR canonicalization
5//! (deterministic canonical encoding per RFC 8949) and supports key rotation via
6//! the did:plc audit log.
7
8use std::borrow::Cow;
9use std::collections::BTreeMap;
10
11use atrium_api::com::atproto::label::defs::Label;
12use ciborium::value::Value;
13use sha2::{Digest, Sha256};
14use thiserror::Error;
15
16use crate::commands::test::labeler::report::{CheckResult, CheckStatus, Stage};
17use crate::common::identity::is_local_labeler_hostname;
18
19/// Checks emitted by the crypto stage.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Check {
22    /// Overall rollup result for the crypto stage.
23    Rollup,
24    /// A label could not be canonicalized for signing.
25    CanonicalizationFailed,
26    /// PLC audit log fetch for historic key fallback.
27    PlcHistoryFetch,
28    /// Labels were verified only against a rotated-out key.
29    RotatedKeysUsed,
30    /// A label failed signature verification.
31    LabelVerificationFailed,
32    /// The signature bytes on a label could not be parsed.
33    SignatureBytesUnparseable,
34}
35
36impl Check {
37    /// Stable check ID string used in `CheckResult.id`.
38    pub fn id(self) -> &'static str {
39        match self {
40            Check::Rollup => "crypto::rollup",
41            Check::CanonicalizationFailed => "crypto::canonicalization_failed",
42            Check::PlcHistoryFetch => "crypto::plc_history_fetch",
43            Check::RotatedKeysUsed => "crypto::rotated_keys_used",
44            Check::LabelVerificationFailed => "crypto::label_verification_failed",
45            Check::SignatureBytesUnparseable => "crypto::signature_bytes_unparseable",
46        }
47    }
48
49    pub fn pass(self) -> CheckResult {
50        CheckResult {
51            id: self.id(),
52            stage: Stage::Crypto,
53            status: CheckStatus::Pass,
54            summary: Cow::Borrowed(match self {
55                Check::Rollup => "All labels verified with current or historic keys",
56                _ => "crypto check passed",
57            }),
58            diagnostic: None,
59            skipped_reason: None,
60        }
61    }
62
63    pub fn spec_violation(
64        self,
65        diagnostic: Box<dyn miette::Diagnostic + Send + Sync>,
66    ) -> CheckResult {
67        CheckResult {
68            id: self.id(),
69            stage: Stage::Crypto,
70            status: CheckStatus::SpecViolation,
71            summary: Cow::Borrowed(match self {
72                Check::Rollup => "Labels failed verification",
73                Check::CanonicalizationFailed => "Label canonicalization failed",
74                Check::LabelVerificationFailed => "Label signature verification failed",
75                Check::SignatureBytesUnparseable => "Signature bytes are unparseable",
76                _ => "crypto check failed",
77            }),
78            diagnostic: Some(diagnostic),
79            skipped_reason: None,
80        }
81    }
82
83    pub fn network_error(
84        self,
85        diagnostic: Box<dyn miette::Diagnostic + Send + Sync>,
86    ) -> CheckResult {
87        CheckResult {
88            id: self.id(),
89            stage: Stage::Crypto,
90            status: CheckStatus::NetworkError,
91            summary: Cow::Borrowed(match self {
92                Check::PlcHistoryFetch => "PLC history fetch failed",
93                _ => "crypto network error",
94            }),
95            diagnostic: Some(diagnostic),
96            skipped_reason: None,
97        }
98    }
99
100    pub fn advisory(self) -> CheckResult {
101        CheckResult {
102            id: self.id(),
103            stage: Stage::Crypto,
104            status: CheckStatus::Advisory,
105            summary: Cow::Borrowed(match self {
106                Check::RotatedKeysUsed => "Labels signed by rotated-out key",
107                _ => "crypto advisory",
108            }),
109            diagnostic: None,
110            skipped_reason: None,
111        }
112    }
113
114    pub fn skip(self, reason: impl Into<Cow<'static, str>>) -> CheckResult {
115        CheckResult {
116            id: self.id(),
117            stage: Stage::Crypto,
118            status: CheckStatus::Skipped,
119            summary: Cow::Borrowed(match self {
120                Check::Rollup => "Crypto stage (no labels to verify)",
121                _ => "crypto check skipped",
122            }),
123            diagnostic: None,
124            skipped_reason: Some(reason.into()),
125        }
126    }
127}
128
129/// The canonical form of a label as it was signed by the labeler.
130pub struct CanonicalLabel {
131    /// SHA-256 hash of the canonical bytes (with sig stripped).
132    pub prehash: [u8; 32],
133    /// The DRISL-CBOR bytes that were hashed (sig stripped).
134    pub canonical_bytes: Vec<u8>,
135    /// Raw signature bytes (r || s) extracted from the sig field.
136    pub signature_bytes: Vec<u8>,
137}
138
139/// Error from label canonicalization.
140#[derive(Debug, Clone, Error)]
141pub enum CanonicalizeError {
142    /// The serialized CBOR representation could not be produced.
143    #[error("Invalid label CBOR: {cause}")]
144    InvalidLabelCbor {
145        /// Details of the serialization failure.
146        cause: String,
147    },
148    /// The label contains a floating-point value (not allowed in DRISL).
149    #[error("Floating-point values are not allowed in labels")]
150    FloatRejected,
151    /// The label contains indefinite-length CBOR items (not allowed in DRISL).
152    #[error("Indefinite-length items are not allowed in labels")]
153    IndefiniteLengthRejected,
154    /// The label is missing a `sig` field.
155    #[error("Label is missing a 'sig' field")]
156    MissingSigField,
157    /// The `sig` field is not a byte string.
158    #[error("The 'sig' field must be a CBOR byte string")]
159    SigFieldWrongType,
160    /// The `sig` field does not contain exactly 64 bytes (r || s).
161    #[error("The 'sig' field must be 64 bytes (r || s concatenated), got {actual}")]
162    SigFieldWrongLength {
163        /// The actual length of the signature field.
164        actual: usize,
165    },
166}
167
168/// Error from parsing a signature from raw bytes.
169#[derive(Debug, Clone, Error)]
170pub enum SignatureParseError {
171    /// The signature bytes could not be parsed as a secp256k1 (k256) signature.
172    #[error("Failed to parse signature as secp256k1: {cause}")]
173    K256Failed {
174        /// Details of the parsing failure.
175        cause: String,
176    },
177    /// The signature bytes could not be parsed as a NIST P-256 (p256) signature.
178    #[error("Failed to parse signature as NIST P-256: {cause}")]
179    P256Failed {
180        /// Details of the parsing failure.
181        cause: String,
182    },
183}
184
185/// Error from the crypto stage covering signature verification failures.
186#[derive(Debug, Clone, Error, miette::Diagnostic)]
187pub enum CryptoCheckError {
188    /// Current key verification failed; no rotation history available.
189    #[error(
190        "labels failed verification against current key \"{current_key_id}\" and did:web provides no rotation history"
191    )]
192    #[diagnostic(code = "labeler::crypto::did_web_no_rotation_history")]
193    DidWebNoRotationHistory {
194        /// The current key id that failed verification.
195        current_key_id: String,
196    },
197    /// Neither current nor historic keys could verify the labels.
198    #[error(
199        "some labels could not be verified against any of the {} tried key id(s): {tried_keys:?}",
200        tried_keys.len()
201    )]
202    #[diagnostic(code = "labeler::crypto::multi_key_verification_failed")]
203    MultiKeyVerificationFailed {
204        /// List of all key ids that were tried.
205        tried_keys: Vec<String>,
206    },
207    /// Network error fetching PLC audit log prevented checking historic keys.
208    #[error("failed to fetch PLC audit log for {did}: {reason}")]
209    #[diagnostic(code = "labeler::crypto::plc_history_fetch_network_error")]
210    PlcHistoryFetchNetworkError {
211        /// The labeler's DID.
212        did: String,
213        /// Reason for the network failure.
214        reason: String,
215    },
216    /// Label canonicalization failed.
217    #[error("failed to canonicalize label {label_uri} for signing")]
218    #[diagnostic(code = "labeler::crypto::label_canonicalization_failed")]
219    LabelCanonicalizationFailed {
220        /// The label's URI for context.
221        label_uri: String,
222        /// The underlying canonicalization error.
223        #[source]
224        source: CanonicalizeError,
225    },
226    /// The signature bytes on a label could not be parsed for the current key's curve.
227    #[error(
228        "signature field for label {label_uri} is not a valid {curve} ECDSA signature for the current key"
229    )]
230    #[diagnostic(code = "labeler::crypto::signature_bytes_unparseable")]
231    SignatureBytesUnparseable {
232        /// The label's URI for context.
233        label_uri: String,
234        /// The current key's curve name.
235        curve: &'static str,
236    },
237    /// A label failed verification against the current key and PLC history could not be consulted.
238    #[error(
239        "label {label_uri} failed verification against current key \"{current_key_id}\" and PLC history could not be consulted"
240    )]
241    #[diagnostic(code = "labeler::crypto::label_verification_failed_no_history")]
242    LabelVerificationFailedNoHistory {
243        /// The current key id that failed verification.
244        current_key_id: String,
245        /// The label's URI for context.
246        label_uri: String,
247    },
248}
249
250/// Canonicalize a label for signature verification.
251///
252/// This function:
253/// 1. Serializes the label to a `ciborium::Value` tree.
254/// 2. Validates the tree (rejects floats and indefinite-length items).
255/// 3. Extracts and validates the `sig` field (must be 64-byte byte string).
256/// 4. Removes the `sig` field from the tree.
257/// 5. Sorts all map keys by their canonical CBOR-encoded byte representation (RFC 8949 deterministic encoding).
258/// 6. Re-serializes the sorted tree to deterministic CBOR bytes.
259/// 7. Computes the SHA-256 prehash of the canonical bytes.
260///
261/// Returns a `CanonicalLabel` containing the prehash, canonical bytes, and signature.
262pub fn canonicalize_label_for_signing(label: &Label) -> Result<CanonicalLabel, CanonicalizeError> {
263    // Canonicalize `label.data` (the plain `LabelData`) rather than the
264    // `Object<LabelData>` wrapper: atrium preserves unknown JSON fields on
265    // the wrapper's `extra_data` bag and re-serializes them as siblings,
266    // which leaks server-side metadata (e.g. database ids) into the form
267    // that gets signed. Labelers sign only the spec's own fields, so
268    // including `extra_data` in the prehash causes verification to fail
269    // against any labeler whose REST response carries extra JSON fields.
270    let mut value: Value = ciborium::value::Value::serialized(&label.data).map_err(|e| {
271        CanonicalizeError::InvalidLabelCbor {
272            cause: format!("{e}"),
273        }
274    })?;
275
276    // floats should never appear, but we check defensively.
277    validate_value(&value)?;
278
279    let signature_bytes = extract_and_remove_sig(&mut value)?;
280
281    canonicalize_tree(&mut value)?;
282
283    let mut canonical_bytes = Vec::new();
284    ciborium::ser::into_writer(&value, &mut canonical_bytes).map_err(|e| {
285        CanonicalizeError::InvalidLabelCbor {
286            cause: format!("Re-serialization failed: {e}"),
287        }
288    })?;
289
290    let prehash: [u8; 32] = Sha256::digest(&canonical_bytes).into();
291
292    Ok(CanonicalLabel {
293        prehash,
294        canonical_bytes,
295        signature_bytes,
296    })
297}
298
299/// Validate that the value tree contains no floats or indefinite-length items.
300fn validate_value(value: &Value) -> Result<(), CanonicalizeError> {
301    match value {
302        Value::Null | Value::Bool(_) | Value::Integer(_) | Value::Bytes(_) | Value::Text(_) => {
303            Ok(())
304        }
305        Value::Float(_) => Err(CanonicalizeError::FloatRejected),
306        Value::Array(arr) => {
307            for item in arr {
308                validate_value(item)?;
309            }
310            Ok(())
311        }
312        Value::Map(map) => {
313            for (k, v) in map {
314                validate_value(k)?;
315                validate_value(v)?;
316            }
317            Ok(())
318        }
319        Value::Tag(_, val) => validate_value(val),
320        _ => Ok(()),
321    }
322}
323
324/// Extract the `sig` field from the top-level map and remove it.
325///
326/// The `sig` field must be:
327/// - A byte string (not any other type).
328/// - Exactly 64 bytes long (r || s concatenated).
329fn extract_and_remove_sig(value: &mut Value) -> Result<Vec<u8>, CanonicalizeError> {
330    match value {
331        Value::Map(map) => {
332            // Find and extract the "sig" entry.
333            let sig_key = Value::Text("sig".to_string());
334            let mut sig_value = None;
335
336            // Find the position of the "sig" key.
337            let sig_index = map.iter().position(|(k, _)| k == &sig_key);
338
339            // Extract and remove the sig entry.
340            if let Some(idx) = sig_index {
341                let (_, val) = map.remove(idx);
342                sig_value = Some(val);
343            }
344
345            let sig_value = sig_value.ok_or(CanonicalizeError::MissingSigField)?;
346
347            // Validate and extract the signature bytes.
348            match sig_value {
349                Value::Bytes(ref bytes) => {
350                    if bytes.len() != 64 {
351                        return Err(CanonicalizeError::SigFieldWrongLength {
352                            actual: bytes.len(),
353                        });
354                    }
355                    Ok(bytes.clone())
356                }
357                _ => Err(CanonicalizeError::SigFieldWrongType),
358            }
359        }
360        _ => Err(CanonicalizeError::MissingSigField),
361    }
362}
363
364/// Canonicalize a value tree in-place by sorting maps by their canonical CBOR-encoded key bytes.
365///
366/// Per RFC 8949 deterministic encoding, map keys must be sorted lexicographically
367/// by their canonical CBOR byte representation, not by raw string value.
368fn canonicalize_tree(value: &mut Value) -> Result<(), CanonicalizeError> {
369    match value {
370        Value::Array(arr) => {
371            for item in arr {
372                canonicalize_tree(item)?;
373            }
374            Ok(())
375        }
376        Value::Map(map) => {
377            // Recursively canonicalize all values.
378            for (_, v) in map.iter_mut() {
379                canonicalize_tree(v)?;
380            }
381
382            // Sort map entries by their canonical CBOR-encoded key bytes.
383            let mut entries: Vec<_> = std::mem::take(map);
384            entries.sort_by(|(k1, _), (k2, _)| {
385                let bytes1 = encode_key_to_bytes(k1);
386                let bytes2 = encode_key_to_bytes(k2);
387                bytes1.cmp(&bytes2)
388            });
389
390            // Rebuild the map with sorted entries.
391            *map = entries;
392
393            Ok(())
394        }
395        Value::Tag(_, val) => canonicalize_tree(val),
396        _ => Ok(()),
397    }
398}
399
400/// Encode a CBOR key value to its canonical byte representation.
401///
402/// This is used for sorting keys in deterministic order.
403fn encode_key_to_bytes(value: &Value) -> Vec<u8> {
404    let mut bytes = Vec::new();
405    let _ = ciborium::ser::into_writer(value, &mut bytes);
406    bytes
407}
408
409/// Facts gathered from the crypto stage, populated only when checks pass.
410#[derive(Debug, Clone)]
411pub struct CryptoFacts {
412    /// Number of labels verified with the current (declared) key.
413    pub verified_with_current: usize,
414    /// Hits from historic key verification (key ID -> label count).
415    pub verified_with_historic: Vec<HistoricKeyHit>,
416    /// Number of labels that could not be verified.
417    pub unverified: usize,
418}
419
420/// Record of a successful verification with a historic (rotated-out) key.
421#[derive(Debug, Clone)]
422pub struct HistoricKeyHit {
423    /// The multikey string of the key that verified these labels.
424    pub key_id: String,
425    /// Count of labels verified with this key.
426    pub label_count: usize,
427}
428
429/// Output from the crypto stage.
430#[derive(Debug)]
431pub struct CryptoStageOutput {
432    /// Facts populated only when all checks pass.
433    pub facts: Option<CryptoFacts>,
434    /// All check results from this stage.
435    pub results: Vec<CheckResult>,
436}
437
438/// A label that failed to verify against the current key.
439#[derive(Debug, Clone)]
440struct FailedLabel {
441    /// The label that failed.
442    label: Label,
443    /// Canonicalization error, if any (else signature mismatch).
444    canonicalization_error: Option<CanonicalizeError>,
445}
446
447/// Run the crypto stage: verify labels against identity's signing key, with PLC history fallback.
448///
449/// Logic:
450/// 1. Empty labels → emit Skipped.
451/// 2. For each label: canonicalize → on error buffer a per-label SpecViolation.
452/// 3. Verify against `identity.signing_key` → on success increment `verified_with_current`.
453/// 4. On all-pass: emit `crypto::rollup` Pass.
454/// 5. Otherwise, if the labeler endpoint is local (loopback, RFC 1918, .local):
455///    emit `crypto::rollup` Skipped and drop the buffered per-label violations.
456///    Rationale: a developer testing a local copy of a labeler is unlikely to
457///    have the production signing key present, so label-signature failures are
458///    expected and not a conformance issue for this run.
459/// 6. Else if `did:plc`: fetch PLC audit log and retry against historic keys.
460/// 7. Else (`did:web`): emit `crypto::rollup` SpecViolation with no rotation history.
461pub async fn run(
462    identity: &crate::commands::test::labeler::identity::IdentityFacts,
463    labels: &[Label],
464    http: &dyn crate::common::identity::HttpClient,
465) -> CryptoStageOutput {
466    // Handle the empty-labels case early.
467    if labels.is_empty() {
468        return CryptoStageOutput {
469            facts: None,
470            results: vec![Check::Rollup.skip("labeler published no labels; nothing to verify")],
471        };
472    }
473
474    let mut results = Vec::new();
475    let mut per_label_violations = Vec::new();
476    let mut verified_with_current = 0usize;
477    let mut failed_against_current: Vec<FailedLabel> = Vec::new();
478
479    // Verify all labels against the current key. Per-label SpecViolations are
480    // buffered so we can drop them cleanly when a local labeler triggers the
481    // "production key not present" skip path.
482    for label in labels {
483        match canonicalize_label_for_signing(label) {
484            Err(err) => {
485                let diagnostic = CryptoCheckError::LabelCanonicalizationFailed {
486                    label_uri: label.uri.clone(),
487                    source: err.clone(),
488                };
489                per_label_violations
490                    .push(Check::CanonicalizationFailed.spec_violation(Box::new(diagnostic)));
491                failed_against_current.push(FailedLabel {
492                    label: label.clone(),
493                    canonicalization_error: Some(err),
494                });
495            }
496            Ok(canonical) => {
497                match parse_signature(&canonical.signature_bytes, &identity.signing_key) {
498                    Err(_) => {
499                        let diagnostic = CryptoCheckError::SignatureBytesUnparseable {
500                            label_uri: label.uri.clone(),
501                            curve: identity.signing_key.curve_name(),
502                        };
503                        per_label_violations.push(
504                            Check::SignatureBytesUnparseable.spec_violation(Box::new(diagnostic)),
505                        );
506                        failed_against_current.push(FailedLabel {
507                            label: label.clone(),
508                            canonicalization_error: None,
509                        });
510                    }
511                    Ok(signature) => {
512                        match identity
513                            .signing_key
514                            .verify_prehash(&canonical.prehash, &signature)
515                        {
516                            Ok(()) => {
517                                verified_with_current += 1;
518                            }
519                            Err(_) => {
520                                failed_against_current.push(FailedLabel {
521                                    label: label.clone(),
522                                    canonicalization_error: None,
523                                });
524                            }
525                        }
526                    }
527                }
528            }
529        }
530    }
531
532    tracing::debug!(
533        total_labels = labels.len(),
534        verified_with_current,
535        failed = failed_against_current.len(),
536        "crypto stage: current-key verification complete"
537    );
538
539    // Check if all labels verified with current key.
540    if failed_against_current.is_empty() {
541        results.push(CheckResult {
542            summary: Cow::Owned(format!(
543                "{verified_with_current} labels verified against current key"
544            )),
545            ..Check::Rollup.pass()
546        });
547        return CryptoStageOutput {
548            facts: Some(CryptoFacts {
549                verified_with_current,
550                verified_with_historic: Vec::new(),
551                unverified: 0,
552            }),
553            results,
554        };
555    }
556
557    // Some labels failed to verify. If the labeler endpoint is local, assume
558    // the developer is testing with a signing key different from the one
559    // published in the DID document, and skip the rest of the stage rather
560    // than flagging the mismatch as a spec violation.
561    if is_local_labeler_hostname(&identity.labeler_endpoint) {
562        results.push(Check::Rollup.skip(
563            "local labeler signing key does not match the published DID document \
564             (production signing key not available in this test environment)",
565        ));
566        return CryptoStageOutput {
567            facts: None,
568            results,
569        };
570    }
571
572    // Non-local: commit the buffered per-label violations before falling
573    // through to the PLC-history / did:web branches below.
574    results.extend(per_label_violations);
575
576    // Check DID type for history fallback.
577    match identity.did.method() {
578        crate::common::identity::DidMethod::Plc => {
579            tracing::debug!(
580                did = %identity.did,
581                "crypto stage: fetching PLC audit log for historic keys"
582            );
583            match crate::common::identity::plc_history_for_fragment(
584                &identity.did,
585                "atproto_label",
586                http,
587            )
588            .await
589            {
590                Err(e) => {
591                    // Transport error fetching PLC history.
592                    let diagnostic = CryptoCheckError::PlcHistoryFetchNetworkError {
593                        did: identity.did.to_string(),
594                        reason: format!("{e}"),
595                    };
596                    results.push(Check::PlcHistoryFetch.network_error(Box::new(diagnostic)));
597
598                    // Emit Fail for each failed label since history could not be consulted.
599                    for failed in &failed_against_current {
600                        let diagnostic = CryptoCheckError::LabelVerificationFailedNoHistory {
601                            current_key_id: identity.signing_key_id.clone(),
602                            label_uri: failed.label.uri.clone(),
603                        };
604                        results.push(
605                            Check::LabelVerificationFailed.spec_violation(Box::new(diagnostic)),
606                        );
607                    }
608                    CryptoStageOutput {
609                        facts: None,
610                        results,
611                    }
612                }
613                Ok(historic_keys) => {
614                    tracing::debug!(
615                        historic_key_count = historic_keys.len(),
616                        "crypto stage: PLC audit log returned historic keys"
617                    );
618                    let mut historic_hits: BTreeMap<String, usize> = BTreeMap::new();
619                    let mut tried_historic_key_ids = Vec::new();
620
621                    // Try each historic key against remaining failed labels.
622                    for historic_key in historic_keys {
623                        tracing::debug!(
624                            key_id = %historic_key.key_id,
625                            "crypto stage: attempting verification with historic key"
626                        );
627                        if failed_against_current.is_empty() {
628                            break; // All labels found matches.
629                        }
630
631                        // Parse the multikey.
632                        match crate::common::identity::parse_multikey(&historic_key.key_id) {
633                            Err(_) => {
634                                // Skip parse failures; log and continue.
635                                tracing::warn!(
636                                    key_id = %historic_key.key_id,
637                                    "failed to parse historic multikey"
638                                );
639                                tried_historic_key_ids.push(historic_key.key_id.clone());
640                                continue;
641                            }
642                            Ok(parsed) => {
643                                // Track that we attempted this historic key.
644                                tried_historic_key_ids.push(historic_key.key_id.clone());
645                                // Try to verify each failed label against this historic key.
646                                let mut newly_verified = Vec::new();
647                                for (i, failed) in failed_against_current.iter().enumerate() {
648                                    // Skip if canonicalization failed (can't retry with different key).
649                                    if failed.canonicalization_error.is_some() {
650                                        continue;
651                                    }
652
653                                    // Re-canonicalize for verification.
654                                    if let Ok(canonical) =
655                                        canonicalize_label_for_signing(&failed.label)
656                                    {
657                                        // Parse signature for the historic key's curve.
658                                        if let Ok(signature) = parse_signature(
659                                            &canonical.signature_bytes,
660                                            &parsed.verifying_key,
661                                        ) {
662                                            if parsed
663                                                .verifying_key
664                                                .verify_prehash(&canonical.prehash, &signature)
665                                                .is_ok()
666                                            {
667                                                newly_verified.push(i);
668                                                *historic_hits
669                                                    .entry(historic_key.key_id.clone())
670                                                    .or_insert(0) += 1;
671                                            }
672                                        }
673                                    }
674                                }
675
676                                // Remove newly verified labels from the failed buffer (in reverse order).
677                                for i in newly_verified.iter().rev() {
678                                    failed_against_current.remove(*i);
679                                }
680                            }
681                        }
682                    }
683
684                    // Determine final outcome.
685                    if failed_against_current.is_empty() {
686                        // All labels found historic-key matches.
687                        let total_count: usize = historic_hits.values().sum();
688                        let distinct_count = historic_hits.len();
689                        results.push(CheckResult {
690                            summary: Cow::Owned(format!(
691                                "{total_count} label(s) signed by a rotated-out key ({distinct_count} distinct key id(s))"
692                            )),
693                            ..Check::RotatedKeysUsed.advisory()
694                        });
695                        results.push(Check::Rollup.pass());
696                        CryptoStageOutput {
697                            facts: Some(CryptoFacts {
698                                verified_with_current,
699                                verified_with_historic: historic_hits
700                                    .into_iter()
701                                    .map(|(key_id, label_count)| HistoricKeyHit {
702                                        key_id,
703                                        label_count,
704                                    })
705                                    .collect(),
706                                unverified: 0,
707                            }),
708                            results,
709                        }
710                    } else {
711                        // Some labels remain unverified after trying all keys.
712                        // List all keys that were tried, including those that did not verify
713                        // anything. Normalise every entry to a bare multibase-`z` multikey so
714                        // the current key and historic keys render in the same shape, then
715                        // drop duplicates (a historic entry may repeat the current key if it
716                        // was never actually rotated).
717                        let mut tried_keys = vec![identity.signing_key_multikey.clone()];
718                        for raw in &tried_historic_key_ids {
719                            let normalised =
720                                raw.strip_prefix("did:key:").unwrap_or(raw).to_string();
721                            if !tried_keys.contains(&normalised) {
722                                tried_keys.push(normalised);
723                            }
724                        }
725                        let diagnostic = CryptoCheckError::MultiKeyVerificationFailed {
726                            tried_keys: tried_keys.clone(),
727                        };
728                        results.push(CheckResult {
729                            summary: Cow::Owned(format!(
730                                "Some labels could not be verified against any key (tried {} key id(s))",
731                                tried_keys.len()
732                            )),
733                            ..Check::Rollup.spec_violation(Box::new(diagnostic))
734                        });
735                        CryptoStageOutput {
736                            facts: None,
737                            results,
738                        }
739                    }
740                }
741            }
742        }
743        _ => {
744            // did:web or other method; no rotation history available.
745            let diagnostic = CryptoCheckError::DidWebNoRotationHistory {
746                current_key_id: identity.signing_key_id.clone(),
747            };
748            results.push(CheckResult {
749                summary: Cow::Borrowed(
750                    "Labels failed verification and did:web provides no rotation history",
751                ),
752                ..Check::Rollup.spec_violation(Box::new(diagnostic))
753            });
754            CryptoStageOutput {
755                facts: None,
756                results,
757            }
758        }
759    }
760}
761
762/// Parse a signature from raw 64-byte (r || s) format into an AnySignature.
763///
764/// Takes the curve variant from the verifying key to determine which curve
765/// to use for parsing. This ensures the signature is parsed for the correct curve,
766/// not via trial-and-error.
767fn parse_signature(
768    bytes: &[u8],
769    verifying_key: &crate::common::identity::AnyVerifyingKey,
770) -> Result<crate::common::identity::AnySignature, SignatureParseError> {
771    match verifying_key {
772        crate::common::identity::AnyVerifyingKey::K256(_) => {
773            k256::ecdsa::Signature::from_slice(bytes)
774                .map(crate::common::identity::AnySignature::K256)
775                .map_err(|e| SignatureParseError::K256Failed {
776                    cause: format!("{e}"),
777                })
778        }
779        crate::common::identity::AnyVerifyingKey::P256(_) => {
780            p256::ecdsa::Signature::from_slice(bytes)
781                .map(crate::common::identity::AnySignature::P256)
782                .map_err(|e| SignatureParseError::P256Failed {
783                    cause: format!("{e}"),
784                })
785        }
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::commands::test::labeler::identity::IdentityFacts;
793    use crate::common::identity::{
794        AnySignature, AnyVerifyingKey, Did, DidDocument, IdentityError, RawDidDocument,
795        encode_multikey,
796    };
797    use atrium_api::app::bsky::labeler::defs::LabelerPolicies;
798    use atrium_api::com::atproto::label::defs::{Label, LabelData};
799    use atrium_api::types::string::Datetime;
800    use k256::ecdsa::SigningKey as K256SigningKey;
801    use k256::ecdsa::signature::hazmat::PrehashSigner;
802    use std::sync::Arc;
803    use url::Url;
804
805    /// HttpClient stub that panics if called. Used by the local-skip crypto
806    /// tests, which are expected to short-circuit before making any network
807    /// request for PLC history.
808    struct PanicHttpClient;
809
810    #[async_trait::async_trait]
811    impl crate::common::identity::HttpClient for PanicHttpClient {
812        async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> {
813            panic!("PanicHttpClient reached for {url}; crypto stage should have short-circuited");
814        }
815    }
816
817    /// Minimal `IdentityFacts` fixture builder for crypto-stage tests. The
818    /// `labeler_endpoint` argument controls the locality heuristic.
819    fn make_crypto_facts(signing_key: AnyVerifyingKey, labeler_endpoint: Url) -> IdentityFacts {
820        let did = Did("did:web:localhost%3A8080".to_string());
821        let multikey = encode_multikey(&signing_key);
822        let doc_json = format!(
823            r##"{{"id":"{did}","verificationMethod":[{{"id":"{did}#atproto_label","type":"Multikey","controller":"{did}","publicKeyMultibase":"{multikey}"}}],"service":[{{"id":"#atproto_labeler","type":"AtprotoLabeler","serviceEndpoint":"{labeler_endpoint}"}},{{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}}]}}"##,
824            did = did.0,
825        );
826        let doc: DidDocument = serde_json::from_str(&doc_json).expect("test DID doc parses");
827        let raw_did_doc = RawDidDocument {
828            parsed: doc,
829            source_bytes: Arc::<[u8]>::from(doc_json.as_bytes()),
830            source_name: "test DID document".to_string(),
831        };
832        let labeler_policies: LabelerPolicies = serde_json::from_value(serde_json::json!({
833            "labelValues": [],
834        }))
835        .expect("LabelerPolicies deserializes");
836        IdentityFacts {
837            did,
838            raw_did_doc,
839            labeler_endpoint,
840            pds_endpoint: Url::parse("https://pds.example.com").unwrap(),
841            signing_key_id: "did:web:localhost%3A8080#atproto_label".to_string(),
842            signing_key_multikey: multikey,
843            signing_key,
844            labeler_record_bytes: Arc::<[u8]>::from(b"{}" as &[u8]),
845            labeler_policies,
846            reason_types: None,
847            subject_types: None,
848            subject_collections: None,
849        }
850    }
851
852    /// Build a syntactically valid `Label` signed with `signing_key`. The
853    /// signature is over the DRISL-CBOR prehash, matching what a conformant
854    /// labeler produces.
855    fn sign_label_with(signing_key: &K256SigningKey) -> Label {
856        let placeholder: Label = LabelData {
857            cid: None,
858            cts: Datetime::new("2026-01-01T00:00:00.000Z".parse().expect("valid datetime")),
859            exp: None,
860            neg: Some(false),
861            sig: Some(vec![0u8; 64]),
862            src: "did:plc:test123456789abcdefghijklmnop"
863                .parse()
864                .expect("valid did"),
865            uri: "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1".to_string(),
866            val: "spam".to_string(),
867            ver: Some(1),
868        }
869        .into();
870        let canonical =
871            canonicalize_label_for_signing(&placeholder).expect("canonicalize placeholder label");
872        let sig: k256::ecdsa::Signature = signing_key
873            .sign_prehash(&canonical.prehash)
874            .expect("sign prehash");
875
876        let mut signed_data = placeholder.data.clone();
877        signed_data.sig = Some(sig.to_bytes().to_vec());
878        signed_data.into()
879    }
880
881    /// Test that the canonicalizer correctly rejects floats.
882    #[test]
883    fn canonicalize_rejects_nan_float() {
884        // Create a Value tree with a float directly (bypassing the Label schema).
885        let value = Value::Map(vec![(
886            Value::Text("test".to_string()),
887            Value::Float(std::f64::consts::PI),
888        )]);
889
890        let result = validate_value(&value);
891        assert!(matches!(result, Err(CanonicalizeError::FloatRejected)));
892    }
893
894    /// Test that missing sig field is rejected.
895    #[test]
896    fn canonicalize_missing_sig_errors() {
897        // Create a simple label value without a sig field.
898        let mut value = Value::Map(vec![(
899            Value::Text("ver".to_string()),
900            Value::Integer(1.into()),
901        )]);
902
903        let result = extract_and_remove_sig(&mut value);
904        assert!(matches!(result, Err(CanonicalizeError::MissingSigField)));
905    }
906
907    /// Test that a fully-signed label round-trips through canonicalize → sign_prehash →
908    /// verify_prehash using a real deterministic ECDSA keypair. This exercises the full
909    /// cryptographic primitive path that the crypto stage relies on.
910    #[test]
911    fn sign_and_verify_label_roundtrip_k256() {
912        // Deterministic seed so the test is bisect-stable.
913        let seed: [u8; 32] = [7u8; 32];
914        let signing_key = K256SigningKey::from_slice(&seed).expect("valid secret scalar");
915        let verifying_key = AnyVerifyingKey::K256(*signing_key.verifying_key());
916
917        // to obtain the prehash the labeler would sign over.
918        let placeholder: Label = LabelData {
919            cid: None,
920            cts: Datetime::new("2026-01-01T00:00:00.000Z".parse().expect("valid datetime")),
921            exp: None,
922            neg: Some(false),
923            sig: Some(vec![0u8; 64]),
924            src: "did:plc:test123456789abcdefghijklmnop"
925                .parse()
926                .expect("valid did"),
927            uri: "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1".to_string(),
928            val: "spam".to_string(),
929            ver: Some(1),
930        }
931        .into();
932        let canonical =
933            canonicalize_label_for_signing(&placeholder).expect("canonicalize placeholder label");
934
935        let sig: k256::ecdsa::Signature = signing_key
936            .sign_prehash(&canonical.prehash)
937            .expect("sign prehash");
938        let sig_bytes = sig.to_bytes().to_vec();
939        assert_eq!(sig_bytes.len(), 64, "k256 signature must be 64 bytes");
940
941        // The sig field is stripped before hashing so the prehash must be identical
942        // to the prehash computed from the placeholder label.
943        let mut signed_data = placeholder.data.clone();
944        signed_data.sig = Some(sig_bytes.clone());
945        let signed: Label = signed_data.into();
946        let signed_canonical =
947            canonicalize_label_for_signing(&signed).expect("canonicalize signed label");
948        assert_eq!(
949            signed_canonical.prehash, canonical.prehash,
950            "prehash must be invariant over changes to the sig field"
951        );
952        assert_eq!(signed_canonical.signature_bytes, sig_bytes);
953
954        // AnyVerifyingKey::verify_prehash path the crypto stage uses at runtime.
955        let any_sig = AnySignature::K256(
956            k256::ecdsa::Signature::from_slice(&signed_canonical.signature_bytes)
957                .expect("parse signature"),
958        );
959        verifying_key
960            .verify_prehash(&signed_canonical.prehash, &any_sig)
961            .expect("signature must verify against the signing key");
962    }
963
964    /// Extra JSON fields (e.g. server-side database ids) that atrium preserves
965    /// on `Label.extra_data` must NOT leak into the canonical prehash. Labelers
966    /// sign only the spec's own fields, so any extra field leaking into the
967    /// prehash would cause verification to fail against every label served by
968    /// a labeler whose REST response carries metadata sibling keys.
969    #[test]
970    fn canonicalize_ignores_extra_data_fields() {
971        let with_id: Label = serde_json::from_str(
972            r#"{
973                "id": 42,
974                "src": "did:plc:test123456789abcdefghijklmnop",
975                "uri": "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1",
976                "val": "spam",
977                "cts": "2026-01-01T00:00:00.000Z",
978                "neg": false,
979                "ver": 1,
980                "sig": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
981                        0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
982            }"#,
983        )
984        .expect("parse label with extra field");
985
986        let without_id: Label = serde_json::from_str(
987            r#"{
988                "src": "did:plc:test123456789abcdefghijklmnop",
989                "uri": "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1",
990                "val": "spam",
991                "cts": "2026-01-01T00:00:00.000Z",
992                "neg": false,
993                "ver": 1,
994                "sig": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
995                        0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
996            }"#,
997        )
998        .expect("parse label without extra field");
999
1000        let with_canonical =
1001            canonicalize_label_for_signing(&with_id).expect("canonicalize label with id");
1002        let without_canonical =
1003            canonicalize_label_for_signing(&without_id).expect("canonicalize label without id");
1004
1005        assert_eq!(
1006            with_canonical.canonical_bytes, without_canonical.canonical_bytes,
1007            "extra JSON fields must not change the canonical bytes"
1008        );
1009        assert_eq!(
1010            with_canonical.prehash, without_canonical.prehash,
1011            "extra JSON fields must not change the prehash"
1012        );
1013    }
1014
1015    /// Test that sig field with wrong length is rejected.
1016    #[test]
1017    fn canonicalize_sig_wrong_length_errors() {
1018        // Create a label value with a 32-byte sig (should be 64).
1019        let sig_value = Value::Bytes(vec![0u8; 32]);
1020        let mut value = Value::Map(vec![(Value::Text("sig".to_string()), sig_value)]);
1021
1022        let result = extract_and_remove_sig(&mut value);
1023        assert!(matches!(
1024            result,
1025            Err(CanonicalizeError::SigFieldWrongLength { actual: 32 })
1026        ));
1027    }
1028
1029    /// Test that parse_signature rejects invalid bytes without panicking.
1030    #[test]
1031    fn parse_signature_rejects_zero_scalar_without_panic() {
1032        use crate::common::identity::AnyVerifyingKey;
1033        use k256::ecdsa::SigningKey as K256SigningKey;
1034
1035        // Create a k256 verifying key.
1036        let seed: [u8; 32] = [7u8; 32];
1037        let signing_key = K256SigningKey::from_slice(&seed).expect("valid secret scalar");
1038        let verifying_key = AnyVerifyingKey::K256(*signing_key.verifying_key());
1039
1040        // Try to parse an invalid signature (64 zero bytes - invalid scalars).
1041        let invalid_sig_bytes = vec![0u8; 64];
1042        let result = parse_signature(&invalid_sig_bytes, &verifying_key);
1043
1044        // Should return an error, not panic.
1045        assert!(result.is_err());
1046        match result.unwrap_err() {
1047            SignatureParseError::K256Failed { .. } => {
1048                // Expected error.
1049            }
1050            _ => panic!("Expected K256Failed error"),
1051        }
1052    }
1053
1054    /// Golden test against real labeler output: constructs `Label`s from bytes captured
1055    /// from production labelers, canonicalizes them, and verifies the real wire signatures
1056    /// against the labelers' published `#atproto_label` multikeys. This is the load-bearing
1057    /// test for canonicalizer correctness — if the canonicalizer drifts from the spec, real
1058    /// signatures will fail to verify. Generated keys signing their own canonicalizer output
1059    /// cannot catch such drift because the bug is symmetric on sign and verify.
1060    ///
1061    /// Fixtures captured 2026-04-15 from:
1062    /// - moderation.bsky.app (did:plc:ar7c4by46qjdydhdevvrndac)
1063    /// - xblock.aendra.dev (did:plc:newitj5jo3uel7o4mnf3vj2o).
1064    #[test]
1065    fn canonicalizes_real_labeler_output_matches_wire_signature() {
1066        use crate::common::identity::parse_multikey;
1067
1068        struct Fixture {
1069            name: &'static str,
1070            src: &'static str,
1071            uri: &'static str,
1072            cid: &'static str,
1073            val: &'static str,
1074            cts: &'static str,
1075            multikey: &'static str,
1076            sig: [u8; 64],
1077        }
1078
1079        let fixtures = [
1080            Fixture {
1081                name: "moderation.bsky.app",
1082                src: "did:plc:ar7c4by46qjdydhdevvrndac",
1083                uri: "at://did:plc:gzdjlsa34b4jpbvegk4dngvb/app.bsky.feed.post/3m5p2kcpjek2t",
1084                cid: "bafyreihmigssl6hpegb3sfou5vemydbo63it5a253udvdoiae5cgfbc3jq",
1085                val: "sexual",
1086                cts: "2025-11-15T20:40:44.774Z",
1087                multikey: "zQ3shmV1BNcX17coaDbfen6zArEad6SCLT3jVWCbC6Y9iinTa",
1088                sig: [
1089                    0x18, 0xb9, 0xe5, 0xc2, 0x36, 0x87, 0x7e, 0x31, 0x17, 0x93, 0xc1, 0xe7, 0xbb,
1090                    0x82, 0xab, 0x78, 0x0d, 0x12, 0x7d, 0xb0, 0xf3, 0x80, 0x4b, 0x18, 0x6f, 0x1e,
1091                    0xeb, 0x77, 0xb8, 0xc7, 0xbd, 0x99, 0x30, 0x0b, 0x92, 0x85, 0xf7, 0xff, 0x3f,
1092                    0xa9, 0x8b, 0x43, 0xae, 0x1f, 0x1c, 0xf5, 0x22, 0x31, 0x9c, 0x70, 0x1e, 0x3e,
1093                    0x87, 0x69, 0xf6, 0x6e, 0x8e, 0x3f, 0x9c, 0x9c, 0x93, 0x18, 0x42, 0xf6,
1094                ],
1095            },
1096            Fixture {
1097                name: "xblock.aendra.dev",
1098                src: "did:plc:newitj5jo3uel7o4mnf3vj2o",
1099                uri: "at://did:plc:yioyxg6ym5gtda5yprh2p4c7/app.bsky.feed.post/3ld5mvbxqtk2p",
1100                cid: "bafyreiafpv7pn7z35dqcv3cbp44sw2efdakhnhxanibkm2q2jyo7u27ubq",
1101                val: "twitter-screenshot",
1102                cts: "2024-12-13T01:26:06.992Z",
1103                multikey: "zQ3shht8JUZuf87GTWQzmZKF1L61PEppz1aGjj7NrpNVmWz8H",
1104                sig: [
1105                    0x68, 0x21, 0x42, 0xb6, 0x7e, 0x95, 0x73, 0x9a, 0x18, 0x95, 0x3e, 0x86, 0x6e,
1106                    0x24, 0xc7, 0x8a, 0x33, 0x6f, 0xfd, 0x40, 0x25, 0xf7, 0xcd, 0xcc, 0x1b, 0x2e,
1107                    0x3d, 0x40, 0xef, 0x5b, 0xdd, 0xa7, 0x77, 0x31, 0x38, 0x9d, 0x54, 0x12, 0x52,
1108                    0xae, 0xdd, 0x18, 0x98, 0x85, 0xf5, 0xcc, 0xe6, 0x63, 0x3c, 0x6f, 0x21, 0xaf,
1109                    0xc8, 0x41, 0xa4, 0xd0, 0x6f, 0x7f, 0xf8, 0x0d, 0xb3, 0x8d, 0x08, 0x8d,
1110                ],
1111            },
1112        ];
1113
1114        for fixture in &fixtures {
1115            let label: Label = LabelData {
1116                cid: Some(fixture.cid.parse().expect("valid cid")),
1117                cts: fixture.cts.parse().expect("valid datetime"),
1118                exp: None,
1119                neg: None,
1120                sig: Some(fixture.sig.to_vec()),
1121                src: fixture.src.parse().expect("valid did"),
1122                uri: fixture.uri.to_string(),
1123                val: fixture.val.to_string(),
1124                ver: Some(1),
1125            }
1126            .into();
1127
1128            let canonical = canonicalize_label_for_signing(&label)
1129                .unwrap_or_else(|e| panic!("{}: canonicalize: {e}", fixture.name));
1130
1131            assert_eq!(
1132                canonical.signature_bytes,
1133                fixture.sig.to_vec(),
1134                "{}: signature_bytes must round-trip through canonicalizer",
1135                fixture.name,
1136            );
1137
1138            let parsed = parse_multikey(fixture.multikey)
1139                .unwrap_or_else(|e| panic!("{}: parse_multikey: {e}", fixture.name));
1140            assert!(
1141                matches!(parsed.verifying_key, AnyVerifyingKey::K256(_)),
1142                "{}: expected secp256k1 multikey",
1143                fixture.name,
1144            );
1145
1146            let any_sig = AnySignature::K256(
1147                k256::ecdsa::Signature::from_slice(&fixture.sig)
1148                    .unwrap_or_else(|e| panic!("{}: parse signature: {e}", fixture.name)),
1149            );
1150            parsed
1151                .verifying_key
1152                .verify_prehash(&canonical.prehash, &any_sig)
1153                .unwrap_or_else(|e| {
1154                    panic!(
1155                        "{}: real labeler signature must verify against canonicalizer output: {e}",
1156                        fixture.name
1157                    )
1158                });
1159        }
1160    }
1161
1162    /// Local labeler with a mismatched signing key: the stage should skip the
1163    /// rollup rather than flagging a SpecViolation, because the developer is
1164    /// testing with a test-environment key and not the production key
1165    /// published in the DID document.
1166    #[tokio::test]
1167    async fn local_labeler_skips_rollup_when_signing_key_mismatches() {
1168        let published_seed: [u8; 32] = [1u8; 32];
1169        let local_seed: [u8; 32] = [2u8; 32];
1170
1171        let published = K256SigningKey::from_slice(&published_seed).expect("valid seed");
1172        let local = K256SigningKey::from_slice(&local_seed).expect("valid seed");
1173
1174        let label = sign_label_with(&local);
1175        let facts = make_crypto_facts(
1176            AnyVerifyingKey::K256(*published.verifying_key()),
1177            Url::parse("http://localhost:8080").unwrap(),
1178        );
1179
1180        let output = run(&facts, &[label], &PanicHttpClient).await;
1181
1182        // Exactly one rollup row, with status Skipped.
1183        assert_eq!(output.results.len(), 1, "expected only the rollup row");
1184        let rollup = &output.results[0];
1185        assert_eq!(rollup.id, "crypto::rollup");
1186        assert_eq!(rollup.status, CheckStatus::Skipped);
1187        let reason = rollup
1188            .skipped_reason
1189            .as_deref()
1190            .expect("skip reason present");
1191        assert!(
1192            reason.contains("local labeler"),
1193            "skip reason should mention local labeler: {reason}"
1194        );
1195        assert!(
1196            output.facts.is_none(),
1197            "facts should be None when the rollup is skipped"
1198        );
1199    }
1200
1201    /// Local labeler whose labels ARE signed with the published key: the
1202    /// stage should still Pass, not Skip. The local-labeler relaxation only
1203    /// fires when verification actually fails.
1204    #[tokio::test]
1205    async fn local_labeler_passes_when_signing_key_matches() {
1206        let seed: [u8; 32] = [3u8; 32];
1207        let signing = K256SigningKey::from_slice(&seed).expect("valid seed");
1208        let label = sign_label_with(&signing);
1209
1210        let facts = make_crypto_facts(
1211            AnyVerifyingKey::K256(*signing.verifying_key()),
1212            Url::parse("http://127.0.0.1:5000").unwrap(),
1213        );
1214
1215        let output = run(&facts, &[label], &PanicHttpClient).await;
1216
1217        let rollup = output
1218            .results
1219            .iter()
1220            .find(|r| r.id == "crypto::rollup")
1221            .expect("rollup row present");
1222        assert_eq!(
1223            rollup.status,
1224            CheckStatus::Pass,
1225            "matching local key should still Pass"
1226        );
1227        let facts = output.facts.expect("facts populated on pass");
1228        assert_eq!(facts.verified_with_current, 1);
1229    }
1230}