use std::borrow::Cow;
use std::collections::BTreeMap;
use atrium_api::com::atproto::label::defs::Label;
use ciborium::value::Value;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::commands::test::labeler::report::{CheckResult, CheckStatus, Stage};
use crate::common::identity::is_local_labeler_hostname;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Check {
Rollup,
CanonicalizationFailed,
PlcHistoryFetch,
RotatedKeysUsed,
LabelVerificationFailed,
SignatureBytesUnparseable,
}
impl Check {
pub fn id(self) -> &'static str {
match self {
Check::Rollup => "crypto::rollup",
Check::CanonicalizationFailed => "crypto::canonicalization_failed",
Check::PlcHistoryFetch => "crypto::plc_history_fetch",
Check::RotatedKeysUsed => "crypto::rotated_keys_used",
Check::LabelVerificationFailed => "crypto::label_verification_failed",
Check::SignatureBytesUnparseable => "crypto::signature_bytes_unparseable",
}
}
pub fn pass(self) -> CheckResult {
CheckResult {
id: self.id(),
stage: Stage::Crypto,
status: CheckStatus::Pass,
summary: Cow::Borrowed(match self {
Check::Rollup => "All labels verified with current or historic keys",
_ => "crypto check passed",
}),
diagnostic: None,
skipped_reason: None,
}
}
pub fn spec_violation(
self,
diagnostic: Box<dyn miette::Diagnostic + Send + Sync>,
) -> CheckResult {
CheckResult {
id: self.id(),
stage: Stage::Crypto,
status: CheckStatus::SpecViolation,
summary: Cow::Borrowed(match self {
Check::Rollup => "Labels failed verification",
Check::CanonicalizationFailed => "Label canonicalization failed",
Check::LabelVerificationFailed => "Label signature verification failed",
Check::SignatureBytesUnparseable => "Signature bytes are unparseable",
_ => "crypto check failed",
}),
diagnostic: Some(diagnostic),
skipped_reason: None,
}
}
pub fn network_error(
self,
diagnostic: Box<dyn miette::Diagnostic + Send + Sync>,
) -> CheckResult {
CheckResult {
id: self.id(),
stage: Stage::Crypto,
status: CheckStatus::NetworkError,
summary: Cow::Borrowed(match self {
Check::PlcHistoryFetch => "PLC history fetch failed",
_ => "crypto network error",
}),
diagnostic: Some(diagnostic),
skipped_reason: None,
}
}
pub fn advisory(self) -> CheckResult {
CheckResult {
id: self.id(),
stage: Stage::Crypto,
status: CheckStatus::Advisory,
summary: Cow::Borrowed(match self {
Check::RotatedKeysUsed => "Labels signed by rotated-out key",
_ => "crypto advisory",
}),
diagnostic: None,
skipped_reason: None,
}
}
pub fn skip(self, reason: impl Into<Cow<'static, str>>) -> CheckResult {
CheckResult {
id: self.id(),
stage: Stage::Crypto,
status: CheckStatus::Skipped,
summary: Cow::Borrowed(match self {
Check::Rollup => "Crypto stage (no labels to verify)",
_ => "crypto check skipped",
}),
diagnostic: None,
skipped_reason: Some(reason.into()),
}
}
}
pub struct CanonicalLabel {
pub prehash: [u8; 32],
pub canonical_bytes: Vec<u8>,
pub signature_bytes: Vec<u8>,
}
#[derive(Debug, Clone, Error)]
pub enum CanonicalizeError {
#[error("Invalid label CBOR: {cause}")]
InvalidLabelCbor {
cause: String,
},
#[error("Floating-point values are not allowed in labels")]
FloatRejected,
#[error("Indefinite-length items are not allowed in labels")]
IndefiniteLengthRejected,
#[error("Label is missing a 'sig' field")]
MissingSigField,
#[error("The 'sig' field must be a CBOR byte string")]
SigFieldWrongType,
#[error("The 'sig' field must be 64 bytes (r || s concatenated), got {actual}")]
SigFieldWrongLength {
actual: usize,
},
}
#[derive(Debug, Clone, Error)]
pub enum SignatureParseError {
#[error("Failed to parse signature as secp256k1: {cause}")]
K256Failed {
cause: String,
},
#[error("Failed to parse signature as NIST P-256: {cause}")]
P256Failed {
cause: String,
},
}
#[derive(Debug, Clone, Error, miette::Diagnostic)]
pub enum CryptoCheckError {
#[error(
"labels failed verification against current key \"{current_key_id}\" and did:web provides no rotation history"
)]
#[diagnostic(code = "labeler::crypto::did_web_no_rotation_history")]
DidWebNoRotationHistory {
current_key_id: String,
},
#[error(
"some labels could not be verified against any of the {} tried key id(s): {tried_keys:?}",
tried_keys.len()
)]
#[diagnostic(code = "labeler::crypto::multi_key_verification_failed")]
MultiKeyVerificationFailed {
tried_keys: Vec<String>,
},
#[error("failed to fetch PLC audit log for {did}: {reason}")]
#[diagnostic(code = "labeler::crypto::plc_history_fetch_network_error")]
PlcHistoryFetchNetworkError {
did: String,
reason: String,
},
#[error("failed to canonicalize label {label_uri} for signing")]
#[diagnostic(code = "labeler::crypto::label_canonicalization_failed")]
LabelCanonicalizationFailed {
label_uri: String,
#[source]
source: CanonicalizeError,
},
#[error(
"signature field for label {label_uri} is not a valid {curve} ECDSA signature for the current key"
)]
#[diagnostic(code = "labeler::crypto::signature_bytes_unparseable")]
SignatureBytesUnparseable {
label_uri: String,
curve: &'static str,
},
#[error(
"label {label_uri} failed verification against current key \"{current_key_id}\" and PLC history could not be consulted"
)]
#[diagnostic(code = "labeler::crypto::label_verification_failed_no_history")]
LabelVerificationFailedNoHistory {
current_key_id: String,
label_uri: String,
},
}
pub fn canonicalize_label_for_signing(label: &Label) -> Result<CanonicalLabel, CanonicalizeError> {
let mut value: Value = ciborium::value::Value::serialized(&label.data).map_err(|e| {
CanonicalizeError::InvalidLabelCbor {
cause: format!("{e}"),
}
})?;
validate_value(&value)?;
let signature_bytes = extract_and_remove_sig(&mut value)?;
canonicalize_tree(&mut value)?;
let mut canonical_bytes = Vec::new();
ciborium::ser::into_writer(&value, &mut canonical_bytes).map_err(|e| {
CanonicalizeError::InvalidLabelCbor {
cause: format!("Re-serialization failed: {e}"),
}
})?;
let prehash: [u8; 32] = Sha256::digest(&canonical_bytes).into();
Ok(CanonicalLabel {
prehash,
canonical_bytes,
signature_bytes,
})
}
fn validate_value(value: &Value) -> Result<(), CanonicalizeError> {
match value {
Value::Null | Value::Bool(_) | Value::Integer(_) | Value::Bytes(_) | Value::Text(_) => {
Ok(())
}
Value::Float(_) => Err(CanonicalizeError::FloatRejected),
Value::Array(arr) => {
for item in arr {
validate_value(item)?;
}
Ok(())
}
Value::Map(map) => {
for (k, v) in map {
validate_value(k)?;
validate_value(v)?;
}
Ok(())
}
Value::Tag(_, val) => validate_value(val),
_ => Ok(()),
}
}
fn extract_and_remove_sig(value: &mut Value) -> Result<Vec<u8>, CanonicalizeError> {
match value {
Value::Map(map) => {
let sig_key = Value::Text("sig".to_string());
let mut sig_value = None;
let sig_index = map.iter().position(|(k, _)| k == &sig_key);
if let Some(idx) = sig_index {
let (_, val) = map.remove(idx);
sig_value = Some(val);
}
let sig_value = sig_value.ok_or(CanonicalizeError::MissingSigField)?;
match sig_value {
Value::Bytes(ref bytes) => {
if bytes.len() != 64 {
return Err(CanonicalizeError::SigFieldWrongLength {
actual: bytes.len(),
});
}
Ok(bytes.clone())
}
_ => Err(CanonicalizeError::SigFieldWrongType),
}
}
_ => Err(CanonicalizeError::MissingSigField),
}
}
fn canonicalize_tree(value: &mut Value) -> Result<(), CanonicalizeError> {
match value {
Value::Array(arr) => {
for item in arr {
canonicalize_tree(item)?;
}
Ok(())
}
Value::Map(map) => {
for (_, v) in map.iter_mut() {
canonicalize_tree(v)?;
}
let mut entries: Vec<_> = std::mem::take(map);
entries.sort_by(|(k1, _), (k2, _)| {
let bytes1 = encode_key_to_bytes(k1);
let bytes2 = encode_key_to_bytes(k2);
bytes1.cmp(&bytes2)
});
*map = entries;
Ok(())
}
Value::Tag(_, val) => canonicalize_tree(val),
_ => Ok(()),
}
}
fn encode_key_to_bytes(value: &Value) -> Vec<u8> {
let mut bytes = Vec::new();
let _ = ciborium::ser::into_writer(value, &mut bytes);
bytes
}
#[derive(Debug, Clone)]
pub struct CryptoFacts {
pub verified_with_current: usize,
pub verified_with_historic: Vec<HistoricKeyHit>,
pub unverified: usize,
}
#[derive(Debug, Clone)]
pub struct HistoricKeyHit {
pub key_id: String,
pub label_count: usize,
}
#[derive(Debug)]
pub struct CryptoStageOutput {
pub facts: Option<CryptoFacts>,
pub results: Vec<CheckResult>,
}
#[derive(Debug, Clone)]
struct FailedLabel {
label: Label,
canonicalization_error: Option<CanonicalizeError>,
}
pub async fn run(
identity: &crate::commands::test::labeler::identity::IdentityFacts,
labels: &[Label],
http: &dyn crate::common::identity::HttpClient,
) -> CryptoStageOutput {
if labels.is_empty() {
return CryptoStageOutput {
facts: None,
results: vec![Check::Rollup.skip("labeler published no labels; nothing to verify")],
};
}
let mut results = Vec::new();
let mut per_label_violations = Vec::new();
let mut verified_with_current = 0usize;
let mut failed_against_current: Vec<FailedLabel> = Vec::new();
for label in labels {
match canonicalize_label_for_signing(label) {
Err(err) => {
let diagnostic = CryptoCheckError::LabelCanonicalizationFailed {
label_uri: label.uri.clone(),
source: err.clone(),
};
per_label_violations
.push(Check::CanonicalizationFailed.spec_violation(Box::new(diagnostic)));
failed_against_current.push(FailedLabel {
label: label.clone(),
canonicalization_error: Some(err),
});
}
Ok(canonical) => {
match parse_signature(&canonical.signature_bytes, &identity.signing_key) {
Err(_) => {
let diagnostic = CryptoCheckError::SignatureBytesUnparseable {
label_uri: label.uri.clone(),
curve: identity.signing_key.curve_name(),
};
per_label_violations.push(
Check::SignatureBytesUnparseable.spec_violation(Box::new(diagnostic)),
);
failed_against_current.push(FailedLabel {
label: label.clone(),
canonicalization_error: None,
});
}
Ok(signature) => {
match identity
.signing_key
.verify_prehash(&canonical.prehash, &signature)
{
Ok(()) => {
verified_with_current += 1;
}
Err(_) => {
failed_against_current.push(FailedLabel {
label: label.clone(),
canonicalization_error: None,
});
}
}
}
}
}
}
}
tracing::debug!(
total_labels = labels.len(),
verified_with_current,
failed = failed_against_current.len(),
"crypto stage: current-key verification complete"
);
if failed_against_current.is_empty() {
results.push(CheckResult {
summary: Cow::Owned(format!(
"{verified_with_current} labels verified against current key"
)),
..Check::Rollup.pass()
});
return CryptoStageOutput {
facts: Some(CryptoFacts {
verified_with_current,
verified_with_historic: Vec::new(),
unverified: 0,
}),
results,
};
}
if is_local_labeler_hostname(&identity.labeler_endpoint) {
results.push(Check::Rollup.skip(
"local labeler signing key does not match the published DID document \
(production signing key not available in this test environment)",
));
return CryptoStageOutput {
facts: None,
results,
};
}
results.extend(per_label_violations);
match identity.did.method() {
crate::common::identity::DidMethod::Plc => {
tracing::debug!(
did = %identity.did,
"crypto stage: fetching PLC audit log for historic keys"
);
match crate::common::identity::plc_history_for_fragment(
&identity.did,
"atproto_label",
http,
)
.await
{
Err(e) => {
let diagnostic = CryptoCheckError::PlcHistoryFetchNetworkError {
did: identity.did.to_string(),
reason: format!("{e}"),
};
results.push(Check::PlcHistoryFetch.network_error(Box::new(diagnostic)));
for failed in &failed_against_current {
let diagnostic = CryptoCheckError::LabelVerificationFailedNoHistory {
current_key_id: identity.signing_key_id.clone(),
label_uri: failed.label.uri.clone(),
};
results.push(
Check::LabelVerificationFailed.spec_violation(Box::new(diagnostic)),
);
}
CryptoStageOutput {
facts: None,
results,
}
}
Ok(historic_keys) => {
tracing::debug!(
historic_key_count = historic_keys.len(),
"crypto stage: PLC audit log returned historic keys"
);
let mut historic_hits: BTreeMap<String, usize> = BTreeMap::new();
let mut tried_historic_key_ids = Vec::new();
for historic_key in historic_keys {
tracing::debug!(
key_id = %historic_key.key_id,
"crypto stage: attempting verification with historic key"
);
if failed_against_current.is_empty() {
break; }
match crate::common::identity::parse_multikey(&historic_key.key_id) {
Err(_) => {
tracing::warn!(
key_id = %historic_key.key_id,
"failed to parse historic multikey"
);
tried_historic_key_ids.push(historic_key.key_id.clone());
continue;
}
Ok(parsed) => {
tried_historic_key_ids.push(historic_key.key_id.clone());
let mut newly_verified = Vec::new();
for (i, failed) in failed_against_current.iter().enumerate() {
if failed.canonicalization_error.is_some() {
continue;
}
if let Ok(canonical) =
canonicalize_label_for_signing(&failed.label)
{
if let Ok(signature) = parse_signature(
&canonical.signature_bytes,
&parsed.verifying_key,
) {
if parsed
.verifying_key
.verify_prehash(&canonical.prehash, &signature)
.is_ok()
{
newly_verified.push(i);
*historic_hits
.entry(historic_key.key_id.clone())
.or_insert(0) += 1;
}
}
}
}
for i in newly_verified.iter().rev() {
failed_against_current.remove(*i);
}
}
}
}
if failed_against_current.is_empty() {
let total_count: usize = historic_hits.values().sum();
let distinct_count = historic_hits.len();
results.push(CheckResult {
summary: Cow::Owned(format!(
"{total_count} label(s) signed by a rotated-out key ({distinct_count} distinct key id(s))"
)),
..Check::RotatedKeysUsed.advisory()
});
results.push(Check::Rollup.pass());
CryptoStageOutput {
facts: Some(CryptoFacts {
verified_with_current,
verified_with_historic: historic_hits
.into_iter()
.map(|(key_id, label_count)| HistoricKeyHit {
key_id,
label_count,
})
.collect(),
unverified: 0,
}),
results,
}
} else {
let mut tried_keys = vec![identity.signing_key_multikey.clone()];
for raw in &tried_historic_key_ids {
let normalised =
raw.strip_prefix("did:key:").unwrap_or(raw).to_string();
if !tried_keys.contains(&normalised) {
tried_keys.push(normalised);
}
}
let diagnostic = CryptoCheckError::MultiKeyVerificationFailed {
tried_keys: tried_keys.clone(),
};
results.push(CheckResult {
summary: Cow::Owned(format!(
"Some labels could not be verified against any key (tried {} key id(s))",
tried_keys.len()
)),
..Check::Rollup.spec_violation(Box::new(diagnostic))
});
CryptoStageOutput {
facts: None,
results,
}
}
}
}
}
_ => {
let diagnostic = CryptoCheckError::DidWebNoRotationHistory {
current_key_id: identity.signing_key_id.clone(),
};
results.push(CheckResult {
summary: Cow::Borrowed(
"Labels failed verification and did:web provides no rotation history",
),
..Check::Rollup.spec_violation(Box::new(diagnostic))
});
CryptoStageOutput {
facts: None,
results,
}
}
}
}
fn parse_signature(
bytes: &[u8],
verifying_key: &crate::common::identity::AnyVerifyingKey,
) -> Result<crate::common::identity::AnySignature, SignatureParseError> {
match verifying_key {
crate::common::identity::AnyVerifyingKey::K256(_) => {
k256::ecdsa::Signature::from_slice(bytes)
.map(crate::common::identity::AnySignature::K256)
.map_err(|e| SignatureParseError::K256Failed {
cause: format!("{e}"),
})
}
crate::common::identity::AnyVerifyingKey::P256(_) => {
p256::ecdsa::Signature::from_slice(bytes)
.map(crate::common::identity::AnySignature::P256)
.map_err(|e| SignatureParseError::P256Failed {
cause: format!("{e}"),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::test::labeler::identity::IdentityFacts;
use crate::common::identity::{
AnySignature, AnyVerifyingKey, Did, DidDocument, IdentityError, RawDidDocument,
encode_multikey,
};
use atrium_api::app::bsky::labeler::defs::LabelerPolicies;
use atrium_api::com::atproto::label::defs::{Label, LabelData};
use atrium_api::types::string::Datetime;
use k256::ecdsa::SigningKey as K256SigningKey;
use k256::ecdsa::signature::hazmat::PrehashSigner;
use std::sync::Arc;
use url::Url;
struct PanicHttpClient;
#[async_trait::async_trait]
impl crate::common::identity::HttpClient for PanicHttpClient {
async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> {
panic!("PanicHttpClient reached for {url}; crypto stage should have short-circuited");
}
}
fn make_crypto_facts(signing_key: AnyVerifyingKey, labeler_endpoint: Url) -> IdentityFacts {
let did = Did("did:web:localhost%3A8080".to_string());
let multikey = encode_multikey(&signing_key);
let doc_json = format!(
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"}}]}}"##,
did = did.0,
);
let doc: DidDocument = serde_json::from_str(&doc_json).expect("test DID doc parses");
let raw_did_doc = RawDidDocument {
parsed: doc,
source_bytes: Arc::<[u8]>::from(doc_json.as_bytes()),
source_name: "test DID document".to_string(),
};
let labeler_policies: LabelerPolicies = serde_json::from_value(serde_json::json!({
"labelValues": [],
}))
.expect("LabelerPolicies deserializes");
IdentityFacts {
did,
raw_did_doc,
labeler_endpoint,
pds_endpoint: Url::parse("https://pds.example.com").unwrap(),
signing_key_id: "did:web:localhost%3A8080#atproto_label".to_string(),
signing_key_multikey: multikey,
signing_key,
labeler_record_bytes: Arc::<[u8]>::from(b"{}" as &[u8]),
labeler_policies,
reason_types: None,
subject_types: None,
subject_collections: None,
}
}
fn sign_label_with(signing_key: &K256SigningKey) -> Label {
let placeholder: Label = LabelData {
cid: None,
cts: Datetime::new("2026-01-01T00:00:00.000Z".parse().expect("valid datetime")),
exp: None,
neg: Some(false),
sig: Some(vec![0u8; 64]),
src: "did:plc:test123456789abcdefghijklmnop"
.parse()
.expect("valid did"),
uri: "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1".to_string(),
val: "spam".to_string(),
ver: Some(1),
}
.into();
let canonical =
canonicalize_label_for_signing(&placeholder).expect("canonicalize placeholder label");
let sig: k256::ecdsa::Signature = signing_key
.sign_prehash(&canonical.prehash)
.expect("sign prehash");
let mut signed_data = placeholder.data.clone();
signed_data.sig = Some(sig.to_bytes().to_vec());
signed_data.into()
}
#[test]
fn canonicalize_rejects_nan_float() {
let value = Value::Map(vec![(
Value::Text("test".to_string()),
Value::Float(std::f64::consts::PI),
)]);
let result = validate_value(&value);
assert!(matches!(result, Err(CanonicalizeError::FloatRejected)));
}
#[test]
fn canonicalize_missing_sig_errors() {
let mut value = Value::Map(vec![(
Value::Text("ver".to_string()),
Value::Integer(1.into()),
)]);
let result = extract_and_remove_sig(&mut value);
assert!(matches!(result, Err(CanonicalizeError::MissingSigField)));
}
#[test]
fn sign_and_verify_label_roundtrip_k256() {
let seed: [u8; 32] = [7u8; 32];
let signing_key = K256SigningKey::from_slice(&seed).expect("valid secret scalar");
let verifying_key = AnyVerifyingKey::K256(*signing_key.verifying_key());
let placeholder: Label = LabelData {
cid: None,
cts: Datetime::new("2026-01-01T00:00:00.000Z".parse().expect("valid datetime")),
exp: None,
neg: Some(false),
sig: Some(vec![0u8; 64]),
src: "did:plc:test123456789abcdefghijklmnop"
.parse()
.expect("valid did"),
uri: "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1".to_string(),
val: "spam".to_string(),
ver: Some(1),
}
.into();
let canonical =
canonicalize_label_for_signing(&placeholder).expect("canonicalize placeholder label");
let sig: k256::ecdsa::Signature = signing_key
.sign_prehash(&canonical.prehash)
.expect("sign prehash");
let sig_bytes = sig.to_bytes().to_vec();
assert_eq!(sig_bytes.len(), 64, "k256 signature must be 64 bytes");
let mut signed_data = placeholder.data.clone();
signed_data.sig = Some(sig_bytes.clone());
let signed: Label = signed_data.into();
let signed_canonical =
canonicalize_label_for_signing(&signed).expect("canonicalize signed label");
assert_eq!(
signed_canonical.prehash, canonical.prehash,
"prehash must be invariant over changes to the sig field"
);
assert_eq!(signed_canonical.signature_bytes, sig_bytes);
let any_sig = AnySignature::K256(
k256::ecdsa::Signature::from_slice(&signed_canonical.signature_bytes)
.expect("parse signature"),
);
verifying_key
.verify_prehash(&signed_canonical.prehash, &any_sig)
.expect("signature must verify against the signing key");
}
#[test]
fn canonicalize_ignores_extra_data_fields() {
let with_id: Label = serde_json::from_str(
r#"{
"id": 42,
"src": "did:plc:test123456789abcdefghijklmnop",
"uri": "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1",
"val": "spam",
"cts": "2026-01-01T00:00:00.000Z",
"neg": false,
"ver": 1,
"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,
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]
}"#,
)
.expect("parse label with extra field");
let without_id: Label = serde_json::from_str(
r#"{
"src": "did:plc:test123456789abcdefghijklmnop",
"uri": "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1",
"val": "spam",
"cts": "2026-01-01T00:00:00.000Z",
"neg": false,
"ver": 1,
"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,
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]
}"#,
)
.expect("parse label without extra field");
let with_canonical =
canonicalize_label_for_signing(&with_id).expect("canonicalize label with id");
let without_canonical =
canonicalize_label_for_signing(&without_id).expect("canonicalize label without id");
assert_eq!(
with_canonical.canonical_bytes, without_canonical.canonical_bytes,
"extra JSON fields must not change the canonical bytes"
);
assert_eq!(
with_canonical.prehash, without_canonical.prehash,
"extra JSON fields must not change the prehash"
);
}
#[test]
fn canonicalize_sig_wrong_length_errors() {
let sig_value = Value::Bytes(vec![0u8; 32]);
let mut value = Value::Map(vec![(Value::Text("sig".to_string()), sig_value)]);
let result = extract_and_remove_sig(&mut value);
assert!(matches!(
result,
Err(CanonicalizeError::SigFieldWrongLength { actual: 32 })
));
}
#[test]
fn parse_signature_rejects_zero_scalar_without_panic() {
use crate::common::identity::AnyVerifyingKey;
use k256::ecdsa::SigningKey as K256SigningKey;
let seed: [u8; 32] = [7u8; 32];
let signing_key = K256SigningKey::from_slice(&seed).expect("valid secret scalar");
let verifying_key = AnyVerifyingKey::K256(*signing_key.verifying_key());
let invalid_sig_bytes = vec![0u8; 64];
let result = parse_signature(&invalid_sig_bytes, &verifying_key);
assert!(result.is_err());
match result.unwrap_err() {
SignatureParseError::K256Failed { .. } => {
}
_ => panic!("Expected K256Failed error"),
}
}
#[test]
fn canonicalizes_real_labeler_output_matches_wire_signature() {
use crate::common::identity::parse_multikey;
struct Fixture {
name: &'static str,
src: &'static str,
uri: &'static str,
cid: &'static str,
val: &'static str,
cts: &'static str,
multikey: &'static str,
sig: [u8; 64],
}
let fixtures = [
Fixture {
name: "moderation.bsky.app",
src: "did:plc:ar7c4by46qjdydhdevvrndac",
uri: "at://did:plc:gzdjlsa34b4jpbvegk4dngvb/app.bsky.feed.post/3m5p2kcpjek2t",
cid: "bafyreihmigssl6hpegb3sfou5vemydbo63it5a253udvdoiae5cgfbc3jq",
val: "sexual",
cts: "2025-11-15T20:40:44.774Z",
multikey: "zQ3shmV1BNcX17coaDbfen6zArEad6SCLT3jVWCbC6Y9iinTa",
sig: [
0x18, 0xb9, 0xe5, 0xc2, 0x36, 0x87, 0x7e, 0x31, 0x17, 0x93, 0xc1, 0xe7, 0xbb,
0x82, 0xab, 0x78, 0x0d, 0x12, 0x7d, 0xb0, 0xf3, 0x80, 0x4b, 0x18, 0x6f, 0x1e,
0xeb, 0x77, 0xb8, 0xc7, 0xbd, 0x99, 0x30, 0x0b, 0x92, 0x85, 0xf7, 0xff, 0x3f,
0xa9, 0x8b, 0x43, 0xae, 0x1f, 0x1c, 0xf5, 0x22, 0x31, 0x9c, 0x70, 0x1e, 0x3e,
0x87, 0x69, 0xf6, 0x6e, 0x8e, 0x3f, 0x9c, 0x9c, 0x93, 0x18, 0x42, 0xf6,
],
},
Fixture {
name: "xblock.aendra.dev",
src: "did:plc:newitj5jo3uel7o4mnf3vj2o",
uri: "at://did:plc:yioyxg6ym5gtda5yprh2p4c7/app.bsky.feed.post/3ld5mvbxqtk2p",
cid: "bafyreiafpv7pn7z35dqcv3cbp44sw2efdakhnhxanibkm2q2jyo7u27ubq",
val: "twitter-screenshot",
cts: "2024-12-13T01:26:06.992Z",
multikey: "zQ3shht8JUZuf87GTWQzmZKF1L61PEppz1aGjj7NrpNVmWz8H",
sig: [
0x68, 0x21, 0x42, 0xb6, 0x7e, 0x95, 0x73, 0x9a, 0x18, 0x95, 0x3e, 0x86, 0x6e,
0x24, 0xc7, 0x8a, 0x33, 0x6f, 0xfd, 0x40, 0x25, 0xf7, 0xcd, 0xcc, 0x1b, 0x2e,
0x3d, 0x40, 0xef, 0x5b, 0xdd, 0xa7, 0x77, 0x31, 0x38, 0x9d, 0x54, 0x12, 0x52,
0xae, 0xdd, 0x18, 0x98, 0x85, 0xf5, 0xcc, 0xe6, 0x63, 0x3c, 0x6f, 0x21, 0xaf,
0xc8, 0x41, 0xa4, 0xd0, 0x6f, 0x7f, 0xf8, 0x0d, 0xb3, 0x8d, 0x08, 0x8d,
],
},
];
for fixture in &fixtures {
let label: Label = LabelData {
cid: Some(fixture.cid.parse().expect("valid cid")),
cts: fixture.cts.parse().expect("valid datetime"),
exp: None,
neg: None,
sig: Some(fixture.sig.to_vec()),
src: fixture.src.parse().expect("valid did"),
uri: fixture.uri.to_string(),
val: fixture.val.to_string(),
ver: Some(1),
}
.into();
let canonical = canonicalize_label_for_signing(&label)
.unwrap_or_else(|e| panic!("{}: canonicalize: {e}", fixture.name));
assert_eq!(
canonical.signature_bytes,
fixture.sig.to_vec(),
"{}: signature_bytes must round-trip through canonicalizer",
fixture.name,
);
let parsed = parse_multikey(fixture.multikey)
.unwrap_or_else(|e| panic!("{}: parse_multikey: {e}", fixture.name));
assert!(
matches!(parsed.verifying_key, AnyVerifyingKey::K256(_)),
"{}: expected secp256k1 multikey",
fixture.name,
);
let any_sig = AnySignature::K256(
k256::ecdsa::Signature::from_slice(&fixture.sig)
.unwrap_or_else(|e| panic!("{}: parse signature: {e}", fixture.name)),
);
parsed
.verifying_key
.verify_prehash(&canonical.prehash, &any_sig)
.unwrap_or_else(|e| {
panic!(
"{}: real labeler signature must verify against canonicalizer output: {e}",
fixture.name
)
});
}
}
#[tokio::test]
async fn local_labeler_skips_rollup_when_signing_key_mismatches() {
let published_seed: [u8; 32] = [1u8; 32];
let local_seed: [u8; 32] = [2u8; 32];
let published = K256SigningKey::from_slice(&published_seed).expect("valid seed");
let local = K256SigningKey::from_slice(&local_seed).expect("valid seed");
let label = sign_label_with(&local);
let facts = make_crypto_facts(
AnyVerifyingKey::K256(*published.verifying_key()),
Url::parse("http://localhost:8080").unwrap(),
);
let output = run(&facts, &[label], &PanicHttpClient).await;
assert_eq!(output.results.len(), 1, "expected only the rollup row");
let rollup = &output.results[0];
assert_eq!(rollup.id, "crypto::rollup");
assert_eq!(rollup.status, CheckStatus::Skipped);
let reason = rollup
.skipped_reason
.as_deref()
.expect("skip reason present");
assert!(
reason.contains("local labeler"),
"skip reason should mention local labeler: {reason}"
);
assert!(
output.facts.is_none(),
"facts should be None when the rollup is skipped"
);
}
#[tokio::test]
async fn local_labeler_passes_when_signing_key_matches() {
let seed: [u8; 32] = [3u8; 32];
let signing = K256SigningKey::from_slice(&seed).expect("valid seed");
let label = sign_label_with(&signing);
let facts = make_crypto_facts(
AnyVerifyingKey::K256(*signing.verifying_key()),
Url::parse("http://127.0.0.1:5000").unwrap(),
);
let output = run(&facts, &[label], &PanicHttpClient).await;
let rollup = output
.results
.iter()
.find(|r| r.id == "crypto::rollup")
.expect("rollup row present");
assert_eq!(
rollup.status,
CheckStatus::Pass,
"matching local key should still Pass"
);
let facts = output.facts.expect("facts populated on pass");
assert_eq!(facts.verified_with_current, 1);
}
}