cairn_mod/signing.rs
1//! Label canonical encoding, signing, and verification (§6.2, §6.3, §F2).
2//!
3//! The canonical encoding is ATProto DAG-CBOR / DRISL: sorted map keys
4//! (length-first, then byte order), shortest-form integers, no floats, no
5//! indefinite-length items. proto-blue's `lex_cbor::encode` implements the
6//! profile; byte-level parity against `@atproto/api` is pinned by the
7//! fixture corpus at `tests/fixtures/signature-corpus/`.
8//!
9//! Signing pipeline per §6.2:
10//! label (sig removed, ver included) -> LexValue -> canonical CBOR
11//! -> SHA-256 -> k256::sign_prehash (RFC 6979) -> low-S normalize
12//! -> raw 64-byte (r, s) compact.
13//!
14//! Verification is the symmetric path through proto-blue's `verify_signature`
15//! with `allow_malleable: false`, which rejects high-S signatures at the
16//! verifier — the boundary at which a malicious or buggy external high-S
17//! sig could enter the system.
18
19use std::collections::BTreeMap;
20
21use proto_blue_crypto::{
22 K256Keypair, Signer as _, format_did_key, k256_compress_pubkey, parse_multikey,
23 verify_signature,
24};
25use proto_blue_lex_cbor::encode;
26use proto_blue_lex_data::LexValue;
27
28use crate::error::{Error, Result};
29use crate::label::Label;
30use crate::signing_key::SigningKey;
31
32/// Build the `LexValue::Map` used for canonical encoding per §6.2.
33///
34/// Rules applied here (each a §6.2 constraint the fixture corpus pins):
35/// - `sig` is always omitted (step 3).
36/// - `ver` is always present (step 2).
37/// - `neg` is omitted when `false` (schema default) and present as `true`
38/// otherwise — matches `@atproto/api`'s `omitFalse` behavior.
39/// - `cid` and `exp` are omitted when absent (never encoded as null: DAG-CBOR
40/// draws a hard line between "field absent" and "field present with null
41/// value," and the spec prescribes absence).
42fn label_to_lex_value(label: &Label) -> LexValue {
43 let mut m = BTreeMap::new();
44 m.insert("ver".to_string(), LexValue::Integer(label.ver));
45 m.insert("src".to_string(), LexValue::String(label.src.clone()));
46 m.insert("uri".to_string(), LexValue::String(label.uri.clone()));
47 if let Some(cid) = &label.cid {
48 m.insert("cid".to_string(), LexValue::String(cid.clone()));
49 }
50 m.insert("val".to_string(), LexValue::String(label.val.clone()));
51 if label.neg {
52 m.insert("neg".to_string(), LexValue::Bool(true));
53 }
54 m.insert("cts".to_string(), LexValue::String(label.cts.clone()));
55 if let Some(exp) = &label.exp {
56 m.insert("exp".to_string(), LexValue::String(exp.clone()));
57 }
58 LexValue::Map(m)
59}
60
61/// Canonical CBOR bytes of `label` with `sig` omitted — the exact byte
62/// sequence that gets SHA-256'd before signing (§6.2 step 5 input).
63///
64/// Exposed for the parity corpus test, which compares these bytes to the
65/// `.cbor` fixture produced by `@atproto/api`.
66pub fn canonical_bytes(label: &Label) -> Result<Vec<u8>> {
67 Ok(encode(&label_to_lex_value(label))?)
68}
69
70/// Build the wire-format `LexValue` for a *signed* label — same field rules
71/// as the canonical pre-signing form but **also includes** `sig` as
72/// `LexValue::Bytes`.
73///
74/// Used by the subscribeLabels frame encoder: the `#labels` body embeds each
75/// label in full, sig included, so consumers can verify per §6.3.
76///
77/// Errors when `label.sig` is `None` — the wire encoding requires a signed
78/// label, and callers should have either just signed it or loaded a signed
79/// row from storage.
80pub fn label_to_lex_value_with_sig(label: &Label) -> Result<LexValue> {
81 let sig = label
82 .sig
83 .ok_or_else(|| Error::Signing("wire encoding requires signed label".into()))?;
84 let LexValue::Map(mut m) = label_to_lex_value(label) else {
85 unreachable!("label_to_lex_value always returns a Map")
86 };
87 m.insert("sig".to_string(), LexValue::Bytes(sig.to_vec()));
88 Ok(LexValue::Map(m))
89}
90
91/// Sign `label` with `key`, returning a raw 64-byte compact `(r, s)` signature.
92///
93/// Pipeline matches §6.2 end-to-end: strip `sig`, canonical-encode, SHA-256,
94/// RFC 6979 sign-prehash, low-S normalize. proto-blue's `K256Keypair::sign`
95/// performs the last three steps atomically.
96pub fn sign_label(key: &SigningKey, label: &Label) -> Result<[u8; 64]> {
97 let keypair = K256Keypair::from_private_key(key.expose_secret())?;
98 let bytes = canonical_bytes(label)?;
99 let sig = keypair.sign(&bytes)?;
100 sig.as_slice().try_into().map_err(|_| {
101 Error::Signing(format!(
102 "proto-blue returned non-64-byte signature ({} bytes)",
103 sig.len()
104 ))
105 })
106}
107
108/// Verify that `label.sig` is a valid signature by the key encoded in
109/// `public_key_multibase` over the canonical CBOR of `label` with `sig`
110/// removed (§6.3).
111///
112/// `public_key_multibase` is the `publicKeyMultibase` form published at the
113/// labeler's `#atproto_label` verification method. Only ES256K (secp256k1)
114/// keys are accepted — ATProto label signatures are defined only for that
115/// curve. The DID-document fetch path is out of scope here and lives in #11.
116///
117/// Rejects high-S signatures at the boundary: proto-blue's verifier calls
118/// `normalize_s()` and returns `Ok(false)` when the input was high-S,
119/// regardless of whether the (r, normalize_s(s)) pair would have verified.
120/// This is the security-relevant property for malleability resistance.
121pub fn verify_label(public_key_multibase: &str, label: &Label) -> Result<()> {
122 let sig = label
123 .sig
124 .ok_or_else(|| Error::Signing("label has no signature".into()))?;
125
126 let parsed = parse_multikey(public_key_multibase)?;
127 if parsed.jwt_alg != "ES256K" {
128 return Err(Error::Signing(format!(
129 "expected ES256K label key, got {}",
130 parsed.jwt_alg
131 )));
132 }
133 let compressed = k256_compress_pubkey(&parsed.key_bytes)?;
134 let did = format_did_key("ES256K", &compressed);
135
136 let bytes = canonical_bytes(label)?;
137 let ok = verify_signature(&did, &bytes, &sig, false)?;
138 if !ok {
139 return Err(Error::Signing("signature verification failed".into()));
140 }
141 Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn fixture_label() -> Label {
149 Label {
150 ver: 1,
151 src: "did:plc:test".to_string(),
152 uri: "at://did:plc:subject/app.bsky.feed.post/abc123".to_string(),
153 cid: None,
154 val: "spam".to_string(),
155 neg: false,
156 cts: "2026-04-22T12:00:00.000Z".to_string(),
157 exp: None,
158 sig: None,
159 }
160 }
161
162 #[test]
163 fn canonical_encoding_omits_sig_and_defaults() {
164 // Build two labels differing only in `sig` presence; canonical bytes
165 // must be identical because sig is stripped before encoding.
166 let l1 = fixture_label();
167 let mut l2 = fixture_label();
168 l2.sig = Some([0u8; 64]);
169
170 let b1 = canonical_bytes(&l1).expect("encode l1");
171 let b2 = canonical_bytes(&l2).expect("encode l2");
172 assert_eq!(b1, b2, "sig-present vs sig-absent must canonicalize equal");
173
174 // neg=false: byte sequence must not contain the UTF-8 for "neg".
175 assert!(
176 !b1.windows(3).any(|w| w == b"neg"),
177 "neg=false must be omitted from canonical form"
178 );
179 }
180
181 #[test]
182 fn wire_encoding_includes_sig_and_pre_sign_does_not() {
183 // Concrete anti-confusion test for the two-encoder split:
184 // - label_to_lex_value_with_sig embeds the 64 sig bytes verbatim.
185 // - canonical_bytes (the signing input) must never contain them.
186 // A marker sig of repeated 0xCD is deliberately chosen: 0xCD is not
187 // a valid ASCII character (so CID/uri/val text strings can't contain
188 // it) and is not a valid DAG-CBOR structural byte for our field
189 // shapes, so accidental collisions in unrelated encoded content
190 // won't mask a real bug.
191 let marker: [u8; 64] = [0xCD; 64];
192 let mut label = fixture_label();
193 label.sig = Some(marker);
194
195 let presign = canonical_bytes(&label).expect("canonical");
196 let wire = encode(&label_to_lex_value_with_sig(&label).expect("with_sig"))
197 .expect("encode with_sig");
198
199 assert!(
200 wire.windows(64).any(|w| w == marker),
201 "wire encoding must contain the 64 sig bytes contiguously"
202 );
203 assert!(
204 !presign.windows(64).any(|w| w == marker),
205 "pre-sign encoding must NOT contain sig bytes — any match means sig leaked into the signing input"
206 );
207
208 // Also assert the wire encoding rejects an unsigned label, since
209 // callers should have either signed-then-encoded or loaded from
210 // a row that already carries sig.
211 let mut unsigned = fixture_label();
212 unsigned.sig = None;
213 let err = label_to_lex_value_with_sig(&unsigned)
214 .expect_err("unsigned label must not wire-encode");
215 assert!(matches!(err, Error::Signing(_)), "got {err:?}");
216 }
217
218 #[test]
219 fn sign_then_verify_roundtrip() {
220 use proto_blue_crypto::Keypair as _;
221
222 let key = SigningKey::from_bytes([0x42; 32]);
223 let mut label = fixture_label();
224 label.sig = Some(sign_label(&key, &label).expect("sign"));
225
226 // Reconstruct the multibase that verify_label expects.
227 let kp = K256Keypair::from_private_key(key.expose_secret()).unwrap();
228 let multibase = proto_blue_crypto::format_multikey("ES256K", &kp.public_key_compressed());
229
230 verify_label(&multibase, &label).expect("verify should pass");
231 }
232
233 #[test]
234 fn signature_is_64_bytes() {
235 let key = SigningKey::from_bytes([0x11; 32]);
236 let sig = sign_label(&key, &fixture_label()).expect("sign");
237 assert_eq!(sig.len(), 64);
238 }
239}