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};
17use crate::common::identity::is_local_labeler_hostname;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Check {
22 Rollup,
24 CanonicalizationFailed,
26 PlcHistoryFetch,
28 RotatedKeysUsed,
30 LabelVerificationFailed,
32 SignatureBytesUnparseable,
34}
35
36impl Check {
37 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
129pub struct CanonicalLabel {
131 pub prehash: [u8; 32],
133 pub canonical_bytes: Vec<u8>,
135 pub signature_bytes: Vec<u8>,
137}
138
139#[derive(Debug, Clone, Error)]
141pub enum CanonicalizeError {
142 #[error("Invalid label CBOR: {cause}")]
144 InvalidLabelCbor {
145 cause: String,
147 },
148 #[error("Floating-point values are not allowed in labels")]
150 FloatRejected,
151 #[error("Indefinite-length items are not allowed in labels")]
153 IndefiniteLengthRejected,
154 #[error("Label is missing a 'sig' field")]
156 MissingSigField,
157 #[error("The 'sig' field must be a CBOR byte string")]
159 SigFieldWrongType,
160 #[error("The 'sig' field must be 64 bytes (r || s concatenated), got {actual}")]
162 SigFieldWrongLength {
163 actual: usize,
165 },
166}
167
168#[derive(Debug, Clone, Error)]
170pub enum SignatureParseError {
171 #[error("Failed to parse signature as secp256k1: {cause}")]
173 K256Failed {
174 cause: String,
176 },
177 #[error("Failed to parse signature as NIST P-256: {cause}")]
179 P256Failed {
180 cause: String,
182 },
183}
184
185#[derive(Debug, Clone, Error, miette::Diagnostic)]
187pub enum CryptoCheckError {
188 #[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 current_key_id: String,
196 },
197 #[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 tried_keys: Vec<String>,
206 },
207 #[error("failed to fetch PLC audit log for {did}: {reason}")]
209 #[diagnostic(code = "labeler::crypto::plc_history_fetch_network_error")]
210 PlcHistoryFetchNetworkError {
211 did: String,
213 reason: String,
215 },
216 #[error("failed to canonicalize label {label_uri} for signing")]
218 #[diagnostic(code = "labeler::crypto::label_canonicalization_failed")]
219 LabelCanonicalizationFailed {
220 label_uri: String,
222 #[source]
224 source: CanonicalizeError,
225 },
226 #[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 label_uri: String,
234 curve: &'static str,
236 },
237 #[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 current_key_id: String,
245 label_uri: String,
247 },
248}
249
250pub fn canonicalize_label_for_signing(label: &Label) -> Result<CanonicalLabel, CanonicalizeError> {
263 let mut value: Value = ciborium::value::Value::serialized(&label.data).map_err(|e| {
271 CanonicalizeError::InvalidLabelCbor {
272 cause: format!("{e}"),
273 }
274 })?;
275
276 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
299fn 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
324fn extract_and_remove_sig(value: &mut Value) -> Result<Vec<u8>, CanonicalizeError> {
330 match value {
331 Value::Map(map) => {
332 let sig_key = Value::Text("sig".to_string());
334 let mut sig_value = None;
335
336 let sig_index = map.iter().position(|(k, _)| k == &sig_key);
338
339 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 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
364fn 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 for (_, v) in map.iter_mut() {
379 canonicalize_tree(v)?;
380 }
381
382 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 *map = entries;
392
393 Ok(())
394 }
395 Value::Tag(_, val) => canonicalize_tree(val),
396 _ => Ok(()),
397 }
398}
399
400fn 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#[derive(Debug, Clone)]
411pub struct CryptoFacts {
412 pub verified_with_current: usize,
414 pub verified_with_historic: Vec<HistoricKeyHit>,
416 pub unverified: usize,
418}
419
420#[derive(Debug, Clone)]
422pub struct HistoricKeyHit {
423 pub key_id: String,
425 pub label_count: usize,
427}
428
429#[derive(Debug)]
431pub struct CryptoStageOutput {
432 pub facts: Option<CryptoFacts>,
434 pub results: Vec<CheckResult>,
436}
437
438#[derive(Debug, Clone)]
440struct FailedLabel {
441 label: Label,
443 canonicalization_error: Option<CanonicalizeError>,
445}
446
447pub async fn run(
462 identity: &crate::commands::test::labeler::identity::IdentityFacts,
463 labels: &[Label],
464 http: &dyn crate::common::identity::HttpClient,
465) -> CryptoStageOutput {
466 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 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 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 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 results.extend(per_label_violations);
575
576 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 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 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 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; }
630
631 match crate::common::identity::parse_multikey(&historic_key.key_id) {
633 Err(_) => {
634 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 tried_historic_key_ids.push(historic_key.key_id.clone());
645 let mut newly_verified = Vec::new();
647 for (i, failed) in failed_against_current.iter().enumerate() {
648 if failed.canonicalization_error.is_some() {
650 continue;
651 }
652
653 if let Ok(canonical) =
655 canonicalize_label_for_signing(&failed.label)
656 {
657 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 for i in newly_verified.iter().rev() {
678 failed_against_current.remove(*i);
679 }
680 }
681 }
682 }
683
684 if failed_against_current.is_empty() {
686 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 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 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
762fn 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 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 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 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]
883 fn canonicalize_rejects_nan_float() {
884 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]
896 fn canonicalize_missing_sig_errors() {
897 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]
911 fn sign_and_verify_label_roundtrip_k256() {
912 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 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 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 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 #[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]
1017 fn canonicalize_sig_wrong_length_errors() {
1018 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]
1031 fn parse_signature_rejects_zero_scalar_without_panic() {
1032 use crate::common::identity::AnyVerifyingKey;
1033 use k256::ecdsa::SigningKey as K256SigningKey;
1034
1035 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 let invalid_sig_bytes = vec![0u8; 64];
1042 let result = parse_signature(&invalid_sig_bytes, &verifying_key);
1043
1044 assert!(result.is_err());
1046 match result.unwrap_err() {
1047 SignatureParseError::K256Failed { .. } => {
1048 }
1050 _ => panic!("Expected K256Failed error"),
1051 }
1052 }
1053
1054 #[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 #[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 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 #[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}