An encrypted personal cloud built on the AT Protocol.

Add web OAuth callback, device pairing, and identity safeguards

Wire the browser OAuth flow end-to-end: callback page strips auth
code from URL, exchanges it for DPoP-bound tokens, detects existing
encryption identities, and routes to either the cabinet or the new
device management page.

Add device pairing pages for both requesting (new device) and
approving (existing device) identity transfers via PDS relay, with
ephemeral X25519 keypairs and fingerprint verification.

Unify CLI identity setup between legacy and OAuth login paths into
a shared ensure_identity_and_publish function. Add --force flag with
destructive confirmation prompt for overriding existing identities.

Infrastructure: extract shared crypto worker singleton, add
console_log/panic_hook bridge for Rust log to browser console, add
DPoP nonce retry and token refresh to authenticated XRPC calls.

sans-self.org a441a92c 37b5e52d

Waiting for spindle ...
+1421 -157
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html) 13 13 14 14 ### Added 15 + - Build web login, callback, setup, and recover routes [#173](https://issues.opake.app/issues/173.html) 15 16 - Add device-to-device key pairing via PDS [#183](https://issues.opake.app/issues/183.html) 16 17 - Wire authenticated API layer for PDS and AppView [#174](https://issues.opake.app/issues/174.html) 17 18 - Rewrite web auth store as discriminated union state machine [#172](https://issues.opake.app/issues/172.html)
+24
Cargo.lock
··· 386 386 ] 387 387 388 388 [[package]] 389 + name = "console_error_panic_hook" 390 + version = "0.1.7" 391 + source = "registry+https://github.com/rust-lang/crates.io-index" 392 + checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" 393 + dependencies = [ 394 + "cfg-if", 395 + "wasm-bindgen", 396 + ] 397 + 398 + [[package]] 399 + name = "console_log" 400 + version = "1.0.0" 401 + source = "registry+https://github.com/rust-lang/crates.io-index" 402 + checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" 403 + dependencies = [ 404 + "log", 405 + "wasm-bindgen", 406 + "web-sys", 407 + ] 408 + 409 + [[package]] 389 410 name = "const-oid" 390 411 version = "0.9.6" 391 412 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1494 1515 name = "opake-wasm" 1495 1516 version = "0.1.0" 1496 1517 dependencies = [ 1518 + "console_error_panic_hook", 1519 + "console_log", 1497 1520 "getrandom 0.2.17", 1521 + "log", 1498 1522 "opake-core", 1499 1523 "serde", 1500 1524 "serde-wasm-bindgen",
+70 -36
crates/opake-cli/src/commands/login.rs
··· 2 2 use chrono::Utc; 3 3 use clap::Args; 4 4 use log::debug; 5 + use opake_core::client::Transport; 5 6 use opake_core::client::{Session, XrpcClient}; 7 + use opake_core::crypto::OsRng; 6 8 use opake_core::resolve::resolve_pds_for_login; 7 9 8 10 use crate::config::{AccountConfig, FileStorage}; ··· 48 50 /// Don't redirect to frontend after OAuth - show inline response instead 49 51 #[arg(long)] 50 52 no_redirect: bool, 53 + 54 + /// Overwrite existing encryption identity (generates new keypair even if one exists on PDS) 55 + #[arg(long)] 56 + force: bool, 51 57 } 52 58 53 59 impl LoginCommand { ··· 72 78 }; 73 79 74 80 if self.legacy { 75 - return Self::legacy_login(&pds_url, &identifier, storage).await; 81 + return Self::legacy_login(&pds_url, &identifier, storage, self.force).await; 76 82 } 77 83 78 84 match crate::oauth::try_oauth_login( ··· 81 87 resolved_handle.as_deref(), 82 88 storage, 83 89 self.no_redirect, 90 + self.force, 84 91 ) 85 92 .await 86 93 { 87 94 Ok(session) => Ok(Some(session)), 88 95 Err(e) => { 89 96 log::warn!("OAuth login failed, falling back to password authentication. Password auth is deprecated by AT Protocol and will stop working. Error: {e}"); 90 - Self::legacy_login(&pds_url, &identifier, storage).await 97 + Self::legacy_login(&pds_url, &identifier, storage, self.force).await 91 98 } 92 99 } 93 100 } ··· 96 103 pds_url: &str, 97 104 identifier: &str, 98 105 storage: &FileStorage, 106 + force: bool, 99 107 ) -> Result<Option<Session>> { 100 108 let password = resolve_password(prefixed_get_env("PASSWORD"), || { 101 109 prompt_password(identifier, pds_url) ··· 118 126 119 127 storage.save_config_anyhow(&cfg)?; 120 128 121 - let has_local_identity = identity::load_identity(storage, session.did()).is_ok(); 122 - let has_published_key = client 123 - .get_record( 124 - session.did(), 125 - opake_core::records::PUBLIC_KEY_COLLECTION, 126 - opake_core::records::PUBLIC_KEY_RKEY, 127 - ) 128 - .await 129 - .is_ok(); 129 + ensure_identity_and_publish(&mut client, storage, session.did(), force).await?; 130 + println!("Logged in as {}", session.handle()); 130 131 131 - if !has_local_identity && has_published_key { 132 - // Existing identity on another device — don't generate a new one. 133 - println!("Logged in as {}", session.handle()); 134 - println!(); 135 - println!("This account has an existing encryption identity."); 136 - println!("Run `opake pair request` to transfer it from another device."); 137 - return Ok(Some(session)); 138 - } 132 + Ok(Some(session)) 133 + } 134 + } 139 135 140 - let (identity, generated) = 141 - identity::ensure_identity(storage, session.did(), &mut opake_core::crypto::OsRng)?; 136 + /// Check for existing published key, prompt for --force confirmation if needed, 137 + /// generate (or load) identity, and publish the public key. Shared by both 138 + /// legacy and OAuth login paths. 139 + pub async fn ensure_identity_and_publish( 140 + client: &mut XrpcClient<impl Transport>, 141 + storage: &FileStorage, 142 + did: &str, 143 + force: bool, 144 + ) -> Result<()> { 145 + let has_local_identity = identity::load_identity(storage, did).is_ok(); 146 + let has_published_key = client 147 + .get_record( 148 + did, 149 + opake_core::records::PUBLIC_KEY_COLLECTION, 150 + opake_core::records::PUBLIC_KEY_RKEY, 151 + ) 152 + .await 153 + .is_ok(); 142 154 143 - if generated { 144 - println!("Generated new encryption keypair"); 155 + if !has_local_identity && has_published_key && force { 156 + println!(); 157 + println!("WARNING: --force will generate a new encryption identity."); 158 + println!("All data encrypted to the old identity will become permanently unreadable."); 159 + println!(); 160 + println!("Type exactly: This will brick my data and I am okay with that"); 161 + print!("> "); 162 + std::io::Write::flush(&mut std::io::stdout())?; 163 + let mut confirmation = String::new(); 164 + std::io::stdin().read_line(&mut confirmation)?; 165 + if confirmation.trim() != "This will brick my data and I am okay with that" { 166 + anyhow::bail!("Identity reset cancelled."); 145 167 } 168 + println!("Proceeding with new identity generation."); 169 + } 146 170 147 - let public_key_bytes = identity.public_key_bytes()?; 148 - let verify_key_bytes = identity.verify_key_bytes()?; 149 - opake_core::resolve::publish_public_key( 150 - &mut client, 151 - &public_key_bytes, 152 - verify_key_bytes.as_ref(), 153 - &Utc::now().to_rfc3339(), 154 - ) 155 - .await?; 156 - println!("Published encryption public key"); 171 + if !has_local_identity && has_published_key && !force { 172 + println!(); 173 + println!("This account has an existing encryption identity."); 174 + println!("Run `opake pair request` to transfer it from another device,"); 175 + println!("or `opake login --force` to generate a new identity (invalidates old one)."); 176 + return Ok(()); 177 + } 178 + 179 + let (identity, generated) = identity::ensure_identity(storage, did, &mut OsRng)?; 180 + if generated { 181 + println!("Generated new encryption keypair"); 182 + } 157 183 158 - println!("Logged in as {}", session.handle()); 184 + let public_key_bytes = identity.public_key_bytes()?; 185 + let verify_key_bytes = identity.verify_key_bytes()?; 186 + opake_core::resolve::publish_public_key( 187 + client, 188 + &public_key_bytes, 189 + verify_key_bytes.as_ref(), 190 + &Utc::now().to_rfc3339(), 191 + ) 192 + .await?; 193 + println!("Published encryption public key"); 159 194 160 - Ok(Some(session)) 161 - } 195 + Ok(()) 162 196 } 163 197 164 198 #[cfg(test)]
+3 -36
crates/opake-cli/src/oauth.rs
··· 17 17 use tokio::io::{AsyncReadExt, AsyncWriteExt}; 18 18 use tokio::net::TcpListener; 19 19 20 + use crate::commands::login::ensure_identity_and_publish; 20 21 use crate::config::{AccountConfig, FileStorage}; 21 - use crate::identity; 22 22 use crate::transport::ReqwestTransport; 23 23 24 24 /// Attempt a full OAuth login flow. Returns `Err` if the PDS doesn't support ··· 35 35 handle: Option<&str>, 36 36 storage: &FileStorage, 37 37 no_redirect: bool, 38 + force: bool, 38 39 ) -> Result<Session> { 39 40 let transport = ReqwestTransport::new(); 40 41 ··· 212 213 session.clone(), 213 214 ); 214 215 215 - let has_local_identity = identity::load_identity(storage, &did).is_ok(); 216 - let has_published_key = client 217 - .get_record( 218 - &did, 219 - opake_core::records::PUBLIC_KEY_COLLECTION, 220 - opake_core::records::PUBLIC_KEY_RKEY, 221 - ) 222 - .await 223 - .is_ok(); 224 - 225 - if !has_local_identity && has_published_key { 226 - // Existing identity on another device — don't generate a new one. 227 - println!("Logged in as {handle} (OAuth)"); 228 - println!(); 229 - println!("This account has an existing encryption identity."); 230 - println!("Run `opake pair request` to transfer it from another device."); 231 - return Ok(session); 232 - } 233 - 234 - let (identity, generated) = identity::ensure_identity(storage, &did, &mut OsRng)?; 235 - if generated { 236 - println!("Generated new encryption keypair"); 237 - } 238 - 239 - let public_key_bytes = identity.public_key_bytes()?; 240 - let verify_key_bytes = identity.verify_key_bytes()?; 241 - opake_core::resolve::publish_public_key( 242 - &mut client, 243 - &public_key_bytes, 244 - verify_key_bytes.as_ref(), 245 - &Utc::now().to_rfc3339(), 246 - ) 247 - .await?; 248 - println!("Published encryption public key"); 249 - 216 + ensure_identity_and_publish(&mut client, storage, &did, force).await?; 250 217 println!("Logged in as {handle} (OAuth)"); 251 218 252 219 Ok(session)
+1
crates/opake-core/src/lib.rs
··· 15 15 pub use opake_derive::RedactedDebug; 16 16 17 17 pub fn binding_check() -> &'static str { 18 + log::debug!("binding_check called"); 18 19 "WORKS" 19 20 } 20 21
+3
crates/opake-wasm/Cargo.toml
··· 13 13 wasm-bindgen = "0.2" 14 14 serde-wasm-bindgen = "0.6" 15 15 serde = { workspace = true } 16 + log = { workspace = true } 17 + console_log = { version = "1", features = ["color"] } 18 + console_error_panic_hook = "0.1" 16 19 17 20 # OsRng on wasm32 needs crypto.getRandomValues() 18 21 getrandom = { version = "0.2", features = ["js"] }
+11 -5
crates/opake-wasm/src/lib.rs
··· 1 - #[wasm_bindgen(js_name = bindingCheck)] 2 - pub fn binding_check() -> String { 3 - opake_core::binding_check().to_owned() 4 - } 5 - 6 1 use opake_core::client::dpop::DpopKeyPair; 7 2 use opake_core::client::oauth_discovery::generate_pkce; 8 3 use opake_core::crypto::{ContentKey, EncryptedPayload, OsRng, X25519PrivateKey, X25519PublicKey}; ··· 10 5 use opake_core::storage::Identity; 11 6 use serde::Serialize; 12 7 use wasm_bindgen::prelude::*; 8 + 9 + #[wasm_bindgen(start)] 10 + pub fn init() { 11 + console_error_panic_hook::set_once(); 12 + console_log::init_with_level(log::Level::Debug).ok(); 13 + } 14 + 15 + #[wasm_bindgen(js_name = bindingCheck)] 16 + pub fn binding_check() -> String { 17 + opake_core::binding_check().to_owned() 18 + } 13 19 14 20 /// DTO for EncryptedPayload that serializes the nonce as Vec<u8> 15 21 /// so serde-wasm-bindgen produces a proper Uint8Array instead of
+114 -22
web/src/lib/api.ts
··· 1 1 // XRPC and AppView API helpers. 2 2 3 - import { wrap, type Remote } from "comlink"; 4 - import type { CryptoApi } from "@/workers/crypto.worker"; 5 3 import type { OAuthSession, Session } from "@/lib/storage-types"; 4 + import type { TokenResponse } from "@/lib/oauth"; 5 + import { getCryptoWorker } from "@/lib/worker"; 6 + import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 6 7 7 8 interface ApiConfig { 8 9 pdsUrl: string; ··· 52 53 // Authenticated XRPC (DPoP or Legacy) 53 54 // --------------------------------------------------------------------------- 54 55 55 - let workerInstance: Remote<CryptoApi> | null = null; 56 - 57 - function getWorker(): Remote<CryptoApi> { 58 - if (!workerInstance) { 59 - const raw = new Worker( 60 - new URL("../workers/crypto.worker.ts", import.meta.url), 61 - { type: "module" }, 62 - ); 63 - workerInstance = wrap<CryptoApi>(raw); 64 - } 65 - return workerInstance; 66 - } 67 - 68 56 interface AuthenticatedXrpcParams { 69 57 pdsUrl: string; 70 58 lexicon: string; ··· 78 66 ): Promise<unknown> { 79 67 const { pdsUrl, lexicon, method = "GET", body } = params; 80 68 const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/${lexicon}`; 69 + const jsonBody = body ? JSON.stringify(body) : undefined; 81 70 82 71 const headers: Record<string, string> = { 83 72 "Content-Type": "application/json", ··· 89 78 headers.Authorization = `Bearer ${session.accessJwt}`; 90 79 } 91 80 92 - const response = await fetch(url, { 93 - method, 94 - headers, 95 - body: body ? JSON.stringify(body) : undefined, 96 - }); 81 + let response = await fetch(url, { method, headers, body: jsonBody }); 82 + 83 + // DPoP nonce retry — the PDS has a different nonce than the AS. 84 + if (session.type === "oauth" && requiresNonceRetry(response)) { 85 + const nonce = response.headers.get("dpop-nonce"); 86 + if (nonce) { 87 + session.dpopNonce = nonce; 88 + await attachDpopAuth(headers, session, method, url); 89 + response = await fetch(url, { method, headers, body: jsonBody }); 90 + } 91 + } 92 + 93 + // Token expired — refresh and retry once. 94 + if (response.status === 401 && session.type === "oauth" && session.refreshToken) { 95 + console.debug("[api] 401 — attempting token refresh"); 96 + const refreshed = await refreshAccessToken(session); 97 + if (refreshed) { 98 + await attachDpopAuth(headers, session, method, url); 99 + response = await fetch(url, { method, headers, body: jsonBody }); 100 + 101 + // The refreshed token might also need a nonce retry on the PDS 102 + if (requiresNonceRetry(response)) { 103 + const nonce = response.headers.get("dpop-nonce"); 104 + if (nonce) { 105 + session.dpopNonce = nonce; 106 + await attachDpopAuth(headers, session, method, url); 107 + response = await fetch(url, { method, headers, body: jsonBody }); 108 + } 109 + } 110 + } 111 + } 97 112 98 113 if (!response.ok) { 99 - throw new Error(`XRPC ${lexicon}: ${response.status}`); 114 + const detail = await response.text().catch(() => ""); 115 + throw new Error(`XRPC ${lexicon}: ${response.status} ${detail}`.trim()); 100 116 } 101 117 102 118 return response.json(); 103 119 } 104 120 121 + // --------------------------------------------------------------------------- 122 + // Token refresh 123 + // --------------------------------------------------------------------------- 124 + 125 + const storage = new IndexedDbStorage(); 126 + 127 + /** Refresh an expired OAuth access token. Mutates the session in place and persists to IndexedDB. */ 128 + async function refreshAccessToken(session: OAuthSession): Promise<boolean> { 129 + const worker = getCryptoWorker(); 130 + const url = session.tokenEndpoint; 131 + 132 + const body = new URLSearchParams({ 133 + grant_type: "refresh_token", 134 + refresh_token: session.refreshToken, 135 + client_id: session.clientId, 136 + }); 137 + 138 + const timestamp = Math.floor(Date.now() / 1000); 139 + const proof = await worker.createDpopProof( 140 + session.dpopKey, "POST", url, timestamp, session.dpopNonce, null, 141 + ); 142 + 143 + const headers: Record<string, string> = { 144 + "Content-Type": "application/x-www-form-urlencoded", 145 + DPoP: proof, 146 + }; 147 + 148 + let response = await fetch(url, { method: "POST", headers, body: body.toString() }); 149 + let nonce = response.headers.get("dpop-nonce") ?? session.dpopNonce; 150 + 151 + // Nonce retry for the AS 152 + if (response.status === 400) { 153 + const errorBody = await response.clone().json().catch(() => null) as { 154 + error?: string; 155 + } | null; 156 + if (errorBody?.error === "use_dpop_nonce" && nonce) { 157 + const retryProof = await worker.createDpopProof( 158 + session.dpopKey, "POST", url, timestamp, nonce, null, 159 + ); 160 + headers.DPoP = retryProof; 161 + response = await fetch(url, { method: "POST", headers, body: body.toString() }); 162 + nonce = response.headers.get("dpop-nonce") ?? nonce; 163 + } 164 + } 165 + 166 + if (!response.ok) { 167 + console.error("[api] token refresh failed:", response.status); 168 + return false; 169 + } 170 + 171 + const tokenResponse = (await response.json()) as TokenResponse; 172 + console.debug("[api] token refreshed, new expiry:", tokenResponse.expires_in); 173 + 174 + const now = Math.floor(Date.now() / 1000); 175 + session.accessToken = tokenResponse.access_token; 176 + session.refreshToken = tokenResponse.refresh_token ?? session.refreshToken; 177 + session.dpopNonce = nonce; 178 + session.expiresAt = tokenResponse.expires_in ? now + tokenResponse.expires_in : null; 179 + 180 + // Persist updated session 181 + await storage.saveSession(session.did, session).catch((err) => { 182 + console.warn("[api] failed to persist refreshed session:", err); 183 + }); 184 + 185 + return true; 186 + } 187 + 188 + /** Check if a response is a DPoP nonce challenge (400 use_dpop_nonce or 401 with nonce header). */ 189 + function requiresNonceRetry(response: Response): boolean { 190 + if (response.headers.has("dpop-nonce")) { 191 + if (response.status === 401) return true; 192 + if (response.status === 400) return true; 193 + } 194 + return false; 195 + } 196 + 105 197 async function attachDpopAuth( 106 198 headers: Record<string, string>, 107 199 session: OAuthSession, 108 200 method: string, 109 201 url: string, 110 202 ): Promise<void> { 111 - const worker = getWorker(); 203 + const worker = getCryptoWorker(); 112 204 const timestamp = Math.floor(Date.now() / 1000); 113 205 const proof = await worker.createDpopProof( 114 206 session.dpopKey,
+37
web/src/lib/encoding.ts
··· 1 + // Base64, fingerprint, and AT-URI encoding utilities. 2 + 3 + /** Standard base64 from bytes. */ 4 + export function uint8ArrayToBase64(bytes: Uint8Array): string { 5 + let binary = ""; 6 + for (const byte of bytes) { 7 + binary += String.fromCharCode(byte); 8 + } 9 + return btoa(binary); 10 + } 11 + 12 + /** Base64 to bytes — handles unpadded strings (PDS returns unpadded). */ 13 + export function base64ToUint8Array(b64: string): Uint8Array { 14 + // Pad to multiple of 4 15 + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); 16 + const binary = atob(padded); 17 + const bytes = new Uint8Array(binary.length); 18 + for (let i = 0; i < binary.length; i++) { 19 + bytes[i] = binary.charCodeAt(i); 20 + } 21 + return bytes; 22 + } 23 + 24 + /** First 8 bytes of a public key as a colon-separated hex fingerprint. */ 25 + export function formatFingerprint(pubkey: Uint8Array): string { 26 + return Array.from(pubkey.slice(0, 8)) 27 + .map((b) => b.toString(16).padStart(2, "0")) 28 + .join(":"); 29 + } 30 + 31 + /** Extract the rkey from an AT-URI: `at://did/collection/rkey` → `rkey`. */ 32 + export function rkeyFromUri(atUri: string): string { 33 + const parts = atUri.split("/"); 34 + const rkey = parts.at(-1); 35 + if (!rkey) throw new Error(`Invalid AT-URI: ${atUri}`); 36 + return rkey; 37 + }
+30 -15
web/src/lib/oauth.ts
··· 146 146 accessToken: string | null, 147 147 worker: CryptoWorker, 148 148 ): Promise<{ response: Response; dpopNonce: string | null }> { 149 + console.debug("[dpop] creating proof for", method, url); 149 150 const timestamp = Math.floor(Date.now() / 1000); 150 151 const proof = await worker.createDpopProof( 151 152 dpopKey, method, url, timestamp, dpopNonce, accessToken, 152 153 ); 154 + console.debug("[dpop] proof created, sending request"); 153 155 154 156 const headers: Record<string, string> = { 155 157 "Content-Type": "application/x-www-form-urlencoded", ··· 160 162 } 161 163 162 164 let response = await fetch(url, { method, headers, body: body.toString() }); 165 + console.debug("[dpop] response:", response.status); 163 166 let nonce = response.headers.get("dpop-nonce") ?? dpopNonce; 164 167 165 168 // Retry on use_dpop_nonce ··· 168 171 error?: string; 169 172 error_description?: string; 170 173 } | null; 174 + console.debug("[dpop] 400 error body:", errorBody); 171 175 172 176 if (errorBody?.error === "use_dpop_nonce" && nonce) { 177 + console.debug("[dpop] retrying with server nonce"); 173 178 const retryProof = await worker.createDpopProof( 174 179 dpopKey, method, url, timestamp, nonce, accessToken, 175 180 ); 176 181 headers.DPoP = retryProof; 177 182 response = await fetch(url, { method, headers, body: body.toString() }); 183 + console.debug("[dpop] retry response:", response.status); 178 184 nonce = response.headers.get("dpop-nonce") ?? nonce; 179 185 } 180 186 } ··· 310 316 record.signingAlgo = "ed25519"; 311 317 } 312 318 313 - const timestamp = Math.floor(Date.now() / 1000); 314 - const proof = await worker.createDpopProof( 315 - dpopKey, "POST", url, timestamp, dpopNonce, accessToken, 316 - ); 319 + const jsonBody = JSON.stringify({ 320 + repo: did, 321 + collection: "app.opake.cloud.publicKey", 322 + rkey: "self", 323 + record, 324 + }); 317 325 318 - const response = await fetch(url, { 319 - method: "POST", 320 - headers: { 326 + const makeHeaders = async (nonce: string | null): Promise<Record<string, string>> => { 327 + const timestamp = Math.floor(Date.now() / 1000); 328 + const proof = await worker.createDpopProof( 329 + dpopKey, "POST", url, timestamp, nonce, accessToken, 330 + ); 331 + return { 321 332 "Content-Type": "application/json", 322 333 Authorization: `DPoP ${accessToken}`, 323 334 DPoP: proof, 324 - }, 325 - body: JSON.stringify({ 326 - repo: did, 327 - collection: "app.opake.cloud.publicKey", 328 - rkey: "self", 329 - record, 330 - }), 331 - }); 335 + }; 336 + }; 337 + 338 + let headers = await makeHeaders(dpopNonce); 339 + let response = await fetch(url, { method: "POST", headers, body: jsonBody }); 340 + 341 + // DPoP nonce retry — PDS nonce differs from AS nonce 342 + if ((response.status === 401 || response.status === 400) && response.headers.has("dpop-nonce")) { 343 + const nonce = response.headers.get("dpop-nonce"); 344 + headers = await makeHeaders(nonce); 345 + response = await fetch(url, { method: "POST", headers, body: jsonBody }); 346 + } 332 347 333 348 if (!response.ok) { 334 349 const body = await response.text().catch(() => "");
+284
web/src/lib/pairing.ts
··· 1 + // Device pairing XRPC orchestration. 2 + // Consumes authenticatedXrpc from api.ts and crypto worker functions. 3 + 4 + import type { Remote } from "comlink"; 5 + import type { CryptoApi } from "@/workers/crypto.worker"; 6 + import type { WrappedKey, AtBytes } from "@/lib/crypto-types"; 7 + import type { Identity, Session } from "@/lib/storage-types"; 8 + import { authenticatedXrpc } from "@/lib/api"; 9 + import { 10 + uint8ArrayToBase64, 11 + base64ToUint8Array, 12 + formatFingerprint, 13 + rkeyFromUri, 14 + } from "@/lib/encoding"; 15 + 16 + // --------------------------------------------------------------------------- 17 + // Types 18 + // --------------------------------------------------------------------------- 19 + 20 + export interface PendingPairRequest { 21 + uri: string; 22 + fingerprint: string; 23 + createdAt: string; 24 + ephemeralKey: Uint8Array; 25 + } 26 + 27 + interface PairResponseRecord { 28 + wrappedKey: WrappedKey; 29 + ciphertext: AtBytes; 30 + nonce: AtBytes; 31 + } 32 + 33 + const PAIR_REQUEST_COLLECTION = "app.opake.cloud.pairRequest"; 34 + const PAIR_RESPONSE_COLLECTION = "app.opake.cloud.pairResponse"; 35 + const SCHEMA_VERSION = 1; 36 + 37 + // --------------------------------------------------------------------------- 38 + // Create pair request (new device) 39 + // --------------------------------------------------------------------------- 40 + 41 + /** Publish a pairRequest record and return its AT-URI. */ 42 + export async function createPairRequest( 43 + pdsUrl: string, 44 + did: string, 45 + ephemeralPubKey: Uint8Array, 46 + session: Session, 47 + ): Promise<string> { 48 + const rkey = generateTid(); 49 + 50 + const record = { 51 + $type: PAIR_REQUEST_COLLECTION, 52 + version: SCHEMA_VERSION, 53 + ephemeralKey: { $bytes: uint8ArrayToBase64(ephemeralPubKey) }, 54 + algo: "x25519", 55 + createdAt: new Date().toISOString(), 56 + }; 57 + 58 + const result = (await authenticatedXrpc( 59 + { 60 + pdsUrl, 61 + lexicon: "com.atproto.repo.createRecord", 62 + method: "POST", 63 + body: { 64 + repo: did, 65 + collection: PAIR_REQUEST_COLLECTION, 66 + rkey, 67 + record, 68 + }, 69 + }, 70 + session, 71 + )) as { uri: string }; 72 + 73 + return result.uri; 74 + } 75 + 76 + // --------------------------------------------------------------------------- 77 + // List pending pair requests (existing device) 78 + // --------------------------------------------------------------------------- 79 + 80 + export async function listPairRequests( 81 + pdsUrl: string, 82 + did: string, 83 + session: Session, 84 + ): Promise<PendingPairRequest[]> { 85 + const result = (await authenticatedXrpc( 86 + { 87 + pdsUrl, 88 + lexicon: `com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${PAIR_REQUEST_COLLECTION}&limit=50`, 89 + method: "GET", 90 + }, 91 + session, 92 + )) as { 93 + records: Array<{ 94 + uri: string; 95 + value: { 96 + ephemeralKey: AtBytes; 97 + createdAt: string; 98 + }; 99 + }>; 100 + }; 101 + 102 + return result.records.map((rec) => { 103 + const keyBytes = base64ToUint8Array(rec.value.ephemeralKey.$bytes); 104 + return { 105 + uri: rec.uri, 106 + fingerprint: formatFingerprint(keyBytes), 107 + createdAt: rec.value.createdAt, 108 + ephemeralKey: keyBytes, 109 + }; 110 + }); 111 + } 112 + 113 + // --------------------------------------------------------------------------- 114 + // Poll for pair response (new device) 115 + // --------------------------------------------------------------------------- 116 + 117 + export async function pollForPairResponse( 118 + pdsUrl: string, 119 + did: string, 120 + requestRkey: string, 121 + session: Session, 122 + ): Promise<PairResponseRecord | null> { 123 + const result = (await authenticatedXrpc( 124 + { 125 + pdsUrl, 126 + lexicon: `com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${PAIR_RESPONSE_COLLECTION}&limit=50`, 127 + method: "GET", 128 + }, 129 + session, 130 + )) as { 131 + records: Array<{ 132 + uri: string; 133 + value: { 134 + request: string; 135 + wrappedKey: WrappedKey; 136 + ciphertext: AtBytes; 137 + nonce: AtBytes; 138 + }; 139 + }>; 140 + }; 141 + 142 + // Find the response that references our request 143 + const requestUri = `at://${did}/${PAIR_REQUEST_COLLECTION}/${requestRkey}`; 144 + const match = result.records.find((rec) => rec.value.request === requestUri); 145 + if (!match) return null; 146 + 147 + return { 148 + wrappedKey: match.value.wrappedKey, 149 + ciphertext: match.value.ciphertext, 150 + nonce: match.value.nonce, 151 + }; 152 + } 153 + 154 + // --------------------------------------------------------------------------- 155 + // Receive pair response (new device — unwrap + decrypt) 156 + // --------------------------------------------------------------------------- 157 + 158 + export async function receivePairResponse( 159 + response: PairResponseRecord, 160 + ephemeralPrivKey: Uint8Array, 161 + worker: Remote<CryptoApi>, 162 + ): Promise<Identity> { 163 + // Unwrap the content key using the ephemeral private key 164 + const contentKey = await worker.unwrapKey(response.wrappedKey, ephemeralPrivKey); 165 + 166 + // Decrypt the identity JSON 167 + const ciphertext = base64ToUint8Array(response.ciphertext.$bytes); 168 + const nonce = base64ToUint8Array(response.nonce.$bytes); 169 + const plaintext = await worker.decryptBlob(contentKey, ciphertext, nonce); 170 + 171 + // Parse the identity (mirrors Rust's serde_json::from_slice) 172 + const decoder = new TextDecoder(); 173 + const identity = JSON.parse(decoder.decode(plaintext)) as Identity; 174 + 175 + return identity; 176 + } 177 + 178 + // --------------------------------------------------------------------------- 179 + // Approve pair request (existing device — encrypt + wrap) 180 + // --------------------------------------------------------------------------- 181 + 182 + export async function approvePairRequest( 183 + pdsUrl: string, 184 + did: string, 185 + requestUri: string, 186 + ephemeralPubKey: Uint8Array, 187 + identity: Identity, 188 + session: Session, 189 + worker: Remote<CryptoApi>, 190 + ): Promise<string> { 191 + // Generate a content key for encrypting the identity 192 + const contentKey = await worker.generateContentKey(); 193 + 194 + // Serialize identity to JSON (mirrors Rust's serde_json::to_vec) 195 + const encoder = new TextEncoder(); 196 + const plaintext = encoder.encode(JSON.stringify(identity)); 197 + 198 + // Encrypt identity with the content key 199 + const encrypted = await worker.encryptBlob(contentKey, plaintext); 200 + 201 + // Wrap the content key to the requester's ephemeral public key 202 + const wrappedKey = await worker.wrapKey(contentKey, ephemeralPubKey, did); 203 + 204 + const rkey = generateTid(); 205 + 206 + const record = { 207 + $type: PAIR_RESPONSE_COLLECTION, 208 + version: SCHEMA_VERSION, 209 + request: requestUri, 210 + wrappedKey, 211 + ciphertext: { $bytes: uint8ArrayToBase64(encrypted.ciphertext) }, 212 + nonce: { $bytes: uint8ArrayToBase64(encrypted.nonce) }, 213 + algo: "aes-256-gcm", 214 + createdAt: new Date().toISOString(), 215 + }; 216 + 217 + const result = (await authenticatedXrpc( 218 + { 219 + pdsUrl, 220 + lexicon: "com.atproto.repo.createRecord", 221 + method: "POST", 222 + body: { 223 + repo: did, 224 + collection: PAIR_RESPONSE_COLLECTION, 225 + rkey, 226 + record, 227 + }, 228 + }, 229 + session, 230 + )) as { uri: string }; 231 + 232 + return result.uri; 233 + } 234 + 235 + // --------------------------------------------------------------------------- 236 + // Cleanup (delete request + response records) 237 + // --------------------------------------------------------------------------- 238 + 239 + export async function cleanupPairRecords( 240 + pdsUrl: string, 241 + did: string, 242 + requestUri: string, 243 + responseUri: string | null, 244 + session: Session, 245 + ): Promise<void> { 246 + const deleteRecord = async (collection: string, uri: string) => { 247 + const rkey = rkeyFromUri(uri); 248 + await authenticatedXrpc( 249 + { 250 + pdsUrl, 251 + lexicon: "com.atproto.repo.deleteRecord", 252 + method: "POST", 253 + body: { repo: did, collection, rkey }, 254 + }, 255 + session, 256 + ); 257 + }; 258 + 259 + await deleteRecord(PAIR_REQUEST_COLLECTION, requestUri); 260 + if (responseUri) { 261 + await deleteRecord(PAIR_RESPONSE_COLLECTION, responseUri); 262 + } 263 + } 264 + 265 + // --------------------------------------------------------------------------- 266 + // TID generation (AT Protocol timestamp-based ID) 267 + // --------------------------------------------------------------------------- 268 + 269 + const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; 270 + 271 + function generateTid(): string { 272 + const now = BigInt(Date.now()) * 1000n; 273 + const clockId = BigInt(Math.floor(Math.random() * 1024)); 274 + const tid = (now << 10n) | clockId; 275 + 276 + let result = ""; 277 + let remaining = tid; 278 + for (let i = 0; i < 13; i++) { 279 + result = TID_CHARS[Number(remaining & 31n)] + result; 280 + remaining >>= 5n; 281 + } 282 + 283 + return result; 284 + }
+21
web/src/lib/worker.ts
··· 1 + // Shared crypto worker singleton. 2 + // One Comlink-wrapped WASM worker for the entire app. 3 + 4 + import { wrap, type Remote } from "comlink"; 5 + import type { CryptoApi } from "@/workers/crypto.worker"; 6 + 7 + let instance: Remote<CryptoApi> | null = null; 8 + 9 + export function getCryptoWorker(): Remote<CryptoApi> { 10 + if (!instance) { 11 + const raw = new Worker( 12 + new URL("../workers/crypto.worker.ts", import.meta.url), 13 + { type: "module" }, 14 + ); 15 + raw.addEventListener("error", (e) => { 16 + console.error("[worker] error:", e.message, e.filename, e.lineno); 17 + }); 18 + instance = wrap<CryptoApi>(raw); 19 + } 20 + return instance; 21 + }
+4
web/src/main.tsx
··· 3 3 import { createRouter, RouterProvider } from "@tanstack/react-router"; 4 4 import { routeTree } from "./routeTree.gen"; 5 5 import "./index.css"; 6 + import { getCryptoWorker } from "@/lib/worker"; 7 + 8 + console.debug("[opake] app starting"); 9 + getCryptoWorker(); // warm up WASM worker early 6 10 7 11 const router = createRouter({ routeTree }); 8 12
+93 -8
web/src/routeTree.gen.ts
··· 13 13 import { Route as CabinetRouteImport } from './routes/cabinet' 14 14 import { Route as IndexRouteImport } from './routes/index' 15 15 import { Route as OauthCliCallbackRouteImport } from './routes/oauth.cli-callback' 16 + import { Route as OauthCallbackRouteImport } from './routes/oauth.callback' 17 + import { Route as CabinetDevicesIndexRouteImport } from './routes/cabinet.devices.index' 18 + import { Route as CabinetDevicesPairRouteImport } from './routes/cabinet.devices.pair' 16 19 17 20 const LoginRoute = LoginRouteImport.update({ 18 21 id: '/login', ··· 34 37 path: '/oauth/cli-callback', 35 38 getParentRoute: () => rootRouteImport, 36 39 } as any) 40 + const OauthCallbackRoute = OauthCallbackRouteImport.update({ 41 + id: '/oauth/callback', 42 + path: '/oauth/callback', 43 + getParentRoute: () => rootRouteImport, 44 + } as any) 45 + const CabinetDevicesIndexRoute = CabinetDevicesIndexRouteImport.update({ 46 + id: '/devices/', 47 + path: '/devices/', 48 + getParentRoute: () => CabinetRoute, 49 + } as any) 50 + const CabinetDevicesPairRoute = CabinetDevicesPairRouteImport.update({ 51 + id: '/devices/pair', 52 + path: '/devices/pair', 53 + getParentRoute: () => CabinetRoute, 54 + } as any) 37 55 38 56 export interface FileRoutesByFullPath { 39 57 '/': typeof IndexRoute 40 - '/cabinet': typeof CabinetRoute 58 + '/cabinet': typeof CabinetRouteWithChildren 41 59 '/login': typeof LoginRoute 60 + '/oauth/callback': typeof OauthCallbackRoute 42 61 '/oauth/cli-callback': typeof OauthCliCallbackRoute 62 + '/cabinet/devices/pair': typeof CabinetDevicesPairRoute 63 + '/cabinet/devices/': typeof CabinetDevicesIndexRoute 43 64 } 44 65 export interface FileRoutesByTo { 45 66 '/': typeof IndexRoute 46 - '/cabinet': typeof CabinetRoute 67 + '/cabinet': typeof CabinetRouteWithChildren 47 68 '/login': typeof LoginRoute 69 + '/oauth/callback': typeof OauthCallbackRoute 48 70 '/oauth/cli-callback': typeof OauthCliCallbackRoute 71 + '/cabinet/devices/pair': typeof CabinetDevicesPairRoute 72 + '/cabinet/devices': typeof CabinetDevicesIndexRoute 49 73 } 50 74 export interface FileRoutesById { 51 75 __root__: typeof rootRouteImport 52 76 '/': typeof IndexRoute 53 - '/cabinet': typeof CabinetRoute 77 + '/cabinet': typeof CabinetRouteWithChildren 54 78 '/login': typeof LoginRoute 79 + '/oauth/callback': typeof OauthCallbackRoute 55 80 '/oauth/cli-callback': typeof OauthCliCallbackRoute 81 + '/cabinet/devices/pair': typeof CabinetDevicesPairRoute 82 + '/cabinet/devices/': typeof CabinetDevicesIndexRoute 56 83 } 57 84 export interface FileRouteTypes { 58 85 fileRoutesByFullPath: FileRoutesByFullPath 59 - fullPaths: '/' | '/cabinet' | '/login' | '/oauth/cli-callback' 86 + fullPaths: 87 + | '/' 88 + | '/cabinet' 89 + | '/login' 90 + | '/oauth/callback' 91 + | '/oauth/cli-callback' 92 + | '/cabinet/devices/pair' 93 + | '/cabinet/devices/' 60 94 fileRoutesByTo: FileRoutesByTo 61 - to: '/' | '/cabinet' | '/login' | '/oauth/cli-callback' 62 - id: '__root__' | '/' | '/cabinet' | '/login' | '/oauth/cli-callback' 95 + to: 96 + | '/' 97 + | '/cabinet' 98 + | '/login' 99 + | '/oauth/callback' 100 + | '/oauth/cli-callback' 101 + | '/cabinet/devices/pair' 102 + | '/cabinet/devices' 103 + id: 104 + | '__root__' 105 + | '/' 106 + | '/cabinet' 107 + | '/login' 108 + | '/oauth/callback' 109 + | '/oauth/cli-callback' 110 + | '/cabinet/devices/pair' 111 + | '/cabinet/devices/' 63 112 fileRoutesById: FileRoutesById 64 113 } 65 114 export interface RootRouteChildren { 66 115 IndexRoute: typeof IndexRoute 67 - CabinetRoute: typeof CabinetRoute 116 + CabinetRoute: typeof CabinetRouteWithChildren 68 117 LoginRoute: typeof LoginRoute 118 + OauthCallbackRoute: typeof OauthCallbackRoute 69 119 OauthCliCallbackRoute: typeof OauthCliCallbackRoute 70 120 } 71 121 ··· 99 149 preLoaderRoute: typeof OauthCliCallbackRouteImport 100 150 parentRoute: typeof rootRouteImport 101 151 } 152 + '/oauth/callback': { 153 + id: '/oauth/callback' 154 + path: '/oauth/callback' 155 + fullPath: '/oauth/callback' 156 + preLoaderRoute: typeof OauthCallbackRouteImport 157 + parentRoute: typeof rootRouteImport 158 + } 159 + '/cabinet/devices/': { 160 + id: '/cabinet/devices/' 161 + path: '/devices' 162 + fullPath: '/cabinet/devices/' 163 + preLoaderRoute: typeof CabinetDevicesIndexRouteImport 164 + parentRoute: typeof CabinetRoute 165 + } 166 + '/cabinet/devices/pair': { 167 + id: '/cabinet/devices/pair' 168 + path: '/devices/pair' 169 + fullPath: '/cabinet/devices/pair' 170 + preLoaderRoute: typeof CabinetDevicesPairRouteImport 171 + parentRoute: typeof CabinetRoute 172 + } 102 173 } 103 174 } 104 175 176 + interface CabinetRouteChildren { 177 + CabinetDevicesPairRoute: typeof CabinetDevicesPairRoute 178 + CabinetDevicesIndexRoute: typeof CabinetDevicesIndexRoute 179 + } 180 + 181 + const CabinetRouteChildren: CabinetRouteChildren = { 182 + CabinetDevicesPairRoute: CabinetDevicesPairRoute, 183 + CabinetDevicesIndexRoute: CabinetDevicesIndexRoute, 184 + } 185 + 186 + const CabinetRouteWithChildren = 187 + CabinetRoute._addFileChildren(CabinetRouteChildren) 188 + 105 189 const rootRouteChildren: RootRouteChildren = { 106 190 IndexRoute: IndexRoute, 107 - CabinetRoute: CabinetRoute, 191 + CabinetRoute: CabinetRouteWithChildren, 108 192 LoginRoute: LoginRoute, 193 + OauthCallbackRoute: OauthCallbackRoute, 109 194 OauthCliCallbackRoute: OauthCliCallbackRoute, 110 195 } 111 196 export const routeTree = rootRouteImport
+109
web/src/routes/cabinet.devices.index.tsx
··· 1 + import { createFileRoute, redirect, Link } from "@tanstack/react-router"; 2 + import { OpakeLogo } from "@/components/OpakeLogo"; 3 + import { useAuthStore } from "@/stores/auth"; 4 + import { ArrowsLeftRight, Key } from "@phosphor-icons/react"; 5 + 6 + function DevicesPage() { 7 + const phase = useAuthStore((s) => s.phase); 8 + 9 + if (phase === "ready") { 10 + return <ActiveIdentityView />; 11 + } 12 + 13 + return <IdentityRequiredView />; 14 + } 15 + 16 + /** User already has identity on this device — show device management. */ 17 + function ActiveIdentityView() { 18 + return ( 19 + <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 20 + <div className="flex w-full max-w-lg flex-col items-center gap-8 px-6 py-12"> 21 + <OpakeLogo size="lg" /> 22 + 23 + <div className="text-center"> 24 + <h1 className="text-2xl font-semibold text-base-content"> 25 + Device identity active 26 + </h1> 27 + <p className="mt-2 text-sm text-base-content/60"> 28 + This device has an encryption identity. You can approve pairing 29 + requests from other devices. 30 + </p> 31 + </div> 32 + 33 + <Link 34 + to="/cabinet/devices/pair" 35 + className="btn btn-neutral w-full max-w-xs" 36 + > 37 + <ArrowsLeftRight size={20} aria-hidden="true" /> 38 + Manage pairing 39 + </Link> 40 + 41 + <Link to="/cabinet" className="text-sm text-base-content/50 hover:text-base-content/70"> 42 + Back to cabinet 43 + </Link> 44 + </div> 45 + </div> 46 + ); 47 + } 48 + 49 + /** User needs to get their identity onto this device. */ 50 + function IdentityRequiredView() { 51 + return ( 52 + <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 53 + <div className="flex w-full max-w-lg flex-col items-center gap-8 px-6 py-12"> 54 + <OpakeLogo size="lg" /> 55 + 56 + <div className="text-center"> 57 + <h1 className="text-2xl font-semibold text-base-content"> 58 + Set up encryption 59 + </h1> 60 + <p className="mt-2 text-sm text-base-content/60"> 61 + Your account has an encryption identity on another device. 62 + Transfer it to use Opake here. 63 + </p> 64 + </div> 65 + 66 + <div className="grid w-full max-w-md gap-4 sm:grid-cols-2"> 67 + <Link 68 + to="/cabinet/devices/pair" 69 + className="card card-bordered bg-base-100 p-5 transition-colors hover:border-primary/40" 70 + > 71 + <ArrowsLeftRight size={24} className="mb-3 text-primary" aria-hidden="true" /> 72 + <h2 className="font-medium text-base-content"> 73 + Pair with existing device 74 + </h2> 75 + <p className="mt-1 text-caption text-base-content/60"> 76 + Transfer your encryption identity from another device. 77 + </p> 78 + </Link> 79 + 80 + <div 81 + className="card card-bordered bg-base-100 p-5 opacity-50" 82 + aria-disabled="true" 83 + > 84 + <Key size={24} className="mb-3 text-base-content/40" aria-hidden="true" /> 85 + <h2 className="font-medium text-base-content"> 86 + Recover from seed phrase 87 + </h2> 88 + <p className="mt-1 text-caption text-base-content/60"> 89 + Restore your identity from your backup phrase. 90 + </p> 91 + <span className="mt-2 inline-block text-xs text-base-content/40"> 92 + Coming soon 93 + </span> 94 + </div> 95 + </div> 96 + </div> 97 + </div> 98 + ); 99 + } 100 + 101 + export const Route = createFileRoute("/cabinet/devices/")({ 102 + beforeLoad: () => { 103 + const state = useAuthStore.getState(); 104 + if (state.phase !== "ready" && state.phase !== "awaiting_identity") { 105 + throw redirect({ to: "/login" }); 106 + } 107 + }, 108 + component: DevicesPage, 109 + });
+410
web/src/routes/cabinet.devices.pair.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 + import { OpakeLogo } from "@/components/OpakeLogo"; 4 + import { useAuthStore } from "@/stores/auth"; 5 + import { getCryptoWorker } from "@/lib/worker"; 6 + import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 7 + import { formatFingerprint } from "@/lib/encoding"; 8 + import { rkeyFromUri } from "@/lib/encoding"; 9 + import { 10 + createPairRequest, 11 + listPairRequests, 12 + pollForPairResponse, 13 + receivePairResponse, 14 + approvePairRequest, 15 + cleanupPairRecords, 16 + type PendingPairRequest, 17 + } from "@/lib/pairing"; 18 + import { CheckCircle, Warning } from "@phosphor-icons/react"; 19 + 20 + const POLL_INTERVAL_MS = 3000; 21 + 22 + const storage = new IndexedDbStorage(); 23 + 24 + // --------------------------------------------------------------------------- 25 + // Route 26 + // --------------------------------------------------------------------------- 27 + 28 + export const Route = createFileRoute("/cabinet/devices/pair")({ 29 + beforeLoad: () => { 30 + const state = useAuthStore.getState(); 31 + if (state.phase !== "ready" && state.phase !== "awaiting_identity") { 32 + throw redirect({ to: "/login" }); 33 + } 34 + }, 35 + component: PairPage, 36 + }); 37 + 38 + // --------------------------------------------------------------------------- 39 + // Page component — dispatches to request or approve mode 40 + // --------------------------------------------------------------------------- 41 + 42 + function PairPage() { 43 + const phase = useAuthStore((s) => s.phase); 44 + const [mode, setMode] = useState<"loading" | "request" | "approve">("loading"); 45 + 46 + useEffect(() => { 47 + if (phase === "awaiting_identity") { 48 + setMode("request"); 49 + return; 50 + } 51 + 52 + // phase === "ready" — check if we have a local identity 53 + const state = useAuthStore.getState(); 54 + if (state.phase !== "ready") return; 55 + 56 + storage 57 + .loadIdentity(state.did) 58 + .then(() => setMode("approve")) 59 + .catch(() => setMode("request")); 60 + }, [phase]); 61 + 62 + if (mode === "loading") { 63 + return ( 64 + <PageShell> 65 + <span className="loading loading-spinner loading-lg text-primary" /> 66 + </PageShell> 67 + ); 68 + } 69 + 70 + if (mode === "request") { 71 + return <RequestMode />; 72 + } 73 + 74 + return <ApproveMode />; 75 + } 76 + 77 + // --------------------------------------------------------------------------- 78 + // Request mode — new device requesting identity 79 + // --------------------------------------------------------------------------- 80 + 81 + type RequestState = 82 + | { step: "generating" } 83 + | { step: "waiting"; fingerprint: string; requestUri: string } 84 + | { step: "receiving" } 85 + | { step: "success" } 86 + | { step: "error"; message: string }; 87 + 88 + function RequestMode() { 89 + const navigate = useNavigate(); 90 + const [state, setState] = useState<RequestState>({ step: "generating" }); 91 + const ephemeralPrivKeyRef = useRef<Uint8Array | null>(null); 92 + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); 93 + 94 + const cleanup = useCallback(() => { 95 + if (pollRef.current) { 96 + clearInterval(pollRef.current); 97 + pollRef.current = null; 98 + } 99 + }, []); 100 + 101 + useEffect(() => { 102 + let cancelled = false; 103 + 104 + async function init() { 105 + const authState = useAuthStore.getState(); 106 + if (authState.phase !== "awaiting_identity" && authState.phase !== "ready") return; 107 + 108 + const { did, pdsUrl } = authState; 109 + const worker = getCryptoWorker(); 110 + const session = await storage.loadSession(did); 111 + 112 + try { 113 + // Generate ephemeral keypair 114 + const ephemeral = await worker.generateEphemeralKeypair(); 115 + ephemeralPrivKeyRef.current = ephemeral.privateKey; 116 + 117 + // Create pair request on PDS 118 + const requestUri = await createPairRequest(pdsUrl, did, ephemeral.publicKey, session); 119 + 120 + if (cancelled) return; 121 + 122 + const fingerprint = formatFingerprint(ephemeral.publicKey); 123 + const requestRkey = rkeyFromUri(requestUri); 124 + setState({ step: "waiting", fingerprint, requestUri }); 125 + 126 + // Poll for response 127 + pollRef.current = setInterval(async () => { 128 + try { 129 + const response = await pollForPairResponse(pdsUrl, did, requestRkey, session); 130 + if (!response || cancelled) return; 131 + 132 + cleanup(); 133 + setState({ step: "receiving" }); 134 + 135 + const identity = await receivePairResponse( 136 + response, 137 + ephemeralPrivKeyRef.current!, 138 + worker, 139 + ); 140 + 141 + await storage.saveIdentity(did, identity); 142 + 143 + // Clean up PDS records (best-effort) 144 + await cleanupPairRecords(pdsUrl, did, requestUri, null, session).catch(() => {}); 145 + 146 + // Transition auth store to ready 147 + useAuthStore.setState({ 148 + phase: "ready", 149 + did, 150 + handle: authState.handle, 151 + pdsUrl, 152 + }); 153 + 154 + setState({ step: "success" }); 155 + setTimeout(() => navigate({ to: "/cabinet" }), 1500); 156 + } catch (err) { 157 + cleanup(); 158 + setState({ 159 + step: "error", 160 + message: err instanceof Error ? err.message : String(err), 161 + }); 162 + } 163 + }, POLL_INTERVAL_MS); 164 + } catch (err) { 165 + if (cancelled) return; 166 + setState({ 167 + step: "error", 168 + message: err instanceof Error ? err.message : String(err), 169 + }); 170 + } 171 + } 172 + 173 + init(); 174 + return () => { 175 + cancelled = true; 176 + cleanup(); 177 + }; 178 + }, [cleanup, navigate]); 179 + 180 + return ( 181 + <PageShell> 182 + {state.step === "generating" && ( 183 + <div className="flex flex-col items-center gap-4"> 184 + <span className="loading loading-spinner loading-lg text-primary" /> 185 + <p className="text-sm text-base-content/60">Generating keypair…</p> 186 + </div> 187 + )} 188 + 189 + {state.step === "waiting" && ( 190 + <div className="flex flex-col items-center gap-6 text-center"> 191 + <h1 className="text-2xl font-semibold text-base-content"> 192 + Pair this device 193 + </h1> 194 + <p className="text-sm text-base-content/60"> 195 + Approve this request from your existing device. Verify the fingerprint matches. 196 + </p> 197 + 198 + <div className="rounded-lg bg-base-100 px-6 py-4 font-mono text-lg tracking-wider text-primary"> 199 + {state.fingerprint} 200 + </div> 201 + 202 + <div className="flex items-center gap-2 text-sm text-base-content/50"> 203 + <span className="loading loading-spinner loading-xs" /> 204 + Waiting for approval… 205 + </div> 206 + </div> 207 + )} 208 + 209 + {state.step === "receiving" && ( 210 + <div className="flex flex-col items-center gap-4"> 211 + <span className="loading loading-spinner loading-lg text-primary" /> 212 + <p className="text-sm text-base-content/60">Receiving identity…</p> 213 + </div> 214 + )} 215 + 216 + {state.step === "success" && ( 217 + <div className="flex flex-col items-center gap-4"> 218 + <CheckCircle size={48} className="text-success" weight="fill" /> 219 + <p className="text-lg font-medium text-base-content">Device paired</p> 220 + <p className="text-sm text-base-content/60">Redirecting to cabinet…</p> 221 + </div> 222 + )} 223 + 224 + {state.step === "error" && <ErrorView message={state.message} />} 225 + </PageShell> 226 + ); 227 + } 228 + 229 + // --------------------------------------------------------------------------- 230 + // Approve mode — existing device approving a request 231 + // --------------------------------------------------------------------------- 232 + 233 + type ApproveState = 234 + | { step: "loading" } 235 + | { step: "empty" } 236 + | { step: "selecting"; requests: PendingPairRequest[] } 237 + | { step: "approving" } 238 + | { step: "success" } 239 + | { step: "error"; message: string }; 240 + 241 + function ApproveMode() { 242 + const [state, setState] = useState<ApproveState>({ step: "loading" }); 243 + 244 + const loadRequests = useCallback(async () => { 245 + const authState = useAuthStore.getState(); 246 + if (authState.phase !== "ready") return; 247 + 248 + const { did, pdsUrl } = authState; 249 + const session = await storage.loadSession(did); 250 + 251 + try { 252 + const requests = await listPairRequests(pdsUrl, did, session); 253 + if (requests.length === 0) { 254 + setState({ step: "empty" }); 255 + } else { 256 + setState({ step: "selecting", requests }); 257 + } 258 + } catch (err) { 259 + setState({ 260 + step: "error", 261 + message: err instanceof Error ? err.message : String(err), 262 + }); 263 + } 264 + }, []); 265 + 266 + useEffect(() => { 267 + loadRequests(); 268 + }, [loadRequests]); 269 + 270 + const handleApprove = useCallback(async (request: PendingPairRequest) => { 271 + setState({ step: "approving" }); 272 + 273 + const authState = useAuthStore.getState(); 274 + if (authState.phase !== "ready") return; 275 + 276 + const { did, pdsUrl } = authState; 277 + const worker = getCryptoWorker(); 278 + 279 + try { 280 + const session = await storage.loadSession(did); 281 + const identity = await storage.loadIdentity(did); 282 + 283 + await approvePairRequest( 284 + pdsUrl, 285 + did, 286 + request.uri, 287 + request.ephemeralKey, 288 + identity, 289 + session, 290 + worker, 291 + ); 292 + 293 + setState({ step: "success" }); 294 + } catch (err) { 295 + setState({ 296 + step: "error", 297 + message: err instanceof Error ? err.message : String(err), 298 + }); 299 + } 300 + }, []); 301 + 302 + return ( 303 + <PageShell> 304 + {state.step === "loading" && ( 305 + <div className="flex flex-col items-center gap-4"> 306 + <span className="loading loading-spinner loading-lg text-primary" /> 307 + <p className="text-sm text-base-content/60">Loading pair requests…</p> 308 + </div> 309 + )} 310 + 311 + {state.step === "empty" && ( 312 + <div className="flex flex-col items-center gap-6 text-center"> 313 + <h1 className="text-2xl font-semibold text-base-content"> 314 + No pending requests 315 + </h1> 316 + <p className="text-sm text-base-content/60"> 317 + Start a pairing request from your new device first, then come back here to approve it. 318 + </p> 319 + <button onClick={loadRequests} className="btn btn-neutral btn-sm"> 320 + Refresh 321 + </button> 322 + </div> 323 + )} 324 + 325 + {state.step === "selecting" && ( 326 + <div className="flex flex-col items-center gap-6"> 327 + <h1 className="text-2xl font-semibold text-base-content"> 328 + Approve a device 329 + </h1> 330 + <p className="text-sm text-base-content/60"> 331 + Verify the fingerprint matches what your new device shows. 332 + </p> 333 + 334 + <div className="flex w-full max-w-sm flex-col gap-3"> 335 + {state.requests.map((req) => ( 336 + <div 337 + key={req.uri} 338 + className="card card-bordered bg-base-100 p-4" 339 + > 340 + <div className="mb-2 font-mono text-sm tracking-wider text-primary"> 341 + {req.fingerprint} 342 + </div> 343 + <div className="mb-3 text-xs text-base-content/50"> 344 + {new Date(req.createdAt).toLocaleString()} 345 + </div> 346 + <button 347 + onClick={() => handleApprove(req)} 348 + className="btn btn-neutral btn-sm w-full" 349 + > 350 + Approve 351 + </button> 352 + </div> 353 + ))} 354 + </div> 355 + </div> 356 + )} 357 + 358 + {state.step === "approving" && ( 359 + <div className="flex flex-col items-center gap-4"> 360 + <span className="loading loading-spinner loading-lg text-primary" /> 361 + <p className="text-sm text-base-content/60">Encrypting and sending identity…</p> 362 + </div> 363 + )} 364 + 365 + {state.step === "success" && ( 366 + <div className="flex flex-col items-center gap-4"> 367 + <CheckCircle size={48} className="text-success" weight="fill" /> 368 + <p className="text-lg font-medium text-base-content">Approved</p> 369 + <p className="text-sm text-base-content/60"> 370 + The other device should receive your identity shortly. 371 + </p> 372 + </div> 373 + )} 374 + 375 + {state.step === "error" && <ErrorView message={state.message} />} 376 + </PageShell> 377 + ); 378 + } 379 + 380 + // --------------------------------------------------------------------------- 381 + // Shared components 382 + // --------------------------------------------------------------------------- 383 + 384 + function PageShell({ children }: { children: React.ReactNode }) { 385 + return ( 386 + <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 387 + <div className="flex w-full max-w-md flex-col items-center gap-8 px-6 py-12"> 388 + <OpakeLogo size="lg" /> 389 + {children} 390 + </div> 391 + </div> 392 + ); 393 + } 394 + 395 + function ErrorView({ message }: { message: string }) { 396 + return ( 397 + <div className="flex flex-col items-center gap-6 text-center"> 398 + <Warning size={48} className="text-error" weight="fill" /> 399 + <div className="flex flex-col gap-2"> 400 + <h1 className="text-2xl font-semibold text-base-content"> 401 + Pairing failed 402 + </h1> 403 + <p className="text-base-content/60">{message}</p> 404 + </div> 405 + <a href="/cabinet/devices" className="btn btn-neutral btn-sm"> 406 + Try again 407 + </a> 408 + </div> 409 + ); 410 + }
+17 -3
web/src/routes/cabinet.tsx
··· 1 1 import { useState } from "react"; 2 - import { createFileRoute, redirect } from "@tanstack/react-router"; 2 + import { createFileRoute, redirect, Outlet, useMatch } from "@tanstack/react-router"; 3 3 import { Sidebar } from "@/components/cabinet/Sidebar"; 4 4 import { TopBar } from "@/components/cabinet/TopBar"; 5 5 import { PanelStack } from "@/components/cabinet/PanelStack"; ··· 9 9 Panel, 10 10 SectionType, 11 11 } from "@/components/cabinet/types"; 12 + 13 + function CabinetLayout() { 14 + // If a child route matched (e.g. /cabinet/devices), render it instead of the cabinet UI 15 + const devicesMatch = useMatch({ from: "/cabinet/devices/", shouldThrow: false }); 16 + const pairMatch = useMatch({ from: "/cabinet/devices/pair", shouldThrow: false }); 17 + 18 + if (devicesMatch || pairMatch) { 19 + return <Outlet />; 20 + } 21 + 22 + return <CabinetPage />; 23 + } 12 24 13 25 function CabinetPage() { 14 26 const [panels, setPanels] = useState<Panel[]>([ ··· 96 108 export const Route = createFileRoute("/cabinet")({ 97 109 beforeLoad: () => { 98 110 const state = useAuthStore.getState(); 99 - if (state.phase !== "ready") throw redirect({ to: "/login" }); 111 + if (state.phase !== "ready" && state.phase !== "awaiting_identity") { 112 + throw redirect({ to: "/login" }); 113 + } 100 114 }, 101 - component: CabinetPage, 115 + component: CabinetLayout, 102 116 });
+98
web/src/routes/oauth.callback.tsx
··· 1 + import { useEffect, useRef } from "react"; 2 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 + import { OpakeLogo } from "@/components/OpakeLogo"; 4 + import { useAuthStore } from "@/stores/auth"; 5 + 6 + function OAuthCallbackPage() { 7 + const navigate = useNavigate(); 8 + const phase = useAuthStore((s) => s.phase); 9 + const completeLogin = useAuthStore((s) => s.completeLogin); 10 + const errorMessage = phase === "error" ? (useAuthStore.getState() as { message: string }).message : null; 11 + 12 + const hasStartedRef = useRef(false); 13 + 14 + useEffect(() => { 15 + if (hasStartedRef.current) return; 16 + hasStartedRef.current = true; 17 + 18 + const params = new URLSearchParams(window.location.search); 19 + const code = params.get("code"); 20 + const state = params.get("state"); 21 + 22 + // Strip query params from URL immediately 23 + window.history.replaceState({}, "", window.location.pathname); 24 + 25 + if (!code || !state) { 26 + useAuthStore.setState({ 27 + phase: "error", 28 + message: "Missing authorization code or state parameter.", 29 + }); 30 + return; 31 + } 32 + 33 + completeLogin(code, state); 34 + }, [completeLogin]); 35 + 36 + useEffect(() => { 37 + if (phase === "ready") { 38 + navigate({ to: "/cabinet" }); 39 + } else if (phase === "awaiting_identity") { 40 + navigate({ to: "/cabinet/devices" }); 41 + } 42 + }, [phase, navigate]); 43 + 44 + return ( 45 + <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 46 + <div className="flex w-full max-w-md flex-col items-center gap-8 px-6 py-12"> 47 + <OpakeLogo size="lg" /> 48 + 49 + {(phase === "authenticating" || phase === "initializing" || phase === "unauthenticated") && ( 50 + <div className="flex flex-col items-center gap-4"> 51 + <span className="loading loading-spinner loading-lg text-primary" /> 52 + <p className="text-sm text-base-content/60">Completing login…</p> 53 + </div> 54 + )} 55 + 56 + {phase === "error" && ( 57 + <div className="flex flex-col items-center gap-6 text-center"> 58 + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-error/20"> 59 + <svg 60 + className="h-8 w-8 text-error" 61 + fill="none" 62 + viewBox="0 0 24 24" 63 + stroke="currentColor" 64 + strokeWidth={2} 65 + aria-hidden="true" 66 + > 67 + <path 68 + strokeLinecap="round" 69 + strokeLinejoin="round" 70 + d="M6 18L18 6M6 6l12 12" 71 + /> 72 + </svg> 73 + </div> 74 + 75 + <div className="flex flex-col gap-2"> 76 + <h1 className="text-2xl font-semibold text-base-content"> 77 + Login failed 78 + </h1> 79 + <p className="text-base-content/60">{errorMessage}</p> 80 + </div> 81 + 82 + <a href="/login" className="btn btn-neutral btn-sm mt-2"> 83 + Try again 84 + </a> 85 + </div> 86 + )} 87 + </div> 88 + </div> 89 + ); 90 + } 91 + 92 + export const Route = createFileRoute("/oauth/callback")({ 93 + beforeLoad: () => { 94 + const state = useAuthStore.getState(); 95 + if (state.phase === "ready") throw redirect({ to: "/cabinet" }); 96 + }, 97 + component: OAuthCallbackPage, 98 + });
+89 -32
web/src/stores/auth.ts
··· 1 1 // Auth store — real OAuth 2.0 + DPoP flow via Zustand. 2 2 // 3 3 // State machine: initializing → unauthenticated ↔ authenticating → ready 4 + // ↘ awaiting_identity 4 5 // ↘ error 5 6 6 7 import { create } from "zustand"; 7 - import { wrap, type Remote } from "comlink"; 8 - import type { CryptoApi } from "@/workers/crypto.worker"; 9 8 import type { OAuthSession, Config } from "@/lib/storage-types"; 10 9 import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 10 + import type { Remote } from "comlink"; 11 + import type { CryptoApi } from "@/workers/crypto.worker"; 12 + import { getCryptoWorker } from "@/lib/worker"; 13 + import { authenticatedXrpc } from "@/lib/api"; 11 14 import { 12 15 resolveHandleToPds, 13 16 discoverAuthorizationServer, ··· 32 35 | { phase: "unauthenticated" } 33 36 | { phase: "authenticating" } 34 37 | { phase: "ready"; did: string; handle: string; pdsUrl: string } 38 + | { phase: "awaiting_identity"; did: string; handle: string; pdsUrl: string } 35 39 | { phase: "error"; message: string }; 36 40 37 41 interface AuthActions { ··· 49 53 50 54 const storage = new IndexedDbStorage(); 51 55 52 - let workerInstance: Remote<CryptoApi> | null = null; 56 + // --------------------------------------------------------------------------- 57 + // Helpers 58 + // --------------------------------------------------------------------------- 53 59 54 - function getWorker(): Remote<CryptoApi> { 55 - if (!workerInstance) { 56 - const raw = new Worker( 57 - new URL("../workers/crypto.worker.ts", import.meta.url), 58 - { type: "module" }, 60 + /** Check if publicKey/self already exists on the PDS (i.e. another device published it). */ 61 + async function checkExistingPublicKey( 62 + pdsUrl: string, 63 + did: string, 64 + session: OAuthSession, 65 + _worker: Remote<CryptoApi>, 66 + ): Promise<boolean> { 67 + try { 68 + await authenticatedXrpc( 69 + { 70 + pdsUrl, 71 + lexicon: `com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.opake.cloud.publicKey&rkey=self`, 72 + method: "GET", 73 + }, 74 + session, 59 75 ); 60 - workerInstance = wrap<CryptoApi>(raw); 76 + return true; 77 + } catch (error) { 78 + console.warn("[auth] publicKey/self lookup failed (treating as new account):", error); 79 + return false; 61 80 } 62 - return workerInstance; 63 81 } 64 82 65 83 // --------------------------------------------------------------------------- ··· 86 104 87 105 // Verify session exists 88 106 await storage.loadSession(did); 89 - set({ phase: "ready", did, handle: account.handle, pdsUrl: account.pdsUrl }); 107 + 108 + // Check if identity exists locally 109 + const hasIdentity = await storage 110 + .loadIdentity(did) 111 + .then(() => true) 112 + .catch(() => false); 113 + 114 + if (hasIdentity) { 115 + set({ phase: "ready", did, handle: account.handle, pdsUrl: account.pdsUrl }); 116 + } else { 117 + set({ phase: "awaiting_identity", did, handle: account.handle, pdsUrl: account.pdsUrl }); 118 + } 90 119 } catch { 91 120 set({ phase: "unauthenticated" }); 92 121 } ··· 94 123 95 124 startLogin: async (handle: string) => { 96 125 set({ phase: "authenticating" }); 97 - const worker = getWorker(); 126 + const worker = getCryptoWorker(); 98 127 99 128 try { 100 129 // Resolve handle → PDS ··· 147 176 ); 148 177 window.location.href = authUrl; 149 178 } catch (error) { 179 + console.error("[auth] startLogin failed:", error); 150 180 const message = error instanceof Error ? error.message : String(error); 151 181 set({ phase: "error", message }); 152 182 } 153 183 }, 154 184 155 185 completeLogin: async (code: string, callbackState: string) => { 186 + console.debug("[auth] completeLogin called, current phase:", get().phase); 156 187 if (get().phase === "ready") return; 157 188 set({ phase: "authenticating" }); 158 - const worker = getWorker(); 189 + const worker = getCryptoWorker(); 159 190 160 191 try { 161 192 const pending = loadPendingState(); 193 + console.debug("[auth] pending state:", pending ? "loaded" : "missing"); 162 194 if (!pending) throw new Error("No pending OAuth state — start login again"); 163 195 164 196 // CSRF verification ··· 167 199 throw new Error("OAuth state mismatch — possible CSRF attack"); 168 200 } 169 201 202 + console.debug("[auth] CSRF ok, exchanging code"); 170 203 const redirectUri = buildRedirectUri(); 171 204 172 205 // Exchange code for tokens ··· 181 214 worker, 182 215 ); 183 216 217 + console.debug("[auth] token exchange done, sub:", tokenResponse.sub); 184 218 const did = tokenResponse.sub; 185 219 if (!did) throw new Error("Token response missing `sub` claim"); 186 220 ··· 202 236 clientId: pending.clientId, 203 237 }; 204 238 205 - // Generate identity keypair 206 - const identity = await worker.generateIdentity(did); 207 - 208 - // Publish public key to PDS 209 - await publishPublicKey( 239 + console.debug("[auth] checking existing publicKey/self"); 240 + // Check if this DID already has a publicKey/self record on the PDS. 241 + // If so, another device owns the identity — don't overwrite it. 242 + const hasExistingKey = await checkExistingPublicKey( 210 243 pending.pdsUrl, 211 244 did, 212 - identity.publicKey, 213 - identity.verifyKey, 214 - session.accessToken, 215 - session.dpopKey, 216 - session.dpopNonce, 245 + session, 217 246 worker, 218 247 ); 219 248 220 - // Persist everything to IndexedDB 249 + // Persist config + session regardless 221 250 const config: Config = await storage.loadConfig().catch(() => ({ 222 251 defaultDid: null, 223 252 accounts: {}, ··· 228 257 229 258 await storage.saveConfig(config); 230 259 await storage.saveSession(did, session); 231 - await storage.saveIdentity(did, identity); 232 260 233 261 clearPendingState(); 234 262 235 - set({ 236 - phase: "ready", 237 - did, 238 - handle: pending.handle, 239 - pdsUrl: pending.pdsUrl, 240 - }); 263 + console.debug("[auth] hasExistingKey:", hasExistingKey); 264 + 265 + if (hasExistingKey) { 266 + // Identity exists on PDS but not locally — user needs to pair 267 + set({ 268 + phase: "awaiting_identity", 269 + did, 270 + handle: pending.handle, 271 + pdsUrl: pending.pdsUrl, 272 + }); 273 + } else { 274 + // Fresh account — generate identity and publish key 275 + const identity = await worker.generateIdentity(did); 276 + 277 + await publishPublicKey( 278 + pending.pdsUrl, 279 + did, 280 + identity.publicKey, 281 + identity.verifyKey, 282 + session.accessToken, 283 + session.dpopKey, 284 + session.dpopNonce, 285 + worker, 286 + ); 287 + 288 + await storage.saveIdentity(did, identity); 289 + 290 + set({ 291 + phase: "ready", 292 + did, 293 + handle: pending.handle, 294 + pdsUrl: pending.pdsUrl, 295 + }); 296 + } 241 297 } catch (error) { 298 + console.error("[auth] completeLogin failed:", error); 242 299 clearPendingState(); 243 300 const message = error instanceof Error ? error.message : String(error); 244 301 set({ phase: "error", message }); ··· 247 304 248 305 logout: async () => { 249 306 const current = get(); 250 - if (current.phase === "ready") { 307 + if (current.phase === "ready" || current.phase === "awaiting_identity") { 251 308 try { 252 309 await storage.removeAccount(current.did); 253 310 } catch {
+2
web/src/workers/crypto.worker.ts
··· 17 17 import type { EncryptedPayload, WrappedKey, DpopKeyPair, PkceChallenge, EphemeralKeypair } from "@/lib/crypto-types"; 18 18 import type { Identity } from "@/lib/storage-types"; 19 19 20 + console.debug("[worker] initializing WASM"); 20 21 await init(); 22 + console.debug("[worker] ready, binding check:", bindingCheck()); 21 23 22 24 const cryptoApi = { 23 25 ping(): string {