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%
4.8 KB · 162 lines typescript
Raw Blame History
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