feat(secure-storage-web): create @bytelyst/secure-storage-web — encrypted IndexedDB storage

- 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
This commit is contained in:
saravanakumardb1 2026-03-21 11:19:19 -07:00
parent 1bce981f43
commit ce08587680
6 changed files with 440 additions and 0 deletions

View File

@ -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',

View File

@ -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"
}
}

View File

@ -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';

View File

@ -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);
});
});

View File

@ -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<IDBDatabase> {
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<T>(db: IDBDatabase, store: string, key: string): Promise<T | undefined> {
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<void> {
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<void> {
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<void> {
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<string[]> {
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<void> {
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<string | null> {
if (this.fallback) {
return localStorage.getItem(this.ns(key));
}
const { db, masterKey } = await this.ensureReady();
const stored = await idbGet<StoredValue>(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<void> {
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<void> {
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<boolean> {
if (this.fallback) {
return localStorage.getItem(this.ns(key)) !== null;
}
const { db } = await this.ensureReady();
const stored = await idbGet<StoredValue>(db, DATA_STORE, this.ns(key));
return stored !== undefined;
}
/** List all stored keys (without namespace prefix). */
async keys(): Promise<string[]> {
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<CryptoKey>(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 };
}
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}