a simple rust terminal ui (tui) for setting up alternative plc rotation keys driven by: secure enclave hardware (not synced) or software-based keys (synced to icloud)
plc secure-enclave touchid icloud atproto
at main 898 lines 31 kB view raw
1use serde::{Deserialize, Serialize}; 2use std::collections::BTreeMap; 3 4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 5pub struct PlcService { 6 #[serde(rename = "type")] 7 pub service_type: String, 8 pub endpoint: String, 9} 10 11#[derive(Debug, Clone, Serialize, Deserialize)] 12#[serde(rename_all = "camelCase")] 13pub struct PlcOperation { 14 #[serde(rename = "type")] 15 pub op_type: String, 16 pub rotation_keys: Vec<String>, 17 pub verification_methods: BTreeMap<String, String>, 18 pub also_known_as: Vec<String>, 19 pub services: BTreeMap<String, PlcService>, 20 #[serde(skip_serializing_if = "Option::is_none")] 21 pub prev: Option<String>, 22 #[serde(skip_serializing_if = "Option::is_none")] 23 pub sig: Option<String>, 24} 25 26#[derive(Debug, Clone, Serialize, Deserialize)] 27#[serde(rename_all = "camelCase")] 28pub struct PlcState { 29 pub did: String, 30 pub rotation_keys: Vec<String>, 31 pub verification_methods: BTreeMap<String, String>, 32 pub also_known_as: Vec<String>, 33 pub services: BTreeMap<String, PlcService>, 34} 35 36/// Represents a single change in a PLC operation diff. 37#[derive(Debug, Clone)] 38pub struct ChangeEntry { 39 pub kind: String, // "added", "removed", "modified" 40 pub description: String, 41} 42 43/// Diff between two PLC states. 44#[derive(Debug, Clone)] 45pub struct OperationDiff { 46 pub changes: Vec<ChangeEntry>, 47} 48 49/// Serialize a PLC operation for signing (without sig field). 50/// Produces canonical DAG-CBOR with keys sorted by length then lexicographic. 51pub fn serialize_for_signing(op: &PlcOperation) -> anyhow::Result<Vec<u8>> { 52 let mut signing_op = op.clone(); 53 signing_op.sig = None; 54 // Serialize to JSON first, then re-serialize to DAG-CBOR via serde_json::Value. 55 // serde_ipld_dagcbor sorts map keys in DAG-CBOR canonical order when serializing 56 // from a serde_json::Value (which uses a BTreeMap internally). 57 let json_val = serde_json::to_value(&signing_op)?; 58 let bytes = serde_ipld_dagcbor::to_vec(&json_val)?; 59 Ok(bytes) 60} 61 62/// Serialize a signed PLC operation to canonical DAG-CBOR (for CID computation). 63pub fn serialize_to_dag_cbor(op: &PlcOperation) -> anyhow::Result<Vec<u8>> { 64 let json_val = serde_json::to_value(op)?; 65 let bytes = serde_ipld_dagcbor::to_vec(&json_val)?; 66 Ok(bytes) 67} 68 69/// Compute CIDv1 (dag-cbor + sha256) of a signed operation. 70pub fn compute_cid(op: &PlcOperation) -> anyhow::Result<String> { 71 use sha2::{Digest, Sha256}; 72 73 let bytes = serde_ipld_dagcbor::to_vec(op)?; 74 let hash = Sha256::digest(&bytes); 75 76 // CIDv1: version(1) + codec(dag-cbor=0x71) + multihash(sha256=0x12, len=0x20, digest) 77 let mut cid_bytes = Vec::new(); 78 // CID version 1 79 cid_bytes.push(0x01); 80 // dag-cbor codec 81 cid_bytes.push(0x71); 82 // sha2-256 multihash 83 cid_bytes.push(0x12); 84 cid_bytes.push(0x20); 85 cid_bytes.extend_from_slice(&hash); 86 87 // Encode as base32lower with 'b' prefix 88 let encoded = base32_encode(&cid_bytes); 89 Ok(format!("b{}", encoded)) 90} 91 92fn base32_encode(data: &[u8]) -> String { 93 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; 94 let mut result = String::new(); 95 let mut buffer: u64 = 0; 96 let mut bits = 0; 97 98 for &byte in data { 99 buffer = (buffer << 8) | byte as u64; 100 bits += 8; 101 while bits >= 5 { 102 bits -= 5; 103 result.push(ALPHABET[((buffer >> bits) & 0x1f) as usize] as char); 104 } 105 } 106 107 if bits > 0 { 108 buffer <<= 5 - bits; 109 result.push(ALPHABET[(buffer & 0x1f) as usize] as char); 110 } 111 112 result 113} 114 115/// Build an update operation from current state and desired changes. 116pub fn build_update_operation( 117 current_state: &PlcState, 118 prev_cid: &str, 119 new_rotation_keys: Option<Vec<String>>, 120 new_verification_methods: Option<BTreeMap<String, String>>, 121 new_also_known_as: Option<Vec<String>>, 122 new_services: Option<BTreeMap<String, PlcService>>, 123) -> PlcOperation { 124 PlcOperation { 125 op_type: "plc_operation".to_string(), 126 rotation_keys: new_rotation_keys.unwrap_or_else(|| current_state.rotation_keys.clone()), 127 verification_methods: new_verification_methods 128 .unwrap_or_else(|| current_state.verification_methods.clone()), 129 also_known_as: new_also_known_as 130 .unwrap_or_else(|| current_state.also_known_as.clone()), 131 services: new_services.unwrap_or_else(|| current_state.services.clone()), 132 prev: Some(prev_cid.to_string()), 133 sig: None, 134 } 135} 136 137/// Compute diff between current state and a proposed operation. 138pub fn compute_diff(current: &PlcState, proposed: &PlcOperation) -> OperationDiff { 139 let mut changes = Vec::new(); 140 141 // Compare rotation keys 142 if current.rotation_keys != proposed.rotation_keys { 143 for (i, key) in proposed.rotation_keys.iter().enumerate() { 144 if i >= current.rotation_keys.len() { 145 changes.push(ChangeEntry { 146 kind: "added".to_string(), 147 description: format!("rotationKeys[{}]: {}", i, truncate_key(key)), 148 }); 149 } else if current.rotation_keys[i] != *key { 150 changes.push(ChangeEntry { 151 kind: "modified".to_string(), 152 description: format!( 153 "rotationKeys[{}]: {} -> {}", 154 i, 155 truncate_key(&current.rotation_keys[i]), 156 truncate_key(key) 157 ), 158 }); 159 } 160 } 161 for i in proposed.rotation_keys.len()..current.rotation_keys.len() { 162 changes.push(ChangeEntry { 163 kind: "removed".to_string(), 164 description: format!( 165 "rotationKeys[{}]: {}", 166 i, 167 truncate_key(&current.rotation_keys[i]) 168 ), 169 }); 170 } 171 } 172 173 // Compare also_known_as 174 if current.also_known_as != proposed.also_known_as { 175 changes.push(ChangeEntry { 176 kind: "modified".to_string(), 177 description: format!( 178 "alsoKnownAs: {:?} -> {:?}", 179 current.also_known_as, proposed.also_known_as 180 ), 181 }); 182 } 183 184 // Compare verification methods 185 if current.verification_methods != proposed.verification_methods { 186 changes.push(ChangeEntry { 187 kind: "modified".to_string(), 188 description: "verificationMethods changed".to_string(), 189 }); 190 } 191 192 // Compare services 193 for (name, svc) in &proposed.services { 194 match current.services.get(name) { 195 Some(current_svc) if current_svc.endpoint != svc.endpoint => { 196 changes.push(ChangeEntry { 197 kind: "modified".to_string(), 198 description: format!("services.{}.endpoint: {} -> {}", name, current_svc.endpoint, svc.endpoint), 199 }); 200 } 201 None => { 202 changes.push(ChangeEntry { 203 kind: "added".to_string(), 204 description: format!("services.{}", name), 205 }); 206 } 207 _ => {} 208 } 209 } 210 211 if changes.is_empty() { 212 changes.push(ChangeEntry { 213 kind: "modified".to_string(), 214 description: "No visible changes".to_string(), 215 }); 216 } 217 218 OperationDiff { changes } 219} 220 221fn truncate_key(key: &str) -> String { 222 if key.len() > 30 { 223 format!("{}...", &key[..30]) 224 } else { 225 key.to_string() 226 } 227} 228 229#[cfg(test)] 230mod tests { 231 use super::*; 232 233 #[test] 234 fn test_serialize_for_signing_omits_sig() { 235 let op = PlcOperation { 236 op_type: "plc_operation".to_string(), 237 rotation_keys: vec!["did:key:z123".to_string()], 238 verification_methods: BTreeMap::new(), 239 also_known_as: vec![], 240 services: BTreeMap::new(), 241 prev: Some("bafytest".to_string()), 242 sig: Some("should_be_omitted".to_string()), 243 }; 244 245 let bytes = serialize_for_signing(&op).unwrap(); 246 // Deserialize back and check sig is absent 247 let val: serde_json::Value = 248 serde_ipld_dagcbor::from_slice(&bytes).unwrap(); 249 assert!(val.get("sig").is_none()); 250 } 251 252 #[test] 253 fn test_compute_cid_format() { 254 let op = PlcOperation { 255 op_type: "plc_operation".to_string(), 256 rotation_keys: vec!["did:key:z123".to_string()], 257 verification_methods: BTreeMap::new(), 258 also_known_as: vec![], 259 services: BTreeMap::new(), 260 prev: None, 261 sig: Some("testsig".to_string()), 262 }; 263 264 let cid = compute_cid(&op).unwrap(); 265 assert!(cid.starts_with("bafyrei"), "CID should start with bafyrei, got: {}", cid); 266 } 267 268 #[test] 269 fn test_build_update_operation() { 270 let state = PlcState { 271 did: "did:plc:test".to_string(), 272 rotation_keys: vec!["did:key:old".to_string()], 273 verification_methods: BTreeMap::new(), 274 also_known_as: vec!["at://test.bsky.social".to_string()], 275 services: BTreeMap::new(), 276 }; 277 278 let op = build_update_operation( 279 &state, 280 "bafytest", 281 Some(vec!["did:key:new".to_string(), "did:key:old".to_string()]), 282 None, 283 None, 284 None, 285 ); 286 287 assert_eq!(op.rotation_keys.len(), 2); 288 assert_eq!(op.rotation_keys[0], "did:key:new"); 289 assert_eq!(op.prev, Some("bafytest".to_string())); 290 assert!(op.sig.is_none()); 291 } 292 293 // --- Additional tests --- 294 295 #[test] 296 fn test_build_update_preserves_unchanged_fields() { 297 let mut vm = BTreeMap::new(); 298 vm.insert("atproto".to_string(), "did:key:zVeri".to_string()); 299 300 let mut services = BTreeMap::new(); 301 services.insert( 302 "atproto_pds".to_string(), 303 PlcService { 304 service_type: "AtprotoPersonalDataServer".to_string(), 305 endpoint: "https://pds.example.com".to_string(), 306 }, 307 ); 308 309 let state = PlcState { 310 did: "did:plc:test".to_string(), 311 rotation_keys: vec!["did:key:rot1".to_string()], 312 verification_methods: vm.clone(), 313 also_known_as: vec!["at://alice.test".to_string()], 314 services: services.clone(), 315 }; 316 317 // Only change rotation keys, rest should come from state 318 let op = build_update_operation( 319 &state, 320 "bafyprev", 321 Some(vec!["did:key:new".to_string()]), 322 None, 323 None, 324 None, 325 ); 326 327 assert_eq!(op.verification_methods, vm); 328 assert_eq!(op.also_known_as, vec!["at://alice.test"]); 329 assert_eq!(op.services.len(), 1); 330 assert_eq!(op.services["atproto_pds"].endpoint, "https://pds.example.com"); 331 assert_eq!(op.op_type, "plc_operation"); 332 } 333 334 #[test] 335 fn test_build_update_all_fields_changed() { 336 let state = PlcState { 337 did: "did:plc:test".to_string(), 338 rotation_keys: vec!["did:key:old".to_string()], 339 verification_methods: BTreeMap::new(), 340 also_known_as: vec![], 341 services: BTreeMap::new(), 342 }; 343 344 let mut new_vm = BTreeMap::new(); 345 new_vm.insert("atproto".to_string(), "did:key:zNewVeri".to_string()); 346 347 let mut new_svc = BTreeMap::new(); 348 new_svc.insert( 349 "atproto_pds".to_string(), 350 PlcService { 351 service_type: "AtprotoPersonalDataServer".to_string(), 352 endpoint: "https://new-pds.example.com".to_string(), 353 }, 354 ); 355 356 let op = build_update_operation( 357 &state, 358 "bafyprev", 359 Some(vec!["did:key:new".to_string()]), 360 Some(new_vm.clone()), 361 Some(vec!["at://new.handle".to_string()]), 362 Some(new_svc.clone()), 363 ); 364 365 assert_eq!(op.rotation_keys, vec!["did:key:new"]); 366 assert_eq!(op.verification_methods, new_vm); 367 assert_eq!(op.also_known_as, vec!["at://new.handle"]); 368 assert_eq!(op.services, new_svc); 369 } 370 371 #[test] 372 fn test_serialize_for_signing_roundtrip() { 373 let mut vm = BTreeMap::new(); 374 vm.insert("atproto".to_string(), "did:key:zVeri".to_string()); 375 376 let op = PlcOperation { 377 op_type: "plc_operation".to_string(), 378 rotation_keys: vec!["did:key:z1".to_string(), "did:key:z2".to_string()], 379 verification_methods: vm, 380 also_known_as: vec!["at://test.bsky.social".to_string()], 381 services: BTreeMap::new(), 382 prev: Some("bafytest".to_string()), 383 sig: None, 384 }; 385 386 let bytes = serialize_for_signing(&op).unwrap(); 387 388 // Should be valid CBOR that deserializes back 389 let val: serde_json::Value = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); 390 assert_eq!(val["type"], "plc_operation"); 391 assert!(val.get("sig").is_none()); 392 assert_eq!(val["rotationKeys"].as_array().unwrap().len(), 2); 393 } 394 395 #[test] 396 fn test_serialize_deterministic() { 397 let op = PlcOperation { 398 op_type: "plc_operation".to_string(), 399 rotation_keys: vec!["did:key:z1".to_string()], 400 verification_methods: BTreeMap::new(), 401 also_known_as: vec![], 402 services: BTreeMap::new(), 403 prev: Some("bafytest".to_string()), 404 sig: None, 405 }; 406 407 // Serialize twice, should get identical bytes 408 let bytes1 = serialize_for_signing(&op).unwrap(); 409 let bytes2 = serialize_for_signing(&op).unwrap(); 410 assert_eq!(bytes1, bytes2, "DAG-CBOR serialization should be deterministic"); 411 } 412 413 #[test] 414 fn test_compute_cid_deterministic() { 415 let op = PlcOperation { 416 op_type: "plc_operation".to_string(), 417 rotation_keys: vec!["did:key:z1".to_string()], 418 verification_methods: BTreeMap::new(), 419 also_known_as: vec![], 420 services: BTreeMap::new(), 421 prev: None, 422 sig: Some("sig123".to_string()), 423 }; 424 425 let cid1 = compute_cid(&op).unwrap(); 426 let cid2 = compute_cid(&op).unwrap(); 427 assert_eq!(cid1, cid2, "CID computation should be deterministic"); 428 } 429 430 #[test] 431 fn test_compute_cid_different_ops_different_cids() { 432 let op1 = PlcOperation { 433 op_type: "plc_operation".to_string(), 434 rotation_keys: vec!["did:key:z1".to_string()], 435 verification_methods: BTreeMap::new(), 436 also_known_as: vec![], 437 services: BTreeMap::new(), 438 prev: None, 439 sig: Some("sig1".to_string()), 440 }; 441 442 let op2 = PlcOperation { 443 op_type: "plc_operation".to_string(), 444 rotation_keys: vec!["did:key:z2".to_string()], // different key 445 verification_methods: BTreeMap::new(), 446 also_known_as: vec![], 447 services: BTreeMap::new(), 448 prev: None, 449 sig: Some("sig2".to_string()), 450 }; 451 452 let cid1 = compute_cid(&op1).unwrap(); 453 let cid2 = compute_cid(&op2).unwrap(); 454 assert_ne!(cid1, cid2); 455 } 456 457 #[test] 458 fn test_compute_cid_length() { 459 let op = PlcOperation { 460 op_type: "plc_operation".to_string(), 461 rotation_keys: vec![], 462 verification_methods: BTreeMap::new(), 463 also_known_as: vec![], 464 services: BTreeMap::new(), 465 prev: None, 466 sig: Some("s".to_string()), 467 }; 468 469 let cid = compute_cid(&op).unwrap(); 470 // CIDv1 with base32: 'b' prefix + base32(1 + 1 + 1 + 1 + 32 = 36 bytes) 471 // base32 of 36 bytes = ceil(36*8/5) = 58 chars 472 assert!(cid.len() > 50, "CID should be reasonably long: {}", cid); 473 assert!(cid.starts_with("b")); // base32lower prefix 474 } 475 476 #[test] 477 fn test_base32_encode_empty() { 478 assert_eq!(base32_encode(&[]), ""); 479 } 480 481 #[test] 482 fn test_base32_encode_known_vector() { 483 // RFC 4648 test vectors (lowercase) 484 assert_eq!(base32_encode(b"f"), "my"); 485 assert_eq!(base32_encode(b"fo"), "mzxq"); 486 assert_eq!(base32_encode(b"foo"), "mzxw6"); 487 assert_eq!(base32_encode(b"foob"), "mzxw6yq"); 488 assert_eq!(base32_encode(b"fooba"), "mzxw6ytb"); 489 assert_eq!(base32_encode(b"foobar"), "mzxw6ytboi"); 490 } 491 492 #[test] 493 fn test_compute_diff_no_changes() { 494 let state = PlcState { 495 did: "did:plc:test".to_string(), 496 rotation_keys: vec!["did:key:k1".to_string()], 497 verification_methods: BTreeMap::new(), 498 also_known_as: vec!["at://test".to_string()], 499 services: BTreeMap::new(), 500 }; 501 502 let op = PlcOperation { 503 op_type: "plc_operation".to_string(), 504 rotation_keys: vec!["did:key:k1".to_string()], 505 verification_methods: BTreeMap::new(), 506 also_known_as: vec!["at://test".to_string()], 507 services: BTreeMap::new(), 508 prev: Some("bafyprev".to_string()), 509 sig: None, 510 }; 511 512 let diff = compute_diff(&state, &op); 513 assert_eq!(diff.changes.len(), 1); 514 assert!(diff.changes[0].description.contains("No visible changes")); 515 } 516 517 #[test] 518 fn test_compute_diff_rotation_key_added() { 519 let state = PlcState { 520 did: "did:plc:test".to_string(), 521 rotation_keys: vec!["did:key:k1".to_string()], 522 verification_methods: BTreeMap::new(), 523 also_known_as: vec![], 524 services: BTreeMap::new(), 525 }; 526 527 let op = PlcOperation { 528 op_type: "plc_operation".to_string(), 529 rotation_keys: vec![ 530 "did:key:k1".to_string(), 531 "did:key:k2".to_string(), 532 ], 533 verification_methods: BTreeMap::new(), 534 also_known_as: vec![], 535 services: BTreeMap::new(), 536 prev: None, 537 sig: None, 538 }; 539 540 let diff = compute_diff(&state, &op); 541 let added: Vec<_> = diff.changes.iter().filter(|c| c.kind == "added").collect(); 542 assert_eq!(added.len(), 1); 543 assert!(added[0].description.contains("rotationKeys[1]")); 544 } 545 546 #[test] 547 fn test_compute_diff_rotation_key_removed() { 548 let state = PlcState { 549 did: "did:plc:test".to_string(), 550 rotation_keys: vec![ 551 "did:key:k1".to_string(), 552 "did:key:k2".to_string(), 553 "did:key:k3".to_string(), 554 ], 555 verification_methods: BTreeMap::new(), 556 also_known_as: vec![], 557 services: BTreeMap::new(), 558 }; 559 560 let op = PlcOperation { 561 op_type: "plc_operation".to_string(), 562 rotation_keys: vec!["did:key:k1".to_string()], 563 verification_methods: BTreeMap::new(), 564 also_known_as: vec![], 565 services: BTreeMap::new(), 566 prev: None, 567 sig: None, 568 }; 569 570 let diff = compute_diff(&state, &op); 571 let removed: Vec<_> = diff.changes.iter().filter(|c| c.kind == "removed").collect(); 572 assert_eq!(removed.len(), 2); 573 } 574 575 #[test] 576 fn test_compute_diff_rotation_key_modified() { 577 let state = PlcState { 578 did: "did:plc:test".to_string(), 579 rotation_keys: vec!["did:key:old".to_string()], 580 verification_methods: BTreeMap::new(), 581 also_known_as: vec![], 582 services: BTreeMap::new(), 583 }; 584 585 let op = PlcOperation { 586 op_type: "plc_operation".to_string(), 587 rotation_keys: vec!["did:key:new".to_string()], 588 verification_methods: BTreeMap::new(), 589 also_known_as: vec![], 590 services: BTreeMap::new(), 591 prev: None, 592 sig: None, 593 }; 594 595 let diff = compute_diff(&state, &op); 596 let modified: Vec<_> = diff.changes.iter().filter(|c| c.kind == "modified").collect(); 597 assert_eq!(modified.len(), 1); 598 assert!(modified[0].description.contains("rotationKeys[0]")); 599 } 600 601 #[test] 602 fn test_compute_diff_handle_changed() { 603 let state = PlcState { 604 did: "did:plc:test".to_string(), 605 rotation_keys: vec![], 606 verification_methods: BTreeMap::new(), 607 also_known_as: vec!["at://old.handle".to_string()], 608 services: BTreeMap::new(), 609 }; 610 611 let op = PlcOperation { 612 op_type: "plc_operation".to_string(), 613 rotation_keys: vec![], 614 verification_methods: BTreeMap::new(), 615 also_known_as: vec!["at://new.handle".to_string()], 616 services: BTreeMap::new(), 617 prev: None, 618 sig: None, 619 }; 620 621 let diff = compute_diff(&state, &op); 622 let aka_changes: Vec<_> = diff 623 .changes 624 .iter() 625 .filter(|c| c.description.contains("alsoKnownAs")) 626 .collect(); 627 assert_eq!(aka_changes.len(), 1); 628 } 629 630 #[test] 631 fn test_compute_diff_verification_methods_changed() { 632 let mut old_vm = BTreeMap::new(); 633 old_vm.insert("atproto".to_string(), "did:key:old".to_string()); 634 635 let mut new_vm = BTreeMap::new(); 636 new_vm.insert("atproto".to_string(), "did:key:new".to_string()); 637 638 let state = PlcState { 639 did: "did:plc:test".to_string(), 640 rotation_keys: vec![], 641 verification_methods: old_vm, 642 also_known_as: vec![], 643 services: BTreeMap::new(), 644 }; 645 646 let op = PlcOperation { 647 op_type: "plc_operation".to_string(), 648 rotation_keys: vec![], 649 verification_methods: new_vm, 650 also_known_as: vec![], 651 services: BTreeMap::new(), 652 prev: None, 653 sig: None, 654 }; 655 656 let diff = compute_diff(&state, &op); 657 let vm_changes: Vec<_> = diff 658 .changes 659 .iter() 660 .filter(|c| c.description.contains("verificationMethods")) 661 .collect(); 662 assert_eq!(vm_changes.len(), 1); 663 } 664 665 #[test] 666 fn test_compute_diff_service_endpoint_changed() { 667 let mut old_svc = BTreeMap::new(); 668 old_svc.insert( 669 "atproto_pds".to_string(), 670 PlcService { 671 service_type: "AtprotoPersonalDataServer".to_string(), 672 endpoint: "https://old-pds.example.com".to_string(), 673 }, 674 ); 675 676 let mut new_svc = BTreeMap::new(); 677 new_svc.insert( 678 "atproto_pds".to_string(), 679 PlcService { 680 service_type: "AtprotoPersonalDataServer".to_string(), 681 endpoint: "https://new-pds.example.com".to_string(), 682 }, 683 ); 684 685 let state = PlcState { 686 did: "did:plc:test".to_string(), 687 rotation_keys: vec![], 688 verification_methods: BTreeMap::new(), 689 also_known_as: vec![], 690 services: old_svc, 691 }; 692 693 let op = PlcOperation { 694 op_type: "plc_operation".to_string(), 695 rotation_keys: vec![], 696 verification_methods: BTreeMap::new(), 697 also_known_as: vec![], 698 services: new_svc, 699 prev: None, 700 sig: None, 701 }; 702 703 let diff = compute_diff(&state, &op); 704 let svc_changes: Vec<_> = diff 705 .changes 706 .iter() 707 .filter(|c| c.description.contains("services.atproto_pds")) 708 .collect(); 709 assert_eq!(svc_changes.len(), 1); 710 assert!(svc_changes[0].description.contains("old-pds")); 711 assert!(svc_changes[0].description.contains("new-pds")); 712 } 713 714 #[test] 715 fn test_compute_diff_service_added() { 716 let state = PlcState { 717 did: "did:plc:test".to_string(), 718 rotation_keys: vec![], 719 verification_methods: BTreeMap::new(), 720 also_known_as: vec![], 721 services: BTreeMap::new(), 722 }; 723 724 let mut new_svc = BTreeMap::new(); 725 new_svc.insert( 726 "atproto_pds".to_string(), 727 PlcService { 728 service_type: "AtprotoPersonalDataServer".to_string(), 729 endpoint: "https://pds.example.com".to_string(), 730 }, 731 ); 732 733 let op = PlcOperation { 734 op_type: "plc_operation".to_string(), 735 rotation_keys: vec![], 736 verification_methods: BTreeMap::new(), 737 also_known_as: vec![], 738 services: new_svc, 739 prev: None, 740 sig: None, 741 }; 742 743 let diff = compute_diff(&state, &op); 744 let added: Vec<_> = diff.changes.iter().filter(|c| c.kind == "added").collect(); 745 assert_eq!(added.len(), 1); 746 assert!(added[0].description.contains("services.atproto_pds")); 747 } 748 749 #[test] 750 fn test_truncate_key_short() { 751 assert_eq!(truncate_key("short"), "short"); 752 } 753 754 #[test] 755 fn test_truncate_key_long() { 756 let long_key = "did:key:zDnaeLongKeyThatExceedsThirtyCharactersForSure"; 757 let truncated = truncate_key(long_key); 758 assert!(truncated.ends_with("...")); 759 assert_eq!(truncated.len(), 33); // 30 chars + "..." 760 } 761 762 #[test] 763 fn test_truncate_key_exactly_30() { 764 let key = "a".repeat(30); 765 assert_eq!(truncate_key(&key), key); // no truncation 766 } 767 768 #[test] 769 fn test_plc_operation_json_serialization() { 770 let mut services = BTreeMap::new(); 771 services.insert( 772 "atproto_pds".to_string(), 773 PlcService { 774 service_type: "AtprotoPersonalDataServer".to_string(), 775 endpoint: "https://pds.example.com".to_string(), 776 }, 777 ); 778 779 let op = PlcOperation { 780 op_type: "plc_operation".to_string(), 781 rotation_keys: vec!["did:key:z1".to_string()], 782 verification_methods: BTreeMap::new(), 783 also_known_as: vec!["at://test.handle".to_string()], 784 services, 785 prev: Some("bafytest".to_string()), 786 sig: None, 787 }; 788 789 let json = serde_json::to_value(&op).unwrap(); 790 assert_eq!(json["type"], "plc_operation"); 791 assert!(json.get("sig").is_none()); // skip_serializing_if 792 assert_eq!(json["prev"], "bafytest"); 793 assert_eq!(json["rotationKeys"][0], "did:key:z1"); 794 assert_eq!(json["alsoKnownAs"][0], "at://test.handle"); 795 } 796 797 #[test] 798 fn test_plc_operation_json_with_sig() { 799 let op = PlcOperation { 800 op_type: "plc_operation".to_string(), 801 rotation_keys: vec![], 802 verification_methods: BTreeMap::new(), 803 also_known_as: vec![], 804 services: BTreeMap::new(), 805 prev: None, 806 sig: Some("base64urlsig".to_string()), 807 }; 808 809 let json = serde_json::to_value(&op).unwrap(); 810 assert_eq!(json["sig"], "base64urlsig"); 811 assert!(json.get("prev").is_none()); // skip_serializing_if Option::is_none 812 } 813 814 #[test] 815 fn test_plc_state_deserialization() { 816 let json = serde_json::json!({ 817 "did": "did:plc:test123", 818 "rotationKeys": ["did:key:z1", "did:key:z2"], 819 "verificationMethods": {"atproto": "did:key:zV"}, 820 "alsoKnownAs": ["at://alice.test"], 821 "services": { 822 "atproto_pds": { 823 "type": "AtprotoPersonalDataServer", 824 "endpoint": "https://pds.example.com" 825 } 826 } 827 }); 828 829 let state: PlcState = serde_json::from_value(json).unwrap(); 830 assert_eq!(state.did, "did:plc:test123"); 831 assert_eq!(state.rotation_keys.len(), 2); 832 assert_eq!(state.verification_methods["atproto"], "did:key:zV"); 833 assert_eq!(state.also_known_as[0], "at://alice.test"); 834 assert_eq!(state.services["atproto_pds"].endpoint, "https://pds.example.com"); 835 } 836 837 #[test] 838 fn test_dag_cbor_field_ordering() { 839 // DAG-CBOR sorts map keys by length then lexicographic. 840 // Verify our serialization is consistent. 841 let op = PlcOperation { 842 op_type: "plc_operation".to_string(), 843 rotation_keys: vec!["did:key:z1".to_string()], 844 verification_methods: BTreeMap::new(), 845 also_known_as: vec![], 846 services: BTreeMap::new(), 847 prev: Some("bafytest".to_string()), 848 sig: None, 849 }; 850 851 let bytes = serialize_for_signing(&op).unwrap(); 852 853 // Round-trip: serialize -> deserialize -> serialize should be identical 854 let val: serde_json::Value = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); 855 // Re-construct the operation from deserialized values 856 let bytes2 = serde_ipld_dagcbor::to_vec(&val).unwrap(); 857 // The bytes should be identical (deterministic encoding) 858 assert_eq!(bytes, bytes2, "DAG-CBOR round-trip should produce identical bytes"); 859 } 860 861 #[test] 862 fn test_dag_cbor_key_names_and_order() { 863 let mut services = BTreeMap::new(); 864 services.insert("atproto_pds".to_string(), PlcService { 865 service_type: "AtprotoPersonalDataServer".to_string(), 866 endpoint: "https://pds.example.com".to_string(), 867 }); 868 let mut vm = BTreeMap::new(); 869 vm.insert("atproto".to_string(), "did:key:zV".to_string()); 870 871 let op = PlcOperation { 872 op_type: "plc_operation".to_string(), 873 rotation_keys: vec!["did:key:z1".to_string()], 874 verification_methods: vm, 875 also_known_as: vec!["at://test.handle".to_string()], 876 services, 877 prev: Some("bafytest".to_string()), 878 sig: None, 879 }; 880 881 let bytes = serialize_for_signing(&op).unwrap(); 882 883 // Verify key names are correct by round-tripping through JSON 884 let val: serde_json::Value = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); 885 let obj = val.as_object().unwrap(); 886 assert!(obj.contains_key("type"), "should have 'type' key"); 887 assert!(obj.contains_key("prev"), "should have 'prev' key"); 888 assert!(obj.contains_key("rotationKeys"), "should have 'rotationKeys' key"); 889 assert!(obj.contains_key("alsoKnownAs"), "should have 'alsoKnownAs' key"); 890 assert!(obj.contains_key("services"), "should have 'services' key"); 891 assert!(obj.contains_key("verificationMethods"), "should have 'verificationMethods' key"); 892 assert!(!obj.contains_key("sig"), "sig should be absent"); 893 894 // Verify deterministic round-trip (proves canonical DAG-CBOR ordering) 895 let bytes2 = serde_ipld_dagcbor::to_vec(&val).unwrap(); 896 assert_eq!(bytes, bytes2, "DAG-CBOR round-trip should produce identical bytes"); 897 } 898}