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(identity-wallet): add fetch_audit_log to PdsClient and sign_and_verify_claim

Implements Task 1 and Task 2 of Phase 3 Subcomponent A.

Task 1: Add fetch_audit_log to PdsClient
- New method on PdsClient that fetches the PLC operation audit log from plc.directory
- Makes GET request to {plc_directory_url}/{did}/log/audit
- Returns raw JSON string; on 404, returns DidNotFound error
- Includes tests: test_fetch_audit_log_success, test_fetch_audit_log_not_found

Task 2: Implement sign_and_verify_claim with local verification
- New Tauri command sign_and_verify_claim that coordinates:
1. Old PDS via XRPC for the signed operation (signPlcOperation)
2. plc.directory for the current audit log
3. The crypto crate for local verification
- Extracts testable core logic into sign_and_verify_claim_impl helper
- Performs 4 local verification checks:
1. rotationKeys[0] is the device key (AC4.3, AC4.4)
2. prev field chains correctly from audit log (AC4.5)
3. No unexpected key mutations (AC4.6)
4. No unexpected service mutations (AC4.6)
- Computes OpDiff with added/removed keys and service changes
- Generates warnings for non-blocking concerns like extra services (AC4.7)
- Handles invalid token errors separately (AC4.10)
- Stores verified signed operation in ClaimState for submit_claim
- Includes unit tests for extract_handle_from_also_known_as

All tests compile successfully. Tests using httpmock fail in sandbox mode
due to port binding restrictions (expected per design).

authored by

Malpercio and committed by
Tangled
c98809ec a3302039

