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

Configure Feed

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

feat(identity-wallet): Shamir share backup UI and relay share generation

After DID ceremony, the relay now generates a 2-of-3 Shamir split of a
per-user 32-byte recovery secret. Share 2 is stored atomically in the
accounts table (V010 migration). Shares 1 and 3 are returned to the app:
Share 1 is written to iCloud Keychain automatically; Share 3 is shown to
the user in a new ShamirBackupScreen with copy, QR code, and backup
guidance. The user must confirm backup before the onboarding flow completes.

authored by

Malpercio and committed by
Tangled
79e94338 4ed7ae3b

+416 -20
+1
Cargo.lock
··· 4100 4100 "clap", 4101 4101 "common", 4102 4102 "crypto", 4103 + "data-encoding", 4103 4104 "hickory-resolver", 4104 4105 "opentelemetry", 4105 4106 "opentelemetry-otlp",
+5 -4
apps/identity-wallet/CLAUDE.md
··· 1 1 # Identity Wallet Mobile App 2 2 3 - Last verified: 2026-03-20 3 + Last verified: 2026-03-21 4 4 5 5 ## Purpose 6 6 ··· 12 12 13 13 **Exposes:** 14 14 - `src/lib/ipc.ts` — typed wrappers for all Tauri IPC commands; import these instead of calling `invoke()` directly. Exports: `createAccount()`, `getOrCreateDeviceKey()`, `signWithDeviceKey()`, `performDIDCeremony()`, and their associated types (`DevicePublicKey`, `DeviceKeyError`, `CreateAccountResult`, `CreateAccountError`, `DIDCeremonyResult`, `DIDCeremonyError`) 15 - - `src/lib/components/onboarding/` — seven onboarding screen components (WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen) 16 - - `src/routes/+page.svelte` — root page: eight-step onboarding state machine (welcome -> claim_code -> email -> handle -> loading -> did_ceremony -> did_success -> shamir_backup) 15 + - `src/lib/components/onboarding/` — eight onboarding screen components (WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen, ShamirBackupScreen) 16 + - `src/routes/+page.svelte` — root page: nine-step onboarding state machine (welcome -> claim_code -> email -> handle -> loading -> did_ceremony -> did_success -> shamir_backup -> complete) 17 17 18 18 **Guarantees:** 19 19 - SSR is disabled globally (`ssr = false` in `src/routes/+layout.ts`); the frontend is a fully static SPA loaded from disk by WKWebView ··· 31 31 - `src/lib.rs::create_account(claim_code, email, handle) -> Result<CreateAccountResult, CreateAccountError>` — Tauri IPC command: gets or creates device key via `device_key::get_or_create()`, POSTs to relay `/v1/accounts/mobile`, stores tokens in Keychain on success 32 32 - `src/lib.rs::get_or_create_device_key() -> Result<DevicePublicKey, DeviceKeyError>` — Tauri IPC command: delegates to `device_key::get_or_create()` 33 33 - `src/lib.rs::sign_with_device_key(data: Vec<u8>) -> Result<Vec<u8>, DeviceKeyError>` — Tauri IPC command: delegates to `device_key::sign()` 34 - - `src/lib.rs::perform_did_ceremony(handle: String) -> Result<DIDCeremonyResult, DIDCeremonyError>` — Tauri IPC command: fetches relay signing key (GET /v1/relay/keys), builds signed did:plc genesis op via `crypto::build_did_plc_genesis_op_with_external_signer` using device key as signer, POSTs genesis op to relay (POST /v1/dids with Bearer token), persists DID and upgraded session token in Keychain 34 + - `src/lib.rs::perform_did_ceremony(handle: String) -> Result<DIDCeremonyResult, DIDCeremonyError>` — Tauri IPC command: fetches relay signing key (GET /v1/relay/keys), builds signed did:plc genesis op via `crypto::build_did_plc_genesis_op_with_external_signer` using device key as signer, POSTs genesis op to relay (POST /v1/dids with Bearer token), persists DID + upgraded session token + Share 1 in Keychain, returns `{ did, share3 }` to frontend 35 35 - `src/device_key.rs` — P-256 device key management with `#[cfg]`-based dispatch: macOS/simulator uses software keys via `crypto` crate + Keychain storage; real iOS device uses Secure Enclave via `security-framework`. Public API: `get_or_create() -> Result<DevicePublicKey, DeviceKeyError>` (idempotent), `sign(data) -> Result<Vec<u8>, DeviceKeyError>` 36 36 - `src/keychain.rs` — iOS Keychain abstraction (`store_item`, `get_item`, `delete_item`) under service `"ezpds-identity-wallet"` 37 37 - `src/http.rs` — `RelayClient` with compile-time base URL (localhost:8080 debug, relay.ezpds.com release); methods: `post()`, `get()`, `post_with_bearer()`; static `base_url()` accessor ··· 190 190 - Keychain accounts `"device-rotation-key-pub"` and `"device-rotation-key-app-label"` store SE metadata (real iOS device path only); changing them orphans the SE key lookup 191 191 - Keychain account `"session-token"` stores the pending (pre-DID) or full (post-DID) session token; `perform_did_ceremony` reads the pending token and overwrites it with the upgraded token on success 192 192 - Keychain account `"did"` stores the user's did:plc after successful DID ceremony; persisted for use in subsequent app sessions 193 + - Keychain account `"recovery-share-1"` stores Share 1 of the Shamir recovery split (base32, 52 chars); written by `perform_did_ceremony` immediately after DID promotion; never displayed to the user (iCloud Keychain automatic backup) 193 194 - `DevicePublicKey` serializes with `#[serde(rename_all = "camelCase")]` -- TypeScript receives `{ multibase, keyId }` (not `key_id`) 194 195 195 196 ## Key Files
+3 -1
apps/identity-wallet/package.json
··· 11 11 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 12 12 }, 13 13 "dependencies": { 14 - "@tauri-apps/api": "^2" 14 + "@tauri-apps/api": "^2", 15 + "qrcode": "^1.5.4" 15 16 }, 16 17 "devDependencies": { 18 + "@types/qrcode": "^1.5.5", 17 19 "@sveltejs/adapter-static": "^3.0.8", 18 20 "@sveltejs/kit": "^2.20.4", 19 21 "@sveltejs/vite-plugin-svelte": "^5.0.3",
+31 -1
apps/identity-wallet/src-tauri/src/lib.rs
··· 52 52 signed_creation_op: serde_json::Value, 53 53 } 54 54 55 - /// Response from POST /v1/dids — the promoted DID and upgraded session token. 55 + /// Response from POST /v1/dids — the promoted DID, upgraded session token, and Shamir shares. 56 56 #[derive(Deserialize)] 57 57 struct CreateDidResponse { 58 58 did: String, 59 59 session_token: String, 60 + /// Share 1 of 3 — to be stored in iCloud Keychain by the app. 61 + shamir_share_1: String, 62 + /// Share 3 of 3 — to be shown to the user for manual backup. 63 + shamir_share_3: String, 60 64 } 61 65 62 66 /// Relay error envelope: { "error": { "code": "...", "message": "..." } } ··· 118 122 #[serde(rename_all = "camelCase")] 119 123 pub struct DIDCeremonyResult { 120 124 pub did: String, 125 + /// Share 3 of 3 — the user's manual backup share. 126 + /// Share 1 has already been stored in iCloud Keychain by the Rust backend. 127 + pub share3: String, 121 128 } 122 129 123 130 /// Typed error returned to the Svelte frontend as a rejected Promise. ··· 349 356 DIDCeremonyError::KeychainError 350 357 })?; 351 358 359 + // Step 8: Store Share 1 in iCloud Keychain for automatic backup. 360 + keychain::store_item( 361 + "recovery-share-1", 362 + create_did_resp.shamir_share_1.as_bytes(), 363 + ) 364 + .map_err(|e| { 365 + tracing::error!(error = %e, "failed to store recovery share 1 in keychain"); 366 + DIDCeremonyError::KeychainError 367 + })?; 368 + 352 369 Ok(DIDCeremonyResult { 353 370 did: create_did_resp.did, 371 + share3: create_did_resp.shamir_share_3, 354 372 }) 355 373 } 356 374 ··· 522 540 fn did_ceremony_result_serializes_did_in_camel_case() { 523 541 let result = DIDCeremonyResult { 524 542 did: "did:plc:abcdefghijklmnopqrstuvwx".into(), 543 + share3: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRST".into(), 525 544 }; 526 545 let json = serde_json::to_value(&result).unwrap(); 527 546 assert_eq!(json["did"], "did:plc:abcdefghijklmnopqrstuvwx"); 547 + } 548 + 549 + #[test] 550 + fn did_ceremony_result_serializes_share3_in_camel_case() { 551 + let share = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRST"; 552 + let result = DIDCeremonyResult { 553 + did: "did:plc:abcdefghijklmnopqrstuvwx".into(), 554 + share3: share.into(), 555 + }; 556 + let json = serde_json::to_value(&result).unwrap(); 557 + assert_eq!(json["share3"], share); 528 558 } 529 559 530 560 // -- DIDCeremonyError serialization (one test per variant) --
+2 -2
apps/identity-wallet/src/lib/components/onboarding/DIDCeremonyScreen.svelte
··· 8 8 onsuccess, 9 9 }: { 10 10 handle: string; 11 - onsuccess: (did: string) => void; 11 + onsuccess: (result: import('$lib/ipc').DIDCeremonyResult) => void; 12 12 } = $props(); 13 13 14 14 let loading = $state(true); ··· 20 20 try { 21 21 const result = await performDIDCeremony(handle); 22 22 loading = false; 23 - onsuccess(result.did); 23 + onsuccess(result); 24 24 } catch (raw: unknown) { 25 25 loading = false; 26 26 if (
+265
apps/identity-wallet/src/lib/components/onboarding/ShamirBackupScreen.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import QRCode from 'qrcode'; 4 + 5 + let { 6 + share3, 7 + oncomplete, 8 + }: { 9 + share3: string; 10 + oncomplete: () => void; 11 + } = $props(); 12 + 13 + let confirmed = $state(false); 14 + let copied = $state(false); 15 + let qrSvg = $state(''); 16 + 17 + // Format share as groups of 4 for readability (52 chars → 13 groups of 4). 18 + // Mirrors hardware wallet recovery key display conventions. 19 + let formattedShare = $derived(share3.match(/.{1,4}/g)?.join(' ') ?? share3); 20 + 21 + onMount(async () => { 22 + qrSvg = await QRCode.toString(share3, { 23 + type: 'svg', 24 + width: 200, 25 + margin: 2, 26 + }); 27 + }); 28 + 29 + async function copyShare() { 30 + await navigator.clipboard.writeText(share3); 31 + copied = true; 32 + setTimeout(() => { 33 + copied = false; 34 + }, 2000); 35 + } 36 + </script> 37 + 38 + <div class="screen"> 39 + <div class="header"> 40 + <h2>Back Up Your Recovery Key</h2> 41 + <p class="subtitle"> 42 + Your recovery key has been split into 3 parts for safety. If you ever lose access to your 43 + account, any 2 parts can restore it. 44 + </p> 45 + </div> 46 + 47 + <div class="share-row"> 48 + <div class="share-status"> 49 + <span class="check-icon" aria-hidden="true">✓</span> 50 + <div> 51 + <p class="share-label">Part 1 of 3</p> 52 + <p class="share-desc">Saved to iCloud Keychain automatically</p> 53 + </div> 54 + </div> 55 + </div> 56 + 57 + <div class="share-block"> 58 + <p class="share-label">Part 3 of 3 — Save this yourself</p> 59 + <code class="share-code">{formattedShare}</code> 60 + <button class="copy-btn" onclick={copyShare}> 61 + {copied ? 'Copied!' : 'Copy'} 62 + </button> 63 + 64 + {#if qrSvg} 65 + <div class="qr-container" aria-label="QR code for recovery key part 3"> 66 + {@html qrSvg} 67 + </div> 68 + {/if} 69 + 70 + <div class="backup-tips"> 71 + <p class="tip-label">Backup options</p> 72 + <ul> 73 + <li>Save to a password manager (1Password, Bitwarden, etc.)</li> 74 + <li>Print and store in a safe place</li> 75 + <li>Write it down and keep it separate from your device</li> 76 + </ul> 77 + </div> 78 + </div> 79 + 80 + <label class="confirm-label"> 81 + <input type="checkbox" bind:checked={confirmed} /> 82 + I've saved Part 3 somewhere safe 83 + </label> 84 + 85 + {#if !confirmed} 86 + <p class="warning" role="alert">You must save your recovery key before continuing.</p> 87 + {/if} 88 + 89 + <button class="cta" onclick={oncomplete} disabled={!confirmed}> 90 + I've Saved My Backup 91 + </button> 92 + </div> 93 + 94 + <style> 95 + .screen { 96 + display: flex; 97 + flex-direction: column; 98 + height: 100%; 99 + padding: 2rem 1.5rem; 100 + gap: 1.25rem; 101 + overflow-y: auto; 102 + } 103 + 104 + .header h2 { 105 + font-size: 1.4rem; 106 + font-weight: 700; 107 + margin: 0 0 0.5rem; 108 + } 109 + 110 + .subtitle { 111 + font-size: 0.9rem; 112 + color: #6b7280; 113 + margin: 0; 114 + line-height: 1.5; 115 + } 116 + 117 + .share-row { 118 + background: #f0fdf4; 119 + border: 1px solid #bbf7d0; 120 + border-radius: 12px; 121 + padding: 1rem; 122 + } 123 + 124 + .share-status { 125 + display: flex; 126 + align-items: center; 127 + gap: 0.75rem; 128 + } 129 + 130 + .check-icon { 131 + width: 36px; 132 + height: 36px; 133 + background: #22c55e; 134 + color: #fff; 135 + border-radius: 50%; 136 + display: flex; 137 + align-items: center; 138 + justify-content: center; 139 + font-size: 1.1rem; 140 + font-weight: 700; 141 + flex-shrink: 0; 142 + } 143 + 144 + .share-label { 145 + font-size: 0.8rem; 146 + font-weight: 600; 147 + color: #374151; 148 + margin: 0 0 0.2rem; 149 + text-transform: uppercase; 150 + letter-spacing: 0.04em; 151 + } 152 + 153 + .share-desc { 154 + font-size: 0.875rem; 155 + color: #6b7280; 156 + margin: 0; 157 + } 158 + 159 + .share-block { 160 + background: #f9fafb; 161 + border: 1px solid #e5e7eb; 162 + border-radius: 12px; 163 + padding: 1rem; 164 + display: flex; 165 + flex-direction: column; 166 + gap: 0.75rem; 167 + } 168 + 169 + .share-code { 170 + font-family: monospace; 171 + font-size: 0.85rem; 172 + background: #fff; 173 + border: 1px solid #d1d5db; 174 + border-radius: 8px; 175 + padding: 0.75rem; 176 + word-break: break-all; 177 + letter-spacing: 0.08em; 178 + line-height: 1.8; 179 + } 180 + 181 + .copy-btn { 182 + align-self: flex-start; 183 + padding: 0.4rem 1rem; 184 + background: #007aff; 185 + color: #fff; 186 + border: none; 187 + border-radius: 8px; 188 + font-size: 0.9rem; 189 + font-weight: 600; 190 + cursor: pointer; 191 + } 192 + 193 + .qr-container { 194 + display: flex; 195 + justify-content: center; 196 + padding: 0.5rem 0; 197 + } 198 + 199 + .qr-container :global(svg) { 200 + max-width: 180px; 201 + height: auto; 202 + } 203 + 204 + .backup-tips { 205 + border-top: 1px solid #e5e7eb; 206 + padding-top: 0.75rem; 207 + } 208 + 209 + .tip-label { 210 + font-size: 0.8rem; 211 + font-weight: 600; 212 + color: #374151; 213 + margin: 0 0 0.4rem; 214 + } 215 + 216 + ul { 217 + margin: 0; 218 + padding-left: 1.25rem; 219 + font-size: 0.85rem; 220 + color: #6b7280; 221 + display: flex; 222 + flex-direction: column; 223 + gap: 0.25rem; 224 + } 225 + 226 + .confirm-label { 227 + display: flex; 228 + align-items: center; 229 + gap: 0.6rem; 230 + font-size: 0.95rem; 231 + font-weight: 500; 232 + cursor: pointer; 233 + } 234 + 235 + .confirm-label input[type='checkbox'] { 236 + width: 20px; 237 + height: 20px; 238 + accent-color: #007aff; 239 + flex-shrink: 0; 240 + } 241 + 242 + .warning { 243 + font-size: 0.85rem; 244 + color: #ef4444; 245 + margin: 0; 246 + text-align: center; 247 + } 248 + 249 + .cta { 250 + width: 100%; 251 + padding: 1rem; 252 + background: #007aff; 253 + color: #fff; 254 + border: none; 255 + border-radius: 12px; 256 + font-size: 1.1rem; 257 + font-weight: 600; 258 + cursor: pointer; 259 + } 260 + 261 + .cta:disabled { 262 + background: #93c5fd; 263 + cursor: not-allowed; 264 + } 265 + </style>
+6
apps/identity-wallet/src/lib/ipc.ts
··· 113 113 */ 114 114 export type DIDCeremonyResult = { 115 115 did: string; 116 + /** 117 + * Share 3 of 3 — the user's manual backup share. 118 + * Base32-encoded (RFC 4648, no padding), 52 uppercase A-Z/2-7 characters. 119 + * Share 1 has already been stored in iCloud Keychain by the Rust backend. 120 + */ 121 + share3: string; 116 122 }; 117 123 118 124 /**
+41 -8
apps/identity-wallet/src/routes/+page.svelte
··· 6 6 import LoadingScreen from '$lib/components/onboarding/LoadingScreen.svelte'; 7 7 import DIDCeremonyScreen from '$lib/components/onboarding/DIDCeremonyScreen.svelte'; 8 8 import DIDSuccessScreen from '$lib/components/onboarding/DIDSuccessScreen.svelte'; 9 + import ShamirBackupScreen from '$lib/components/onboarding/ShamirBackupScreen.svelte'; 9 10 import { createAccount, type CreateAccountError } from '$lib/ipc'; 10 11 11 12 // ── Onboarding step type ───────────────────────────────────────────────── ··· 26 27 | 'loading' 27 28 | 'did_ceremony' 28 29 | 'did_success' 29 - | 'shamir_backup'; 30 + | 'shamir_backup' 31 + | 'complete'; 30 32 31 33 // ── State ──────────────────────────────────────────────────────────────── 32 34 33 35 let step = $state<OnboardingStep>('welcome'); 34 - let form = $state({ claimCode: '', email: '', handle: '', did: '' }); 36 + let form = $state({ claimCode: '', email: '', handle: '', did: '', share3: '' }); 35 37 36 38 /** 37 39 * Per-field error messages displayed by each screen. ··· 141 143 {:else if step === 'did_ceremony'} 142 144 <DIDCeremonyScreen 143 145 handle={form.handle} 144 - onsuccess={(did) => { form.did = did; step = 'did_success'; }} 146 + onsuccess={(result) => { form.did = result.did; form.share3 = result.share3; step = 'did_success'; }} 145 147 /> 146 148 {:else if step === 'did_success'} 147 149 <DIDSuccessScreen ··· 149 151 oncontinue={() => { step = 'shamir_backup'; }} 150 152 /> 151 153 {:else if step === 'shamir_backup'} 152 - <div class="placeholder"> 153 - <h2>Backup</h2> 154 - <p>Shamir backup coming soon…</p> 154 + <ShamirBackupScreen 155 + share3={form.share3} 156 + oncomplete={() => { step = 'complete'; }} 157 + /> 158 + {:else if step === 'complete'} 159 + <div class="complete"> 160 + <div class="complete-icon" aria-hidden="true">✓</div> 161 + <h2>You're All Set!</h2> 162 + <p>Your identity is ready. Your recovery key has been safely backed up.</p> 155 163 </div> 156 164 {/if} 157 165 </div> ··· 163 171 flex-direction: column; 164 172 } 165 173 166 - .placeholder { 174 + .complete { 167 175 display: flex; 168 176 flex-direction: column; 169 177 align-items: center; 170 178 justify-content: center; 171 179 height: 100%; 172 - gap: 1rem; 180 + gap: 1.25rem; 173 181 text-align: center; 174 182 padding: 2rem; 183 + } 184 + 185 + .complete-icon { 186 + width: 64px; 187 + height: 64px; 188 + background: #007aff; 189 + color: #fff; 190 + border-radius: 50%; 191 + display: flex; 192 + align-items: center; 193 + justify-content: center; 194 + font-size: 2rem; 195 + font-weight: 700; 196 + } 197 + 198 + .complete h2 { 199 + font-size: 1.5rem; 200 + font-weight: 700; 201 + margin: 0; 202 + } 203 + 204 + .complete p { 205 + font-size: 0.95rem; 206 + color: #6b7280; 207 + margin: 0; 175 208 } 176 209 </style>
+1
crates/relay/Cargo.toml
··· 31 31 reqwest = { workspace = true } 32 32 base64 = { workspace = true } 33 33 rand_core = { workspace = true } 34 + data-encoding = { workspace = true } 34 35 sha2 = { workspace = true } 35 36 subtle = { workspace = true } 36 37 uuid = { workspace = true }
+3 -1
crates/relay/src/db/CLAUDE.md
··· 1 1 # Database Module 2 2 3 - Last verified: 2026-03-13 3 + Last verified: 2026-03-21 4 4 5 5 ## Latest Updates 6 + - **V010**: Adds nullable `recovery_share` column to `accounts` — stores Share 2 of the Shamir 2-of-3 split for relay-side custody; base32-encoded (52 chars); NULL for pre-Shamir accounts 6 7 - **V009**: Rebuilt sessions with nullable device_id (devices are deleted at DID promotion) and added token_hash UNIQUE column for Bearer token authentication (same SHA-256 hex pattern as pending_sessions) 7 8 - **V008**: Rebuilt accounts with nullable password_hash (mobile accounts have no password); added pending_did column to pending_accounts for DID pre-store retry resilience 8 9 ··· 43 44 - `migrations/V007__pending_sessions.sql` - pending_sessions table: id, account_id (FK→pending_accounts), device_id (FK→devices), token_hash (UNIQUE), created_at, expires_at; used by POST /v1/accounts/mobile to issue a pre-DID session for the DID-creation step 44 45 - `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 45 46 - `migrations/V009__sessions_v2.sql` - Rebuilds sessions: makes device_id nullable (devices are transient, deleted at DID promotion) and adds token_hash UNIQUE column for Bearer token auth via require_session 47 + - `migrations/V010__recovery_shares.sql` - Adds nullable recovery_share TEXT to accounts: stores Share 2 of the Shamir 2-of-3 recovery split (base32, 52 chars); written atomically inside promote_account transaction
+13
crates/relay/src/db/migrations/V010__recovery_shares.sql
··· 1 + -- V010: Add recovery_share to accounts for Shamir relay custody 2 + -- Applied in a single transaction by the migration runner. 3 + -- 4 + -- Stores Share 2 of the 2-of-3 Shamir split of the per-user recovery secret. 5 + -- Share 1 goes to the user's iCloud Keychain; Share 3 goes to the user's 6 + -- manual backup. Any two of the three shares can reconstruct the recovery 7 + -- secret, enabling account recovery without both the relay and the user's 8 + -- device being available simultaneously. 9 + -- 10 + -- Encoded as base32 (RFC 4648, no padding) — 52 uppercase A-Z/2-7 characters. 11 + -- NULL for accounts created before V010 (pre-Shamir accounts). 12 + 13 + ALTER TABLE accounts ADD COLUMN recovery_share TEXT;
+45 -3
crates/relay/src/routes/create_did.rs
··· 42 42 // 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR 43 43 44 44 use axum::{extract::State, http::HeaderMap, Json}; 45 + use data_encoding::BASE32_NOPAD; 46 + use rand_core::{OsRng, RngCore}; 45 47 use serde::{Deserialize, Serialize}; 46 48 47 49 use crate::app::AppState; ··· 63 65 pub did_document: serde_json::Value, 64 66 pub status: &'static str, 65 67 pub session_token: String, 68 + /// Share 1 of 3 — for storage in the user's iCloud Keychain. 69 + /// Base32-encoded (RFC 4648, no padding), 52 uppercase chars. 70 + pub shamir_share_1: String, 71 + /// Share 3 of 3 — for user-directed manual backup. 72 + /// Base32-encoded (RFC 4648, no padding), 52 uppercase chars. 73 + pub shamir_share_3: String, 66 74 } 67 75 68 76 pub async fn create_did_handler( ··· 92 100 .await?; 93 101 } 94 102 95 - // Phase 4: Build DID document, generate session, atomically promote. 103 + // Phase 4: Build DID document, generate session, generate Shamir shares, atomically promote. 96 104 let did_document = build_did_document(&verified)?; 97 105 let session_token = generate_token(); 106 + let (share1, share2, share3) = generate_recovery_shares()?; 98 107 promote_account( 99 108 &state.db, 100 109 did, ··· 102 111 &session.account_id, 103 112 &did_document, 104 113 &session_token.hash, 114 + &share2, 105 115 ) 106 116 .await?; 107 117 ··· 110 120 did_document, 111 121 status: "active", 112 122 session_token: session_token.plaintext, 123 + shamir_share_1: share1, 124 + shamir_share_3: share3, 113 125 })) 114 126 } 115 127 ··· 272 284 Ok(()) 273 285 } 274 286 287 + /// Generate a fresh 32-byte recovery secret, split it into 3 Shamir shares, 288 + /// and return the shares base32-encoded as `(share1, share2, share3)`. 289 + /// 290 + /// Share 1 → user's iCloud Keychain (returned to app). 291 + /// Share 2 → relay DB custody (stored in accounts.recovery_share). 292 + /// Share 3 → user-directed manual backup (returned to app). 293 + /// 294 + /// Any 2 of the 3 shares can reconstruct the original secret. 295 + fn generate_recovery_shares() -> Result<(String, String, String), ApiError> { 296 + let mut secret = [0u8; 32]; 297 + OsRng.try_fill_bytes(&mut secret).map_err(|e| { 298 + tracing::error!(error = %e, "OS RNG unavailable during recovery share generation"); 299 + ApiError::new(ErrorCode::InternalError, "failed to generate recovery secret") 300 + })?; 301 + 302 + let [s1, s2, s3] = crypto::split_secret(&secret).map_err(|e| { 303 + tracing::error!(error = %e, "shamir split failed"); 304 + ApiError::new(ErrorCode::InternalError, "failed to split recovery secret") 305 + })?; 306 + 307 + Ok(( 308 + BASE32_NOPAD.encode(&*s1.data), 309 + BASE32_NOPAD.encode(&*s2.data), 310 + BASE32_NOPAD.encode(&*s3.data), 311 + )) 312 + } 313 + 275 314 /// POST the signed genesis operation to plc.directory (Step 9). 276 315 async fn post_to_plc_directory( 277 316 http_client: &reqwest::Client, ··· 317 356 /// 318 357 /// In a single transaction: INSERT accounts + did_documents + sessions, 319 358 /// then DELETE pending_sessions + devices + pending_accounts. 359 + /// `recovery_share` is Share 2 of the Shamir split; stored for relay-side custody. 320 360 async fn promote_account( 321 361 db: &sqlx::SqlitePool, 322 362 did: &str, ··· 324 364 account_id: &str, 325 365 did_document: &serde_json::Value, 326 366 token_hash: &str, 367 + recovery_share: &str, 327 368 ) -> Result<(), ApiError> { 328 369 let did_document_str = serde_json::to_string(did_document).map_err(|e| { 329 370 tracing::error!(error = %e, "failed to serialize DID document"); ··· 338 379 .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to begin transaction"))?; 339 380 340 381 sqlx::query( 341 - "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 342 - VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 382 + "INSERT INTO accounts (did, email, password_hash, recovery_share, created_at, updated_at) \ 383 + VALUES (?, ?, NULL, ?, datetime('now'), datetime('now'))", 343 384 ) 344 385 .bind(did) 345 386 .bind(email) 387 + .bind(recovery_share) 346 388 .execute(&mut *tx) 347 389 .await 348 390 .map_err(|e| {