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