/* * vault.ts * Zyquo Cloud Web * * Author: Simon-Pierre Boucher * Mail: contact@spboucher.ai * * Opt-in passphrase lock for the provider-key map: AES-GCM with a key derived * from the user's passphrase via PBKDF2 (WebCrypto). Honesty by design: this * is obfuscation-at-rest, NOT a secure vault — without a passphrase keys sit * in plaintext localStorage, and even with one, a compromised page can read * them once unlocked. The UI says so. */ import { isOverlayActive, rawKeysRecord, readKeyMap, setRawKeysRecord, setUnlockedOverlay, type KeyMap, } from './keys' const PBKDF2_ITERATIONS = 310_000 interface VaultBlob { __zyquoVault: 1 salt: string iv: string ciphertext: string } function isVaultBlob(value: unknown): value is VaultBlob { return ( typeof value === 'object' && value !== null && (value as VaultBlob).__zyquoVault === 1 && typeof (value as VaultBlob).salt === 'string' && typeof (value as VaultBlob).iv === 'string' && typeof (value as VaultBlob).ciphertext === 'string' ) } /** Whether the stored key record is passphrase-encrypted. */ export function isVaultEnabled(): boolean { const raw = rawKeysRecord() if (!raw) return false try { return isVaultBlob(JSON.parse(raw)) } catch { return false } } /** Vault enabled and not yet unlocked this session. */ export function isLocked(): boolean { return isVaultEnabled() && !isOverlayActive() } function toBase64(bytes: Uint8Array): string { let binary = '' for (const b of bytes) binary += String.fromCharCode(b) return btoa(binary) } function fromBase64(base64: string): Uint8Array { const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) return bytes } async function deriveKey(passphrase: string, salt: Uint8Array): Promise { const material = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey'] ) return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ) } async function encryptMap(map: KeyMap, passphrase: string): Promise { const salt = crypto.getRandomValues(new Uint8Array(16)) const iv = crypto.getRandomValues(new Uint8Array(12)) const key = await deriveKey(passphrase, salt) const plaintext = new TextEncoder().encode(JSON.stringify(map)) const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv as BufferSource }, key, plaintext as BufferSource ) return { __zyquoVault: 1, salt: toBase64(salt), iv: toBase64(iv), ciphertext: toBase64(new Uint8Array(ciphertext)), } } async function decryptBlob(blob: VaultBlob, passphrase: string): Promise { const key = await deriveKey(passphrase, fromBase64(blob.salt)) const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: fromBase64(blob.iv) as BufferSource }, key, fromBase64(blob.ciphertext) as BufferSource ) return JSON.parse(new TextDecoder().decode(plaintext)) as KeyMap } /** Encrypts the current plaintext key map with a passphrase and keeps it unlocked. */ export async function enableVault(passphrase: string): Promise { const map = readKeyMap() const blob = await encryptMap(map, passphrase) setRawKeysRecord(JSON.stringify(blob)) setUnlockedOverlay(map) } /** Decrypts into the in-memory overlay for this session. Throws on wrong passphrase. */ export async function unlockVault(passphrase: string): Promise { const raw = rawKeysRecord() if (!raw) throw new Error('No vault present') const blob: unknown = JSON.parse(raw) if (!isVaultBlob(blob)) throw new Error('Keys are not encrypted') const map = await decryptBlob(blob, passphrase) setUnlockedOverlay(map) } /** Re-encrypts the current (unlocked) map — call after key edits while locked mode is on. */ export async function persistVault(passphrase: string): Promise { const map = readKeyMap() const blob = await encryptMap(map, passphrase) setRawKeysRecord(JSON.stringify(blob)) } /** Turns the lock off: writes the decrypted map back as plaintext. */ export async function disableVault(passphrase: string): Promise { const raw = rawKeysRecord() if (!raw) { setUnlockedOverlay(null) return } const blob: unknown = JSON.parse(raw) if (isVaultBlob(blob)) { const map = await decryptBlob(blob, passphrase) setRawKeysRecord(JSON.stringify(map)) } setUnlockedOverlay(null) } /** Locks the session (drops the in-memory overlay; ciphertext stays at rest). */ export function lockVault(): void { setUnlockedOverlay(null) }