diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index 0c04b443..d5d73103 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -874,13 +874,21 @@ class FolkMapViewer extends HTMLElement { const script = document.createElement("script"); script.src = MAPLIBRE_JS; script.onload = () => resolve(); - script.onerror = reject; + script.onerror = () => reject(new Error("Failed to load MapLibre GL")); document.head.appendChild(script); }); } private async initMapView() { - await this.loadMapLibre(); + try { + await this.loadMapLibre(); + } catch { + const container = this.shadow.getElementById("map-container"); + if (container) { + container.innerHTML = `
Map unavailable — check your connection and try reloading.
`; + } + return; + } const container = this.shadow.getElementById("map-container"); if (!container || !(window as any).maplibregl) return; diff --git a/server/shell.ts b/server/shell.ts index 28a82a6d..c321b1ef 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -9,8 +9,9 @@ import { resolve } from "node:path"; import type { ModuleInfo, SubPageInfo, OnboardingAction } from "../shared/module"; import { getDocumentData } from "./community-store"; -// ── Browser compatibility polyfills (inline, runs before ES modules) ── -const COMPAT_POLYFILLS = ``; +// ── Browser compatibility gate + polyfills (inline, runs before ES modules) ── +const COMPAT_GATE = ``; +const COMPAT_POLYFILLS = ``; // ── Dynamic per-module favicon (inline, runs after body parse) ── // Badge map mirrors MODULE_BADGES from rstack-app-switcher.ts — kept in sync manually. @@ -267,6 +268,7 @@ export function renderShell(opts: ShellOptions): string { + ${COMPAT_GATE} ${COMPAT_POLYFILLS} @@ -1749,6 +1751,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string { ${faviconScript(moduleId)} ${escapeHtml(title)} + ${COMPAT_GATE} ${COMPAT_POLYFILLS} @@ -2463,6 +2466,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string { ${escapeHtml(mod.name)} — rSpace + ${COMPAT_GATE} ${COMPAT_POLYFILLS} @@ -2801,6 +2805,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string { ${escapeHtml(subPage.title)} — ${escapeHtml(mod.name)} | rSpace + ${COMPAT_GATE} ${COMPAT_POLYFILLS} diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index e28d0c75..933b03fc 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -291,6 +291,21 @@ export async function migrateSpaceMemberDid(oldDid: string, newDid: string): Pro return result.count; } +// ============================================================================ +// PASSPHRASE SALT OPERATIONS +// ============================================================================ + +/** Store a passphrase salt for a user (non-PRF browsers like Firefox) */ +export async function setPassphraseSalt(userId: string, salt: string): Promise { + await sql`UPDATE users SET passphrase_salt = ${salt}, updated_at = NOW() WHERE id = ${userId}`; +} + +/** Get the passphrase salt for a user (null if user has PRF support) */ +export async function getPassphraseSalt(userId: string): Promise { + const [row] = await sql`SELECT passphrase_salt FROM users WHERE id = ${userId}`; + return row?.passphrase_salt || null; +} + // ============================================================================ // RECOVERY TOKEN OPERATIONS // ============================================================================ diff --git a/src/encryptid/key-derivation.ts b/src/encryptid/key-derivation.ts index 5a4a721b..a1c615dc 100644 --- a/src/encryptid/key-derivation.ts +++ b/src/encryptid/key-derivation.ts @@ -331,6 +331,27 @@ export class EncryptIDKeyManager { return result; } + /** + * Export 32 bytes of raw key material for use with DocBridge.initFromKey(). + * Derives via HKDF with a unique info string so it doesn't collide with other derived keys. + */ + async getKeyMaterial(): Promise { + if (!this.masterKey) { + throw new Error('Key manager not initialized'); + } + const encoder = new TextEncoder(); + return crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: encoder.encode('encryptid-doc-bridge-key-v1'), + info: encoder.encode('DocBridge-256'), + }, + this.masterKey, + 256 + ); + } + /** * Clear all keys from memory */ diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index 9f26899b..fdf807b8 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -30,6 +30,9 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS up_key_manager_address TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS up_chain_id INTEGER; ALTER TABLE users ADD COLUMN IF NOT EXISTS up_deployed_at TIMESTAMPTZ; +-- Passphrase-based encryption salt (for non-PRF browsers like Firefox) +ALTER TABLE users ADD COLUMN IF NOT EXISTS passphrase_salt TEXT; + CREATE TABLE IF NOT EXISTS credentials ( credential_id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index b76ff71c..bd376737 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -135,6 +135,8 @@ import { getUserUPAddress, setUserUPAddress, getUserByUPAddress, + setPassphraseSalt, + getPassphraseSalt, updateUserDid, migrateSpaceMemberDid, setUserLoggedOutAt, @@ -591,7 +593,11 @@ app.post('/api/register/start', async (c) => { * Complete registration - verify and store credential */ app.post('/api/register/complete', async (c) => { - const { challenge, credential, userId, username, email, clientDid, eoaAddress } = await c.req.json(); + const body = await c.req.json().catch(() => null); + if (!body) { + return c.json({ error: 'Invalid request body' }, 400); + } + const { challenge, credential, userId, username, email, clientDid, eoaAddress } = body; if (!userId || !credential || !username) { return c.json({ error: 'Missing required fields: userId, credential, username' }, 400); @@ -781,53 +787,109 @@ app.post('/api/auth/start', async (c) => { * Complete authentication - verify and issue token */ app.post('/api/auth/complete', async (c) => { - const { challenge, credential } = await c.req.json(); + try { + const body = await c.req.json().catch(() => null); + if (!body?.challenge || !body?.credential?.credentialId) { + return c.json({ error: 'Missing required fields: challenge, credential' }, 400); + } + const { challenge, credential } = body; - // Verify challenge from database - const challengeRecord = await getChallenge(challenge); - if (!challengeRecord || challengeRecord.type !== 'authentication') { - return c.json({ error: 'Invalid challenge' }, 400); - } - if (Date.now() > challengeRecord.expiresAt) { + // Verify challenge from database + const challengeRecord = await getChallenge(challenge); + if (!challengeRecord || challengeRecord.type !== 'authentication') { + return c.json({ error: 'Invalid challenge' }, 400); + } + if (Date.now() > challengeRecord.expiresAt) { + await deleteChallenge(challenge); + return c.json({ error: 'Challenge expired' }, 400); + } await deleteChallenge(challenge); - return c.json({ error: 'Challenge expired' }, 400); + + // Look up credential from database + const storedCredential = await getCredential(credential.credentialId); + if (!storedCredential) { + return c.json({ error: 'Unknown credential' }, 401); + } + + // In production, verify signature against stored public key + // For now, we trust the client-side verification + + // Update counter and last used in database + await updateCredentialUsage(credential.credentialId, storedCredential.counter + 1); + + console.log('EncryptID: Authentication successful', { + credentialId: credential.credentialId.slice(0, 20) + '...', + userId: storedCredential.userId.slice(0, 20) + '...', + }); + + // Generate session token + const token = await generateSessionToken( + storedCredential.userId, + storedCredential.username + ); + + // Read stored DID from database + const authUser = await getUserById(storedCredential.userId); + const authDid = authUser?.did || `did:key:${storedCredential.userId.slice(0, 32)}`; + + return c.json({ + success: true, + userId: storedCredential.userId, + username: storedCredential.username, + token, + did: authDid, + }); + } catch (error: any) { + console.error('EncryptID: auth/complete error:', error); + return c.json({ error: 'Authentication failed' }, 500); } - await deleteChallenge(challenge); +}); - // Look up credential from database - const storedCredential = await getCredential(credential.credentialId); - if (!storedCredential) { - return c.json({ error: 'Unknown credential' }, 400); +// ============================================================================ +// PASSPHRASE SALT ENDPOINT +// ============================================================================ + +/** + * Get passphrase salt for the authenticated user. + * Used by non-PRF browsers (Firefox) to derive encryption keys from a passphrase. + */ +app.get('/api/account/passphrase-salt', async (c) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401); } + try { + const payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256'); + const userId = payload.sub as string; + const salt = await getPassphraseSalt(userId); + if (!salt) return c.json({ error: 'No passphrase salt — user has PRF support' }, 404); + return c.json({ salt }); + } catch { + return c.json({ error: 'Invalid token' }, 401); + } +}); - // In production, verify signature against stored public key - // For now, we trust the client-side verification - - // Update counter and last used in database - await updateCredentialUsage(credential.credentialId, storedCredential.counter + 1); - - console.log('EncryptID: Authentication successful', { - credentialId: credential.credentialId.slice(0, 20) + '...', - userId: storedCredential.userId.slice(0, 20) + '...', - }); - - // Generate session token - const token = await generateSessionToken( - storedCredential.userId, - storedCredential.username - ); - - // Read stored DID from database - const authUser = await getUserById(storedCredential.userId); - const authDid = authUser?.did || `did:key:${storedCredential.userId.slice(0, 32)}`; - - return c.json({ - success: true, - userId: storedCredential.userId, - username: storedCredential.username, - token, - did: authDid, - }); +/** + * Store passphrase salt for the authenticated user (called once during registration + * when the browser doesn't support PRF). + */ +app.post('/api/account/passphrase-salt', async (c) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401); + } + try { + const payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256'); + const userId = payload.sub as string; + const { salt } = await c.req.json(); + if (!salt || typeof salt !== 'string') { + return c.json({ error: 'Missing salt' }, 400); + } + await setPassphraseSalt(userId, salt); + return c.json({ success: true }); + } catch { + return c.json({ error: 'Invalid token' }, 401); + } }); // ============================================================================ @@ -7338,9 +7400,18 @@ app.get('/', (c) => { - + @@ -7816,8 +7887,8 @@ app.get('/', (c) => { document.getElementById('step5-email').classList.remove('pending'); } - // Skip steps 3-4 if no PRF - goToStep(prfSupported ? 3 : 5); + // PRF: show key derivation step; non-PRF: show passphrase setup step (3) then skip wallet (4) + goToStep(prfSupported ? 3 : 3); } catch (err) { showError(err.message); btn.textContent = 'Create Passkey'; @@ -7825,6 +7896,43 @@ app.get('/', (c) => { } }; + window.handleStep3Continue = async () => { + if (regData.prfSupported) { + goToStep(4); + return; + } + // Non-PRF: validate and save passphrase + const pp = document.getElementById('encryption-passphrase')?.value || ''; + const ppConfirm = document.getElementById('encryption-passphrase-confirm')?.value || ''; + if (!pp || pp.length < 8) { + showError('Passphrase must be at least 8 characters.'); + return; + } + if (pp !== ppConfirm) { + showError('Passphrases do not match.'); + return; + } + hideMessages(); + try { + // Generate salt, store server-side + const salt = crypto.getRandomValues(new Uint8Array(32)); + const saltB64 = bufferToBase64url(salt); + await fetch('/api/account/passphrase-salt', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + regData.token }, + body: JSON.stringify({ salt: saltB64 }), + }); + // Init encryption from passphrase + const km = getKeyManager(); + await km.initFromPassphrase(pp, salt); + showSuccess('Encryption passphrase set successfully.'); + } catch (e) { + console.warn('Passphrase setup failed:', e); + } + // Skip wallet step (no PRF = no EOA wallet) + goToStep(5); + }; + window.finishRegistration = () => { const { token, username, did, prfSupported, clientDid, eoaAddress } = regData; showProfile(token, username, did, { prfSupported, clientDid, eoaAddress }); @@ -7986,18 +8094,45 @@ app.get('/', (c) => { } } + // Non-PRF: check for passphrase salt and prompt for passphrase + if (!prfResults?.first) { + try { + const saltRes = await fetch('/api/account/passphrase-salt', { + headers: { 'Authorization': 'Bearer ' + data.token }, + }); + if (saltRes.ok) { + const { salt } = await saltRes.json(); + const pp = prompt('Enter your encryption passphrase:'); + if (pp) { + const km = getKeyManager(); + await km.initFromPassphrase(pp, new Uint8Array(base64urlToBuffer(salt))); + } + } + } catch (e) { + console.warn('EncryptID: Passphrase salt fetch failed', e); + } + } + // Remember this account for next login if (data.username) addKnownAccount(data.username, data.username); showProfile(data.token, data.username, data.did); } catch (err) { if (err.name === 'NotAllowedError') { - showError('No passkey found on this device. Use email to sign in.'); + showError('Passkey dismissed. Try again, or sign in with email.'); // Auto-show email fallback const fb = document.getElementById('email-fallback-section'); const link = document.getElementById('show-email-fallback'); if (fb) fb.style.display = 'block'; if (link) link.style.display = 'none'; + } else if (err.name === 'NotSupportedError') { + showError('Passkeys are not supported on this browser. Use email to sign in.'); + const fb = document.getElementById('email-fallback-section'); + const link = document.getElementById('show-email-fallback'); + if (fb) fb.style.display = 'block'; + if (link) link.style.display = 'none'; + } else if (err.name === 'AbortError') { + // Silent — conditional UI abort } else { showError(err.message || 'Authentication failed'); } diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts index 19f584bf..8ef13d48 100644 --- a/src/encryptid/ui/login-button.ts +++ b/src/encryptid/ui/login-button.ts @@ -9,11 +9,12 @@ import { registerPasskey, authenticatePasskey, base64urlToBuffer, + bufferToBase64url, detectCapabilities, startConditionalUI, WebAuthnCapabilities, } from '../webauthn'; -import { getKeyManager } from '../key-derivation'; +import { getKeyManager, EncryptIDKeyManager } from '../key-derivation'; import { getSessionManager, AuthLevel } from '../session'; import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge'; import { getVaultManager, resetVaultManager } from '../vault'; @@ -376,6 +377,49 @@ const styles = ` text-align: center; padding: 8px; } + + .error-msg { + font-size: 0.85rem; + color: #f87171; + background: rgba(248,113,113,0.1); + border: 1px solid rgba(248,113,113,0.25); + border-radius: var(--eid-radius); + padding: 10px 14px; + margin-bottom: 8px; + line-height: 1.45; + } + + .compat-note { + font-size: 0.85rem; + color: #fbbf24; + background: rgba(251,191,36,0.1); + border: 1px solid rgba(251,191,36,0.25); + border-radius: var(--eid-radius); + padding: 10px 14px; + margin-bottom: 8px; + line-height: 1.45; + text-align: center; + } + + .passphrase-overlay { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + } + + .passphrase-modal { + background: var(--eid-bg, #0f172a); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 12px; + padding: 24px; + max-width: 380px; + width: 90vw; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + } `; // ============================================================================ @@ -401,6 +445,9 @@ export class EncryptIDLoginButton extends HTMLElement { private showDropdown: boolean = false; private showEmailFallback: boolean = false; private emailSent: boolean = false; + private errorMessage: string = ''; + private showPassphrasePrompt: boolean = false; + private passphraseResolver: ((passphrase: string) => void) | null = null; private capabilities: WebAuthnCapabilities | null = null; // Configurable attributes @@ -482,6 +529,7 @@ export class EncryptIDLoginButton extends HTMLElement { ${isLoggedIn && this.showUser ? this.renderUserInfo(did!, authLevel) : this.renderLoginButton()} ${this.showDropdown ? this.renderDropdown() : ''} + ${this.showPassphrasePrompt ? this.renderPassphraseModal() : ''} `; this.attachEventListeners(); @@ -500,6 +548,15 @@ export class EncryptIDLoginButton extends HTMLElement { `; } + // WebAuthn unavailable — show email-only login + if (this.capabilities?.webauthn === false) { + return this.renderEmailOnlyFallback(sizeClass, variantClass); + } + + const errorDiv = this.errorMessage + ? `
${escapeHtml(this.errorMessage)}
` + : ''; + const accounts = getKnownAccounts(); // No known accounts → passkey-first button + email fallback @@ -513,6 +570,7 @@ export class EncryptIDLoginButton extends HTMLElement { return `
+ ${errorDiv} +
+ This passphrase encrypts your documents. It's never sent to any server. +
+
+ `; + } + + private renderEmailOnlyFallback(sizeClass: string, variantClass: string): string { + if (this.emailSent) { + return ` +
+
Passkeys are not available in this browser.
+ +
`; + } + return ` +
+
Passkeys are not available in this browser.
+ +
`; + } + private attachEventListeners() { const session = getSessionManager(); const isLoggedIn = session.isValid(); @@ -653,6 +761,25 @@ export class EncryptIDLoginButton extends HTMLElement { this.handleLogin(); }); } + + // Passphrase modal submit + this.shadow.querySelector('[data-action="submit-passphrase"]')?.addEventListener('click', () => { + const input = this.shadow.querySelector('[data-passphrase-input]') as HTMLInputElement; + const passphrase = input?.value || ''; + if (passphrase && this.passphraseResolver) { + this.showPassphrasePrompt = false; + this.passphraseResolver(passphrase); + this.passphraseResolver = null; + } + }); + const ppInput = this.shadow.querySelector('[data-passphrase-input]') as HTMLInputElement; + ppInput?.addEventListener('keydown', (e) => { + if ((e as KeyboardEvent).key === 'Enter') { + this.shadow.querySelector('[data-action="submit-passphrase"]')?.click(); + } + }); + // Auto-focus passphrase input when modal is shown + ppInput?.focus(); } /** @@ -684,6 +811,7 @@ export class EncryptIDLoginButton extends HTMLElement { private async handleLogin(username?: string) { if (this.loading) return; + this.errorMessage = ''; this.loading = true; this.render(); @@ -716,7 +844,7 @@ export class EncryptIDLoginButton extends HTMLElement { // Get derived keys const keys = await keyManager.getKeys(); - // Create session + // Create session first (need JWT for salt endpoint) const sessionManager = getSessionManager(); await sessionManager.createSession(result, keys.did, { encrypt: true, @@ -724,6 +852,40 @@ export class EncryptIDLoginButton extends HTMLElement { wallet: !!keys.eoaAddress, }); + // Non-PRF path: passphrase-based encryption for Firefox and other browsers + if (!result.prfOutput) { + const session = sessionManager.getSession(); + if (session?.token) { + try { + const saltRes = await fetch(`${ENCRYPTID_AUTH}/api/account/passphrase-salt`, { + headers: { 'Authorization': `Bearer ${session.token}` }, + }); + if (saltRes.ok) { + const { salt } = await saltRes.json(); + // Prompt for passphrase + const passphrase = await this.promptPassphrase(); + if (passphrase) { + const saltBytes = base64urlToBuffer(salt); + await keyManager.initFromPassphrase(passphrase, new Uint8Array(saltBytes)); + // Get raw key material for DocBridge + const rawKey = await keyManager.getKeyMaterial(); + await getDocBridge().initFromKey(new Uint8Array(rawKey)); + // Load vault + const docCrypto = getDocBridge().getDocCrypto(); + if (docCrypto) { + getVaultManager(docCrypto).load() + .then(() => syncWalletsOnLogin(docCrypto)) + .catch(err => console.warn('Vault load failed:', err)); + } + } + } + // 404 = user doesn't have a passphrase salt yet (new registration, will be set up) + } catch (e) { + console.warn('Passphrase salt fetch failed:', e); + } + } + } + // Remember this account for next time // Use the username from JWT claims if available, otherwise the one selected const session = sessionManager.getSession(); @@ -741,17 +903,33 @@ export class EncryptIDLoginButton extends HTMLElement { })); } catch (error: any) { - // If no credential found, auto-show email fallback + dispatch event - if (error.name === 'NotAllowedError' || error.message?.includes('No credential')) { - this.showEmailFallback = true; - this.dispatchEvent(new CustomEvent('login-register-needed', { - bubbles: true, - })); - } else { - this.dispatchEvent(new CustomEvent('login-error', { - detail: { error: error.message }, - bubbles: true, - })); + const name = error?.name || ''; + switch (name) { + case 'AbortError': + // Conditional UI abort — silent, expected + break; + case 'NotAllowedError': + this.errorMessage = 'Passkey dismissed. Try again, or sign in with email.'; + this.showEmailFallback = true; + this.dispatchEvent(new CustomEvent('login-register-needed', { bubbles: true })); + break; + case 'NotSupportedError': + this.errorMessage = 'Passkeys are not supported on this browser. Sign in with email.'; + this.showEmailFallback = true; + break; + case 'InvalidStateError': + this.errorMessage = 'This passkey is already registered. Try signing in instead.'; + break; + case 'SecurityError': + this.errorMessage = 'Security error: this page cannot use your passkey.'; + break; + default: + this.errorMessage = error?.message || 'Authentication failed. Please try again.'; + this.dispatchEvent(new CustomEvent('login-error', { + detail: { error: error?.message }, + bubbles: true, + })); + break; } } finally { this.loading = false; @@ -916,6 +1094,32 @@ export class EncryptIDLoginButton extends HTMLElement { // Auto-login after registration await this.handleLogin(username); + + // If PRF not supported, set up passphrase-based encryption + if (!credential.prfSupported) { + const session = getSessionManager().getSession(); + if (session?.token) { + const passphrase = await this.promptPassphrase(); + if (passphrase) { + const salt = EncryptIDKeyManager.generateSalt(); + const saltB64 = bufferToBase64url(salt); + // Store salt server-side + await fetch(`${ENCRYPTID_AUTH}/api/account/passphrase-salt`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.token}`, + }, + body: JSON.stringify({ salt: saltB64 }), + }); + // Init encryption from passphrase + const keyManager = getKeyManager(); + await keyManager.initFromPassphrase(passphrase, salt); + const rawKey = await keyManager.getKeyMaterial(); + await getDocBridge().initFromKey(new Uint8Array(rawKey)); + } + } + } } catch (error: any) { this.dispatchEvent(new CustomEvent('register-error', { detail: { error: error.message }, diff --git a/website/public/shell.css b/website/public/shell.css index 6c0b02fe..032d9e1e 100644 --- a/website/public/shell.css +++ b/website/public/shell.css @@ -651,3 +651,14 @@ body.rspace-headers-minimized .rapp-subnav { transform: none; } } + +/* ── Flex gap fallback for Safari <14.1 ── */ +@supports not (gap: 1px) { + .rstack-header__left > * + * { margin-left: 4px; } + .rstack-header__right > * + * { margin-left: 12px; } + .rstack-header__brand { gap: 0; } + .rstack-header__brand > * + * { margin-left: 10px; } + .rspace-banner > * + * { margin-left: 12px; } + .rapp-nav > * + * { margin-left: 8px; } + .rapp-nav__actions > * + * { margin-left: 8px; } +}