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:
parent
c6cb875ba4
commit
76df746e15
|
|
@ -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 = `<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");
|
||||
if (!container || !(window as any).maplibregl) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = `<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>`;
|
||||
// ── Browser compatibility gate + polyfills (inline, runs before ES modules) ──
|
||||
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) ──
|
||||
// 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: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>
|
||||
${COMPAT_GATE}
|
||||
${COMPAT_POLYFILLS}
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<link rel="stylesheet" href="/shell.css">
|
||||
|
|
@ -1749,6 +1751,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
|
|||
${faviconScript(moduleId)}
|
||||
<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>
|
||||
${COMPAT_GATE}
|
||||
${COMPAT_POLYFILLS}
|
||||
<link rel="stylesheet" href="/theme.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">
|
||||
<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>
|
||||
${COMPAT_GATE}
|
||||
${COMPAT_POLYFILLS}
|
||||
<link rel="stylesheet" href="/theme.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">
|
||||
<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>
|
||||
${COMPAT_GATE}
|
||||
${COMPAT_POLYFILLS}
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<link rel="stylesheet" href="/shell.css">
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
</div>
|
||||
</div>
|
||||
<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 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="goToStep(4)">Continue</button>
|
||||
<button class="btn-primary" onclick="handleStep3Continue()">Continue</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Your Wallet -->
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() : ''}
|
||||
</div>
|
||||
${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
|
||||
? `<div class="error-msg">${escapeHtml(this.errorMessage)}</div>`
|
||||
: '';
|
||||
|
||||
const accounts = getKnownAccounts();
|
||||
|
||||
// No known accounts → passkey-first button + email fallback
|
||||
|
|
@ -513,6 +570,7 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
|
||||
return `
|
||||
<div class="username-form">
|
||||
${errorDiv}
|
||||
<button class="login-btn ${sizeClass} ${variantClass}" data-action="passkey-login">
|
||||
${PASSKEY_ICON}
|
||||
<span>${this.label}</span>
|
||||
|
|
@ -527,6 +585,7 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
if (accounts.length === 1) {
|
||||
const name = escapeHtml(accounts[0].displayName || accounts[0].username);
|
||||
return `
|
||||
${errorDiv}
|
||||
<button class="login-btn ${sizeClass} ${variantClass}" data-username="${escapeHtml(accounts[0].username)}">
|
||||
${PASSKEY_ICON}
|
||||
<span>Sign in as ${name}</span>
|
||||
|
|
@ -548,6 +607,7 @@ export class EncryptIDLoginButton extends HTMLElement {
|
|||
}).join('');
|
||||
|
||||
return `
|
||||
${errorDiv}
|
||||
<div class="account-list">
|
||||
${items}
|
||||
</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() {
|
||||
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<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) {
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue