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 claim module types and error enums

- Create claim.rs with output types (IdentityInfo, VerifiedClaimOp, OpDiff, ServiceChange, ClaimResult)
- Add error types (ResolveError, ClaimError) with SCREAMING_SNAKE_CASE serialization
- Add ClaimState struct for cross-command state persistence
- Add Clone derive to PlcDidDocument and PlcService to support ClaimState storage in Mutex
- Add pub mod claim; to lib.rs module declarations
- Verified: cargo check passes without errors

authored by

Malpercio and committed by
Tangled
98ade832 6c281489

+1147 -2
+138
apps/identity-wallet/src-tauri/src/claim.rs
··· 1 + // pattern: Functional Core (types and errors) 2 + // 3 + // Types: IdentityInfo, VerifiedClaimOp, OpDiff, ServiceChange, ClaimResult, 4 + // ClaimState, ResolveError, ClaimError 5 + // These are all data structures with no side effects. 6 + 7 + use serde::Serialize; 8 + 9 + use crate::oauth_client::OAuthClient; 10 + use crate::pds_client::PlcDidDocument; 11 + 12 + // ── Output types ─────────────────────────────────────────────────────────── 13 + 14 + /// Identity information resolved from a handle or DID. 15 + /// 16 + /// Returned by `resolve_identity` command. 17 + #[derive(Debug, Serialize, Clone)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct IdentityInfo { 20 + /// The DID (e.g., "did:plc:abc123...") 21 + pub did: String, 22 + /// The handle (e.g., "alice.test") 23 + pub handle: String, 24 + /// The PDS endpoint URL (e.g., "https://pds.example.com") 25 + pub pds_url: String, 26 + /// Current rotation keys from the DID document 27 + pub current_rotation_keys: Vec<String>, 28 + /// Whether the device key is a rotation key (true if device key == rotation_keys[0]) 29 + pub device_key_is_root: bool, 30 + } 31 + 32 + /// Verified claim operation ready for submission. 33 + /// 34 + /// Returned by `verify_claim` command. 35 + #[derive(Debug, Serialize, Clone)] 36 + #[serde(rename_all = "camelCase")] 37 + pub struct VerifiedClaimOp { 38 + /// Diff of keys and services between current DID doc and proposed operation 39 + pub diff: OpDiff, 40 + /// Signed operation (ready for PLC submission) 41 + pub signed_op: String, 42 + /// Warnings from verification (e.g., "This operation will break X") 43 + pub warnings: Vec<String>, 44 + } 45 + 46 + /// Diff of changes between current DID document and proposed operation. 47 + #[derive(Debug, Serialize, Clone)] 48 + #[serde(rename_all = "camelCase")] 49 + pub struct OpDiff { 50 + /// Keys being added in this operation 51 + pub added_keys: Vec<String>, 52 + /// Keys being removed in this operation 53 + pub removed_keys: Vec<String>, 54 + /// Service endpoint changes (added/removed/modified) 55 + pub changed_services: Vec<ServiceChange>, 56 + /// Previous CID (content identifier) of the DID document 57 + pub prev_cid: String, 58 + } 59 + 60 + /// Change to a service endpoint in the DID document. 61 + #[derive(Debug, Serialize, Clone)] 62 + #[serde(rename_all = "camelCase")] 63 + pub struct ServiceChange { 64 + /// Service ID (e.g., "atproto_pds") 65 + pub id: String, 66 + /// Type of change: "added", "removed", or "modified" 67 + pub change_type: String, 68 + /// Old endpoint URL (None if added) 69 + pub old_endpoint: Option<String>, 70 + /// New endpoint URL (None if removed) 71 + pub new_endpoint: Option<String>, 72 + } 73 + 74 + /// Result of a successful claim submission. 75 + #[derive(Debug, Serialize, Clone)] 76 + #[serde(rename_all = "camelCase")] 77 + pub struct ClaimResult { 78 + /// Updated DID document after claim was applied 79 + pub updated_did_doc: serde_json::Value, 80 + } 81 + 82 + // ── State persisted across the claim flow ────────────────────────────────── 83 + 84 + /// Claim flow state persisted in `AppState`. 85 + /// 86 + /// This state is set by `resolve_identity` and used by subsequent 87 + /// `start_pds_auth`, `request_claim_verification`, `sign_and_verify_claim`, 88 + /// and `submit_claim` commands within the same claim flow session. 89 + pub struct ClaimState { 90 + /// The DID being claimed (resolved by `resolve_identity`) 91 + pub did: String, 92 + /// The PDS endpoint URL (discovered by `resolve_identity`) 93 + pub pds_url: String, 94 + /// The DID document fetched from plc.directory (discovered by `resolve_identity`) 95 + pub did_doc: PlcDidDocument, 96 + /// OAuth client for the PDS (set after `start_pds_auth` succeeds) 97 + pub pds_oauth_client: Option<OAuthClient>, 98 + /// Verified signed operation (set after `sign_and_verify_claim` succeeds) 99 + pub verified_signed_op: Option<String>, 100 + } 101 + 102 + // ── Error types ──────────────────────────────────────────────────────────── 103 + 104 + /// Error returned by `resolve_identity` command. 105 + /// 106 + /// Serializes as `{ "code": "SCREAMING_SNAKE_CASE" }` matching the 107 + /// existing error pattern (CreateAccountError, DeviceKeyError, etc.). 108 + #[derive(Debug, Serialize)] 109 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 110 + pub enum ResolveError { 111 + /// Handle resolution failed (DNS and HTTP fallback both failed) 112 + HandleNotFound, 113 + /// DID not found in plc.directory (404 response) 114 + DidNotFound, 115 + /// PDS endpoint is unreachable 116 + PdsUnreachable, 117 + /// Network error during discovery (timeout, connection refused, etc.) 118 + NetworkError { message: String }, 119 + } 120 + 121 + /// Error returned by claim flow commands (`verify_claim`, `request_claim_verification`, etc.). 122 + /// 123 + /// Serializes as `{ "code": "SCREAMING_SNAKE_CASE", "message": "..." }` matching 124 + /// the existing error pattern. 125 + #[derive(Debug, Serialize)] 126 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 127 + pub enum ClaimError { 128 + /// PDS XRPC token request failed or returned invalid token 129 + InvalidToken, 130 + /// Claim verification failed (operation verification, signature validation, etc.) 131 + VerificationFailed { message: String }, 132 + /// PLC directory operation submission failed 133 + PlcDirectoryError { message: String }, 134 + /// User is not authorized for this operation 135 + Unauthorized, 136 + /// Network error during claim flow (timeout, connection refused, etc.) 137 + NetworkError { message: String }, 138 + }
+1
apps/identity-wallet/src-tauri/src/lib.rs
··· 1 + pub mod claim; 1 2 pub mod device_key; 2 3 pub mod home; 3 4 pub mod http;
+2 -2
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 57 57 /// 58 58 /// Returned from `GET {plc_directory_url}/{did}`. 59 59 /// Field names use camelCase per the API. 60 - #[derive(Debug, Deserialize)] 60 + #[derive(Debug, Clone, Deserialize)] 61 61 #[serde(rename_all = "camelCase")] 62 62 pub struct PlcDidDocument { 63 63 pub did: String, ··· 68 68 } 69 69 70 70 /// PLC service entry (one service in `PlcDidDocument.services`). 71 - #[derive(Debug, Deserialize)] 71 + #[derive(Debug, Clone, Deserialize)] 72 72 pub struct PlcService { 73 73 #[serde(rename = "type")] 74 74 pub service_type: String,
+268
docs/implementation-plans/2026-03-28-plc-key-management-phase4/phase_01.md
··· 1 + # Claim Flow Backend — Phase 1: Types, Errors, and resolve_identity 2 + 3 + **Goal:** Create the claim module with all shared types, error enums, ClaimState for cross-command state, and the `resolve_identity` command that resolves a handle/DID to identity information. 4 + 5 + **Architecture:** A new `claim.rs` module in the identity-wallet Tauri backend. Types follow the existing `{ code: "SCREAMING_SNAKE_CASE" }` error serialization pattern. `resolve_identity` delegates to `PdsClient` for resolution and discovery, and checks `IdentityStore` for existing device key status. A `ClaimState` struct persists across the multi-step claim flow via a new `Mutex<Option<ClaimState>>` field on `AppState`. 6 + 7 + **Tech Stack:** Rust, serde, tauri, tokio 8 + 9 + **Scope:** 4 phases from design Phase 4 (this is phase 1 of 4) 10 + 11 + **Codebase verified:** 2026-03-28 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC4: Claim flow executes end-to-end 20 + - **plc-key-management.AC4.1 Success:** `resolve_identity` returns correct `IdentityInfo` including current rotation keys and PDS URL 21 + 22 + --- 23 + 24 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 25 + <!-- START_TASK_1 --> 26 + ### Task 1: Create claim.rs with types and error enums 27 + 28 + **Verifies:** None (infrastructure — types verified by compiler) 29 + 30 + **Files:** 31 + - Create: `apps/identity-wallet/src-tauri/src/claim.rs` 32 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (add `pub mod claim;` declaration) 33 + 34 + **Implementation:** 35 + 36 + Create `claim.rs` with all types needed across the claim flow. These types map to the IPC contracts in the design plan (lines 184-280). 37 + 38 + **Note:** The `ServiceChange` type is not explicitly defined in the design's TypeScript IPC contracts (only referenced as `ServiceChange[]` in `OpDiff`). The definition below is inferred from the design context — it represents a change to a service entry between the current DID doc and the proposed operation. 39 + 40 + **Types to create:** 41 + 42 + ```rust 43 + use serde::Serialize; 44 + 45 + // --- Output types --- 46 + 47 + #[derive(Debug, Serialize, Clone)] 48 + #[serde(rename_all = "camelCase")] 49 + pub struct IdentityInfo { 50 + pub did: String, 51 + pub handle: String, 52 + pub pds_url: String, 53 + pub current_rotation_keys: Vec<String>, 54 + pub device_key_is_root: bool, 55 + } 56 + 57 + #[derive(Debug, Serialize, Clone)] 58 + #[serde(rename_all = "camelCase")] 59 + pub struct VerifiedClaimOp { 60 + pub diff: OpDiff, 61 + pub signed_op: String, 62 + pub warnings: Vec<String>, 63 + } 64 + 65 + #[derive(Debug, Serialize, Clone)] 66 + #[serde(rename_all = "camelCase")] 67 + pub struct OpDiff { 68 + pub added_keys: Vec<String>, 69 + pub removed_keys: Vec<String>, 70 + pub changed_services: Vec<ServiceChange>, 71 + pub prev_cid: String, 72 + } 73 + 74 + #[derive(Debug, Serialize, Clone)] 75 + #[serde(rename_all = "camelCase")] 76 + pub struct ServiceChange { 77 + pub id: String, 78 + pub change_type: String, // "added", "removed", "modified" 79 + pub old_endpoint: Option<String>, 80 + pub new_endpoint: Option<String>, 81 + } 82 + 83 + #[derive(Debug, Serialize, Clone)] 84 + #[serde(rename_all = "camelCase")] 85 + pub struct ClaimResult { 86 + pub updated_did_doc: serde_json::Value, 87 + } 88 + ``` 89 + 90 + **Error types:** 91 + 92 + ```rust 93 + #[derive(Debug, Serialize)] 94 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 95 + pub enum ResolveError { 96 + HandleNotFound, 97 + DidNotFound, 98 + PdsUnreachable, 99 + NetworkError { message: String }, 100 + } 101 + 102 + #[derive(Debug, Serialize)] 103 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 104 + pub enum ClaimError { 105 + InvalidToken, 106 + VerificationFailed { message: String }, 107 + PlcDirectoryError { message: String }, 108 + Unauthorized, 109 + NetworkError { message: String }, 110 + } 111 + ``` 112 + 113 + **ClaimState** (cross-command state persisted in AppState): 114 + 115 + ```rust 116 + use crate::oauth_client::OAuthClient; 117 + use crate::pds_client::PlcDidDocument; 118 + 119 + pub struct ClaimState { 120 + pub did: String, 121 + pub pds_url: String, 122 + pub did_doc: PlcDidDocument, 123 + pub pds_oauth_client: Option<OAuthClient>, 124 + pub verified_signed_op: Option<String>, 125 + } 126 + ``` 127 + 128 + Also add `Clone` derive to `PlcDidDocument` in `pds_client.rs` (line ~60). `ClaimState` stores a `PlcDidDocument` inside a `tokio::Mutex`, and downstream commands need to read fields while holding the lock guard. Adding `Clone` prevents issues if the implementer needs to extract data outside the lock scope. 129 + 130 + Add `pub mod claim;` to `lib.rs` module declarations (alongside existing `pub mod device_key;`, `pub mod home;`, etc.). 131 + 132 + **Verification:** 133 + 134 + Run: `cargo check -p identity-wallet-tauri` 135 + Expected: Compiles without errors 136 + 137 + **Commit:** `feat(identity-wallet): add claim module types and error enums` 138 + <!-- END_TASK_1 --> 139 + 140 + <!-- START_TASK_2 --> 141 + ### Task 2: Add ClaimState to AppState 142 + 143 + **Verifies:** None (infrastructure — wiring) 144 + 145 + **Files:** 146 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` (AppState struct definition and `new()` method) 147 + 148 + **Implementation:** 149 + 150 + Add a new field to `AppState`: 151 + 152 + ```rust 153 + pub claim_state: tokio::sync::Mutex<Option<crate::claim::ClaimState>>, 154 + ``` 155 + 156 + Use `tokio::sync::Mutex` (not `std::sync::Mutex`) because claim commands are async and hold the lock across `.await` points (e.g., `resolve_identity` locks claim_state, does async PDS calls, then writes to it before releasing). 157 + 158 + **Important:** The existing `AppState` fields (`pending_auth`, `oauth_session`) use `std::sync::Mutex`. This mixing is intentional and correct — those fields are locked briefly for non-async operations (take/replace of `Option` values with no `.await` while locked). `claim_state` is different: commands hold the lock across multiple async calls. Using `std::sync::Mutex` here would deadlock the Tokio runtime. Do NOT "harmonize" by changing either direction. 159 + 160 + Update `AppState::new()` to initialize the field: 161 + 162 + ```rust 163 + claim_state: tokio::sync::Mutex::new(None), 164 + ``` 165 + 166 + **Verification:** 167 + 168 + Run: `cargo check -p identity-wallet-tauri` 169 + Expected: Compiles without errors 170 + 171 + **Commit:** `feat(identity-wallet): add claim_state to AppState` 172 + <!-- END_TASK_2 --> 173 + <!-- END_SUBCOMPONENT_A --> 174 + 175 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 176 + <!-- START_TASK_3 --> 177 + ### Task 3: Implement resolve_identity command 178 + 179 + **Verifies:** plc-key-management.AC4.1 180 + 181 + **Files:** 182 + - Modify: `apps/identity-wallet/src-tauri/src/claim.rs` (add resolve_identity function) 183 + 184 + **Implementation:** 185 + 186 + Add a `resolve_identity` Tauri command to `claim.rs`. Follow the existing pattern of a thin Tauri wrapper calling testable core logic. 187 + 188 + The function: 189 + 1. Determines if input is a DID (starts with `"did:"`) or a handle 190 + 2. If handle: calls `PdsClient::resolve_handle(handle)` to get DID 191 + 3. Calls `PdsClient::discover_pds(did)` to fetch DID doc from plc.directory and extract PDS endpoint 192 + 4. Extracts handle from `also_known_as` entries (format: `at://handle`, strip `at://` prefix). **Edge case:** if `also_known_as` is empty or contains no `at://` entries, fall back to the original `handle_or_did` input if it was a handle, or use `"unknown"` if it was a DID 193 + 5. Checks if DID is in `IdentityStore::list_identities()`; if so, calls `get_or_create_device_key(did)` and compares `key.key_id` against `did_doc.rotation_keys[0]` to determine `device_key_is_root` 194 + 6. Stores `did`, `pds_url`, and `did_doc` in `AppState.claim_state` for use by subsequent commands 195 + 7. Returns `IdentityInfo` 196 + 197 + Map `PdsClientError` variants to `ResolveError`: 198 + - `HandleNotFound` → `ResolveError::HandleNotFound` 199 + - `DidNotFound` → `ResolveError::DidNotFound` 200 + - `PdsUnreachable { .. }` → `ResolveError::PdsUnreachable` 201 + - `NetworkError { message }` / `InvalidResponse { message }` → `ResolveError::NetworkError { message }` 202 + 203 + The Tauri command signature: 204 + 205 + ```rust 206 + #[tauri::command] 207 + pub async fn resolve_identity( 208 + state: tauri::State<'_, crate::oauth::AppState>, 209 + handle_or_did: String, 210 + ) -> Result<IdentityInfo, ResolveError> { 211 + // ... 212 + } 213 + ``` 214 + 215 + **Testing:** 216 + Tests must verify AC4.1: 217 + - plc-key-management.AC4.1: resolve_identity returns correct IdentityInfo including current rotation keys and PDS URL 218 + 219 + Specific test cases: 220 + 1. **Handle input → correct IdentityInfo:** Mock DNS/HTTP handle resolution returning a DID, mock plc.directory returning a DID doc with known rotation keys and PDS service. Assert returned `IdentityInfo` has correct `did`, `handle`, `pds_url`, `current_rotation_keys`, and `device_key_is_root: false` (unregistered DID). 221 + 2. **DID input → skips handle resolution:** Pass a `did:plc:...` string directly. Mock only plc.directory. Assert correct IdentityInfo without handle resolution mock being hit. 222 + 3. **Handle not found → ResolveError::HandleNotFound:** Mock both DNS and HTTP fallback to fail. Assert `HandleNotFound` error code. 223 + 4. **DID not found → ResolveError::DidNotFound:** Mock plc.directory to return 404. Assert `DidNotFound` error code. 224 + 225 + Follow existing pds_client.rs test patterns: `#[tokio::test]` with `httpmock::MockServer`, `PdsClient::new_for_test(mock_server.base_url())`. 226 + 227 + **Verification:** 228 + 229 + Run: `cargo test -p identity-wallet-tauri -- claim` 230 + Expected: All tests pass 231 + 232 + **Commit:** `feat(identity-wallet): implement resolve_identity command (AC4.1)` 233 + <!-- END_TASK_3 --> 234 + 235 + <!-- START_TASK_4 --> 236 + ### Task 4: Serialization tests for claim types 237 + 238 + **Verifies:** None (infrastructure — ensures IPC contract correctness) 239 + 240 + **Files:** 241 + - Modify: `apps/identity-wallet/src-tauri/src/claim.rs` (add `#[cfg(test)]` module) 242 + 243 + **Implementation:** 244 + 245 + Add serialization tests following the pattern in `lib.rs` (60+ serialization tests). Each test constructs a Rust type, serializes to `serde_json::Value`, and asserts field names and values match the TypeScript IPC contract. 246 + 247 + Tests to write: 248 + 1. **IdentityInfo serializes camelCase:** Assert `pdsUrl` (not `pds_url`), `currentRotationKeys`, `deviceKeyIsRoot` 249 + 2. **VerifiedClaimOp serializes camelCase:** Assert `signedOp`, `diff`, `warnings` 250 + 3. **OpDiff serializes camelCase:** Assert `addedKeys`, `removedKeys`, `changedServices`, `prevCid` 251 + 4. **ServiceChange serializes camelCase:** Assert `changeType`, `oldEndpoint`, `newEndpoint` 252 + 5. **ClaimResult serializes camelCase:** Assert `updatedDidDoc` 253 + 6. **ResolveError::HandleNotFound serializes correctly:** Assert `{"code": "HANDLE_NOT_FOUND"}` 254 + 7. **ResolveError::NetworkError serializes correctly:** Assert `{"code": "NETWORK_ERROR", "message": "..."}` 255 + 8. **ClaimError::VerificationFailed serializes correctly:** Assert `{"code": "VERIFICATION_FAILED", "message": "..."}` 256 + 9. **ClaimError::InvalidToken serializes correctly:** Assert `{"code": "INVALID_TOKEN"}` 257 + 10. **ClaimError::PlcDirectoryError serializes correctly:** Assert `{"code": "PLC_DIRECTORY_ERROR", "message": "..."}` 258 + 259 + Follow the exact pattern from `lib.rs` tests (e.g., `error_expired_code_serializes_correctly`). 260 + 261 + **Verification:** 262 + 263 + Run: `cargo test -p identity-wallet-tauri -- claim` 264 + Expected: All tests pass 265 + 266 + **Commit:** `test(identity-wallet): add serialization tests for claim types` 267 + <!-- END_TASK_4 --> 268 + <!-- END_SUBCOMPONENT_B -->
+150
docs/implementation-plans/2026-03-28-plc-key-management-phase4/phase_02.md
··· 1 + # Claim Flow Backend — Phase 2: start_pds_auth and request_claim_verification 2 + 3 + **Goal:** Implement OAuth authentication to an arbitrary PDS and the email verification trigger command. 4 + 5 + **Architecture:** `start_pds_auth` reuses the existing OAuth PKCE+DPoP infrastructure (`pkce::generate`, `DPoPKeypair`, `generate_state_param`, `handle_deep_link`) but targets an arbitrary PDS via `PdsClient` methods instead of the relay. After successful authentication, an `OAuthClient` pointing at the old PDS is stored in `ClaimState.pds_oauth_client`. `request_claim_verification` uses that client to call the XRPC endpoint `requestPlcOperationSignature`. 6 + 7 + **Tech Stack:** Rust, tauri, tokio, reqwest, serde 8 + 9 + **Scope:** 4 phases from design Phase 4 (this is phase 2 of 4) 10 + 11 + **Codebase verified:** 2026-03-28 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC4: Claim flow executes end-to-end 20 + - **plc-key-management.AC4.2 Success:** `request_claim_verification` calls `requestPlcOperationSignature` on the old PDS 21 + 22 + --- 23 + 24 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 25 + <!-- START_TASK_1 --> 26 + ### Task 1: Implement start_pds_auth command 27 + 28 + **Verifies:** None (infrastructure — OAuth flow wiring; verified operationally by downstream commands) 29 + 30 + **Files:** 31 + - Modify: `apps/identity-wallet/src-tauri/src/claim.rs` 32 + 33 + **Implementation:** 34 + 35 + Add `start_pds_auth` Tauri command to `claim.rs`. This command performs OAuth PKCE+DPoP against an arbitrary PDS discovered via `PdsClient`. It reuses the existing deep-link callback mechanism (`handle_deep_link` in `oauth.rs`). 36 + 37 + The function: 38 + 39 + 1. Reads `ClaimState.did` and `ClaimState.pds_url` from `AppState.claim_state` (set by `resolve_identity` in Phase 1). Returns `ClaimError::Unauthorized` if claim state is empty. 40 + 2. Calls `PdsClient::discover_auth_server(pds_url)` to get `AuthServerMetadata`. 41 + 3. Generates PKCE verifier/challenge via `oauth::pkce::generate()`. 42 + 4. Generates CSRF state via `oauth::generate_state_param()`. 43 + 5. Gets DPoP keypair via `oauth::DPoPKeypair::get_or_create()`, computes JWK thumbprint. 44 + 6. Builds DPoP proof for PAR: `dpop.make_proof("POST", &metadata.pushed_authorization_request_endpoint, None, None)`. 45 + 7. Calls `PdsClient::pds_par(metadata, pkce_challenge, state, dpop_proof, dpop_jkt, Some(did))` — passes the DID as `login_hint` so the PDS pre-selects the correct account. 46 + 8. Sets up `tokio::sync::oneshot::channel()` and stores `PendingOAuthFlow { tx, pkce_verifier, csrf_state }` in `AppState.pending_auth`. 47 + 9. Builds authorize URL via `PdsClient::build_pds_authorize_url(metadata, request_uri, Some(did))`. 48 + 10. Opens Safari via `app.opener().open_url(authorize_url)`. 49 + 11. Awaits the oneshot receiver. On timeout or channel drop, returns `ClaimError::Unauthorized`. 50 + 12. On receiving `CallbackParams { code, .. }`: 51 + - Builds DPoP proof for token exchange: `dpop.make_proof("POST", &metadata.token_endpoint, None, None)` 52 + - Calls `PdsClient::pds_token_exchange(metadata, code, pkce_verifier, dpop_proof)` 53 + - Implements nonce retry: if response status is 400 and contains `DPoP-Nonce` header with `"use_dpop_nonce"` error, rebuild proof with nonce and retry once 54 + - Parses successful response JSON to extract `access_token`, `refresh_token`, `expires_in` 55 + 13. Creates `OAuthSession { access_token, refresh_token, expires_at: now + expires_in, dpop_nonce: nonce_from_response }`. 56 + 14. Creates `OAuthClient::new(Arc::new(Mutex::new(session)), pds_url)`. **Note:** This creates a new `OAuthClient` instance that shares the same DPoP keypair (loaded from Keychain) as the relay's `OAuthClient`. This is intentional and safe — the DPoP keypair is used to generate per-request proofs with `htu` (target URI) binding, and DPoP nonces are tracked per-session (the `OAuthSession` object), not per-keypair. The PDS session and relay session are fully independent. 57 + 15. Stores the `OAuthClient` in `ClaimState.pds_oauth_client`. 58 + 16. Emits a `"pds_auth_ready"` Tauri event (frontend listens for this to advance the UI). 59 + 60 + The Tauri command signature: 61 + 62 + ```rust 63 + #[tauri::command] 64 + pub async fn start_pds_auth( 65 + app: tauri::AppHandle, 66 + state: tauri::State<'_, crate::oauth::AppState>, 67 + pds_url: String, 68 + ) -> Result<(), ClaimError> 69 + ``` 70 + 71 + Map errors: 72 + - `PdsClientError::*` → `ClaimError::NetworkError { message }` 73 + - `OAuthError::StateMismatch` → `ClaimError::Unauthorized` 74 + - `OAuthError::CallbackAbandoned` → `ClaimError::Unauthorized` 75 + - Channel drop / timeout → `ClaimError::Unauthorized` 76 + 77 + Note: `start_pds_auth` uses `pds_url` from the frontend parameter (passed from `IdentityInfo.pdsUrl` returned by `resolve_identity`). It also reads `ClaimState.did` for the login_hint. If `ClaimState` is empty, the user hasn't called `resolve_identity` first — return `Unauthorized`. 78 + 79 + **Verification:** 80 + 81 + Run: `cargo check -p identity-wallet-tauri` 82 + Expected: Compiles without errors 83 + 84 + Note: Full integration testing of OAuth flows requires Safari and deep-links which are not available in `cargo test`. The OAuth path is verified indirectly through `request_claim_verification` and `sign_and_verify_claim` tests that mock the `OAuthClient`. The token exchange nonce retry logic follows the same proven pattern from `exchange_code_with_retry` in `oauth.rs`. 85 + 86 + **Commit:** `feat(identity-wallet): implement start_pds_auth command` 87 + <!-- END_TASK_1 --> 88 + 89 + <!-- START_TASK_2 --> 90 + ### Task 2: Implement request_claim_verification command with tests 91 + 92 + **Verifies:** plc-key-management.AC4.2 93 + 94 + **Files:** 95 + - Modify: `apps/identity-wallet/src-tauri/src/claim.rs` 96 + 97 + **Implementation:** 98 + 99 + Add `request_claim_verification` Tauri command. This command calls the `requestPlcOperationSignature` XRPC endpoint on the old PDS to trigger email verification. 100 + 101 + The function: 102 + 1. Reads `ClaimState` from `AppState.claim_state`. Returns `ClaimError::Unauthorized` if empty. 103 + 2. Reads `pds_oauth_client` from ClaimState. Returns `ClaimError::Unauthorized` if `None` (user hasn't completed PDS auth). 104 + 3. Calls `pds_client::request_plc_operation_signature(oauth_client)`. 105 + 4. Returns `Ok(())` on success. 106 + 107 + Map errors: 108 + - `PdsClientError::NetworkError { message }` → `ClaimError::NetworkError { message }` 109 + - `PdsClientError::InvalidResponse { message }` → `ClaimError::NetworkError { message }` 110 + - Any non-2xx → `ClaimError::NetworkError` with status description 111 + 112 + The Tauri command signature: 113 + 114 + ```rust 115 + #[tauri::command] 116 + pub async fn request_claim_verification( 117 + state: tauri::State<'_, crate::oauth::AppState>, 118 + did: String, 119 + ) -> Result<(), ClaimError> 120 + ``` 121 + 122 + **Testing:** 123 + Tests must verify AC4.2: 124 + - plc-key-management.AC4.2: request_claim_verification calls requestPlcOperationSignature on the old PDS 125 + 126 + Test approach: Use `httpmock::MockServer` to mock the PDS's XRPC endpoint. Create an `OAuthClient` via `OAuthClient::new_for_test()` pointing at the mock server. Construct a `ClaimState` with the test `OAuthClient` and store it in an `AppState`. 127 + 128 + Specific test cases: 129 + 1. **Success — calls XRPC endpoint:** Set up mock expecting POST `/xrpc/com.atproto.identity.requestPlcOperationSignature` returning 200. Call core logic function. Assert mock was hit exactly once. 130 + 2. **Unauthorized — no claim state:** Call with empty claim state. Assert `ClaimError::Unauthorized`. 131 + 3. **Unauthorized — no OAuth client:** Set up ClaimState without `pds_oauth_client`. Assert `ClaimError::Unauthorized`. 132 + 4. **Network error — PDS returns 500:** Mock returns 500. Assert `ClaimError::NetworkError`. 133 + 134 + To make the core logic testable without Tauri's `State`, extract a helper: 135 + ```rust 136 + pub(crate) async fn request_claim_verification_impl( 137 + claim_state: &ClaimState, 138 + ) -> Result<(), ClaimError> 139 + ``` 140 + 141 + Follow existing pattern from `home.rs` (`load_home_data_with_urls` helper). 142 + 143 + **Verification:** 144 + 145 + Run: `cargo test -p identity-wallet-tauri -- claim::tests::request_claim` 146 + Expected: All tests pass 147 + 148 + **Commit:** `feat(identity-wallet): implement request_claim_verification command (AC4.2)` 149 + <!-- END_TASK_2 --> 150 + <!-- END_SUBCOMPONENT_A -->
+219
docs/implementation-plans/2026-03-28-plc-key-management-phase4/phase_03.md
··· 1 + # Claim Flow Backend — Phase 3: sign_and_verify_claim 2 + 3 + **Goal:** Implement the core claim verification command — call `signPlcOperation` on the old PDS, then verify the returned signed operation locally using the crypto crate before allowing submission. 4 + 5 + **Architecture:** `sign_and_verify_claim` coordinates three systems: (1) old PDS via XRPC for the signed operation, (2) plc.directory for the current audit log, and (3) the crypto crate for local verification. The local verification ensures the wallet never submits an operation it hasn't inspected. A new `fetch_audit_log` method on `PdsClient` fetches the audit log. Diffs between the proposed operation and the current DID document are computed to produce an `OpDiff` for the frontend. 6 + 7 + **Tech Stack:** Rust, serde, crypto crate (verify_plc_operation, parse_audit_log), tokio 8 + 9 + **Scope:** 4 phases from design Phase 4 (this is phase 3 of 4) 10 + 11 + **Codebase verified:** 2026-03-28 12 + 13 + **Prerequisite:** This phase depends on `crypto::build_did_plc_rotation_op` and `crypto::verify_plc_operation` from design Phase 1 (already implemented in `crates/crypto/src/plc.rs`). Tests in this phase use `build_did_plc_rotation_op` to construct valid/invalid mock PLC operations for verification testing. 14 + 15 + --- 16 + 17 + ## Acceptance Criteria Coverage 18 + 19 + This phase implements and tests: 20 + 21 + ### plc-key-management.AC4: Claim flow executes end-to-end 22 + - **plc-key-management.AC4.3 Success:** `sign_and_verify_claim` returns a verified operation with the device key at `rotationKeys[0]` 23 + - **plc-key-management.AC4.4 Failure:** `sign_and_verify_claim` returns `VERIFICATION_FAILED` when the old PDS returns an operation with a different key at `rotationKeys[0]` 24 + - **plc-key-management.AC4.5 Failure:** `sign_and_verify_claim` returns `VERIFICATION_FAILED` when `prev` does not chain from the current audit log 25 + - **plc-key-management.AC4.6 Failure:** `sign_and_verify_claim` returns `VERIFICATION_FAILED` when unexpected keys or services are altered 26 + - **plc-key-management.AC4.7 Success:** `sign_and_verify_claim` populates `warnings` for non-blocking concerns (e.g., old PDS added an extra service) 27 + - **plc-key-management.AC4.10 Failure:** `sign_and_verify_claim` returns `INVALID_TOKEN` when the email verification token is wrong 28 + 29 + --- 30 + 31 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 32 + <!-- START_TASK_1 --> 33 + ### Task 1: Add fetch_audit_log to PdsClient 34 + 35 + **Verifies:** None (infrastructure — enables prev chain verification) 36 + 37 + **Files:** 38 + - Modify: `apps/identity-wallet/src-tauri/src/pds_client.rs` 39 + 40 + **Implementation:** 41 + 42 + Add a method to `PdsClient` that fetches the audit log from plc.directory: 43 + 44 + ```rust 45 + /// Fetch the PLC operation audit log for a DID. 46 + /// 47 + /// Calls `GET {plc_directory_url}/{did}/log/audit` and returns the raw JSON string. 48 + pub async fn fetch_audit_log(&self, did: &str) -> Result<String, PdsClientError> { 49 + let url = format!("{}/{}/log/audit", self.plc_directory_url, did); 50 + let resp = self.client.get(&url).send().await 51 + .map_err(|e| PdsClientError::NetworkError { message: e.to_string() })?; 52 + if !resp.status().is_success() { 53 + return Err(PdsClientError::DidNotFound); 54 + } 55 + resp.text().await 56 + .map_err(|e| PdsClientError::NetworkError { message: e.to_string() }) 57 + } 58 + ``` 59 + 60 + Add a test: 61 + - Mock `GET /{did}/log/audit` returning a JSON array of audit entries. Assert the method returns the raw JSON string. 62 + 63 + **Verification:** 64 + 65 + Run: `cargo test -p identity-wallet-tauri -- pds_client::tests::audit` 66 + Expected: Test passes 67 + 68 + **Commit:** `feat(identity-wallet): add fetch_audit_log to PdsClient` 69 + <!-- END_TASK_1 --> 70 + 71 + <!-- START_TASK_2 --> 72 + ### Task 2: Implement sign_and_verify_claim command with tests 73 + 74 + **Verifies:** plc-key-management.AC4.3, plc-key-management.AC4.4, plc-key-management.AC4.5, plc-key-management.AC4.6, plc-key-management.AC4.7, plc-key-management.AC4.10 75 + 76 + **Files:** 77 + - Modify: `apps/identity-wallet/src-tauri/src/claim.rs` 78 + 79 + **Implementation:** 80 + 81 + Add `sign_and_verify_claim` Tauri command. Extract testable core logic into a helper function: 82 + 83 + ```rust 84 + pub(crate) async fn sign_and_verify_claim_impl( 85 + pds_client: &PdsClient, 86 + claim_state: &ClaimState, 87 + device_key_id: &str, 88 + token: &str, 89 + ) -> Result<(VerifiedClaimOp, String), ClaimError> 90 + ``` 91 + 92 + The function performs these steps: 93 + 94 + **Step 1: Get recommended credentials from old PDS** 95 + - Call `pds_client::get_recommended_did_credentials(oauth_client)` using the `pds_oauth_client` from `ClaimState`. 96 + - This returns the PDS's recommended `rotation_keys`, `also_known_as`, `verification_methods`, `services`. 97 + 98 + **Step 2: Build the sign request** 99 + - Construct `SignPlcOperationRequest` with: 100 + - `token`: the email verification token from the user 101 + - `rotation_keys`: `Some(vec![device_key_id, ...recommended.rotation_keys])` — device key prepended at position [0] 102 + - `also_known_as`: from recommended credentials (keep existing) 103 + - `verification_methods`: from recommended credentials (keep existing) 104 + - `services`: from recommended credentials (keep existing) 105 + 106 + **Step 3: Call signPlcOperation on old PDS** 107 + - Call `pds_client::sign_plc_operation(oauth_client, &request)`. 108 + - On error: inspect the HTTP error. If the PDS returns a 400-level error indicating an invalid token (check response body for `"InvalidToken"` or `"ExpiredToken"` error strings), return `ClaimError::InvalidToken`. Otherwise return `ClaimError::NetworkError`. 109 + - On success: get `SignPlcOperationResponse.operation` (a `serde_json::Value`). 110 + 111 + **Step 4: Serialize operation for verification** 112 + - Convert to JSON string: `serde_json::to_string(&response.operation)`. 113 + 114 + **Step 5: Fetch current audit log** 115 + - Call `pds_client.fetch_audit_log(&claim_state.did)`. 116 + - Parse via `crypto::parse_audit_log(&log_json)`. 117 + - Get the last entry's CID as `expected_prev`. 118 + 119 + **Step 6: Verify operation signature** 120 + - Build authorized rotation keys from the current DID document: `claim_state.did_doc.rotation_keys.iter().map(|k| crypto::DidKeyUri(k.clone())).collect()`. 121 + - Call `crypto::verify_plc_operation(&op_json_str, &authorized_keys)`. 122 + - On `CryptoError`: return `ClaimError::VerificationFailed { message }`. 123 + - On success: get `VerifiedPlcOp`. 124 + 125 + **Step 7: Local verification checks** 126 + 127 + Check 1 — **rotationKeys[0] is our device key** (AC4.3, AC4.4): 128 + ```rust 129 + if verified_op.rotation_keys.first() != Some(&device_key_id.to_string()) { 130 + return Err(ClaimError::VerificationFailed { 131 + message: format!( 132 + "Expected device key at rotationKeys[0], found: {:?}", 133 + verified_op.rotation_keys.first() 134 + ), 135 + }); 136 + } 137 + ``` 138 + 139 + Check 2 — **prev chains correctly** (AC4.5): 140 + ```rust 141 + match (&verified_op.prev, expected_prev.as_deref()) { 142 + (Some(op_prev), Some(expected)) if op_prev == expected => { /* OK */ } 143 + (prev, expected) => { 144 + return Err(ClaimError::VerificationFailed { 145 + message: format!( 146 + "prev mismatch: operation has {:?}, expected {:?}", 147 + prev, expected 148 + ), 149 + }); 150 + } 151 + } 152 + ``` 153 + 154 + Check 3 — **no unexpected key mutations** (AC4.6): 155 + Compare `verified_op.rotation_keys[1..]` against `claim_state.did_doc.rotation_keys`. Any key removed from the original set (other than natural reordering from our key insertion) is an error. 156 + 157 + Check 4 — **no unexpected service mutations** (AC4.6): 158 + Compare `verified_op.services` against `claim_state.did_doc.services`. Any service endpoint changed or service removed from the original set is an error. 159 + 160 + **Type conversion note:** `verified_op.services` is `BTreeMap<String, crypto::PlcService>` (from the crypto crate), while `claim_state.did_doc.services` is `HashMap<String, pds_client::PlcService>` (from pds_client). Both `PlcService` types have identical fields (`service_type: String`, `endpoint: String`). Compare by iterating the maps and matching on `service_type` and `endpoint` field values rather than comparing the types directly. 161 + 162 + **Step 8: Compute diff and warnings** 163 + 164 + Build `OpDiff`: 165 + - `added_keys`: keys in `verified_op.rotation_keys` not in `did_doc.rotation_keys` (should be just our device key) 166 + - `removed_keys`: keys in `did_doc.rotation_keys` not in `verified_op.rotation_keys` 167 + - `changed_services`: compare services maps — identify added, removed, modified services → `Vec<ServiceChange>` 168 + - `prev_cid`: `verified_op.prev.unwrap_or_default()` 169 + 170 + Build `warnings: Vec<String>` (AC4.7): 171 + - If the PDS added an extra service not in the original DID doc → add warning like `"Old PDS added service: {id}"` 172 + - If the PDS added extra `also_known_as` entries → add warning 173 + - These are non-blocking (not errors) because PDS may legitimately add auxiliary services. 174 + 175 + **Step 9: Store verified operation** 176 + - Store the signed operation JSON string in `ClaimState.verified_signed_op` for `submit_claim`. 177 + - Return `VerifiedClaimOp { diff, signed_op: op_json_str, warnings }`. 178 + 179 + The Tauri command wrapper: 180 + 181 + ```rust 182 + #[tauri::command] 183 + pub async fn sign_and_verify_claim( 184 + state: tauri::State<'_, crate::oauth::AppState>, 185 + did: String, 186 + token: String, 187 + ) -> Result<VerifiedClaimOp, ClaimError> 188 + ``` 189 + 190 + **Testing:** 191 + 192 + Each test sets up a `httpmock::MockServer` mocking both the PDS XRPC endpoints and plc.directory audit log. Create a test helper that builds a valid signed PLC rotation operation using the crypto crate's `build_did_plc_rotation_op` with a test keypair. 193 + 194 + Tests must verify each AC listed above: 195 + 196 + 1. **AC4.3 — success path:** Mock PDS returning a valid signed operation with test device key at `rotationKeys[0]`. Mock plc.directory audit log with matching `prev`. Assert returns `VerifiedClaimOp` with correct `diff.addedKeys` containing the device key. 197 + 198 + 2. **AC4.4 — wrong key at rotationKeys[0]:** Mock PDS returning operation with a DIFFERENT key at `rotationKeys[0]`. Assert `ClaimError::VerificationFailed` with message about wrong key. 199 + 200 + 3. **AC4.5 — prev chain mismatch:** Mock PDS returning operation with `prev` CID that doesn't match the last audit log entry's CID. Assert `ClaimError::VerificationFailed` with message about prev mismatch. 201 + 202 + 4. **AC4.6 — unexpected key removal:** Mock PDS returning operation that removes a rotation key from the original set. Assert `ClaimError::VerificationFailed`. 203 + 204 + 5. **AC4.6 — unexpected service change:** Mock PDS returning operation that changes an existing service endpoint. Assert `ClaimError::VerificationFailed`. 205 + 206 + 6. **AC4.7 — warnings for benign additions:** Mock PDS returning operation that adds an extra service not in original DID doc. Assert success with `warnings` non-empty. 207 + 208 + 7. **AC4.10 — invalid token:** Mock PDS returning 400 error for signPlcOperation. Assert `ClaimError::InvalidToken`. 209 + 210 + Follow existing test patterns: `#[tokio::test]`, `httpmock::MockServer`, test helper functions for constructing mock PLC operations using the crypto crate. 211 + 212 + **Verification:** 213 + 214 + Run: `cargo test -p identity-wallet-tauri -- claim::tests::sign_and_verify` 215 + Expected: All tests pass 216 + 217 + **Commit:** `feat(identity-wallet): implement sign_and_verify_claim with local verification (AC4.3-AC4.7, AC4.10)` 218 + <!-- END_TASK_2 --> 219 + <!-- END_SUBCOMPONENT_A -->
+277
docs/implementation-plans/2026-03-28-plc-key-management-phase4/phase_04.md
··· 1 + # Claim Flow Backend — Phase 4: submit_claim, Command Registration, and IPC Wrappers 2 + 3 + **Goal:** Complete the claim flow with `submit_claim` (posts to plc.directory + persists identity), register all five claim commands in Tauri's handler, and add typed TypeScript IPC wrappers. 4 + 5 + **Architecture:** `submit_claim` reads the verified signed operation from `ClaimState`, POSTs it to plc.directory, registers the identity in `IdentityStore`, stores the DID document and audit log, then clears claim state. Command registration adds all five claim commands to `generate_handler![]` in `lib.rs`. IPC wrappers in `ipc.ts` follow the existing typed function pattern with discriminated union error types. 6 + 7 + **Tech Stack:** Rust, serde, reqwest, TypeScript, Tauri IPC 8 + 9 + **Scope:** 4 phases from design Phase 4 (this is phase 4 of 4) 10 + 11 + **Codebase verified:** 2026-03-28 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC4: Claim flow executes end-to-end 20 + - **plc-key-management.AC4.8 Success:** `submit_claim` POSTs the signed operation to plc.directory and persists the identity to IdentityStore 21 + - **plc-key-management.AC4.9 Failure:** `submit_claim` returns `PLC_DIRECTORY_ERROR` when plc.directory rejects the operation 22 + 23 + --- 24 + 25 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 26 + <!-- START_TASK_1 --> 27 + ### Task 1: Add post_plc_operation to PdsClient 28 + 29 + **Verifies:** None (infrastructure — enables submit_claim) 30 + 31 + **Files:** 32 + - Modify: `apps/identity-wallet/src-tauri/src/pds_client.rs` 33 + 34 + **Implementation:** 35 + 36 + Add a method to `PdsClient` that submits a signed PLC operation to plc.directory: 37 + 38 + ```rust 39 + /// Submit a signed PLC operation to plc.directory. 40 + /// 41 + /// Calls `POST {plc_directory_url}/{did}` with the signed operation as JSON body. 42 + pub async fn post_plc_operation( 43 + &self, 44 + did: &str, 45 + operation: &serde_json::Value, 46 + ) -> Result<(), PdsClientError> { 47 + let url = format!("{}/{}", self.plc_directory_url, did); 48 + let resp = self.client.post(&url) 49 + .json(operation) 50 + .send() 51 + .await 52 + .map_err(|e| PdsClientError::NetworkError { message: e.to_string() })?; 53 + if resp.status().is_success() { 54 + Ok(()) 55 + } else { 56 + let body = resp.text().await.unwrap_or_default(); 57 + Err(PdsClientError::InvalidResponse { 58 + message: format!("plc.directory rejected operation: {}", body), 59 + }) 60 + } 61 + } 62 + ``` 63 + 64 + Add tests: 65 + - Mock POST `/{did}` returning 200. Assert method returns Ok(()). 66 + - Mock POST `/{did}` returning 409. Assert method returns error with body text. 67 + 68 + **Verification:** 69 + 70 + Run: `cargo test -p identity-wallet-tauri -- pds_client::tests::post_plc` 71 + Expected: Tests pass 72 + 73 + **Commit:** `feat(identity-wallet): add post_plc_operation to PdsClient` 74 + <!-- END_TASK_1 --> 75 + 76 + <!-- START_TASK_2 --> 77 + ### Task 2: Implement submit_claim command with tests 78 + 79 + **Verifies:** plc-key-management.AC4.8, plc-key-management.AC4.9 80 + 81 + **Files:** 82 + - Modify: `apps/identity-wallet/src-tauri/src/claim.rs` 83 + 84 + **Implementation:** 85 + 86 + Add `submit_claim` Tauri command. Extract testable core logic into a helper: 87 + 88 + ```rust 89 + pub(crate) async fn submit_claim_impl( 90 + pds_client: &PdsClient, 91 + claim_state: &ClaimState, 92 + ) -> Result<ClaimResult, ClaimError> 93 + ``` 94 + 95 + **Note:** `IdentityStore` is a stateless unit struct (no fields, all state lives in Keychain). It can be instantiated inline as `let store = IdentityStore;` — no need to pass it as a parameter or extract it from AppState. 96 + 97 + The function: 98 + 99 + 1. Read `verified_signed_op` from `ClaimState`. Return `ClaimError::Unauthorized` if `None` (user hasn't completed verification). 100 + 2. POST the signed operation to plc.directory: 101 + - Parse the stored JSON string back to `serde_json::Value` 102 + - Call `pds_client.post_plc_operation(&claim_state.did, &operation)` (implemented in Task 1) 103 + - Map `PdsClientError::InvalidResponse` → `ClaimError::PlcDirectoryError { message }` 104 + 3. Persist the claimed identity to `IdentityStore`: 105 + - `IdentityStore.add_identity(&did)` — registers DID in managed-dids index. If already exists (`IdentityAlreadyExists`), this is fine — the user may have a partially completed prior claim. 106 + - `IdentityStore.get_or_create_device_key(&did)` — ensure device key exists. 107 + - Re-fetch the DID document from plc.directory: `pds_client.discover_pds(&did)` → get updated `PlcDidDocument`. 108 + - `IdentityStore.store_did_doc(&did, &serde_json::to_string(&did_doc)?)` — persist updated DID doc. 109 + - `pds_client.fetch_audit_log(&did)` → `IdentityStore.store_plc_log(&did, &log_json)` — persist updated audit log. 110 + 4. Clear `ClaimState` (set `AppState.claim_state` to `None`). 111 + 5. Return `ClaimResult { updated_did_doc: serde_json::to_value(&did_doc) }`. 112 + 113 + The Tauri command wrapper: 114 + 115 + ```rust 116 + #[tauri::command] 117 + pub async fn submit_claim( 118 + state: tauri::State<'_, crate::oauth::AppState>, 119 + did: String, 120 + ) -> Result<ClaimResult, ClaimError> 121 + ``` 122 + 123 + **Testing:** 124 + 125 + Tests must verify each AC: 126 + 127 + 1. **AC4.8 — success:** Mock plc.directory POST `/{did}` returning 200. Set up ClaimState with a verified signed op. Assert: mock was hit with correct body, `IdentityStore.list_identities()` includes the DID, `IdentityStore.get_did_doc()` returns the updated doc, `IdentityStore.get_plc_log()` returns the stored log. Also mock the re-fetch of DID doc and audit log after submission. 128 + 129 + 2. **AC4.9 — plc.directory rejects operation:** Mock plc.directory POST returning 409 with error body. Assert `ClaimError::PlcDirectoryError` with message from response body. 130 + 131 + 3. **No verified op — unauthorized:** Call with ClaimState that has `verified_signed_op: None`. Assert `ClaimError::Unauthorized`. 132 + 133 + Follow existing test patterns: `#[tokio::test]`, `httpmock::MockServer` for plc.directory, in-memory Keychain mock for IdentityStore. 134 + 135 + **Verification:** 136 + 137 + Run: `cargo test -p identity-wallet-tauri -- claim::tests::submit` 138 + Expected: All tests pass 139 + 140 + **Commit:** `feat(identity-wallet): implement submit_claim command (AC4.8, AC4.9)` 141 + <!-- END_TASK_2 --> 142 + <!-- END_SUBCOMPONENT_A --> 143 + 144 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 145 + <!-- START_TASK_3 --> 146 + ### Task 3: Register claim commands in lib.rs 147 + 148 + **Verifies:** None (infrastructure — wiring) 149 + 150 + **Files:** 151 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (the `generate_handler![]` macro invocation) 152 + 153 + **Implementation:** 154 + 155 + Add all five claim commands to the `generate_handler![]` macro (search for `tauri::generate_handler!`): 156 + 157 + ```rust 158 + .invoke_handler(tauri::generate_handler![ 159 + create_account, 160 + get_or_create_device_key, 161 + sign_with_device_key, 162 + perform_did_ceremony, 163 + register_handle, 164 + check_handle_resolution, 165 + get_relay_url, 166 + save_relay_url, 167 + home::load_home_data, 168 + home::log_out, 169 + oauth::start_oauth_flow, 170 + claim::resolve_identity, 171 + claim::start_pds_auth, 172 + claim::request_claim_verification, 173 + claim::sign_and_verify_claim, 174 + claim::submit_claim, 175 + ]) 176 + ``` 177 + 178 + **Verification:** 179 + 180 + Run: `cargo check -p identity-wallet-tauri` 181 + Expected: Compiles without errors 182 + 183 + **Commit:** `feat(identity-wallet): register claim commands in Tauri handler` 184 + <!-- END_TASK_3 --> 185 + 186 + <!-- START_TASK_4 --> 187 + ### Task 4: Add TypeScript IPC wrappers 188 + 189 + **Verifies:** None (infrastructure — frontend IPC contract) 190 + 191 + **Files:** 192 + - Modify: `apps/identity-wallet/src/lib/ipc.ts` 193 + 194 + **Implementation:** 195 + 196 + Add typed TypeScript wrappers following the existing pattern. All new types and functions go after the existing exports. 197 + 198 + **Types to add:** 199 + 200 + ```typescript 201 + // --- Claim flow types --- 202 + 203 + export interface IdentityInfo { 204 + did: string; 205 + handle: string; 206 + pdsUrl: string; 207 + currentRotationKeys: string[]; 208 + deviceKeyIsRoot: boolean; 209 + } 210 + 211 + export interface VerifiedClaimOp { 212 + diff: OpDiff; 213 + signedOp: string; 214 + warnings: string[]; 215 + } 216 + 217 + export interface OpDiff { 218 + addedKeys: string[]; 219 + removedKeys: string[]; 220 + changedServices: ServiceChange[]; 221 + prevCid: string; 222 + } 223 + 224 + export interface ServiceChange { 225 + id: string; 226 + changeType: string; 227 + oldEndpoint: string | null; 228 + newEndpoint: string | null; 229 + } 230 + 231 + export interface ClaimResult { 232 + updatedDidDoc: Record<string, unknown>; 233 + } 234 + 235 + // --- Claim flow error types --- 236 + 237 + export type ResolveError = 238 + | { code: 'HANDLE_NOT_FOUND' } 239 + | { code: 'DID_NOT_FOUND' } 240 + | { code: 'PDS_UNREACHABLE' } 241 + | { code: 'NETWORK_ERROR'; message: string }; 242 + 243 + export type ClaimError = 244 + | { code: 'INVALID_TOKEN' } 245 + | { code: 'VERIFICATION_FAILED'; message: string } 246 + | { code: 'PLC_DIRECTORY_ERROR'; message: string } 247 + | { code: 'UNAUTHORIZED' } 248 + | { code: 'NETWORK_ERROR'; message: string }; 249 + ``` 250 + 251 + **Functions to add:** 252 + 253 + ```typescript 254 + export const resolveIdentity = (handleOrDid: string): Promise<IdentityInfo> => 255 + invoke('resolve_identity', { handleOrDid }); 256 + 257 + export const startPdsAuth = (pdsUrl: string): Promise<void> => 258 + invoke('start_pds_auth', { pdsUrl }); 259 + 260 + export const requestClaimVerification = (did: string): Promise<void> => 261 + invoke('request_claim_verification', { did }); 262 + 263 + export const signAndVerifyClaim = (did: string, token: string): Promise<VerifiedClaimOp> => 264 + invoke('sign_and_verify_claim', { did, token }); 265 + 266 + export const submitClaim = (did: string): Promise<ClaimResult> => 267 + invoke('submit_claim', { did }); 268 + ``` 269 + 270 + **Verification:** 271 + 272 + Run: `cd apps/identity-wallet && pnpm exec tsc --noEmit` 273 + Expected: TypeScript compiles without errors 274 + 275 + **Commit:** `feat(identity-wallet): add TypeScript IPC wrappers for claim commands` 276 + <!-- END_TASK_4 --> 277 + <!-- END_SUBCOMPONENT_B -->
+92
docs/implementation-plans/2026-03-28-plc-key-management-phase4/test-requirements.md
··· 1 + # Test Requirements: PLC Key Management Phase 4 2 + 3 + Phase 4 covers **plc-key-management.AC4: Claim flow executes end-to-end** (AC4.1 through AC4.10). All automated tests live in colocated `#[cfg(test)]` modules within the source files. Tests use `#[tokio::test]`, `httpmock::MockServer` for HTTP mocking, and the in-memory Keychain test double from `keychain.rs`. 4 + 5 + ## Automated Test Coverage 6 + 7 + | AC | Description | Test Type | Test Location | Test Name Pattern | 8 + |----|------------|-----------|---------------|-------------------| 9 + | AC4.1 | `resolve_identity` returns correct `IdentityInfo` including current rotation keys and PDS URL | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_resolve_identity_handle_returns_correct_info` | 10 + | AC4.1 | `resolve_identity` with DID input skips handle resolution | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_resolve_identity_did_input_skips_handle_resolution` | 11 + | AC4.1 | `resolve_identity` returns `HandleNotFound` when resolution fails | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_resolve_identity_handle_not_found` | 12 + | AC4.1 | `resolve_identity` returns `DidNotFound` when plc.directory 404s | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_resolve_identity_did_not_found` | 13 + | AC4.2 | `request_claim_verification` calls `requestPlcOperationSignature` on the old PDS | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_request_claim_verification_calls_xrpc` | 14 + | AC4.2 | `request_claim_verification` returns `Unauthorized` when no claim state exists | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_request_claim_verification_no_claim_state` | 15 + | AC4.2 | `request_claim_verification` returns `Unauthorized` when no OAuth client exists | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_request_claim_verification_no_oauth_client` | 16 + | AC4.2 | `request_claim_verification` returns `NetworkError` when PDS returns 500 | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_request_claim_verification_pds_error` | 17 + | AC4.3 | `sign_and_verify_claim` returns a verified operation with the device key at `rotationKeys[0]` | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_sign_and_verify_claim_success` | 18 + | AC4.4 | `sign_and_verify_claim` returns `VERIFICATION_FAILED` when a different key is at `rotationKeys[0]` | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_sign_and_verify_claim_wrong_key_at_position_zero` | 19 + | AC4.5 | `sign_and_verify_claim` returns `VERIFICATION_FAILED` when `prev` does not chain from the current audit log | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_sign_and_verify_claim_prev_chain_mismatch` | 20 + | AC4.6 | `sign_and_verify_claim` returns `VERIFICATION_FAILED` when unexpected keys are removed | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_sign_and_verify_claim_unexpected_key_removal` | 21 + | AC4.6 | `sign_and_verify_claim` returns `VERIFICATION_FAILED` when unexpected services are altered | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_sign_and_verify_claim_unexpected_service_change` | 22 + | AC4.7 | `sign_and_verify_claim` populates `warnings` for non-blocking concerns | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_sign_and_verify_claim_warnings_for_benign_additions` | 23 + | AC4.8 | `submit_claim` POSTs the signed operation to plc.directory and persists the identity to IdentityStore | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_submit_claim_success` | 24 + | AC4.9 | `submit_claim` returns `PLC_DIRECTORY_ERROR` when plc.directory rejects the operation | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_submit_claim_plc_directory_rejects` | 25 + | AC4.10 | `sign_and_verify_claim` returns `INVALID_TOKEN` when the email verification token is wrong | Integration | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_sign_and_verify_claim_invalid_token` | 26 + 27 + ### Supplementary Tests (infrastructure, not tied to a specific AC) 28 + 29 + | Description | Test Type | Test Location | Test Name Pattern | 30 + |------------|-----------|---------------|-------------------| 31 + | `IdentityInfo` serializes with camelCase field names | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_identity_info_serializes_camel_case` | 32 + | `VerifiedClaimOp` serializes with camelCase field names | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_verified_claim_op_serializes_camel_case` | 33 + | `OpDiff` serializes with camelCase field names | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_op_diff_serializes_camel_case` | 34 + | `ServiceChange` serializes with camelCase field names | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_service_change_serializes_camel_case` | 35 + | `ClaimResult` serializes with camelCase field names | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_claim_result_serializes_camel_case` | 36 + | `ResolveError::HandleNotFound` serializes to `{"code":"HANDLE_NOT_FOUND"}` | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_resolve_error_handle_not_found_serializes` | 37 + | `ResolveError::NetworkError` serializes to `{"code":"NETWORK_ERROR","message":"..."}` | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_resolve_error_network_error_serializes` | 38 + | `ClaimError::VerificationFailed` serializes to `{"code":"VERIFICATION_FAILED","message":"..."}` | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_claim_error_verification_failed_serializes` | 39 + | `ClaimError::InvalidToken` serializes to `{"code":"INVALID_TOKEN"}` | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_claim_error_invalid_token_serializes` | 40 + | `ClaimError::PlcDirectoryError` serializes to `{"code":"PLC_DIRECTORY_ERROR","message":"..."}` | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_claim_error_plc_directory_error_serializes` | 41 + | `submit_claim` returns `Unauthorized` when `verified_signed_op` is `None` | Unit | `apps/identity-wallet/src-tauri/src/claim.rs` `#[cfg(test)]` | `test_submit_claim_no_verified_op` | 42 + | `fetch_audit_log` returns raw JSON from plc.directory | Integration | `apps/identity-wallet/src-tauri/src/pds_client.rs` `#[cfg(test)]` | `test_fetch_audit_log_success` | 43 + | `post_plc_operation` POSTs operation and returns Ok on 200 | Integration | `apps/identity-wallet/src-tauri/src/pds_client.rs` `#[cfg(test)]` | `test_post_plc_operation_success` | 44 + | `post_plc_operation` returns error with body on rejection (409) | Integration | `apps/identity-wallet/src-tauri/src/pds_client.rs` `#[cfg(test)]` | `test_post_plc_operation_rejected` | 45 + 46 + ## Human Verification 47 + 48 + | AC | Description | Why Not Automated | Verification Approach | 49 + |----|------------|-------------------|----------------------| 50 + | AC4.2 | `request_claim_verification` calls `requestPlcOperationSignature` on the old PDS (live PDS round-trip) | The XRPC call to a live PDS (e.g., bsky.social) requires real OAuth tokens obtained via Safari deep-link, which is unavailable in `cargo test`. The automated test mocks the PDS with httpmock. | 1. Build and run on an iOS simulator or device. 2. Complete the claim flow through PDS auth (OAuth via Safari). 3. On the email verification screen, tap "Send code." 4. Confirm the PDS sends a verification email to the account's registered address. 5. Confirm no errors appear in the Tauri console log. | 51 + | AC4.3 | `sign_and_verify_claim` returns a verified operation with the device key at `rotationKeys[0]` (live PDS) | The automated test constructs mock PLC operations using the crypto crate. A live PDS (bsky.social) may produce operation structures with subtle differences from the mock (field ordering, extra fields, PDS-specific key formats). | 1. Continue from AC4.2 verification. 2. Enter the verification token received by email. 3. Confirm the review screen shows the device key as the first added rotation key. 4. Confirm `diff.addedKeys` on the review screen contains the device's `did:key` URI. 5. Confirm `diff.prevCid` is non-empty. | 52 + | AC4.8 | `submit_claim` POSTs the signed operation to plc.directory and persists the identity (live plc.directory) | Automated tests mock plc.directory. Verifying that plc.directory accepts a real signed operation and that the DID document updates correctly requires submitting to the live service, which permanently mutates the DID's state. | 1. **Use a test DID** (not a production identity). 2. Continue from AC4.3 verification. 3. Tap "Confirm" on the review screen. 4. Confirm success screen appears with updated DID document. 5. Verify at `https://plc.directory/{did}` that `rotationKeys[0]` is the device key. 6. Verify `https://plc.directory/{did}/log/audit` shows the new operation. 7. Restart the app and confirm the identity appears in the identity list. | 53 + 54 + ## Test Implementation Notes 55 + 56 + ### Test helpers and shared fixtures 57 + 58 + - **`ClaimState` construction:** Tests for `request_claim_verification_impl`, `sign_and_verify_claim_impl`, and `submit_claim_impl` all need a `ClaimState` with varying levels of completeness. Create a helper function like `make_test_claim_state(mock_server: &MockServer) -> ClaimState` that builds a fully populated state, and let individual tests override fields (e.g., set `pds_oauth_client` to `None` for unauthorized tests). 59 + 60 + - **Mock PLC operations:** Tests for AC4.3 through AC4.7 need valid signed PLC operations as mock PDS responses. Use `crypto::build_did_plc_rotation_op` with a test P-256 keypair to construct operations. This ensures the crypto crate's own verification accepts them, isolating the test to the claim module's logic (key position checks, prev chain validation, service mutation detection). 61 + 62 + - **`OAuthClient::new_for_test(base_url)`:** The existing test constructor on `OAuthClient` creates a client pointing at an httpmock server. Use this for all tests that need an authenticated PDS client in `ClaimState.pds_oauth_client`. 63 + 64 + - **`PdsClient::new_for_test(base_url)`:** The existing test constructor sets both the PDS base URL and the plc.directory URL to the mock server. Tests that need different URLs for PDS vs. plc.directory may need two mock servers or conditional URL routing via mock path patterns. 65 + 66 + - **In-memory Keychain:** The `#[cfg(test)]` Keychain implementation in `keychain.rs` uses a thread-local `HashMap`. `submit_claim` tests that verify IdentityStore persistence automatically use this in-memory backend. Call `keychain::tests::clear_test_keychain()` in test setup if isolation between tests is needed. 67 + 68 + ### Testable core logic pattern 69 + 70 + Each Tauri command extracts its core logic into a `_impl` helper that takes explicit parameters instead of `tauri::State`. This avoids constructing a full Tauri app context in tests: 71 + 72 + - `resolve_identity` -> core logic tested directly by constructing `PdsClient::new_for_test()` and calling the helper with mock server URLs 73 + - `request_claim_verification_impl(claim_state)` -> takes a `&ClaimState` reference 74 + - `sign_and_verify_claim_impl(pds_client, claim_state, device_key_id, token)` -> takes all dependencies explicitly 75 + - `submit_claim_impl(pds_client, claim_state)` -> takes PDS client and claim state 76 + 77 + ### Test execution 78 + 79 + ```bash 80 + # Run all Phase 4 claim tests 81 + cargo test -p identity-wallet-tauri -- claim 82 + 83 + # Run specific AC group 84 + cargo test -p identity-wallet-tauri -- claim::tests::resolve_identity 85 + cargo test -p identity-wallet-tauri -- claim::tests::request_claim 86 + cargo test -p identity-wallet-tauri -- claim::tests::sign_and_verify 87 + cargo test -p identity-wallet-tauri -- claim::tests::submit 88 + 89 + # Run PdsClient infrastructure tests added in Phase 4 90 + cargo test -p identity-wallet-tauri -- pds_client::tests::audit 91 + cargo test -p identity-wallet-tauri -- pds_client::tests::post_plc 92 + ```