1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Check {
21 Rollup,
23 CanonicalizationFailed,
25 PlcHistoryFetch,
27 RotatedKeysUsed,
29 LabelVerificationFailed,
31 SignatureBytesUnparseable,
33}
34
35impl Check {
36 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
128pub struct CanonicalLabel {
130 pub prehash: [u8; 32],
132 pub canonical_bytes: Vec<u8>,
134 pub signature_bytes: Vec<u8>,
136}
137
138#[derive(Debug, Clone, Error)]
140pub enum CanonicalizeError {
141 #[error("Invalid label CBOR: {cause}")]
143 InvalidLabelCbor {
144 cause: String,
146 },
147 #[error("Floating-point values are not allowed in labels")]
149 FloatRejected,
150 #[error("Indefinite-length items are not allowed in labels")]
152 IndefiniteLengthRejected,
153 #[error("Label is missing a 'sig' field")]
155 MissingSigField,
156 #[error("The 'sig' field must be a CBOR byte string")]
158 SigFieldWrongType,
159 #[error("The 'sig' field must be 64 bytes (r || s concatenated), got {actual}")]
161 SigFieldWrongLength {
162 actual: usize,
164 },
165}
166
167#[derive(Debug, Clone, Error)]
169pub enum SignatureParseError {
170 #[error("Failed to parse signature as secp256k1: {cause}")]
172 K256Failed {
173 cause: String,
175 },
176 #[error("Failed to parse signature as NIST P-256: {cause}")]
178 P256Failed {
179 cause: String,
181 },
182}
183
184#[derive(Debug, Clone, Error, miette::Diagnostic)]
186pub enum CryptoCheckError {
187 #[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 current_key_id: String,
195 },
196 #[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 tried_keys: Vec<String>,
205 },
206 #[error("failed to fetch PLC audit log for {did}: {reason}")]
208 #[diagnostic(code = "labeler::crypto::plc_history_fetch_network_error")]
209 PlcHistoryFetchNetworkError {
210 did: String,
212 reason: String,
214 },
215 #[error("failed to canonicalize label {label_uri} for signing")]
217 #[diagnostic(code = "labeler::crypto::label_canonicalization_failed")]
218 LabelCanonicalizationFailed {
219 label_uri: String,
221 #[source]
223 source: CanonicalizeError,
224 },
225 #[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 label_uri: String,
233 curve: &'static str,
235 },
236 #[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 current_key_id: String,
244 label_uri: String,
246 },
247}
248
249pub fn canonicalize_label_for_signing(label: &Label) -> Result<CanonicalLabel, CanonicalizeError> {
262 let mut value: Value = ciborium::value::Value::serialized(&label.data).map_err(|e| {
270 CanonicalizeError::InvalidLabelCbor {
271 cause: format!("{e}"),
272 }
273 })?;
274
275 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
298fn 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
323fn extract_and_remove_sig(value: &mut Value) -> Result<Vec<u8>, CanonicalizeError> {
329 match value {
330 Value::Map(map) => {
331 let sig_key = Value::Text("sig".to_string());
333 let mut sig_value = None;
334
335 let sig_index = map.iter().position(|(k, _)| k == &sig_key);
337
338 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 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
363fn 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 for (_, v) in map.iter_mut() {
378 canonicalize_tree(v)?;
379 }
380
381 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 *map = entries;
391
392 Ok(())
393 }
394 Value::Tag(_, val) => canonicalize_tree(val),
395 _ => Ok(()),
396 }
397}
398
399fn 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#[derive(Debug, Clone)]
410pub struct CryptoFacts {
411 pub verified_with_current: usize,
413 pub verified_with_historic: Vec<HistoricKeyHit>,
415 pub unverified: usize,
417}
418
419#[derive(Debug, Clone)]
421pub struct HistoricKeyHit {
422 pub key_id: String,
424 pub label_count: usize,
426}
427
428#[derive(Debug)]
430pub struct CryptoStageOutput {
431 pub facts: Option<CryptoFacts>,
433 pub results: Vec<CheckResult>,
435}
436
437#[derive(Debug, Clone)]
439struct FailedLabel {
440 label: Label,
442 canonicalization_error: Option<CanonicalizeError>,
444}
445
446pub async fn run(
456 identity: &crate::commands::test::labeler::identity::IdentityFacts,
457 labels: &[Label],
458 http: &dyn crate::common::identity::HttpClient,
459) -> CryptoStageOutput {
460 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 for label in labels {
474 match canonicalize_label_for_signing(label) {
475 Err(err) => {
476 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 match parse_signature(&canonical.signature_bytes, &identity.signing_key) {
490 Err(_) => {
491 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 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 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 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 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 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 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; }
605
606 match crate::common::identity::parse_multikey(&historic_key.key_id) {
608 Err(_) => {
609 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 tried_historic_key_ids.push(historic_key.key_id.clone());
620 let mut newly_verified = Vec::new();
622 for (i, failed) in failed_against_current.iter().enumerate() {
623 if failed.canonicalization_error.is_some() {
625 continue;
626 }
627
628 if let Ok(canonical) =
630 canonicalize_label_for_signing(&failed.label)
631 {
632 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 for i in newly_verified.iter().rev() {
653 failed_against_current.remove(*i);
654 }
655 }
656 }
657 }
658
659 if failed_against_current.is_empty() {
661 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 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 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
737fn 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]
775 fn canonicalize_rejects_nan_float() {
776 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]
788 fn canonicalize_missing_sig_errors() {
789 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]
803 fn sign_and_verify_label_roundtrip_k256() {
804 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 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 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 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 #[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]
909 fn canonicalize_sig_wrong_length_errors() {
910 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]
923 fn parse_signature_rejects_zero_scalar_without_panic() {
924 use crate::common::identity::AnyVerifyingKey;
925 use k256::ecdsa::SigningKey as K256SigningKey;
926
927 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 let invalid_sig_bytes = vec![0u8; 64];
934 let result = parse_signature(&invalid_sig_bytes, &verifying_key);
935
936 assert!(result.is_err());
938 match result.unwrap_err() {
939 SignatureParseError::K256Failed { .. } => {
940 }
942 _ => panic!("Expected K256Failed error"),
943 }
944 }
945
946 #[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}