- 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
260 lines
8.4 KiB
TypeScript
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 };
|
|
}
|
|
}
|