forked from
smokesignal.events/atproto-plc
Rust and WASM did-method-plc tools and structures
1//! Operation types for did:plc (genesis, update, tombstone)
2
3use crate::crypto::{SigningKey, VerifyingKey};
4use crate::document::ServiceEndpoint;
5use crate::encoding::{base64url_decode, compute_cid, dag_cbor_encode};
6use crate::error::{PlcError, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Represents a PLC operation (genesis, update, or tombstone)
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(tag = "type")]
13pub enum Operation {
14 /// Standard PLC operation (genesis or update)
15 #[serde(rename = "plc_operation")]
16 PlcOperation {
17 /// Rotation keys (1-5 did:key strings)
18 #[serde(rename = "rotationKeys")]
19 rotation_keys: Vec<String>,
20
21 /// Verification methods (max 10 entries)
22 #[serde(rename = "verificationMethods")]
23 verification_methods: HashMap<String, String>,
24
25 /// Also-known-as URIs
26 #[serde(rename = "alsoKnownAs")]
27 also_known_as: Vec<String>,
28
29 /// Service endpoints
30 services: HashMap<String, ServiceEndpoint>,
31
32 /// Previous operation CID (null for genesis)
33 #[serde(skip_serializing_if = "Option::is_none")]
34 prev: Option<String>,
35
36 /// Base64url-encoded signature
37 sig: String,
38 },
39
40 /// Tombstone operation (marks DID as deleted)
41 #[serde(rename = "plc_tombstone")]
42 PlcTombstone {
43 /// Previous operation CID (never null for tombstone)
44 prev: String,
45
46 /// Base64url-encoded signature
47 sig: String,
48 },
49
50 /// Legacy create operation (for backwards compatibility)
51 #[serde(rename = "create")]
52 LegacyCreate {
53 /// Signing key (did:key format)
54 #[serde(rename = "signingKey")]
55 signing_key: String,
56
57 /// Recovery key (did:key format)
58 #[serde(rename = "recoveryKey")]
59 recovery_key: String,
60
61 /// Handle (e.g., "alice.bsky.social")
62 handle: String,
63
64 /// Service endpoint URL
65 service: String,
66
67 /// Previous operation CID
68 #[serde(skip_serializing_if = "Option::is_none")]
69 prev: Option<String>,
70
71 /// Base64url-encoded signature
72 sig: String,
73 },
74}
75
76impl Operation {
77 /// Create a new unsigned genesis operation
78 pub fn new_genesis(
79 rotation_keys: Vec<String>,
80 verification_methods: HashMap<String, String>,
81 also_known_as: Vec<String>,
82 services: HashMap<String, ServiceEndpoint>,
83 ) -> UnsignedOperation {
84 UnsignedOperation::PlcOperation {
85 rotation_keys,
86 verification_methods,
87 also_known_as,
88 services,
89 prev: None,
90 }
91 }
92
93 /// Create a new unsigned update operation
94 pub fn new_update(
95 rotation_keys: Vec<String>,
96 verification_methods: HashMap<String, String>,
97 also_known_as: Vec<String>,
98 services: HashMap<String, ServiceEndpoint>,
99 prev: String,
100 ) -> UnsignedOperation {
101 UnsignedOperation::PlcOperation {
102 rotation_keys,
103 verification_methods,
104 also_known_as,
105 services,
106 prev: Some(prev),
107 }
108 }
109
110 /// Create a new unsigned tombstone operation
111 pub fn new_tombstone(prev: String) -> UnsignedOperation {
112 UnsignedOperation::PlcTombstone { prev }
113 }
114
115 /// Get the previous operation CID, if any
116 pub fn prev(&self) -> Option<&str> {
117 match self {
118 Operation::PlcOperation { prev, .. } => prev.as_deref(),
119 Operation::PlcTombstone { prev, .. } => Some(prev),
120 Operation::LegacyCreate { prev, .. } => prev.as_deref(),
121 }
122 }
123
124 /// Get the signature as a base64url string
125 pub fn signature(&self) -> &str {
126 match self {
127 Operation::PlcOperation { sig, .. } => sig,
128 Operation::PlcTombstone { sig, .. } => sig,
129 Operation::LegacyCreate { sig, .. } => sig,
130 }
131 }
132
133 /// Check if this is a genesis operation (prev is None)
134 pub fn is_genesis(&self) -> bool {
135 self.prev().is_none()
136 }
137
138 /// Compute the CID of this operation
139 ///
140 /// # Errors
141 ///
142 /// Returns an error if DAG-CBOR encoding fails
143 pub fn cid(&self) -> Result<String> {
144 let encoded = dag_cbor_encode(self)?;
145 compute_cid(&encoded)
146 }
147
148 /// Verify the signature on this operation using the provided rotation keys
149 ///
150 /// # Errors
151 ///
152 /// Returns `PlcError::SignatureVerificationFailed` if verification fails
153 pub fn verify(&self, rotation_keys: &[VerifyingKey]) -> Result<()> {
154 if rotation_keys.is_empty() {
155 return Err(PlcError::InvalidRotationKeys(
156 "At least one rotation key is required for verification".to_string(),
157 ));
158 }
159
160 // Get the unsigned operation data
161 let unsigned_data = self.unsigned_data()?;
162
163 // Decode signature
164 let signature = base64url_decode(self.signature())?;
165
166 // Try to verify with each rotation key
167 let mut last_error = None;
168 for key in rotation_keys {
169 match key.verify(&unsigned_data, &signature) {
170 Ok(_) => return Ok(()), // Success!
171 Err(e) => last_error = Some(e),
172 }
173 }
174
175 // If we get here, none of the keys verified the signature
176 Err(last_error.unwrap_or(PlcError::SignatureVerificationFailed))
177 }
178
179 /// Get the unsigned data that was signed
180 fn unsigned_data(&self) -> Result<Vec<u8>> {
181 let unsigned = match self {
182 Operation::PlcOperation {
183 rotation_keys,
184 verification_methods,
185 also_known_as,
186 services,
187 prev,
188 ..
189 } => UnsignedOperation::PlcOperation {
190 rotation_keys: rotation_keys.clone(),
191 verification_methods: verification_methods.clone(),
192 also_known_as: also_known_as.clone(),
193 services: services.clone(),
194 prev: prev.clone(),
195 },
196 Operation::PlcTombstone { prev, .. } => UnsignedOperation::PlcTombstone {
197 prev: prev.clone(),
198 },
199 Operation::LegacyCreate {
200 signing_key,
201 recovery_key,
202 handle,
203 service,
204 prev,
205 ..
206 } => UnsignedOperation::LegacyCreate {
207 signing_key: signing_key.clone(),
208 recovery_key: recovery_key.clone(),
209 handle: handle.clone(),
210 service: service.clone(),
211 prev: prev.clone(),
212 },
213 };
214
215 dag_cbor_encode(&unsigned)
216 }
217
218 /// Get the rotation keys from this operation, if any
219 pub fn rotation_keys(&self) -> Option<&[String]> {
220 match self {
221 Operation::PlcOperation { rotation_keys, .. } => Some(rotation_keys),
222 _ => None,
223 }
224 }
225}
226
227/// An unsigned operation that needs to be signed
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(tag = "type")]
230pub enum UnsignedOperation {
231 /// Standard PLC operation (genesis or update)
232 #[serde(rename = "plc_operation")]
233 PlcOperation {
234 /// Rotation keys for signing future operations
235 #[serde(rename = "rotationKeys")]
236 rotation_keys: Vec<String>,
237
238 /// Verification methods for authentication
239 #[serde(rename = "verificationMethods")]
240 verification_methods: HashMap<String, String>,
241
242 /// Also-known-as URIs (aliases)
243 #[serde(rename = "alsoKnownAs")]
244 also_known_as: Vec<String>,
245
246 /// Service endpoints
247 services: HashMap<String, ServiceEndpoint>,
248
249 /// CID of previous operation (None for genesis)
250 #[serde(skip_serializing_if = "Option::is_none")]
251 prev: Option<String>,
252 },
253
254 /// Tombstone operation
255 #[serde(rename = "plc_tombstone")]
256 PlcTombstone {
257 /// CID of previous operation
258 prev: String,
259 },
260
261 /// Legacy create operation
262 #[serde(rename = "create")]
263 LegacyCreate {
264 /// Signing key for the DID
265 #[serde(rename = "signingKey")]
266 signing_key: String,
267
268 /// Recovery key for the DID
269 #[serde(rename = "recoveryKey")]
270 recovery_key: String,
271
272 /// Handle for the DID
273 handle: String,
274
275 /// Service endpoint
276 service: String,
277
278 /// CID of previous operation (None for genesis)
279 #[serde(skip_serializing_if = "Option::is_none")]
280 prev: Option<String>,
281 },
282}
283
284impl UnsignedOperation {
285 /// Sign this operation with the provided signing key
286 ///
287 /// # Errors
288 ///
289 /// Returns an error if signing or encoding fails
290 pub fn sign(self, key: &SigningKey) -> Result<Operation> {
291 // Serialize to DAG-CBOR
292 let data = dag_cbor_encode(&self)?;
293
294 // Sign the data
295 let signature = key.sign_base64url(&data)?;
296
297 // Create the signed operation
298 let operation = match self {
299 UnsignedOperation::PlcOperation {
300 rotation_keys,
301 verification_methods,
302 also_known_as,
303 services,
304 prev,
305 } => Operation::PlcOperation {
306 rotation_keys,
307 verification_methods,
308 also_known_as,
309 services,
310 prev,
311 sig: signature,
312 },
313 UnsignedOperation::PlcTombstone { prev } => Operation::PlcTombstone {
314 prev,
315 sig: signature,
316 },
317 UnsignedOperation::LegacyCreate {
318 signing_key,
319 recovery_key,
320 handle,
321 service,
322 prev,
323 } => Operation::LegacyCreate {
324 signing_key,
325 recovery_key,
326 handle,
327 service,
328 prev,
329 sig: signature,
330 },
331 };
332
333 Ok(operation)
334 }
335
336 /// Compute the CID of this unsigned operation
337 ///
338 /// This is used to derive the DID from the genesis operation
339 pub fn cid(&self) -> Result<String> {
340 let encoded = dag_cbor_encode(self)?;
341 compute_cid(&encoded)
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::crypto::SigningKey;
349
350 #[test]
351 fn test_genesis_operation() {
352 let key = SigningKey::generate_p256();
353 let did_key = key.to_did_key();
354
355 let unsigned = Operation::new_genesis(
356 vec![did_key.clone()],
357 HashMap::new(),
358 vec![],
359 HashMap::new(),
360 );
361
362 let signed = unsigned.sign(&key).unwrap();
363 assert!(signed.is_genesis());
364 assert_eq!(signed.prev(), None);
365 }
366
367 #[test]
368 fn test_update_operation() {
369 let key = SigningKey::generate_p256();
370 let did_key = key.to_did_key();
371
372 let unsigned = Operation::new_update(
373 vec![did_key],
374 HashMap::new(),
375 vec![],
376 HashMap::new(),
377 "bafyreib2rxk3rybk3aobmv5msrxগত7h4b4kfzxx4wxltyqu7e7vgq".to_string(),
378 );
379
380 let signed = unsigned.sign(&key).unwrap();
381 assert!(!signed.is_genesis());
382 assert!(signed.prev().is_some());
383 }
384
385 #[test]
386 fn test_tombstone_operation() {
387 let key = SigningKey::generate_p256();
388
389 let unsigned = Operation::new_tombstone(
390 "bafyreib2rxk3rybk3aobmv5msrxhgt7h4b4kfzxx4wxltyqu7e7vgq".to_string(),
391 );
392
393 let signed = unsigned.sign(&key).unwrap();
394 assert!(!signed.is_genesis());
395 assert!(signed.prev().is_some());
396 }
397
398 #[test]
399 fn test_sign_and_verify() {
400 let key = SigningKey::generate_p256();
401 let did_key = key.to_did_key();
402
403 let unsigned = Operation::new_genesis(
404 vec![did_key.clone()],
405 HashMap::new(),
406 vec![],
407 HashMap::new(),
408 );
409
410 let signed = unsigned.sign(&key).unwrap();
411
412 // Parse the rotation key to get verifying key
413 let verifying_key = VerifyingKey::from_did_key(&did_key).unwrap();
414
415 // Verify should succeed
416 assert!(signed.verify(&[verifying_key]).is_ok());
417
418 // Verify with wrong key should fail
419 let wrong_key = SigningKey::generate_p256();
420 let wrong_verifying_key = wrong_key.verifying_key();
421 assert!(signed.verify(&[wrong_verifying_key]).is_err());
422 }
423
424 #[test]
425 fn test_operation_cid() {
426 let key = SigningKey::generate_p256();
427 let did_key = key.to_did_key();
428
429 let unsigned = Operation::new_genesis(
430 vec![did_key],
431 HashMap::new(),
432 vec![],
433 HashMap::new(),
434 );
435
436 let signed = unsigned.sign(&key).unwrap();
437 let cid = signed.cid().unwrap();
438
439 // CID should start with 'b' (CIDv1 in base32)
440 assert!(cid.starts_with('b'));
441 }
442}