From 22db2f439fb4d496edbaf2cc82eb75187a44c057 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 14:50:16 -0800 Subject: [PATCH] feat: client-side encryption wiring + space scoping UI (Phase 5+6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 — EncryptID → DocCrypto bridge: - Add EncryptedDocBridge connecting WebAuthn PRF to document encryption - Add per-doc relay mode to SyncServer (encrypted spaces bypass participant mode) - Wire encryption toggle to syncServer.setRelayOnly() on PATCH /:slug/encryption - Restore relay mode for encrypted spaces on server startup - Initialize DocBridge from PRF on login, clear on sign-out (both login-button + identity) - Use bridge helpers for encrypted backup toggle in My Account Phase 6 — Space scoping UI: - Add "Modules" tab to Edit Space modal (enable/disable modules, scope toggles, encryption) - Auto-filter app switcher by space's enabledModules via renderShell() - Show "G" badge on global-scoped modules in app switcher - Show lock icon in header for encrypted spaces - Add getSpaceShellMeta() helper for auto-populating shell options Co-Authored-By: Claude Opus 4.6 --- server/index.ts | 21 ++ server/local-first/sync-server.ts | 27 ++- server/shell.ts | 29 ++- server/spaces.ts | 7 +- shared/components/rstack-app-switcher.ts | 12 ++ shared/components/rstack-identity.ts | 6 +- shared/components/rstack-space-switcher.ts | 176 +++++++++++++++++ shared/local-first/encryptid-bridge.ts | 212 +++++++++++++++++++++ src/encryptid/ui/login-button.ts | 7 + 9 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 shared/local-first/encryptid-bridge.ts diff --git a/server/index.ts b/server/index.ts index 0954bd4..b972e17 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1624,5 +1624,26 @@ const server = Bun.serve({ ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e)); loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e)); +// Restore relay mode for encrypted spaces +(async () => { + try { + const slugs = await listCommunities(); + let relayCount = 0; + for (const slug of slugs) { + await loadCommunity(slug); + const data = getDocumentData(slug); + if (data?.meta?.encrypted) { + syncServer.setRelayOnly(slug, true); + relayCount++; + } + } + if (relayCount > 0) { + console.log(`[Encryption] ${relayCount} space(s) set to relay mode (encrypted)`); + } + } catch (e) { + console.error("[Encryption] Failed to restore relay modes:", e); + } +})(); + console.log(`rSpace unified server running on http://localhost:${PORT}`); console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`); diff --git a/server/local-first/sync-server.ts b/server/local-first/sync-server.ts index d85ac1f..5d0177a 100644 --- a/server/local-first/sync-server.ts +++ b/server/local-first/sync-server.ts @@ -82,6 +82,7 @@ export class SyncServer { #docs = new Map>(); #docSubscribers = new Map>(); // docId → Set #participantMode: boolean; + #relayOnlyDocs = new Set(); // docIds forced to relay mode (encrypted spaces) #onDocChange?: (docId: string, doc: Automerge.Doc) => void; constructor(opts: SyncServerOptions = {}) { @@ -89,6 +90,30 @@ export class SyncServer { this.#onDocChange = opts.onDocChange; } + /** + * Mark a doc (or all docs matching a prefix) as relay-only. + * Relay-only docs are forwarded between peers without server reading them. + * Used for encrypted spaces where server can't decrypt content. + */ + setRelayOnly(docIdOrPrefix: string, relay: boolean): void { + if (relay) { + this.#relayOnlyDocs.add(docIdOrPrefix); + } else { + this.#relayOnlyDocs.delete(docIdOrPrefix); + } + } + + /** + * Check if a docId should use relay mode (either explicitly set or prefix-matched). + */ + isRelayOnly(docId: string): boolean { + if (this.#relayOnlyDocs.has(docId)) return true; + for (const prefix of this.#relayOnlyDocs) { + if (docId.startsWith(prefix + ':')) return true; + } + return false; + } + /** * Register a new WebSocket peer. */ @@ -263,7 +288,7 @@ export class SyncServer { const { docId, data } = msg; const syncMsg = new Uint8Array(data); - if (this.#participantMode) { + if (this.#participantMode && !this.isRelayOnly(docId)) { // Server participates: apply sync message to server's doc let doc = this.#docs.get(docId); if (!doc) { diff --git a/server/shell.ts b/server/shell.ts index 4cf6a67..9d7e149 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -6,6 +6,16 @@ */ import type { ModuleInfo } from "../shared/module"; +import { getDocumentData } from "./community-store"; + +/** Extract enabledModules and encryption status from a loaded space. */ +export function getSpaceShellMeta(spaceSlug: string): { enabledModules: string[] | null; spaceEncrypted: boolean } { + const data = getDocumentData(spaceSlug); + return { + enabledModules: data?.meta?.enabledModules ?? null, + spaceEncrypted: !!data?.meta?.encrypted, + }; +} export interface ShellOptions { /** Page */ @@ -30,6 +40,10 @@ export interface ShellOptions { head?: string; /** Space visibility level (for client-side access gate) */ spaceVisibility?: string; + /** Enabled modules for this space (null = all). Filters the app switcher. */ + enabledModules?: string[] | null; + /** Whether this space has client-side encryption enabled */ + spaceEncrypted?: boolean; } export function renderShell(opts: ShellOptions): string { @@ -47,7 +61,18 @@ export function renderShell(opts: ShellOptions): string { spaceVisibility = "public_read", } = opts; - const moduleListJSON = JSON.stringify(modules); + // Auto-populate from space data when not explicitly provided + const spaceMeta = (opts.enabledModules === undefined || opts.spaceEncrypted === undefined) + ? getSpaceShellMeta(spaceSlug) + : null; + const enabledModules = opts.enabledModules ?? spaceMeta?.enabledModules ?? null; + const spaceEncrypted = opts.spaceEncrypted ?? spaceMeta?.spaceEncrypted ?? false; + + // Filter modules by enabledModules (null = show all) + const visibleModules = enabledModules + ? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id)) + : modules; + const moduleListJSON = JSON.stringify(visibleModules); const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`; return `<!DOCTYPE html> @@ -78,7 +103,7 @@ export function renderShell(opts: ShellOptions): string { <div class="rstack-header__left"> <a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a> <rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher> - <rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher> + <rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>${spaceEncrypted ? '<span class="rstack-header__encrypted" title="End-to-end encrypted space">🔒</span>' : ''} </div> <div class="rstack-header__center"> <rstack-mi></rstack-mi> diff --git a/server/spaces.ts b/server/spaces.ts index 66adb78..aa02e20 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -1151,12 +1151,15 @@ spaces.patch("/:slug/encryption", async (c) => { const body = await c.req.json<{ encrypted: boolean; encryptionKeyId?: string }>(); setEncryption(slug, body.encrypted, body.encryptionKeyId); + // Switch SyncServer to relay mode for encrypted spaces (server can't read ciphertext) + syncServer.setRelayOnly(slug, body.encrypted); + return c.json({ ok: true, encrypted: body.encrypted, message: body.encrypted - ? "Space encryption enabled. Document will be encrypted at rest." - : "Space encryption disabled. Document will be stored in plaintext.", + ? "Space encryption enabled. Documents synced in relay mode (server cannot read content)." + : "Space encryption disabled. Documents synced in participant mode.", }); }); diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 8f3fa00..ab210ae 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -14,6 +14,7 @@ export interface AppSwitcherModule { icon: string; description: string; standaloneDomain?: string; + scoping?: { defaultScope: 'space' | 'global'; userConfigurable: boolean }; } // Pastel badge abbreviations & colors for each module @@ -196,6 +197,10 @@ export class RStackAppSwitcher extends HTMLElement { ? `${window.location.protocol}//rspace.online/${m.id}` : rspaceNavUrl(space, m.id); + const scopeBadge = m.scoping?.defaultScope === "global" + ? `<span class="scope-badge scope-global" title="Global data (shared across spaces)">G</span>` + : ""; + return ` <div class="item-row ${m.id === current ? "active" : ""}"> <a class="item" @@ -205,6 +210,7 @@ export class RStackAppSwitcher extends HTMLElement { <div class="item-text"> <span class="item-name-row"> <span class="item-name">${m.name}</span> + ${scopeBadge} <span class="item-emoji">${m.icon}</span> </span> <span class="item-desc">${m.description}</span> @@ -412,6 +418,12 @@ a.rstack-header:hover { background: rgba(255,255,255,0.05); } .item-emoji { font-size: 0.875rem; flex-shrink: 0; } .item-desc { font-size: 0.7rem; opacity: 0.5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.scope-badge { + font-size: 0.55rem; font-weight: 700; padding: 1px 4px; border-radius: 3px; + text-transform: uppercase; letter-spacing: 0.04em; line-height: 1; flex-shrink: 0; +} +.scope-global { background: rgba(139,92,246,0.2); color: #a78bfa; } + .category-header { padding: 8px 14px 4px; font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.5; diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index b2f4c0b..7781f22 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -7,6 +7,7 @@ */ import { rspaceNavUrl, getCurrentModule } from "../url-helpers"; +import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } from "../local-first/encryptid-bridge"; const SESSION_KEY = "encryptid_session"; const ENCRYPTID_URL = "https://auth.rspace.online"; @@ -497,6 +498,7 @@ export class RStackIdentity extends HTMLElement { dropdown.classList.remove("open"); if (action === "signout") { clearSession(); + resetDocBridge(); this.#stopNotifPolling(); this.#notifications = []; this.#render(); @@ -532,11 +534,11 @@ export class RStackIdentity extends HTMLElement { // Backup toggle const backupToggle = this.#shadow.getElementById("backup-toggle") as HTMLInputElement; if (backupToggle) { - backupToggle.checked = localStorage.getItem("encryptid_backup_enabled") === "true"; + backupToggle.checked = isEncryptedBackupEnabled(); backupToggle.addEventListener("change", (e) => { e.stopPropagation(); const enabled = backupToggle.checked; - localStorage.setItem("encryptid_backup_enabled", enabled ? "true" : "false"); + setEncryptedBackupEnabled(enabled); this.dispatchEvent(new CustomEvent("backup-toggle", { bubbles: true, composed: true, detail: { enabled } })); }); } diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 04fb002..f8f9318 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -397,6 +397,7 @@ export class RStackSpaceSwitcher extends HTMLElement { <h2>Edit Space</h2> <div class="tabs"> <button class="tab active" data-tab="settings">Settings</button> + <button class="tab" data-tab="modules">Modules</button> <button class="tab" data-tab="members">Members</button> <button class="tab" data-tab="invitations">Invitations</button> </div> @@ -423,6 +424,11 @@ export class RStackSpaceSwitcher extends HTMLElement { </div> </div> + <div class="tab-panel hidden" id="panel-modules"> + <div id="es-modules-list" class="modules-list"><div class="loading">Loading modules...</div></div> + <div class="status" id="es-modules-status"></div> + </div> + <div class="tab-panel hidden" id="panel-members"> <div id="es-members-list" class="member-list"><div class="loading">Loading members...</div></div> </div> @@ -449,6 +455,7 @@ export class RStackSpaceSwitcher extends HTMLElement { panel?.classList.remove("hidden"); // Lazy-load content + if ((tab as HTMLElement).dataset.tab === "modules") this.#loadModulesConfig(overlay, slug); if ((tab as HTMLElement).dataset.tab === "members") this.#loadMembers(overlay, slug); if ((tab as HTMLElement).dataset.tab === "invitations") this.#loadInvitations(overlay, slug); }); @@ -680,6 +687,140 @@ export class RStackSpaceSwitcher extends HTMLElement { } } + async #loadModulesConfig(overlay: HTMLElement, slug: string) { + const container = overlay.querySelector("#es-modules-list") as HTMLElement; + const statusEl = overlay.querySelector("#es-modules-status") as HTMLElement; + try { + const token = getAccessToken(); + const headers: Record<string, string> = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + + // Fetch module config and encryption status in parallel + const [modRes, encRes] = await Promise.all([ + fetch(`/api/spaces/${slug}/modules`, { headers }), + fetch(`/api/spaces/${slug}/encryption`, { headers }), + ]); + + if (!modRes.ok) { container.innerHTML = `<div class="loading">Failed to load modules</div>`; return; } + const modData = await modRes.json(); + const encData = encRes.ok ? await encRes.json() : { encrypted: false }; + + interface ModConfig { + id: string; name: string; icon: string; enabled: boolean; + scoping: { defaultScope: string; userConfigurable: boolean; currentScope: string }; + } + + const modules: ModConfig[] = modData.modules || []; + + let html = ` + <div class="module-encryption"> + <label class="module-row"> + <span class="module-label">🔒 End-to-end encryption</span> + <label class="toggle-switch"> + <input type="checkbox" id="es-encrypt-toggle" ${encData.encrypted ? "checked" : ""} /> + <span class="toggle-slider"></span> + </label> + </label> + <div class="module-hint">When enabled, documents are encrypted client-side. Server cannot read content.</div> + </div> + <div class="module-divider"></div> + <div class="module-section-label">Enabled Modules</div> + `; + + for (const m of modules) { + if (m.id === "rspace") continue; // core module, always enabled + html += ` + <label class="module-row"> + <span class="module-label">${m.icon} ${m.name}</span> + <label class="toggle-switch"> + <input type="checkbox" class="mod-toggle" data-mod-id="${m.id}" ${m.enabled ? "checked" : ""} /> + <span class="toggle-slider"></span> + </label> + </label>`; + + if (m.scoping.userConfigurable) { + html += ` + <div class="scope-row"> + <span class="scope-label">Data scope:</span> + <select class="scope-select" data-mod-id="${m.id}"> + <option value="space" ${m.scoping.currentScope === "space" ? "selected" : ""}>Space</option> + <option value="global" ${m.scoping.currentScope === "global" ? "selected" : ""}>Global</option> + </select> + </div>`; + } + } + + html += ` + <div class="actions" style="margin-top:1rem"> + <button class="btn btn--primary" id="es-modules-save">Save Module Config</button> + </div> + `; + + container.innerHTML = html; + + // Save handler + container.querySelector("#es-modules-save")?.addEventListener("click", async () => { + statusEl.textContent = "Saving..."; + statusEl.className = "status"; + + // Collect enabled modules + const enabledModules: string[] = ["rspace"]; + container.querySelectorAll(".mod-toggle").forEach((cb) => { + const el = cb as HTMLInputElement; + if (el.checked) enabledModules.push(el.dataset.modId!); + }); + + // Collect scope overrides + const scopeOverrides: Record<string, string> = {}; + container.querySelectorAll(".scope-select").forEach((sel) => { + const el = sel as HTMLSelectElement; + scopeOverrides[el.dataset.modId!] = el.value; + }); + + // Check encryption toggle + const encryptToggle = container.querySelector("#es-encrypt-toggle") as HTMLInputElement; + const newEncrypted = encryptToggle?.checked ?? false; + + try { + const token = getAccessToken(); + const authHeaders = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }; + + // Save modules config + const modSaveRes = await fetch(`/api/spaces/${slug}/modules`, { + method: "PATCH", + headers: authHeaders, + body: JSON.stringify({ enabledModules, scopeOverrides }), + }); + if (!modSaveRes.ok) { + const d = await modSaveRes.json(); + throw new Error(d.error || "Failed to save modules"); + } + + // Save encryption if changed + if (newEncrypted !== encData.encrypted) { + const encSaveRes = await fetch(`/api/spaces/${slug}/encryption`, { + method: "PATCH", + headers: authHeaders, + body: JSON.stringify({ encrypted: newEncrypted }), + }); + if (!encSaveRes.ok) { + const d = await encSaveRes.json(); + throw new Error(d.error || "Failed to update encryption"); + } + } + + statusEl.textContent = "Saved! Reload to apply changes."; + statusEl.className = "status success"; + } catch (err: any) { + statusEl.textContent = err.message || "Failed to save"; + statusEl.className = "status error"; + } + }); + } catch { + container.innerHTML = `<div class="loading">Failed to load module config</div>`; + } + } + #getCurrentModule(): string { return getModule(); } @@ -1000,6 +1141,41 @@ select.input { appearance: auto; } .loading { padding: 16px; text-align: center; font-size: 0.85rem; color: #94a3b8; } +/* Modules config panel */ +.modules-list { max-height: 400px; overflow-y: auto; } +.module-encryption { margin-bottom: 0.25rem; } +.module-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 0.75rem 0; } +.module-section-label { font-size: 0.72rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.5rem; } +.module-row { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 0; cursor: pointer; +} +.module-label { font-size: 0.875rem; } +.module-hint { font-size: 0.75rem; color: #64748b; margin-top: 2px; padding-left: 2px; } +.scope-row { + display: flex; align-items: center; gap: 8px; + padding: 2px 0 6px 24px; font-size: 0.8rem; color: #94a3b8; +} +.scope-label { font-size: 0.75rem; } +.scope-select { + padding: 3px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.05); color: white; font-size: 0.75rem; outline: none; +} +.toggle-switch { + position: relative; display: inline-block; width: 36px; height: 20px; +} +.toggle-switch input { opacity: 0; width: 0; height: 0; } +.toggle-slider { + position: absolute; cursor: pointer; inset: 0; + background: rgba(255,255,255,0.15); border-radius: 20px; transition: 0.2s; +} +.toggle-slider:before { + content: ""; position: absolute; height: 14px; width: 14px; + left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.2s; +} +input:checked + .toggle-slider { background: #06b6d4; } +input:checked + .toggle-slider:before { transform: translateX(16px); } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } `; diff --git a/shared/local-first/encryptid-bridge.ts b/shared/local-first/encryptid-bridge.ts new file mode 100644 index 0000000..af30acf --- /dev/null +++ b/shared/local-first/encryptid-bridge.ts @@ -0,0 +1,212 @@ +/** + * EncryptID → DocCrypto Bridge + * + * Connects the EncryptID WebAuthn PRF output to the local-first + * DocCrypto layer for client-side document encryption. + * + * Data flow: + * WebAuthn PRF output + * → DocCrypto.initFromPRF() + * → deriveSpaceKey(spaceId) + * → deriveDocKey(spaceKey, docId) + * → AES-256-GCM encrypt/decrypt + * + * Usage: + * import { EncryptedDocBridge } from './encryptid-bridge'; + * + * const bridge = new EncryptedDocBridge(); + * + * // After EncryptID authentication (PRF output available): + * await bridge.initFromAuth(authResult.prfOutput); + * + * // Pass to EncryptedDocStore for a space: + * const store = new EncryptedDocStore(spaceSlug, bridge.getDocCrypto()); + * + * // On sign-out: + * bridge.clear(); + */ + +import { DocCrypto } from './crypto'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface BridgeState { + initialized: boolean; + /** Cached space keys for quick doc key derivation */ + spaceKeys: Map<string, CryptoKey>; +} + +// ============================================================================ +// EncryptedDocBridge +// ============================================================================ + +export class EncryptedDocBridge { + #crypto: DocCrypto; + #spaceKeys = new Map<string, CryptoKey>(); + #initialized = false; + + constructor() { + this.#crypto = new DocCrypto(); + } + + /** + * Initialize from WebAuthn PRF output. + * Call this after EncryptID authentication returns prfOutput. + */ + async initFromAuth(prfOutput: ArrayBuffer): Promise<void> { + await this.#crypto.initFromPRF(prfOutput); + this.#spaceKeys.clear(); + this.#initialized = true; + } + + /** + * Initialize from raw key material (e.g., from EncryptIDKeyManager's + * encryption key or any 256-bit key). + */ + async initFromKey(key: CryptoKey | Uint8Array | ArrayBuffer): Promise<void> { + await this.#crypto.init(key); + this.#spaceKeys.clear(); + this.#initialized = true; + } + + /** + * Get the DocCrypto instance for use with EncryptedDocStore. + * Returns null if not initialized. + */ + getDocCrypto(): DocCrypto | undefined { + return this.#initialized ? this.#crypto : undefined; + } + + /** + * Pre-derive and cache a space key for faster doc key derivation. + */ + async warmSpaceKey(spaceId: string): Promise<void> { + if (!this.#initialized) return; + if (!this.#spaceKeys.has(spaceId)) { + const key = await this.#crypto.deriveSpaceKey(spaceId); + this.#spaceKeys.set(spaceId, key); + } + } + + /** + * Get a cached space key (or derive it). + */ + async getSpaceKey(spaceId: string): Promise<CryptoKey> { + if (!this.#initialized) throw new Error('Bridge not initialized'); + let key = this.#spaceKeys.get(spaceId); + if (!key) { + key = await this.#crypto.deriveSpaceKey(spaceId); + this.#spaceKeys.set(spaceId, key); + } + return key; + } + + /** + * Derive a document key directly (convenience for one-off operations). + */ + async deriveDocKey(spaceId: string, docId: string): Promise<CryptoKey> { + const spaceKey = await this.getSpaceKey(spaceId); + return this.#crypto.deriveDocKey(spaceKey, docId); + } + + /** + * Check if the bridge is ready for encryption operations. + */ + get isInitialized(): boolean { + return this.#initialized; + } + + /** + * Clear all key material from memory. Call on sign-out. + */ + clear(): void { + this.#crypto.clear(); + this.#spaceKeys.clear(); + this.#initialized = false; + } +} + +// ============================================================================ +// Singleton for app-wide access +// ============================================================================ + +let _bridge: EncryptedDocBridge | null = null; + +/** + * Get the global EncryptedDocBridge singleton. + */ +export function getDocBridge(): EncryptedDocBridge { + if (!_bridge) { + _bridge = new EncryptedDocBridge(); + } + return _bridge; +} + +/** + * Reset the global bridge (e.g., on sign-out). + */ +export function resetDocBridge(): void { + if (_bridge) { + _bridge.clear(); + _bridge = null; + } +} + +// ============================================================================ +// Helper: check if a space supports client-side encryption +// ============================================================================ + +/** + * Check localStorage for whether the user has encryption enabled for a space. + * This is set by the space encryption toggle in the UI. + */ +export function isSpaceEncryptionEnabled(spaceSlug: string): boolean { + try { + return localStorage.getItem(`rspace:${spaceSlug}:encrypted`) === 'true'; + } catch { + return false; + } +} + +/** + * Set the client-side encryption flag for a space. + */ +export function setSpaceEncryptionEnabled(spaceSlug: string, enabled: boolean): void { + try { + if (enabled) { + localStorage.setItem(`rspace:${spaceSlug}:encrypted`, 'true'); + } else { + localStorage.removeItem(`rspace:${spaceSlug}:encrypted`); + } + } catch { + // localStorage unavailable (SSR, etc.) + } +} + +/** + * Check if encrypted backup is enabled globally. + */ +export function isEncryptedBackupEnabled(): boolean { + try { + return localStorage.getItem('encryptid_backup_enabled') === 'true'; + } catch { + return false; + } +} + +/** + * Toggle encrypted backup flag. + */ +export function setEncryptedBackupEnabled(enabled: boolean): void { + try { + if (enabled) { + localStorage.setItem('encryptid_backup_enabled', 'true'); + } else { + localStorage.removeItem('encryptid_backup_enabled'); + } + } catch { + // localStorage unavailable + } +} diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts index 435a0c2..5b40f6b 100644 --- a/src/encryptid/ui/login-button.ts +++ b/src/encryptid/ui/login-button.ts @@ -14,6 +14,7 @@ import { } from '../webauthn'; import { getKeyManager } from '../key-derivation'; import { getSessionManager, AuthLevel } from '../session'; +import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge'; // ============================================================================ // STYLES @@ -408,6 +409,8 @@ export class EncryptIDLoginButton extends HTMLElement { const keyManager = getKeyManager(); if (result.prfOutput) { await keyManager.initFromPRF(result.prfOutput); + // Initialize doc encryption bridge for local-first encrypted storage + await getDocBridge().initFromAuth(result.prfOutput); } // Get derived keys @@ -502,6 +505,9 @@ export class EncryptIDLoginButton extends HTMLElement { const keyManager = getKeyManager(); keyManager.clear(); + // Clear doc encryption bridge key material + resetDocBridge(); + this.dispatchEvent(new CustomEvent('logout', { bubbles: true })); this.render(); } @@ -515,6 +521,7 @@ export class EncryptIDLoginButton extends HTMLElement { const keyManager = getKeyManager(); if (result.prfOutput) { await keyManager.initFromPRF(result.prfOutput); + await getDocBridge().initFromAuth(result.prfOutput); } const keys = await keyManager.getKeys();