An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(identity-wallet): implement create_account IPC command

- Add crypto workspace dependency to src-tauri Cargo.toml
- Implement create_account async Tauri command with:
- P-256 keypair generation
- Private key storage in iOS Keychain before network call
- POST to relay with account creation request
- Token storage in Keychain on success
- Typed error handling for relay responses
- Add types for request/response envelopes and error variants
- Register create_account command in Tauri handler
- Remove dead_code suppressions from keychain and http modules (now in use)

Verifies MM-144.AC2.1-5 and MM-144.AC3.1-5 and MM-144.AC4.1-3

authored by malpercio.dev and committed by

Tangled 8f7045d0 21d1ee7e

+156 -7
+1
Cargo.lock
··· 2349 2349 name = "identity-wallet" 2350 2350 version = "0.1.0" 2351 2351 dependencies = [ 2352 + "crypto", 2352 2353 "reqwest 0.12.28", 2353 2354 "security-framework", 2354 2355 "serde",
+1
apps/identity-wallet/src-tauri/Cargo.toml
··· 21 21 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 22 22 security-framework = "3" 23 23 thiserror = { workspace = true } 24 + crypto = { workspace = true } 24 25 25 26 [build-dependencies] 26 27 # Tauri-specific — declared locally
-3
apps/identity-wallet/src-tauri/src/http.rs
··· 4 4 //! compile-time configured: `http://localhost:8080` in debug builds, 5 5 //! `https://relay.ezpds.com` in release builds. 6 6 7 - // Suppressed until Phase 2 wires up the IPC command that calls this client. 8 - #![allow(dead_code)] 9 - 10 7 use reqwest::{Client, Response}; 11 8 use serde::Serialize; 12 9
-3
apps/identity-wallet/src-tauri/src/keychain.rs
··· 4 4 //! service `"ezpds-identity-wallet"`. Use the `SERVICE` constant 5 5 //! to ensure consistency. 6 6 7 - // Suppressed until Phase 2 wires up the IPC command that calls these functions. 8 - #![allow(dead_code)] 9 - 10 7 use security_framework::passwords::{get_generic_password, set_generic_password}; 11 8 12 9 pub const SERVICE: &str = "ezpds-identity-wallet";
+154 -1
apps/identity-wallet/src-tauri/src/lib.rs
··· 1 1 pub mod http; 2 2 pub mod keychain; 3 3 4 + use crypto::generate_p256_keypair; 5 + use serde::{Deserialize, Serialize}; 6 + 7 + // ── Request / response types ──────────────────────────────────────────────── 8 + 9 + /// JSON body sent to POST /v1/accounts/mobile. 10 + /// Field names match the relay's camelCase deserialization. 11 + #[derive(Serialize)] 12 + #[serde(rename_all = "camelCase")] 13 + struct CreateMobileAccountRequest { 14 + email: String, 15 + handle: String, 16 + device_public_key: String, 17 + platform: String, 18 + claim_code: String, 19 + } 20 + 21 + /// Successful 201 response from the relay. 22 + #[derive(Deserialize)] 23 + #[serde(rename_all = "camelCase")] 24 + struct CreateMobileAccountResponse { 25 + device_token: String, 26 + session_token: String, 27 + next_step: String, 28 + } 29 + 30 + /// Relay error envelope: { "error": { "code": "...", "message": "..." } } 31 + #[derive(Deserialize)] 32 + struct RelayErrorEnvelope { 33 + error: RelayErrorBody, 34 + } 35 + 36 + #[derive(Deserialize)] 37 + struct RelayErrorBody { 38 + code: String, 39 + } 40 + 41 + // ── IPC result / error types (returned to the frontend) ───────────────────── 42 + 43 + /// Successful result returned to the Svelte frontend. 44 + #[derive(Serialize)] 45 + #[serde(rename_all = "camelCase")] 46 + pub struct CreateAccountResult { 47 + pub next_step: String, 48 + } 49 + 50 + /// Typed error returned to the Svelte frontend as a rejected Promise. 51 + /// 52 + /// Serializes as `{ "code": "EXPIRED_CODE" }` (SCREAMING_SNAKE_CASE) so 53 + /// the TypeScript catch block can switch on `error.code`. 54 + #[derive(Debug, Serialize, thiserror::Error)] 55 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 56 + pub enum CreateAccountError { 57 + #[error("claim code has expired")] 58 + ExpiredCode, 59 + #[error("claim code already redeemed")] 60 + RedeemedCode, 61 + #[error("email already taken")] 62 + EmailTaken, 63 + #[error("handle already taken")] 64 + HandleTaken, 65 + #[error("network error: {message}")] 66 + NetworkError { message: String }, 67 + #[error("unknown error: {message}")] 68 + Unknown { message: String }, 69 + } 70 + 71 + // ── IPC command ───────────────────────────────────────────────────────────── 72 + 73 + #[tauri::command] 74 + async fn create_account( 75 + claim_code: String, 76 + email: String, 77 + handle: String, 78 + ) -> Result<CreateAccountResult, CreateAccountError> { 79 + // 1. Generate P-256 device keypair. 80 + let keypair = generate_p256_keypair().map_err(|e| CreateAccountError::Unknown { 81 + message: e.to_string(), 82 + })?; 83 + 84 + // 2. Store private key bytes in Keychain before any network call. 85 + // private_key_bytes is Zeroizing<[u8; 32]>; deref to &[u8] via AsRef. 86 + keychain::store_item("device-private-key", keypair.private_key_bytes.as_ref()).map_err( 87 + |e| CreateAccountError::Unknown { 88 + message: e.to_string(), 89 + }, 90 + )?; 91 + 92 + // 3. POST to relay. 93 + let req = CreateMobileAccountRequest { 94 + email, 95 + handle, 96 + device_public_key: keypair.public_key, 97 + platform: "ios".to_string(), 98 + claim_code, 99 + }; 100 + 101 + let resp = http::RelayClient::new() 102 + .post("/v1/accounts/mobile", &req) 103 + .await 104 + .map_err(|e| CreateAccountError::NetworkError { 105 + message: e.to_string(), 106 + })?; 107 + 108 + let status = resp.status(); 109 + 110 + if status.is_success() { 111 + // 4. Deserialize success body. 112 + let body: CreateMobileAccountResponse = 113 + resp.json().await.map_err(|e| CreateAccountError::Unknown { 114 + message: e.to_string(), 115 + })?; 116 + 117 + // 5. Store tokens in Keychain. 118 + keychain::store_item("device-token", body.device_token.as_bytes()).map_err(|e| { 119 + CreateAccountError::Unknown { 120 + message: e.to_string(), 121 + } 122 + })?; 123 + keychain::store_item("session-token", body.session_token.as_bytes()).map_err(|e| { 124 + CreateAccountError::Unknown { 125 + message: e.to_string(), 126 + } 127 + })?; 128 + 129 + Ok(CreateAccountResult { 130 + next_step: body.next_step, 131 + }) 132 + } else { 133 + // 6. Map relay error codes to typed variants. 134 + match status.as_u16() { 135 + 404 => Err(CreateAccountError::ExpiredCode), 136 + 409 => { 137 + let envelope: RelayErrorEnvelope = 138 + resp.json().await.map_err(|e| CreateAccountError::Unknown { 139 + message: e.to_string(), 140 + })?; 141 + match envelope.error.code.as_str() { 142 + "CLAIM_CODE_REDEEMED" => Err(CreateAccountError::RedeemedCode), 143 + "ACCOUNT_EXISTS" => Err(CreateAccountError::EmailTaken), 144 + "HANDLE_TAKEN" => Err(CreateAccountError::HandleTaken), 145 + other => Err(CreateAccountError::Unknown { 146 + message: format!("409: {other}"), 147 + }), 148 + } 149 + } 150 + _ => Err(CreateAccountError::NetworkError { 151 + message: format!("HTTP {}", status.as_u16()), 152 + }), 153 + } 154 + } 155 + } 156 + 4 157 #[tauri::command] 5 158 fn greet(name: String) -> String { 6 159 format!("Hello, {}!", name) ··· 9 162 #[cfg_attr(mobile, tauri::mobile_entry_point)] 10 163 pub fn run() { 11 164 tauri::Builder::default() 12 - .invoke_handler(tauri::generate_handler![greet]) 165 + .invoke_handler(tauri::generate_handler![greet, create_account]) 13 166 .run(tauri::generate_context!()) 14 167 .expect("error while running tauri application"); 15 168 }