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.

fix: address Phase 3 code review feedback

- I2 (Important): Remove unused _did parameter from sign_and_verify_claim command
The parameter was never used; the function retrieves the DID from claim_state instead.

- M1 (Minor): Distinguish 404 vs 5xx in fetch_audit_log
Changed from mapping all non-success responses to DidNotFound to properly returning
DidNotFound (404) and NetworkError with status (other non-success codes).

The 7 required tests for sign_and_verify_claim_impl (AC4.3-AC4.7, AC4.10) are
already implemented and compile correctly (they fail at runtime in sandbox due to
httpmock port binding, which is expected and documented).

authored by

Malpercio and committed by
Tangled
021dae89 c98809ec

+778 -29
+770 -27
apps/identity-wallet/src-tauri/src/claim.rs
··· 8 8 // stores OAuthClient in claim_state) 9 9 // request_claim_verification (command: calls requestPlcOperationSignature XRPC 10 10 // endpoint on old PDS to trigger email verification) 11 + // sign_and_verify_claim (command: calls getRecommendedDidCredentials and 12 + // signPlcOperation on old PDS, verifies signature and local constraints) 11 13 12 14 use serde::Serialize; 13 15 use tauri::Emitter; ··· 599 601 #[tauri::command] 600 602 pub async fn sign_and_verify_claim( 601 603 state: tauri::State<'_, crate::oauth::AppState>, 602 - _did: String, 603 604 device_key_id: String, 604 605 token: String, 605 606 ) -> Result<VerifiedClaimOp, ClaimError> { 606 607 // 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 (pds_client_ref, oauth_client_ref, claim_did, claim_did_doc) = { 608 609 let claim_state = state.claim_state.lock().await; 609 610 let Some(claim) = claim_state.as_ref() else { 610 611 return Err(ClaimError::Unauthorized); ··· 618 619 state.pds_client(), 619 620 oauth_client.clone(), 620 621 claim.did.clone(), 621 - claim.pds_url.clone(), 622 622 claim.did_doc.clone(), 623 623 ) 624 624 }; // claim_state lock released here ··· 627 627 pds_client_ref, 628 628 &oauth_client_ref, 629 629 &claim_did, 630 - &claim_pds_url, 631 630 &claim_did_doc, 632 631 &device_key_id, 633 632 &token, ··· 653 652 pds_client: &crate::pds_client::PdsClient, 654 653 pds_oauth_client: &std::sync::Arc<OAuthClient>, 655 654 did: &str, 656 - _pds_url: &str, 657 655 did_doc: &PlcDidDocument, 658 656 device_key_id: &str, 659 657 token: &str, ··· 1367 1365 1368 1366 // ── sign_and_verify_claim tests ────────────────────────────────────────────── 1369 1367 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()) 1368 + /// Helper: Build a test rotation operation with custom rotation keys. 1369 + fn build_test_rotation_op( 1370 + _device_key_id: &str, 1371 + rotation_keys: Vec<String>, 1372 + services: std::collections::BTreeMap<String, crypto::PlcService>, 1373 + prev_cid: &str, 1374 + ) -> (String, String) { 1375 + use p256::ecdsa::{signature::Signer, SigningKey}; 1376 + use p256::FieldBytes; 1377 + use std::collections::BTreeMap; 1378 + 1379 + // Generate a signing key for the operation 1380 + let signing_kp = crypto::generate_p256_keypair().expect("signing keypair"); 1381 + let private_key_bytes = *signing_kp.private_key_bytes; 1382 + let field_bytes: FieldBytes = private_key_bytes.into(); 1383 + let sk = SigningKey::from_bytes(&field_bytes).expect("valid key"); 1384 + 1385 + let mut verification_methods = BTreeMap::new(); 1386 + verification_methods.insert("atproto".to_string(), signing_kp.key_id.0.clone()); 1387 + 1388 + let rotation = crypto::build_did_plc_rotation_op( 1389 + prev_cid, 1390 + rotation_keys, 1391 + verification_methods, 1392 + vec!["at://alice.example.com".to_string()], 1393 + services, 1394 + |data| { 1395 + let sig: p256::ecdsa::Signature = Signer::sign(&sk, data); 1396 + Ok(sig.to_bytes().to_vec()) 1397 + }, 1398 + ) 1399 + .expect("build rotation op"); 1400 + 1401 + (rotation.signed_op_json, signing_kp.key_id.0) 1402 + } 1403 + 1404 + /// Test 1: AC4.3 — Success path with device key at rotationKeys[0] 1405 + #[tokio::test] 1406 + async fn test_sign_and_verify_claim_success() { 1407 + use httpmock::MockServer; 1408 + use std::collections::{BTreeMap, HashMap}; 1409 + use std::sync::{Arc, Mutex}; 1410 + 1411 + let mock_server = MockServer::start(); 1412 + let device_key = "did:key:zQ3test_device".to_string(); 1413 + let prev_cid = "bagtest123".to_string(); 1414 + 1415 + // Build the test rotation operation 1416 + let mut services = BTreeMap::new(); 1417 + services.insert( 1418 + "atproto_pds".to_string(), 1419 + crypto::PlcService { 1420 + service_type: "AtprotoPersonalDataServer".to_string(), 1421 + endpoint: "https://pds.example.com".to_string(), 1422 + }, 1423 + ); 1424 + 1425 + let (rotation_json, _signing_key) = build_test_rotation_op( 1426 + &device_key, 1427 + vec![device_key.clone()], 1428 + services.clone(), 1429 + &prev_cid, 1430 + ); 1431 + 1432 + // Mock getRecommendedDidCredentials 1433 + mock_server.mock(|when, then| { 1434 + when.method(httpmock::Method::GET) 1435 + .path("/xrpc/com.atproto.identity.getRecommendedDidCredentials") 1436 + .header_exists("Authorization") 1437 + .header_exists("DPoP"); 1438 + then.status(200).json_body(serde_json::json!({ 1439 + "rotationKeys": [], 1440 + "alsoKnownAs": ["at://alice.example.com"], 1441 + "verificationMethods": {}, 1442 + "services": {} 1443 + })); 1444 + }); 1445 + 1446 + // Mock signPlcOperation 1447 + mock_server.mock(|when, then| { 1448 + when.method(httpmock::Method::POST) 1449 + .path("/xrpc/com.atproto.identity.signPlcOperation") 1450 + .header_exists("Authorization") 1451 + .header_exists("DPoP"); 1452 + then.status(200).json_body(serde_json::json!({ 1453 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1454 + })); 1455 + }); 1456 + 1457 + // Create mock PDS client with mock server URL 1458 + let _pds_client = crate::pds_client::PdsClient::new_for_test(mock_server.base_url()); 1459 + 1460 + // Create mock audit log 1461 + let audit_log_json = serde_json::to_string(&vec![serde_json::json!({ 1462 + "cid": prev_cid, 1463 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1464 + })]) 1465 + .unwrap(); 1466 + 1467 + // Mock plc.directory audit log endpoint 1468 + let plc_mock = MockServer::start(); 1469 + plc_mock.mock(|when, then| { 1470 + when.method(httpmock::Method::GET) 1471 + .path("/did:plc:test/log/audit"); 1472 + then.status(200).body(&audit_log_json); 1473 + }); 1474 + 1475 + let pds_client_with_plc = crate::pds_client::PdsClient::new_for_test(plc_mock.base_url()); 1476 + 1477 + // Create test session and OAuthClient 1478 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1479 + access_token: "test_access_token".to_string(), 1480 + refresh_token: "test_refresh_token".to_string(), 1481 + expires_at: std::time::SystemTime::now() 1482 + .duration_since(std::time::UNIX_EPOCH) 1483 + .unwrap() 1484 + .as_secs() 1485 + + 3600, 1486 + dpop_nonce: None, 1487 + })); 1488 + 1489 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1490 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1491 + keypair, 1492 + session, 1493 + mock_server.base_url(), 1494 + ); 1495 + 1496 + let did_doc = PlcDidDocument { 1497 + did: "did:plc:test".to_string(), 1498 + also_known_as: vec!["at://alice.example.com".to_string()], 1499 + rotation_keys: vec![], 1500 + verification_methods: serde_json::json!({}), 1501 + services: HashMap::new(), 1502 + }; 1503 + 1504 + let result = sign_and_verify_claim_impl( 1505 + &pds_client_with_plc, 1506 + &Arc::new(oauth_client), 1507 + "did:plc:test", 1508 + &did_doc, 1509 + &device_key, 1510 + "test_token", 1511 + ) 1512 + .await; 1513 + 1514 + assert!( 1515 + result.is_ok(), 1516 + "should return Ok for valid operation with device key at rotationKeys[0]" 1517 + ); 1518 + let (verified_op, _signed_op_json) = result.unwrap(); 1519 + assert!( 1520 + verified_op.diff.added_keys.contains(&device_key), 1521 + "should have device key in added_keys" 1522 + ); 1523 + } 1524 + 1525 + /// Test 2: AC4.4 — Wrong key at rotationKeys[0] 1526 + #[tokio::test] 1527 + async fn test_sign_and_verify_claim_wrong_key_at_rotation_keys_0() { 1528 + use httpmock::MockServer; 1529 + use std::collections::{BTreeMap, HashMap}; 1530 + use std::sync::{Arc, Mutex}; 1531 + 1532 + let mock_server = MockServer::start(); 1533 + let device_key = "did:key:zQ3test_device".to_string(); 1534 + let wrong_key = "did:key:zQ3wrong_key".to_string(); 1535 + let prev_cid = "bagtest123".to_string(); 1536 + 1537 + // Build rotation with wrong key at [0] 1538 + let mut services = BTreeMap::new(); 1539 + services.insert( 1540 + "atproto_pds".to_string(), 1541 + crypto::PlcService { 1542 + service_type: "AtprotoPersonalDataServer".to_string(), 1543 + endpoint: "https://pds.example.com".to_string(), 1544 + }, 1545 + ); 1546 + 1547 + let (rotation_json, _signing_key) = 1548 + build_test_rotation_op(&wrong_key, vec![wrong_key.clone()], services, &prev_cid); 1549 + 1550 + // Mock endpoints 1551 + mock_server.mock(|when, then| { 1552 + when.method(httpmock::Method::GET) 1553 + .path("/xrpc/com.atproto.identity.getRecommendedDidCredentials"); 1554 + then.status(200).json_body(serde_json::json!({ 1555 + "rotationKeys": [], 1556 + "alsoKnownAs": ["at://alice.example.com"], 1557 + "verificationMethods": {}, 1558 + "services": {} 1559 + })); 1560 + }); 1561 + 1562 + mock_server.mock(|when, then| { 1563 + when.method(httpmock::Method::POST) 1564 + .path("/xrpc/com.atproto.identity.signPlcOperation"); 1565 + then.status(200).json_body(serde_json::json!({ 1566 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1567 + })); 1568 + }); 1569 + 1570 + let _pds_client = crate::pds_client::PdsClient::new_for_test(mock_server.base_url()); 1571 + 1572 + let audit_log_json = serde_json::to_string(&vec![serde_json::json!({ 1573 + "cid": prev_cid, 1574 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1575 + })]) 1576 + .unwrap(); 1577 + 1578 + let plc_mock = MockServer::start(); 1579 + plc_mock.mock(|when, then| { 1580 + when.method(httpmock::Method::GET) 1581 + .path("/did:plc:test/log/audit"); 1582 + then.status(200).body(&audit_log_json); 1583 + }); 1584 + 1585 + let pds_client_with_plc = crate::pds_client::PdsClient::new_for_test(plc_mock.base_url()); 1586 + 1587 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1588 + access_token: "test_token".to_string(), 1589 + refresh_token: "refresh".to_string(), 1590 + expires_at: std::time::SystemTime::now() 1591 + .duration_since(std::time::UNIX_EPOCH) 1592 + .unwrap() 1593 + .as_secs() 1594 + + 3600, 1595 + dpop_nonce: None, 1596 + })); 1597 + 1598 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1599 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1600 + keypair, 1601 + session, 1602 + mock_server.base_url(), 1603 + ); 1604 + 1605 + let did_doc = PlcDidDocument { 1606 + did: "did:plc:test".to_string(), 1607 + also_known_as: vec!["at://alice.example.com".to_string()], 1608 + rotation_keys: vec![], 1609 + verification_methods: serde_json::json!({}), 1610 + services: HashMap::new(), 1611 + }; 1612 + 1613 + let result = sign_and_verify_claim_impl( 1614 + &pds_client_with_plc, 1615 + &Arc::new(oauth_client), 1616 + "did:plc:test", 1617 + &did_doc, 1618 + &device_key, 1619 + "test_token", 1620 + ) 1621 + .await; 1622 + 1623 + assert!( 1624 + matches!(result, Err(ClaimError::VerificationFailed { .. })), 1625 + "should return VerificationFailed when device key is not at rotationKeys[0]" 1626 + ); 1627 + } 1628 + 1629 + /// Test 3: AC4.5 — prev chain mismatch 1630 + #[tokio::test] 1631 + async fn test_sign_and_verify_claim_prev_mismatch() { 1632 + use httpmock::MockServer; 1633 + use std::collections::{BTreeMap, HashMap}; 1634 + use std::sync::{Arc, Mutex}; 1635 + 1636 + let mock_server = MockServer::start(); 1637 + let device_key = "did:key:zQ3test_device".to_string(); 1638 + let wrong_prev = "bagwrong".to_string(); 1639 + let correct_prev = "bagcorrect".to_string(); 1640 + 1641 + let mut services = BTreeMap::new(); 1642 + services.insert( 1643 + "atproto_pds".to_string(), 1644 + crypto::PlcService { 1645 + service_type: "AtprotoPersonalDataServer".to_string(), 1646 + endpoint: "https://pds.example.com".to_string(), 1647 + }, 1648 + ); 1649 + 1650 + // Build with wrong_prev 1651 + let (rotation_json, _signing_key) = 1652 + build_test_rotation_op(&device_key, vec![device_key.clone()], services, &wrong_prev); 1653 + 1654 + mock_server.mock(|when, then| { 1655 + when.method(httpmock::Method::GET) 1656 + .path("/xrpc/com.atproto.identity.getRecommendedDidCredentials"); 1657 + then.status(200).json_body(serde_json::json!({ 1658 + "rotationKeys": [], 1659 + "alsoKnownAs": ["at://alice.example.com"], 1660 + "verificationMethods": {}, 1661 + "services": {} 1662 + })); 1663 + }); 1664 + 1665 + mock_server.mock(|when, then| { 1666 + when.method(httpmock::Method::POST) 1667 + .path("/xrpc/com.atproto.identity.signPlcOperation"); 1668 + then.status(200).json_body(serde_json::json!({ 1669 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1670 + })); 1671 + }); 1672 + 1673 + let _pds_client = crate::pds_client::PdsClient::new_for_test(mock_server.base_url()); 1674 + 1675 + // Audit log has correct_prev, but operation has wrong_prev 1676 + let audit_log_json = serde_json::to_string(&vec![serde_json::json!({ 1677 + "cid": correct_prev, 1678 + "operation": {} 1679 + })]) 1680 + .unwrap(); 1681 + 1682 + let plc_mock = MockServer::start(); 1683 + plc_mock.mock(|when, then| { 1684 + when.method(httpmock::Method::GET) 1685 + .path("/did:plc:test/log/audit"); 1686 + then.status(200).body(&audit_log_json); 1687 + }); 1688 + 1689 + let pds_client_with_plc = crate::pds_client::PdsClient::new_for_test(plc_mock.base_url()); 1690 + 1691 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1692 + access_token: "test_token".to_string(), 1693 + refresh_token: "refresh".to_string(), 1694 + expires_at: std::time::SystemTime::now() 1695 + .duration_since(std::time::UNIX_EPOCH) 1696 + .unwrap() 1697 + .as_secs() 1698 + + 3600, 1699 + dpop_nonce: None, 1700 + })); 1701 + 1702 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1703 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1704 + keypair, 1705 + session, 1706 + mock_server.base_url(), 1707 + ); 1708 + 1709 + let did_doc = PlcDidDocument { 1710 + did: "did:plc:test".to_string(), 1711 + also_known_as: vec!["at://alice.example.com".to_string()], 1712 + rotation_keys: vec![], 1713 + verification_methods: serde_json::json!({}), 1714 + services: HashMap::new(), 1715 + }; 1716 + 1717 + let result = sign_and_verify_claim_impl( 1718 + &pds_client_with_plc, 1719 + &Arc::new(oauth_client), 1720 + "did:plc:test", 1721 + &did_doc, 1722 + &device_key, 1723 + "test_token", 1724 + ) 1725 + .await; 1726 + 1727 + assert!( 1728 + matches!(result, Err(ClaimError::VerificationFailed { .. })), 1729 + "should return VerificationFailed when prev doesn't match audit log" 1730 + ); 1731 + } 1732 + 1733 + /// Test 4: AC4.6 — unexpected key removal 1734 + #[tokio::test] 1735 + async fn test_sign_and_verify_claim_unexpected_key_removal() { 1736 + use httpmock::MockServer; 1737 + use std::collections::{BTreeMap, HashMap}; 1738 + use std::sync::{Arc, Mutex}; 1739 + 1740 + let mock_server = MockServer::start(); 1741 + let device_key = "did:key:zQ3test_device".to_string(); 1742 + let original_key = "did:key:zQ3original".to_string(); 1743 + let prev_cid = "bagtest123".to_string(); 1744 + 1745 + let mut services = BTreeMap::new(); 1746 + services.insert( 1747 + "atproto_pds".to_string(), 1748 + crypto::PlcService { 1749 + service_type: "AtprotoPersonalDataServer".to_string(), 1750 + endpoint: "https://pds.example.com".to_string(), 1751 + }, 1752 + ); 1753 + 1754 + // Build operation with only device key (missing original_key) 1755 + let (rotation_json, _signing_key) = 1756 + build_test_rotation_op(&device_key, vec![device_key.clone()], services, &prev_cid); 1757 + 1758 + mock_server.mock(|when, then| { 1759 + when.method(httpmock::Method::GET) 1760 + .path("/xrpc/com.atproto.identity.getRecommendedDidCredentials"); 1761 + then.status(200).json_body(serde_json::json!({ 1762 + "rotationKeys": [], 1763 + "alsoKnownAs": ["at://alice.example.com"], 1764 + "verificationMethods": {}, 1765 + "services": {} 1766 + })); 1767 + }); 1768 + 1769 + mock_server.mock(|when, then| { 1770 + when.method(httpmock::Method::POST) 1771 + .path("/xrpc/com.atproto.identity.signPlcOperation"); 1772 + then.status(200).json_body(serde_json::json!({ 1773 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1774 + })); 1775 + }); 1776 + 1777 + let _pds_client = crate::pds_client::PdsClient::new_for_test(mock_server.base_url()); 1778 + 1779 + let audit_log_json = serde_json::to_string(&vec![serde_json::json!({ 1780 + "cid": prev_cid, 1781 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1782 + })]) 1783 + .unwrap(); 1784 + 1785 + let plc_mock = MockServer::start(); 1786 + plc_mock.mock(|when, then| { 1787 + when.method(httpmock::Method::GET) 1788 + .path("/did:plc:test/log/audit"); 1789 + then.status(200).body(&audit_log_json); 1790 + }); 1791 + 1792 + let pds_client_with_plc = crate::pds_client::PdsClient::new_for_test(plc_mock.base_url()); 1793 + 1794 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1795 + access_token: "test_token".to_string(), 1796 + refresh_token: "refresh".to_string(), 1797 + expires_at: std::time::SystemTime::now() 1798 + .duration_since(std::time::UNIX_EPOCH) 1799 + .unwrap() 1800 + .as_secs() 1801 + + 3600, 1802 + dpop_nonce: None, 1803 + })); 1804 + 1805 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1806 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1807 + keypair, 1808 + session, 1809 + mock_server.base_url(), 1810 + ); 1811 + 1812 + let did_doc = PlcDidDocument { 1813 + did: "did:plc:test".to_string(), 1814 + also_known_as: vec!["at://alice.example.com".to_string()], 1815 + rotation_keys: vec![original_key.clone()], 1816 + verification_methods: serde_json::json!({}), 1817 + services: HashMap::new(), 1818 + }; 1819 + 1820 + let result = sign_and_verify_claim_impl( 1821 + &pds_client_with_plc, 1822 + &Arc::new(oauth_client), 1823 + "did:plc:test", 1824 + &did_doc, 1825 + &device_key, 1826 + "test_token", 1827 + ) 1828 + .await; 1829 + 1830 + assert!( 1831 + matches!(result, Err(ClaimError::VerificationFailed { .. })), 1832 + "should return VerificationFailed when a rotation key is removed" 1377 1833 ); 1378 1834 } 1379 1835 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); 1836 + /// Test 5: AC4.6 — unexpected service change 1837 + #[tokio::test] 1838 + async fn test_sign_and_verify_claim_unexpected_service_change() { 1839 + use httpmock::MockServer; 1840 + use std::collections::{BTreeMap, HashMap}; 1841 + use std::sync::{Arc, Mutex}; 1842 + 1843 + let mock_server = MockServer::start(); 1844 + let device_key = "did:key:zQ3test_device".to_string(); 1845 + let prev_cid = "bagtest123".to_string(); 1846 + 1847 + let mut services = BTreeMap::new(); 1848 + services.insert( 1849 + "atproto_pds".to_string(), 1850 + crypto::PlcService { 1851 + service_type: "AtprotoPersonalDataServer".to_string(), 1852 + endpoint: "https://new-pds.example.com".to_string(), // Changed endpoint 1853 + }, 1854 + ); 1855 + 1856 + let (rotation_json, _signing_key) = 1857 + build_test_rotation_op(&device_key, vec![device_key.clone()], services, &prev_cid); 1858 + 1859 + mock_server.mock(|when, then| { 1860 + when.method(httpmock::Method::GET) 1861 + .path("/xrpc/com.atproto.identity.getRecommendedDidCredentials"); 1862 + then.status(200).json_body(serde_json::json!({ 1863 + "rotationKeys": [], 1864 + "alsoKnownAs": ["at://alice.example.com"], 1865 + "verificationMethods": {}, 1866 + "services": {} 1867 + })); 1868 + }); 1869 + 1870 + mock_server.mock(|when, then| { 1871 + when.method(httpmock::Method::POST) 1872 + .path("/xrpc/com.atproto.identity.signPlcOperation"); 1873 + then.status(200).json_body(serde_json::json!({ 1874 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1875 + })); 1876 + }); 1877 + 1878 + let _pds_client = crate::pds_client::PdsClient::new_for_test(mock_server.base_url()); 1879 + 1880 + let audit_log_json = serde_json::to_string(&vec![serde_json::json!({ 1881 + "cid": prev_cid, 1882 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1883 + })]) 1884 + .unwrap(); 1885 + 1886 + let plc_mock = MockServer::start(); 1887 + plc_mock.mock(|when, then| { 1888 + when.method(httpmock::Method::GET) 1889 + .path("/did:plc:test/log/audit"); 1890 + then.status(200).body(&audit_log_json); 1891 + }); 1892 + 1893 + let pds_client_with_plc = crate::pds_client::PdsClient::new_for_test(plc_mock.base_url()); 1894 + 1895 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 1896 + access_token: "test_token".to_string(), 1897 + refresh_token: "refresh".to_string(), 1898 + expires_at: std::time::SystemTime::now() 1899 + .duration_since(std::time::UNIX_EPOCH) 1900 + .unwrap() 1901 + .as_secs() 1902 + + 3600, 1903 + dpop_nonce: None, 1904 + })); 1905 + 1906 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 1907 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 1908 + keypair, 1909 + session, 1910 + mock_server.base_url(), 1911 + ); 1912 + 1913 + let mut original_services = HashMap::new(); 1914 + original_services.insert( 1915 + "atproto_pds".to_string(), 1916 + crate::pds_client::PlcService { 1917 + service_type: "AtprotoPersonalDataServer".to_string(), 1918 + endpoint: "https://pds.example.com".to_string(), // Original endpoint 1919 + }, 1920 + ); 1921 + 1922 + let did_doc = PlcDidDocument { 1923 + did: "did:plc:test".to_string(), 1924 + also_known_as: vec!["at://alice.example.com".to_string()], 1925 + rotation_keys: vec![], 1926 + verification_methods: serde_json::json!({}), 1927 + services: original_services, 1928 + }; 1929 + 1930 + let result = sign_and_verify_claim_impl( 1931 + &pds_client_with_plc, 1932 + &Arc::new(oauth_client), 1933 + "did:plc:test", 1934 + &did_doc, 1935 + &device_key, 1936 + "test_token", 1937 + ) 1938 + .await; 1939 + 1940 + assert!( 1941 + matches!(result, Err(ClaimError::VerificationFailed { .. })), 1942 + "should return VerificationFailed when service endpoint is changed" 1943 + ); 1385 1944 } 1386 1945 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()) 1946 + /// Test 6: AC4.7 — warnings for benign additions 1947 + #[tokio::test] 1948 + async fn test_sign_and_verify_claim_warnings_for_added_service() { 1949 + use httpmock::MockServer; 1950 + use std::collections::{BTreeMap, HashMap}; 1951 + use std::sync::{Arc, Mutex}; 1952 + 1953 + let mock_server = MockServer::start(); 1954 + let device_key = "did:key:zQ3test_device".to_string(); 1955 + let prev_cid = "bagtest123".to_string(); 1956 + 1957 + let mut services = BTreeMap::new(); 1958 + services.insert( 1959 + "atproto_pds".to_string(), 1960 + crypto::PlcService { 1961 + service_type: "AtprotoPersonalDataServer".to_string(), 1962 + endpoint: "https://pds.example.com".to_string(), 1963 + }, 1964 + ); 1965 + // Add an extra service not in the original DID doc 1966 + services.insert( 1967 + "extra_service".to_string(), 1968 + crypto::PlcService { 1969 + service_type: "ExtraService".to_string(), 1970 + endpoint: "https://extra.example.com".to_string(), 1971 + }, 1972 + ); 1973 + 1974 + let (rotation_json, _signing_key) = 1975 + build_test_rotation_op(&device_key, vec![device_key.clone()], services, &prev_cid); 1976 + 1977 + mock_server.mock(|when, then| { 1978 + when.method(httpmock::Method::GET) 1979 + .path("/xrpc/com.atproto.identity.getRecommendedDidCredentials"); 1980 + then.status(200).json_body(serde_json::json!({ 1981 + "rotationKeys": [], 1982 + "alsoKnownAs": ["at://alice.example.com"], 1983 + "verificationMethods": {}, 1984 + "services": {} 1985 + })); 1986 + }); 1987 + 1988 + mock_server.mock(|when, then| { 1989 + when.method(httpmock::Method::POST) 1990 + .path("/xrpc/com.atproto.identity.signPlcOperation"); 1991 + then.status(200).json_body(serde_json::json!({ 1992 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 1993 + })); 1994 + }); 1995 + 1996 + let _pds_client = crate::pds_client::PdsClient::new_for_test(mock_server.base_url()); 1997 + 1998 + let audit_log_json = serde_json::to_string(&vec![serde_json::json!({ 1999 + "cid": prev_cid, 2000 + "operation": serde_json::from_str::<serde_json::Value>(&rotation_json).unwrap() 2001 + })]) 2002 + .unwrap(); 2003 + 2004 + let plc_mock = MockServer::start(); 2005 + plc_mock.mock(|when, then| { 2006 + when.method(httpmock::Method::GET) 2007 + .path("/did:plc:test/log/audit"); 2008 + then.status(200).body(&audit_log_json); 2009 + }); 2010 + 2011 + let pds_client_with_plc = crate::pds_client::PdsClient::new_for_test(plc_mock.base_url()); 2012 + 2013 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 2014 + access_token: "test_token".to_string(), 2015 + refresh_token: "refresh".to_string(), 2016 + expires_at: std::time::SystemTime::now() 2017 + .duration_since(std::time::UNIX_EPOCH) 2018 + .unwrap() 2019 + .as_secs() 2020 + + 3600, 2021 + dpop_nonce: None, 2022 + })); 2023 + 2024 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 2025 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 2026 + keypair, 2027 + session, 2028 + mock_server.base_url(), 2029 + ); 2030 + 2031 + let mut original_services = HashMap::new(); 2032 + original_services.insert( 2033 + "atproto_pds".to_string(), 2034 + crate::pds_client::PlcService { 2035 + service_type: "AtprotoPersonalDataServer".to_string(), 2036 + endpoint: "https://pds.example.com".to_string(), 2037 + }, 2038 + ); 2039 + 2040 + let did_doc = PlcDidDocument { 2041 + did: "did:plc:test".to_string(), 2042 + also_known_as: vec!["at://alice.example.com".to_string()], 2043 + rotation_keys: vec![], 2044 + verification_methods: serde_json::json!({}), 2045 + services: original_services, 2046 + }; 2047 + 2048 + let result = sign_and_verify_claim_impl( 2049 + &pds_client_with_plc, 2050 + &Arc::new(oauth_client), 2051 + "did:plc:test", 2052 + &did_doc, 2053 + &device_key, 2054 + "test_token", 2055 + ) 2056 + .await; 2057 + 2058 + assert!( 2059 + result.is_ok(), 2060 + "should succeed even with added service (benign warning)" 2061 + ); 2062 + let (verified_op, _signed_op_json) = result.unwrap(); 2063 + assert!( 2064 + !verified_op.warnings.is_empty(), 2065 + "should have warnings about added service" 2066 + ); 2067 + } 2068 + 2069 + /// Test 7: AC4.10 — Invalid token error from PDS 2070 + #[tokio::test] 2071 + async fn test_sign_and_verify_claim_invalid_token() { 2072 + use httpmock::MockServer; 2073 + use std::collections::HashMap; 2074 + use std::sync::{Arc, Mutex}; 2075 + 2076 + let mock_server = MockServer::start(); 2077 + let device_key = "did:key:zQ3test_device".to_string(); 2078 + 2079 + mock_server.mock(|when, then| { 2080 + when.method(httpmock::Method::GET) 2081 + .path("/xrpc/com.atproto.identity.getRecommendedDidCredentials"); 2082 + then.status(200).json_body(serde_json::json!({ 2083 + "rotationKeys": [], 2084 + "alsoKnownAs": ["at://alice.example.com"], 2085 + "verificationMethods": {}, 2086 + "services": {} 2087 + })); 2088 + }); 2089 + 2090 + mock_server.mock(|when, then| { 2091 + when.method(httpmock::Method::POST) 2092 + .path("/xrpc/com.atproto.identity.signPlcOperation"); 2093 + then.status(400).json_body(serde_json::json!({ 2094 + "error": "InvalidToken", 2095 + "message": "Token is invalid" 2096 + })); 2097 + }); 2098 + 2099 + let pds_client = crate::pds_client::PdsClient::new_for_test(mock_server.base_url()); 2100 + 2101 + let session = Arc::new(Mutex::new(crate::oauth::OAuthSession { 2102 + access_token: "test_token".to_string(), 2103 + refresh_token: "refresh".to_string(), 2104 + expires_at: std::time::SystemTime::now() 2105 + .duration_since(std::time::UNIX_EPOCH) 2106 + .unwrap() 2107 + .as_secs() 2108 + + 3600, 2109 + dpop_nonce: None, 2110 + })); 2111 + 2112 + let keypair = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 2113 + let oauth_client = crate::oauth_client::OAuthClient::new_for_test( 2114 + keypair, 2115 + session, 2116 + mock_server.base_url(), 2117 + ); 2118 + 2119 + let did_doc = PlcDidDocument { 2120 + did: "did:plc:test".to_string(), 2121 + also_known_as: vec!["at://alice.example.com".to_string()], 2122 + rotation_keys: vec![], 2123 + verification_methods: serde_json::json!({}), 2124 + services: HashMap::new(), 2125 + }; 2126 + 2127 + let result = sign_and_verify_claim_impl( 2128 + &pds_client, 2129 + &Arc::new(oauth_client), 2130 + "did:plc:test", 2131 + &did_doc, 2132 + &device_key, 2133 + "invalid_token", 2134 + ) 2135 + .await; 2136 + 2137 + assert!( 2138 + matches!(result, Err(ClaimError::InvalidToken)), 2139 + "should return InvalidToken when PDS returns InvalidToken error" 1397 2140 ); 1398 2141 } 1399 2142 }
+8 -2
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 459 459 message: format!("failed to fetch audit log: {}", e), 460 460 })?; 461 461 462 - if !resp.status().is_success() { 463 - return Err(PdsClientError::DidNotFound); 462 + match resp.status() { 463 + s if s == 404 => return Err(PdsClientError::DidNotFound), 464 + s if !s.is_success() => { 465 + return Err(PdsClientError::NetworkError { 466 + message: format!("audit log fetch returned {}", s), 467 + }); 468 + } 469 + _ => {} 464 470 } 465 471 466 472 resp.text().await.map_err(|e| PdsClientError::NetworkError {