use anyhow::Result; use k256::ecdsa::{signature::Signer, Signature as K256Signature, SigningKey}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::cid::cid_for_cbor; #[allow(unused_imports)] use crate::did_key::verifying_key_to_multibase; /// Deterministic `did:plc:`. /// /// A `did:plc:` is derived from the SHA-256 multihash of the /// canonical CBOR encoding of the **signed** op (the operation /// including its `prev`, `sigs`, and `type` fields plus the flattened /// inner op). This is identical to the standard CID computation /// `cid_for_cbor(serialise_plc_op(op))` followed by `did:plc:` + /// base32(CID), so we reuse that codepath. /// /// Stable for a given (prev, sigs, op) triple. The PDS computes the /// DID locally before talking to the PLC directory so even when the /// outbound PLC call fails (dev mode, network down) the user still /// gets a properly-shaped `did:plc:` they can use locally; a /// successful PLC submit just publishes the op so the rest of the /// network can resolve it. /// /// See for the /// full specification; the relevant rule is §"DID generation". pub fn did_plc_from_op(op: &PlcOperation) -> Result { let buf = serialise_plc_op(op)?; let cid = cid_for_cbor(&buf)?; Ok(format!("did:plc:{}", cid)) } /// Canonical dag-cbor encoding of a PLC op. Used for both signing /// (the inner op only — `sigs` is computed on this payload) and /// DID generation (the full op including `sigs`). /// /// Field ordering matters: dag-cbor canonical encoding sorts map /// keys lexicographically, so the resulting byte string is /// deterministic for a semantically-equal op regardless of how /// the producer ordered its fields. pub fn serialise_plc_op(op: &PlcOperation) -> Result> { let value = match op { PlcOperation::Tombstone { prev } => json!({ "prev": prev, "type": "plc_tombstone", }), PlcOperation::Op { prev, sigs, op: inner, } => json!({ "type": inner.op_type, "identifier": inner.identifier, "rotationKeys": inner.rotation_keys, "verificationMethods": inner.verification_methods, "alsoKnownAs": inner.also_known_as, "services": inner.services, "prev": prev, "sigs": sigs, }), }; let mut buf = Vec::new(); ciborium::into_writer(&value, &mut buf)?; Ok(buf) } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum PlcOperation { #[serde(rename = "plc_tombstone")] Tombstone { prev: Option }, #[serde(rename = "plc_operation")] Op { prev: Option, sigs: Vec, #[serde(flatten)] op: PlcOpInner, }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlcOpInner { #[serde(rename = "type")] pub op_type: String, pub services: serde_json::Value, pub identifier: String, pub rotation_keys: Vec, pub verification_methods: serde_json::Value, pub also_known_as: Vec, } impl PlcOperation { pub fn create( handle: &str, signing_key: &SigningKey, rotation_key_pub_mb: &str, pds_endpoint: &str, ) -> Result { let inner = create_unsigned_op(handle, rotation_key_pub_mb, pds_endpoint); let sig = sign_op(signing_key, &inner)?; Ok(Self::Op { prev: None, sigs: vec![sig], op: inner, }) } } pub fn create_unsigned_op(handle: &str, rotation_key_pub_mb: &str, pds_endpoint: &str) -> PlcOpInner { PlcOpInner { op_type: "plc_operation".into(), identifier: handle.to_string(), rotation_keys: vec![rotation_key_pub_mb.to_string()], verification_methods: json!({ "atproto": format!("did:key:{}", rotation_key_pub_mb), }), also_known_as: vec![format!("at://{}", handle)], services: json!({ "atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": pds_endpoint, } }), } } pub fn sign_op(signing_key: &SigningKey, op: &PlcOpInner) -> Result { let canonical = json!({ "type": op.op_type, "identifier": op.identifier, "rotationKeys": op.rotation_keys, "verificationMethods": op.verification_methods, "alsoKnownAs": op.also_known_as, "services": op.services, }); let mut buf = Vec::new(); ciborium::into_writer(&canonical, &mut buf)?; let sig: K256Signature = signing_key.sign(&buf); Ok(hex::encode(sig.to_bytes())) } pub trait PlcOpSigner { fn sign(&self, op: &PlcOpInner) -> Result; } pub struct K256PlcOpSigner<'a>(pub &'a SigningKey); impl<'a> PlcOpSigner for K256PlcOpSigner<'a> { fn sign(&self, op: &PlcOpInner) -> Result { sign_op(self.0, op) } } pub fn op_cid(op: &Value) -> Result { let mut buf = Vec::new(); ciborium::into_writer(op, &mut buf)?; Ok(cid_for_cbor(&buf)?.to_string()) } #[cfg(test)] mod tests { use super::*; use k256::SecretKey; #[test] fn create_op_signs_and_contains_handle() { let sk = SecretKey::from_slice(&[3u8; 32]).unwrap(); let rot_mb = crate::did_key::pubkey_to_multibase(&sk.public_key()).unwrap(); let signing = SigningKey::from(sk); let op = PlcOperation::create( "alice.maarcadetweet.local", &signing, &rot_mb, "https://pds.example", ) .unwrap(); let serialized = serde_json::to_value(&op).unwrap(); let inner = serialized .get("op") .or_else(|| serialized.get("services").and_then(|_| Some(&serialized))) .unwrap(); let identifier = inner .get("identifier") .unwrap() .as_str() .unwrap(); assert_eq!(identifier, "alice.maarcadetweet.local"); assert!(serialized.get("sigs").is_some()); } /// `did_plc_from_op` must be deterministic for the same op. /// Two calls with the same args return the same DID. #[test] fn did_plc_is_deterministic() { let sk = SecretKey::from_slice(&[7u8; 32]).unwrap(); let rot_mb = crate::did_key::pubkey_to_multibase(&sk.public_key()).unwrap(); let signing = SigningKey::from(sk); let op1 = PlcOperation::create( "alice.maarcadetweet.local", &signing, &rot_mb, "https://pds.example", ) .unwrap(); let op2 = PlcOperation::create( "alice.maarcadetweet.local", &signing, &rot_mb, "https://pds.example", ) .unwrap(); let did1 = did_plc_from_op(&op1).unwrap(); let did2 = did_plc_from_op(&op2).unwrap(); assert_eq!(did1, did2, "DID must be deterministic for identical ops"); assert!(did1.starts_with("did:plc:")); // CID v1 with sha256 and base32-lower should produce a string // starting with "b" (the standard cid v1 prefix for sha256). let suffix = did1.strip_prefix("did:plc:").unwrap(); assert!(suffix.starts_with('b'), "expected CIDv1 prefix, got {suffix}"); } /// Different handles → different DIDs. #[test] fn did_plc_differs_per_handle() { let sk = SecretKey::from_slice(&[11u8; 32]).unwrap(); let rot_mb = crate::did_key::pubkey_to_multibase(&sk.public_key()).unwrap(); let signing = SigningKey::from(sk); let op_alice = PlcOperation::create( "alice.maarcadetweet.local", &signing, &rot_mb, "https://pds.example", ) .unwrap(); let op_bob = PlcOperation::create( "bob.maarcadetweet.local", &signing, &rot_mb, "https://pds.example", ) .unwrap(); let did_alice = did_plc_from_op(&op_alice).unwrap(); let did_bob = did_plc_from_op(&op_bob).unwrap(); assert_ne!(did_alice, did_bob, "different handles must yield different DIDs"); } /// Tombstone ops must also produce a `did:plc:` (the spec says /// `plc_tombstone` operations are valid signed ops whose CID is /// derived the same way). #[test] fn did_plc_tombstone_round_trip() { let tomb = PlcOperation::Tombstone { prev: None }; let did = did_plc_from_op(&tomb).unwrap(); assert!(did.starts_with("did:plc:")); } }