An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(crypto): implement build_did_plc_genesis_op for did:plc genesis ops (MM-89)

authored by malpercio.dev and committed by

Tangled 807f6b54 89ba309f

+396
+1
crates/crypto/Cargo.toml
··· 18 18 ciborium = { workspace = true } 19 19 data-encoding = { workspace = true } 20 20 serde = { workspace = true } 21 + serde_json = { workspace = true } 21 22 sha2 = { workspace = true }
+2
crates/crypto/src/error.rs
··· 10 10 SecretSharing(String), 11 11 #[error("secret reconstruction failed: {0}")] 12 12 SecretReconstruction(String), 13 + #[error("plc operation failed: {0}")] 14 + PlcOperation(String), 13 15 }
+2
crates/crypto/src/lib.rs
··· 2 2 3 3 pub mod error; 4 4 pub mod keys; 5 + pub mod plc; 5 6 pub mod shamir; 6 7 7 8 pub use error::CryptoError; 8 9 pub use keys::{ 9 10 decrypt_private_key, encrypt_private_key, generate_p256_keypair, DidKeyUri, P256Keypair, 10 11 }; 12 + pub use plc::{build_did_plc_genesis_op, PlcGenesisOp}; 11 13 pub use shamir::{combine_shares, split_secret, ShamirShare};
+391
crates/crypto/src/plc.rs
··· 1 + // pattern: Functional Core 2 + // 3 + // Pure did:plc genesis operation builder. No I/O, no HTTP, no DB. 4 + // Builds a signed genesis operation from key material and identity fields, 5 + // derives the DID, and returns both for use by the relay's imperative shell. 6 + // 7 + // Algorithm: 8 + // 1. Construct UnsignedPlcOp (fields in DAG-CBOR canonical order) 9 + // 2. CBOR-encode unsigned op (ciborium) 10 + // 3. ECDSA-SHA256 sign the CBOR bytes (p256, RFC 6979 deterministic, low-S) 11 + // 4. base64url-encode the 64-byte r‖s signature 12 + // 5. Construct SignedPlcOp (same fields + sig) 13 + // 6. CBOR-encode signed op 14 + // 7. SHA-256 hash of signed CBOR 15 + // 8. base32-lowercase first 24 chars → DID suffix 16 + // 9. JSON-serialize signed op → signed_op_json 17 + // 18 + // References: 19 + // - did:plc spec v0.1: https://web.plc.directory/spec/v0.1/did-plc 20 + // - RFC 6979: deterministic ECDSA nonce generation 21 + // - DAG-CBOR: map keys sorted by byte-length then alphabetically 22 + 23 + use std::collections::BTreeMap; 24 + 25 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 26 + use ciborium::ser::into_writer; 27 + use p256::{ 28 + FieldBytes, 29 + ecdsa::{SigningKey, Signature, signature::Signer}, 30 + }; 31 + use serde::Serialize; 32 + use sha2::{Digest, Sha256}; 33 + 34 + use crate::{CryptoError, DidKeyUri}; 35 + 36 + /// The result of building a did:plc genesis operation. 37 + #[derive(Debug)] 38 + pub struct PlcGenesisOp { 39 + /// The derived DID, e.g. `"did:plc:abcdefghijklmnopqrstuvwx"`. 40 + /// Ready to use as the database primary key. 41 + pub did: String, 42 + /// The signed genesis operation as a JSON string. 43 + /// Ready to POST to plc.directory. 44 + pub signed_op_json: String, 45 + } 46 + 47 + // ── Internal serialization types ──────────────────────────────────────────── 48 + // 49 + // Field declaration order matches DAG-CBOR canonical ordering: 50 + // sort by UTF-8 byte length of the serialized key name, then alphabetically. 51 + // 52 + // For UnsignedPlcOp key lengths: 53 + // "prev" → 4 bytes 54 + // "type" → 4 bytes ("prev" < "type" alphabetically) 55 + // "services" → 8 bytes 56 + // "alsoKnownAs" → 11 bytes 57 + // "rotationKeys" → 12 bytes 58 + // "verificationMethods" → 19 bytes 59 + 60 + #[derive(Serialize, Clone)] 61 + struct PlcService { 62 + // "type" → 4 bytes 63 + #[serde(rename = "type")] 64 + service_type: String, 65 + // "endpoint" → 8 bytes 66 + endpoint: String, 67 + } 68 + 69 + #[derive(Serialize)] 70 + struct UnsignedPlcOp { 71 + prev: Option<String>, 72 + #[serde(rename = "type")] 73 + op_type: String, 74 + services: BTreeMap<String, PlcService>, 75 + #[serde(rename = "alsoKnownAs")] 76 + also_known_as: Vec<String>, 77 + #[serde(rename = "rotationKeys")] 78 + rotation_keys: Vec<String>, 79 + #[serde(rename = "verificationMethods")] 80 + verification_methods: BTreeMap<String, String>, 81 + } 82 + 83 + // For SignedPlcOp key lengths (includes "sig"): 84 + // "sig" → 3 bytes (shortest — comes first) 85 + // "prev" → 4 bytes 86 + // "type" → 4 bytes 87 + // "services" → 8 bytes 88 + // "alsoKnownAs" → 11 bytes 89 + // "rotationKeys" → 12 bytes 90 + // "verificationMethods" → 19 bytes 91 + 92 + #[derive(Serialize)] 93 + struct SignedPlcOp { 94 + sig: String, 95 + prev: Option<String>, 96 + #[serde(rename = "type")] 97 + op_type: String, 98 + services: BTreeMap<String, PlcService>, 99 + #[serde(rename = "alsoKnownAs")] 100 + also_known_as: Vec<String>, 101 + #[serde(rename = "rotationKeys")] 102 + rotation_keys: Vec<String>, 103 + #[serde(rename = "verificationMethods")] 104 + verification_methods: BTreeMap<String, String>, 105 + } 106 + 107 + // ── Public API ─────────────────────────────────────────────────────────────── 108 + 109 + /// Build and sign a did:plc genesis operation, returning the signed operation 110 + /// JSON and the derived DID. 111 + /// 112 + /// # Parameters 113 + /// - `rotation_key`: The user's device key (highest-priority rotation key). Placed at `rotationKeys[0]`. 114 + /// - `signing_key`: The relay's signing key. Placed at `rotationKeys[1]` and `verificationMethods.atproto`. 115 + /// - `signing_private_key`: Raw 32-byte P-256 private key scalar for `signing_key`. 116 + /// - `handle`: The account handle, e.g. `"alice.example.com"`. Stored as `"at://alice.example.com"` in `alsoKnownAs`. 117 + /// - `service_endpoint`: The relay's public URL, e.g. `"https://relay.example.com"`. 118 + /// 119 + /// # Errors 120 + /// Returns `CryptoError::PlcOperation` if `signing_private_key` is not a valid P-256 scalar 121 + /// (e.g. all-zero bytes, or a value ≥ the curve order). 122 + pub fn build_did_plc_genesis_op( 123 + rotation_key: &DidKeyUri, 124 + signing_key: &DidKeyUri, 125 + signing_private_key: &[u8; 32], 126 + handle: &str, 127 + service_endpoint: &str, 128 + ) -> Result<PlcGenesisOp, CryptoError> { 129 + // Step 1: Construct signing key from raw scalar bytes. 130 + let field_bytes: FieldBytes = (*signing_private_key).into(); 131 + let sk = SigningKey::from_bytes(&field_bytes) 132 + .map_err(|e| CryptoError::PlcOperation(format!("invalid signing key: {e}")))?; 133 + 134 + // Step 2: Build the unsigned operation. 135 + let mut verification_methods = BTreeMap::new(); 136 + verification_methods.insert("atproto".to_string(), signing_key.0.clone()); 137 + 138 + let mut services = BTreeMap::new(); 139 + services.insert( 140 + "atproto_pds".to_string(), 141 + PlcService { 142 + service_type: "AtprotoPersonalDataServer".to_string(), 143 + endpoint: service_endpoint.to_string(), 144 + }, 145 + ); 146 + 147 + let unsigned_op = UnsignedPlcOp { 148 + prev: None, 149 + op_type: "plc_operation".to_string(), 150 + services: services.clone(), 151 + also_known_as: vec![format!("at://{handle}")], 152 + rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()], 153 + verification_methods: verification_methods.clone(), 154 + }; 155 + 156 + // Step 3: CBOR-encode the unsigned operation. 157 + let mut unsigned_cbor = Vec::new(); 158 + into_writer(&unsigned_op, &mut unsigned_cbor) 159 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?; 160 + 161 + // Step 4: ECDSA-SHA256 sign (RFC 6979 deterministic, low-S canonical). 162 + // Signer::sign internally hashes with SHA-256 before signing. 163 + let sig: Signature = sk.sign(&unsigned_cbor); 164 + let sig_bytes = sig.to_bytes(); 165 + 166 + // Step 5: base64url-encode the 64-byte r‖s signature (no padding). 167 + let sig_str = URL_SAFE_NO_PAD.encode(&sig_bytes[..]); 168 + 169 + // Step 6: Build the signed operation (same fields + sig). 170 + let signed_op = SignedPlcOp { 171 + sig: sig_str, 172 + prev: None, 173 + op_type: "plc_operation".to_string(), 174 + services, 175 + also_known_as: vec![format!("at://{handle}")], 176 + rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()], 177 + verification_methods, 178 + }; 179 + 180 + // Step 7: CBOR-encode the signed operation. 181 + let mut signed_cbor = Vec::new(); 182 + into_writer(&signed_op, &mut signed_cbor) 183 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?; 184 + 185 + // Step 8: SHA-256 hash of the signed CBOR. 186 + let hash = Sha256::digest(&signed_cbor); 187 + 188 + // Step 9: base32-lowercase, take first 24 characters. 189 + let base32_encoding = { 190 + let mut spec = data_encoding::Specification::new(); 191 + spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567"); 192 + spec.encoding() 193 + .map_err(|e| CryptoError::PlcOperation(format!("build base32 encoding: {e}")))? 194 + }; 195 + let encoded = base32_encoding.encode(hash.as_ref()); 196 + let did = format!("did:plc:{}", &encoded[..24]); 197 + 198 + // Step 10: JSON-serialize the signed operation. 199 + let signed_op_json = serde_json::to_string(&signed_op) 200 + .map_err(|e| CryptoError::PlcOperation(format!("json serialize signed op: {e}")))?; 201 + 202 + Ok(PlcGenesisOp { did, signed_op_json }) 203 + } 204 + 205 + // ── Tests ──────────────────────────────────────────────────────────────────── 206 + 207 + #[cfg(test)] 208 + mod tests { 209 + use super::*; 210 + use crate::generate_p256_keypair; 211 + 212 + /// Generates two test keypairs and calls build_did_plc_genesis_op with them. 213 + /// Returns (rotation_key, signing_key, private_key_bytes, result). 214 + fn make_genesis_op() -> (DidKeyUri, DidKeyUri, [u8; 32], PlcGenesisOp) { 215 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 216 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 217 + let private_key_bytes = *signing_kp.private_key_bytes; 218 + let result = build_did_plc_genesis_op( 219 + &rotation_kp.key_id, 220 + &signing_kp.key_id, 221 + &private_key_bytes, 222 + "alice.example.com", 223 + "https://relay.example.com", 224 + ) 225 + .expect("genesis op should succeed"); 226 + (rotation_kp.key_id, signing_kp.key_id, private_key_bytes, result) 227 + } 228 + 229 + /// MM-89.AC1.1: did matches ^did:plc:[a-z2-7]{24}$ 230 + #[test] 231 + fn did_matches_expected_format() { 232 + let (_, _, _, op) = make_genesis_op(); 233 + assert!( 234 + op.did.starts_with("did:plc:"), 235 + "DID should start with 'did:plc:'" 236 + ); 237 + let suffix = op.did.strip_prefix("did:plc:").unwrap(); 238 + assert_eq!(suffix.len(), 24, "DID suffix should be 24 chars"); 239 + assert!( 240 + suffix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)), 241 + "DID suffix should only contain [a-z2-7], got: {suffix}" 242 + ); 243 + } 244 + 245 + /// MM-89.AC1.2: signed_op_json contains all required fields with correct values 246 + #[test] 247 + fn signed_op_json_contains_required_fields() { 248 + let (_, _, _, op) = make_genesis_op(); 249 + let v: serde_json::Value = 250 + serde_json::from_str(&op.signed_op_json).expect("valid JSON"); 251 + 252 + assert_eq!(v["type"], "plc_operation", "type field"); 253 + assert!(v["rotationKeys"].is_array(), "rotationKeys is array"); 254 + assert!( 255 + v["verificationMethods"].is_object(), 256 + "verificationMethods is object" 257 + ); 258 + assert!(v["alsoKnownAs"].is_array(), "alsoKnownAs is array"); 259 + assert!(v["services"].is_object(), "services is object"); 260 + assert_eq!(v["prev"], serde_json::Value::Null, "prev is null"); 261 + assert!(v["sig"].is_string(), "sig is string"); 262 + } 263 + 264 + /// MM-89.AC1.3: rotation_key at rotationKeys[0]; signing_key at rotationKeys[1] and verificationMethods.atproto 265 + #[test] 266 + fn keys_placed_in_correct_positions() { 267 + let (rotation_key, signing_key, _, op) = make_genesis_op(); 268 + let v: serde_json::Value = 269 + serde_json::from_str(&op.signed_op_json).expect("valid JSON"); 270 + assert_eq!( 271 + v["rotationKeys"][0].as_str().unwrap(), 272 + rotation_key.0, 273 + "rotationKeys[0] should be rotation_key" 274 + ); 275 + assert_eq!( 276 + v["rotationKeys"][1].as_str().unwrap(), 277 + signing_key.0, 278 + "rotationKeys[1] should be signing_key" 279 + ); 280 + assert_eq!( 281 + v["verificationMethods"]["atproto"].as_str().unwrap(), 282 + signing_key.0, 283 + "verificationMethods.atproto should be signing_key" 284 + ); 285 + } 286 + 287 + /// MM-89.AC1.4: RFC 6979 determinism — same inputs produce same DID 288 + #[test] 289 + fn same_inputs_produce_same_did() { 290 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 291 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 292 + let private_key_bytes = *signing_kp.private_key_bytes; 293 + 294 + let op1 = build_did_plc_genesis_op( 295 + &rotation_kp.key_id, 296 + &signing_kp.key_id, 297 + &private_key_bytes, 298 + "alice.example.com", 299 + "https://relay.example.com", 300 + ) 301 + .expect("first call should succeed"); 302 + 303 + let op2 = build_did_plc_genesis_op( 304 + &rotation_kp.key_id, 305 + &signing_kp.key_id, 306 + &private_key_bytes, 307 + "alice.example.com", 308 + "https://relay.example.com", 309 + ) 310 + .expect("second call should succeed"); 311 + 312 + assert_eq!(op1.did, op2.did, "DID must be identical for same inputs"); 313 + assert_eq!( 314 + op1.signed_op_json, op2.signed_op_json, 315 + "signed_op_json must be identical for same inputs" 316 + ); 317 + } 318 + 319 + /// MM-89.AC1.5: Invalid signing key (all-zero scalar) returns CryptoError::PlcOperation 320 + #[test] 321 + fn invalid_signing_key_returns_error() { 322 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 323 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 324 + let zero_bytes = [0u8; 32]; // Zero scalar is invalid for P-256 325 + 326 + let result = build_did_plc_genesis_op( 327 + &rotation_kp.key_id, 328 + &signing_kp.key_id, 329 + &zero_bytes, 330 + "alice.example.com", 331 + "https://relay.example.com", 332 + ); 333 + 334 + assert!( 335 + matches!(result, Err(CryptoError::PlcOperation(_))), 336 + "Zero scalar should return CryptoError::PlcOperation, got: {result:?}" 337 + ); 338 + } 339 + 340 + /// MM-89.AC3.2: sig field is base64url (no padding) decoding to exactly 64 bytes 341 + #[test] 342 + fn sig_field_is_base64url_no_padding_and_64_bytes() { 343 + let (_, _, _, op) = make_genesis_op(); 344 + let v: serde_json::Value = 345 + serde_json::from_str(&op.signed_op_json).expect("valid JSON"); 346 + let sig_str = v["sig"].as_str().expect("sig is a string"); 347 + 348 + // No padding characters 349 + assert!( 350 + !sig_str.contains('='), 351 + "sig should not contain padding '=', got: {sig_str}" 352 + ); 353 + // Decodes to exactly 64 bytes 354 + let decoded = URL_SAFE_NO_PAD 355 + .decode(sig_str) 356 + .expect("sig should be valid base64url"); 357 + assert_eq!( 358 + decoded.len(), 359 + 64, 360 + "sig should decode to 64 bytes (r‖s), got {} bytes", 361 + decoded.len() 362 + ); 363 + } 364 + 365 + /// MM-89.AC3.3: alsoKnownAs contains at://{handle} 366 + #[test] 367 + fn also_known_as_contains_at_uri() { 368 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 369 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 370 + let private_key_bytes = *signing_kp.private_key_bytes; 371 + 372 + let op = build_did_plc_genesis_op( 373 + &rotation_kp.key_id, 374 + &signing_kp.key_id, 375 + &private_key_bytes, 376 + "alice.example.com", 377 + "https://relay.example.com", 378 + ) 379 + .expect("genesis op should succeed"); 380 + 381 + let v: serde_json::Value = 382 + serde_json::from_str(&op.signed_op_json).expect("valid JSON"); 383 + let also_known_as = v["alsoKnownAs"].as_array().expect("alsoKnownAs is array"); 384 + assert!( 385 + also_known_as 386 + .iter() 387 + .any(|e| e.as_str() == Some("at://alice.example.com")), 388 + "alsoKnownAs should contain 'at://alice.example.com', got: {also_known_as:?}" 389 + ); 390 + } 391 + }