CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(crypto): skip rollup for local labelers whose signing key differs from the DID document

When a developer points the tool at a local copy of a production labeler
(e.g. `test labeler http://localhost:8080 --did did:plc:<prod>`), the
local instance almost never has the production signing key. The crypto
stage then failed every label against the DID-document key and emitted
`SpecViolation`, masking the rest of the run.

This commit changes `crypto::run` so that per-label violations are
buffered rather than pushed directly, and the final rollup decision
considers locality:

- If every label verifies against the current key: `Pass` (unchanged).
- Otherwise, if `is_local_labeler_hostname(&identity.labeler_endpoint)`:
emit `crypto::rollup` `Skipped` with a reason naming the production
key's absence, and discard the buffered violations. PLC history
fallback is also skipped — a key swap in a test environment is
expected and not a rotation event.
- Otherwise: commit the buffered violations and fall through to the
existing PLC-history / did:web branches.

Adds two unit tests: one that drives a mismatched-key local labeler
and asserts `Skipped`, and one that confirms a matching local key
still produces `Pass`. The `PanicHttpClient` stub asserts that the
skip path short-circuits before any network request.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

authored by

Jack Grigg
Claude Opus 4.7
and committed by
Tangled
be6d6d8d d0188cc6

+198 -12
+9
src/commands/test/labeler/CLAUDE.md
··· 104 104 history, so a failure there is a hard `SpecViolation`. Verification that 105 105 only succeeds against a historic key still passes the stage but emits an 106 106 `Advisory`. 107 + - **Crypto stage skips local labelers with mismatched signing keys**: when 108 + the labeler endpoint is local (per `is_local_labeler_hostname`) and at 109 + least one label fails verification against the DID-document signing 110 + key, `crypto::rollup` is `Skipped` rather than `SpecViolation`. The 111 + rationale is that developers testing a local copy of a labeler will 112 + typically not have the production signing key present, so a mismatch is 113 + expected; PLC history fallback is also skipped in this case. If the 114 + local labeler's key happens to match the published key, verification 115 + proceeds normally (`Pass`). 107 116 - **Identity stage downgrades local endpoint mismatches to Advisory**: 108 117 when the user supplies `--target http://<local>:<port> --did <prod-did>` 109 118 and the DID document advertises a different (production) endpoint,
+189 -12
src/commands/test/labeler/crypto.rs
··· 14 14 use thiserror::Error; 15 15 16 16 use crate::commands::test::labeler::report::{CheckResult, CheckStatus, Stage}; 17 + use crate::common::identity::is_local_labeler_hostname; 17 18 18 19 /// Checks emitted by the crypto stage. 19 20 #[derive(Debug, Clone, Copy, PartialEq, Eq)] ··· 447 448 /// 448 449 /// Logic: 449 450 /// 1. Empty labels → emit Skipped. 450 - /// 2. For each label: canonicalize → on error emit per-label SpecViolation. 451 + /// 2. For each label: canonicalize → on error buffer a per-label SpecViolation. 451 452 /// 3. Verify against `identity.signing_key` → on success increment `verified_with_current`. 452 453 /// 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. 454 + /// 5. Otherwise, if the labeler endpoint is local (loopback, RFC 1918, .local): 455 + /// emit `crypto::rollup` Skipped and drop the buffered per-label violations. 456 + /// Rationale: a developer testing a local copy of a labeler is unlikely to 457 + /// have the production signing key present, so label-signature failures are 458 + /// expected and not a conformance issue for this run. 459 + /// 6. Else if `did:plc`: fetch PLC audit log and retry against historic keys. 460 + /// 7. Else (`did:web`): emit `crypto::rollup` SpecViolation with no rotation history. 455 461 pub async fn run( 456 462 identity: &crate::commands::test::labeler::identity::IdentityFacts, 457 463 labels: &[Label], ··· 466 472 } 467 473 468 474 let mut results = Vec::new(); 475 + let mut per_label_violations = Vec::new(); 469 476 let mut verified_with_current = 0usize; 470 477 let mut failed_against_current: Vec<FailedLabel> = Vec::new(); 471 478 472 - // Verify all labels against the current key. 479 + // Verify all labels against the current key. Per-label SpecViolations are 480 + // buffered so we can drop them cleanly when a local labeler triggers the 481 + // "production key not present" skip path. 473 482 for label in labels { 474 483 match canonicalize_label_for_signing(label) { 475 484 Err(err) => { 476 - // Canonicalization failed — emit a per-label SpecViolation. 477 485 let diagnostic = CryptoCheckError::LabelCanonicalizationFailed { 478 486 label_uri: label.uri.clone(), 479 487 source: err.clone(), 480 488 }; 481 - results.push(Check::CanonicalizationFailed.spec_violation(Box::new(diagnostic))); 489 + per_label_violations 490 + .push(Check::CanonicalizationFailed.spec_violation(Box::new(diagnostic))); 482 491 failed_against_current.push(FailedLabel { 483 492 label: label.clone(), 484 493 canonicalization_error: Some(err), 485 494 }); 486 495 } 487 496 Ok(canonical) => { 488 - // Try to parse the signature. 489 497 match parse_signature(&canonical.signature_bytes, &identity.signing_key) { 490 498 Err(_) => { 491 - // Signature parsing failed. 492 499 let diagnostic = CryptoCheckError::SignatureBytesUnparseable { 493 500 label_uri: label.uri.clone(), 494 501 curve: identity.signing_key.curve_name(), 495 502 }; 496 - results.push( 503 + per_label_violations.push( 497 504 Check::SignatureBytesUnparseable.spec_violation(Box::new(diagnostic)), 498 505 ); 499 506 failed_against_current.push(FailedLabel { ··· 502 509 }); 503 510 } 504 511 Ok(signature) => { 505 - // Try to verify against the current key. 506 512 match identity 507 513 .signing_key 508 514 .verify_prehash(&canonical.prehash, &signature) ··· 548 554 }; 549 555 } 550 556 551 - // Some labels failed current-key verification; check DID type for history fallback. 557 + // Some labels failed to verify. If the labeler endpoint is local, assume 558 + // the developer is testing with a signing key different from the one 559 + // published in the DID document, and skip the rest of the stage rather 560 + // than flagging the mismatch as a spec violation. 561 + if is_local_labeler_hostname(&identity.labeler_endpoint) { 562 + results.push(Check::Rollup.skip( 563 + "local labeler signing key does not match the published DID document \ 564 + (production signing key not available in this test environment)", 565 + )); 566 + return CryptoStageOutput { 567 + facts: None, 568 + results, 569 + }; 570 + } 571 + 572 + // Non-local: commit the buffered per-label violations before falling 573 + // through to the PLC-history / did:web branches below. 574 + results.extend(per_label_violations); 575 + 576 + // Check DID type for history fallback. 552 577 match identity.did.method() { 553 578 crate::common::identity::DidMethod::Plc => { 554 579 tracing::debug!( ··· 764 789 #[cfg(test)] 765 790 mod tests { 766 791 use super::*; 767 - use crate::common::identity::{AnySignature, AnyVerifyingKey}; 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; 768 798 use atrium_api::com::atproto::label::defs::{Label, LabelData}; 769 799 use atrium_api::types::string::Datetime; 770 800 use k256::ecdsa::SigningKey as K256SigningKey; 771 801 use k256::ecdsa::signature::hazmat::PrehashSigner; 802 + use std::sync::Arc; 803 + use url::Url; 804 + 805 + /// HttpClient stub that panics if called. Used by the local-skip crypto 806 + /// tests, which are expected to short-circuit before making any network 807 + /// request for PLC history. 808 + struct PanicHttpClient; 809 + 810 + #[async_trait::async_trait] 811 + impl crate::common::identity::HttpClient for PanicHttpClient { 812 + async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> { 813 + panic!("PanicHttpClient reached for {url}; crypto stage should have short-circuited"); 814 + } 815 + } 816 + 817 + /// Minimal `IdentityFacts` fixture builder for crypto-stage tests. The 818 + /// `labeler_endpoint` argument controls the locality heuristic. 819 + fn make_crypto_facts(signing_key: AnyVerifyingKey, labeler_endpoint: Url) -> IdentityFacts { 820 + let did = Did("did:web:localhost%3A8080".to_string()); 821 + let multikey = encode_multikey(&signing_key); 822 + let doc_json = format!( 823 + r##"{{"id":"{did}","verificationMethod":[{{"id":"{did}#atproto_label","type":"Multikey","controller":"{did}","publicKeyMultibase":"{multikey}"}}],"service":[{{"id":"#atproto_labeler","type":"AtprotoLabeler","serviceEndpoint":"{labeler_endpoint}"}},{{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}}]}}"##, 824 + did = did.0, 825 + ); 826 + let doc: DidDocument = serde_json::from_str(&doc_json).expect("test DID doc parses"); 827 + let raw_did_doc = RawDidDocument { 828 + parsed: doc, 829 + source_bytes: Arc::<[u8]>::from(doc_json.as_bytes()), 830 + source_name: "test DID document".to_string(), 831 + }; 832 + let labeler_policies: LabelerPolicies = serde_json::from_value(serde_json::json!({ 833 + "labelValues": [], 834 + })) 835 + .expect("LabelerPolicies deserializes"); 836 + IdentityFacts { 837 + did, 838 + raw_did_doc, 839 + labeler_endpoint, 840 + pds_endpoint: Url::parse("https://pds.example.com").unwrap(), 841 + signing_key_id: "did:web:localhost%3A8080#atproto_label".to_string(), 842 + signing_key_multikey: multikey, 843 + signing_key, 844 + labeler_record_bytes: Arc::<[u8]>::from(b"{}" as &[u8]), 845 + labeler_policies, 846 + reason_types: None, 847 + subject_types: None, 848 + subject_collections: None, 849 + } 850 + } 851 + 852 + /// Build a syntactically valid `Label` signed with `signing_key`. The 853 + /// signature is over the DRISL-CBOR prehash, matching what a conformant 854 + /// labeler produces. 855 + fn sign_label_with(signing_key: &K256SigningKey) -> Label { 856 + let placeholder: Label = LabelData { 857 + cid: None, 858 + cts: Datetime::new("2026-01-01T00:00:00.000Z".parse().expect("valid datetime")), 859 + exp: None, 860 + neg: Some(false), 861 + sig: Some(vec![0u8; 64]), 862 + src: "did:plc:test123456789abcdefghijklmnop" 863 + .parse() 864 + .expect("valid did"), 865 + uri: "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1".to_string(), 866 + val: "spam".to_string(), 867 + ver: Some(1), 868 + } 869 + .into(); 870 + let canonical = 871 + canonicalize_label_for_signing(&placeholder).expect("canonicalize placeholder label"); 872 + let sig: k256::ecdsa::Signature = signing_key 873 + .sign_prehash(&canonical.prehash) 874 + .expect("sign prehash"); 875 + 876 + let mut signed_data = placeholder.data.clone(); 877 + signed_data.sig = Some(sig.to_bytes().to_vec()); 878 + signed_data.into() 879 + } 772 880 773 881 /// Test that the canonicalizer correctly rejects floats. 774 882 #[test] ··· 1049 1157 ) 1050 1158 }); 1051 1159 } 1160 + } 1161 + 1162 + /// Local labeler with a mismatched signing key: the stage should skip the 1163 + /// rollup rather than flagging a SpecViolation, because the developer is 1164 + /// testing with a test-environment key and not the production key 1165 + /// published in the DID document. 1166 + #[tokio::test] 1167 + async fn local_labeler_skips_rollup_when_signing_key_mismatches() { 1168 + let published_seed: [u8; 32] = [1u8; 32]; 1169 + let local_seed: [u8; 32] = [2u8; 32]; 1170 + 1171 + let published = K256SigningKey::from_slice(&published_seed).expect("valid seed"); 1172 + let local = K256SigningKey::from_slice(&local_seed).expect("valid seed"); 1173 + 1174 + let label = sign_label_with(&local); 1175 + let facts = make_crypto_facts( 1176 + AnyVerifyingKey::K256(*published.verifying_key()), 1177 + Url::parse("http://localhost:8080").unwrap(), 1178 + ); 1179 + 1180 + let output = run(&facts, &[label], &PanicHttpClient).await; 1181 + 1182 + // Exactly one rollup row, with status Skipped. 1183 + assert_eq!(output.results.len(), 1, "expected only the rollup row"); 1184 + let rollup = &output.results[0]; 1185 + assert_eq!(rollup.id, "crypto::rollup"); 1186 + assert_eq!(rollup.status, CheckStatus::Skipped); 1187 + let reason = rollup 1188 + .skipped_reason 1189 + .as_deref() 1190 + .expect("skip reason present"); 1191 + assert!( 1192 + reason.contains("local labeler"), 1193 + "skip reason should mention local labeler: {reason}" 1194 + ); 1195 + assert!( 1196 + output.facts.is_none(), 1197 + "facts should be None when the rollup is skipped" 1198 + ); 1199 + } 1200 + 1201 + /// Local labeler whose labels ARE signed with the published key: the 1202 + /// stage should still Pass, not Skip. The local-labeler relaxation only 1203 + /// fires when verification actually fails. 1204 + #[tokio::test] 1205 + async fn local_labeler_passes_when_signing_key_matches() { 1206 + let seed: [u8; 32] = [3u8; 32]; 1207 + let signing = K256SigningKey::from_slice(&seed).expect("valid seed"); 1208 + let label = sign_label_with(&signing); 1209 + 1210 + let facts = make_crypto_facts( 1211 + AnyVerifyingKey::K256(*signing.verifying_key()), 1212 + Url::parse("http://127.0.0.1:5000").unwrap(), 1213 + ); 1214 + 1215 + let output = run(&facts, &[label], &PanicHttpClient).await; 1216 + 1217 + let rollup = output 1218 + .results 1219 + .iter() 1220 + .find(|r| r.id == "crypto::rollup") 1221 + .expect("rollup row present"); 1222 + assert_eq!( 1223 + rollup.status, 1224 + CheckStatus::Pass, 1225 + "matching local key should still Pass" 1226 + ); 1227 + let facts = output.facts.expect("facts populated on pass"); 1228 + assert_eq!(facts.verified_with_current, 1); 1052 1229 } 1053 1230 }