SPB Git

spb/zyquo-cloud-web

Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

phase3: local persistence — IndexedDB conversations, keys + AES-GCM passphrase vault, settings, export/import; 13/13 gate checks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Jul 31, 2026) parent ba33813 Browse files

12 changed files +694 −3

modified docs/PLAN.md +17 −1
@@ -74,7 +74,23 @@ live OpenAI from headless Chromium: token-by-token rendering, usage events, and
74 AbortController cancellation all verified. Playwright added (dev-only) as the 74 AbortController cancellation all verified. Playwright added (dev-only) as the
75 browser harness that Phase 7 will reuse for the full matrix. 75 browser harness that Phase 7 will reuse for the full matrix.
76 76
77−## Phase 3 — Local persistence — pending 77+## Phase 3 — Local persistence
78+
79+- [x] 3.A `storage/keys.ts` — namespaced localStorage map, get/set/remove, masked display, in-memory overlay for vault mode; `providers/testKey.ts` Test action (/models or min completion, ms latency)
80+- [x] 3.A `storage/vault.ts` — opt-in passphrase lock: PBKDF2 (310k, SHA-256) → AES-GCM; enable/unlock/lock/disable; honest framing (obfuscation-at-rest, not a secure vault)
81+- [x] 3.B `storage/db.ts` (dependency-free IndexedDB promise wrapper) + `storage/conversations.ts` — CRUD, updatedAt sorting, schema versioning + migrate(), title+message search
82+- [x] 3.C `storage/settings.ts` — theme (light default), default model, per-provider proxy URLs, UI prefs
83+- [x] 3.C `storage/backup.ts` — export (keys opt-in with warning) / import / clear-everything
84+- [x] Dev harness (`features/devHarness.ts`, DEV-only) exposing storage for browser gate scripts
85+- [x] PHASE GATE: 13/13 checks in real Chromium (scripts/gate-phase3.mjs) — reload survival, **browser-restart survival** (persistent context), export→clear→import exact restore, vault encrypt-at-rest / wrong-pass rejection / unlock / disable
86+
87+**Phase 3 summary.** All state is now local and durable: keys in
88+`zyquo.cloud.web.keys`, conversations in IndexedDB with schema versioning,
89+settings in `zyquo.cloud.web.settings`. Export/import is the backup story;
90+clear-data wipes everything. The optional passphrase lock encrypts the key map
91+with WebCrypto AES-GCM and is honestly framed. Gate ran in a real browser with
92+a persistent profile: reload AND restart survival, exact export round-trip, and
93+vault behavior all green.
78 94
79 ## Phase 4 — Design system & UI — pending 95 ## Phase 4 — Design system & UI — pending
80 96
modified eslint.config.js +4 −1
@@ -78,7 +78,10 @@ export default [
78 ...tsPlugin.configs.recommended.rules, 78 ...tsPlugin.configs.recommended.rules,
79 ...reactHooks.configs.recommended.rules, 79 ...reactHooks.configs.recommended.rules,
80 'no-unused-vars': 'off', 80 'no-unused-vars': 'off',
81− '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 81+ '@typescript-eslint/no-unused-vars': [
82+ 'error',
83+ { argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' },
84+ ],
82 'no-undef': 'off', 85 'no-undef': 'off',
83 }, 86 },
84 }, 87 },
added scripts/gate-phase3.mjs +155 −0
@@ -0,0 +1,155 @@
1+/*
2+ * gate-phase3.mjs
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Phase 3 gate: in a real browser, verify that (1) keys + conversations
9+ * survive a page reload AND a full browser restart (fresh context, same
10+ * storage dir), (2) export → clear → import restores exactly, (3) the
11+ * passphrase vault encrypts at rest and unlocks correctly.
12+ * Usage: node scripts/gate-phase3.mjs [baseURL]
13+ */
14+
15+import { chromium } from 'playwright'
16+import { mkdtempSync } from 'node:fs'
17+import { tmpdir } from 'node:os'
18+import { join } from 'node:path'
19+
20+const baseURL = process.argv[2] ?? 'http://localhost:5173'
21+const userDataDir = mkdtempSync(join(tmpdir(), 'zyquo-gate3-'))
22+const results = []
23+const check = (name, ok) => {
24+ results.push([name, ok])
25+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}`)
26+}
27+
28+const conversationFixture = {
29+ id: 'gate3-conv',
30+ title: 'Gate 3 conversation',
31+ createdAt: 1000,
32+ updatedAt: 2000,
33+ modelID: 'gpt-5.6-terra',
34+ provider: 'openai',
35+ parameters: { temperature: 0.7 },
36+ messages: [
37+ { id: 'm1', role: 'user', text: 'hello persistence', createdAt: 1000 },
38+ { id: 'm2', role: 'assistant', text: 'hello back', createdAt: 1500, usage: { inputTokens: 3, outputTokens: 2 } },
39+ ],
40+ pinned: true,
41+ hasAutoTitle: false,
42+}
43+
44+// --- Session 1: write data ---
45+let browser = await chromium.launchPersistentContext(userDataDir, { baseURL })
46+let page = await browser.newPage()
47+await page.goto(baseURL)
48+await page.evaluate(async (conv) => {
49+ const z = window.__zyquo
50+ z.keys.setKey('openai', 'sk-test-persistence-1234')
51+ z.settings.saveSettings({ ...z.settings.loadSettings(), chatFontSize: 15, theme: 'light' })
52+ await z.conversations.saveConversation(conv)
53+}, conversationFixture)
54+
55+// Reload (same session)
56+await page.reload()
57+const afterReload = await page.evaluate(async () => {
58+ const z = window.__zyquo
59+ const convs = await z.conversations.loadConversations()
60+ return {
61+ key: z.keys.getKey('openai'),
62+ fontSize: z.settings.loadSettings().chatFontSize,
63+ conv: convs.find((c) => c.id === 'gate3-conv') ?? null,
64+ }
65+})
66+check('keys survive reload', afterReload.key === 'sk-test-persistence-1234')
67+check('settings survive reload', afterReload.fontSize === 15)
68+check(
69+ 'conversation survives reload (full round-trip)',
70+ afterReload.conv !== null &&
71+ afterReload.conv.messages.length === 2 &&
72+ afterReload.conv.messages[1].usage.outputTokens === 2 &&
73+ afterReload.conv.pinned === true
74+)
75+
76+// --- Browser restart (close everything, reopen same profile) ---
77+await browser.close()
78+browser = await chromium.launchPersistentContext(userDataDir, { baseURL })
79+page = await browser.newPage()
80+await page.goto(baseURL)
81+const afterRestart = await page.evaluate(async () => {
82+ const z = window.__zyquo
83+ const convs = await z.conversations.loadConversations()
84+ return { key: z.keys.getKey('openai'), count: convs.length, title: convs[0]?.title }
85+})
86+check('keys survive browser restart', afterRestart.key === 'sk-test-persistence-1234')
87+check(
88+ 'conversations survive browser restart',
89+ afterRestart.count === 1 && afterRestart.title === 'Gate 3 conversation'
90+)
91+
92+// --- Export → clear → import ---
93+const roundTrip = await page.evaluate(async () => {
94+ const z = window.__zyquo
95+ const exported = await z.backup.exportAll(true)
96+ await z.backup.clearEverything()
97+ const emptyAfterClear =
98+ (await z.conversations.loadConversations()).length === 0 && z.keys.getKey('openai') === undefined
99+ const imported = await z.backup.importAll(exported)
100+ const convs = await z.conversations.loadConversations()
101+ return {
102+ emptyAfterClear,
103+ imported,
104+ key: z.keys.getKey('openai'),
105+ conv: convs.find((c) => c.id === 'gate3-conv') ?? null,
106+ fontSize: z.settings.loadSettings().chatFontSize,
107+ }
108+})
109+check('clear empties everything', roundTrip.emptyAfterClear)
110+check(
111+ 'export→clear→import restores exactly',
112+ roundTrip.key === 'sk-test-persistence-1234' &&
113+ roundTrip.conv !== null &&
114+ roundTrip.conv.messages.length === 2 &&
115+ roundTrip.fontSize === 15 &&
116+ roundTrip.imported.conversations === 1
117+)
118+
119+// --- Passphrase vault ---
120+const vaultResult = await page.evaluate(async () => {
121+ const z = window.__zyquo
122+ await z.vault.enableVault('correct horse battery staple')
123+ const raw = z.keys.rawKeysRecord()
124+ const encryptedAtRest = raw.includes('__zyquoVault') && !raw.includes('sk-test-persistence-1234')
125+ const readableWhileUnlocked = z.keys.getKey('openai') === 'sk-test-persistence-1234'
126+ z.vault.lockVault()
127+ const lockedNow = z.vault.isLocked()
128+ let wrongPassFailed = false
129+ try {
130+ await z.vault.unlockVault('wrong passphrase')
131+ } catch {
132+ wrongPassFailed = true
133+ }
134+ await z.vault.unlockVault('correct horse battery staple')
135+ const unlockedKey = z.keys.getKey('openai')
136+ await z.vault.disableVault('correct horse battery staple')
137+ const plaintextAgain = z.keys.rawKeysRecord().includes('sk-test-persistence-1234')
138+ return { encryptedAtRest, readableWhileUnlocked, lockedNow, wrongPassFailed, unlockedKey, plaintextAgain }
139+})
140+check('vault encrypts keys at rest (AES-GCM)', vaultResult.encryptedAtRest)
141+check('keys readable while unlocked', vaultResult.readableWhileUnlocked)
142+check('lock drops the in-memory overlay', vaultResult.lockedNow)
143+check('wrong passphrase rejected', vaultResult.wrongPassFailed)
144+check('correct passphrase unlocks', vaultResult.unlockedKey === 'sk-test-persistence-1234')
145+check('disable restores plaintext', vaultResult.plaintextAgain)
146+
147+await browser.close()
148+
149+const failures = results.filter(([, ok]) => !ok)
150+if (failures.length === 0) {
151+ console.log(`GATE PASS: ${results.length}/${results.length} persistence checks green`)
152+} else {
153+ console.error(`GATE FAIL: ${failures.length} failures`)
154+ process.exit(1)
155+}
added src/features/devHarness.ts +36 −0
@@ -0,0 +1,36 @@
1+/*
2+ * devHarness.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Dev-only test harness: exposes the storage layer on window so the
9+ * Playwright gate scripts (Phases 3 & 7) can drive real browser round-trips.
10+ * Excluded from production builds via the import.meta.env.DEV guard.
11+ */
12+
13+import * as conversations from '../storage/conversations'
14+import * as keys from '../storage/keys'
15+import * as settings from '../storage/settings'
16+import * as vault from '../storage/vault'
17+import * as backup from '../storage/backup'
18+import { testKey } from '../providers/testKey'
19+import { clientFor } from '../providers/registry'
20+import { cheapestModel, findModel, CATALOG } from '../providers/catalog'
21+
22+export function installDevHarness(): void {
23+ if (!import.meta.env.DEV) return
24+ ;(window as unknown as Record<string, unknown>).__zyquo = {
25+ conversations,
26+ keys,
27+ settings,
28+ vault,
29+ backup,
30+ testKey,
31+ clientFor,
32+ cheapestModel,
33+ findModel,
34+ CATALOG,
35+ }
36+}
modified src/main.tsx +3 −0
@@ -9,9 +9,12 @@
9 import { StrictMode } from 'react' 9 import { StrictMode } from 'react'
10 import { createRoot } from 'react-dom/client' 10 import { createRoot } from 'react-dom/client'
11 import App from './App' 11 import App from './App'
12+import { installDevHarness } from './features/devHarness'
12 import './design/tokens.css' 13 import './design/tokens.css'
13 import './design/global.css' 14 import './design/global.css'
14 15
16+installDevHarness()
17+
15 const container = document.getElementById('root') 18 const container = document.getElementById('root')
16 if (!container) throw new Error('Zyquo Cloud Web: #root container missing') 19 if (!container) throw new Error('Zyquo Cloud Web: #root container missing')
17 20
added src/providers/testKey.ts +46 −0
@@ -0,0 +1,46 @@
1+/*
2+ * testKey.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Key validation, ported from the native ProviderClient extension: the
9+ * cheapest authenticated call available — /models where supported, otherwise
10+ * (Perplexity) a minimal non-streaming completion against the provider's
11+ * cheapest catalog model. Returns round-trip latency in ms.
12+ */
13+
14+import type { Provider } from '../types'
15+import { cheapestModel } from './catalog'
16+import { clientFor, PROVIDER_META } from './registry'
17+import { ProviderError } from './types'
18+
19+export async function testKey(
20+ provider: Provider,
21+ apiKey: string,
22+ baseURLOverride?: string
23+): Promise<number> {
24+ const client = clientFor(provider)
25+ const start = performance.now()
26+ if (PROVIDER_META[provider].supportsModelListing) {
27+ await client.listModelIDs(apiKey, baseURLOverride)
28+ } else {
29+ const model = cheapestModel(provider)
30+ if (!model) throw ProviderError.noModelAvailable(provider)
31+ await client.complete(
32+ {
33+ model,
34+ messages: [
35+ { id: 'key-test', role: 'user', text: 'Reply with exactly: OK', createdAt: 0 },
36+ ],
37+ // Perplexity requires max_tokens ≥ 16 (probe-verified, docs/CORS-MATRIX.md).
38+ parameters: { maxTokens: 16 },
39+ stream: false,
40+ ...(baseURLOverride !== undefined ? { baseURLOverride } : {}),
41+ },
42+ apiKey
43+ )
44+ }
45+ return performance.now() - start
46+}
added src/storage/backup.ts +70 −0
@@ -0,0 +1,70 @@
1+/*
2+ * backup.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Export / import of all local data — the only "backup" mechanism since there
9+ * is no cloud. Exports may contain API keys ONLY when the user explicitly
10+ * opts in (the UI warns about this). Clear-data controls live here too.
11+ */
12+
13+import type { Conversation, Settings } from '../types'
14+import { clearConversations, loadConversations, saveConversation } from './conversations'
15+import { clearAllKeys, readKeyMap, replaceKeyMap, type KeyMap } from './keys'
16+import { clearSettings, loadSettings, saveSettings } from './settings'
17+
18+const EXPORT_VERSION = 1
19+
20+export interface ExportedData {
21+ app: 'zyquo-cloud-web'
22+ version: number
23+ exportedAt: string
24+ settings: Settings
25+ conversations: Conversation[]
26+ /** Present only when the user opted in to exporting keys. */
27+ keys?: KeyMap
28+}
29+
30+export async function exportAll(includeKeys: boolean): Promise<ExportedData> {
31+ const data: ExportedData = {
32+ app: 'zyquo-cloud-web',
33+ version: EXPORT_VERSION,
34+ exportedAt: new Date().toISOString(),
35+ settings: loadSettings(),
36+ conversations: await loadConversations(),
37+ }
38+ if (includeKeys) data.keys = readKeyMap()
39+ return data
40+}
41+
42+export function isExportedData(value: unknown): value is ExportedData {
43+ return (
44+ typeof value === 'object' &&
45+ value !== null &&
46+ (value as ExportedData).app === 'zyquo-cloud-web' &&
47+ typeof (value as ExportedData).version === 'number' &&
48+ Array.isArray((value as ExportedData).conversations)
49+ )
50+}
51+
52+/** Restores an export. Existing conversations with the same id are overwritten. */
53+export async function importAll(data: ExportedData): Promise<{ conversations: number; keys: number }> {
54+ if (data.settings) saveSettings({ ...loadSettings(), ...data.settings })
55+ for (const conversation of data.conversations) {
56+ await saveConversation(conversation)
57+ }
58+ let keyCount = 0
59+ if (data.keys) {
60+ replaceKeyMap({ ...readKeyMap(), ...data.keys })
61+ keyCount = Object.keys(data.keys).length
62+ }
63+ return { conversations: data.conversations.length, keys: keyCount }
64+}
65+
66+export async function clearEverything(): Promise<void> {
67+ await clearConversations()
68+ clearAllKeys()
69+ clearSettings()
70+}
added src/storage/conversations.ts +63 −0
@@ -0,0 +1,63 @@
1+/*
2+ * conversations.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Conversation persistence — IndexedDB (namespace zyquo.cloud.web), one
9+ * record per conversation, autosaved on every mutation by the store layer.
10+ * Includes schema versioning/migration for stored records.
11+ */
12+
13+import type { Conversation } from '../types'
14+import { dbClear, dbDelete, dbGetAll, dbPut, STORE_CONVERSATIONS } from './db'
15+
16+/** Bump when the stored Conversation shape changes; migrate() upgrades old records. */
17+const SCHEMA_VERSION = 1
18+
19+interface StoredConversation extends Conversation {
20+ schemaVersion?: number
21+}
22+
23+/** Upgrades a stored record from any older schema to the current one. */
24+function migrate(record: StoredConversation): Conversation {
25+ const version = record.schemaVersion ?? 1
26+ // v1 is current — future migrations chain here (v1 → v2 → …).
27+ void version
28+ const { schemaVersion: _schemaVersion, ...conversation } = record
29+ return {
30+ ...conversation,
31+ parameters: conversation.parameters ?? {},
32+ messages: conversation.messages ?? [],
33+ pinned: conversation.pinned ?? false,
34+ hasAutoTitle: conversation.hasAutoTitle ?? true,
35+ }
36+}
37+
38+/** All conversations, newest-first by updatedAt. */
39+export async function loadConversations(): Promise<Conversation[]> {
40+ const records = await dbGetAll<StoredConversation>(STORE_CONVERSATIONS)
41+ return records.map(migrate).sort((a, b) => b.updatedAt - a.updatedAt)
42+}
43+
44+export async function saveConversation(conversation: Conversation): Promise<void> {
45+ const record: StoredConversation = { ...conversation, schemaVersion: SCHEMA_VERSION }
46+ await dbPut(STORE_CONVERSATIONS, record)
47+}
48+
49+export async function deleteConversation(id: string): Promise<void> {
50+ await dbDelete(STORE_CONVERSATIONS, id)
51+}
52+
53+export async function clearConversations(): Promise<void> {
54+ await dbClear(STORE_CONVERSATIONS)
55+}
56+
57+/** Case-insensitive search across titles and all message text. */
58+export function matchesSearch(conversation: Conversation, query: string): boolean {
59+ const q = query.trim().toLowerCase()
60+ if (q === '') return true
61+ if (conversation.title.toLowerCase().includes(q)) return true
62+ return conversation.messages.some((message) => message.text.toLowerCase().includes(q))
63+}
added src/storage/db.ts +75 −0
@@ -0,0 +1,75 @@
1+/*
2+ * db.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Tiny promise wrapper around IndexedDB — no external dependency (every
9+ * dependency is key-theft surface). One database, versioned schema.
10+ */
11+
12+const DB_NAME = 'zyquo.cloud.web'
13+const DB_VERSION = 1
14+export const STORE_CONVERSATIONS = 'conversations'
15+
16+let dbPromise: Promise<IDBDatabase> | null = null
17+
18+export function openDB(): Promise<IDBDatabase> {
19+ if (dbPromise) return dbPromise
20+ dbPromise = new Promise((resolve, reject) => {
21+ const request = indexedDB.open(DB_NAME, DB_VERSION)
22+ request.onupgradeneeded = () => {
23+ const db = request.result
24+ // v1: conversations store keyed by id, indexed by updatedAt for sorting.
25+ if (!db.objectStoreNames.contains(STORE_CONVERSATIONS)) {
26+ const store = db.createObjectStore(STORE_CONVERSATIONS, { keyPath: 'id' })
27+ store.createIndex('updatedAt', 'updatedAt')
28+ }
29+ }
30+ request.onsuccess = () => {
31+ request.result.onversionchange = () => request.result.close()
32+ resolve(request.result)
33+ }
34+ request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed'))
35+ request.onblocked = () => reject(new Error('IndexedDB open blocked'))
36+ })
37+ return dbPromise
38+}
39+
40+function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
41+ return new Promise((resolve, reject) => {
42+ request.onsuccess = () => resolve(request.result)
43+ request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
44+ })
45+}
46+
47+export async function dbGet<T>(store: string, key: string): Promise<T | undefined> {
48+ const db = await openDB()
49+ const tx = db.transaction(store, 'readonly')
50+ return requestToPromise(tx.objectStore(store).get(key) as IDBRequest<T | undefined>)
51+}
52+
53+export async function dbGetAll<T>(store: string): Promise<T[]> {
54+ const db = await openDB()
55+ const tx = db.transaction(store, 'readonly')
56+ return requestToPromise(tx.objectStore(store).getAll() as IDBRequest<T[]>)
57+}
58+
59+export async function dbPut(store: string, value: unknown): Promise<void> {
60+ const db = await openDB()
61+ const tx = db.transaction(store, 'readwrite')
62+ await requestToPromise(tx.objectStore(store).put(value))
63+}
64+
65+export async function dbDelete(store: string, key: string): Promise<void> {
66+ const db = await openDB()
67+ const tx = db.transaction(store, 'readwrite')
68+ await requestToPromise(tx.objectStore(store).delete(key))
69+}
70+
71+export async function dbClear(store: string): Promise<void> {
72+ const db = await openDB()
73+ const tx = db.transaction(store, 'readwrite')
74+ await requestToPromise(tx.objectStore(store).clear())
75+}
added src/storage/settings.ts +63 −0
@@ -0,0 +1,63 @@
1+/*
2+ * settings.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * App settings — localStorage under zyquo.cloud.web.settings. Light theme is
9+ * the flagship default (never dark-auto). Proxy base-URL overrides per
10+ * provider live here (CORS fallback / Zyquo Router).
11+ */
12+
13+import type { Settings } from '../types'
14+import { defaultModel } from '../providers/catalog'
15+
16+const SETTINGS_KEY = 'zyquo.cloud.web.settings'
17+
18+export function defaultSettings(): Settings {
19+ const model = defaultModel()
20+ return {
21+ theme: 'light',
22+ accent: 'indigo',
23+ chatFontSize: 13.5,
24+ density: 'comfortable',
25+ defaultModelID: model.id,
26+ defaultProvider: model.provider,
27+ defaultSystemPrompt: '',
28+ defaultParameters: {},
29+ proxyBaseURLs: {},
30+ streamingEnabled: true,
31+ aliases: {},
32+ favoriteModelIDs: [],
33+ recentModelIDs: [],
34+ firstRunAcknowledged: false,
35+ focusMode: false,
36+ }
37+}
38+
39+export function loadSettings(): Settings {
40+ try {
41+ const raw = localStorage.getItem(SETTINGS_KEY)
42+ if (!raw) return defaultSettings()
43+ const parsed: unknown = JSON.parse(raw)
44+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
45+ return { ...defaultSettings(), ...(parsed as Partial<Settings>) }
46+ }
47+ return defaultSettings()
48+ } catch {
49+ return defaultSettings()
50+ }
51+}
52+
53+export function saveSettings(settings: Settings): void {
54+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
55+}
56+
57+export function clearSettings(): void {
58+ localStorage.removeItem(SETTINGS_KEY)
59+}
60+
61+export function rawSettingsRecord(): string | null {
62+ return localStorage.getItem(SETTINGS_KEY)
63+}
added src/storage/vault.ts +161 −0
@@ -0,0 +1,161 @@
1+/*
2+ * vault.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Opt-in passphrase lock for the provider-key map: AES-GCM with a key derived
9+ * from the user's passphrase via PBKDF2 (WebCrypto). Honesty by design: this
10+ * is obfuscation-at-rest, NOT a secure vault — without a passphrase keys sit
11+ * in plaintext localStorage, and even with one, a compromised page can read
12+ * them once unlocked. The UI says so.
13+ */
14+
15+import {
16+ isOverlayActive,
17+ rawKeysRecord,
18+ readKeyMap,
19+ setRawKeysRecord,
20+ setUnlockedOverlay,
21+ type KeyMap,
22+} from './keys'
23+
24+const PBKDF2_ITERATIONS = 310_000
25+
26+interface VaultBlob {
27+ __zyquoVault: 1
28+ salt: string
29+ iv: string
30+ ciphertext: string
31+}
32+
33+function isVaultBlob(value: unknown): value is VaultBlob {
34+ return (
35+ typeof value === 'object' &&
36+ value !== null &&
37+ (value as VaultBlob).__zyquoVault === 1 &&
38+ typeof (value as VaultBlob).salt === 'string' &&
39+ typeof (value as VaultBlob).iv === 'string' &&
40+ typeof (value as VaultBlob).ciphertext === 'string'
41+ )
42+}
43+
44+/** Whether the stored key record is passphrase-encrypted. */
45+export function isVaultEnabled(): boolean {
46+ const raw = rawKeysRecord()
47+ if (!raw) return false
48+ try {
49+ return isVaultBlob(JSON.parse(raw))
50+ } catch {
51+ return false
52+ }
53+}
54+
55+/** Vault enabled and not yet unlocked this session. */
56+export function isLocked(): boolean {
57+ return isVaultEnabled() && !isOverlayActive()
58+}
59+
60+function toBase64(bytes: Uint8Array): string {
61+ let binary = ''
62+ for (const b of bytes) binary += String.fromCharCode(b)
63+ return btoa(binary)
64+}
65+
66+function fromBase64(base64: string): Uint8Array {
67+ const binary = atob(base64)
68+ const bytes = new Uint8Array(binary.length)
69+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
70+ return bytes
71+}
72+
73+async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
74+ const material = await crypto.subtle.importKey(
75+ 'raw',
76+ new TextEncoder().encode(passphrase),
77+ 'PBKDF2',
78+ false,
79+ ['deriveKey']
80+ )
81+ return crypto.subtle.deriveKey(
82+ { name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
83+ material,
84+ { name: 'AES-GCM', length: 256 },
85+ false,
86+ ['encrypt', 'decrypt']
87+ )
88+}
89+
90+async function encryptMap(map: KeyMap, passphrase: string): Promise<VaultBlob> {
91+ const salt = crypto.getRandomValues(new Uint8Array(16))
92+ const iv = crypto.getRandomValues(new Uint8Array(12))
93+ const key = await deriveKey(passphrase, salt)
94+ const plaintext = new TextEncoder().encode(JSON.stringify(map))
95+ const ciphertext = await crypto.subtle.encrypt(
96+ { name: 'AES-GCM', iv: iv as BufferSource },
97+ key,
98+ plaintext as BufferSource
99+ )
100+ return {
101+ __zyquoVault: 1,
102+ salt: toBase64(salt),
103+ iv: toBase64(iv),
104+ ciphertext: toBase64(new Uint8Array(ciphertext)),
105+ }
106+}
107+
108+async function decryptBlob(blob: VaultBlob, passphrase: string): Promise<KeyMap> {
109+ const key = await deriveKey(passphrase, fromBase64(blob.salt))
110+ const plaintext = await crypto.subtle.decrypt(
111+ { name: 'AES-GCM', iv: fromBase64(blob.iv) as BufferSource },
112+ key,
113+ fromBase64(blob.ciphertext) as BufferSource
114+ )
115+ return JSON.parse(new TextDecoder().decode(plaintext)) as KeyMap
116+}
117+
118+/** Encrypts the current plaintext key map with a passphrase and keeps it unlocked. */
119+export async function enableVault(passphrase: string): Promise<void> {
120+ const map = readKeyMap()
121+ const blob = await encryptMap(map, passphrase)
122+ setRawKeysRecord(JSON.stringify(blob))
123+ setUnlockedOverlay(map)
124+}
125+
126+/** Decrypts into the in-memory overlay for this session. Throws on wrong passphrase. */
127+export async function unlockVault(passphrase: string): Promise<void> {
128+ const raw = rawKeysRecord()
129+ if (!raw) throw new Error('No vault present')
130+ const blob: unknown = JSON.parse(raw)
131+ if (!isVaultBlob(blob)) throw new Error('Keys are not encrypted')
132+ const map = await decryptBlob(blob, passphrase)
133+ setUnlockedOverlay(map)
134+}
135+
136+/** Re-encrypts the current (unlocked) map — call after key edits while locked mode is on. */
137+export async function persistVault(passphrase: string): Promise<void> {
138+ const map = readKeyMap()
139+ const blob = await encryptMap(map, passphrase)
140+ setRawKeysRecord(JSON.stringify(blob))
141+}
142+
143+/** Turns the lock off: writes the decrypted map back as plaintext. */
144+export async function disableVault(passphrase: string): Promise<void> {
145+ const raw = rawKeysRecord()
146+ if (!raw) {
147+ setUnlockedOverlay(null)
148+ return
149+ }
150+ const blob: unknown = JSON.parse(raw)
151+ if (isVaultBlob(blob)) {
152+ const map = await decryptBlob(blob, passphrase)
153+ setRawKeysRecord(JSON.stringify(map))
154+ }
155+ setUnlockedOverlay(null)
156+}
157+
158+/** Locks the session (drops the in-memory overlay; ciphertext stays at rest). */
159+export function lockVault(): void {
160+ setUnlockedOverlay(null)
161+}
modified tsconfig.app.tsbuildinfo +1 −1
@@ -1 +1 @@
1−{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/features/streamtestview.tsx","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/types.ts","./src/storage/keys.ts","./src/types/index.ts"],"version":"5.9.3"}
\ No newline at end of file
1+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/features/streamtestview.tsx","./src/features/devharness.ts","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/testkey.ts","./src/providers/types.ts","./src/storage/backup.ts","./src/storage/conversations.ts","./src/storage/db.ts","./src/storage/keys.ts","./src/storage/settings.ts","./src/storage/vault.ts","./src/types/index.ts"],"version":"5.9.3"}
\ No newline at end of file
2 2
Menu

Theme