An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add test plan for MM-145 P-256 keypair via Secure Enclave

authored by

Malpercio and committed by
Tangled
9baf2640 aefe4b06

+125
+125
docs/test-plans/2026-03-18-MM-145.md
··· 1 + # MM-145 Test Analysis and Human Test Plan 2 + 3 + ## Coverage Validation 4 + 5 + **Automated Criteria:** 9 | **Covered:** 9 | **Missing:** 0 6 + 7 + ### Covered 8 + 9 + | Criterion | Test File | Verifies | 10 + |-----------|-----------|----------| 11 + | MM-145.AC1.1 `get_or_create_returns_valid_multibase` | `apps/identity-wallet/src-tauri/src/device_key.rs:283` | Calls `get_or_create()`, asserts multibase starts with `'z'`, decodes via `multibase::decode` and asserts decoded length is 33 bytes (compressed P-256 SEC1 point). | 12 + | MM-145.AC1.2 `get_or_create_is_idempotent` | `apps/identity-wallet/src-tauri/src/device_key.rs:295` | Calls `get_or_create()` twice, asserts both `multibase` and `key_id` are identical across calls. | 13 + | MM-145.AC1.3 `key_id_has_did_key_prefix` | `apps/identity-wallet/src-tauri/src/device_key.rs:307` | Calls `get_or_create()`, asserts `key_id` starts with `"did:key:z"`. | 14 + | MM-145.AC3.1 `sign_returns_64_bytes` | `apps/identity-wallet/src-tauri/src/device_key.rs:318` | Ensures key exists, calls `sign(b"test payload")`, asserts result length is exactly 64 bytes. | 15 + | MM-145.AC3.2 `sign_is_deterministic` | `apps/identity-wallet/src-tauri/src/device_key.rs:326` | Calls `sign()` twice with identical data, asserts both signatures are byte-identical (RFC 6979). | 16 + | MM-145.AC3.3 `sign_before_generate_returns_key_not_found` | `apps/identity-wallet/src-tauri/src/device_key.rs:338` | Deletes Keychain entry to simulate fresh state, calls `sign()`, asserts `Err(DeviceKeyError::KeyNotFound)`. | 17 + | MM-145.AC4.1 `device_key_error_serializes_as_code` | `apps/identity-wallet/src-tauri/src/device_key.rs:351` | Serializes `KeyGenerationFailed`, `KeyNotFound`, `KeychainError` to JSON; asserts `{ "code": "SCREAMING_SNAKE_CASE" }` format. | 18 + | MM-145.AC4.1 `device_public_key_serializes_camel_case` | `apps/identity-wallet/src-tauri/src/device_key.rs:371` | Constructs `DevicePublicKey`, serializes to JSON, asserts `key_id` appears as `keyId` and snake_case `key_id` is absent. | 19 + | MM-145.AC5.1 `create_account_uses_device_key_public_key` | `apps/identity-wallet/src-tauri/src/lib.rs:339` | Calls `device_key::get_or_create()` (the same function `create_account` uses), asserts multibase format and idempotency. | 20 + 21 + ### Missing 22 + 23 + (none) 24 + 25 + **Result: PASS** 26 + 27 + --- 28 + 29 + ## Human Test Plan 30 + 31 + ### Prerequisites 32 + 33 + - macOS Ventura (13) or later with Xcode installed (latest stable) 34 + - iOS Simulator platform installed (Xcode > Settings > Platforms > iOS) 35 + - Cocoapods installed (`sudo gem install cocoapods`) 36 + - Nix dev shell active from workspace root: `nix develop --impure --accept-flake-config` 37 + - Frontend dependencies installed: `cd apps/identity-wallet && pnpm install` 38 + - Xcode project generated: `cargo tauri ios init` (with PATH patched per CLAUDE.md instructions) 39 + - For physical device tests (AC2.1, AC2.2): an Apple Developer account and a physical iOS device with Secure Enclave 40 + - Automated tests passing: `cargo test -p identity-wallet -- --test-threads=1` 41 + 42 + ### Phase 1: Key Generation (Simulator) 43 + 44 + | Step | Action | Expected | 45 + |------|--------|----------| 46 + | 1.1 | From `apps/identity-wallet/`, run `cargo tauri ios dev` to launch the app in the iOS Simulator | App builds and opens in Simulator; Vite HMR connects successfully | 47 + | 1.2 | In a Svelte component or browser console, call `getOrCreateDeviceKey()` (import from `$lib/ipc.ts`) | Promise resolves with `{ multibase: "z...", keyId: "did:key:z..." }` where `multibase` starts with `z` and `keyId` starts with `did:key:z` | 48 + | 1.3 | Call `getOrCreateDeviceKey()` a second time | Returns the exact same `multibase` and `keyId` values as step 1.2 | 49 + | 1.4 | Force-quit the Simulator app (swipe up in app switcher), relaunch, call `getOrCreateDeviceKey()` again | Returns the exact same `multibase` and `keyId` values as steps 1.2/1.3 (key persists in macOS Keychain used by Simulator) | 50 + 51 + ### Phase 2: Secure Enclave Persistence (Physical Device) 52 + 53 + | Step | Action | Expected | 54 + |------|--------|----------| 55 + | 2.1 | Build and deploy to a physical iOS device via `cargo tauri ios dev` (connected USB or same network). Call `getOrCreateDeviceKey()` and record the returned `multibase` string | Returns a `DevicePublicKey` with multibase starting with `z` | 56 + | 2.2 | Force-kill the app from the iOS multitasking view (swipe up). Wait 5 seconds. Relaunch the app from the home screen | App opens normally | 57 + | 2.3 | Call `getOrCreateDeviceKey()` again | Returns the identical `multibase` and `keyId` from step 2.1 (SE key tag and Keychain metadata survived cold restart) | 58 + | 2.4 | Attempt to call `SecKey.external_representation()` on the SE private key (requires a debug breakpoint or test harness in the SE code path) | Returns `None` / nil -- the Secure Enclave hardware refuses to export the private key. This is a hardware-level guarantee confirming the non-extractable property (AC2.2) | 59 + 60 + ### Phase 3: Signing (Simulator) 61 + 62 + | Step | Action | Expected | 63 + |------|--------|----------| 64 + | 3.1 | In the running Simulator app, call `signWithDeviceKey(new Uint8Array([1, 2, 3]))` from a Svelte component or browser console via `ipc.ts` | Promise resolves with a `Uint8Array` of exactly 64 bytes (raw r\|\|s ECDSA signature) | 65 + | 3.2 | Call `signWithDeviceKey(new Uint8Array([1, 2, 3]))` again with the same input | Returns the exact same 64-byte array (RFC 6979 deterministic on simulator path) | 66 + | 3.3 | On a fresh Simulator install (delete the app, reinstall), call `signWithDeviceKey(new Uint8Array([1, 2, 3]))` before calling `getOrCreateDeviceKey()` | Promise rejects with `{ code: "KEY_NOT_FOUND" }` | 67 + 68 + ### Phase 4: TypeScript IPC Contract (Simulator) 69 + 70 + | Step | Action | Expected | 71 + |------|--------|----------| 72 + | 4.1 | In a Svelte component, import `getOrCreateDeviceKey` from `$lib/ipc.ts` and call it | TypeScript compiler accepts the call with no type errors; result type is `DevicePublicKey` with fields `multibase: string` and `keyId: string` | 73 + | 4.2 | In a Svelte component, import `signWithDeviceKey` from `$lib/ipc.ts` and call with `new Uint8Array([1,2,3])` | TypeScript compiler accepts the call; result type is `Promise<Uint8Array>`. At runtime, the resolved value is a real `Uint8Array` (not a number array) | 74 + | 4.3 | Verify the `ipc.ts` `signWithDeviceKey` function converts `Uint8Array` to `number[]` before calling `invoke()` and converts the `number[]` response back to `Uint8Array` | Inspect lines 103-106 of `apps/identity-wallet/src/lib/ipc.ts`: `Array.from(data)` on input, `new Uint8Array(bytes)` on output (workaround for tauri#10336) | 75 + | 4.4 | Trigger `KEY_NOT_FOUND` per step 3.3, catch the rejected promise, confirm the error object has shape `{ code: "KEY_NOT_FOUND" }` matching the `DeviceKeyError` type definition in `ipc.ts` | Error code is one of the 5 values in the `DeviceKeyError.code` union type | 76 + 77 + ### End-to-End: Account Creation with Device Key 78 + 79 + **Purpose:** Validates that `create_account` correctly wires `device_key::get_or_create()` as the public key source for the relay request, and that the full onboarding flow works through the frontend. 80 + 81 + | Step | Action | Expected | 82 + |------|--------|----------| 83 + | E2E.1 | Start the relay locally (`cargo run -p relay` or via Nix) with a valid admin token configured | Relay running at `http://localhost:8080` | 84 + | E2E.2 | Create a claim code via the relay admin API (POST to `/v1/admin/claim-codes` with the admin token) | Receive a claim code string | 85 + | E2E.3 | Launch the identity-wallet app in the Simulator via `cargo tauri ios dev` | App opens to the Welcome screen | 86 + | E2E.4 | Navigate through onboarding: Welcome > tap Start > enter the claim code from E2E.2 > enter an email > enter a handle > tap Create | App transitions from Loading screen. The Rust backend calls `device_key::get_or_create()` to obtain the multibase key, then POSTs to `/v1/accounts/mobile` with `devicePublicKey` set to that multibase value | 87 + | E2E.5 | Verify the relay received the account creation request | Check relay logs or query the relay's account list -- the account exists with the device public key matching the multibase from `getOrCreateDeviceKey()` | 88 + | E2E.6 | Verify Keychain tokens were stored | In the Simulator app, the `create_account` success path stored `device-token` and `session-token` in Keychain. A subsequent authenticated relay call using these tokens should succeed | 89 + 90 + ### End-to-End: Error Mapping through Frontend 91 + 92 + **Purpose:** Validates that relay error codes propagate correctly through the Rust backend to the TypeScript frontend as typed error objects. 93 + 94 + | Step | Action | Expected | 95 + |------|--------|----------| 96 + | ERR.1 | Submit the onboarding form with an already-redeemed claim code | Frontend catches `{ code: "EXPIRED_CODE" }` and navigates back to the claim code screen with an appropriate error message | 97 + | ERR.2 | Submit the onboarding form with an email already registered on the relay | Frontend catches `{ code: "EMAIL_TAKEN" }` and navigates back to the email screen | 98 + | ERR.3 | Submit the onboarding form with a handle already taken on the relay | Frontend catches `{ code: "HANDLE_TAKEN" }` and navigates back to the handle screen | 99 + | ERR.4 | Submit the onboarding form while the relay is stopped | Frontend catches `{ code: "NETWORK_ERROR", message: "..." }` | 100 + 101 + ### Human Verification Required 102 + 103 + | Criterion | Why Manual | Steps | 104 + |-----------|------------|-------| 105 + | MM-145.AC1.4 (Key persists across app restarts) | Requires real app lifecycle (cold restart) that `cargo test` cannot simulate; the automated `get_or_create_is_idempotent` test covers in-process idempotency but not cross-process persistence | Phase 1 steps 1.3-1.4 (Simulator) and Phase 2 steps 2.1-2.3 (physical device) | 106 + | MM-145.AC2.1 (SE key survives cold restart on real device) | Requires physical iOS device with Secure Enclave hardware; the macOS/Simulator software path used by `cargo test` does not exercise the SE code path | Phase 2 steps 2.1-2.3 | 107 + | MM-145.AC2.2 (SE private key non-extractable) | Hardware guarantee of Apple Secure Enclave; cannot be verified in software-only tests; requires physical device | Phase 2 step 2.4 | 108 + | MM-145.AC4.2 (Frontend IPC type correctness) | Validates real Tauri IPC serialization/deserialization round-trip between Rust and TypeScript at runtime; `cargo test` only tests Rust serde, not the full IPC bridge | Phase 4 steps 4.1-4.4 and Phase 3 step 3.3 | 109 + 110 + ### Traceability 111 + 112 + | Acceptance Criterion | Automated Test | Manual Step | 113 + |----------------------|----------------|-------------| 114 + | MM-145.AC1.1 (valid multibase output) | `get_or_create_returns_valid_multibase` in `device_key.rs:283` | Phase 1 step 1.2 | 115 + | MM-145.AC1.2 (idempotent across calls) | `get_or_create_is_idempotent` in `device_key.rs:295` | Phase 1 step 1.3 | 116 + | MM-145.AC1.3 (did:key prefix) | `key_id_has_did_key_prefix` in `device_key.rs:307` | Phase 1 step 1.2 | 117 + | MM-145.AC1.4 (persists across restarts) | -- | Phase 1 step 1.4, Phase 2 steps 2.1-2.3 | 118 + | MM-145.AC2.1 (SE persistence on real device) | -- | Phase 2 steps 2.1-2.3 | 119 + | MM-145.AC2.2 (SE non-extractable guarantee) | -- | Phase 2 step 2.4 | 120 + | MM-145.AC3.1 (64-byte signature) | `sign_returns_64_bytes` in `device_key.rs:318` | Phase 3 step 3.1 | 121 + | MM-145.AC3.2 (deterministic signing) | `sign_is_deterministic` in `device_key.rs:326` | Phase 3 step 3.2 | 122 + | MM-145.AC3.3 (KeyNotFound before generate) | `sign_before_generate_returns_key_not_found` in `device_key.rs:338` | Phase 3 step 3.3 | 123 + | MM-145.AC4.1 (error/struct serialization) | `device_key_error_serializes_as_code` + `device_public_key_serializes_camel_case` in `device_key.rs:351,371` | Phase 4 steps 4.1-4.4 | 124 + | MM-145.AC4.2 (frontend IPC types correct) | -- | Phase 4 steps 4.1-4.4 | 125 + | MM-145.AC5.1 (create_account uses device key) | `create_account_uses_device_key_public_key` in `lib.rs:339` | E2E steps E2E.4-E2E.5 |