music on atproto
plyr.fm
1//! ATProto label types and signing.
2//!
3//! Labels are signed metadata tags that can be applied to ATProto resources.
4//! This module implements the com.atproto.label.defs#label schema.
5
6use bytes::Bytes;
7use chrono::Utc;
8use k256::ecdsa::{signature::Signer, Signature, SigningKey};
9use serde::{Deserialize, Serialize};
10
11/// ATProto label as defined in com.atproto.label.defs#label.
12///
13/// Labels are signed by the labeler's `#atproto_label` key.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct Label {
17 /// Version of the label format (currently 1).
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub ver: Option<i64>,
20
21 /// DID of the labeler that created this label.
22 pub src: String,
23
24 /// AT URI of the resource this label applies to.
25 pub uri: String,
26
27 /// CID of the specific version (optional).
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub cid: Option<String>,
30
31 /// The label value (e.g., "copyright-violation").
32 pub val: String,
33
34 /// If true, this negates a previous label.
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub neg: Option<bool>,
37
38 /// Timestamp when label was created (ISO 8601).
39 pub cts: String,
40
41 /// Expiration timestamp (optional).
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub exp: Option<String>,
44
45 /// DAG-CBOR signature of the label.
46 #[serde(skip_serializing_if = "Option::is_none")]
47 #[serde(with = "serde_bytes_opt")]
48 pub sig: Option<Bytes>,
49}
50
51mod serde_bytes_opt {
52 use bytes::Bytes;
53 use serde::{Deserialize, Deserializer, Serialize, Serializer};
54
55 pub fn serialize<S>(value: &Option<Bytes>, serializer: S) -> Result<S::Ok, S::Error>
56 where
57 S: Serializer,
58 {
59 match value {
60 Some(bytes) => serde_bytes::Bytes::new(bytes.as_ref()).serialize(serializer),
61 None => serializer.serialize_none(),
62 }
63 }
64
65 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Bytes>, D::Error>
66 where
67 D: Deserializer<'de>,
68 {
69 let opt: Option<serde_bytes::ByteBuf> = Option::deserialize(deserializer)?;
70 Ok(opt.map(|b| Bytes::from(b.into_vec())))
71 }
72}
73
74impl Label {
75 /// Create a new unsigned label.
76 pub fn new(src: impl Into<String>, uri: impl Into<String>, val: impl Into<String>) -> Self {
77 Self {
78 ver: Some(1),
79 src: src.into(),
80 uri: uri.into(),
81 cid: None,
82 val: val.into(),
83 neg: None,
84 cts: Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
85 exp: None,
86 sig: None,
87 }
88 }
89
90 /// Set the CID for a specific version of the resource.
91 pub fn with_cid(mut self, cid: impl Into<String>) -> Self {
92 self.cid = Some(cid.into());
93 self
94 }
95
96 /// Set this as a negation label.
97 pub fn negated(mut self) -> Self {
98 self.neg = Some(true);
99 self
100 }
101
102 /// Sign this label with a secp256k1 key.
103 ///
104 /// The signing process:
105 /// 1. Serialize the label without the `sig` field to DAG-CBOR
106 /// 2. Sign the bytes with the secp256k1 key
107 /// 3. Attach the signature
108 pub fn sign(mut self, signing_key: &SigningKey) -> Result<Self, LabelError> {
109 // Create unsigned version for signing
110 let unsigned = UnsignedLabel {
111 ver: self.ver,
112 src: &self.src,
113 uri: &self.uri,
114 cid: self.cid.as_deref(),
115 val: &self.val,
116 neg: self.neg,
117 cts: &self.cts,
118 exp: self.exp.as_deref(),
119 };
120
121 // Encode to DAG-CBOR
122 let cbor_bytes =
123 serde_ipld_dagcbor::to_vec(&unsigned).map_err(LabelError::Serialization)?;
124
125 // Sign with secp256k1
126 let signature: Signature = signing_key.sign(&cbor_bytes);
127 self.sig = Some(Bytes::copy_from_slice(&signature.to_bytes()));
128
129 Ok(self)
130 }
131}
132
133/// Unsigned label for serialization during signing.
134#[derive(Serialize)]
135#[serde(rename_all = "camelCase")]
136struct UnsignedLabel<'a> {
137 #[serde(skip_serializing_if = "Option::is_none")]
138 ver: Option<i64>,
139 src: &'a str,
140 uri: &'a str,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 cid: Option<&'a str>,
143 val: &'a str,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 neg: Option<bool>,
146 cts: &'a str,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 exp: Option<&'a str>,
149}
150
151/// Label-related errors.
152#[derive(Debug, thiserror::Error)]
153pub enum LabelError {
154 #[error("failed to serialize label: {0}")]
155 Serialization(#[from] serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>),
156
157 #[error("invalid signing key: {0}")]
158 InvalidKey(String),
159
160 #[error("database error: {0}")]
161 Database(#[from] sqlx::Error),
162}
163
164/// Label signer that holds the signing key and labeler DID.
165#[derive(Clone)]
166pub struct LabelSigner {
167 signing_key: SigningKey,
168 labeler_did: String,
169}
170
171impl LabelSigner {
172 /// Create a new label signer from a hex-encoded private key.
173 pub fn from_hex(hex_key: &str, labeler_did: impl Into<String>) -> Result<Self, LabelError> {
174 let key_bytes = hex::decode(hex_key)
175 .map_err(|e| LabelError::InvalidKey(format!("invalid hex: {e}")))?;
176 let signing_key = SigningKey::from_slice(&key_bytes)
177 .map_err(|e| LabelError::InvalidKey(format!("invalid key: {e}")))?;
178 Ok(Self {
179 signing_key,
180 labeler_did: labeler_did.into(),
181 })
182 }
183
184 /// Get the labeler DID.
185 pub fn did(&self) -> &str {
186 &self.labeler_did
187 }
188
189 /// Sign an arbitrary label.
190 pub fn sign_label(&self, label: Label) -> Result<Label, LabelError> {
191 label.sign(&self.signing_key)
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn test_label_creation() {
201 let label = Label::new(
202 "did:plc:test",
203 "at://did:plc:user/fm.plyr.track/abc123",
204 "copyright-violation",
205 );
206
207 assert_eq!(label.src, "did:plc:test");
208 assert_eq!(label.val, "copyright-violation");
209 assert!(label.sig.is_none());
210 }
211
212 #[test]
213 fn test_label_signing() {
214 // Generate a test key
215 let signing_key = SigningKey::random(&mut rand::thread_rng());
216
217 let label = Label::new(
218 "did:plc:test",
219 "at://did:plc:user/fm.plyr.track/abc123",
220 "copyright-violation",
221 )
222 .sign(&signing_key)
223 .unwrap();
224
225 assert!(label.sig.is_some());
226 assert_eq!(label.sig.as_ref().unwrap().len(), 64); // secp256k1 signature is 64 bytes
227 }
228}