From ce0858768031440c5fadad8364089cb3a5f4eed4 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 21 Mar 2026 11:19:19 -0700 Subject: [PATCH] =?UTF-8?q?feat(secure-storage-web):=20create=20@bytelyst/?= =?UTF-8?q?secure-storage-web=20=E2=80=94=20encrypted=20IndexedDB=20storag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IndexedDB-backed key-value store with non-extractable AES-256-GCM CryptoKey - Key material never leaves browser crypto subsystem - API: set, get, delete, clear, has, keys — all async - Namespace isolation for multi-app usage - Falls back to localStorage when SubtleCrypto unavailable - 16 Vitest tests (fake-indexeddb), all passing - Add IndexedDB globals to root ESLint config --- eslint.config.js | 5 + packages/secure-storage-web/package.json | 24 ++ packages/secure-storage-web/src/index.ts | 19 ++ .../src/secure-storage.test.ts | 123 +++++++++ .../secure-storage-web/src/secure-storage.ts | 259 ++++++++++++++++++ packages/secure-storage-web/tsconfig.json | 10 + 6 files changed, 440 insertions(+) create mode 100644 packages/secure-storage-web/package.json create mode 100644 packages/secure-storage-web/src/index.ts create mode 100644 packages/secure-storage-web/src/secure-storage.test.ts create mode 100644 packages/secure-storage-web/src/secure-storage.ts create mode 100644 packages/secure-storage-web/tsconfig.json diff --git a/eslint.config.js b/eslint.config.js index a3ab2a53..9b9fcb49 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -62,6 +62,11 @@ export default [ AesGcmParams: 'readonly', Crypto: 'readonly', ArrayBufferView: 'readonly', + indexedDB: 'readonly', + IDBDatabase: 'readonly', + IDBRequest: 'readonly', + IDBTransaction: 'readonly', + IDBObjectStore: 'readonly', Blob: 'readonly', File: 'readonly', FormData: 'readonly', diff --git a/packages/secure-storage-web/package.json b/packages/secure-storage-web/package.json new file mode 100644 index 00000000..baa64329 --- /dev/null +++ b/packages/secure-storage-web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/secure-storage-web", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.0.0", + "fake-indexeddb": "^6.0.0" + } +} diff --git a/packages/secure-storage-web/src/index.ts b/packages/secure-storage-web/src/index.ts new file mode 100644 index 00000000..15919c34 --- /dev/null +++ b/packages/secure-storage-web/src/index.ts @@ -0,0 +1,19 @@ +/** + * @bytelyst/secure-storage-web + * + * Encrypted key-value storage for web applications. + * Uses IndexedDB + Web Crypto (non-extractable AES-256-GCM key). + * Falls back to localStorage when SubtleCrypto is unavailable. + * + * @example + * ```typescript + * import { SecureStorage } from '@bytelyst/secure-storage-web'; + * + * const storage = new SecureStorage('com.myapp'); + * await storage.set('auth_token', 'eyJhbGci...'); + * const token = await storage.get('auth_token'); + * await storage.delete('auth_token'); + * ``` + */ + +export { SecureStorage } from './secure-storage.js'; diff --git a/packages/secure-storage-web/src/secure-storage.test.ts b/packages/secure-storage-web/src/secure-storage.test.ts new file mode 100644 index 00000000..99e2c808 --- /dev/null +++ b/packages/secure-storage-web/src/secure-storage.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { SecureStorage } from './secure-storage.js'; + +describe('SecureStorage', () => { + let storage: SecureStorage; + + beforeEach(async () => { + storage = new SecureStorage('com.test.app'); + await storage.clear(); + }); + + // ── Basic CRUD ────────────────────────────────── + + it('set and get', async () => { + await storage.set('token', 'abc123'); + const val = await storage.get('token'); + expect(val).toBe('abc123'); + }); + + it('get returns null for missing key', async () => { + const val = await storage.get('nonexistent'); + expect(val).toBeNull(); + }); + + it('overwrite existing key', async () => { + await storage.set('token', 'first'); + await storage.set('token', 'second'); + const val = await storage.get('token'); + expect(val).toBe('second'); + }); + + it('delete removes value', async () => { + await storage.set('token', 'abc'); + await storage.delete('token'); + const val = await storage.get('token'); + expect(val).toBeNull(); + }); + + it('delete non-existent key does not throw', async () => { + await expect(storage.delete('nope')).resolves.toBeUndefined(); + }); + + // ── Clear ─────────────────────────────────────── + + it('clear removes all values', async () => { + await storage.set('a', '1'); + await storage.set('b', '2'); + await storage.clear(); + expect(await storage.get('a')).toBeNull(); + expect(await storage.get('b')).toBeNull(); + }); + + // ── Has ───────────────────────────────────────── + + it('has returns true for existing key', async () => { + await storage.set('x', 'val'); + expect(await storage.has('x')).toBe(true); + }); + + it('has returns false for missing key', async () => { + expect(await storage.has('missing')).toBe(false); + }); + + // ── Keys ──────────────────────────────────────── + + it('keys lists stored keys', async () => { + await storage.set('alpha', '1'); + await storage.set('beta', '2'); + const keys = await storage.keys(); + expect(keys.sort()).toEqual(['alpha', 'beta']); + }); + + it('keys returns empty for fresh storage', async () => { + const keys = await storage.keys(); + expect(keys).toEqual([]); + }); + + // ── Namespace isolation ───────────────────────── + + it('different namespaces are isolated', async () => { + const s1 = new SecureStorage('ns1'); + const s2 = new SecureStorage('ns2'); + await s1.set('key', 'from-s1'); + await s2.set('key', 'from-s2'); + expect(await s1.get('key')).toBe('from-s1'); + expect(await s2.get('key')).toBe('from-s2'); + }); + + // ── Data types ────────────────────────────────── + + it('empty string', async () => { + await storage.set('empty', ''); + const val = await storage.get('empty'); + expect(val).toBe(''); + }); + + it('unicode values', async () => { + const text = 'こんにちは世界 🌍 مرحبا Ñoño'; + await storage.set('unicode', text); + expect(await storage.get('unicode')).toBe(text); + }); + + it('large value', async () => { + const big = 'X'.repeat(100_000); + await storage.set('big', big); + expect(await storage.get('big')).toBe(big); + }); + + it('JSON string value', async () => { + const json = JSON.stringify({ token: 'abc', user: { id: 1 } }); + await storage.set('session', json); + const parsed = JSON.parse((await storage.get('session'))!); + expect(parsed.token).toBe('abc'); + expect(parsed.user.id).toBe(1); + }); + + // ── isSupported ───────────────────────────────── + + it('isSupported returns true in test environment', () => { + expect(SecureStorage.isSupported()).toBe(true); + }); +}); diff --git a/packages/secure-storage-web/src/secure-storage.ts b/packages/secure-storage-web/src/secure-storage.ts new file mode 100644 index 00000000..99832acf --- /dev/null +++ b/packages/secure-storage-web/src/secure-storage.ts @@ -0,0 +1,259 @@ +/** + * @bytelyst/secure-storage-web — Encrypted web storage + * + * IndexedDB-backed key-value storage with a non-extractable AES-256-GCM + * CryptoKey managed by Web Crypto. The encryption key never leaves the + * browser's crypto subsystem. + * + * Falls back to localStorage if IndexedDB or SubtleCrypto is unavailable. + */ + +const DB_NAME = 'bytelyst_secure_storage'; +const DB_VERSION = 1; +const KEY_STORE = 'crypto_keys'; +const DATA_STORE = 'encrypted_data'; +const MASTER_KEY_ID = '__master_key__'; +const ALGORITHM = 'AES-GCM'; +const KEY_SIZE = 256; +const IV_BYTES = 12; +const TAG_BITS = 128; + +// ── IndexedDB helpers ─────────────────────────────── + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(KEY_STORE)) { + db.createObjectStore(KEY_STORE); + } + if (!db.objectStoreNames.contains(DATA_STORE)) { + db.createObjectStore(DATA_STORE); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function idbGet(db: IDBDatabase, store: string, key: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readonly'); + const req = tx.objectStore(store).get(key); + req.onsuccess = () => resolve(req.result as T | undefined); + req.onerror = () => reject(req.error); + }); +} + +function idbPut(db: IDBDatabase, store: string, key: string, value: unknown): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).put(value, key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +function idbDelete(db: IDBDatabase, store: string, key: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).delete(key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +function idbClear(db: IDBDatabase, store: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).clear(); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +function idbGetAllKeys(db: IDBDatabase, store: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readonly'); + const req = tx.objectStore(store).getAllKeys(); + req.onsuccess = () => resolve(req.result as string[]); + req.onerror = () => reject(req.error); + }); +} + +// ── Stored encrypted value format ─────────────────── + +interface StoredValue { + /** Ciphertext bytes. */ + ct: ArrayBuffer; + /** Initialization vector. */ + iv: ArrayBuffer; +} + +// ── SecureStorage class ───────────────────────────── + +/** + * Encrypted key-value storage backed by IndexedDB + Web Crypto. + * + * The AES-256-GCM master key is generated once and stored as a + * **non-extractable** CryptoKey in IndexedDB. Values are encrypted + * before storage and decrypted on retrieval. The raw key material + * never leaves the browser's crypto subsystem. + * + * Falls back to plaintext localStorage if IndexedDB or SubtleCrypto + * is not available (e.g., legacy browsers, server-side rendering). + * + * @example + * ```typescript + * const storage = new SecureStorage('com.myapp'); + * await storage.set('auth_token', 'eyJhbGci...'); + * const token = await storage.get('auth_token'); + * await storage.delete('auth_token'); + * await storage.clear(); + * ``` + */ +export class SecureStorage { + private readonly namespace: string; + private db: IDBDatabase | null = null; + private masterKey: CryptoKey | null = null; + private readonly fallback: boolean; + + constructor(namespace: string) { + this.namespace = namespace; + this.fallback = !SecureStorage.isSupported(); + } + + /** Check if IndexedDB + SubtleCrypto are available. */ + static isSupported(): boolean { + return ( + typeof indexedDB !== 'undefined' && + typeof globalThis !== 'undefined' && + !!globalThis.crypto?.subtle + ); + } + + // ── Public API ────────────────────────────────── + + /** Store an encrypted value. */ + async set(key: string, value: string): Promise { + if (this.fallback) { + localStorage.setItem(this.ns(key), value); + return; + } + const { db, masterKey } = await this.ensureReady(); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const encoded = new TextEncoder().encode(value); + + const ciphertext = await crypto.subtle.encrypt( + { name: ALGORITHM, iv: iv.buffer as ArrayBuffer, tagLength: TAG_BITS }, + masterKey, + encoded.buffer as ArrayBuffer + ); + + const stored: StoredValue = { ct: ciphertext, iv: iv.buffer as ArrayBuffer }; + await idbPut(db, DATA_STORE, this.ns(key), stored); + } + + /** Retrieve and decrypt a value. Returns `null` if not found. */ + async get(key: string): Promise { + if (this.fallback) { + return localStorage.getItem(this.ns(key)); + } + const { db, masterKey } = await this.ensureReady(); + const stored = await idbGet(db, DATA_STORE, this.ns(key)); + if (!stored) return null; + + const plaintext = await crypto.subtle.decrypt( + { name: ALGORITHM, iv: stored.iv, tagLength: TAG_BITS }, + masterKey, + stored.ct + ); + + return new TextDecoder().decode(plaintext); + } + + /** Delete a stored value. */ + async delete(key: string): Promise { + if (this.fallback) { + localStorage.removeItem(this.ns(key)); + return; + } + const { db } = await this.ensureReady(); + await idbDelete(db, DATA_STORE, this.ns(key)); + } + + /** Clear all stored values (keeps the master key). */ + async clear(): Promise { + if (this.fallback) { + const prefix = this.ns(''); + const toRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(prefix)) toRemove.push(k); + } + toRemove.forEach(k => localStorage.removeItem(k)); + return; + } + const { db } = await this.ensureReady(); + await idbClear(db, DATA_STORE); + } + + /** Check if a key exists. */ + async has(key: string): Promise { + if (this.fallback) { + return localStorage.getItem(this.ns(key)) !== null; + } + const { db } = await this.ensureReady(); + const stored = await idbGet(db, DATA_STORE, this.ns(key)); + return stored !== undefined; + } + + /** List all stored keys (without namespace prefix). */ + async keys(): Promise { + if (this.fallback) { + const prefix = this.ns(''); + const result: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(prefix)) result.push(k.slice(prefix.length)); + } + return result; + } + const { db } = await this.ensureReady(); + const allKeys = await idbGetAllKeys(db, DATA_STORE); + const prefix = this.ns(''); + return allKeys.filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length)); + } + + // ── Internal ──────────────────────────────────── + + private ns(key: string): string { + return `${this.namespace}:${key}`; + } + + private async ensureReady(): Promise<{ db: IDBDatabase; masterKey: CryptoKey }> { + if (this.db && this.masterKey) { + return { db: this.db, masterKey: this.masterKey }; + } + + const db = await openDb(); + this.db = db; + + // Try to load existing master key + let masterKey = await idbGet(db, KEY_STORE, MASTER_KEY_ID); + + if (!masterKey) { + // Generate a new non-extractable master key + masterKey = await crypto.subtle.generateKey( + { name: ALGORITHM, length: KEY_SIZE }, + false, // non-extractable — key material never leaves crypto subsystem + ['encrypt', 'decrypt'] + ); + await idbPut(db, KEY_STORE, MASTER_KEY_ID, masterKey); + } + + this.masterKey = masterKey; + return { db, masterKey }; + } +} diff --git a/packages/secure-storage-web/tsconfig.json b/packages/secure-storage-web/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/secure-storage-web/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +}