An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(crypto): add PLC rotation op building, generalized verification, CID computation, and audit log parsing

Extend the crypto crate to support non-genesis PLC operations as the pure
functional core for key sovereignty (Phase 1 of plc-key-management).

New public API:
- compute_cid: CIDv1 (dag-cbor, sha-256) from signed op CBOR bytes
- build_did_plc_rotation_op: rotation ops with flexible params and external signer callback
- verify_plc_operation: generalized verifier for genesis and rotation ops, tries multiple authorized keys
- parse_audit_log / diff_audit_logs: parse plc.directory audit log JSON and find new operations
- PlcService promoted to public, new types: SignedPlcOperation, VerifiedPlcOp, AuditEntry

14 new tests (62 total), all passing. Existing API unchanged.

authored by

Malpercio and committed by
Tangled
22265e61 2d286dc8

+700 -8
+4 -2
crates/crypto/src/lib.rs
··· 10 10 decrypt_private_key, encrypt_private_key, generate_p256_keypair, DidKeyUri, P256Keypair, 11 11 }; 12 12 pub use plc::{ 13 - build_did_plc_genesis_op, build_did_plc_genesis_op_with_external_signer, verify_genesis_op, 14 - PlcGenesisOp, VerifiedGenesisOp, 13 + build_did_plc_genesis_op, build_did_plc_genesis_op_with_external_signer, 14 + build_did_plc_rotation_op, compute_cid, diff_audit_logs, parse_audit_log, verify_genesis_op, 15 + verify_plc_operation, AuditEntry, PlcGenesisOp, PlcService, SignedPlcOperation, 16 + VerifiedGenesisOp, VerifiedPlcOp, 15 17 }; 16 18 pub use shamir::{combine_shares, split_secret, ShamirShare};
+696 -6
crates/crypto/src/plc.rs
··· 63 63 // "rotationKeys" → 12 bytes 64 64 // "verificationMethods" → 19 bytes 65 65 66 - #[derive(Serialize, Deserialize, Clone)] 67 - struct PlcService { 68 - // "type" → 4 bytes 66 + /// A service entry in a PLC operation's `services` map. 67 + #[derive(Serialize, Deserialize, Clone, Debug)] 68 + pub struct PlcService { 69 + /// Service type, e.g. `"AtprotoPersonalDataServer"`. 69 70 #[serde(rename = "type")] 70 - service_type: String, 71 - // "endpoint" → 8 bytes 72 - endpoint: String, 71 + pub service_type: String, 72 + /// Service endpoint URL, e.g. `"https://relay.example.com"`. 73 + pub endpoint: String, 73 74 } 74 75 75 76 #[derive(Serialize)] ··· 111 112 verification_methods: BTreeMap<String, String>, 112 113 } 113 114 115 + // ── CID computation ───────────────────────────────────────────────────────── 116 + 117 + /// CIDv1 prefix for dag-cbor + sha-256: version(1) + codec(0x71) + hash(0x12) + length(0x20). 118 + const CIDV1_DAG_CBOR_SHA256_PREFIX: [u8; 4] = [0x01, 0x71, 0x12, 0x20]; 119 + 120 + /// Compute a CIDv1 (dag-cbor, sha-256) from signed operation CBOR bytes. 121 + /// 122 + /// Returns a multibase base32lower-encoded CID string (prefix `b`), matching 123 + /// the format used in did:plc `prev` fields. 124 + /// 125 + /// # Parameters 126 + /// - `signed_op_cbor`: DAG-CBOR encoded bytes of a signed PLC operation. 127 + pub fn compute_cid(signed_op_cbor: &[u8]) -> Result<String, CryptoError> { 128 + let hash = Sha256::digest(signed_op_cbor); 129 + let mut cid_bytes = Vec::with_capacity(36); 130 + cid_bytes.extend_from_slice(&CIDV1_DAG_CBOR_SHA256_PREFIX); 131 + cid_bytes.extend_from_slice(&hash); 132 + 133 + let encoded = base32_lowercase()?.encode(&cid_bytes); 134 + // Strip padding — multibase base32lower is unpadded 135 + let encoded = encoded.trim_end_matches('='); 136 + Ok(format!("b{encoded}")) 137 + } 138 + 114 139 // ── Public API ─────────────────────────────────────────────────────────────── 140 + 141 + /// The result of building a signed PLC operation (genesis or rotation). 142 + /// 143 + /// Contains the signed operation JSON (ready to POST to plc.directory) and 144 + /// the operation's CID (for use as `prev` in subsequent operations). 145 + #[non_exhaustive] 146 + #[derive(Debug)] 147 + pub struct SignedPlcOperation { 148 + /// The CID of this operation, for use as `prev` in the next operation. 149 + pub cid: String, 150 + /// The signed operation as a JSON string, ready to POST to plc.directory. 151 + pub signed_op_json: String, 152 + } 115 153 116 154 /// The result of verifying a client-submitted did:plc genesis operation. 117 155 /// ··· 294 332 ) 295 333 } 296 334 335 + /// Build and sign a did:plc rotation operation with an external signing callback. 336 + /// 337 + /// Unlike genesis ops, rotation ops have a non-null `prev` field and accept 338 + /// arbitrary rotation keys, verification methods, also-known-as, and services 339 + /// (the caller determines the new state, not this function). 340 + /// 341 + /// # Parameters 342 + /// - `prev_cid`: The CID of the previous operation in the chain (from [`compute_cid`]). 343 + /// - `rotation_keys`: The new set of rotation key `did:key:` URIs. 344 + /// - `verification_methods`: Map of method name → `did:key:` URI (e.g. `{"atproto": "did:key:z..."}`). 345 + /// - `also_known_as`: The new set of `alsoKnownAs` URIs (e.g. `["at://alice.example.com"]`). 346 + /// - `services`: Map of service name → [`PlcService`]. 347 + /// - `sign`: Callback receiving CBOR-encoded unsigned op bytes; must return raw 64-byte 348 + /// r‖s P-256 ECDSA signature bytes (big-endian, low-S canonical). 349 + /// 350 + /// # Errors 351 + /// Returns `CryptoError::PlcOperation` if `sign` returns `Err` or serialization fails. 352 + pub fn build_did_plc_rotation_op<F>( 353 + prev_cid: &str, 354 + rotation_keys: Vec<String>, 355 + verification_methods: BTreeMap<String, String>, 356 + also_known_as: Vec<String>, 357 + services: BTreeMap<String, PlcService>, 358 + sign: F, 359 + ) -> Result<SignedPlcOperation, CryptoError> 360 + where 361 + F: FnOnce(&[u8]) -> Result<Vec<u8>, CryptoError>, 362 + { 363 + let unsigned_op = UnsignedPlcOp { 364 + prev: Some(prev_cid.to_string()), 365 + op_type: "plc_operation".to_string(), 366 + services: services.clone(), 367 + also_known_as: also_known_as.clone(), 368 + rotation_keys: rotation_keys.clone(), 369 + verification_methods: verification_methods.clone(), 370 + }; 371 + 372 + // CBOR-encode the unsigned operation. 373 + let mut unsigned_cbor = Vec::new(); 374 + into_writer(&unsigned_op, &mut unsigned_cbor) 375 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?; 376 + 377 + // Sign the CBOR bytes. 378 + let sig_bytes = sign(&unsigned_cbor)?; 379 + if sig_bytes.len() != 64 { 380 + return Err(CryptoError::PlcOperation(format!( 381 + "signing callback returned {} bytes, expected 64", 382 + sig_bytes.len() 383 + ))); 384 + } 385 + let sig_str = URL_SAFE_NO_PAD.encode(&sig_bytes); 386 + 387 + // Build the signed operation. 388 + let signed_op = SignedPlcOp { 389 + sig: sig_str, 390 + prev: Some(prev_cid.to_string()), 391 + op_type: "plc_operation".to_string(), 392 + services, 393 + also_known_as, 394 + rotation_keys, 395 + verification_methods, 396 + }; 397 + 398 + // CBOR-encode the signed operation to compute CID. 399 + let mut signed_cbor = Vec::new(); 400 + into_writer(&signed_op, &mut signed_cbor) 401 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?; 402 + 403 + let cid = compute_cid(&signed_cbor)?; 404 + 405 + // JSON-serialize the signed operation. 406 + let signed_op_json = serde_json::to_string(&signed_op) 407 + .map_err(|e| CryptoError::PlcOperation(format!("json serialize signed op: {e}")))?; 408 + 409 + Ok(SignedPlcOperation { 410 + cid, 411 + signed_op_json, 412 + }) 413 + } 414 + 415 + /// The result of verifying a signed PLC operation (genesis or rotation). 416 + /// 417 + /// Returned by [`verify_plc_operation`]. Fields are extracted from the verified 418 + /// signed op; the caller uses them for semantic validation and DID document 419 + /// construction. 420 + #[non_exhaustive] 421 + pub struct VerifiedPlcOp { 422 + /// The derived DID. `Some` for genesis ops (derived from signed CBOR), 423 + /// `None` for rotation ops (caller provides the DID from context). 424 + pub did: Option<String>, 425 + /// The CID of this operation. 426 + pub cid: String, 427 + /// The `prev` field: `None` for genesis, `Some(cid)` for rotation. 428 + pub prev: Option<String>, 429 + /// Full `rotationKeys` array from the op. 430 + pub rotation_keys: Vec<String>, 431 + /// Full `alsoKnownAs` array from the op. 432 + pub also_known_as: Vec<String>, 433 + /// Full `verificationMethods` map from the op. 434 + pub verification_methods: BTreeMap<String, String>, 435 + /// Full `services` map from the op. 436 + pub services: BTreeMap<String, PlcService>, 437 + } 438 + 439 + /// Verify a signed PLC operation (genesis or rotation). 440 + /// 441 + /// Parses `signed_op_json`, reconstructs the unsigned operation with DAG-CBOR 442 + /// canonical field ordering, and verifies the ECDSA-SHA256 signature against 443 + /// each key in `authorized_rotation_keys` until one succeeds. 444 + /// 445 + /// # Parameters 446 + /// - `signed_op_json`: JSON-encoded signed PLC operation. 447 + /// - `authorized_rotation_keys`: The set of `did:key:` URIs authorized to sign 448 + /// this operation. For genesis ops, these come from the op itself; for rotation 449 + /// ops, they come from the previous operation's state. 450 + /// 451 + /// # Errors 452 + /// Returns `CryptoError::PlcOperation` if no authorized key verifies the 453 + /// signature, or for any parse/format/cryptographic failure. 454 + pub fn verify_plc_operation( 455 + signed_op_json: &str, 456 + authorized_rotation_keys: &[DidKeyUri], 457 + ) -> Result<VerifiedPlcOp, CryptoError> { 458 + if authorized_rotation_keys.is_empty() { 459 + return Err(CryptoError::PlcOperation( 460 + "authorized_rotation_keys must not be empty".to_string(), 461 + )); 462 + } 463 + 464 + // Parse the signed op, rejecting unknown fields. 465 + let signed_op: SignedPlcOp = serde_json::from_str(signed_op_json) 466 + .map_err(|e| CryptoError::PlcOperation(format!("invalid signed op JSON: {e}")))?; 467 + 468 + if signed_op.op_type != "plc_operation" { 469 + return Err(CryptoError::PlcOperation(format!( 470 + "expected type 'plc_operation', got '{}'", 471 + signed_op.op_type 472 + ))); 473 + } 474 + 475 + // Base64url-decode the signature. 476 + let sig_bytes = URL_SAFE_NO_PAD 477 + .decode(&signed_op.sig) 478 + .map_err(|e| CryptoError::PlcOperation(format!("invalid sig base64url: {e}")))?; 479 + let signature = Signature::try_from(sig_bytes.as_slice()) 480 + .map_err(|e| CryptoError::PlcOperation(format!("invalid ECDSA signature bytes: {e}")))?; 481 + 482 + // Reconstruct the unsigned operation. 483 + let unsigned_op = UnsignedPlcOp { 484 + prev: signed_op.prev.clone(), 485 + op_type: signed_op.op_type.clone(), 486 + services: signed_op.services.clone(), 487 + also_known_as: signed_op.also_known_as.clone(), 488 + rotation_keys: signed_op.rotation_keys.clone(), 489 + verification_methods: signed_op.verification_methods.clone(), 490 + }; 491 + 492 + let mut unsigned_cbor = Vec::new(); 493 + into_writer(&unsigned_op, &mut unsigned_cbor) 494 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?; 495 + 496 + // Try each authorized rotation key until one verifies the signature. 497 + let mut last_error = String::new(); 498 + for key in authorized_rotation_keys { 499 + match verify_signature_with_key(key, &unsigned_cbor, &signature) { 500 + Ok(()) => { 501 + // Signature verified — compute DID and CID. 502 + let mut signed_cbor = Vec::new(); 503 + into_writer(&signed_op, &mut signed_cbor).map_err(|e| { 504 + CryptoError::PlcOperation(format!("cbor encode signed op: {e}")) 505 + })?; 506 + 507 + let cid = compute_cid(&signed_cbor)?; 508 + 509 + // DID is only derivable from genesis ops (prev == None). 510 + let did = if signed_op.prev.is_none() { 511 + let hash = Sha256::digest(&signed_cbor); 512 + let encoded = base32_lowercase()?.encode(hash.as_ref()); 513 + Some(format!("did:plc:{}", &encoded[..24])) 514 + } else { 515 + None 516 + }; 517 + 518 + return Ok(VerifiedPlcOp { 519 + did, 520 + cid, 521 + prev: signed_op.prev, 522 + rotation_keys: signed_op.rotation_keys, 523 + also_known_as: signed_op.also_known_as, 524 + verification_methods: signed_op.verification_methods, 525 + services: signed_op.services, 526 + }); 527 + } 528 + Err(e) => { 529 + last_error = e; 530 + } 531 + } 532 + } 533 + 534 + Err(CryptoError::PlcOperation(format!( 535 + "no authorized rotation key verified the signature: {last_error}" 536 + ))) 537 + } 538 + 539 + /// Parse a did:key URI into a P-256 VerifyingKey and verify a signature. 540 + /// Returns Ok(()) on success, Err(message) on failure. 541 + fn verify_signature_with_key( 542 + key: &DidKeyUri, 543 + message: &[u8], 544 + signature: &Signature, 545 + ) -> Result<(), String> { 546 + let key_str = key 547 + .0 548 + .strip_prefix("did:key:") 549 + .ok_or_else(|| "rotation key missing did:key: prefix".to_string())?; 550 + let (_, multikey_bytes) = 551 + multibase::decode(key_str).map_err(|e| format!("decode rotation key multibase: {e}"))?; 552 + if multikey_bytes.get(..2) != Some(P256_MULTICODEC_PREFIX) { 553 + return Err("rotation key is not a P-256 key (wrong multicodec prefix)".to_string()); 554 + } 555 + let verifying_key = VerifyingKey::from_sec1_bytes(&multikey_bytes[2..]) 556 + .map_err(|e| format!("invalid P-256 public key: {e}"))?; 557 + verifying_key 558 + .verify(message, signature) 559 + .map_err(|e| format!("signature verification failed: {e}")) 560 + } 561 + 562 + // ── Audit log types ───────────────────────────────────────────────────────── 563 + 564 + /// A single entry from a plc.directory audit log. 565 + /// 566 + /// Returned by [`parse_audit_log`]. The `operation` field contains the raw 567 + /// signed PLC operation as a JSON value; use [`verify_plc_operation`] to 568 + /// validate it cryptographically. 569 + #[non_exhaustive] 570 + #[derive(Debug, Clone, Serialize, Deserialize)] 571 + pub struct AuditEntry { 572 + /// The DID this operation belongs to. 573 + pub did: String, 574 + /// The CID of this operation. 575 + pub cid: String, 576 + /// ISO 8601 timestamp when plc.directory received this operation. 577 + #[serde(rename = "createdAt")] 578 + pub created_at: String, 579 + /// Whether plc.directory considers this operation invalidated. 580 + pub nullified: bool, 581 + /// The raw signed PLC operation. 582 + pub operation: serde_json::Value, 583 + } 584 + 585 + /// Parse a plc.directory audit log JSON response into a list of entries. 586 + /// 587 + /// # Parameters 588 + /// - `json`: The JSON response body from `GET https://plc.directory/{did}/log/audit`. 589 + /// 590 + /// # Errors 591 + /// Returns `CryptoError::PlcOperation` if the JSON cannot be parsed. 592 + pub fn parse_audit_log(json: &str) -> Result<Vec<AuditEntry>, CryptoError> { 593 + serde_json::from_str(json) 594 + .map_err(|e| CryptoError::PlcOperation(format!("parse audit log: {e}"))) 595 + } 596 + 597 + /// Find operations in `current` that are not present in `cached`, by CID. 598 + /// 599 + /// Returns the new entries in the order they appear in `current`. 600 + pub fn diff_audit_logs(cached: &[AuditEntry], current: &[AuditEntry]) -> Vec<AuditEntry> { 601 + let cached_cids: std::collections::HashSet<&str> = 602 + cached.iter().map(|e| e.cid.as_str()).collect(); 603 + current 604 + .iter() 605 + .filter(|e| !cached_cids.contains(e.cid.as_str())) 606 + .cloned() 607 + .collect() 608 + } 609 + 297 610 /// Verify a client-submitted did:plc signed genesis operation. 298 611 /// 299 612 /// Parses `signed_op_json` into a [`SignedPlcOp`] (rejecting unknown fields), ··· 823 1136 assert_eq!( 824 1137 verified.did, result.did, 825 1138 "verified DID must match the DID returned by the builder" 1139 + ); 1140 + } 1141 + 1142 + // ── compute_cid tests ───────────────────────────────────────────────── 1143 + 1144 + /// CID starts with multibase prefix 'b' (base32lower) and contains only [a-z2-7] 1145 + #[test] 1146 + fn compute_cid_returns_valid_multibase_format() { 1147 + let (_, _, _, op) = make_genesis_op(); 1148 + // CBOR-encode the signed op to get the bytes compute_cid expects 1149 + let signed_op: serde_json::Value = 1150 + serde_json::from_str(&op.signed_op_json).expect("valid JSON"); 1151 + let mut cbor_bytes = Vec::new(); 1152 + into_writer(&signed_op, &mut cbor_bytes).expect("cbor encode"); 1153 + 1154 + let cid = compute_cid(&cbor_bytes).expect("compute_cid should succeed"); 1155 + 1156 + assert!( 1157 + cid.starts_with('b'), 1158 + "CID must start with multibase prefix 'b', got: {cid}" 1159 + ); 1160 + // After the 'b' prefix, all chars should be base32lower [a-z2-7] 1161 + assert!( 1162 + cid[1..] 1163 + .chars() 1164 + .all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)), 1165 + "CID body should only contain [a-z2-7], got: {cid}" 1166 + ); 1167 + } 1168 + 1169 + /// CID is deterministic: same bytes → same CID 1170 + #[test] 1171 + fn compute_cid_is_deterministic() { 1172 + let data = b"test data for CID computation"; 1173 + let cid1 = compute_cid(data).expect("first call"); 1174 + let cid2 = compute_cid(data).expect("second call"); 1175 + assert_eq!(cid1, cid2, "same input must produce same CID"); 1176 + } 1177 + 1178 + /// Different inputs produce different CIDs 1179 + #[test] 1180 + fn compute_cid_different_inputs_produce_different_cids() { 1181 + let cid1 = compute_cid(b"input one").expect("cid1"); 1182 + let cid2 = compute_cid(b"input two").expect("cid2"); 1183 + assert_ne!(cid1, cid2, "different inputs must produce different CIDs"); 1184 + } 1185 + 1186 + /// CID encodes a valid CIDv1 structure: version(1) + codec(0x71) + multihash(0x12, 0x20, 32 bytes) 1187 + #[test] 1188 + fn compute_cid_encodes_valid_cidv1_structure() { 1189 + let cid = compute_cid(b"test").expect("compute_cid"); 1190 + 1191 + // Decode: strip multibase prefix 'b', base32-decode the rest 1192 + let body = &cid[1..]; // strip 'b' 1193 + let cid_bytes = base32_lowercase() 1194 + .expect("base32 encoding") 1195 + .decode(body.as_bytes()) 1196 + .expect("base32 decode"); 1197 + 1198 + assert_eq!(cid_bytes[0], 0x01, "CID version must be 1"); 1199 + assert_eq!(cid_bytes[1], 0x71, "codec must be dag-cbor (0x71)"); 1200 + assert_eq!(cid_bytes[2], 0x12, "hash function must be sha-256 (0x12)"); 1201 + assert_eq!(cid_bytes[3], 0x20, "hash length must be 32 (0x20)"); 1202 + assert_eq!(cid_bytes.len(), 36, "CIDv1 with sha-256 should be 36 bytes"); 1203 + } 1204 + 1205 + // ── verify_plc_operation tests ───────────────────────────────────────── 1206 + 1207 + #[test] 1208 + fn verify_plc_operation_genesis_op() { 1209 + let (signing_key, op) = make_op_for_verify(); 1210 + let result = verify_plc_operation(&op.signed_op_json, &[signing_key]); 1211 + 1212 + assert!(result.is_ok(), "verify genesis op should succeed"); 1213 + let verified = result.unwrap(); 1214 + assert_eq!(verified.did, Some(op.did), "DID must match genesis DID"); 1215 + assert!(verified.prev.is_none(), "genesis op prev must be None"); 1216 + assert!(verified.cid.starts_with('b'), "CID must start with 'b'"); 1217 + } 1218 + 1219 + #[test] 1220 + fn verify_plc_operation_rotation_op() { 1221 + let (signing_key, private_key_bytes, _genesis, prev_cid) = make_genesis_for_rotation(); 1222 + 1223 + let field_bytes: FieldBytes = private_key_bytes.into(); 1224 + let sk = SigningKey::from_bytes(&field_bytes).expect("valid key"); 1225 + 1226 + let mut verification_methods = BTreeMap::new(); 1227 + verification_methods.insert("atproto".to_string(), signing_key.0.clone()); 1228 + let mut services = BTreeMap::new(); 1229 + services.insert( 1230 + "atproto_pds".to_string(), 1231 + PlcService { 1232 + service_type: "AtprotoPersonalDataServer".to_string(), 1233 + endpoint: "https://relay.example.com".to_string(), 1234 + }, 1235 + ); 1236 + 1237 + let rotation = build_did_plc_rotation_op( 1238 + &prev_cid, 1239 + vec![signing_key.0.clone()], 1240 + verification_methods, 1241 + vec!["at://alice.example.com".to_string()], 1242 + services, 1243 + |data| { 1244 + let sig: Signature = Signer::sign(&sk, data); 1245 + Ok(sig.to_bytes().to_vec()) 1246 + }, 1247 + ) 1248 + .expect("rotation op"); 1249 + 1250 + // Verify the rotation op with the signing key as authorized 1251 + let verified = verify_plc_operation(&rotation.signed_op_json, &[signing_key.clone()]) 1252 + .expect("verify rotation op"); 1253 + 1254 + assert!(verified.did.is_none(), "rotation op DID must be None"); 1255 + assert_eq!( 1256 + verified.prev.as_deref(), 1257 + Some(prev_cid.as_str()), 1258 + "prev must be the genesis CID" 1259 + ); 1260 + assert_eq!(verified.cid, rotation.cid, "CID must match builder CID"); 1261 + } 1262 + 1263 + #[test] 1264 + fn verify_plc_operation_rejects_wrong_key() { 1265 + let (_, op) = make_op_for_verify(); 1266 + let wrong_kp = generate_p256_keypair().expect("wrong keypair"); 1267 + 1268 + let result = verify_plc_operation(&op.signed_op_json, &[wrong_kp.key_id]); 1269 + assert!( 1270 + matches!(result, Err(CryptoError::PlcOperation(_))), 1271 + "wrong key must fail" 1272 + ); 1273 + } 1274 + 1275 + #[test] 1276 + fn verify_plc_operation_tries_multiple_keys() { 1277 + let (signing_key, op) = make_op_for_verify(); 1278 + let wrong_kp = generate_p256_keypair().expect("wrong keypair"); 1279 + 1280 + // Correct key is second in the list — should still succeed 1281 + let result = verify_plc_operation(&op.signed_op_json, &[wrong_kp.key_id, signing_key]); 1282 + assert!(result.is_ok(), "should succeed when correct key is in list"); 1283 + } 1284 + 1285 + #[test] 1286 + fn verify_plc_operation_rejects_empty_key_list() { 1287 + let (_, op) = make_op_for_verify(); 1288 + let result = verify_plc_operation(&op.signed_op_json, &[]); 1289 + assert!( 1290 + matches!(result, Err(CryptoError::PlcOperation(ref msg)) if msg.contains("must not be empty")), 1291 + "empty key list must fail" 1292 + ); 1293 + } 1294 + 1295 + // ── audit log tests ─────────────────────────────────────────────────── 1296 + 1297 + fn sample_audit_log_json() -> String { 1298 + serde_json::to_string(&serde_json::json!([ 1299 + { 1300 + "did": "did:plc:abc123", 1301 + "cid": "bafyreiabc", 1302 + "createdAt": "2026-01-01T00:00:00.000Z", 1303 + "nullified": false, 1304 + "operation": { 1305 + "type": "plc_operation", 1306 + "prev": null, 1307 + "sig": "dGVzdA", 1308 + "rotationKeys": [], 1309 + "verificationMethods": {}, 1310 + "alsoKnownAs": [], 1311 + "services": {} 1312 + } 1313 + }, 1314 + { 1315 + "did": "did:plc:abc123", 1316 + "cid": "bafyreibcd", 1317 + "createdAt": "2026-01-02T00:00:00.000Z", 1318 + "nullified": false, 1319 + "operation": { 1320 + "type": "plc_operation", 1321 + "prev": "bafyreiabc", 1322 + "sig": "dGVzdDI", 1323 + "rotationKeys": [], 1324 + "verificationMethods": {}, 1325 + "alsoKnownAs": [], 1326 + "services": {} 1327 + } 1328 + } 1329 + ])) 1330 + .unwrap() 1331 + } 1332 + 1333 + #[test] 1334 + fn parse_audit_log_returns_correct_entries() { 1335 + let json = sample_audit_log_json(); 1336 + let entries = parse_audit_log(&json).expect("parse should succeed"); 1337 + 1338 + assert_eq!(entries.len(), 2); 1339 + assert_eq!(entries[0].did, "did:plc:abc123"); 1340 + assert_eq!(entries[0].cid, "bafyreiabc"); 1341 + assert!(!entries[0].nullified); 1342 + assert_eq!(entries[1].cid, "bafyreibcd"); 1343 + assert_eq!(entries[1].created_at, "2026-01-02T00:00:00.000Z"); 1344 + } 1345 + 1346 + #[test] 1347 + fn parse_audit_log_rejects_invalid_json() { 1348 + let result = parse_audit_log("not json"); 1349 + assert!(matches!(result, Err(CryptoError::PlcOperation(_)))); 1350 + } 1351 + 1352 + #[test] 1353 + fn parse_audit_log_handles_empty_array() { 1354 + let entries = parse_audit_log("[]").expect("empty array"); 1355 + assert!(entries.is_empty()); 1356 + } 1357 + 1358 + #[test] 1359 + fn diff_audit_logs_finds_new_entries() { 1360 + let json = sample_audit_log_json(); 1361 + let all = parse_audit_log(&json).expect("parse"); 1362 + 1363 + // Cached has only the first entry; current has both 1364 + let cached = &all[..1]; 1365 + let new = diff_audit_logs(cached, &all); 1366 + 1367 + assert_eq!(new.len(), 1); 1368 + assert_eq!(new[0].cid, "bafyreibcd"); 1369 + } 1370 + 1371 + #[test] 1372 + fn diff_audit_logs_returns_empty_when_no_new_entries() { 1373 + let json = sample_audit_log_json(); 1374 + let all = parse_audit_log(&json).expect("parse"); 1375 + 1376 + let new = diff_audit_logs(&all, &all); 1377 + assert!(new.is_empty()); 1378 + } 1379 + 1380 + #[test] 1381 + fn diff_audit_logs_returns_all_when_cache_empty() { 1382 + let json = sample_audit_log_json(); 1383 + let all = parse_audit_log(&json).expect("parse"); 1384 + 1385 + let new = diff_audit_logs(&[], &all); 1386 + assert_eq!(new.len(), 2); 1387 + } 1388 + 1389 + // ── build_did_plc_rotation_op tests ───────────────────────────────────── 1390 + 1391 + /// Helper: build a genesis op and return everything needed to chain a rotation op. 1392 + fn make_genesis_for_rotation() -> (DidKeyUri, [u8; 32], PlcGenesisOp, String) { 1393 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 1394 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 1395 + let private_key_bytes = *signing_kp.private_key_bytes; 1396 + let genesis = build_did_plc_genesis_op( 1397 + &rotation_kp.key_id, 1398 + &signing_kp.key_id, 1399 + &private_key_bytes, 1400 + "alice.example.com", 1401 + "https://relay.example.com", 1402 + ) 1403 + .expect("genesis op"); 1404 + 1405 + // Compute the CID of the genesis op for use as prev 1406 + let signed_op: SignedPlcOp = 1407 + serde_json::from_str(&genesis.signed_op_json).expect("parse genesis"); 1408 + let mut cbor = Vec::new(); 1409 + into_writer(&signed_op, &mut cbor).expect("cbor encode"); 1410 + let prev_cid = compute_cid(&cbor).expect("compute cid"); 1411 + 1412 + (signing_kp.key_id, private_key_bytes, genesis, prev_cid) 1413 + } 1414 + 1415 + #[test] 1416 + fn rotation_op_has_non_null_prev() { 1417 + let (signing_key, private_key_bytes, _, prev_cid) = make_genesis_for_rotation(); 1418 + 1419 + let field_bytes: FieldBytes = private_key_bytes.into(); 1420 + let sk = SigningKey::from_bytes(&field_bytes).expect("valid key"); 1421 + 1422 + let mut verification_methods = BTreeMap::new(); 1423 + verification_methods.insert("atproto".to_string(), signing_key.0.clone()); 1424 + let mut services = BTreeMap::new(); 1425 + services.insert( 1426 + "atproto_pds".to_string(), 1427 + PlcService { 1428 + service_type: "AtprotoPersonalDataServer".to_string(), 1429 + endpoint: "https://relay.example.com".to_string(), 1430 + }, 1431 + ); 1432 + 1433 + let result = build_did_plc_rotation_op( 1434 + &prev_cid, 1435 + vec![signing_key.0.clone()], 1436 + verification_methods, 1437 + vec!["at://alice.example.com".to_string()], 1438 + services, 1439 + |data| { 1440 + let sig: Signature = Signer::sign(&sk, data); 1441 + Ok(sig.to_bytes().to_vec()) 1442 + }, 1443 + ) 1444 + .expect("rotation op"); 1445 + 1446 + let v: serde_json::Value = 1447 + serde_json::from_str(&result.signed_op_json).expect("valid JSON"); 1448 + assert_eq!( 1449 + v["prev"].as_str().unwrap(), 1450 + prev_cid, 1451 + "prev must match the provided CID" 1452 + ); 1453 + assert_eq!(v["type"], "plc_operation"); 1454 + } 1455 + 1456 + #[test] 1457 + fn rotation_op_cid_is_valid_multibase() { 1458 + let (signing_key, private_key_bytes, _, prev_cid) = make_genesis_for_rotation(); 1459 + 1460 + let field_bytes: FieldBytes = private_key_bytes.into(); 1461 + let sk = SigningKey::from_bytes(&field_bytes).expect("valid key"); 1462 + 1463 + let mut verification_methods = BTreeMap::new(); 1464 + verification_methods.insert("atproto".to_string(), signing_key.0.clone()); 1465 + let mut services = BTreeMap::new(); 1466 + services.insert( 1467 + "atproto_pds".to_string(), 1468 + PlcService { 1469 + service_type: "AtprotoPersonalDataServer".to_string(), 1470 + endpoint: "https://relay.example.com".to_string(), 1471 + }, 1472 + ); 1473 + 1474 + let result = build_did_plc_rotation_op( 1475 + &prev_cid, 1476 + vec![signing_key.0.clone()], 1477 + verification_methods, 1478 + vec!["at://alice.example.com".to_string()], 1479 + services, 1480 + |data| { 1481 + let sig: Signature = Signer::sign(&sk, data); 1482 + Ok(sig.to_bytes().to_vec()) 1483 + }, 1484 + ) 1485 + .expect("rotation op"); 1486 + 1487 + assert!( 1488 + result.cid.starts_with('b'), 1489 + "CID must start with multibase prefix 'b'" 1490 + ); 1491 + assert_ne!( 1492 + result.cid, prev_cid, 1493 + "rotation CID must differ from genesis CID" 1494 + ); 1495 + } 1496 + 1497 + #[test] 1498 + fn rotation_op_signing_error_propagates() { 1499 + let (signing_key, _, _, prev_cid) = make_genesis_for_rotation(); 1500 + 1501 + let mut verification_methods = BTreeMap::new(); 1502 + verification_methods.insert("atproto".to_string(), signing_key.0.clone()); 1503 + 1504 + let result = build_did_plc_rotation_op( 1505 + &prev_cid, 1506 + vec![signing_key.0.clone()], 1507 + verification_methods, 1508 + vec![], 1509 + BTreeMap::new(), 1510 + |_| Err(CryptoError::PlcOperation("SE unavailable".to_string())), 1511 + ); 1512 + 1513 + assert!( 1514 + matches!(result, Err(CryptoError::PlcOperation(msg)) if msg.contains("SE unavailable")), 1515 + "signing error must propagate" 826 1516 ); 827 1517 } 828 1518