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%
1/*2 * vault.ts3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * Opt-in passphrase lock for the provider-key map: AES-GCM with a key derived9 * from the user's passphrase via PBKDF2 (WebCrypto). Honesty by design: this10 * is obfuscation-at-rest, NOT a secure vault — without a passphrase keys sit11 * in plaintext localStorage, and even with one, a compromised page can read12 * them once unlocked. The UI says so.13 */1415import {16 isOverlayActive,17 rawKeysRecord,18 readKeyMap,19 setRawKeysRecord,20 setUnlockedOverlay,21 type KeyMap,22} from './keys'2324const PBKDF2_ITERATIONS = 310_0002526interface VaultBlob {27 __zyquoVault: 128 salt: string29 iv: string30 ciphertext: string31}3233function 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}4344/** Whether the stored key record is passphrase-encrypted. */45export function isVaultEnabled(): boolean {46 const raw = rawKeysRecord()47 if (!raw) return false48 try {49 return isVaultBlob(JSON.parse(raw))50 } catch {51 return false52 }53}5455/** Vault enabled and not yet unlocked this session. */56export function isLocked(): boolean {57 return isVaultEnabled() && !isOverlayActive()58}5960function toBase64(bytes: Uint8Array): string {61 let binary = ''62 for (const b of bytes) binary += String.fromCharCode(b)63 return btoa(binary)64}6566function 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 bytes71}7273async 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}8990async 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 BufferSource99 )100 return {101 __zyquoVault: 1,102 salt: toBase64(salt),103 iv: toBase64(iv),104 ciphertext: toBase64(new Uint8Array(ciphertext)),105 }106}107108async 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 BufferSource114 )115 return JSON.parse(new TextDecoder().decode(plaintext)) as KeyMap116}117118/** Encrypts the current plaintext key map with a passphrase and keeps it unlocked. */119export 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}125126/** Decrypts into the in-memory overlay for this session. Throws on wrong passphrase. */127export 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}135136/** Re-encrypts the current (unlocked) map — call after key edits while locked mode is on. */137export async function persistVault(passphrase: string): Promise<void> {138 const map = readKeyMap()139 const blob = await encryptMap(map, passphrase)140 setRawKeysRecord(JSON.stringify(blob))141}142143/** Turns the lock off: writes the decrypted map back as plaintext. */144export async function disableVault(passphrase: string): Promise<void> {145 const raw = rawKeysRecord()146 if (!raw) {147 setUnlockedOverlay(null)148 return149 }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}157158/** Locks the session (drops the in-memory overlay; ciphertext stays at rest). */159export function lockVault(): void {160 setUnlockedOverlay(null)161}162