···11+# MM-144: Mobile Onboarding Flow UI Design
22+33+## Summary
44+55+This document specifies the onboarding flow for `apps/identity-wallet/`, the Tauri v2 iOS app. A new user sees a five-step wizard — Welcome, Claim Code, Email, Handle, Loading — that collects credentials, calls the relay's account-creation endpoint, stores the returned tokens in the iOS Keychain, and hands off to the DID ceremony flow.
66+77+All relay API calls are proxied through Tauri IPC: the Svelte frontend invokes a Rust command, the Rust backend performs the HTTP request, stores tokens, and returns a typed result. The webview never holds raw credentials.
88+99+The work is structured in four phases: first, adding Rust dependencies and implementing the Keychain abstraction; second, implementing the `create_account` IPC command end-to-end; third, building the five Svelte screen components; fourth, wiring the state machine in `+page.svelte` and adding the typed IPC wrapper to `ipc.ts`.
1010+1111+## Definition of Done
1212+1313+1. A five-screen onboarding wizard (Welcome → Claim Code → Email → Handle → Loading) renders in the app.
1414+2. Submitting valid credentials calls `POST /v1/accounts/mobile` through Tauri IPC → Rust → HTTP.
1515+3. `device_token`, `session_token`, and the device private key are stored in the iOS Keychain.
1616+4. Errors (expired code, redeemed code, email taken, handle taken, network error) display user-friendly messages and return to the appropriate entry screen.
1717+5. On success, the app navigates to the DID ceremony flow (`nextStep: "did_creation"`).
1818+6. The flow works in the iOS simulator via `cargo tauri ios dev`.
1919+2020+## Acceptance Criteria
2121+2222+### MM-144.AC1: Onboarding screens render correctly
2323+- **MM-144.AC1.1 Success:** Welcome screen shows app branding and a "Get Started" CTA button that advances to Claim Code step
2424+- **MM-144.AC1.2 Success:** Claim Code screen shows a 6-character alphanumeric input; the Next button is disabled until exactly 6 characters are entered
2525+- **MM-144.AC1.3 Success:** Email screen shows an email input; the Next button is disabled until a valid email format is entered
2626+- **MM-144.AC1.4 Success:** Handle screen shows a handle input; the Next button is disabled until the handle is non-empty
2727+- **MM-144.AC1.5 Success:** Loading screen shows a spinner and status message while account creation is in progress
2828+- **MM-144.AC1.6 Success:** Each screen's Next/Submit button only advances when its validation condition is met
2929+3030+### MM-144.AC2: Account creation succeeds end-to-end
3131+- **MM-144.AC2.1 Success:** Valid email, handle, and claim code submission invokes the `create_account` Rust command via Tauri IPC
3232+- **MM-144.AC2.2 Success:** The Rust command POSTs to `POST /v1/accounts/mobile` with `email`, `handle`, `claimCode`, `devicePublicKey`, and `platform: "ios"`
3333+- **MM-144.AC2.3 Success:** On 201 response, `device_token` and `session_token` are stored in the iOS Keychain
3434+- **MM-144.AC2.4 Success:** The device P-256 private key is stored in the iOS Keychain before the HTTP request
3535+- **MM-144.AC2.5 Success:** On success, the frontend receives `{ nextStep: "did_creation" }` and advances past the loading screen
3636+3737+### MM-144.AC3: Error handling
3838+- **MM-144.AC3.1 Failure:** A relay 404 response (expired claim code) surfaces as "This claim code has expired. Please request a new one." and returns the user to the Claim Code screen
3939+- **MM-144.AC3.2 Failure:** A relay 409 response for a redeemed claim code surfaces as "This claim code has already been used." and returns the user to the Claim Code screen
4040+- **MM-144.AC3.3 Failure:** A relay 409 response for a taken email surfaces as "An account with that email already exists." and returns the user to the Email screen
4141+- **MM-144.AC3.4 Failure:** A relay 409 response for a taken handle surfaces as "That handle is taken. Please choose another." and returns the user to the Handle screen
4242+- **MM-144.AC3.5 Failure:** A network or server error (non-4xx) surfaces as "Couldn't reach the server. Check your connection." and returns the user to the Handle screen
4343+4444+### MM-144.AC4: iOS Keychain storage
4545+- **MM-144.AC4.1 Success:** `device_token` is stored in the iOS Keychain under service `"ezpds-identity-wallet"`, account `"device-token"` using `kSecClassGenericPassword`
4646+- **MM-144.AC4.2 Success:** `session_token` is stored in the iOS Keychain under service `"ezpds-identity-wallet"`, account `"session-token"` using `kSecClassGenericPassword`
4747+- **MM-144.AC4.3 Success:** Device P-256 private key (raw bytes) is stored in the iOS Keychain under service `"ezpds-identity-wallet"`, account `"device-private-key"` using `kSecClassGenericPassword`
4848+4949+### MM-144.AC5: iOS simulator compatibility
5050+- **MM-144.AC5.1 Success:** `cargo build --workspace` succeeds after adding new Rust dependencies
5151+- **MM-144.AC5.2 Success:** `pnpm build` in `apps/identity-wallet/` succeeds after adding new frontend components
5252+5353+## Glossary
5454+5555+- **Onboarding flow**: The sequence of screens a new user sees before their account exists. Terminates by calling the relay provisioning endpoint and storing credentials.
5656+- **Claim code**: A 6-character alphanumeric code pre-generated by an admin. Redeemable once; identifies a provisioning slot.
5757+- **Handle**: An ATProto handle (e.g. `alice.ezpds.com`). Must be unique across the relay. User-chosen during onboarding.
5858+- **Device keypair**: A P-256 elliptic curve keypair generated on-device at account creation time. The public key is sent to the relay; the private key never leaves the device (stored in Keychain).
5959+- **device_token**: A 32-byte base64url-encoded token returned by the relay, scoped to this device. Used to authenticate device-level operations.
6060+- **session_token**: A 32-byte base64url-encoded token returned by the relay, scoped to this session. Used to authenticate user-level API calls.
6161+- **iOS Keychain**: Apple's secure credential store. Items are AES-256-GCM encrypted, hardware-backed, and scoped to the app's bundle ID. Accessed via Security.framework.
6262+- **`kSecClassGenericPassword`**: The Keychain item class for storing arbitrary secrets (tokens, keys). Identified by `service` + `account` pair.
6363+- **`security-framework`**: A Rust crate that wraps Apple's Security.framework, providing safe bindings to Keychain operations.
6464+- **`reqwest`**: An async Rust HTTP client library. Used with `rustls-tls` (no OpenSSL dependency) for iOS compatibility.
6565+- **State machine**: The frontend navigation model. A `step` variable of type `OnboardingStep` determines which screen component is rendered. Transitions are triggered by user actions and async results.
6666+- **DID ceremony**: The next phase after onboarding (out of scope for this ticket). The onboarding flow hands off to it by emitting `nextStep: "did_creation"`.
6767+6868+## Architecture
6969+7070+Five-screen single-page state machine in `+page.svelte`. All Svelte screens are separate components in `src/lib/components/onboarding/`. The `create_account` Tauri IPC command is the only new Rust command — it encapsulates key generation, HTTP, and Keychain writes atomically from the frontend's perspective.
7171+7272+### Frontend Structure
7373+7474+```
7575+src/
7676+ routes/
7777+ +page.svelte ← owns step + form $state; renders active screen
7878+ lib/
7979+ ipc.ts ← adds createAccount() alongside existing greet()
8080+ components/
8181+ onboarding/
8282+ WelcomeScreen.svelte
8383+ ClaimCodeScreen.svelte ← 6-char, auto-uppercase, length-gated submit
8484+ EmailScreen.svelte ← regex email validation
8585+ HandleScreen.svelte ← non-empty validation
8686+ LoadingScreen.svelte ← spinner + status text
8787+```
8888+8989+### State Machine
9090+9191+```typescript
9292+// Contracts only — implementation in Phase 4
9393+type OnboardingStep = 'welcome' | 'claim_code' | 'email' | 'handle' | 'loading' | 'error';
9494+9595+// +page.svelte state:
9696+let step: OnboardingStep = $state('welcome');
9797+let form = $state({ claimCode: '', email: '', handle: '' });
9898+let errorMessage: string | null = $state(null);
9999+```
100100+101101+### IPC Contract
102102+103103+```typescript
104104+// src/lib/ipc.ts additions
105105+type CreateAccountParams = { claimCode: string; email: string; handle: string };
106106+type CreateAccountResult = { nextStep: string };
107107+108108+export const createAccount: (p: CreateAccountParams) => Promise<CreateAccountResult>;
109109+```
110110+111111+```rust
112112+// src-tauri/src/lib.rs — command signature
113113+#[tauri::command]
114114+async fn create_account(
115115+ claim_code: String,
116116+ email: String,
117117+ handle: String,
118118+) -> Result<CreateAccountResult, CreateAccountError>
119119+```
120120+121121+### Rust Backend Structure
122122+123123+```
124124+src-tauri/src/
125125+ lib.rs ← registers create_account alongside greet
126126+ keychain.rs ← store_token / get_token via security-framework
127127+ http.rs ← relay HTTP client wrapper (reqwest)
128128+```
129129+130130+### Error Mapping
131131+132132+```rust
133133+// Contracts only
134134+#[derive(serde::Serialize)]
135135+#[serde(tag = "code")]
136136+enum CreateAccountError {
137137+ ExpiredCode,
138138+ RedeemedCode,
139139+ EmailTaken,
140140+ HandleTaken,
141141+ NetworkError { message: String },
142142+ Unknown { message: String },
143143+}
144144+```
145145+146146+HTTP status → error variant:
147147+- 404 → `ExpiredCode`
148148+- 409 + body "claim code already redeemed" → `RedeemedCode`
149149+- 409 + body "email already taken" → `EmailTaken`
150150+- 409 + body "handle already taken" → `HandleTaken`
151151+- Other / network failure → `NetworkError`
152152+153153+### Relay Configuration
154154+155155+```rust
156156+// src-tauri/src/http.rs
157157+#[cfg(debug_assertions)]
158158+const RELAY_BASE_URL: &str = "http://localhost:8080";
159159+#[cfg(not(debug_assertions))]
160160+const RELAY_BASE_URL: &str = "https://relay.ezpds.com";
161161+```
162162+163163+### New Cargo Dependencies
164164+165165+```toml
166166+# src-tauri/Cargo.toml
167167+reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
168168+security-framework = "3"
169169+```
170170+171171+## Existing Patterns
172172+173173+**IPC commands:** Follow the existing `greet` pattern — `#[tauri::command]`, registered with `generate_handler!` in `run()`, wrapped in `ipc.ts`. `create_account` is a new command alongside `greet`.
174174+175175+**`ipc.ts`:** All IPC calls go through this file; no raw `invoke()` in components. `createAccount()` follows the same pattern as `greet()`.
176176+177177+**Svelte 5 runes:** `$state()` for reactive variables, consistent with `+page.svelte` existing usage.
178178+179179+**Cargo workspace:** New dependencies declared locally in `src-tauri/Cargo.toml`, not in `[workspace.dependencies]` — consistent with all Tauri-specific deps being local.
180180+181181+## Implementation Phases
182182+183183+<!-- START_PHASE_1 -->
184184+### Phase 1: Rust Infrastructure — Keychain + HTTP Client
185185+186186+**Goal:** Add `reqwest` and `security-framework` to `src-tauri/Cargo.toml`, implement `keychain.rs` (store/get tokens via iOS Keychain), and implement `http.rs` (relay HTTP client wrapper). No new IPC command yet — this phase is pure infrastructure.
187187+188188+**Components:**
189189+- `apps/identity-wallet/src-tauri/Cargo.toml` — add `reqwest` (0.12, json + rustls-tls) and `security-framework` (3) dependencies; add `tokio` if not present for async runtime
190190+- `apps/identity-wallet/src-tauri/src/keychain.rs` — `store_token(service, account, data) -> Result<()>` and `get_token(service, account) -> Result<Vec<u8>>` using `security-framework`
191191+- `apps/identity-wallet/src-tauri/src/http.rs` — `RelayClient` struct with `base_url`, `new() -> Self`, and `post_json<T, R>(path, body) -> Result<R>` using `reqwest`; `RELAY_BASE_URL` constant with `#[cfg(debug_assertions)]` split
192192+- `apps/identity-wallet/src-tauri/src/lib.rs` — add `mod keychain; mod http;` declarations
193193+194194+**Dependencies:** None (Phase 1 is infrastructure; no IPC command implemented here).
195195+196196+**Done when:** `cargo build --workspace` succeeds cleanly. `cargo clippy --workspace -- -D warnings` passes. `cargo fmt --all --check` passes.
197197+<!-- END_PHASE_1 -->
198198+199199+<!-- START_PHASE_2 -->
200200+### Phase 2: create_account IPC Command
201201+202202+**Goal:** Implement the `create_account` Tauri IPC command. It generates a P-256 keypair using `crates/crypto`, stores the private key in Keychain, POSTs to the relay, stores `device_token` and `session_token` in Keychain, and returns `CreateAccountResult` or a typed `CreateAccountError`.
203203+204204+**Components:**
205205+- `apps/identity-wallet/src-tauri/Cargo.toml` — add `ezpds-crypto` workspace dependency (path: `../../../crates/crypto`)
206206+- `apps/identity-wallet/src-tauri/src/lib.rs` — implement `create_account` command: gen keypair via `crypto`, store privkey in Keychain, call `http::RelayClient::post_json`, store tokens in Keychain, return result; add `CreateAccountResult` and `CreateAccountError` types; register command in `generate_handler!`
207207+- `apps/identity-wallet/src-tauri/src/keychain.rs` — may need `store_bytes` variant alongside `store_token` for raw key material
208208+209209+**Dependencies:** Phase 1 (keychain.rs and http.rs must exist).
210210+211211+**Done when:** `cargo build --workspace` succeeds. The command compiles and is registered. `cargo clippy` and `cargo fmt --check` pass. (End-to-end HTTP test requires simulator and running relay — manual only.)
212212+<!-- END_PHASE_2 -->
213213+214214+<!-- START_PHASE_3 -->
215215+### Phase 3: Onboarding Screen Components
216216+217217+**Goal:** Build the five Svelte screen components. Each is self-contained: it owns local validation state and emits events to the parent. No global state or IPC calls in the components themselves.
218218+219219+**Components:**
220220+- `apps/identity-wallet/src/lib/components/onboarding/WelcomeScreen.svelte` — app name/tagline, "Get Started" button, emits `start` event
221221+- `apps/identity-wallet/src/lib/components/onboarding/ClaimCodeScreen.svelte` — 6-char alphanumeric input (auto-uppercase), Next disabled until `value.length === 6`, emits `next` with value
222222+- `apps/identity-wallet/src/lib/components/onboarding/EmailScreen.svelte` — email input, basic regex validation, Next disabled until valid, emits `next` with value
223223+- `apps/identity-wallet/src/lib/components/onboarding/HandleScreen.svelte` — handle input, non-empty validation, Next disabled until non-empty, emits `next` with value
224224+- `apps/identity-wallet/src/lib/components/onboarding/LoadingScreen.svelte` — spinner, status text prop, no interactive elements
225225+226226+**Dependencies:** None (components are standalone; no IPC yet).
227227+228228+**Done when:** `pnpm build` in `apps/identity-wallet/` succeeds with no TypeScript errors. Components render without error (manual visual check in simulator or browser dev).
229229+<!-- END_PHASE_3 -->
230230+231231+<!-- START_PHASE_4 -->
232232+### Phase 4: State Machine Orchestrator + ipc.ts Integration
233233+234234+**Goal:** Wire up `+page.svelte` as the onboarding state machine. Replace the `greet` demo with the five-screen wizard. Add `createAccount()` to `ipc.ts`. Handle success (navigate to DID ceremony placeholder) and all error cases (typed error → user message → step reversion).
235235+236236+**Components:**
237237+- `apps/identity-wallet/src/lib/ipc.ts` — add `CreateAccountParams`, `CreateAccountResult`, `CreateAccountError` types and `createAccount()` wrapper
238238+- `apps/identity-wallet/src/routes/+page.svelte` — replace greet demo with onboarding state machine: `step: OnboardingStep` + `form` + `errorMessage` using Svelte 5 `$state`; render active screen component; on loading step invoke `createAccount()`, on success advance to DID placeholder, on error map code → message + step reversion
239239+240240+**Error → screen mapping:**
241241+- `EXPIRED_CODE` / `REDEEMED_CODE` → `'claim_code'` step + message
242242+- `EMAIL_TAKEN` → `'email'` step + message
243243+- `HANDLE_TAKEN` → `'handle'` step + message
244244+- `NETWORK_ERROR` / `UNKNOWN` → `'handle'` step + message
245245+246246+**Dependencies:** Phase 2 (IPC command), Phase 3 (screen components).
247247+248248+**Done when:** `pnpm build` succeeds. State machine renders each screen in sequence. Error cases display correct messages and revert to correct step. (Full end-to-end test requires simulator + relay — manual only.)
249249+<!-- END_PHASE_4 -->
250250+251251+## Additional Considerations
252252+253253+**`reqwest` TLS on iOS:** Use `rustls-tls` feature with `default-features = false`. Avoids OpenSSL (not available on iOS) and `native-tls` (links against macOS Security framework in a way that conflicts with iOS sysroot). `rustls-tls` bundles its own TLS stack.
254254+255255+**`security-framework` on iOS vs macOS:** The crate supports both targets. On iOS, some macOS-only APIs are unavailable but the Keychain password APIs (`GenericPassword`) are present on both. Use `security_framework::passwords::get_generic_password` / `set_generic_password`.
256256+257257+**P-256 key generation:** The `crates/crypto` crate already implements P-256 key generation. The `create_account` command imports this crate via workspace path dependency, not an external crate.
258258+259259+**`tokio` runtime:** Tauri v2 uses `tokio` internally for async commands. Check if `tokio` is already a transitive dependency before adding it explicitly — it may only need `features = ["rt"]` added if present.
260260+261261+**DID ceremony placeholder:** Phase 4 emits a placeholder on `nextStep === "did_creation"` (e.g., a simple "Setup complete" screen). The actual DID ceremony is out of scope for MM-144.
262262+263263+**Claim code error disambiguation:** The relay's `create_mobile_account.rs` returns 404 for invalid/expired codes and 409 for already-redeemed codes. The Rust error mapping reads the HTTP status code, not the body text, to distinguish these cases. The 409 body is used to distinguish `EmailTaken` vs `HandleTaken`.
···56565757**Step 1: Create nix/module.nix**
58585959-Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/nix/module.nix` with the following contents:
5959+Create `/Users/malpercio/workspace/malpercio-dev/ezpds/nix/module.nix` with the following contents:
60606161```nix
6262{ lib, pkgs, config, ... }:
···33333434**Step 1: Edit flake.nix**
35353636-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/flake.nix`, insert the `nixosModules.default` output after the closing `);` of the `devShells` output and before the `};` that closes the outputs let block.
3636+In `/Users/malpercio/workspace/malpercio-dev/ezpds/flake.nix`, insert the `nixosModules.default` output after the closing `);` of the `devShells` output and before the `};` that closes the outputs let block.
37373838The current end of the outputs block is:
3939
···93939494**Files:** None (read-only validation)
95959696-All `nix eval` commands below must be run from the repo root (`/Users/jacob.zweifel/workspace/malpercio-dev/ezpds`).
9696+All `nix eval` commands below must be run from the repo root (`/Users/malpercio/workspace/malpercio-dev/ezpds`).
97979898**Step 1: Verify ExecStart with minimal config**
9999···373373374374**Step 1: Add nix-check recipe**
375375376376-Append to `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/justfile`:
376376+Append to `/Users/malpercio/workspace/malpercio-dev/ezpds/justfile`:
377377378378```just
379379# Validate NixOS module evaluation (flake structure check).
···45454646**Step 1: Update AppState struct**
47474848-Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`.
4848+Open `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`.
49495050The current `AppState` (lines 7–13):
5151```rust
···196196197197**Step 1: Review current main.rs structure**
198198199199-The relevant section of `run()` in `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/main.rs` currently looks like this (lines 23–45):
199199+The relevant section of `run()` in `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/main.rs` currently looks like this (lines 23–45):
200200201201```rust
202202async fn run() -> anyhow::Result<()> {
···53535454**Step 1: Add ciborium and data-encoding to workspace Cargo.toml**
55555656-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add these two lines after the existing `base64` entry:
5656+In `/Users/malpercio/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add these two lines after the existing `base64` entry:
57575858```toml
5959ciborium = "0.2"
···62626363**Step 2: Add new deps to crates/crypto/Cargo.toml**
64646565-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/Cargo.toml`, add to the `[dependencies]` section:
6565+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/Cargo.toml`, add to the `[dependencies]` section:
66666767```toml
6868ciborium = { workspace = true }
···127127128128**Step 1: Add PlcOperation variant to error.rs**
129129130130-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/src/error.rs`, add the new variant to the `CryptoError` enum:
130130+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/src/error.rs`, add the new variant to the `CryptoError` enum:
131131132132```rust
133133#[derive(Debug, thiserror::Error)]
···151151152152**Step 2: Create crates/crypto/src/plc.rs**
153153154154-Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/src/plc.rs` with this content:
154154+Create `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/src/plc.rs` with this content:
155155156156```rust
157157// pattern: Functional Core
···550550551551**Step 3: Add plc module to lib.rs and re-export public types**
552552553553-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/src/lib.rs`, add the new module declaration and re-exports:
553553+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/src/lib.rs`, add the new module declaration and re-exports:
554554555555```rust
556556// crypto: signing, Shamir secret sharing, DID operations.
···604604605605**Step 1: Add new contracts to CLAUDE.md**
606606607607-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add the following to the **Public API contracts** section (add after the existing contracts):
607607+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add the following to the **Public API contracts** section (add after the existing contracts):
608608609609```markdown
610610### `build_did_plc_genesis_op`
···55555656**Step 1: Add reqwest to workspace Cargo.toml**
57575858-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add after the existing entries:
5858+In `/Users/malpercio/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add after the existing entries:
59596060```toml
6161reqwest = { version = "0.12", features = ["json"] }
···63636464**Step 2: Update crates/relay/Cargo.toml**
65656666-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`, add to `[dependencies]`:
6666+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`, add to `[dependencies]`:
67676868```toml
6969reqwest = { workspace = true }
···106106107107**Step 1: Create the migration file**
108108109109-Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations/V008__did_promotion.sql`:
109109+Create `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations/V008__did_promotion.sql`:
110110111111```sql
112112-- V008: DID promotion support
···154154155155**Step 2: Add V008 to the MIGRATIONS array in db/mod.rs**
156156157157-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/mod.rs`, find the `MIGRATIONS` static array. Add the V008 entry after V007:
157157+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/db/mod.rs`, find the `MIGRATIONS` static array. Add the V008 entry after V007:
158158159159```rust
160160Migration { version: 8, sql: include_str!("migrations/V008__did_promotion.sql") },
···177177178178**Step 3: Update crates/relay/src/db/CLAUDE.md**
179179180180-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add to the Key Files section:
180180+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/db/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add to the Key Files section:
181181182182```
183183- `migrations/V008__did_promotion.sql` - Rebuilds accounts with nullable password_hash (mobile accounts have no password); adds pending_did column to pending_accounts for DID pre-store retry resilience
···210210211211**Step 1: Add plc_directory_url to Config struct**
212212213213-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/common/src/config.rs`:
213213+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/common/src/config.rs`:
214214215215**1a. Add to `Config` struct** (after `signing_key_master_key`):
216216···253253254254**Step 2: Add ErrorCode variants**
255255256256-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/common/src/error.rs`, add to the `ErrorCode` enum (keeping the existing variants unchanged). Match the existing pattern — bare variants with doc comments, no `#[error(...)]` attribute (the enum derives `Serialize` for wire format, not `thiserror::Error`):
256256+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/common/src/error.rs`, add to the `ErrorCode` enum (keeping the existing variants unchanged). Match the existing pattern — bare variants with doc comments, no `#[error(...)]` attribute (the enum derives `Serialize` for wire format, not `thiserror::Error`):
257257258258```rust
259259/// The DID has already been fully promoted to an active account.
···308308309309**Step 1: Add http_client field to AppState**
310310311311-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`:
311311+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`:
312312313313**1a. Add reqwest use import** (at the top of the file with other imports):
314314···552552553553**Step 2: Create crates/relay/src/routes/create_did.rs**
554554555555-Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/routes/create_did.rs`:
555555+Create `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/routes/create_did.rs`:
556556557557```rust
558558// pattern: Imperative Shell
···1368136813691369**Step 3: Add create_did module to routes/mod.rs**
1370137013711371-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/routes/mod.rs`, add:
13711371+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/routes/mod.rs`, add:
1372137213731373```rust
13741374pub mod create_did;
···1380138013811381**Step 4: Register POST /v1/dids in app.rs router**
1382138213831383-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`, in the `app(state: AppState)` function:
13831383+In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`, in the `app(state: AppState)` function:
1384138413851385**4a. Add the import** at the top of the function body or via `use`:
13861386···1398139813991399**Step 5: Create bruno/create-did.bru**
1400140014011401-Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/bruno/create-did.bru`:
14011401+Create `/Users/malpercio/workspace/malpercio-dev/ezpds/bruno/create-did.bru`:
1402140214031403```
14041404meta {
···191191192192**Step 10: Update `.gitignore` to exclude frontend build artifacts**
193193194194-The workspace `.gitignore` is at `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/.gitignore`. Append these lines at the end of the file:
194194+The workspace `.gitignore` is at `/Users/malpercio/workspace/malpercio-dev/ezpds/.gitignore`. Append these lines at the end of the file:
195195196196```
197197# SvelteKit / frontend build artifacts
···314314**Verifies:** MM-143.AC2.1, MM-143.AC2.2, MM-143.AC2.3, MM-143.AC2.4, MM-143.AC2.5
315315316316**Files:**
317317-- Modify: `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml` (add workspace member)
318318-- Modify: `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/rust-toolchain.toml` (add iOS targets)
317317+- Modify: `/Users/malpercio/workspace/malpercio-dev/ezpds/Cargo.toml` (add workspace member)
318318+- Modify: `/Users/malpercio/workspace/malpercio-dev/ezpds/rust-toolchain.toml` (add iOS targets)
319319320320**Step 1: Add `apps/identity-wallet/src-tauri` to workspace members**
321321322322-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`, the current `members` block is:
322322+In `/Users/malpercio/workspace/malpercio-dev/ezpds/Cargo.toml`, the current `members` block is:
323323324324```toml
325325members = [
···344344345345**Step 2: Add iOS targets to `rust-toolchain.toml`**
346346347347-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/rust-toolchain.toml`, the current `targets` line is:
347347+In `/Users/malpercio/workspace/malpercio-dev/ezpds/rust-toolchain.toml`, the current `targets` line is:
348348349349```toml
350350targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
···4747- Modify: `apps/identity-wallet/src-tauri/Cargo.toml` (add tauri and tauri-build deps)
4848- Create: `apps/identity-wallet/src-tauri/build.rs`
4949- Create: `apps/identity-wallet/src-tauri/tauri.conf.json`
5050-- Modify: `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/flake.nix` (scope `buildDepsOnly` to relay-only packages)
5050+- Modify: `/Users/malpercio/workspace/malpercio-dev/ezpds/flake.nix` (scope `buildDepsOnly` to relay-only packages)
51515252**IMPORTANT — ordering:** `tauri::generate_context!()` (added in Task 2) reads `tauri.conf.json` at compile time. Both `tauri.conf.json` and the `tauri-build` build script must exist BEFORE updating `lib.rs` in Task 2. Create all three files in this task, then verify `cargo build` succeeds with the Phase 1 stub `lib.rs` before proceeding to Task 2.
5353···144144145145Fix: scope `buildDepsOnly` to only the 4 relay-related packages.
146146147147-In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/flake.nix`, the current `cargoArtifacts` line (line 42) is:
147147+In `/Users/malpercio/workspace/malpercio-dev/ezpds/flake.nix`, the current `cargoArtifacts` line (line 42) is:
148148149149```nix
150150 cargoArtifacts = craneLib.buildDepsOnly commonArgs;
···11+# MM-144 Onboarding Flow — Implementation Plan
22+33+**Goal:** Add Rust dependencies and implement the Keychain abstraction (`keychain.rs`) and relay HTTP client (`http.rs`) for the identity-wallet Tauri backend.
44+55+**Architecture:** Infrastructure phase only — no IPC command, no frontend changes. Two new Rust modules are added to `src-tauri/src/`, new crate dependencies are added to `src-tauri/Cargo.toml`, and module declarations are added to `lib.rs`. Verification is `cargo build` success.
66+77+**Tech Stack:** Rust stable, Tauri v2, `security-framework` v3 (iOS Keychain), `reqwest` v0.12 (`rustls-tls`), `thiserror` v2 (workspace dep)
88+99+**Scope:** Phase 1 of 4
1010+1111+**Codebase verified:** 2026-03-15
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This infrastructure phase does not implement user-facing behavior. It creates the building blocks Phase 2 depends on.
1818+1919+**Verifies:** None — operational verification only (`cargo build`, `cargo clippy`, `cargo fmt --check`)
2020+2121+---
2222+2323+<!-- START_SUBCOMPONENT_A (tasks 1-4) -->
2424+2525+<!-- START_TASK_1 -->
2626+### Task 1: Add Cargo dependencies
2727+2828+**Files:**
2929+- Modify: `apps/identity-wallet/src-tauri/Cargo.toml`
3030+3131+**Step 1: Add the new dependencies**
3232+3333+Open `apps/identity-wallet/src-tauri/Cargo.toml`. The current `[dependencies]` section is:
3434+3535+```toml
3636+[dependencies]
3737+tauri = { version = "2", features = [] }
3838+serde = { workspace = true }
3939+serde_json = { workspace = true }
4040+```
4141+4242+Replace with:
4343+4444+```toml
4545+[dependencies]
4646+tauri = { version = "2", features = [] }
4747+serde = { workspace = true }
4848+serde_json = { workspace = true }
4949+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
5050+security-framework = "3"
5151+thiserror = { workspace = true }
5252+```
5353+5454+**Why `default-features = false` on reqwest:** The default features include `default-tls` (OpenSSL). On iOS there is no OpenSSL; `rustls-tls` bundles its own TLS implementation.
5555+5656+**Why `thiserror = { workspace = true }`:** The root `Cargo.toml` has `thiserror = "2"` in `[workspace.dependencies]` (used by `crates/crypto`). The `keychain.rs` module uses it for `KeychainError`.
5757+5858+**Note:** `crypto = { workspace = true }` is NOT added here — it is only needed in Phase 2 when `create_account` calls `generate_p256_keypair`. Adding it now would create unused-dependency warnings.
5959+6060+**Step 2: Verify the change compiles**
6161+6262+```bash
6363+cargo build -p identity-wallet
6464+```
6565+6666+Expected: dependency resolution and download succeed; compilation may fail with "unused import" warnings until modules are created in later tasks — that is fine at this step. If resolution itself fails (e.g., `rustls-tls` feature not found), check reqwest version.
6767+6868+**Step 3: Commit**
6969+7070+```bash
7171+git add apps/identity-wallet/src-tauri/Cargo.toml
7272+git commit -m "chore(identity-wallet): add reqwest, security-framework, thiserror deps"
7373+```
7474+<!-- END_TASK_1 -->
7575+7676+<!-- START_TASK_2 -->
7777+### Task 2: Create `keychain.rs`
7878+7979+**Files:**
8080+- Create: `apps/identity-wallet/src-tauri/src/keychain.rs`
8181+8282+**Step 1: Create the file with the following content**
8383+8484+```rust
8585+//! iOS Keychain storage for identity-wallet credentials.
8686+//!
8787+//! All items are stored as `kSecClassGenericPassword` under
8888+//! service `"ezpds-identity-wallet"`. Use the `SERVICE` constant
8989+//! to ensure consistency.
9090+9191+// Suppressed until Phase 2 wires up the IPC command that calls these functions.
9292+#![allow(dead_code)]
9393+9494+use security_framework::passwords::{get_generic_password, set_generic_password};
9595+9696+pub const SERVICE: &str = "ezpds-identity-wallet";
9797+9898+#[derive(Debug, thiserror::Error)]
9999+pub enum KeychainError {
100100+ #[error("keychain error: {0}")]
101101+ Security(#[from] security_framework::base::Error),
102102+}
103103+104104+/// Store arbitrary bytes in the Keychain under the given account name.
105105+///
106106+/// Creates the entry if it doesn't exist, or updates it if it does.
107107+pub fn store_item(account: &str, data: &[u8]) -> Result<(), KeychainError> {
108108+ set_generic_password(SERVICE, account, data).map_err(KeychainError::Security)
109109+}
110110+111111+/// Retrieve bytes from the Keychain for the given account name.
112112+///
113113+/// Returns `Err` with `errSecItemNotFound` if no entry exists.
114114+pub fn get_item(account: &str) -> Result<Vec<u8>, KeychainError> {
115115+ get_generic_password(SERVICE, account).map_err(KeychainError::Security)
116116+}
117117+```
118118+119119+**Why `thiserror`:** Already added to `Cargo.toml` in Task 1. It generates the `Error` impl and `From` conversion automatically.
120120+121121+**Why `#![allow(dead_code)]`:** `store_item` and `get_item` are not called until Phase 2's `create_account` command. Without this suppression, `cargo clippy --workspace -- -D warnings` would fail in Task 4. Remove this attribute in Phase 2 Task 1 once the functions are in use.
122122+123123+**Step 2: Commit**
124124+125125+```bash
126126+git add apps/identity-wallet/src-tauri/src/keychain.rs
127127+git commit -m "feat(identity-wallet): add keychain module for iOS credential storage"
128128+```
129129+<!-- END_TASK_2 -->
130130+131131+<!-- START_TASK_3 -->
132132+### Task 3: Create `http.rs`
133133+134134+**Files:**
135135+- Create: `apps/identity-wallet/src-tauri/src/http.rs`
136136+137137+**Step 1: Create the file with the following content**
138138+139139+```rust
140140+//! Relay HTTP client for identity-wallet.
141141+//!
142142+//! All relay API calls go through `RelayClient`. The base URL is
143143+//! compile-time configured: `http://localhost:8080` in debug builds,
144144+//! `https://relay.ezpds.com` in release builds.
145145+146146+// Suppressed until Phase 2 wires up the IPC command that calls this client.
147147+#![allow(dead_code)]
148148+149149+use reqwest::{Client, Response};
150150+use serde::Serialize;
151151+152152+#[cfg(debug_assertions)]
153153+const RELAY_BASE_URL: &str = "http://localhost:8080";
154154+#[cfg(not(debug_assertions))]
155155+const RELAY_BASE_URL: &str = "https://relay.ezpds.com";
156156+157157+/// HTTP client for relay API requests.
158158+pub struct RelayClient {
159159+ client: Client,
160160+ base_url: &'static str,
161161+}
162162+163163+impl RelayClient {
164164+ /// Create a new `RelayClient` with the compile-time base URL.
165165+ pub fn new() -> Self {
166166+ Self {
167167+ client: Client::new(),
168168+ base_url: RELAY_BASE_URL,
169169+ }
170170+ }
171171+172172+ /// POST JSON to `path` (relative, e.g. `"/v1/accounts/mobile"`).
173173+ ///
174174+ /// Returns the raw `Response` so callers can inspect the status code
175175+ /// before attempting to deserialize the body.
176176+ pub async fn post<T: Serialize>(&self, path: &str, body: &T) -> reqwest::Result<Response> {
177177+ let url = format!("{}{}", self.base_url, path);
178178+ self.client.post(&url).json(body).send().await
179179+ }
180180+}
181181+182182+impl Default for RelayClient {
183183+ fn default() -> Self {
184184+ Self::new()
185185+ }
186186+}
187187+```
188188+189189+**Why return raw `Response` instead of deserializing here:** The caller (`create_account` in Phase 2) needs to inspect the HTTP status code first to map error variants (`ExpiredCode`, `EmailTaken`, etc.) before deciding whether to deserialize the success body. If we deserialize here, the error information is lost.
190190+191191+**Step 2: Commit**
192192+193193+```bash
194194+git add apps/identity-wallet/src-tauri/src/http.rs
195195+git commit -m "feat(identity-wallet): add relay HTTP client module"
196196+```
197197+<!-- END_TASK_3 -->
198198+199199+<!-- START_TASK_4 -->
200200+### Task 4: Declare modules in `lib.rs` and verify build
201201+202202+**Files:**
203203+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs`
204204+205205+**Step 1: Add module declarations**
206206+207207+Open `apps/identity-wallet/src-tauri/src/lib.rs`. The current file begins directly with:
208208+209209+```rust
210210+#[tauri::command]
211211+fn greet(name: String) -> String {
212212+ format!("Hello, {}!", name)
213213+}
214214+```
215215+216216+Add the module declarations at the very top of the file, before the `#[tauri::command]` attribute:
217217+218218+```rust
219219+pub mod http;
220220+pub mod keychain;
221221+222222+#[tauri::command]
223223+fn greet(name: String) -> String {
224224+ format!("Hello, {}!", name)
225225+}
226226+```
227227+228228+Leave the rest of the file unchanged (the `run()` function and `#[cfg(test)]` block stay as-is).
229229+230230+**Step 2: Verify the full build**
231231+232232+```bash
233233+cargo build --workspace
234234+```
235235+236236+Expected: build succeeds with zero errors. `keychain.rs` and `http.rs` have `#![allow(dead_code)]` so unused item warnings are suppressed.
237237+238238+**Step 3: Verify lints**
239239+240240+```bash
241241+cargo clippy --workspace -- -D warnings
242242+```
243243+244244+Expected: passes. `keychain.rs` and `http.rs` already suppress dead_code warnings via `#![allow(dead_code)]`. These suppressions are removed in Phase 2 Task 1 when the functions are called from `create_account`.
245245+246246+**Step 4: Verify formatting**
247247+248248+```bash
249249+cargo fmt --all --check
250250+```
251251+252252+Expected: passes.
253253+254254+**Step 5: Commit**
255255+256256+```bash
257257+git add apps/identity-wallet/src-tauri/src/lib.rs
258258+git commit -m "feat(identity-wallet): register keychain and http modules in lib.rs"
259259+```
260260+<!-- END_TASK_4 -->
261261+262262+<!-- END_SUBCOMPONENT_A -->
···11+# MM-144 Test Requirements
22+33+## Coverage Summary
44+55+| Category | Automated | Human Verification |
66+|---|---|---|
77+| AC1 (UI rendering) | 0 | 6 |
88+| AC2 (account creation) | 3 | 2 |
99+| AC3 (error handling) | 5 | 5 |
1010+| AC4 (Keychain storage) | 0 | 3 |
1111+| AC5 (build passes) | 2 | 0 |
1212+| **Total** | **10** | **16** |
1313+1414+Note: Several AC3 criteria appear in both sections. The Rust error-mapping logic (HTTP status code + relay error code -> `CreateAccountError` variant) is unit-testable. The full user-facing flow (error message text displayed on the correct screen) requires iOS Simulator verification because the frontend has no test framework.
1515+1616+---
1717+1818+## Automated Tests
1919+2020+### AC2: Account creation succeeds end-to-end
2121+2222+| Criterion | Test Type | File | What to Verify |
2323+|---|---|---|---|
2424+| MM-144.AC2.2: The Rust command POSTs to `POST /v1/accounts/mobile` with `email`, `handle`, `claimCode`, `devicePublicKey`, and `platform: "ios"` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateMobileAccountRequest` serializes with the correct camelCase field names and includes all five fields. Construct a `CreateMobileAccountRequest`, serialize to `serde_json::Value`, assert keys are `email`, `handle`, `claimCode`, `devicePublicKey`, `platform` and that `platform` value is `"ios"`. |
2525+| MM-144.AC2.5: On success, the frontend receives `{ nextStep: "did_creation" }` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountResult` serializes correctly. Construct `CreateAccountResult { next_step: "did_creation".into() }`, serialize to JSON, assert the output is `{ "nextStep": "did_creation" }`. |
2626+2727+### AC3: Error handling (Rust error variant mapping)
2828+2929+| Criterion | Test Type | File | What to Verify |
3030+|---|---|---|---|
3131+| MM-144.AC3.1: A relay 404 response maps to `ExpiredCode` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::ExpiredCode` serializes as `{ "code": "EXPIRED_CODE" }`. Construct the variant, serialize to `serde_json::Value`, assert `value["code"] == "EXPIRED_CODE"`. |
3232+| MM-144.AC3.2: A relay 409/`CLAIM_CODE_REDEEMED` maps to `RedeemedCode` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::RedeemedCode` serializes as `{ "code": "REDEEMED_CODE" }`. Same pattern as above. |
3333+| MM-144.AC3.3: A relay 409/`ACCOUNT_EXISTS` maps to `EmailTaken` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::EmailTaken` serializes as `{ "code": "EMAIL_TAKEN" }`. Same pattern. |
3434+| MM-144.AC3.4: A relay 409/`HANDLE_TAKEN` maps to `HandleTaken` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::HandleTaken` serializes as `{ "code": "HANDLE_TAKEN" }`. Same pattern. |
3535+| MM-144.AC3.5: A network or server error maps to `NetworkError` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::NetworkError { message: "..." }` serializes as `{ "code": "NETWORK_ERROR", "message": "..." }`. Construct the variant with a test message, serialize, assert both fields. |
3636+3737+### AC5: Build passes
3838+3939+| Criterion | Test Type | File | What to Verify |
4040+|---|---|---|---|
4141+| MM-144.AC5.1: `cargo build --workspace` succeeds after adding new Rust dependencies | integration (CI) | N/A (CI pipeline command) | Run `cargo build --workspace && cargo clippy --workspace -- -D warnings && cargo fmt --all --check`. Exit code 0 for all three commands. This is a build-level gate, not a test file. |
4242+| MM-144.AC5.2: `pnpm build` in `apps/identity-wallet/` succeeds after adding new frontend components | integration (CI) | N/A (CI pipeline command) | Run `cd apps/identity-wallet && pnpm build`. Exit code 0. Verifies TypeScript compilation and Svelte component validity. |
4343+4444+### Existing relay-side coverage (already written, not new work)
4545+4646+The relay's `POST /v1/accounts/mobile` endpoint already has comprehensive integration tests in `crates/relay/src/routes/create_mobile_account.rs`. These tests cover the server-side behavior that the mobile client depends on:
4747+4848+| Relay Behavior | Existing Test | Relevant AC |
4949+|---|---|---|
5050+| 201 response with correct shape | `returns_201_with_correct_shape` | AC2.2, AC2.3 |
5151+| 404 for invalid/expired claim code | `invalid_claim_code_returns_404`, `expired_claim_code_returns_404` | AC3.1 |
5252+| 409 `CLAIM_CODE_REDEEMED` for redeemed code | `already_redeemed_claim_code_returns_409` | AC3.2 |
5353+| 409 `ACCOUNT_EXISTS` for duplicate email | `duplicate_email_in_pending_returns_409`, `duplicate_email_in_accounts_returns_409` | AC3.3 |
5454+| 409 `HANDLE_TAKEN` for duplicate handle | `duplicate_handle_in_pending_returns_409`, `duplicate_handle_in_handles_returns_409` | AC3.4 |
5555+| `nextStep: "did_creation"` in success response | `returns_201_with_correct_shape` (asserts `json["nextStep"] == "did_creation"`) | AC2.5 |
5656+5757+These tests validate that the relay produces the exact HTTP status codes and error envelope shapes that the Tauri client's error-mapping logic depends on. No new relay tests are needed for MM-144.
5858+5959+---
6060+6161+## Human Verification
6262+6363+### AC1: Onboarding screens render correctly
6464+6565+| Criterion | Justification | Verification Steps |
6666+|---|---|---|
6767+| MM-144.AC1.1: Welcome screen shows app branding and a "Get Started" CTA button that advances to Claim Code step | No frontend test framework configured (Svelte 5 components, no Vitest/Playwright/Testing Library setup). Visual/interactive behavior requires rendering in a browser or iOS Simulator. | 1. Run `cd apps/identity-wallet && cargo tauri ios dev` to launch in iOS Simulator. 2. Verify the Welcome screen displays "Identity Wallet" heading and "Your self-sovereign identity, in your pocket." tagline. 3. Verify a "Get Started" button is visible. 4. Tap "Get Started" and verify the app advances to the Claim Code screen. |
6868+| MM-144.AC1.2: Claim Code screen shows a 6-character alphanumeric input; the Next button is disabled until exactly 6 characters are entered | Same as above -- input validation behavior (disabled state, character filtering) is DOM-level and requires a rendering context. | 1. From the Welcome screen, tap "Get Started" to reach the Claim Code screen. 2. Verify an input field is displayed with placeholder "ABC123". 3. Verify the "Next" button is disabled (grayed out, not tappable). 4. Type "abc" (3 characters) -- verify the input auto-uppercases to "ABC" and the button remains disabled. 5. Type "12#$34" -- verify non-alphanumeric characters are stripped, leaving "ABC123" (6 chars), and the button becomes enabled. 6. Delete one character -- verify the button disables again. |
6969+| MM-144.AC1.3: Email screen shows an email input; the Next button is disabled until a valid email format is entered | Same as above -- regex-based email validation tied to DOM input state. | 1. Advance to the Email screen (Welcome -> Claim Code with valid 6-char code -> Email). 2. Verify an email input field is displayed with placeholder "you@example.com". 3. Verify the "Next" button is disabled. 4. Type "notanemail" -- verify the button remains disabled. 5. Type "user@example.com" -- verify the button becomes enabled. 6. Clear the field and type "user@" -- verify the button is disabled (incomplete email). |
7070+| MM-144.AC1.4: Handle screen shows a handle input; the Next button is disabled until the handle is non-empty | Same as above -- non-empty validation on a text input. | 1. Advance to the Handle screen (Welcome -> Claim Code -> Email -> Handle). 2. Verify a handle input field is displayed with placeholder "alice". 3. Verify the "Create Account" button is disabled. 4. Type "myhandle" -- verify the button becomes enabled. 5. Clear the field -- verify the button disables again. 6. Type a single space and then delete it -- verify the button remains disabled (trims whitespace). |
7171+| MM-144.AC1.5: Loading screen shows a spinner and status message while account creation is in progress | Loading screen is transient (visible only during the async HTTP call). Requires a running relay or a slow/intercepted network to observe. | 1. Set up a running relay (`cargo run -p relay`) with a valid claim code seeded in the database. 2. Run `cargo tauri ios dev`. 3. Complete all onboarding steps with valid data. 4. On submitting the Handle screen, verify the Loading screen appears with a spinning animation and the text "Creating your account...". 5. (Optional: use Network Link Conditioner on the Simulator to add latency and make the loading screen visible for longer.) |
7272+| MM-144.AC1.6: Each screen's Next/Submit button only advances when its validation condition is met | Aggregate criterion covering all per-screen validation. Fully covered by AC1.2-AC1.4 verification steps above. | 1. Perform all verification steps for AC1.2, AC1.3, and AC1.4. 2. On each screen, attempt to tap the disabled button and verify no navigation occurs. 3. Verify that entering valid data and tapping the button advances to the next screen. |
7373+7474+### AC2: Account creation succeeds end-to-end
7575+7676+| Criterion | Justification | Verification Steps |
7777+|---|---|---|
7878+| MM-144.AC2.1: Valid email, handle, and claim code submission invokes the `create_account` Rust command via Tauri IPC | The IPC bridge between the Svelte frontend and Rust backend requires a running Tauri app in the iOS Simulator. The `invoke()` call cannot be tested without the Tauri runtime. | 1. Start the relay with a seeded claim code: `cargo run -p relay`. 2. Run `cargo tauri ios dev`. 3. Complete the onboarding flow with valid claim code, email, and handle. 4. Verify the app does not remain stuck on the Loading screen (successful IPC call means it either advances to success or shows an error). 5. Check relay logs to confirm a `POST /v1/accounts/mobile` request was received with the correct fields. |
7979+| MM-144.AC2.3: On 201 response, `device_token` and `session_token` are stored in the iOS Keychain | Keychain writes use `security-framework` calling real iOS Security.framework APIs. These APIs are unavailable outside of an Apple platform runtime (no mock framework is configured). | 1. Complete a successful onboarding flow in the iOS Simulator (relay returns 201). 2. After the "Account Created!" placeholder appears, use Xcode's Keychain debugging or `security` CLI in the Simulator shell to verify: `xcrun simctl keychain <device-id> dump` (or attach a debugger and call `SecItemCopyMatching` with service `"ezpds-identity-wallet"` and account `"device-token"`). 3. Verify `device-token` and `session-token` entries exist under service `"ezpds-identity-wallet"`. |
8080+8181+### AC3: Error handling (frontend message display and screen navigation)
8282+8383+| Criterion | Justification | Verification Steps |
8484+|---|---|---|
8585+| MM-144.AC3.1: Expired claim code surfaces as "This claim code has expired. Please request a new one." and returns user to Claim Code screen | The error message text and screen-reversion logic live in the Svelte `+page.svelte` state machine. No frontend test framework is configured to verify DOM content or navigation state. | 1. Start the relay. Do NOT seed a claim code (or seed one that is already expired). 2. Run `cargo tauri ios dev`. 3. Enter a non-existent or expired 6-character claim code, a valid email, and a valid handle. 4. Submit and wait for the loading screen to resolve. 5. Verify the app returns to the Claim Code screen. 6. Verify the error message "This claim code has expired. Please request a new one." is displayed in red below the input. |
8686+| MM-144.AC3.2: Redeemed claim code surfaces as "This claim code has already been used." and returns user to Claim Code screen | Same as above -- frontend error message rendering. | 1. Start the relay and seed a claim code. 2. Use the claim code once (complete a full successful onboarding). 3. Restart the app (kill and relaunch via `cargo tauri ios dev`). 4. Enter the same (now-redeemed) claim code with a different email and handle. 5. Submit and verify the app returns to the Claim Code screen with the message "This claim code has already been used." |
8787+| MM-144.AC3.3: Email taken surfaces as "An account with that email already exists." and returns user to Email screen | Same as above. | 1. Start the relay. Seed two claim codes. 2. Complete onboarding with claim code 1, email "alice@example.com", and handle "alice". 3. Restart the app. 4. Begin onboarding with claim code 2, email "alice@example.com" (same email), and handle "bob". 5. Submit and verify the app returns to the Email screen with the message "An account with that email already exists." |
8888+| MM-144.AC3.4: Handle taken surfaces as "That handle is taken. Please choose another." and returns user to Handle screen | Same as above. | 1. Start the relay. Seed two claim codes. 2. Complete onboarding with claim code 1, email "alice@example.com", and handle "alice.ezpds.com". 3. Restart the app. 4. Begin onboarding with claim code 2, email "bob@example.com", and handle "alice.ezpds.com" (same handle). 5. Submit and verify the app returns to the Handle screen with the message "That handle is taken. Please choose another." |
8989+| MM-144.AC3.5: Network/server error surfaces as "Couldn't reach the server. Check your connection." and returns user to Handle screen | Same as above. | 1. Do NOT start the relay (no server running). 2. Run `cargo tauri ios dev`. 3. Complete all onboarding steps with any valid-looking inputs. 4. Submit and verify the app returns to the Handle screen with the message "Couldn't reach the server. Check your connection." |
9090+9191+### AC4: iOS Keychain storage
9292+9393+| Criterion | Justification | Verification Steps |
9494+|---|---|---|
9595+| MM-144.AC4.1: `device_token` stored under service `"ezpds-identity-wallet"`, account `"device-token"` | Keychain APIs (`security-framework::passwords::set_generic_password`) call real iOS Security.framework. No mock framework is set up; unit testing Keychain operations requires an Apple runtime. The `store_item` function is a thin wrapper with no branching logic worth isolating. | 1. Complete a successful onboarding flow in the iOS Simulator. 2. Pause execution after success (add a breakpoint in `create_account` after the `store_item("device-token", ...)` call, or inspect post-hoc). 3. In the Xcode debugger console, call `SecItemCopyMatching` with query dict `{ kSecClass: kSecClassGenericPassword, kSecAttrService: "ezpds-identity-wallet", kSecAttrAccount: "device-token", kSecReturnData: true }`. 4. Verify data is returned and its base64url-decoded length is 43 characters (base64url encoding of 32 bytes). |
9696+| MM-144.AC4.2: `session_token` stored under service `"ezpds-identity-wallet"`, account `"session-token"` | Same as above. | 1. Same setup as AC4.1. 2. Query the Keychain with account `"session-token"`. 3. Verify data is returned and matches the expected format. |
9797+| MM-144.AC4.3: Device P-256 private key stored under service `"ezpds-identity-wallet"`, account `"device-private-key"` | Same as above. The private key is stored before the HTTP request (AC2.4 ordering), but verifying ordering requires stepping through with a debugger. | 1. Same setup as AC4.1 (or even a failed HTTP request -- the private key is stored before the POST). 2. Query the Keychain with account `"device-private-key"`. 3. Verify data is returned and its length is 32 bytes (raw P-256 private key scalar). 4. (Ordering verification) Set a breakpoint on the `http::RelayClient::new().post(...)` call in `create_account`. When hit, query the Keychain for `"device-private-key"` -- it must already exist, confirming the key was stored before the HTTP request (AC2.4). |
9898+9999+---
100100+101101+## Test Implementation Notes
102102+103103+### Unit tests in `src-tauri/src/lib.rs`
104104+105105+All automated tests for AC2 and AC3 are serde serialization tests that verify the IPC contract between Rust and TypeScript. They should be added to the existing `#[cfg(test)] mod tests` block in `apps/identity-wallet/src-tauri/src/lib.rs`. These tests do not require any external dependencies (no network, no Keychain, no Tauri runtime).
106106+107107+Example test structure:
108108+109109+```rust
110110+#[cfg(test)]
111111+mod tests {
112112+ use super::*;
113113+114114+ // -- AC2.2: Request serialization --
115115+ #[test]
116116+ fn create_mobile_account_request_serializes_camel_case() {
117117+ let req = CreateMobileAccountRequest {
118118+ email: "test@example.com".into(),
119119+ handle: "alice".into(),
120120+ device_public_key: "pubkey123".into(),
121121+ platform: "ios".into(),
122122+ claim_code: "ABC123".into(),
123123+ };
124124+ let json = serde_json::to_value(&req).unwrap();
125125+ assert_eq!(json["email"], "test@example.com");
126126+ assert_eq!(json["handle"], "alice");
127127+ assert_eq!(json["devicePublicKey"], "pubkey123");
128128+ assert_eq!(json["platform"], "ios");
129129+ assert_eq!(json["claimCode"], "ABC123");
130130+ }
131131+132132+ // -- AC2.5: Result serialization --
133133+ #[test]
134134+ fn create_account_result_serializes_camel_case() {
135135+ let result = CreateAccountResult { next_step: "did_creation".into() };
136136+ let json = serde_json::to_value(&result).unwrap();
137137+ assert_eq!(json["nextStep"], "did_creation");
138138+ }
139139+140140+ // -- AC3.1-AC3.5: Error variant serialization --
141141+ #[test]
142142+ fn error_expired_code_serializes_correctly() {
143143+ let err = CreateAccountError::ExpiredCode;
144144+ let json = serde_json::to_value(&err).unwrap();
145145+ assert_eq!(json["code"], "EXPIRED_CODE");
146146+ }
147147+148148+ // ... (one test per error variant)
149149+}
150150+```
151151+152152+### What is NOT testable without additional infrastructure
153153+154154+1. **Frontend component rendering** (AC1.*): Requires a frontend test framework (Vitest + Testing Library, or Playwright). Not configured in this project.
155155+2. **Tauri IPC bridge** (AC2.1): Requires the Tauri runtime to broker `invoke()` calls between the WebView and Rust. Cannot be unit-tested.
156156+3. **iOS Keychain operations** (AC4.*): Requires Apple Security.framework at runtime. The `keychain.rs` functions are thin wrappers over `security-framework` crate calls with no branching logic.
157157+4. **End-to-end HTTP flow** (AC2.3, AC2.4): The `create_account` command calls real Keychain APIs and real HTTP endpoints in sequence. Mocking either would require adding `mockall` or a similar framework plus trait-based dependency injection, which is not set up.
158158+159159+### Future automation opportunities
160160+161161+- **Frontend tests**: Adding Vitest + `@testing-library/svelte` would allow testing component validation logic (AC1.2-AC1.4, AC1.6) and error message display (AC3.1-AC3.5 frontend side).
162162+- **Playwright e2e**: Adding Playwright with `@playwright/test` would allow full browser-based testing of the state machine transitions.
163163+- **Keychain mocking**: Extracting the Keychain operations behind a trait and using `mockall` would allow unit-testing the `create_account` command's orchestration logic (key storage ordering, token storage after HTTP success) without an Apple runtime.
···11+# MM-145 — P-256 Keypair via Secure Enclave: Phase 2
22+33+**Goal:** Replace the Phase 1 real-device stubs with a Secure Enclave P-256 implementation using the safe `security_framework` 3.x wrapper.
44+55+**Architecture:** The SE path uses `security_framework::key::SecKey::new()` with `Token::SecureEnclave` for hardware-backed key generation. The generated key is permanent in the SE. The SE private key's `application_label` (SHA1 hash of public key, 20 bytes auto-set by the OS) is stored in the regular Keychain for lookup on subsequent launches. The compressed public key (33 bytes) is also stored in the regular Keychain so `get_or_create()` can return without touching the SE hardware on repeat calls. Signing uses `key.create_signature()` which returns DER (70–72 bytes); this is converted to raw r||s (64 bytes) via `p256::ecdsa::Signature::from_der`.
66+77+**Tech Stack:** `security_framework` 3.7.x with `OSX_10_12` feature (already in Cargo.toml; need feature flag added), `p256` 0.13 (ecdsa feature, for DER→r||s conversion), `multibase` 0.9
88+99+**Scope:** Phase 2 of 4 — real-device Secure Enclave path only. Simulator path (Phase 1) unchanged.
1010+1111+**Codebase verified:** 2026-03-19
1212+1313+**Deviation from design doc:**
1414+- Design calls for raw FFI via `security-framework-sys` (`SecKeyCreateRandomKey`, `SecKeyCopyExternalRepresentation`, `SecKeyCreateSignature`). This plan uses the safe `security_framework` 3.x wrapper instead — same functionality, no `unsafe` blocks, no new dependency.
1515+- Design calls for `kSecAttrApplicationTag` as the lookup key. This plan stores the OS-assigned `application_label` (SHA1 of public key) plus the compressed public key bytes in the regular Keychain (`keychain.rs`). This avoids needing `kSecAttrApplicationTag` FFI and is equally stable across app restarts.
1616+- Design says add `security-framework-sys` as explicit dep. This plan adds `OSX_10_12` feature to the existing `security-framework` dep instead — no new crate needed.
1717+1818+---
1919+2020+## Acceptance Criteria Coverage
2121+2222+This phase implements (no automated tests — SE hardware required):
2323+2424+### MM-145.AC2: Private key material is protected (real device only)
2525+- **MM-145.AC2.1 Success:** key retrieved after cold restart matches key from initial generation (persistence via SE; verified manually on physical device)
2626+- **MM-145.AC2.2 Success:** private key bytes cannot be extracted from the Keychain (`SecKey::new` with `Token::SecureEnclave` is non-extractable by design — verified by attempting `external_representation()` on the private key, which the SE rejects)
2727+2828+---
2929+3030+<!-- START_SUBCOMPONENT_A (task 1) -->
3131+3232+<!-- START_TASK_1 -->
3333+### Task 1: Add `OSX_10_12` feature flag to `security-framework` in Cargo.toml
3434+3535+**Verifies:** None (infrastructure — enables SE APIs)
3636+3737+**Files:**
3838+- Modify: `apps/identity-wallet/src-tauri/Cargo.toml` (line 24, the `security-framework` dep)
3939+4040+**Why:** `security_framework::key::SecKey::new()`, `GenerateKeyOptions`, `Token`, `Algorithm`, and `ItemSearchOptions::load_refs()` are all gated behind the `OSX_10_12` feature in the `security-framework` crate (they were introduced in macOS 10.12 / iOS 10). Without this feature, the SE path code won't compile.
4141+4242+**Step 1: Update the `security-framework` dep**
4343+4444+In `apps/identity-wallet/src-tauri/Cargo.toml`, find line 24:
4545+4646+```toml
4747+security-framework = "3"
4848+```
4949+5050+Replace with:
5151+5252+```toml
5353+security-framework = { version = "3", features = ["OSX_10_12"] }
5454+```
5555+5656+**Step 2: Verify `cargo check` still passes**
5757+5858+```bash
5959+cargo check -p identity-wallet
6060+```
6161+6262+Expected: compiles without errors. The `OSX_10_12` feature is additive and backwards-compatible with existing `keychain.rs` usage.
6363+6464+**Commit:** Do not commit yet — continue to Task 2.
6565+<!-- END_TASK_1 -->
6666+6767+<!-- END_SUBCOMPONENT_A -->
6868+6969+<!-- START_SUBCOMPONENT_B (tasks 2-3) -->
7070+7171+<!-- START_TASK_2 -->
7272+### Task 2: Implement SE path `get_or_create()` and `sign()` — replace Phase 1 real-device stubs
7373+7474+**Verifies:** MM-145.AC2.1, MM-145.AC2.2 (manual device verification only)
7575+7676+**Files:**
7777+- Modify: `apps/identity-wallet/src-tauri/src/device_key.rs` (replace the two `#[cfg(all(target_os = "ios", not(target_env = "sim")))]` stub functions)
7878+7979+**Step 1: Add required imports at the top of `device_key.rs`**
8080+8181+Add to the top of `device_key.rs` (after the existing `use serde::Serialize;` line):
8282+8383+```rust
8484+#[cfg(all(target_os = "ios", not(target_env = "sim")))]
8585+use security_framework::{
8686+ access_control::{ProtectionMode, SecAccessControl},
8787+ item::{ItemClass, ItemSearchOptions, KeyClass, Location, Reference, SearchResult},
8888+ key::{Algorithm, GenerateKeyOptions, KeyType, SecKey, Token},
8989+};
9090+```
9191+9292+These imports are gated to the real-device cfg so they don't cause unused-import warnings on macOS/simulator.
9393+9494+**Step 2: Replace the two real-device stubs**
9595+9696+Find these stubs in `device_key.rs`:
9797+9898+```rust
9999+#[cfg(all(target_os = "ios", not(target_env = "sim")))]
100100+pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> {
101101+ Err(DeviceKeyError::KeyGenerationFailed)
102102+}
103103+104104+#[cfg(all(target_os = "ios", not(target_env = "sim")))]
105105+pub fn sign(_data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> {
106106+ Err(DeviceKeyError::KeyGenerationFailed)
107107+}
108108+```
109109+110110+Replace them with:
111111+112112+```rust
113113+/// Account names used to store SE key metadata in the regular Keychain.
114114+/// The SE private key itself is stored in the Secure Enclave and never leaves it.
115115+#[cfg(all(target_os = "ios", not(target_env = "sim")))]
116116+const SE_PUB_ACCOUNT: &str = "device-rotation-key-pub";
117117+#[cfg(all(target_os = "ios", not(target_env = "sim")))]
118118+const SE_APP_LABEL_ACCOUNT: &str = "device-rotation-key-app-label";
119119+120120+#[cfg(all(target_os = "ios", not(target_env = "sim")))]
121121+pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> {
122122+ // Fast path: if we already stored the compressed public key, return it directly.
123123+ // This avoids SE hardware interaction on every call after first generation.
124124+ if let Ok(compressed) = crate::keychain::get_item(SE_PUB_ACCOUNT) {
125125+ let multibase = multibase::encode(multibase::Base::Base58Btc, &compressed);
126126+ // did:key requires the P-256 multicodec varint prefix [0x80, 0x24] (0x1200 as LEB128).
127127+ const P256_MULTICODEC: &[u8] = &[0x80, 0x24];
128128+ let mut multikey = Vec::with_capacity(2 + compressed.len());
129129+ multikey.extend_from_slice(P256_MULTICODEC);
130130+ multikey.extend_from_slice(&compressed);
131131+ let key_id = format!("did:key:{}", multibase::encode(multibase::Base::Base58Btc, &multikey));
132132+ return Ok(DevicePublicKey { multibase, key_id });
133133+ }
134134+135135+ // Generate a new SE-backed P-256 key.
136136+ // set_location(DataProtectionKeychain) is required — without it, security_framework sets
137137+ // kSecAttrIsPermanent = false, meaning the key is not persisted to the Keychain and will
138138+ // not survive app restart (breaking AC2.1).
139139+ // set_access_control with PRIVATE_KEY_USAGE is required for SE keys — the SE enforces
140140+ // that only explicitly-authorized operations can use the private key for signing.
141141+ //
142142+ // Note: SecAccessControl::create_with_protection takes Option<ProtectionMode> and a raw
143143+ // flags u64. The PRIVATE_KEY_USAGE flag is kSecAccessControlPrivateKeyUsage = 1 << 30.
144144+ // If the compiler reports an ambiguous type on the flags argument, use `0x4000_0000_u64`.
145145+ let access_control = SecAccessControl::create_with_protection(
146146+ Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly),
147147+ 1 << 30, // kSecAccessControlPrivateKeyUsage
148148+ )
149149+ .map_err(|_| DeviceKeyError::KeyGenerationFailed)?;
150150+151151+ let mut opts = GenerateKeyOptions::default();
152152+ opts.set_key_type(KeyType::ec())
153153+ .set_size_in_bits(256)
154154+ .set_token(Token::SecureEnclave)
155155+ .set_label("ezpds-device-rotation-key")
156156+ .set_location(Location::DataProtectionKeychain)
157157+ .set_access_control(access_control); // takes ownership (by value)
158158+159159+ let priv_key = SecKey::new(&opts).map_err(|_| DeviceKeyError::KeyGenerationFailed)?;
160160+161161+ // Retrieve the public key and its external representation.
162162+ // SecKeyCopyExternalRepresentation on the *public* key returns the uncompressed
163163+ // 65-byte X9.62 point (0x04 || x[32] || y[32]).
164164+ let pub_key = priv_key.public_key().ok_or(DeviceKeyError::KeyGenerationFailed)?;
165165+ let pub_repr = pub_key
166166+ .external_representation()
167167+ .ok_or(DeviceKeyError::KeyGenerationFailed)?;
168168+ let uncompressed: Vec<u8> = pub_repr.to_vec(); // 65 bytes
169169+170170+ // Compress: prefix byte = 0x02 (even y) or 0x03 (odd y); keep x[32].
171171+ // The last byte of the y coordinate determines parity.
172172+ let mut compressed = [0u8; 33];
173173+ compressed[0] = if uncompressed[64] & 1 == 0 { 0x02 } else { 0x03 };
174174+ compressed[1..].copy_from_slice(&uncompressed[1..33]);
175175+176176+ // Store the compressed public key for the fast path on future calls.
177177+ crate::keychain::store_item(SE_PUB_ACCOUNT, &compressed)
178178+ .map_err(|e| DeviceKeyError::KeychainError { message: e.to_string() })?;
179179+180180+ // Store the application_label (OS-assigned SHA1 of public key, 20 bytes)
181181+ // so sign() can locate the SE private key on future app launches.
182182+ if let Some(app_label) = priv_key.application_label() {
183183+ crate::keychain::store_item(SE_APP_LABEL_ACCOUNT, &app_label)
184184+ .map_err(|e| DeviceKeyError::KeychainError { message: e.to_string() })?;
185185+ }
186186+187187+ let multibase = multibase::encode(multibase::Base::Base58Btc, &compressed);
188188+ // did:key requires the P-256 multicodec varint prefix [0x80, 0x24] (0x1200 as LEB128).
189189+ const P256_MULTICODEC: &[u8] = &[0x80, 0x24];
190190+ let mut multikey = Vec::with_capacity(2 + compressed.len());
191191+ multikey.extend_from_slice(P256_MULTICODEC);
192192+ multikey.extend_from_slice(&compressed);
193193+ let key_id = format!("did:key:{}", multibase::encode(multibase::Base::Base58Btc, &multikey));
194194+ Ok(DevicePublicKey { multibase, key_id })
195195+}
196196+197197+#[cfg(all(target_os = "ios", not(target_env = "sim")))]
198198+pub fn sign(data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> {
199199+ use p256::ecdsa::Signature;
200200+201201+ // Load the application_label to look up the SE private key.
202202+ let app_label = crate::keychain::get_item(SE_APP_LABEL_ACCOUNT)
203203+ .map_err(|_| DeviceKeyError::KeyNotFound)?;
204204+205205+ // Find the SE private key in the Keychain by its application_label.
206206+ // load_refs(true) returns SearchResult::Ref(CFType) containing the SecKeyRef.
207207+ let mut search = ItemSearchOptions::new();
208208+ search
209209+ .class(ItemClass::key())
210210+ .key_class(KeyClass::private())
211211+ .application_label(&app_label)
212212+ .load_refs(true)
213213+ .limit(1);
214214+215215+ let results = search.search().map_err(|_| DeviceKeyError::KeyNotFound)?;
216216+217217+ // Extract the SecKey from the typed Reference result.
218218+ // SearchResult::Ref wraps a Reference enum; Reference::Key holds the already-wrapped SecKey.
219219+ // No unsafe code is needed — security_framework handles the SecKeyRef wrapping internally.
220220+ let sec_key = match results.into_iter().next() {
221221+ Some(SearchResult::Ref(Reference::Key(key))) => key,
222222+ _ => return Err(DeviceKeyError::KeyNotFound),
223223+ };
224224+225225+ // create_signature uses kSecKeyAlgorithmECDSASignatureMessageX962SHA256.
226226+ // The SE hashes `data` with SHA-256 internally before signing.
227227+ // Returns DER-encoded ECDSA signature (70–72 bytes).
228228+ let der_sig = sec_key
229229+ .create_signature(Algorithm::ECDSASignatureMessageX962SHA256, data)
230230+ .map_err(|_| DeviceKeyError::SigningFailed)?;
231231+232232+ // Convert DER to raw 64-byte r||s (the format expected by ATProto/did:plc).
233233+ // from_der() is a pure parser — it does NOT normalize low-S. Apple's SE may return
234234+ // high-S signatures. normalize_s() ensures s <= order/2 as required by ATProto.
235235+ let sig = Signature::from_der(&der_sig).map_err(|_| DeviceKeyError::InvalidSignature)?;
236236+ let sig = sig.normalize_s().unwrap_or(sig);
237237+ Ok(sig.to_bytes().to_vec())
238238+}
239239+```
240240+241241+**Implementation notes:**
242242+243243+1. **`external_representation()` on private SE key:** Returns `None` — the SE rejects export of private key material. Only the public key's `external_representation()` returns data. This verifies AC2.2 by design.
244244+245245+2. **`SearchResult::Ref(Reference::Key(key))`:** The `security_framework` 3.7.0 safe API wraps the OS-returned `SecKeyRef` inside a typed `Reference::Key(SecKey)`. No unsafe code is needed — the library handles the cast internally.
246246+247247+3. **`set_location` and `set_access_control` on iOS:** `GenerateKeyOptions::to_dictionary()` in `security_framework` 3.7.0 only propagates `kSecAttrIsPermanent` and `kSecAttrAccessControl` into the attributes dictionary under `#[cfg(target_os = "macos")]` — the private key sub-dictionary is skipped on iOS. These calls are included as defensive coding and to document intent, but they have no runtime effect on `aarch64-apple-ios` in this library version. SE keys on iOS are permanent by default through the `Token::SecureEnclave` setting, so AC2.1 is still satisfied. If a future version of `security_framework` corrects this iOS gap, these calls will take effect without code changes.
248248+249249+4. **`ItemSearchOptions::limit()`:** Takes a `u32` or `Limit::Max(1)` — check the installed version's API. If `limit()` takes a different type, use `Limit::Max(1)` from `security_framework::item::Limit`.
250250+251251+5. **`Algorithm::ECDSASignatureMessageX962SHA256`:** Available from `security_framework::key::Algorithm` with the `OSX_10_12` feature. Verify this exact variant name matches the installed version; the underlying constant is `kSecKeyAlgorithmECDSASignatureMessageX962SHA256`.
252252+253253+6. **`normalize_s()` on SE signatures:** Apple's Secure Enclave may return DER signatures where `s > order/2` (high-S). `Signature::from_der` is a pure parser and does not normalize low-S. The `normalize_s()` call ensures the 64-byte r||s output always has low-S as required by the ATProto/did:plc verification protocol. For the simulator path, the `p256` crate's `sign()` trait uses RFC 6979 which inherently produces low-S, so no normalization is needed there.
254254+255255+**Step 3: Verify `cargo check`**
256256+257257+```bash
258258+cargo check -p identity-wallet
259259+```
260260+261261+Expected: compiles without errors. If `security_framework_sys` is not in scope, see note 3 above.
262262+<!-- END_TASK_2 -->
263263+264264+<!-- START_TASK_3 -->
265265+### Task 3: Verify simulator tests still pass + iOS build compiles + commit
266266+267267+**Verifies:** MM-145.AC2.1, MM-145.AC2.2 (build + manual); Phase 1 ACs still pass
268268+269269+**Files:** No changes — verification only.
270270+271271+**Step 1: Simulator path tests unchanged**
272272+273273+```bash
274274+cargo test -p identity-wallet -- --test-threads=1 2>&1
275275+```
276276+277277+Expected: All 7 Phase 1 tests still pass. The SE path changes are gated behind `#[cfg(all(target_os = "ios", not(target_env = "sim")))]` and do not affect the macOS host test run.
278278+279279+**Step 2: Verify iOS build compiles (SE path)**
280280+281281+```bash
282282+cargo build -p identity-wallet --target aarch64-apple-ios 2>&1
283283+```
284284+285285+Expected: compiles without errors. This confirms the SE path code compiles correctly for the real-device target.
286286+287287+If the build fails with "error[E0432]: unresolved import `security_framework_sys`": add `security-framework-sys = { version = "2" }` to `apps/identity-wallet/src-tauri/Cargo.toml` and retry.
288288+289289+**Step 3: Run clippy**
290290+291291+```bash
292292+cargo clippy -p identity-wallet -- -D warnings
293293+```
294294+295295+Expected: no warnings.
296296+297297+**Step 4: Manual device verification (required before Phase 3)**
298298+299299+On a physical iOS device:
300300+1. Build and run the app via `cargo tauri ios dev` targeting the device
301301+2. Call `device_key::get_or_create()` — verify it returns a `DevicePublicKey` with a valid multibase string
302302+3. Force-kill and relaunch the app (cold restart)
303303+4. Call `device_key::get_or_create()` again — verify it returns the **same** multibase string (AC2.1)
304304+5. Try to export the private key via Keychain access — verify it fails (AC2.2, guaranteed by SE hardware)
305305+306306+**Step 5: Commit**
307307+308308+```bash
309309+git add apps/identity-wallet/src-tauri/Cargo.toml \
310310+ apps/identity-wallet/src-tauri/src/device_key.rs
311311+git commit -m "feat(device-key): add Secure Enclave path for real iOS device (Phase 2)"
312312+```
313313+<!-- END_TASK_3 -->
314314+315315+<!-- END_SUBCOMPONENT_B -->
···11+# MM-145 — P-256 Keypair via Secure Enclave: Phase 3
22+33+**Goal:** Replace the `crypto::generate_p256_keypair()` call in `create_account` with `device_key::get_or_create()` so the relay receives the SE-backed (or simulator-fallback) public key.
44+55+**Architecture:** `lib.rs`'s `create_account` function currently generates a software P-256 keypair, stores the private bytes in the Keychain, and sends the public key to the relay. After this phase: `create_account` calls `device_key::get_or_create()`, uses `DevicePublicKey.multibase` as `device_public_key`, and removes the explicit private-key Keychain store step (which `device_key` handles internally). Cleanup code that deleted `"device-private-key"` on error is also removed.
66+77+**Tech Stack:** Pure Rust refactoring — no new dependencies.
88+99+**Scope:** Phase 3 of 4 — wiring only.
1010+1111+**Codebase verified:** 2026-03-19
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+### MM-145.AC5: create_account uses the device key
1818+- **MM-145.AC5.1 Success:** `create_account` sends `DevicePublicKey.multibase` as the `device_public_key` field in the relay request (not a freshly-generated software keypair)
1919+2020+---
2121+2222+<!-- START_SUBCOMPONENT_A (tasks 1-3) -->
2323+2424+<!-- START_TASK_1 -->
2525+### Task 1: Write failing test for AC5.1
2626+2727+**Verifies:** MM-145.AC5.1
2828+2929+**Files:**
3030+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (append to the existing `#[cfg(test)] mod tests` block, around line 198)
3131+3232+**Why this test:** `create_account` makes a real HTTP call to the relay, so we can't integration-test it without a live server. Instead, we test the device key contract that `create_account` depends on: that `device_key::get_or_create()` is the source of the public key, and that it's stable across calls (so `create_account` will always send the same key for a given device).
3333+3434+**Step 1: Add test to the existing `#[cfg(test)] mod tests` block in `lib.rs`**
3535+3636+Find the closing `}` of the existing `mod tests` block (around line 326) and insert before it:
3737+3838+```rust
3939+ // AC5.1 — create_account will use this key as device_public_key.
4040+ // We verify: (a) the key exists and is correctly formatted, (b) it's stable so
4141+ // create_account always sends the same device_public_key for this device.
4242+ #[test]
4343+ fn create_account_uses_device_key_public_key() {
4444+ let key = crate::device_key::get_or_create()
4545+ .expect("device_key::get_or_create must succeed — create_account depends on it");
4646+ // The relay expects multibase: 'z' + base58btc(33-byte compressed P-256 point).
4747+ assert!(
4848+ key.multibase.starts_with('z'),
4949+ "device_public_key sent to relay must be multibase base58btc ('z' prefix), got: {}",
5050+ key.multibase
5151+ );
5252+ // Calling again returns the same key — create_account sends consistent device_public_key.
5353+ let key2 = crate::device_key::get_or_create()
5454+ .expect("second call must also succeed");
5555+ assert_eq!(
5656+ key.multibase,
5757+ key2.multibase,
5858+ "device_public_key must be stable across calls (idempotent)"
5959+ );
6060+ }
6161+```
6262+6363+**Step 2: Run the test — verify it passes (the test doesn't depend on the wiring change)**
6464+6565+```bash
6666+cargo test -p identity-wallet -- create_account_uses_device_key_public_key --test-threads=1 2>&1
6767+```
6868+6969+Expected: test passes. The test validates the `device_key` contract that `create_account` relies on — it doesn't call `create_account` itself (which requires a live relay).
7070+7171+**Why this test doesn't call `create_account` directly:** `create_account` makes a real HTTP call to the relay (no mock server in this codebase). Testing the full wiring would require a running relay instance, which is out of scope for unit tests. Instead, this test guards the API contract: it verifies that `device_key::get_or_create()` succeeds and is idempotent (the same values `create_account` will use). If Phase 3's wiring is incorrect at compile time, `cargo check` catches it; at runtime, the manual test in AC5.1 verifies the full flow against a live relay.
7272+7373+Note: this test passes even before Task 2 because it only tests `device_key::get_or_create()`, not the wiring in `create_account`. It acts as a regression guard — if device key generation breaks, this test will fail, which means `create_account` would also be broken.
7474+<!-- END_TASK_1 -->
7575+7676+<!-- START_TASK_2 -->
7777+### Task 2: Update `create_account` to use `device_key::get_or_create()`
7878+7979+**Verifies:** MM-145.AC5.1
8080+8181+**Files:**
8282+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs`
8383+8484+All line numbers reference the current state of the file (before this task's changes).
8585+8686+**Step 1: Update the import at line 4**
8787+8888+Find line 4:
8989+```rust
9090+use crypto::generate_p256_keypair;
9191+```
9292+9393+Replace with (remove the crypto import; `device_key` is already accessible as `pub mod device_key` declared at line 3):
9494+```rust
9595+// (removed — device_key::get_or_create() replaces crypto::generate_p256_keypair)
9696+```
9797+9898+Actually: just delete line 4 entirely. No replacement import is needed because `device_key` is already declared as a module in this file (`pub mod device_key;` at line 3) and is accessed as `device_key::get_or_create()`.
9999+100100+**Step 2: Replace the keypair generation (lines 116–118)**
101101+102102+Find:
103103+```rust
104104+ // 1. Generate P-256 device keypair.
105105+ let keypair = generate_p256_keypair().map_err(|e| CreateAccountError::Unknown {
106106+ message: e.to_string(),
107107+ })?;
108108+```
109109+110110+Replace with:
111111+```rust
112112+ // 1. Get or create the device's SE-backed (or simulator-fallback) P-256 key.
113113+ let device_key = device_key::get_or_create().map_err(|e| CreateAccountError::Unknown {
114114+ message: e.to_string(),
115115+ })?;
116116+```
117117+118118+**Step 3: Remove the private key Keychain store step (lines 120–123)**
119119+120120+Find and delete:
121121+```rust
122122+ // 2. Store private key bytes in Keychain before any network call.
123123+ // private_key_bytes is Zeroizing<[u8; 32]>; deref to &[u8] via AsRef.
124124+ keychain::store_item("device-private-key", keypair.private_key_bytes.as_ref())
125125+ .map_err(|_| CreateAccountError::KeychainError)?;
126126+```
127127+128128+This entire block is deleted. `device_key::get_or_create()` handles its own Keychain storage internally.
129129+130130+**Step 4: Update `device_public_key` in the request (line 129)**
131131+132132+Find:
133133+```rust
134134+ device_public_key: keypair.public_key,
135135+```
136136+137137+Replace with:
138138+```rust
139139+ device_public_key: device_key.multibase,
140140+```
141141+142142+**Step 5: Remove cleanup calls for `"device-private-key"` (lines 155 and 162–163)**
143143+144144+In the error-handling blocks after the token Keychain stores, find and remove (two occurrences):
145145+```rust
146146+ let _ = keychain::delete_item("device-private-key");
147147+```
148148+149149+These lines were cleanup for the private key that `device_key` now manages. The SE-backed device key is intentionally persistent — it should NOT be deleted on account creation failure.
150150+151151+After Steps 5, 5b, and 5c, the cleanup blocks look like:
152152+```rust
153153+ keychain::store_item("device-token", body.device_token.as_bytes()).map_err(|_| {
154154+ // device-token write failed — nothing to clean up; the device key is persistent by design.
155155+ CreateAccountError::KeychainError
156156+ })?;
157157+158158+ keychain::store_item("session-token", body.session_token.as_bytes()).map_err(|_| {
159159+ // Best-effort cleanup: remove the already-written device-token.
160160+ let _ = keychain::delete_item("device-token");
161161+ CreateAccountError::KeychainError
162162+ })?;
163163+```
164164+165165+**Step 5b: Update the comment on the session-token error handler**
166166+167167+The original comment ("Best-effort cleanup: also remove the already-written device-token and device-private-key.") references the private key cleanup that was just removed. Update it to reflect only what the block now does:
168168+169169+Find (in the session-token `map_err` closure):
170170+```rust
171171+ // Best-effort cleanup: also remove the already-written device-token and device-private-key.
172172+```
173173+174174+Replace with:
175175+```rust
176176+ // Best-effort cleanup: remove the already-written device-token.
177177+```
178178+179179+If the original comment does not mention "device-private-key" (exact wording depends on the current file), update it so it only references `device-token`. The intent is: on session-token write failure, we undo the already-written device-token, but we do NOT touch the device key (it is persistent by design).
180180+181181+**Step 5c: Update the comment on the device-token error handler**
182182+183183+After removing the `let _ = keychain::delete_item("device-private-key")` from the device-token closure, that block contains no deletion — the old comment "ignore deletion errors" is stale. Update it:
184184+185185+Find (in the device-token `map_err` closure):
186186+```rust
187187+ // Best-effort cleanup: ignore deletion errors.
188188+```
189189+190190+Replace with:
191191+```rust
192192+ // device-token write failed — nothing to clean up; the device key is persistent by design.
193193+```
194194+195195+**Step 6: Verify `cargo check`**
196196+197197+```bash
198198+cargo check -p identity-wallet
199199+```
200200+201201+Expected: compiles without errors. If the compiler warns about unused `crypto` import or unused variables, address them.
202202+<!-- END_TASK_2 -->
203203+204204+<!-- START_TASK_3 -->
205205+### Task 3: Verify all tests pass and commit
206206+207207+**Verifies:** All Phase 1 ACs + MM-145.AC5.1
208208+209209+**Files:** No changes — verification only.
210210+211211+**Step 1: Run full test suite**
212212+213213+```bash
214214+cargo test -p identity-wallet -- --test-threads=1 2>&1
215215+```
216216+217217+Expected: all tests pass, including the new `create_account_uses_device_key_public_key` test and all 7 Phase 1 tests.
218218+219219+**Step 2: Run clippy**
220220+221221+```bash
222222+cargo clippy -p identity-wallet -- -D warnings
223223+```
224224+225225+Expected: no warnings. Specifically, the removed `use crypto::generate_p256_keypair;` import should not produce an "unused import" warning (it was deleted).
226226+227227+**Step 3: Commit**
228228+229229+```bash
230230+git add apps/identity-wallet/src-tauri/src/lib.rs
231231+git commit -m "feat(create-account): use device_key::get_or_create() for device public key"
232232+```
233233+<!-- END_TASK_3 -->
234234+235235+<!-- END_SUBCOMPONENT_A -->
···11+# MM-145 — P-256 Keypair via Secure Enclave: Phase 4
22+33+**Goal:** Expose `device_key::get_or_create()` and `device_key::sign()` as Tauri IPC commands, and add typed TypeScript wrappers in `ipc.ts`.
44+55+**Architecture:** Two new async Tauri commands (`get_or_create_device_key`, `sign_with_device_key`) are added to `lib.rs` and registered in `generate_handler![]`. `DevicePublicKey` gains `#[serde(rename_all = "camelCase")]` so `key_id` serializes to `keyId`. Typed TypeScript wrappers in `ipc.ts` convert `Vec<u8>` ↔ `Uint8Array` at the IPC boundary.
66+77+**Tech Stack:** Rust (Tauri v2 IPC), TypeScript (`@tauri-apps/api/core` invoke)
88+99+**Scope:** Phase 4 of 4 — IPC wiring only.
1010+1111+**Codebase verified:** 2026-03-19
1212+1313+**IPC binary data behavior (Tauri v2):**
1414+- `Vec<u8>` parameters: JavaScript must pass `number[]`, NOT `Uint8Array` nested in an object — Tauri's JSON deserializer does not auto-convert `Uint8Array` inside object properties.
1515+- `Vec<u8>` return values: JavaScript receives `number[]` from `invoke()` with standard `#[tauri::command]`.
1616+- The TypeScript wrappers convert at the boundary: `Array.from(uint8array)` outbound, `new Uint8Array(numbers)` inbound.
1717+1818+---
1919+2020+## Acceptance Criteria Coverage
2121+2222+### MM-145.AC4: DeviceKeyError and Tauri commands follow project conventions
2323+- **MM-145.AC4.1 Success:** all `DeviceKeyError` variants serialize as `{ "code": "SCREAMING_SNAKE_CASE" }` (tested in Phase 1; verified again by Phase 4 serialization test for `DevicePublicKey`)
2424+- **MM-145.AC4.2 Success:** frontend `ipc.ts` can call `getOrCreateDeviceKey()` and `signWithDeviceKey()` and receive correct TypeScript types (manual verification on simulator)
2525+2626+---
2727+2828+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
2929+3030+<!-- START_TASK_1 -->
3131+### Task 1: Write failing serialization test for `DevicePublicKey` camelCase
3232+3333+**Verifies:** MM-145.AC4.1 (partially — ensures DevicePublicKey serializes correctly for Tauri IPC)
3434+3535+**Files:**
3636+- Modify: `apps/identity-wallet/src-tauri/src/device_key.rs` (add one test to the existing `#[cfg(test)] mod tests` block)
3737+3838+**Why this task first:** `DevicePublicKey` currently has no `#[serde(rename_all = "camelCase")]` attribute. Without it, `key_id` serializes as `"key_id"` in JSON — the TypeScript side would receive `key_id` not `keyId`, breaking the TypeScript type definition. The test exposes this gap before we fix it.
3939+4040+**Step 1: Add a failing test to the `#[cfg(test)] mod tests` block in `device_key.rs`**
4141+4242+Find the closing `}` of the `mod tests` block (after the `device_key_error_serializes_as_code` test) and insert before it:
4343+4444+```rust
4545+ // Ensures DevicePublicKey serializes key_id as keyId (camelCase) for Tauri IPC.
4646+ // Without #[serde(rename_all = "camelCase")], this test fails.
4747+ #[test]
4848+ fn device_public_key_serializes_camel_case() {
4949+ let key = DevicePublicKey {
5050+ multibase: "zTest".into(),
5151+ key_id: "did:key:zTest".into(),
5252+ };
5353+ let json = serde_json::to_value(&key).unwrap();
5454+ assert_eq!(json["multibase"], "zTest");
5555+ assert_eq!(json["keyId"], "did:key:zTest", "key_id must serialize as keyId for TypeScript");
5656+ // Confirm the snake_case version is NOT present.
5757+ assert!(json.get("key_id").is_none(), "key_id must not appear as snake_case in JSON");
5858+ }
5959+```
6060+6161+**Step 2: Run the test — verify it FAILS**
6262+6363+```bash
6464+cargo test -p identity-wallet -- device_public_key_serializes_camel_case --test-threads=1 2>&1
6565+```
6666+6767+Expected: test fails because `DevicePublicKey` does not yet have `#[serde(rename_all = "camelCase")]`.
6868+<!-- END_TASK_1 -->
6969+7070+<!-- START_TASK_2 -->
7171+### Task 2: Add `#[serde(rename_all = "camelCase")]` to `DevicePublicKey` and add Tauri commands to `lib.rs`
7272+7373+**Verifies:** MM-145.AC4.1, MM-145.AC4.2
7474+7575+**Files:**
7676+- Modify: `apps/identity-wallet/src-tauri/src/device_key.rs` (add serde attribute to `DevicePublicKey`)
7777+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (add two Tauri commands, update `generate_handler![]`)
7878+7979+**Step 1: Add `#[serde(rename_all = "camelCase")]` to `DevicePublicKey` in `device_key.rs`**
8080+8181+Find:
8282+```rust
8383+#[derive(Debug, Serialize)]
8484+pub struct DevicePublicKey {
8585+```
8686+8787+Replace with:
8888+```rust
8989+#[derive(Debug, Serialize)]
9090+#[serde(rename_all = "camelCase")]
9191+pub struct DevicePublicKey {
9292+```
9393+9494+**Step 2: Verify the previously failing test now passes**
9595+9696+```bash
9797+cargo test -p identity-wallet -- device_public_key_serializes_camel_case --test-threads=1 2>&1
9898+```
9999+100100+Expected: passes. `keyId` appears in JSON; `key_id` does not.
101101+102102+**Step 3: Add two new Tauri commands to `lib.rs`**
103103+104104+Add the following two functions anywhere in `lib.rs` after the existing `create_account` function (before the `pub fn run()` function). These are thin wrappers — all logic lives in `device_key`:
105105+106106+```rust
107107+#[tauri::command]
108108+async fn get_or_create_device_key() -> Result<device_key::DevicePublicKey, device_key::DeviceKeyError> {
109109+ device_key::get_or_create()
110110+}
111111+112112+#[tauri::command]
113113+async fn sign_with_device_key(data: Vec<u8>) -> Result<Vec<u8>, device_key::DeviceKeyError> {
114114+ device_key::sign(&data)
115115+}
116116+```
117117+118118+**Step 4: Register the new commands in `generate_handler![]` (line 193)**
119119+120120+Find:
121121+```rust
122122+.invoke_handler(tauri::generate_handler![create_account])
123123+```
124124+125125+Replace with:
126126+```rust
127127+.invoke_handler(tauri::generate_handler![
128128+ create_account,
129129+ get_or_create_device_key,
130130+ sign_with_device_key,
131131+])
132132+```
133133+134134+**Step 5: Verify `cargo check`**
135135+136136+```bash
137137+cargo check -p identity-wallet
138138+```
139139+140140+Expected: compiles without errors or warnings.
141141+<!-- END_TASK_2 -->
142142+143143+<!-- END_SUBCOMPONENT_A -->
144144+145145+<!-- START_SUBCOMPONENT_B (tasks 3-4) -->
146146+147147+<!-- START_TASK_3 -->
148148+### Task 3: Add TypeScript wrappers to `ipc.ts`
149149+150150+**Verifies:** MM-145.AC4.2
151151+152152+**Files:**
153153+- Modify: `apps/identity-wallet/src/lib/ipc.ts` (append after the `createAccount` export)
154154+155155+**Step 1: Append to `apps/identity-wallet/src/lib/ipc.ts`**
156156+157157+Phases 1–3 do not touch `ipc.ts`, so line 47 is stable and is the insertion point. After line 47 (the end of the `createAccount` export), append:
158158+159159+```typescript
160160+// ── Device Key types ──────────────────────────────────────────────────────────
161161+162162+/**
163163+ * Device public key returned by the `get_or_create_device_key` Rust command.
164164+ * Matches DevicePublicKey struct with #[serde(rename_all = "camelCase")].
165165+ */
166166+export type DevicePublicKey = {
167167+ /** 'z' + base58btc(33-byte compressed P-256 public key point). */
168168+ multibase: string;
169169+ /** Full did:key URI: 'did:key:z...' */
170170+ keyId: string;
171171+};
172172+173173+/**
174174+ * Error returned by device key commands.
175175+ *
176176+ * Serialized as `{ code: "KEY_GENERATION_FAILED" }` etc. by the Rust backend.
177177+ * `message` is present only for KEYCHAIN_ERROR.
178178+ */
179179+export type DeviceKeyError = {
180180+ code:
181181+ | 'KEY_GENERATION_FAILED'
182182+ | 'KEY_NOT_FOUND'
183183+ | 'SIGNING_FAILED'
184184+ | 'INVALID_SIGNATURE'
185185+ | 'KEYCHAIN_ERROR';
186186+ message?: string;
187187+};
188188+189189+// ── get_or_create_device_key ─────────────────────────────────────────────────
190190+191191+/**
192192+ * Get or create the device's SE-backed (or simulator-fallback) P-256 keypair.
193193+ *
194194+ * Idempotent — returns the same key on every call for a given device.
195195+ * On failure, the Promise rejects with a `DeviceKeyError`.
196196+ */
197197+export const getOrCreateDeviceKey = (): Promise<DevicePublicKey> =>
198198+ invoke('get_or_create_device_key');
199199+200200+// ── sign_with_device_key ─────────────────────────────────────────────────────
201201+202202+/**
203203+ * Sign arbitrary bytes using the device's SE-backed (or simulator-fallback) P-256 key.
204204+ *
205205+ * Returns the raw 64-byte ECDSA r||s signature as a Uint8Array.
206206+ *
207207+ * IMPORTANT: `data` is converted to `number[]` before passing to Tauri's IPC
208208+ * because Tauri v2's JSON deserializer cannot accept a `Uint8Array` nested inside
209209+ * an object property — it must be a plain number array. See tauri#10336.
210210+ *
211211+ * On failure, the Promise rejects with a `DeviceKeyError` (code: KEY_NOT_FOUND
212212+ * if `getOrCreateDeviceKey` has never been called for this device).
213213+ */
214214+export const signWithDeviceKey = (data: Uint8Array): Promise<Uint8Array> =>
215215+ (invoke('sign_with_device_key', { data: Array.from(data) }) as Promise<number[]>).then(
216216+ (bytes) => new Uint8Array(bytes),
217217+ );
218218+```
219219+220220+**Step 2: Verify the TypeScript file is syntactically valid**
221221+222222+```bash
223223+cd apps/identity-wallet && pnpm tsc --noEmit 2>&1 | head -20
224224+```
225225+226226+Expected: no TypeScript errors. If `pnpm` is not available, use `npx tsc --noEmit`.
227227+<!-- END_TASK_3 -->
228228+229229+<!-- START_TASK_4 -->
230230+### Task 4: Run full test suite, verify build, and commit
231231+232232+**Verifies:** All Phase 1 ACs + MM-145.AC4.1 + MM-145.AC4.2
233233+234234+**Files:** No changes — verification only.
235235+236236+**Step 1: Run all Rust tests**
237237+238238+```bash
239239+cargo test -p identity-wallet -- --test-threads=1 2>&1
240240+```
241241+242242+Expected: all tests pass, including the new `device_public_key_serializes_camel_case` test (8 device_key tests total now, plus existing lib.rs tests).
243243+244244+**Step 2: Run clippy**
245245+246246+```bash
247247+cargo clippy -p identity-wallet -- -D warnings
248248+```
249249+250250+Expected: no warnings.
251251+252252+**Step 3: Verify iOS build compiles**
253253+254254+```bash
255255+cargo build -p identity-wallet --target aarch64-apple-ios 2>&1
256256+```
257257+258258+Expected: compiles without errors.
259259+260260+**Step 4: Manual simulator verification (AC4.2)**
261261+262262+On the iOS Simulator (via `cargo tauri ios dev`):
263263+1. Call `getOrCreateDeviceKey()` from a Svelte component — verify it resolves with `{ multibase: 'z...', keyId: 'did:key:z...' }`
264264+2. Call `signWithDeviceKey(new Uint8Array([1,2,3]))` — verify it resolves with a `Uint8Array` of length 64
265265+3. Call `signWithDeviceKey` before `getOrCreateDeviceKey` is ever called (fresh install) — verify it rejects with `{ code: 'KEY_NOT_FOUND' }`
266266+267267+**Step 5: Commit**
268268+269269+```bash
270270+git add apps/identity-wallet/src-tauri/src/device_key.rs \
271271+ apps/identity-wallet/src-tauri/src/lib.rs \
272272+ apps/identity-wallet/src/lib/ipc.ts
273273+git commit -m "feat(ipc): expose get_or_create_device_key and sign_with_device_key Tauri commands"
274274+```
275275+<!-- END_TASK_4 -->
276276+277277+<!-- END_SUBCOMPONENT_B -->
···11+# MM-145 Test Requirements
22+33+## Automated Tests
44+55+| AC | Test Name | Type | File |
66+|----|-----------|------|------|
77+| MM-145.AC1.1 | `get_or_create_returns_valid_multibase` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` |
88+| MM-145.AC1.2 | `get_or_create_is_idempotent` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` |
99+| MM-145.AC1.3 | `key_id_has_did_key_prefix` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` |
1010+| MM-145.AC3.1 | `sign_returns_64_bytes` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` |
1111+| MM-145.AC3.2 | `sign_is_deterministic` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` |
1212+| MM-145.AC3.3 | `sign_before_generate_returns_key_not_found` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` |
1313+| MM-145.AC4.1 | `device_key_error_serializes_as_code` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` |
1414+| MM-145.AC4.1 | `device_public_key_serializes_camel_case` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` |
1515+| MM-145.AC5.1 | `create_account_uses_device_key_public_key` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` |
1616+1717+## Human Verification
1818+1919+| AC | What to Verify | How |
2020+|----|---------------|-----|
2121+| MM-145.AC1.4 | Key persists across app restarts (real Keychain round-trip) | Implicitly covered by `get_or_create_is_idempotent` on the simulator/macOS path (stateless function always reads from Keychain). Cross-process persistence on a real device is verified manually via AC2.1 below. |
2222+| MM-145.AC2.1 | Key retrieved after cold restart matches key from initial generation (SE tag persistence) | On a physical iOS device: (1) build and run the app via `cargo tauri ios dev`; (2) call `device_key::get_or_create()` and record the returned multibase string; (3) force-kill and relaunch the app (cold restart); (4) call `device_key::get_or_create()` again and verify the multibase string is identical. |
2323+| MM-145.AC2.2 | Private key bytes cannot be extracted from the Keychain (SE non-extractable guarantee) | On a physical iOS device: (1) `SecKey::new` with `Token::SecureEnclave` creates a non-extractable key by hardware design; (2) verify that calling `external_representation()` on the SE private key returns `None` (the SE rejects export). This is a design-level guarantee of Apple's Secure Enclave hardware and cannot be tested in the simulator or via `cargo test`. |
2424+| MM-145.AC4.2 | Frontend `ipc.ts` can call `getOrCreateDeviceKey()` and `signWithDeviceKey()` and receive correct TypeScript types | On the iOS Simulator via `cargo tauri ios dev`: (1) call `getOrCreateDeviceKey()` from a Svelte component and verify it resolves with `{ multibase: 'z...', keyId: 'did:key:z...' }`; (2) call `signWithDeviceKey(new Uint8Array([1,2,3]))` and verify it resolves with a `Uint8Array` of length 64; (3) call `signWithDeviceKey` before `getOrCreateDeviceKey` is ever called (fresh install) and verify it rejects with `{ code: 'KEY_NOT_FOUND' }`. |
2525+2626+## Notes
2727+2828+- **Test isolation:** All unit tests share the macOS Keychain entry `"device-rotation-key-priv"` under service `"ezpds-identity-wallet"`. The `sign_before_generate_returns_key_not_found` test deletes this entry to simulate a fresh state. Tests must run single-threaded to prevent Keychain races.
2929+- **Run command:** `cargo test -p identity-wallet -- --test-threads=1`
3030+- **Platform requirements:** Automated tests run on macOS host via `cargo test`. The simulator/macOS software path is selected at compile time by `#[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))]`. The Secure Enclave path (`#[cfg(all(target_os = "ios", not(target_env = "sim")))]`) compiles only for `aarch64-apple-ios` and requires a physical iOS device for manual verification.
3131+- **Phase ordering:** Tests are introduced incrementally across phases. Phase 1 adds 7 tests in `device_key.rs`. Phase 3 adds 1 test in `lib.rs`. Phase 4 adds 1 test in `device_key.rs`. Total: 9 automated tests.
3232+- **No mock server:** `create_account` makes a real HTTP call to the relay, so AC5.1 is tested indirectly by verifying the `device_key::get_or_create()` contract (correct format, idempotent) rather than calling `create_account` directly. Compile-time verification ensures the wiring is correct (`cargo check` catches type mismatches).
···11+# MM-146 DID Ceremony Implementation Plan
22+33+**Goal:** Add `build_did_plc_genesis_op_with_external_signer` to the crypto crate so callers with non-extractable keys (Secure Enclave) can sign without exposing raw private key bytes.
44+55+**Architecture:** Pure functional core addition to `crates/crypto/src/plc.rs`. The new function accepts an `FnOnce` signing callback instead of raw key bytes; the existing `build_did_plc_genesis_op` is refactored to a thin wrapper that constructs an inline callback from the private key bytes and delegates. No I/O, no new dependencies.
66+77+**Tech Stack:** Rust, p256 (ECDSA), ciborium (DAG-CBOR), base64, sha2
88+99+**Scope:** Phase 2 of 4 from the MM-146 design plan.
1010+1111+**Codebase verified:** 2026-03-20
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase implements and tests:
1818+1919+### MM-146.AC2: build_did_plc_genesis_op_with_external_signer produces valid genesis op
2020+- **MM-146.AC2.1 Success:** Callback receives CBOR-encoded unsigned op bytes; returned `PlcGenesisOp` passes `verify_genesis_op`
2121+- **MM-146.AC2.2 Failure:** Callback returning `Err` propagates as `CryptoError::PlcOperation`
2222+- **MM-146.AC2.3 Success:** Existing `build_did_plc_genesis_op` (now a wrapper) produces identical output to before (existing tests unchanged)
2323+2424+---
2525+2626+<!-- START_SUBCOMPONENT_A (tasks 1-5) -->
2727+2828+<!-- START_TASK_1 -->
2929+### Task 1: Add build_did_plc_genesis_op_with_external_signer to plc.rs
3030+3131+**Files:**
3232+- Modify: `crates/crypto/src/plc.rs` — insert new function before the existing `build_did_plc_genesis_op` (currently at line 161)
3333+3434+**Implementation:**
3535+3636+Insert the following block immediately after the `base32_lowercase` function (currently ending around line 159), and before the existing `build_did_plc_genesis_op` function:
3737+3838+```rust
3939+/// Build and sign a did:plc genesis operation using an external signing callback.
4040+///
4141+/// This variant accepts a signing callback instead of raw private key bytes, enabling
4242+/// use with non-extractable keys such as Apple Secure Enclave keys.
4343+///
4444+/// # Parameters
4545+/// - `rotation_key`: The user's device key (highest-priority rotation key). Placed at `rotationKeys[0]`.
4646+/// - `signing_key`: The relay's signing key. Placed at `rotationKeys[1]` and `verificationMethods.atproto`.
4747+/// - `handle`: The account handle, e.g. `"alice.example.com"`. Stored as `"at://alice.example.com"` in `alsoKnownAs`.
4848+/// - `service_endpoint`: The relay's public URL, e.g. `"https://relay.example.com"`.
4949+/// - `sign`: A callback that receives the CBOR-encoded unsigned op bytes and must return the
5050+/// raw 64-byte r‖s P-256 ECDSA signature bytes (big-endian, low-S canonical).
5151+///
5252+/// # Errors
5353+/// Returns `CryptoError::PlcOperation` if `sign` returns `Err`, or if any serialization step fails.
5454+pub fn build_did_plc_genesis_op_with_external_signer<F>(
5555+ rotation_key: &DidKeyUri,
5656+ signing_key: &DidKeyUri,
5757+ handle: &str,
5858+ service_endpoint: &str,
5959+ sign: F,
6060+) -> Result<PlcGenesisOp, CryptoError>
6161+where
6262+ F: FnOnce(&[u8]) -> Result<Vec<u8>, CryptoError>,
6363+{
6464+ // Step 1: Build the unsigned operation.
6565+ let mut verification_methods = BTreeMap::new();
6666+ verification_methods.insert("atproto".to_string(), signing_key.0.clone());
6767+6868+ let mut services = BTreeMap::new();
6969+ services.insert(
7070+ "atproto_pds".to_string(),
7171+ PlcService {
7272+ service_type: "AtprotoPersonalDataServer".to_string(),
7373+ endpoint: service_endpoint.to_string(),
7474+ },
7575+ );
7676+7777+ let unsigned_op = UnsignedPlcOp {
7878+ prev: None,
7979+ op_type: "plc_operation".to_string(),
8080+ services: services.clone(),
8181+ also_known_as: vec![format!("at://{handle}")],
8282+ rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()],
8383+ verification_methods: verification_methods.clone(),
8484+ };
8585+8686+ // Step 2: CBOR-encode the unsigned operation.
8787+ let mut unsigned_cbor = Vec::new();
8888+ into_writer(&unsigned_op, &mut unsigned_cbor)
8989+ .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?;
9090+9191+ // Step 3: Call external signer with the CBOR bytes.
9292+ // The callback must return raw 64-byte r‖s P-256 ECDSA signature bytes.
9393+ let sig_bytes = sign(&unsigned_cbor)?;
9494+9595+ // Step 4: base64url-encode the signature (no padding).
9696+ let sig_str = URL_SAFE_NO_PAD.encode(&sig_bytes);
9797+9898+ // Step 5: Build the signed operation (same fields + sig).
9999+ let signed_op = SignedPlcOp {
100100+ sig: sig_str,
101101+ prev: None,
102102+ op_type: "plc_operation".to_string(),
103103+ services,
104104+ also_known_as: vec![format!("at://{handle}")],
105105+ rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()],
106106+ verification_methods,
107107+ };
108108+109109+ // Step 6: CBOR-encode the signed operation.
110110+ let mut signed_cbor = Vec::new();
111111+ into_writer(&signed_op, &mut signed_cbor)
112112+ .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?;
113113+114114+ // Step 7: SHA-256 hash of the signed CBOR.
115115+ let hash = Sha256::digest(&signed_cbor);
116116+117117+ // Step 8: base32-lowercase, take first 24 characters.
118118+ let encoded = base32_lowercase()?.encode(hash.as_ref());
119119+ let did = format!("did:plc:{}", &encoded[..24]);
120120+121121+ // Step 9: JSON-serialize the signed operation.
122122+ let signed_op_json = serde_json::to_string(&signed_op)
123123+ .map_err(|e| CryptoError::PlcOperation(format!("json serialize signed op: {e}")))?;
124124+125125+ Ok(PlcGenesisOp {
126126+ did,
127127+ signed_op_json,
128128+ })
129129+}
130130+```
131131+<!-- END_TASK_1 -->
132132+133133+<!-- START_TASK_2 -->
134134+### Task 2: Refactor build_did_plc_genesis_op into a thin wrapper
135135+136136+**Files:**
137137+- Modify: `crates/crypto/src/plc.rs` — replace the body of `build_did_plc_genesis_op` (lines 161–239) with a delegation call to the new function
138138+139139+**Implementation:**
140140+141141+Replace the existing `build_did_plc_genesis_op` implementation (everything from the opening `{` on line 167 through the closing `}` on line 239) with this thin wrapper body:
142142+143143+```rust
144144+pub fn build_did_plc_genesis_op(
145145+ rotation_key: &DidKeyUri,
146146+ signing_key: &DidKeyUri,
147147+ signing_private_key: &[u8; 32],
148148+ handle: &str,
149149+ service_endpoint: &str,
150150+) -> Result<PlcGenesisOp, CryptoError> {
151151+ let field_bytes: FieldBytes = (*signing_private_key).into();
152152+ let sk = SigningKey::from_bytes(&field_bytes)
153153+ .map_err(|e| CryptoError::PlcOperation(format!("invalid signing key: {e}")))?;
154154+ build_did_plc_genesis_op_with_external_signer(
155155+ rotation_key,
156156+ signing_key,
157157+ handle,
158158+ service_endpoint,
159159+ |data| {
160160+ let sig: Signature = Signer::sign(&sk, data);
161161+ Ok(sig.to_bytes().to_vec())
162162+ },
163163+ )
164164+}
165165+```
166166+167167+The function signature (parameter names, types, doc comment) is unchanged. Only the body changes.
168168+169169+**Verification:**
170170+171171+Run: `cargo build -p crypto`
172172+Expected: Compiles without errors or warnings.
173173+<!-- END_TASK_2 -->
174174+175175+<!-- START_TASK_3 -->
176176+### Task 3: Update lib.rs to re-export the new function
177177+178178+**Files:**
179179+- Modify: `crates/crypto/src/lib.rs` line 12 — add `build_did_plc_genesis_op_with_external_signer` to the plc re-export
180180+181181+**Implementation:**
182182+183183+Change line 12 from:
184184+185185+```rust
186186+pub use plc::{build_did_plc_genesis_op, verify_genesis_op, PlcGenesisOp, VerifiedGenesisOp};
187187+```
188188+189189+To:
190190+191191+```rust
192192+pub use plc::{
193193+ build_did_plc_genesis_op, build_did_plc_genesis_op_with_external_signer, verify_genesis_op,
194194+ PlcGenesisOp, VerifiedGenesisOp,
195195+};
196196+```
197197+198198+**Verification:**
199199+200200+Run: `cargo build -p crypto`
201201+Expected: Compiles without errors or warnings.
202202+<!-- END_TASK_3 -->
203203+204204+<!-- START_TASK_4 -->
205205+### Task 4: Add tests for the external signer function
206206+207207+**Verifies:** MM-146.AC2.1, MM-146.AC2.2
208208+209209+**Files:**
210210+- Modify: `crates/crypto/src/plc.rs` — append two new test functions to the existing `mod tests` block (before the closing `}` of the block, which ends at the final line of the file)
211211+212212+**Testing:**
213213+214214+Tests must verify each AC listed above. Both tests go inside the existing `#[cfg(test)] mod tests { use super::*; ... }` block, alongside the existing tests. Do not create a new test module.
215215+216216+Append these two test functions **before the final closing `}` of the `mod tests` block** — i.e., the very last `}` in the file. The existing block ends with `}` on the last line; insert the new functions just above it:
217217+218218+```rust
219219+ // MM-146.AC2.1: Callback receives CBOR bytes; returned PlcGenesisOp passes verify_genesis_op.
220220+ #[test]
221221+ fn external_signer_callback_produces_valid_genesis_op() {
222222+ let rotation_kp = generate_p256_keypair().expect("rotation keypair");
223223+ let signing_kp = generate_p256_keypair().expect("signing keypair");
224224+ let private_key_bytes: [u8; 32] = *signing_kp.private_key_bytes;
225225+226226+ // Simulate SE: the key is available for signing but bytes are not "exposed" to the caller.
227227+ let field_bytes: FieldBytes = private_key_bytes.into();
228228+ let sk = SigningKey::from_bytes(&field_bytes).expect("valid signing key");
229229+230230+ let result = build_did_plc_genesis_op_with_external_signer(
231231+ &rotation_kp.key_id,
232232+ &signing_kp.key_id,
233233+ "alice.example.com",
234234+ "https://relay.example.com",
235235+ |data| {
236236+ let sig: Signature = Signer::sign(&sk, data);
237237+ Ok(sig.to_bytes().to_vec())
238238+ },
239239+ )
240240+ .expect("external signer should succeed");
241241+242242+ // The resulting op must pass verify_genesis_op with the rotation key.
243243+ let verified = verify_genesis_op(&result.signed_op_json, &rotation_kp.key_id)
244244+ .expect("signed op must be verifiable with rotation key");
245245+ assert_eq!(
246246+ verified.did, result.did,
247247+ "verified DID must match the DID returned by the builder"
248248+ );
249249+ }
250250+251251+ // MM-146.AC2.2: Callback returning Err propagates as CryptoError::PlcOperation.
252252+ #[test]
253253+ fn external_signer_callback_error_propagates_as_plc_operation() {
254254+ let rotation_kp = generate_p256_keypair().expect("rotation keypair");
255255+ let signing_kp = generate_p256_keypair().expect("signing keypair");
256256+257257+ let result = build_did_plc_genesis_op_with_external_signer(
258258+ &rotation_kp.key_id,
259259+ &signing_kp.key_id,
260260+ "alice.example.com",
261261+ "https://relay.example.com",
262262+ |_data| Err(CryptoError::PlcOperation("SE signing failed".to_string())),
263263+ );
264264+265265+ assert!(result.is_err(), "must return error when callback fails");
266266+ match result.unwrap_err() {
267267+ CryptoError::PlcOperation(msg) => {
268268+ assert!(
269269+ msg.contains("SE signing failed"),
270270+ "error message must propagate from callback, got: {msg}"
271271+ );
272272+ }
273273+ other => panic!("expected CryptoError::PlcOperation, got: {other:?}"),
274274+ }
275275+ }
276276+```
277277+278278+**Verification:**
279279+280280+Run: `cargo test -p crypto`
281281+Expected: All existing tests still pass (MM-146.AC2.3 confirmed) + 2 new tests pass.
282282+<!-- END_TASK_4 -->
283283+284284+<!-- START_TASK_5 -->
285285+### Task 5: Commit
286286+287287+**Files:** All changes to `crates/crypto/src/plc.rs` and `crates/crypto/src/lib.rs`
288288+289289+```bash
290290+git add crates/crypto/src/plc.rs crates/crypto/src/lib.rs
291291+git commit -m "feat(crypto): add build_did_plc_genesis_op_with_external_signer for SE-backed signing"
292292+```
293293+<!-- END_TASK_5 -->
294294+295295+<!-- END_SUBCOMPONENT_A -->
···11+# MM-146 Test Requirements
22+33+Maps every acceptance criterion from the MM-146 design plan to either an automated test or a documented human verification step. Rationalized against implementation decisions made during phase planning.
44+55+---
66+77+## Automated Tests
88+99+### MM-146.AC1: GET /v1/relay/keys returns active signing key
1010+1111+All AC1 criteria are covered by integration tests in Phase 1, Task 3. Tests use `test_state()` (in-memory SQLite) and axum's `oneshot()` to exercise the full handler stack without a running server.
1212+1313+| Criterion | Test Type | Test File | Test Function | Run Command |
1414+|---|---|---|---|---|
1515+| **AC1.1** Returns 200 with `{ keyId, publicKey, algorithm }` when a signing key is provisioned | Integration | `crates/relay/src/routes/get_relay_signing_key.rs` | `get_relay_keys_returns_200_with_active_key` | `cargo test -p relay get_relay` |
1616+| **AC1.2** Returns the most recently created key when multiple keys exist | Integration | `crates/relay/src/routes/get_relay_signing_key.rs` | `get_relay_keys_returns_most_recently_created_key` | `cargo test -p relay get_relay` |
1717+| **AC1.3** Returns 503 when no signing key is provisioned | Integration | `crates/relay/src/routes/get_relay_signing_key.rs` | `get_relay_keys_returns_503_when_no_key_provisioned` | `cargo test -p relay get_relay` |
1818+| **AC1.4** Endpoint requires no authentication (public, no Bearer token) | Integration | `crates/relay/src/routes/get_relay_signing_key.rs` | `get_relay_keys_requires_no_authentication` | `cargo test -p relay get_relay` |
1919+2020+**Implementation rationale:** These are standard axum handler integration tests following the pattern established by `create_signing_key.rs` and other route files. Each test inserts test data directly via sqlx and sends a request through the full router. AC1.4 is verified by the absence of an Authorization header in the request builder (`get_keys()` sends no auth header) combined with an assertion that the response is 200, not 401.
2121+2222+---
2323+2424+### MM-146.AC2: build_did_plc_genesis_op_with_external_signer produces valid genesis op
2525+2626+All AC2 criteria are covered by unit tests in Phase 2, Task 4. Tests are appended to the existing `#[cfg(test)] mod tests` block in `plc.rs`. These are pure function tests with no I/O.
2727+2828+| Criterion | Test Type | Test File | Test Function | Run Command |
2929+|---|---|---|---|---|
3030+| **AC2.1** Callback receives CBOR-encoded unsigned op bytes; returned `PlcGenesisOp` passes `verify_genesis_op` | Unit | `crates/crypto/src/plc.rs` | `external_signer_callback_produces_valid_genesis_op` | `cargo test -p crypto` |
3131+| **AC2.2** Callback returning `Err` propagates as `CryptoError::PlcOperation` | Unit | `crates/crypto/src/plc.rs` | `external_signer_callback_error_propagates_as_plc_operation` | `cargo test -p crypto` |
3232+| **AC2.3** Existing `build_did_plc_genesis_op` (now a wrapper) produces identical output to before | Unit (existing) | `crates/crypto/src/plc.rs` | *(all pre-existing tests in mod tests)* | `cargo test -p crypto` |
3333+3434+**Implementation rationale:** AC2.1 generates a real P-256 keypair, passes a signing callback that uses the private key, and then verifies the resulting genesis op via `verify_genesis_op` -- the same verification function used in production. AC2.2 passes a callback that returns `Err(CryptoError::PlcOperation(...))` and asserts the error propagates unchanged. AC2.3 requires no new test code: the existing tests for `build_did_plc_genesis_op` exercise the refactored wrapper path implicitly. If the wrapper delegation introduces a regression, those existing tests fail.
3535+3636+---
3737+3838+### MM-146.AC3: perform_did_ceremony completes the full ceremony (partial automated coverage)
3939+4040+AC3 criteria are split between automated serialization tests and human verification. The `perform_did_ceremony` Tauri command orchestrates Keychain (Apple system API), Secure Enclave (hardware), and HTTP calls to a real relay. These I/O boundaries cannot be meaningfully mocked in `cargo test` because:
4141+4242+1. Keychain APIs (`Security.framework`) require a running app context with entitlements.
4343+2. Secure Enclave signing (`device_key::sign`) requires physical or simulated Apple hardware.
4444+3. The `RelayClient` uses a compile-time `LazyLock<RelayClient>` singleton -- no dependency injection seam exists to substitute a mock HTTP client, and introducing one would add complexity beyond the scope of this feature.
4545+4646+Phase 3, Task 3 explicitly acknowledges this gap and provides serialization-contract tests as the automated layer.
4747+4848+| Criterion | Test Type | Test File | Test Function | Run Command |
4949+|---|---|---|---|---|
5050+| **AC3.4** `NoRelaySigningKey` serializes as `{ code: "NO_RELAY_SIGNING_KEY" }` | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_no_relay_signing_key_serializes_correctly` | `cargo test -p identity-wallet` |
5151+| **AC3.5** `RelayKeyFetchFailed` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_relay_key_fetch_failed_serializes_correctly` | `cargo test -p identity-wallet` |
5252+| **AC3.6** `SigningFailed` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_signing_failed_serializes_correctly` | `cargo test -p identity-wallet` |
5353+| **AC3.7** `DidCreationFailed` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_did_creation_failed_serializes_correctly` | `cargo test -p identity-wallet` |
5454+| *(supporting)* `DIDCeremonyResult` serializes `did` field in camelCase | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_result_serializes_did_in_camel_case` | `cargo test -p identity-wallet` |
5555+| *(supporting)* `KeyNotFound` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_key_not_found_serializes_correctly` | `cargo test -p identity-wallet` |
5656+| *(supporting)* `KeychainError` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_keychain_error_serializes_correctly` | `cargo test -p identity-wallet` |
5757+| *(supporting)* `NetworkError` serializes with message field | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_network_error_serializes_with_message` | `cargo test -p identity-wallet` |
5858+5959+**Implementation rationale:** The 8 serde tests verify the contract between Rust and TypeScript. If a variant's serialized `code` string changes, the TypeScript `DIDCeremonyError.code` discriminated union in `ipc.ts` will silently fail to match it. These tests catch that at compile/test time. The behavioral outcomes (AC3.1 through AC3.3, and the runtime error paths of AC3.4 through AC3.7) require human verification on an iOS simulator -- see the next section.
6060+6161+---
6262+6363+## Human Verification
6464+6565+### MM-146.AC3: perform_did_ceremony behavioral outcomes
6666+6767+The following criteria require manual testing on an iOS Simulator (or device) with a running relay instance. They cannot be automated because they depend on Keychain persistence, Secure Enclave hardware signing, and live HTTP round-trips to a relay that has been provisioned with a signing key.
6868+6969+| Criterion | Verification Approach | Justification |
7070+|---|---|---|
7171+| **AC3.1** Given a valid pending session token and provisioned relay key, returns `DIDCeremonyResult { did }` with a valid `did:plc` identifier | **iOS Simulator end-to-end flow:** (1) Start a local relay with a provisioned signing key. (2) Launch the app on the iOS Simulator. (3) Complete the account creation flow (claim code, email, handle). (4) Observe that the DID ceremony screen transitions to the DID success screen. (5) Verify the displayed DID starts with `did:plc:` and is 32 characters long. | The Tauri command touches Keychain, SE, and HTTP in sequence. No mock seam exists for any of these in the current architecture. |
7272+| **AC3.2** Keychain `"session-token"` is overwritten with the full session token from `POST /v1/dids` response | **Post-ceremony Keychain inspection:** After a successful ceremony in the simulator, use `security find-generic-password -s "ezpds-identity-wallet" -a "session-token" -w` in Terminal (or restart the app and verify it reads the upgraded token). Alternatively, add a temporary `tracing::info!` log in the `keychain::store_item` call and inspect Xcode console output. | Keychain writes require a running app with the correct entitlements. The value is set by `keychain::store_item`, which is an opaque `Security.framework` call. |
7373+| **AC3.3** Keychain `"did"` is populated with the resulting DID | **Post-ceremony Keychain inspection:** Same approach as AC3.2, using key `"did"` instead of `"session-token"`. Verify the stored value matches the DID shown on the success screen. | Same justification as AC3.2. |
7474+| **AC3.4** Returns `NoRelaySigningKey` when relay has no key (runtime behavior) | **iOS Simulator with empty relay:** (1) Start a local relay without provisioning a signing key. (2) Complete account creation. (3) Observe the DID ceremony screen shows the error message "The relay hasn't been configured yet. Please try again later." and a Retry button. | The serialization contract is tested automatically; this verifies the runtime HTTP 503 detection path. |
7575+| **AC3.5** Returns `RelayKeyFetchFailed` when `GET /v1/relay/keys` is unreachable (runtime behavior) | **iOS Simulator with relay stopped:** (1) Complete account creation with relay running. (2) Stop the relay process. (3) Observe the DID ceremony screen shows "Couldn't reach the server. Check your connection." and a Retry button. | Requires actual network failure -- cannot be simulated in a unit test without an HTTP mock layer. |
7676+| **AC3.6** Returns `SigningFailed` when SE signing fails (runtime behavior) | **Difficult to trigger intentionally.** SE signing failures are rare and hardware-dependent (e.g., key access revoked, biometric failure on a key with biometric policy). Verify indirectly: the error enum variant exists, serializes correctly (automated test), and the UI maps it to "Device signing failed. Please try again." (code review of `DIDCeremonyScreen.svelte`). | Secure Enclave failures cannot be reliably triggered in the simulator. The code path is verified via code review and the serialization unit test. |
7777+| **AC3.7** Returns `DidCreationFailed` when `POST /v1/dids` returns non-2xx (runtime behavior) | **iOS Simulator with relay returning errors:** (1) Provision the relay signing key. (2) Start the ceremony. (3) Cause `POST /v1/dids` to fail (e.g., use an already-promoted session token, or modify the relay to return 400). (4) Observe the DID ceremony screen shows "Couldn't create your identity. Please try again." and a Retry button. | Requires a specific relay state that produces a non-2xx response. Could also be verified with a proxy (e.g., mitmproxy) that intercepts and returns an error. |
7878+7979+---
8080+8181+### MM-146.AC4: DID ceremony UI
8282+8383+No frontend test framework (Vitest, Playwright, etc.) is configured in the `apps/identity-wallet/` project. All UI criteria are verified manually on the iOS Simulator. The only automated frontend check is `pnpm check` (TypeScript/Svelte type-checking), which validates component props and IPC types at build time but does not render or interact with components.
8484+8585+| Criterion | Verification Approach | Justification |
8686+|---|---|---|
8787+| **AC4.1** App shows loading screen with status text while ceremony is in flight | **iOS Simulator observation:** (1) Launch the app and complete account creation. (2) Observe that a loading screen appears with the text "Setting up your identity..." while the ceremony network calls are in progress. For slow-network testing, use Network Link Conditioner on the simulator to add latency. | UI rendering requires the Tauri runtime and a mobile WebView. `LoadingScreen.svelte` is a pre-existing component; this test confirms it is wired up correctly with the `statusText` prop. |
8888+| **AC4.2** On success, transitions to success screen showing truncated DID and a "Continue" button | **iOS Simulator observation:** (1) Complete a successful ceremony. (2) Verify the success screen appears with the heading "Identity Created!", a truncated DID in `did:plc:xxxxx...xxxx` format, and a "Continue" button. | Requires Tauri IPC round-trip to get a real DID and the Svelte rendering pipeline. |
8989+| **AC4.3** On failure, shows inline error message and a Retry button (does not rewind to previous screen) | **iOS Simulator with relay stopped or unconfigured:** (1) Trigger a ceremony failure (e.g., relay not running). (2) Verify the error message appears inline (red text) with a Retry button. (3) Verify the app does NOT navigate back to the handle or account creation screen. | Tests the error UI path end-to-end including the `catch` block in `DIDCeremonyScreen.svelte`. |
9090+| **AC4.4** Retry button re-invokes the ceremony from the beginning | **iOS Simulator retry flow:** (1) Trigger a failure (relay down). (2) Start the relay and provision a signing key. (3) Tap Retry. (4) Observe the loading screen reappears and the ceremony completes successfully, transitioning to the success screen. | Verifies that `runCeremony()` is called again from scratch (re-fetches device key, relay key, etc.) rather than resuming from a partial state. |
9191+| **AC4.5** "Continue" button transitions to `shamir_backup` placeholder step | **iOS Simulator observation:** (1) Complete a successful ceremony. (2) On the success screen, tap "Continue". (3) Verify the app transitions to a placeholder screen with the heading "Backup" and text "Shamir backup coming soon..." | Simple navigation check. Verifies the `oncontinue` callback in `DIDSuccessScreen.svelte` sets `step = 'shamir_backup'` in `+page.svelte`. |
9292+9393+---
9494+9595+## Coverage Summary
9696+9797+| AC Group | Total Criteria | Automated | Human Verification | Notes |
9898+|---|---|---|---|---|
9999+| AC1 (Relay endpoint) | 4 | 4 | 0 | Full automated coverage via axum integration tests |
100100+| AC2 (Crypto external signer) | 3 | 3 | 0 | Full automated coverage; AC2.3 is implicit via existing tests |
101101+| AC3 (Tauri ceremony command) | 7 | 4 (serde) | 7 (behavioral) | Serialization contracts automated; behavioral outcomes require iOS Simulator. Four criteria have both automated (serde) and human (behavioral) verification. |
102102+| AC4 (Frontend UI) | 5 | 0 | 5 | No frontend test framework configured; all verified on iOS Simulator |
103103+| **Total** | **19** | **11** | **12** | Every criterion has at least one verification method |
104104+105105+**Note on overlapping coverage:** AC3.4 through AC3.7 each appear in both the automated and human columns. The automated tests verify the serde serialization contract (the `code` string matches what TypeScript expects). The human verification confirms the runtime behavior (the correct error variant is produced when the real failure condition occurs). Both layers are necessary: a serialization-only test would miss a bug in the HTTP status code check, while a manual-only test would miss a serialization rename that breaks the TypeScript error handler.