learning_ai_common_plat/packages/secure-storage-web/src/secure-storage.ts
saravanakumardb1 ce08587680 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
2026-03-21 11:19:19 -07:00

260 lines
8.4 KiB
TypeScript

/**
* @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 };
}
}