at main 6.7 kB view raw
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}