feat: cross-browser/cross-device compatibility sweep

Browser compat gate (WASM/ESM check + structuredClone polyfill),
structured WebAuthn error handling with user-facing messages,
email-only login mode when passkeys unavailable, Firefox passphrase
fallback for document encryption (salt storage, modal UI, key
derivation bridge), CSS flex gap fallbacks for Safari <14.1,
MapLibre CDN load error handling, and server-side auth error
improvements with proper HTTP status codes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 19:57:59 -04:00
parent c6cb875ba4
commit 76df746e15
8 changed files with 466 additions and 64 deletions

View File

@ -874,13 +874,21 @@ class FolkMapViewer extends HTMLElement {
const script = document.createElement("script"); const script = document.createElement("script");
script.src = MAPLIBRE_JS; script.src = MAPLIBRE_JS;
script.onload = () => resolve(); script.onload = () => resolve();
script.onerror = reject; script.onerror = () => reject(new Error("Failed to load MapLibre GL"));
document.head.appendChild(script); document.head.appendChild(script);
}); });
} }
private async initMapView() { private async initMapView() {
try {
await this.loadMapLibre(); await this.loadMapLibre();
} catch {
const container = this.shadow.getElementById("map-container");
if (container) {
container.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--rs-text-secondary,#94a3b8);font-size:0.9rem;text-align:center;padding:2rem">Map unavailable — check your connection and try reloading.</div>`;
}
return;
}
const container = this.shadow.getElementById("map-container"); const container = this.shadow.getElementById("map-container");
if (!container || !(window as any).maplibregl) return; if (!container || !(window as any).maplibregl) return;

View File

@ -9,8 +9,9 @@ import { resolve } from "node:path";
import type { ModuleInfo, SubPageInfo, OnboardingAction } from "../shared/module"; import type { ModuleInfo, SubPageInfo, OnboardingAction } from "../shared/module";
import { getDocumentData } from "./community-store"; import { getDocumentData } from "./community-store";
// ── Browser compatibility polyfills (inline, runs before ES modules) ── // ── Browser compatibility gate + polyfills (inline, runs before ES modules) ──
const COMPAT_POLYFILLS = `<script>(function(){if(typeof AbortSignal.timeout!=="function"){AbortSignal.timeout=function(ms){var c=new AbortController();setTimeout(function(){c.abort(new DOMException("The operation was aborted due to timeout","TimeoutError"))},ms);return c.signal}}if(typeof crypto!=="undefined"&&typeof crypto.randomUUID!=="function"){crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&0x0f)|0x40;b[8]=(b[8]&0x3f)|0x80;var h=Array.from(b,function(x){return x.toString(16).padStart(2,"0")}).join("");return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20)}}})()</script>`; const COMPAT_GATE = `<script>(function(){if(typeof WebAssembly!=="object"||!("noModule" in HTMLScriptElement.prototype)){document.documentElement.innerHTML='<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f172a;color:#e2e8f0;font-family:system-ui,sans-serif"><div style="max-width:480px;text-align:center;padding:32px"><h1 style="font-size:1.5rem;margin-bottom:16px">Browser Not Supported</h1><p style="color:#94a3b8;line-height:1.6;margin-bottom:24px">rSpace requires a modern browser with WebAssembly and ES Module support.</p><p style="color:#94a3b8;line-height:1.6">Please use <strong>Chrome 92+</strong>, <strong>Edge 92+</strong>, <strong>Safari 15+</strong>, or <strong>Firefox 89+</strong>.</p></div></body>';document.querySelectorAll("script[type=module]").forEach(function(s){s.remove()});return}})()</script>`;
const COMPAT_POLYFILLS = `<script>(function(){if(typeof structuredClone!=="function"){window.structuredClone=function(v){return JSON.parse(JSON.stringify(v))}}if(typeof AbortSignal.timeout!=="function"){AbortSignal.timeout=function(ms){var c=new AbortController();setTimeout(function(){c.abort(new DOMException("The operation was aborted due to timeout","TimeoutError"))},ms);return c.signal}}if(typeof crypto!=="undefined"&&typeof crypto.randomUUID!=="function"){crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&0x0f)|0x40;b[8]=(b[8]&0x3f)|0x80;var h=Array.from(b,function(x){return x.toString(16).padStart(2,"0")}).join("");return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20)}}})()</script>`;
// ── Dynamic per-module favicon (inline, runs after body parse) ── // ── Dynamic per-module favicon (inline, runs after body parse) ──
// Badge map mirrors MODULE_BADGES from rstack-app-switcher.ts — kept in sync manually. // Badge map mirrors MODULE_BADGES from rstack-app-switcher.ts — kept in sync manually.
@ -267,6 +268,7 @@ export function renderShell(opts: ShellOptions): string {
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://rspace.online/og-image.png"> <meta name="twitter:image" content="https://rspace.online/og-image.png">
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script> <script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
${COMPAT_GATE}
${COMPAT_POLYFILLS} ${COMPAT_POLYFILLS}
<link rel="stylesheet" href="/theme.css"> <link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css"> <link rel="stylesheet" href="/shell.css">
@ -1749,6 +1751,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
${faviconScript(moduleId)} ${faviconScript(moduleId)}
<title>${escapeHtml(title)}</title> <title>${escapeHtml(title)}</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script> <script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
${COMPAT_GATE}
${COMPAT_POLYFILLS} ${COMPAT_POLYFILLS}
<link rel="stylesheet" href="/theme.css"> <link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css"> <link rel="stylesheet" href="/shell.css">
@ -2463,6 +2466,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(mod.name)} rSpace</title> <title>${escapeHtml(mod.name)} rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script> <script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
${COMPAT_GATE}
${COMPAT_POLYFILLS} ${COMPAT_POLYFILLS}
<link rel="stylesheet" href="/theme.css"> <link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css"> <link rel="stylesheet" href="/shell.css">
@ -2801,6 +2805,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>${escapeHtml(subPage.title)} ${escapeHtml(mod.name)} | rSpace</title> <title>${escapeHtml(subPage.title)} ${escapeHtml(mod.name)} | rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script> <script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t);var b=localStorage.getItem('canvas-bg')||'grid';document.documentElement.setAttribute('data-canvas-bg',b)})()</script>
${COMPAT_GATE}
${COMPAT_POLYFILLS} ${COMPAT_POLYFILLS}
<link rel="stylesheet" href="/theme.css"> <link rel="stylesheet" href="/theme.css">
<link rel="stylesheet" href="/shell.css"> <link rel="stylesheet" href="/shell.css">

