Skip to main content

atrg_label/
signing.rs

1//! Label signing utilities.
2//!
3//! Labels are signed by serializing the label to CBOR, then signing the
4//! CBOR bytes with the labeler's private key (ed25519 or secp256k1).
5
6use crate::types::Label;
7
8/// A signer that can produce signatures for labels.
9///
10/// In v0.2.0, this uses a placeholder HMAC-SHA256 implementation.
11/// A future version will support ed25519/secp256k1 per the AT Protocol spec.
12pub struct LabelSigner {
13    /// The signing key bytes.
14    key: Vec<u8>,
15}
16
17impl LabelSigner {
18    /// Create a signer from raw key bytes.
19    pub fn new(key: Vec<u8>) -> Self {
20        Self { key }
21    }
22
23    /// Create a signer from a base64-encoded key string.
24    ///
25    /// **Note:** This is a placeholder that treats the encoded string as raw
26    /// bytes. A production implementation would perform proper base64 decoding.
27    pub fn from_base64(encoded: &str) -> anyhow::Result<Self> {
28        // Placeholder: treat the string as raw bytes.
29        // A future version will perform proper base64 decoding.
30        Ok(Self {
31            key: encoded.as_bytes().to_vec(),
32        })
33    }
34
35    /// Sign a label, returning the base64-encoded signature.
36    ///
37    /// The signing process:
38    /// 1. Serialize the label to canonical CBOR (excluding the `sig` field)
39    /// 2. Sign the CBOR bytes with the private key
40    /// 3. Return base64-encoded signature
41    ///
42    /// **Note:** This is a placeholder implementation using a simple hash.
43    /// Production use requires ed25519 or secp256k1 signing over CBOR.
44    pub fn sign(&self, label: &Label) -> anyhow::Result<String> {
45        // Placeholder: hash the JSON representation with the key.
46        // Real implementation needs proper CBOR serialization
47        // and ed25519/secp256k1 signing.
48        let label_json = serde_json::to_vec(label)?;
49
50        let mut hasher_input = Vec::with_capacity(self.key.len() + label_json.len());
51        hasher_input.extend_from_slice(&self.key);
52        hasher_input.extend_from_slice(&label_json);
53
54        let hash = simple_hash(&hasher_input);
55        Ok(base64_encode(&hash))
56    }
57}
58
59/// Placeholder hash function (djb2 variant producing 8 bytes).
60///
61/// This is **NOT** cryptographically secure — replace with proper signing.
62fn simple_hash(data: &[u8]) -> Vec<u8> {
63    let mut hash: u64 = 5381;
64    for &byte in data {
65        hash = hash.wrapping_mul(33).wrapping_add(byte as u64);
66    }
67    hash.to_be_bytes().to_vec()
68}
69
70/// Simple base64 encoding without external dependency.
71fn base64_encode(data: &[u8]) -> String {
72    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
73    let mut result = String::new();
74    for chunk in data.chunks(3) {
75        let b0 = chunk[0] as u32;
76        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
77        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
78        let triple = (b0 << 16) | (b1 << 8) | b2;
79
80        result.push(CHARSET[((triple >> 18) & 0x3F) as usize] as char);
81        result.push(CHARSET[((triple >> 12) & 0x3F) as usize] as char);
82        if chunk.len() > 1 {
83            result.push(CHARSET[((triple >> 6) & 0x3F) as usize] as char);
84        } else {
85            result.push('=');
86        }
87        if chunk.len() > 2 {
88            result.push(CHARSET[(triple & 0x3F) as usize] as char);
89        } else {
90            result.push('=');
91        }
92    }
93    result
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::types::Label;
100
101    #[test]
102    fn sign_produces_non_empty_string() {
103        let signer = LabelSigner::new(b"test-key".to_vec());
104        let label = Label {
105            ver: 1,
106            src: "did:plc:test".to_string(),
107            uri: "at://did:plc:user/app.bsky.feed.post/abc".to_string(),
108            cid: None,
109            val: "spam".to_string(),
110            neg: false,
111            cts: "1970-01-01T00:00:00Z".to_string(),
112            exp: None,
113        };
114
115        let sig = signer.sign(&label).unwrap();
116        assert!(!sig.is_empty());
117    }
118
119    #[test]
120    fn sign_is_deterministic() {
121        let signer = LabelSigner::new(b"key".to_vec());
122        let label = Label {
123            ver: 1,
124            src: "did:plc:test".to_string(),
125            uri: "at://did:plc:user/app.bsky.feed.post/abc".to_string(),
126            cid: None,
127            val: "spam".to_string(),
128            neg: false,
129            cts: "2024-01-01T00:00:00Z".to_string(),
130            exp: None,
131        };
132
133        let sig1 = signer.sign(&label).unwrap();
134        let sig2 = signer.sign(&label).unwrap();
135        assert_eq!(sig1, sig2);
136    }
137
138    #[test]
139    fn base64_encode_empty() {
140        assert_eq!(base64_encode(&[]), "");
141    }
142
143    #[test]
144    fn base64_encode_single_byte() {
145        let result = base64_encode(&[0x4D]);
146        assert_eq!(result, "TQ==");
147    }
148
149    #[test]
150    fn base64_encode_two_bytes() {
151        let result = base64_encode(&[0x4D, 0x61]);
152        assert_eq!(result, "TWE=");
153    }
154
155    #[test]
156    fn base64_encode_three_bytes() {
157        let result = base64_encode(&[0x4D, 0x61, 0x6E]);
158        assert_eq!(result, "TWFu");
159    }
160
161    #[test]
162    fn from_base64_creates_signer() {
163        let signer = LabelSigner::from_base64("dGVzdC1rZXk=").unwrap();
164        let label = Label {
165            ver: 1,
166            src: "did:plc:test".to_string(),
167            uri: "at://did:plc:user/app.bsky.feed.post/abc".to_string(),
168            cid: None,
169            val: "spam".to_string(),
170            neg: false,
171            cts: "2024-01-01T00:00:00Z".to_string(),
172            exp: None,
173        };
174        let sig = signer.sign(&label).unwrap();
175        assert!(!sig.is_empty());
176    }
177}