Skip to main content

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}