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 MM-145 design plan — P-256 keypair via Secure Enclave

Completed brainstorming session. Design includes:
- New device_key.rs module with compile-time SE/simulator split
- Raw FFI via security-framework-sys (SecKeyCreateRandomKey + kSecAttrTokenIDSecureEnclave)
- DER → raw r||s conversion for ATProto-compatible signatures
- 4 implementation phases

authored by

Malpercio and committed by
Tangled
96c94981 dac88719

+223
+223
docs/design-plans/2026-03-18-MM-145.md
··· 1 + # Mobile Platform Key Generation — P-256 Keypair via Secure Enclave 2 + 3 + ## Summary 4 + 5 + This ticket introduces hardware-backed device key generation for the identity wallet iOS app. Currently, `create_account` generates a P-256 keypair in software and stores the raw private key bytes in the iOS Keychain. This design replaces that approach on real devices: private key material is generated inside and permanently bound to the device's Secure Enclave, a hardware security module that makes the private key non-extractable by any software, including the app itself. The app retains only a reference to the key by a stable Keychain tag; signing operations are delegated to the hardware. 6 + 7 + The implementation is structured as a new `device_key.rs` module with a compile-time conditional split. On real iOS hardware the module uses raw Apple Security framework FFI (`SecKeyCreateRandomKey`, `SecKeyCreateSignature`) to generate and use an SE-backed P-256 keypair. On the iOS Simulator—which has no Secure Enclave hardware—it falls back to the existing software P-256 implementation from the `crypto` crate, storing raw key bytes in the Keychain. Both paths expose identical public types (`DevicePublicKey`, `DeviceKeyError`) and function signatures (`get_or_create`, `sign`), so `create_account` and the two new Tauri IPC commands (`get_or_create_device_key`, `sign_with_device_key`) are unaware of which path is active. The work is delivered in four phases: simulator path with full unit tests, SE path, wiring into `create_account`, and finally exposing the commands through Tauri's IPC bridge with typed TypeScript wrappers in `ipc.ts`. 8 + 9 + ## Definition of Done 10 + 11 + 1. **`get_or_create_device_key()` Tauri command** — generates (or retrieves existing) SE-backed P-256 keypair via raw FFI (`SecKeyCreateRandomKey` + `kSecAttrTokenIDSecureEnclave`); returns the public key as a multibase base58btc compressed-point string; on iOS Simulator falls back to a software P-256 key via the existing `crypto` crate. 12 + 2. **`sign_with_device_key(data: Vec<u8>)` Tauri command** — signs arbitrary bytes using the SE-backed (or simulator-fallback) key via ECDSA P-256; returns the signature bytes. 13 + 3. **`create_account` updated** to call `get_or_create_device_key()` for the device public key instead of `crypto::generate_p256_keypair()`. 14 + 4. **Key persists across app restarts** — referenced by the stable alias `"device-rotation-key"` as a `SecKey` reference in the Keychain; private key bytes never leave Secure Enclave hardware on real devices. 15 + 5. **Simulator fallback** — software-generated P-256 key stored as raw bytes in Keychain, gated on `SecureEnclave.isAvailable` (or equivalent `security-framework-sys` check). 16 + 17 + Out of scope: DID alias update after DID creation (MM-90), Android support, key rotation. 18 + 19 + ## Acceptance Criteria 20 + 21 + ### MM-145.AC1: get_or_create_device_key returns a valid DevicePublicKey 22 + - **MM-145.AC1.1 Success:** public key multibase string starts with `'z'` and decodes (via base58btc) to exactly 33 bytes 23 + - **MM-145.AC1.2 Success:** two successive calls return identical `multibase` and `key_id` values (idempotent) 24 + - **MM-145.AC1.3 Success:** `key_id` is prefixed with `"did:key:z"` 25 + - **MM-145.AC1.4 Success:** key persists — a fresh call after app restart returns the same public key 26 + 27 + ### MM-145.AC2: Private key material is protected (real device only) 28 + - **MM-145.AC2.1 Success:** key retrieved after cold restart matches key from initial generation (persistence via SE tag) 29 + - **MM-145.AC2.2 Success:** private key bytes cannot be extracted from the Keychain (`SecKeyCreateRandomKey` with `kSecAttrTokenIDSecureEnclave` is non-extractable by design) 30 + 31 + ### MM-145.AC3: sign_with_device_key returns a valid ECDSA P-256 signature 32 + - **MM-145.AC3.1 Success:** signing arbitrary data returns exactly 64 bytes 33 + - **MM-145.AC3.2 Success:** signing the same data twice returns identical bytes (RFC 6979 deterministic, simulator path) 34 + - **MM-145.AC3.3 Failure:** calling `sign` before `get_or_create` returns `DeviceKeyError::KeyNotFound` 35 + 36 + ### MM-145.AC4: DeviceKeyError and Tauri commands follow project conventions 37 + - **MM-145.AC4.1 Success:** all `DeviceKeyError` variants serialize as `{ "code": "SCREAMING_SNAKE_CASE" }` 38 + - **MM-145.AC4.2 Success:** frontend `ipc.ts` can call `getOrCreateDeviceKey()` and `signWithDeviceKey()` and receive correct TypeScript types 39 + 40 + ### MM-145.AC5: create_account uses the device key 41 + - **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) 42 + 43 + ## Glossary 44 + 45 + - **Secure Enclave (SE)**: A dedicated hardware security processor built into Apple silicon and A-series chips. It runs its own firmware, isolated from the application processor and operating system. Cryptographic keys marked as SE-backed are generated inside the enclave and cannot be read or exported by any software — only sign/decrypt operations can be requested through the OS API. 46 + - **P-256**: An elliptic curve (also called `secp256r1` or `prime256v1`) standardized by NIST. Used here for asymmetric key pairs: a 32-byte private scalar and a corresponding 33-byte compressed public point. The Secure Enclave natively supports P-256 key generation and ECDSA signing. 47 + - **ECDSA**: Elliptic Curve Digital Signature Algorithm. Produces a signature from a private key and a message digest. The SE path uses the SHA-256 variant (`kSecKeyAlgorithmECDSASignatureMessageX962SHA256`). The simulator path uses RFC 6979 deterministic ECDSA (via the `p256` crate), which always produces the same signature for the same key and data. 48 + - **multibase**: A self-describing binary-to-text encoding prefix scheme. A `'z'` prefix signals base58btc encoding. Used here to encode the 33-byte compressed P-256 public point into a string (`'z'` + bs58(point)) that is also the basis of a `did:key` identifier. 49 + - **did:key**: A DID (Decentralized Identifier) method that encodes a public key directly in the identifier string. No registry or network lookup is needed — the key is the identity. Format: `did:key:z<multibase-encoded-public-key>`. 50 + - **compressed point**: A compact 33-byte encoding of an elliptic curve point. Apple's `SecKeyCopyExternalRepresentation` returns the uncompressed 65-byte form (`0x04 || x || y`); compression: keep `x`, replace `y` with a single parity prefix byte (`0x02` even, `0x03` odd). 51 + - **DER (Distinguished Encoding Rules)**: A binary encoding format for ASN.1 structures. Apple's SE returns ECDSA signatures in DER format (70–72 bytes). The `p256` crate's `Signature::from_der` converts this to the fixed-width 64-byte raw `r || s` form expected by the protocol. 52 + - **raw r||s**: The 64-byte ECDSA signature format used by ATProto/did:plc: two 32-byte big-endian integers (r and s) concatenated. Different from DER, which wraps r and s in an ASN.1 SEQUENCE with variable-length INTEGER encodings. 53 + - **low-S canonicalization**: Normalizing an ECDSA signature so that `s ≤ order/2`. Prevents signature malleability (for any valid signature, a second valid signature exists with `s' = order - s`). The `p256` crate enforces this automatically. 54 + - **`kSecAttrTokenIDSecureEnclave`**: An Apple Keychain attribute constant that instructs the Security framework to generate a key inside the Secure Enclave rather than in software. Setting this attribute makes the resulting private key non-extractable. 55 + - **`kSecAttrApplicationTag`**: An Apple Keychain attribute used as a stable lookup key for `SecKey` references. This document uses the tag `b"ezpds-device-rotation-key"` to identify the device rotation key across app launches. 56 + - **`SecAccessControl` / `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`**: Apple API for setting access policy on a Keychain item. `WhenUnlockedThisDeviceOnly` requires only that the device screen be unlocked — no biometric prompt — and prevents the key from migrating to other devices via iCloud backup. 57 + - **Tauri IPC / `invoke()`**: Tauri's inter-process communication bridge between the JavaScript/TypeScript frontend and the Rust backend. The frontend calls `invoke('command_name', args)`, which dispatches to a Rust function annotated `#[tauri::command]`. Results and errors are serialized as JSON and deserialized into TypeScript types. 58 + - **`security-framework-sys`**: A Rust crate providing raw FFI bindings to Apple's Security framework C API (e.g. `SecKeyCreateRandomKey`, `SecKeyCopyExternalRepresentation`, `SecKeyCreateSignature`). The `-sys` suffix is a Rust convention for a crate that contains only unsafe FFI declarations with no safe wrappers. 59 + - **`#[cfg(...)]` / compile-time conditional**: A Rust attribute that includes or excludes code at compile time based on target platform predicates. Used here to select between the SE path and the simulator software fallback without any runtime branching. 60 + - **RFC 6979**: A standard for deterministic ECDSA nonce generation. Instead of using a random `k` value per signature, the nonce is derived deterministically from the private key and message, making signatures reproducible. The `p256` crate uses RFC 6979 by default. 61 + - **`bs58`**: A Rust crate for base58 encoding using the Bitcoin alphabet (base58btc) used by multibase and `did:key`. Avoids visually ambiguous characters (0, O, I, l). 62 + - **device rotation key**: The P-256 keypair managed by this module — the device's cryptographic identity used during account creation and DID genesis operations. "Rotation" refers to the ATProto concept of an account having a key that can be replaced by a recovery key. 63 + - **DID genesis op**: The initial signed operation that creates a `did:plc` identifier. The device rotation key's public key is included in this operation. Handled in MM-90; this ticket produces the key it depends on. 64 + 65 + ## Architecture 66 + 67 + Device key logic lives in a new `apps/identity-wallet/src-tauri/src/device_key.rs` module behind a single interface. A compile-time `#[cfg]` split selects the implementation path: 68 + 69 + - **Real device** (`#[cfg(not(all(target_vendor = "apple", target_env = "sim")))]`): Secure Enclave via raw FFI (`security-framework-sys` — `SecKeyCreateRandomKey`, `SecKeyCopyExternalRepresentation`, `SecKeyCreateSignature`) 70 + - **Simulator** (`#[cfg(all(target_vendor = "apple", target_env = "sim"))]`): software P-256 via `crypto::generate_p256_keypair()` + `keychain::store_item` 71 + 72 + Both paths expose identical types and functions. `lib.rs` calls `device_key::get_or_create()` and `device_key::sign()` without knowing which path runs. 73 + 74 + **Contracts:** 75 + 76 + ```rust 77 + // device_key.rs public interface 78 + 79 + pub struct DevicePublicKey { 80 + pub multibase: String, // 'z' + base58btc(compressed 33-byte P-256 point) 81 + pub key_id: String, // 'did:key:z...' 82 + } 83 + 84 + pub enum DeviceKeyError { 85 + KeyGenerationFailed, 86 + KeyNotFound, // sign() called before get_or_create() 87 + SigningFailed, 88 + InvalidSignature, // DER → r||s parse failed 89 + KeychainError(String), // underlying OS error 90 + } 91 + 92 + pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError>; 93 + pub fn sign(data: &[u8]) -> Result<Vec<u8>, DeviceKeyError>; // 64-byte raw r||s 94 + ``` 95 + 96 + **Key alias:** `kSecAttrApplicationTag = b"ezpds-device-rotation-key"` on SE path; Keychain account `"device-rotation-key-priv"` under service `"ezpds-identity-wallet"` on simulator. 97 + 98 + **Tauri command contracts (in `lib.rs`):** 99 + 100 + ```rust 101 + #[tauri::command] 102 + async fn get_or_create_device_key() -> Result<DevicePublicKey, DeviceKeyError> 103 + 104 + #[tauri::command] 105 + async fn sign_with_device_key(data: Vec<u8>) -> Result<Vec<u8>, DeviceKeyError> 106 + ``` 107 + 108 + `DeviceKeyError` serializes as `{ "code": "SCREAMING_SNAKE_CASE" }`, following the existing `CreateAccountError` pattern. 109 + 110 + **Key lifecycle — real device:** 111 + 112 + 1. Query Keychain by `kSecAttrApplicationTag` + `kSecAttrTokenIDSecureEnclave` 113 + 2. If found: export public key via `SecKeyCopyExternalRepresentation` → compress 65-byte uncompressed X9.63 point to 33 bytes → base58btc-encode → prepend `'z'` 114 + 3. If not found: `SecKeyCreateRandomKey` with `kSecAttrKeyTypeECSECPrimeRandom`, 256-bit, `kSecAttrTokenIDSecureEnclave`, `kSecAttrIsPermanent: true`, device-unlock-only access control (no biometric, works headlessly) 115 + 116 + **Key lifecycle — simulator:** 117 + 118 + 1. `keychain::get_item("ezpds-identity-wallet", "device-rotation-key-priv")` 119 + 2. If found: reconstruct public key from stored private bytes 120 + 3. If not found: `crypto::generate_p256_keypair()` → store private bytes 121 + 122 + **Public key compression (SE path only):** 123 + 124 + `SecKeyCopyExternalRepresentation` returns 65 bytes (`0x04 || x[32] || y[32]`). Compressed: prefix = `0x02` if `y[31]` is even, `0x03` if odd; output = `[prefix] || x[32]` (33 bytes). Encoded as `'z' + bs58::encode(compressed)`. 125 + 126 + **Signing:** 127 + 128 + - SE path: `SecKeyCreateSignature(..., kSecKeyAlgorithmECDSASignatureMessageX962SHA256, data)` → DER (70–72 bytes) → `p256::ecdsa::Signature::from_der(&der).to_bytes()` → 64-byte raw r||s 129 + - Simulator path: `p256::ecdsa::SigningKey::from_bytes(&stored_bytes).sign(data).to_bytes()` → 64-byte raw r||s directly 130 + 131 + Callers base64url-encode the returned bytes for protocol use (e.g., DID genesis op). 132 + 133 + **`Cargo.toml` additions (`apps/identity-wallet/src-tauri/Cargo.toml`):** 134 + 135 + - `security-framework-sys`: promote from transitive to explicit direct dep 136 + - `bs58`: add explicitly (transitive via `crypto` workspace crate) 137 + - `p256` with `ecdsa` feature: add explicitly (for DER → r||s conversion) 138 + 139 + ## Existing Patterns 140 + 141 + All patterns follow existing conventions in `apps/identity-wallet/src-tauri/`: 142 + 143 + - **Error types with `thiserror`** — matches `CreateAccountError` in `lib.rs` 144 + - **Error serialization as `{ "code": "SCREAMING_SNAKE_CASE" }`** — CLAUDE.md invariant; `DeviceKeyError` follows the same `serde` derive pattern 145 + - **Keychain service name `"ezpds-identity-wallet"`** — CLAUDE.md invariant; simulator path uses this service for `store_item`/`get_item` 146 + - **`{ workspace = true }` for shared deps** — `p256`, `bs58`, `thiserror` declared at workspace level; `src-tauri/Cargo.toml` uses workspace inheritance 147 + - **Module-per-concern file layout** — `keychain.rs`, `http.rs` are single-concern modules; `device_key.rs` follows the same pattern 148 + - **All Tauri commands registered in `tauri::generate_handler![]`** — new commands added to the existing handler list in `lib.rs` 149 + 150 + `keychain.rs` is unchanged. SE key storage uses `SecKey` references (not raw bytes) — a different storage model from `keychain.rs`'s byte API — which is why the logic lives in its own module rather than extending `keychain.rs`. 151 + 152 + ## Implementation Phases 153 + 154 + <!-- START_PHASE_1 --> 155 + ### Phase 1: device_key.rs — simulator path and tests 156 + 157 + **Goal:** Introduce `device_key.rs` with the software fallback implementation and full test coverage. The SE path is not yet present; all tests run on host via `cargo test`. 158 + 159 + **Components:** 160 + - `apps/identity-wallet/src-tauri/src/device_key.rs` (new) — `DevicePublicKey`, `DeviceKeyError`, `get_or_create()` and `sign()` under `#[cfg(all(target_vendor = "apple", target_env = "sim"))]`; `#[cfg(not(...))]` stubs that return `DeviceKeyError::KeyGenerationFailed` (placeholder until Phase 2) 161 + - `apps/identity-wallet/src-tauri/Cargo.toml` — add `bs58`, `p256` (with `ecdsa` feature) explicitly 162 + 163 + **Dependencies:** None (first phase) 164 + 165 + **Done when:** `cargo test` passes including: 166 + - `MM-145.AC1.1` — `get_or_create_returns_valid_multibase` 167 + - `MM-145.AC1.2` — `get_or_create_is_idempotent` 168 + - `MM-145.AC1.3` — `key_id_has_did_key_prefix` 169 + - `MM-145.AC3.1` — `sign_returns_64_bytes` 170 + - `MM-145.AC3.2` — `sign_is_deterministic` 171 + - `MM-145.AC3.3` — `sign_before_generate_returns_key_not_found` 172 + - `MM-145.AC4.1` — `device_key_error_serializes_as_code` 173 + <!-- END_PHASE_1 --> 174 + 175 + <!-- START_PHASE_2 --> 176 + ### Phase 2: device_key.rs — Secure Enclave path 177 + 178 + **Goal:** Add the real-device SE implementation behind `#[cfg(not(all(target_vendor = "apple", target_env = "sim")))]`. All existing tests continue to pass. SE behavior is verified manually on physical device. 179 + 180 + **Components:** 181 + - `apps/identity-wallet/src-tauri/src/device_key.rs` — SE `get_or_create()`: `SecItemCopyMatching` lookup by tag; `SecKeyCreateRandomKey` with `kSecAttrTokenIDSecureEnclave` + device-unlock-only `SecAccessControl`; public key compression and multibase encoding via `SecKeyCopyExternalRepresentation` 182 + - `apps/identity-wallet/src-tauri/src/device_key.rs` — SE `sign()`: `SecKeyCreateSignature` with `kSecKeyAlgorithmECDSASignatureMessageX962SHA256`; DER → raw r||s conversion via `p256::ecdsa::Signature::from_der` 183 + - `apps/identity-wallet/src-tauri/Cargo.toml` — add `security-framework-sys` as explicit direct dep 184 + 185 + **Dependencies:** Phase 1 186 + 187 + **Done when:** `cargo build --target aarch64-apple-ios` succeeds (SE path compiles); `cargo test` still passes (simulator path unchanged); manual verification on physical device confirms `MM-145.AC2.1` (key persists across cold restart) and `MM-145.AC2.2` (private key bytes not extractable) 188 + <!-- END_PHASE_2 --> 189 + 190 + <!-- START_PHASE_3 --> 191 + ### Phase 3: Update create_account to use device_key 192 + 193 + **Goal:** Replace the `crypto::generate_p256_keypair()` call inside `create_account` with `device_key::get_or_create()`. The relay receives the SE-backed (or fallback) public key. 194 + 195 + **Components:** 196 + - `apps/identity-wallet/src-tauri/src/lib.rs` — `mod device_key` declaration; `create_account` updated to call `device_key::get_or_create()` and use `DevicePublicKey.multibase` as the device public key in `CreateMobileAccountRequest`; remove now-unused `crypto::generate_p256_keypair()` call and associated private-key-bytes Keychain store step from `create_account` 197 + 198 + **Dependencies:** Phase 2 199 + 200 + **Done when:** `cargo test` passes including updated `MM-145.AC5.1` — `create_account_uses_device_key_public_key` (verifies `CreateMobileAccountRequest.device_public_key` matches `device_key::get_or_create().multibase`) 201 + <!-- END_PHASE_3 --> 202 + 203 + <!-- START_PHASE_4 --> 204 + ### Phase 4: Add get_or_create_device_key and sign_with_device_key Tauri commands 205 + 206 + **Goal:** Expose `device_key::get_or_create()` and `device_key::sign()` as Tauri IPC commands. Update `ipc.ts` with typed wrappers. 207 + 208 + **Components:** 209 + - `apps/identity-wallet/src-tauri/src/lib.rs` — `get_or_create_device_key` and `sign_with_device_key` async Tauri commands; both registered in `tauri::generate_handler![]` 210 + - `apps/identity-wallet/src/lib/ipc.ts` — typed TypeScript wrappers `getOrCreateDeviceKey()` and `signWithDeviceKey(data: Uint8Array)` following existing `invoke()` pattern in `ipc.ts` 211 + 212 + **Dependencies:** Phase 3 213 + 214 + **Done when:** `cargo test` passes including `MM-145.AC4.1`; `cargo build` succeeds; manual verification on simulator confirms `MM-145.AC4.2` (frontend can call both commands and receive correct types) 215 + <!-- END_PHASE_4 --> 216 + 217 + ## Additional Considerations 218 + 219 + **DER → r||s conversion:** `p256::ecdsa::Signature::from_der` panics if the DER is malformed. In practice Apple's SE never returns malformed DER, but the error path maps to `DeviceKeyError::InvalidSignature` for completeness. 220 + 221 + **Access control and headless tests:** `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` with `.privateKeyUsage` requires only that the device be unlocked — no biometric prompt per operation. This makes the key usable in automated test environments on real hardware. Keys with `.biometryCurrentSet` would break if the user re-enrolls Face ID and are inappropriate here. 222 + 223 + **Key alias update after DID creation:** MM-90 (DID ceremony) will associate the device rotation key with the user's DID. That alias update is out of scope for MM-145; the stable `"device-rotation-key"` alias is sufficient until then.