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.

fix(identity-wallet): address PR review — zeroization, idempotency, error handling

Critical fixes:
- Wrap recovery secret in Zeroizing<[u8; 32]> so stack bytes are cleared on drop
- Add QR generation try-catch with qrFailed state and inline error fallback
- Add clipboard try-catch with copyFailed state and "Copy failed" error message

Architectural fixes:
- Add ShareStorageFailed variant distinct from KeychainError — post-commit
keychain failure shows "do not retry" message with no Retry button
- Add V011 migration (pending_share_{1,2,3}) and pre_store_did_and_shares() to
persist shares alongside pending_did on first attempt, reuse on retry;
prevents Share 2 orphaning when DID ceremony is retried

High/Important fixes:
- Log device key errors in create_account instead of silently discarding them
- Update route-level doc comment with Shamir steps and output fields
- Fix CLAUDE.md Key Files stale component count and missing complete step
- Add share3 assertion to existing serialization test; add ShareStorageFailed
serialization test

authored by

Malpercio and committed by
Tangled
e94bccd7 79e94338

+189 -64
+2 -2
apps/identity-wallet/CLAUDE.md
··· 203 203 - `src-tauri/src/http.rs` -- RelayClient with compile-time base URL 204 204 - `src-tauri/.cargo/config.toml` -- Cargo toolchain overrides for iOS cross-compilation (CC, AR, linker per target) 205 205 - `src/lib/ipc.ts` -- Typed TypeScript wrappers for all Tauri IPC commands (createAccount, getOrCreateDeviceKey, signWithDeviceKey, performDIDCeremony) 206 - - `src/lib/components/onboarding/` -- Seven onboarding screen components (WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen) 207 - - `src/routes/+page.svelte` -- Onboarding state machine (welcome -> claim_code -> email -> handle -> loading -> did_ceremony -> did_success -> shamir_backup) 206 + - `src/lib/components/onboarding/` -- Eight onboarding screen components (WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen, ShamirBackupScreen) 207 + - `src/routes/+page.svelte` -- Onboarding state machine (welcome -> claim_code -> email -> handle -> loading -> did_ceremony -> did_success -> shamir_backup -> complete) 208 208 - `src/routes/+layout.ts` -- `ssr = false; prerender = false` (global SPA config) 209 209 - `svelte.config.js` -- adapter-static with `pages: 'dist'` (SPA mode, matches tauri.conf.json) 210 210 - `vite.config.ts` -- Tauri-compatible Vite server (clearScreen, HMR via TAURI_DEV_HOST, envPrefix)
+21 -3
apps/identity-wallet/src-tauri/src/lib.rs
··· 146 146 DidCreationFailed, 147 147 #[error("keychain operation failed")] 148 148 KeychainError, 149 + /// DID was committed at the relay but Share 1 could not be stored in Keychain. 150 + /// The DID exists — retrying the ceremony will fail. The user can retry the share 151 + /// storage separately once the Keychain is available. 152 + #[error("DID created but recovery share storage failed")] 153 + ShareStorageFailed, 149 154 #[error("network error: {message}")] 150 155 NetworkError { message: String }, 151 156 } ··· 177 182 handle: String, 178 183 ) -> Result<CreateAccountResult, CreateAccountError> { 179 184 // 1. Get or create the device's SE-backed (or simulator-fallback) P-256 key. 180 - let device_key = device_key::get_or_create().map_err(|_| CreateAccountError::KeychainError)?; 185 + let device_key = device_key::get_or_create().map_err(|e| { 186 + tracing::warn!(error = %e, "device key creation failed during account creation"); 187 + CreateAccountError::KeychainError 188 + })?; 181 189 182 190 // 2. POST to relay. 183 191 let req = CreateMobileAccountRequest { ··· 357 365 })?; 358 366 359 367 // Step 8: Store Share 1 in iCloud Keychain for automatic backup. 368 + // Uses ShareStorageFailed (not KeychainError) because the DID is already committed: 369 + // retrying the ceremony will hit DidAlreadyExists. The frontend can surface a distinct 370 + // message rather than telling the user to retry the whole ceremony. 360 371 keychain::store_item( 361 372 "recovery-share-1", 362 373 create_did_resp.shamir_share_1.as_bytes(), 363 374 ) 364 375 .map_err(|e| { 365 - tracing::error!(error = %e, "failed to store recovery share 1 in keychain"); 366 - DIDCeremonyError::KeychainError 376 + tracing::error!(error = %e, "DID committed but recovery share 1 keychain write failed"); 377 + DIDCeremonyError::ShareStorageFailed 367 378 })?; 368 379 369 380 Ok(DIDCeremonyResult { ··· 544 555 }; 545 556 let json = serde_json::to_value(&result).unwrap(); 546 557 assert_eq!(json["did"], "did:plc:abcdefghijklmnopqrstuvwx"); 558 + assert_eq!(json["share3"], "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRST"); 547 559 } 548 560 549 561 #[test] ··· 602 614 let json = serde_json::to_value(&err).unwrap(); 603 615 assert_eq!(json["code"], "NETWORK_ERROR"); 604 616 assert_eq!(json["message"], "Connection refused"); 617 + } 618 + 619 + #[test] 620 + fn did_ceremony_error_share_storage_failed_serializes_correctly() { 621 + let json = serde_json::to_value(&DIDCeremonyError::ShareStorageFailed).unwrap(); 622 + assert_eq!(json["code"], "SHARE_STORAGE_FAILED"); 605 623 } 606 624 }
+11 -1
apps/identity-wallet/src/lib/components/onboarding/DIDCeremonyScreen.svelte
··· 49 49 return "Couldn't create your identity. Please try again."; 50 50 case 'KEYCHAIN_ERROR': 51 51 return "Couldn't save to your device. Please try again."; 52 + case 'SHARE_STORAGE_FAILED': 53 + return 'Your identity was created, but we couldn\u2019t save your recovery key. Please contact support — do not retry setup.'; 52 54 case 'KEY_NOT_FOUND': 53 55 default: 54 56 return 'Something went wrong. Please try again.'; 55 57 } 56 58 } 57 59 60 + function canRetry(err: DIDCeremonyError): boolean { 61 + // SHARE_STORAGE_FAILED means the DID is already committed — retrying the full 62 + // ceremony will fail with DID_ALREADY_EXISTS. Only recoverable out-of-band. 63 + return err.code !== 'SHARE_STORAGE_FAILED'; 64 + } 65 + 58 66 onMount(() => runCeremony()); 59 67 </script> 60 68 ··· 63 71 {:else if error} 64 72 <div class="screen"> 65 73 <p class="error-text">{errorMessage(error)}</p> 66 - <button class="retry" onclick={() => runCeremony()}>Retry</button> 74 + {#if canRetry(error)} 75 + <button class="retry" onclick={() => runCeremony()}>Retry</button> 76 + {/if} 67 77 </div> 68 78 {/if} 69 79
+37 -10
apps/identity-wallet/src/lib/components/onboarding/ShamirBackupScreen.svelte
··· 12 12 13 13 let confirmed = $state(false); 14 14 let copied = $state(false); 15 + let copyFailed = $state(false); 15 16 let qrSvg = $state(''); 17 + let qrFailed = $state(false); 16 18 17 19 // Format share as groups of 4 for readability (52 chars → 13 groups of 4). 18 20 // Mirrors hardware wallet recovery key display conventions. 19 21 let formattedShare = $derived(share3.match(/.{1,4}/g)?.join(' ') ?? share3); 20 22 21 23 onMount(async () => { 22 - qrSvg = await QRCode.toString(share3, { 23 - type: 'svg', 24 - width: 200, 25 - margin: 2, 26 - }); 24 + try { 25 + qrSvg = await QRCode.toString(share3, { 26 + type: 'svg', 27 + width: 200, 28 + margin: 2, 29 + }); 30 + } catch { 31 + // QR generation failed — share text and copy button remain the primary backup methods. 32 + qrFailed = true; 33 + } 27 34 }); 28 35 29 36 async function copyShare() { 30 - await navigator.clipboard.writeText(share3); 31 - copied = true; 32 - setTimeout(() => { 33 - copied = false; 34 - }, 2000); 37 + try { 38 + await navigator.clipboard.writeText(share3); 39 + copied = true; 40 + copyFailed = false; 41 + setTimeout(() => { 42 + copied = false; 43 + }, 2000); 44 + } catch { 45 + // Clipboard denied or bridge error — show failure so the user knows to use another method. 46 + copyFailed = true; 47 + setTimeout(() => { 48 + copyFailed = false; 49 + }, 3000); 50 + } 35 51 } 36 52 </script> 37 53 ··· 60 76 <button class="copy-btn" onclick={copyShare}> 61 77 {copied ? 'Copied!' : 'Copy'} 62 78 </button> 79 + {#if copyFailed} 80 + <p class="inline-error" role="alert">Copy failed. Please write it down or use the QR code.</p> 81 + {/if} 63 82 64 83 {#if qrSvg} 65 84 <div class="qr-container" aria-label="QR code for recovery key part 3"> 66 85 {@html qrSvg} 67 86 </div> 87 + {:else if qrFailed} 88 + <p class="inline-error">QR code unavailable — use Copy or write down the key above.</p> 68 89 {/if} 69 90 70 91 <div class="backup-tips"> ··· 199 220 .qr-container :global(svg) { 200 221 max-width: 180px; 201 222 height: auto; 223 + } 224 + 225 + .inline-error { 226 + font-size: 0.8rem; 227 + color: #ef4444; 228 + margin: 0; 202 229 } 203 230 204 231 .backup-tips {
+3
apps/identity-wallet/src/lib/ipc.ts
··· 136 136 | 'SIGNING_FAILED' 137 137 | 'DID_CREATION_FAILED' 138 138 | 'KEYCHAIN_ERROR' 139 + /** DID was committed at the relay but Share 1 Keychain write failed. Retrying the 140 + * ceremony will fail (DID already exists). Share storage can be retried separately. */ 141 + | 'SHARE_STORAGE_FAILED' 139 142 | 'NETWORK_ERROR'; 140 143 message?: string; 141 144 };
+2
crates/relay/src/db/CLAUDE.md
··· 3 3 Last verified: 2026-03-21 4 4 5 5 ## Latest Updates 6 + - **V011**: Adds nullable `pending_share_{1,2,3}` TEXT columns to `pending_accounts` — stores pre-generated Shamir shares alongside `pending_did` so retried DID ceremony requests return the same shares (prevents Share 2 orphaning in accounts.recovery_share) 6 7 - **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 7 8 - **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) 8 9 - **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 ··· 45 46 - `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 46 47 - `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 48 - `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 49 + - `migrations/V011__pending_shares.sql` - Adds nullable pending_share_{1,2,3} TEXT columns to pending_accounts: idempotent share storage alongside pending_did; all three deleted when pending_accounts row is deleted at promotion
+22
crates/relay/src/db/migrations/V011__pending_shares.sql
··· 1 + -- V011: Add pending shares to pending_accounts for idempotent share generation 2 + -- Applied in a single transaction by the migration runner. 3 + -- 4 + -- Stores the three base32-encoded Shamir shares alongside pending_did so that 5 + -- retried DID ceremony requests return exactly the same shares. Without this, 6 + -- every retry generates a fresh random secret: the first attempt's Share 2 is 7 + -- committed to accounts.recovery_share, but Shares 1 and 3 from that attempt 8 + -- were never delivered — orphaning the relay's share and breaking the 2-of-3 9 + -- scheme for the user. 10 + -- 11 + -- Flow: 12 + -- First attempt: pending_did IS NULL → generate shares, store all three + 13 + -- pending_did in a single UPDATE; proceed to plc.directory 14 + -- Retry attempt: pending_did IS NOT NULL → reuse stored shares; skip plc.directory 15 + -- Promotion: share_2 written to accounts.recovery_share inside the atomic 16 + -- transaction; pending_accounts row (including shares) is deleted 17 + -- 18 + -- All three columns are NULL for pending accounts created before V011. 19 + 20 + ALTER TABLE pending_accounts ADD COLUMN pending_share_1 TEXT; 21 + ALTER TABLE pending_accounts ADD COLUMN pending_share_2 TEXT; 22 + ALTER TABLE pending_accounts ADD COLUMN pending_share_3 TEXT;
+91 -48
crates/relay/src/routes/create_did.rs
··· 11 11 // 12 12 // Processing steps: 13 13 // 1. require_pending_session → PendingSessionInfo { account_id, device_id } 14 - // 2. SELECT handle, pending_did, email FROM pending_accounts WHERE id = account_id 14 + // 2. SELECT handle, pending_did, email, pending_share_{1,2,3} FROM pending_accounts WHERE id = account_id 15 15 // 3. Validate rotationKeyPublic starts with "did:key:z" → DidKeyUri 16 16 // 4. serde_json::to_string(signedCreationOp) → signed_op_str 17 17 // 5. crypto::verify_genesis_op(signed_op_str, rotation_key) → VerifiedGenesisOp ··· 19 19 // verified.rotation_keys[0] == rotationKeyPublic 20 20 // verified.also_known_as[0] == "at://{handle}" 21 21 // verified.atproto_pds_endpoint == config.public_url 22 - // 7. If pending_did IS NULL: UPDATE pending_accounts SET pending_did = verified.did 23 - // If pending_did IS NOT NULL: verify match, set skip_plc_directory = true 22 + // 7. If pending_did IS NULL: generate 3 Shamir shares; UPDATE pending_accounts SET 23 + // pending_did = verified.did, pending_share_{1,2,3} = <base32 shares> 24 + // If pending_did IS NOT NULL: verify match, reuse stored shares, set skip_plc = true 24 25 // 8. SELECT EXISTS(SELECT 1 FROM accounts WHERE did = verified.did) → 409 if true 25 - // 9. If !skip_plc_directory: POST {plc_directory_url}/{did} with signed_op_str 26 + // 9. If !skip_plc: POST {plc_directory_url}/{did} with signed_op_str 26 27 // 10. build_did_document(&verified) → serde_json::Value 27 28 // 11. Generate session token: 32 random bytes → base64url (returned) + SHA-256 hex (stored) 28 29 // 12. Atomic transaction: 29 - // INSERT accounts (did, email, password_hash=NULL) 30 + // INSERT accounts (did, email, password_hash=NULL, recovery_share=pending_share_2) 30 31 // INSERT did_documents (did, document) 31 32 // INSERT sessions (id, did, device_id=NULL, token_hash, expires_at=+1 year) 32 33 // DELETE pending_sessions WHERE account_id = ? 33 34 // DELETE devices WHERE account_id = ? 34 35 // DELETE pending_accounts WHERE id = ? 35 - // 13. Return { "did": "did:plc:...", "did_document": {...}, "status": "active", "session_token": "..." } 36 + // 13. Return { "did", "did_document", "status": "active", "session_token", 37 + // "shamirShare1": <base32>, "shamirShare3": <base32> } 36 38 // 37 39 // Note: handles are NOT inserted here. Handle creation is the caller's responsibility 38 40 // via POST /v1/handles, which validates format and optionally creates DNS records. 39 41 // 40 - // Outputs (success): 200 { "did": "...", "did_document": {...}, "status": "active", "session_token": "..." } 42 + // Outputs (success): 200 { "did": "...", "did_document": {...}, "status": "active", 43 + // "session_token": "...", "shamirShare1": "...", "shamirShare3": "..." } 41 44 // Outputs (error): 400 INVALID_CLAIM, 401 UNAUTHORIZED, 409 DID_ALREADY_EXISTS, 42 45 // 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR 43 46 ··· 45 48 use data_encoding::BASE32_NOPAD; 46 49 use rand_core::{OsRng, RngCore}; 47 50 use serde::{Deserialize, Serialize}; 51 + use zeroize::Zeroizing; 48 52 49 53 use crate::app::AppState; 50 54 use crate::db::is_unique_violation; ··· 87 91 verify_and_validate_genesis_op(&payload, &pending.handle, &state.config.public_url)?; 88 92 let did = &verified.did; 89 93 90 - // Phase 3: Pre-store DID for retry resilience, then POST to plc.directory. 91 - let skip_plc = pre_store_did(&state.db, &session.account_id, did, &pending.pending_did).await?; 94 + // Phase 3: Pre-store DID and Shamir shares for retry resilience, then POST to plc.directory. 95 + // Shares are generated once and stored alongside pending_did so that retries return the 96 + // same shares — preventing Share 2 from being orphaned in accounts.recovery_share. 97 + let (skip_plc, share1, share2, share3) = 98 + pre_store_did_and_shares(&state.db, &session.account_id, did, &pending).await?; 92 99 check_already_promoted(&state.db, did).await?; 93 100 if !skip_plc { 94 101 post_to_plc_directory( ··· 100 107 .await?; 101 108 } 102 109 103 - // Phase 4: Build DID document, generate session, generate Shamir shares, atomically promote. 110 + // Phase 4: Build DID document, generate session, atomically promote. 104 111 let did_document = build_did_document(&verified)?; 105 112 let session_token = generate_token(); 106 - let (share1, share2, share3) = generate_recovery_shares()?; 107 113 promote_account( 108 114 &state.db, 109 115 did, ··· 131 137 handle: String, 132 138 pending_did: Option<String>, 133 139 email: String, 140 + pending_share_1: Option<String>, 141 + pending_share_2: Option<String>, 142 + pending_share_3: Option<String>, 134 143 } 135 144 136 145 /// Load pending account details (Step 2). ··· 138 147 db: &sqlx::SqlitePool, 139 148 account_id: &str, 140 149 ) -> Result<PendingAccount, ApiError> { 141 - let (handle, pending_did, email): (String, Option<String>, String) = 142 - sqlx::query_as("SELECT handle, pending_did, email FROM pending_accounts WHERE id = ?") 143 - .bind(account_id) 144 - .fetch_optional(db) 145 - .await 146 - .map_err(|e| { 147 - tracing::error!(error = %e, "failed to query pending account"); 148 - ApiError::new(ErrorCode::InternalError, "failed to load account") 149 - })? 150 - .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?; 150 + let row: (String, Option<String>, String, Option<String>, Option<String>, Option<String>) = 151 + sqlx::query_as( 152 + "SELECT handle, pending_did, email, pending_share_1, pending_share_2, pending_share_3 \ 153 + FROM pending_accounts WHERE id = ?", 154 + ) 155 + .bind(account_id) 156 + .fetch_optional(db) 157 + .await 158 + .map_err(|e| { 159 + tracing::error!(error = %e, "failed to query pending account"); 160 + ApiError::new(ErrorCode::InternalError, "failed to load account") 161 + })? 162 + .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?; 151 163 Ok(PendingAccount { 152 - handle, 153 - pending_did, 154 - email, 164 + handle: row.0, 165 + pending_did: row.1, 166 + email: row.2, 167 + pending_share_1: row.3, 168 + pending_share_2: row.4, 169 + pending_share_3: row.5, 155 170 }) 156 171 } 157 172 ··· 218 233 Ok((verified, signed_op_str)) 219 234 } 220 235 221 - /// Pre-store the DID in pending_accounts for retry resilience (Step 7). 236 + /// Pre-store the DID and Shamir shares in pending_accounts for retry resilience (Step 7). 237 + /// 238 + /// On first attempt: generates 3 Shamir shares, stores `pending_did` + all three shares 239 + /// in a single UPDATE so they are available to any retry. 240 + /// 241 + /// On retry (pending_did already set): reuses the stored shares and returns `skip_plc = true` 242 + /// to skip the plc.directory call. This guarantees every attempt returns the same shares, 243 + /// preventing Share 2 from being orphaned in accounts.recovery_share. 222 244 /// 223 - /// Returns `true` if a previous attempt already stored this DID (skip plc.directory). 224 - async fn pre_store_did( 245 + /// Returns `(skip_plc, share1, share2, share3)`. 246 + async fn pre_store_did_and_shares( 225 247 db: &sqlx::SqlitePool, 226 248 account_id: &str, 227 249 did: &str, 228 - pending_did: &Option<String>, 229 - ) -> Result<bool, ApiError> { 230 - if let Some(pre_stored_did) = pending_did { 250 + pending: &PendingAccount, 251 + ) -> Result<(bool, String, String, String), ApiError> { 252 + if let Some(pre_stored_did) = &pending.pending_did { 231 253 if did != pre_stored_did { 232 254 tracing::error!( 233 255 derived_did = %did, ··· 239 261 "DID mismatch: derived DID does not match pre-stored value", 240 262 )); 241 263 } 242 - tracing::info!(did = %pre_stored_did, "retry detected: pending_did already set, skipping plc.directory"); 243 - return Ok(true); 264 + tracing::info!(did = %pre_stored_did, "retry detected: pending_did already set, reusing shares, skipping plc.directory"); 265 + let s1 = pending.pending_share_1.clone().ok_or_else(|| { 266 + tracing::error!("retry: pending_share_1 is NULL; shares were not stored on first attempt"); 267 + ApiError::new(ErrorCode::InternalError, "retry: missing shares from first attempt") 268 + })?; 269 + let s2 = pending.pending_share_2.clone().ok_or_else(|| { 270 + tracing::error!("retry: pending_share_2 is NULL; shares were not stored on first attempt"); 271 + ApiError::new(ErrorCode::InternalError, "retry: missing shares from first attempt") 272 + })?; 273 + let s3 = pending.pending_share_3.clone().ok_or_else(|| { 274 + tracing::error!("retry: pending_share_3 is NULL; shares were not stored on first attempt"); 275 + ApiError::new(ErrorCode::InternalError, "retry: missing shares from first attempt") 276 + })?; 277 + return Ok((true, s1, s2, s3)); 244 278 } 245 279 246 - let result = sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 247 - .bind(did) 248 - .bind(account_id) 249 - .execute(db) 250 - .await 251 - .map_err(|e| { 252 - tracing::error!(error = %e, "failed to pre-store pending_did"); 253 - ApiError::new(ErrorCode::InternalError, "failed to store pending DID") 254 - })?; 280 + let (s1, s2, s3) = generate_recovery_shares()?; 281 + 282 + let result = sqlx::query( 283 + "UPDATE pending_accounts \ 284 + SET pending_did = ?, pending_share_1 = ?, pending_share_2 = ?, pending_share_3 = ? \ 285 + WHERE id = ?", 286 + ) 287 + .bind(did) 288 + .bind(&s1) 289 + .bind(&s2) 290 + .bind(&s3) 291 + .bind(account_id) 292 + .execute(db) 293 + .await 294 + .map_err(|e| { 295 + tracing::error!(error = %e, "failed to pre-store pending DID and shares"); 296 + ApiError::new(ErrorCode::InternalError, "failed to store pending DID") 297 + })?; 255 298 256 299 if result.rows_affected() == 0 { 257 300 tracing::error!(account_id = %account_id, "pending account row vanished during DID pre-store"); ··· 260 303 "account no longer exists", 261 304 )); 262 305 } 263 - Ok(false) 306 + Ok((false, s1, s2, s3)) 264 307 } 265 308 266 309 /// Check if the DID is already fully promoted (Step 8). ··· 293 336 /// 294 337 /// Any 2 of the 3 shares can reconstruct the original secret. 295 338 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| { 339 + let mut secret = Zeroizing::new([0u8; 32]); 340 + OsRng.try_fill_bytes(secret.as_mut()).map_err(|e| { 298 341 tracing::error!(error = %e, "OS RNG unavailable during recovery share generation"); 299 342 ApiError::new(ErrorCode::InternalError, "failed to generate recovery secret") 300 343 })?; 301 344 302 - let [s1, s2, s3] = crypto::split_secret(&secret).map_err(|e| { 345 + let [s1, s2, s3] = crypto::split_secret(&*secret).map_err(|e| { 303 346 tracing::error!(error = %e, "shamir split failed"); 304 347 ApiError::new(ErrorCode::InternalError, "failed to split recovery secret") 305 348 })?; 306 349 307 350 Ok(( 308 - BASE32_NOPAD.encode(&*s1.data), 309 - BASE32_NOPAD.encode(&*s2.data), 310 - BASE32_NOPAD.encode(&*s3.data), 351 + BASE32_NOPAD.encode(s1.data.as_ref()), 352 + BASE32_NOPAD.encode(s2.data.as_ref()), 353 + BASE32_NOPAD.encode(s3.data.as_ref()), 311 354 )) 312 355 } 313 356