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:
parent
1bce981f43
commit
ce08587680
@ -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',
|
||||
|
||||
24
packages/secure-storage-web/package.json
Normal file
24
packages/secure-storage-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
19
packages/secure-storage-web/src/index.ts
Normal file
19
packages/secure-storage-web/src/index.ts
Normal 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';
|
||||
123
packages/secure-storage-web/src/secure-storage.test.ts
Normal file
123
packages/secure-storage-web/src/secure-storage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
259
packages/secure-storage-web/src/secure-storage.ts
Normal file
259
packages/secure-storage-web/src/secure-storage.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
packages/secure-storage-web/tsconfig.json
Normal file
10
packages/secure-storage-web/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user