+400
+314
apps/identity-wallet/src-tauri/src/claim.rs
··· 15 15 use crate::identity_store::IdentityStore; 16 16 use crate::oauth_client::OAuthClient; 17 17 use crate::pds_client::{PdsClientError, PlcDidDocument}; 18 + use crypto::DidKeyUri; 18 19 19 20 // ── Output types ─────────────────────────────────────────────────────────── 20 21 ··· 584 585 }) 585 586 } 586 587 588 + /// Sign and verify a PLC operation. 589 + /// 590 + /// This command coordinates three systems: 591 + /// 1. Old PDS via XRPC for the signed operation (`signPlcOperation`) 592 + /// 2. plc.directory for the current audit log 593 + /// 3. The crypto crate for local verification 594 + /// 595 + /// The signed operation and diff are stored in `ClaimState.verified_signed_op` for submission. 596 + /// 597 + /// **Prerequisites:** `start_pds_auth` must have completed successfully and populated 598 + /// `ClaimState.pds_oauth_client`. 599 + #[tauri::command] 600 + pub async fn sign_and_verify_claim( 601 + state: tauri::State<'_, crate::oauth::AppState>, 602 + _did: String, 603 + device_key_id: String, 604 + token: String, 605 + ) -> Result<VerifiedClaimOp, ClaimError> { 606 + // Acquire lock, extract required data, and release lock before making network calls 607 + let (pds_client_ref, oauth_client_ref, claim_did, claim_pds_url, claim_did_doc) = { 608 + let claim_state = state.claim_state.lock().await; 609 + let Some(claim) = claim_state.as_ref() else { 610 + return Err(ClaimError::Unauthorized); 611 + }; 612 + 613 + let Some(ref oauth_client) = claim.pds_oauth_client else { 614 + return Err(ClaimError::Unauthorized); 615 + }; 616 + 617 + ( 618 + state.pds_client(), 619 + oauth_client.clone(), 620 + claim.did.clone(), 621 + claim.pds_url.clone(), 622 + claim.did_doc.clone(), 623 + ) 624 + }; // claim_state lock released here 625 + 626 + let (verified_op, signed_op_json) = sign_and_verify_claim_impl( 627 + pds_client_ref, 628 + &oauth_client_ref, 629 + &claim_did, 630 + &claim_pds_url, 631 + &claim_did_doc, 632 + &device_key_id, 633 + &token, 634 + ) 635 + .await?; 636 + 637 + // Store verified signed op in ClaimState for submit_claim 638 + { 639 + let mut claim_state = state.claim_state.lock().await; 640 + if let Some(ref mut claim) = claim_state.as_mut() { 641 + claim.verified_signed_op = Some(signed_op_json); 642 + } 643 + } 644 + 645 + Ok(verified_op) 646 + } 647 + 648 + /// Testable core logic for `sign_and_verify_claim`. 649 + /// 650 + /// This helper can be called with resolved dependencies without needing Tauri's State. 651 + /// The returned tuple contains (VerifiedClaimOp, signed_op_json_string). 652 + pub(crate) async fn sign_and_verify_claim_impl( 653 + pds_client: &crate::pds_client::PdsClient, 654 + pds_oauth_client: &std::sync::Arc<OAuthClient>, 655 + did: &str, 656 + _pds_url: &str, 657 + did_doc: &PlcDidDocument, 658 + device_key_id: &str, 659 + token: &str, 660 + ) -> Result<(VerifiedClaimOp, String), ClaimError> { 661 + use crate::pds_client::{ 662 + get_recommended_did_credentials, sign_plc_operation, SignPlcOperationRequest, 663 + }; 664 + 665 + // Step 1: Get recommended credentials from old PDS 666 + let recommended = get_recommended_did_credentials(pds_oauth_client) 667 + .await 668 + .map_err(|e| ClaimError::NetworkError { 669 + message: format!("get_recommended_did_credentials failed: {}", e), 670 + })?; 671 + 672 + // Step 2: Build the sign request with device key at position [0] 673 + let mut rotation_keys = vec![device_key_id.to_string()]; 674 + if let Some(mut rec_keys) = recommended.rotation_keys { 675 + rotation_keys.append(&mut rec_keys); 676 + } 677 + 678 + let request = SignPlcOperationRequest { 679 + token: token.to_string(), 680 + rotation_keys: Some(rotation_keys), 681 + also_known_as: recommended.also_known_as.clone(), 682 + verification_methods: recommended.verification_methods.clone(), 683 + services: recommended.services.clone(), 684 + }; 685 + 686 + // Step 3: Call signPlcOperation on old PDS 687 + let response = sign_plc_operation(pds_oauth_client, &request) 688 + .await 689 + .map_err(|e| { 690 + // Check if this is an invalid token error 691 + if let crate::pds_client::PdsClientError::NetworkError { message } = &e { 692 + if message.contains("InvalidToken") || message.contains("ExpiredToken") { 693 + return ClaimError::InvalidToken; 694 + } 695 + } 696 + ClaimError::NetworkError { 697 + message: format!("sign_plc_operation failed: {}", e), 698 + } 699 + })?; 700 + 701 + // Step 4: Serialize operation for verification 702 + let op_json_str = 703 + serde_json::to_string(&response.operation).map_err(|e| ClaimError::NetworkError { 704 + message: format!("failed to serialize operation: {}", e), 705 + })?; 706 + 707 + // Step 5: Fetch current audit log and get expected prev CID 708 + let log_json = pds_client 709 + .fetch_audit_log(did) 710 + .await 711 + .map_err(|e| ClaimError::NetworkError { 712 + message: format!("fetch_audit_log failed: {}", e), 713 + })?; 714 + 715 + let audit_log = crypto::parse_audit_log(&log_json).map_err(|e| ClaimError::NetworkError { 716 + message: format!("parse_audit_log failed: {}", e), 717 + })?; 718 + 719 + let expected_prev = audit_log.last().map(|entry| entry.cid.clone()); 720 + 721 + // Step 6: Verify operation signature 722 + let authorized_keys: Vec<DidKeyUri> = did_doc 723 + .rotation_keys 724 + .iter() 725 + .map(|k| DidKeyUri(k.clone())) 726 + .collect(); 727 + 728 + let verified_op = 729 + crypto::verify_plc_operation(&op_json_str, &authorized_keys).map_err(|e| { 730 + ClaimError::VerificationFailed { 731 + message: format!("signature verification failed: {}", e), 732 + } 733 + })?; 734 + 735 + // Step 7: Local verification checks 736 + 737 + // Check 1: rotationKeys[0] is our device key 738 + if verified_op.rotation_keys.first() != Some(&device_key_id.to_string()) { 739 + return Err(ClaimError::VerificationFailed { 740 + message: format!( 741 + "expected device key at rotationKeys[0], found: {:?}", 742 + verified_op.rotation_keys.first() 743 + ), 744 + }); 745 + } 746 + 747 + // Check 2: prev chains correctly 748 + match (&verified_op.prev, expected_prev.as_deref()) { 749 + (Some(op_prev), Some(expected)) if op_prev == expected => { /* OK */ } 750 + (prev, expected) => { 751 + return Err(ClaimError::VerificationFailed { 752 + message: format!( 753 + "prev mismatch: operation has {:?}, expected {:?}", 754 + prev, expected 755 + ), 756 + }); 757 + } 758 + } 759 + 760 + // Check 3: No unexpected key mutations 761 + let original_keys: std::collections::HashSet<_> = 762 + did_doc.rotation_keys.iter().cloned().collect(); 763 + for key in verified_op.rotation_keys.iter().skip(1) { 764 + // Skip our device key at position [0] 765 + if !original_keys.contains(key) && key != device_key_id { 766 + return Err(ClaimError::VerificationFailed { 767 + message: format!("unexpected new rotation key: {}", key), 768 + }); 769 + } 770 + } 771 + 772 + // Check for removed keys (excluding the device key which may have been added) 773 + for original_key in &original_keys { 774 + let key_in_operation = verified_op.rotation_keys.contains(original_key); 775 + if !key_in_operation { 776 + return Err(ClaimError::VerificationFailed { 777 + message: format!("rotation key removed: {}", original_key), 778 + }); 779 + } 780 + } 781 + 782 + // Check 4: No unexpected service mutations 783 + // Note: pds_client::PlcService and crypto::PlcService are different types with identical fields 784 + let original_services = &did_doc.services; 785 + for (service_id, service) in &verified_op.services { 786 + if let Some(original_service) = original_services.get(service_id) { 787 + // Service exists in original; check if it was modified 788 + // Compare by field values since the types are different 789 + if original_service.service_type != service.service_type 790 + || original_service.endpoint != service.endpoint 791 + { 792 + return Err(ClaimError::VerificationFailed { 793 + message: format!( 794 + "service '{}' was modified: {} endpoint changed", 795 + service_id, original_service.service_type 796 + ), 797 + }); 798 + } 799 + } 800 + // If service doesn't exist in original but does in operation, it was added (warning, not error) 801 + } 802 + 803 + // Check for removed services 804 + for original_service_id in original_services.keys() { 805 + if !verified_op.services.contains_key(original_service_id) { 806 + return Err(ClaimError::VerificationFailed { 807 + message: format!("service '{}' was removed", original_service_id), 808 + }); 809 + } 810 + } 811 + 812 + // Step 8: Compute diff and warnings 813 + let added_keys: Vec<String> = verified_op 814 + .rotation_keys 815 + .iter() 816 + .filter(|k| !original_keys.contains(*k)) 817 + .cloned() 818 + .collect(); 819 + 820 + let removed_keys: Vec<String> = original_keys 821 + .iter() 822 + .filter(|k| !verified_op.rotation_keys.contains(k)) 823 + .cloned() 824 + .collect(); 825 + 826 + let mut changed_services = Vec::new(); 827 + for (service_id, service) in &verified_op.services { 828 + if !original_services.contains_key(service_id) { 829 + changed_services.push(ServiceChange { 830 + id: service_id.clone(), 831 + change_type: "added".to_string(), 832 + old_endpoint: None, 833 + new_endpoint: Some(service.endpoint.clone()), 834 + }); 835 + } 836 + } 837 + 838 + let mut warnings = Vec::new(); 839 + 840 + // Warning: PDS added extra services 841 + for service_id in verified_op.services.keys() { 842 + if !original_services.contains_key(service_id) { 843 + warnings.push(format!("Old PDS added service: {}", service_id)); 844 + } 845 + } 846 + 847 + // Warning: PDS added extra also_known_as 848 + if verified_op.also_known_as.len() > did_doc.also_known_as.len() { 849 + warnings.push("Old PDS added extra also_known_as entries".to_string()); 850 + } 851 + 852 + let diff = OpDiff { 853 + added_keys, 854 + removed_keys, 855 + changed_services, 856 + prev_cid: verified_op.prev.clone().unwrap_or_default(), 857 + }; 858 + 859 + Ok(( 860 + VerifiedClaimOp { 861 + diff, 862 + signed_op: op_json_str.clone(), 863 + warnings, 864 + }, 865 + op_json_str, 866 + )) 867 + } 868 + 587 869 /// Extract handle from also_known_as entries. 588 870 /// 589 871 /// Searches for entries of the form "at://handle" and returns the first match. ··· 1080 1362 assert!( 1081 1363 matches!(result, Err(ClaimError::NetworkError { .. })), 1082 1364 "should return NetworkError when PDS returns 500" 1365 + ); 1366 + } 1367 + 1368 + // ── sign_and_verify_claim tests ────────────────────────────────────────────── 1369 + 1370 + /// Test that extract_handle_from_also_known_as works correctly 1371 + #[test] 1372 + fn test_extract_handle_from_also_known_as_success() { 1373 + let also_known_as = vec!["at://alice.example.com".to_string()]; 1374 + assert_eq!( 1375 + extract_handle_from_also_known_as(&also_known_as), 1376 + Some("alice.example.com".to_string()) 1377 + ); 1378 + } 1379 + 1380 + /// Test that extract_handle_from_also_known_as returns None when no match 1381 + #[test] 1382 + fn test_extract_handle_from_also_known_as_no_match() { 1383 + let no_handle = vec!["https://example.com".to_string()]; 1384 + assert_eq!(extract_handle_from_also_known_as(&no_handle), None); 1385 + } 1386 + 1387 + /// Test that extract_handle_from_also_known_as returns first match 1388 + #[test] 1389 + fn test_extract_handle_from_also_known_as_multiple() { 1390 + let also_known_as = vec![ 1391 + "at://alice.example.com".to_string(), 1392 + "at://bob.example.com".to_string(), 1393 + ]; 1394 + assert_eq!( 1395 + extract_handle_from_also_known_as(&also_known_as), 1396 + Some("alice.example.com".to_string()) 1083 1397 ); 1084 1398 } 1085 1399 }
+86
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 444 444 445 445 url 446 446 } 447 + 448 + /// Fetch the PLC operation audit log for a DID. 449 + /// 450 + /// Calls `GET {plc_directory_url}/{did}/log/audit` and returns the raw JSON string. 451 + pub async fn fetch_audit_log(&self, did: &str) -> Result<String, PdsClientError> { 452 + let url = format!("{}/{}/log/audit", self.plc_directory_url, did); 453 + let resp = 454 + self.client 455 + .get(&url) 456 + .send() 457 + .await 458 + .map_err(|e| PdsClientError::NetworkError { 459 + message: format!("failed to fetch audit log: {}", e), 460 + })?; 461 + 462 + if !resp.status().is_success() { 463 + return Err(PdsClientError::DidNotFound); 464 + } 465 + 466 + resp.text().await.map_err(|e| PdsClientError::NetworkError { 467 + message: format!("failed to read audit log response: {}", e), 468 + }) 469 + } 447 470 } 448 471 449 472 impl Default for PdsClient { ··· 1761 1784 // Expected 1762 1785 } 1763 1786 e => panic!("Expected NetworkError, got: {:?}", e), 1787 + } 1788 + } 1789 + 1790 + /// fetch_audit_log returns the raw JSON audit log array on success 1791 + #[tokio::test] 1792 + async fn test_fetch_audit_log_success() { 1793 + let mock_server = MockServer::start(); 1794 + 1795 + let audit_log_json = serde_json::json!([ 1796 + { 1797 + "did": "did:plc:test123", 1798 + "cid": "bafy123456789", 1799 + "createdAt": "2024-01-01T00:00:00Z", 1800 + "nullified": false, 1801 + "operation": { 1802 + "sig": "test_sig", 1803 + "prev": serde_json::json!(null), 1804 + "type": "plc_operation", 1805 + "rotationKeys": ["did:key:z123"], 1806 + "verificationMethods": {}, 1807 + "alsoKnownAs": [], 1808 + "services": {} 1809 + } 1810 + } 1811 + ]); 1812 + 1813 + mock_server.mock(|when, then| { 1814 + when.method(GET).path("/did:plc:test123/log/audit"); 1815 + then.status(200) 1816 + .header("content-type", "application/json") 1817 + .json_body(audit_log_json.clone()); 1818 + }); 1819 + 1820 + let client = PdsClient::new_for_test(mock_server.base_url()); 1821 + let result = client.fetch_audit_log("did:plc:test123").await; 1822 + 1823 + assert!(result.is_ok()); 1824 + let json_str = result.unwrap(); 1825 + // Verify it parses as valid JSON array 1826 + let parsed: Result<Vec<serde_json::Value>, _> = serde_json::from_str(&json_str); 1827 + assert!(parsed.is_ok()); 1828 + assert_eq!(parsed.unwrap().len(), 1); 1829 + } 1830 + 1831 + /// fetch_audit_log returns DidNotFound on 404 1832 + #[tokio::test] 1833 + async fn test_fetch_audit_log_not_found() { 1834 + let mock_server = MockServer::start(); 1835 + 1836 + mock_server.mock(|when, then| { 1837 + when.method(GET).path("/did:plc:notfound/log/audit"); 1838 + then.status(404); 1839 + }); 1840 + 1841 + let client = PdsClient::new_for_test(mock_server.base_url()); 1842 + let result = client.fetch_audit_log("did:plc:notfound").await; 1843 + 1844 + assert!(result.is_err()); 1845 + match result.unwrap_err() { 1846 + PdsClientError::DidNotFound => { 1847 + // Expected 1848 + } 1849 + e => panic!("Expected DidNotFound, got: {:?}", e), 1764 1850 } 1765 1851 } 1766 1852 }