View File

@ -291,6 +291,21 @@ export async function migrateSpaceMemberDid(oldDid: string, newDid: string): Pro
return result.count; 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<void> {
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<string | null> {
const [row] = await sql`SELECT passphrase_salt FROM users WHERE id = ${userId}`;
return row?.passphrase_salt || null;
}
// ============================================================================ // ============================================================================
// RECOVERY TOKEN OPERATIONS // RECOVERY TOKEN OPERATIONS
// ============================================================================ // ============================================================================

View File

@ -331,6 +331,27 @@ export class EncryptIDKeyManager {
return result; 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<ArrayBuffer> {
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 * Clear all keys from memory
*/ */

View File

@ -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_chain_id INTEGER;
ALTER TABLE users ADD COLUMN IF NOT EXISTS up_deployed_at TIMESTAMPTZ; 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 ( CREATE TABLE IF NOT EXISTS credentials (
credential_id TEXT PRIMARY KEY, credential_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,

View File

@ -135,6 +135,8 @@ import {
getUserUPAddress, getUserUPAddress,
setUserUPAddress, setUserUPAddress,
getUserByUPAddress, getUserByUPAddress,
setPassphraseSalt,
getPassphraseSalt,
updateUserDid, updateUserDid,
migrateSpaceMemberDid, migrateSpaceMemberDid,
setUserLoggedOutAt, setUserLoggedOutAt,
@ -591,7 +593,11 @@ app.post('/api/register/start', async (c) => {
* Complete registration - verify and store credential * Complete registration - verify and store credential
*/ */
app.post('/api/register/complete', async (c) => { 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) { if (!userId || !credential || !username) {
return c.json({ error: 'Missing required fields: userId, credential, username' }, 400); return c.json({ error: 'Missing required fields: userId, credential, username' }, 400);
@ -781,7 +787,12 @@ app.post('/api/auth/start', async (c) => {
* Complete authentication - verify and issue token * Complete authentication - verify and issue token
*/ */
app.post('/api/auth/complete', async (c) => { 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 // Verify challenge from database
const challengeRecord = await getChallenge(challenge); const challengeRecord = await getChallenge(challenge);
@ -797,7 +808,7 @@ app.post('/api/auth/complete', async (c) => {
// Look up credential from database // Look up credential from database
const storedCredential = await getCredential(credential.credentialId); const storedCredential = await getCredential(credential.credentialId);
if (!storedCredential) { if (!storedCredential) {
return c.json({ error: 'Unknown credential' }, 400); return c.json({ error: 'Unknown credential' }, 401);
} }
// In production, verify signature against stored public key // In production, verify signature against stored public key
@ -828,6 +839,57 @@ app.post('/api/auth/complete', async (c) => {
token, token,
did: authDid, did: authDid,
}); });
} catch (error: any) {
console.error('EncryptID: auth/complete error:', error);
return c.json({ error: 'Authentication failed' }, 500);
}
});
// ============================================================================
// 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);
}
});
/**
* 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) => {
</div> </div>
</div> </div>
<div id="step3-no-prf" style="display:none"> <div id="step3-no-prf" style="display:none">
<div class="step-note">Full key derivation requires a compatible authenticator with PRF support. You can upgrade your identity later from a supported device.</div> <div class="step-note">Your browser doesn't support hardware-backed encryption. Set a passphrase to encrypt your documents.</div>
<div class="form-group" style="margin-top:1rem">
<label for="encryption-passphrase">Encryption Passphrase</label>
<input id="encryption-passphrase" type="password" placeholder="Choose a strong passphrase" autocomplete="off" />
</div> </div>
<button class="btn-primary" onclick="goToStep(4)">Continue</button> <div class="form-group">
<label for="encryption-passphrase-confirm">Confirm Passphrase</label>
<input id="encryption-passphrase-confirm" type="password" placeholder="Confirm your passphrase" autocomplete="off" />
</div>
<div style="font-size:0.75rem;color:#94a3b8;margin-top:0.5rem">This passphrase encrypts your documents locally. It is never sent to any server.</div>
</div>
<button class="btn-primary" onclick="handleStep3Continue()">Continue</button>
</div> </div>
<!-- Step 4: Your Wallet --> <!-- Step 4: Your Wallet -->
@ -7816,8 +7887,8 @@ app.get('/', (c) => {
document.getElementById('step5-email').classList.remove('pending'); document.getElementById('step5-email').classList.remove('pending');
} }
// Skip steps 3-4 if no PRF // PRF: show key derivation step; non-PRF: show passphrase setup step (3) then skip wallet (4)
goToStep(prfSupported ? 3 : 5); goToStep(prfSupported ? 3 : 3);
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
btn.textContent = 'Create Passkey'; 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 = () => { window.finishRegistration = () => {
const { token, username, did, prfSupported, clientDid, eoaAddress } = regData; const { token, username, did, prfSupported, clientDid, eoaAddress } = regData;
showProfile(token, username, did, { prfSupported, clientDid, eoaAddress }); 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 // Remember this account for next login
if (data.username) addKnownAccount(data.username, data.username); if (data.username) addKnownAccount(data.username, data.username);
showProfile(data.token, data.username, data.did); showProfile(data.token, data.username, data.did);
} catch (err) { } catch (err) {
if (err.name === 'NotAllowedError') { 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 // Auto-show email fallback
const fb = document.getElementById('email-fallback-section'); const fb = document.getElementById('email-fallback-section');
const link = document.getElementById('show-email-fallback'); const link = document.getElementById('show-email-fallback');
if (fb) fb.style.display = 'block'; if (fb) fb.style.display = 'block';
if (link) link.style.display = 'none'; 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 { } else {
showError(err.message || 'Authentication failed'); showError(err.message || 'Authentication failed');
} }

View File

@ -9,11 +9,12 @@ import {
registerPasskey, registerPasskey,
authenticatePasskey, authenticatePasskey,
base64urlToBuffer, base64urlToBuffer,
bufferToBase64url,
detectCapabilities, detectCapabilities,
startConditionalUI, startConditionalUI,
WebAuthnCapabilities, WebAuthnCapabilities,
} from '../webauthn'; } from '../webauthn';
import { getKeyManager } from '../key-derivation'; import { getKeyManager, EncryptIDKeyManager } from '../key-derivation';
import { getSessionManager, AuthLevel } from '../session'; import { getSessionManager, AuthLevel } from '../session';
import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge'; import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge';
import { getVaultManager, resetVaultManager } from '../vault'; import { getVaultManager, resetVaultManager } from '../vault';
@ -376,6 +377,49 @@ const styles = `
text-align: center; text-align: center;
padding: 8px; 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 showDropdown: boolean = false;
private showEmailFallback: boolean = false; private showEmailFallback: boolean = false;
private emailSent: 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; private capabilities: WebAuthnCapabilities | null = null;
// Configurable attributes // Configurable attributes
@ -482,6 +529,7 @@ export class EncryptIDLoginButton extends HTMLElement {
${isLoggedIn && this.showUser ? this.renderUserInfo(did!, authLevel) : this.renderLoginButton()} ${isLoggedIn && this.showUser ? this.renderUserInfo(did!, authLevel) : this.renderLoginButton()}
${this.showDropdown ? this.renderDropdown() : ''} ${this.showDropdown ? this.renderDropdown() : ''}
</div> </div>
${this.showPassphrasePrompt ? this.renderPassphraseModal() : ''}
`; `;
this.attachEventListeners(); 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
? `<div class="error-msg">${escapeHtml(this.errorMessage)}</div>`
: '';
const accounts = getKnownAccounts(); const accounts = getKnownAccounts();
// No known accounts → passkey-first button + email fallback // No known accounts → passkey-first button + email fallback
@ -513,6 +570,7 @@ export class EncryptIDLoginButton extends HTMLElement {
return ` return `
<div class="username-form"> <div class="username-form">
${errorDiv}
<button class="login-btn ${sizeClass} ${variantClass}" data-action="passkey-login"> <button class="login-btn ${sizeClass} ${variantClass}" data-action="passkey-login">
${PASSKEY_ICON} ${PASSKEY_ICON}
<span>${this.label}</span> <span>${this.label}</span>
@ -527,6 +585,7 @@ export class EncryptIDLoginButton extends HTMLElement {
if (accounts.length === 1) { if (accounts.length === 1) {
const name = escapeHtml(accounts[0].displayName || accounts[0].username); const name = escapeHtml(accounts[0].displayName || accounts[0].username);
return ` return `
${errorDiv}
<button class="login-btn ${sizeClass} ${variantClass}" data-username="${escapeHtml(accounts[0].username)}"> <button class="login-btn ${sizeClass} ${variantClass}" data-username="${escapeHtml(accounts[0].username)}">
${PASSKEY_ICON} ${PASSKEY_ICON}
<span>Sign in as ${name}</span> <span>Sign in as ${name}</span>
@ -548,6 +607,7 @@ export class EncryptIDLoginButton extends HTMLElement {
}).join(''); }).join('');
return ` return `
${errorDiv}
<div class="account-list"> <div class="account-list">
${items} ${items}
</div> </div>
@ -591,6 +651,54 @@ export class EncryptIDLoginButton extends HTMLElement {
`; `;
} }
/**
* Show passphrase modal and return the entered passphrase.
* Resolves when user submits; returns empty string if dismissed.
*/
private promptPassphrase(): Promise<string> {
return new Promise((resolve) => {
this.passphraseResolver = resolve;
this.showPassphrasePrompt = true;
this.loading = false; // allow modal interaction
this.render();
});
}
private renderPassphraseModal(): string {
return `
<div class="passphrase-overlay">
<div class="passphrase-modal">
<div class="compat-note" style="margin-bottom:12px">
Your browser doesn't support hardware encryption.<br>
Enter your encryption passphrase to unlock your documents.
</div>
<input class="username-input" type="password" placeholder="Encryption passphrase" data-passphrase-input autocomplete="off" />
<button class="login-btn small" data-action="submit-passphrase" style="width:100%;justify-content:center;margin-top:8px">Unlock</button>
<div style="font-size:0.75rem;color:var(--eid-text-secondary);text-align:center;margin-top:8px">
This passphrase encrypts your documents. It's never sent to any server.
</div>
</div>
</div>`;
}
private renderEmailOnlyFallback(sizeClass: string, variantClass: string): string {
if (this.emailSent) {
return `
<div class="username-form">
<div class="compat-note">Passkeys are not available in this browser.</div>
<div class="email-fallback-section"><div class="email-sent-msg">Login link sent! Check your inbox.</div></div>
</div>`;
}
return `
<div class="username-form">
<div class="compat-note">Passkeys are not available in this browser.</div>
<div class="email-fallback-section">
<input class="username-input" type="email" placeholder="you@example.com" data-email-input />
<button class="login-btn small ${variantClass}" data-action="send-magic-link">Sign in with email</button>
</div>
</div>`;
}
private attachEventListeners() { private attachEventListeners() {
const session = getSessionManager(); const session = getSessionManager();
const isLoggedIn = session.isValid(); const isLoggedIn = session.isValid();
@ -653,6 +761,25 @@ export class EncryptIDLoginButton extends HTMLElement {
this.handleLogin(); 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<HTMLButtonElement>('[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) { private async handleLogin(username?: string) {
if (this.loading) return; if (this.loading) return;
this.errorMessage = '';
this.loading = true; this.loading = true;
this.render(); this.render();
@ -716,7 +844,7 @@ export class EncryptIDLoginButton extends HTMLElement {
// Get derived keys // Get derived keys
const keys = await keyManager.getKeys(); const keys = await keyManager.getKeys();
// Create session // Create session first (need JWT for salt endpoint)
const sessionManager = getSessionManager(); const sessionManager = getSessionManager();
await sessionManager.createSession(result, keys.did, { await sessionManager.createSession(result, keys.did, {
encrypt: true, encrypt: true,
@ -724,6 +852,40 @@ export class EncryptIDLoginButton extends HTMLElement {
wallet: !!keys.eoaAddress, 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 // Remember this account for next time
// Use the username from JWT claims if available, otherwise the one selected // Use the username from JWT claims if available, otherwise the one selected
const session = sessionManager.getSession(); const session = sessionManager.getSession();
@ -741,17 +903,33 @@ export class EncryptIDLoginButton extends HTMLElement {
})); }));
} catch (error: any) { } catch (error: any) {
// If no credential found, auto-show email fallback + dispatch event const name = error?.name || '';
if (error.name === 'NotAllowedError' || error.message?.includes('No credential')) { 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.showEmailFallback = true;
this.dispatchEvent(new CustomEvent('login-register-needed', { this.dispatchEvent(new CustomEvent('login-register-needed', { bubbles: true }));
bubbles: true, break;
})); case 'NotSupportedError':
} else { 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', { this.dispatchEvent(new CustomEvent('login-error', {
detail: { error: error.message }, detail: { error: error?.message },
bubbles: true, bubbles: true,
})); }));
break;
} }
} finally { } finally {
this.loading = false; this.loading = false;
@ -916,6 +1094,32 @@ export class EncryptIDLoginButton extends HTMLElement {
// Auto-login after registration // Auto-login after registration
await this.handleLogin(username); 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) { } catch (error: any) {
this.dispatchEvent(new CustomEvent('register-error', { this.dispatchEvent(new CustomEvent('register-error', {
detail: { error: error.message }, detail: { error: error.message },

View File

@ -651,3 +651,14 @@ body.rspace-headers-minimized .rapp-subnav {
transform: none; 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; }
}