SPB Git

spb/zyquo-cloud-web Public MIT

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

TypeScript 81.9% CSS 8.9% JavaScript 7.5% Shell 1.1% HTML 0.6%

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 10 days ago (Jul 31, 2026) parent ba33813

Showing 12 changed files with +694 and −3

modified docs/PLAN.md +17 −1
@@ -74,7 +74,23 @@ live OpenAI from headless Chromium: token-by-token rendering, usage events, and
74 74 AbortController cancellation all verified. Playwright added (dev-only) as the
75 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 95 ## Phase 4 — Design system & UI — pending
80 96
modified eslint.config.js +4 −1
@@ -78,7 +78,10 @@ export default [
78 78 ...tsPlugin.configs.recommended.rules,
79 79 ...reactHooks.configs.recommended.rules,
80 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 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 9 import { StrictMode } from 'react'
10 10 import { createRoot } from 'react-dom/client'
11 11 import App from './App'
12 +import { installDevHarness } from './features/devHarness'
12 13 import './design/tokens.css'
13 14 import './design/global.css'
14 15
16 +installDevHarness()
17 +
15 18 const container = document.getElementById('root')
16 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