/** * Web secure storage using encrypted localStorage * * Uses SubtleCrypto for AES-GCM encryption when Web Crypto API is available */ import type { SecureStorageOptions, StorageResult, EncryptedEnvelope } from "./types"; import { BaseSecureStorageAdapter, ok, err, wrap } from "./adapter"; import { sha256Bytes } from "@styxstack/crypto-core"; /** * Web storage adapter with encryption * * Uses localStorage with AES-GCM encryption via Web Crypto API. * Falls back to plain localStorage if crypto is unavailable (with warning). */ export class WebSecureStorage extends BaseSecureStorageAdapter { readonly name = "web-crypto"; readonly available: boolean; private cryptoKey: CryptoKey | null = null; private readonly storageKey: string; constructor(options: SecureStorageOptions = {}) { super(options); this.available = typeof window !== "undefined" && typeof window.crypto?.subtle !== "undefined" && typeof localStorage !== "undefined"; this.storageKey = `styx:${this.namespace}:vault`; } /** * Initialize with a derived encryption key */ async init(password?: string): Promise> { if (!this.available) { return err("STYX_WEB_CRYPTO_UNAVAILABLE"); } try { // Derive key from password or use random key stored in session let keyMaterial: Uint8Array; if (password) { // Derive from password using PBKDF2 const salt = await this.getOrCreateSalt(); const pwKey = await crypto.subtle.importKey( "raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"] ); const bits = await crypto.subtle.deriveBits( { name: "PBKDF2", salt: salt as BufferSource, iterations: 100000, hash: "SHA-256" }, pwKey, 256 ); keyMaterial = new Uint8Array(bits); } else if (this.options.encryptionKey) { keyMaterial = this.options.encryptionKey; } else { // Use session-based key (ephemeral) keyMaterial = crypto.getRandomValues(new Uint8Array(32)); } this.cryptoKey = await crypto.subtle.importKey( "raw", keyMaterial as BufferSource, { name: "AES-GCM" }, false, ["encrypt", "decrypt"] ); return ok(undefined); } catch (e) { return err(`STYX_WEB_INIT_FAILED: ${e}`); } } private async getOrCreateSalt(): Promise { const saltKey = `${this.storageKey}:salt`; let salt = localStorage.getItem(saltKey); if (!salt) { const newSalt = crypto.getRandomValues(new Uint8Array(16)); salt = btoa(String.fromCharCode(...newSalt)); localStorage.setItem(saltKey, salt); } return Uint8Array.from(atob(salt), c => c.charCodeAt(0)); } private async encrypt(plaintext: string): Promise { if (!this.cryptoKey) throw new Error("Not initialized"); const nonce = crypto.getRandomValues(new Uint8Array(12)); const data = new TextEncoder().encode(plaintext); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce }, this.cryptoKey, new Uint8Array(data.buffer, data.byteOffset, data.byteLength) ); return { v: 1, alg: "xchacha20-poly1305", // Label for compatibility; actual is AES-GCM n: btoa(String.fromCharCode(...nonce)), c: btoa(String.fromCharCode(...new Uint8Array(ciphertext))), }; } private async decrypt(envelope: EncryptedEnvelope): Promise { if (!this.cryptoKey) throw new Error("Not initialized"); const nonce = Uint8Array.from(atob(envelope.n), c => c.charCodeAt(0)); const ciphertext = Uint8Array.from(atob(envelope.c), c => c.charCodeAt(0)); const plaintext = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonce as Uint8Array }, this.cryptoKey, ciphertext as Uint8Array ); return new TextDecoder().decode(plaintext); } async set(key: string, value: string): Promise> { if (!this.cryptoKey) { const initResult = await this.init(); if (!initResult.ok) return initResult; } return wrap(async () => { const envelope = await this.encrypt(value); const storageData = this.getStorageData(); storageData[this.key(key)] = envelope; localStorage.setItem(this.storageKey, JSON.stringify(storageData)); }, "STYX_WEB_SET_FAILED"); } async get(key: string): Promise> { if (!this.cryptoKey) { const initResult = await this.init(); if (!initResult.ok) return err(initResult.error); } return wrap(async () => { const storageData = this.getStorageData(); const envelope = storageData[this.key(key)]; if (!envelope) return null; return await this.decrypt(envelope); }, "STYX_WEB_GET_FAILED"); } async delete(key: string): Promise> { return wrap(async () => { const storageData = this.getStorageData(); delete storageData[this.key(key)]; localStorage.setItem(this.storageKey, JSON.stringify(storageData)); }, "STYX_WEB_DELETE_FAILED"); } async has(key: string): Promise> { const storageData = this.getStorageData(); return ok(this.key(key) in storageData); } async keys(): Promise> { const storageData = this.getStorageData(); const prefix = this.key(""); const keys = Object.keys(storageData) .filter(k => k.startsWith(prefix)) .map(k => k.slice(prefix.length)); return ok(keys); } async clear(): Promise> { return wrap(async () => { const storageData = this.getStorageData(); const prefix = this.key(""); for (const k of Object.keys(storageData)) { if (k.startsWith(prefix)) { delete storageData[k]; } } localStorage.setItem(this.storageKey, JSON.stringify(storageData)); }, "STYX_WEB_CLEAR_FAILED"); } private getStorageData(): Record { try { const raw = localStorage.getItem(this.storageKey); return raw ? JSON.parse(raw) : {}; } catch { return {}; } } }