diff --git a/README.md b/README.md index c811c86..a6818d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @wraith-protocol/sdk -The SDK for the [Wraith](https://github.com/wraith-protocol) multichain stealth address platform. One package, five entry points — an agent client for the managed TEE platform and stealth address cryptography for EVM, Stellar, Solana, and CKB chains. +The SDK for the [Wraith](https://github.com/wraith-protocol) multichain stealth address platform. One package, six entry points — an agent client for the managed TEE platform, stealth address cryptography for EVM, Stellar, Solana, and CKB chains, and a browser-only vault for short-lived derived keys. ## Installation @@ -25,9 +25,36 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the SDK semver policy, deprecation | `@wraith-protocol/sdk/chains/stellar` | Stellar stealth address crypto (ed25519) | | `@wraith-protocol/sdk/chains/solana` | Solana stealth address crypto (ed25519) | | `@wraith-protocol/sdk/chains/ckb` | CKB (Nervos) stealth address crypto (secp256k1) | +| `@wraith-protocol/sdk/vault` | Browser-only passphrase vault for short-lived keys | > React Native support is documented in `docs/guides/react-native-setup.mdx` and the companion example at `examples/react-native-stellar`. +## Browser Vault + +`KeyVault` is for browser-only apps that need to hold derived stealth keys briefly between scans and spends. + +```ts +import { KeyVault } from '@wraith-protocol/sdk/vault'; + +const vault = new KeyVault({ + idleTimeoutMs: 2 * 60 * 1000, + lockOnBlur: true, +}); + +await vault.unlock(passphrase); +await vault.put('alice', derivedStealthKeys); +const keys = await vault.get('alice'); +``` + +Threat model: + +- protects against casual local persistence leaks such as `localStorage` +- does not replace hardware wallets +- does not protect against a compromised browser, malicious extension, or XSS +- does not protect against an attacker who can observe the unlocked page context + +For demos, keep vault use opt-in behind a user-controlled toggle so browser-only state stays explicit. + ## Agent Client The root export provides `Wraith` and `WraithAgent` — a lightweight HTTP client for the Wraith managed TEE platform. diff --git a/package.json b/package.json index b03d979..968797f 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,11 @@ "import": "./dist/compat/react-native.js", "require": "./dist/compat/react-native.cjs" }, + "./vault": { + "types": "./dist/vault/index.d.ts", + "import": "./dist/vault/index.js", + "require": "./dist/vault/index.cjs" + }, "./chains/solana": { "types": "./dist/chains/solana/index.d.ts", "import": "./dist/chains/solana/index.js", diff --git a/src/vault/index.ts b/src/vault/index.ts new file mode 100644 index 0000000..2a1f701 --- /dev/null +++ b/src/vault/index.ts @@ -0,0 +1,424 @@ +const DB_NAME = 'wraith-vault'; +const DB_VERSION = 1; +const STORE_META = 'meta'; +const STORE_ENTRIES = 'entries'; +const VAULT_META_KEY = 'vault'; +const DEFAULT_PBKDF2_ITERATIONS = 210_000; +const DEFAULT_IDLE_LOCK_MS = 5 * 60 * 1000; + +export interface KeyVaultOptions { + dbName?: string; + iterations?: number; + idleTimeoutMs?: number | null; + lockOnBlur?: boolean; + lockOnVisibilityChange?: boolean; +} + +interface VaultMetaRecord { + key: typeof VAULT_META_KEY; + salt: string; + checkIv: string; + checkCiphertext: string; + iterations: number; +} + +interface VaultEntryRecord { + key: string; + iv: string; + ciphertext: string; +} + +interface ResolvedOptions { + dbName: string; + iterations: number; + idleTimeoutMs: number | null; + lockOnBlur: boolean; + lockOnVisibilityChange: boolean; +} + +function resolveOptions(options: KeyVaultOptions = {}): ResolvedOptions { + return { + dbName: options.dbName ?? DB_NAME, + iterations: options.iterations ?? DEFAULT_PBKDF2_ITERATIONS, + idleTimeoutMs: + options.idleTimeoutMs === undefined ? DEFAULT_IDLE_LOCK_MS : options.idleTimeoutMs, + lockOnBlur: options.lockOnBlur ?? true, + lockOnVisibilityChange: options.lockOnVisibilityChange ?? true, + }; +} + +function assertBrowserSupport(): void { + if (typeof window === 'undefined' || typeof document === 'undefined') { + throw new Error('KeyVault is browser-only and requires window and document.'); + } + if (typeof indexedDB === 'undefined') { + throw new Error( + 'KeyVault is browser-only and requires IndexedDB. Use the browser runtime or bundle.', + ); + } + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('KeyVault requires WebCrypto (crypto.subtle).'); + } +} + +function assertUnlocked(key: CryptoKey | null): asserts key is CryptoKey { + if (!key) { + throw new Error('KeyVault is locked. Call vault.unlock(passphrase) first.'); + } +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); +} + +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function encodeValue(value: unknown): unknown { + if (value instanceof Uint8Array) { + return { __vaultType: 'Uint8Array', data: bytesToBase64(value) }; + } + + if (value instanceof ArrayBuffer) { + return { __vaultType: 'ArrayBuffer', data: bytesToBase64(new Uint8Array(value)) }; + } + + if (value instanceof Date) { + return { __vaultType: 'Date', data: value.toISOString() }; + } + + if (typeof value === 'bigint') { + return { __vaultType: 'bigint', data: value.toString() }; + } + + if (Array.isArray(value)) { + return value.map((item) => encodeValue(item)); + } + + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + out[key] = encodeValue(item); + } + return out; + } + + return value; +} + +function decodeValue(value: unknown): unknown { + if (!value || typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => decodeValue(item)); + } + + const record = value as Record; + if (record.__vaultType === 'Uint8Array') { + return base64ToBytes(String(record.data)); + } + + if (record.__vaultType === 'ArrayBuffer') { + return base64ToBytes(String(record.data)).buffer; + } + + if (record.__vaultType === 'Date') { + return new Date(String(record.data)); + } + + if (record.__vaultType === 'bigint') { + return BigInt(String(record.data)); + } + + const out: Record = {}; + for (const [key, item] of Object.entries(record)) { + if (key === '__vaultType') continue; + out[key] = decodeValue(item); + } + return out; +} + +function toBytes(input: string): Uint8Array { + return new TextEncoder().encode(input); +} + +async function deriveAesKey(passphrase: string, salt: Uint8Array, iterations: number): Promise { + const passphraseKey = await crypto.subtle.importKey( + 'raw', + toBytes(passphrase) as BufferSource, + 'PBKDF2', + false, + ['deriveKey'], + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt as BufferSource, + iterations, + hash: 'SHA-256', + }, + passphraseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +async function encryptJson(key: CryptoKey, payload: unknown): Promise<{ iv: string; ciphertext: string }> { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = toBytes(JSON.stringify(encodeValue(payload))); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + key, + plaintext as BufferSource, + ); + return { iv: bytesToBase64(iv), ciphertext: bytesToBase64(new Uint8Array(ciphertext)) }; +} + +async function decryptJson(key: CryptoKey, iv: string, ciphertext: string): Promise { + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(iv) as BufferSource }, + key, + base64ToBytes(ciphertext) as BufferSource, + ); + return decodeValue(JSON.parse(new TextDecoder().decode(plaintext))) as T; +} + +export class KeyVault { + private readonly options: ResolvedOptions; + private dbPromise: Promise | null = null; + private db: IDBDatabase | null = null; + private cryptoKey: CryptoKey | null = null; + private idleTimer: ReturnType | null = null; + private readonly onActivity = () => this.resetIdleTimer(); + private readonly onBlur = () => { + void this.lock(); + }; + private readonly onVisibilityChange = () => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + void this.lock(); + } + }; + + constructor(options: KeyVaultOptions = {}) { + assertBrowserSupport(); + this.options = resolveOptions(options); + } + + async unlock(passphrase: string): Promise { + const db = await this.openDB(); + const meta = await this.getMeta(db); + const resolvedMeta = meta ?? (await this.createMeta(db, passphrase, this.options.iterations)); + + const salt = base64ToBytes(resolvedMeta.salt); + const key = await deriveAesKey(passphrase, salt, resolvedMeta.iterations); + + try { + await decryptJson(key, resolvedMeta.checkIv, resolvedMeta.checkCiphertext); + } catch { + throw new Error('Unable to unlock KeyVault. The passphrase is incorrect or the vault is corrupt.'); + } + + this.cryptoKey = key; + this.installAutoLockHooks(); + this.resetIdleTimer(); + } + + async lock(): Promise { + this.cryptoKey = null; + this.clearIdleTimer(); + this.removeAutoLockHooks(); + if (this.db) { + this.db.close(); + this.db = null; + this.dbPromise = null; + } + } + + async put(label: string, keys: T): Promise { + const key = this.cryptoKey; + assertUnlocked(key); + const db = await this.openDB(); + const record = await encryptJson(key, keys); + + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_ENTRIES, 'readwrite'); + tx.objectStore(STORE_ENTRIES).put({ + key: label, + iv: record.iv, + ciphertext: record.ciphertext, + } satisfies VaultEntryRecord); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + + this.resetIdleTimer(); + } + + async get(label: string): Promise { + const key = this.cryptoKey; + assertUnlocked(key); + const db = await this.openDB(); + const record = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_ENTRIES, 'readonly'); + const req = tx.objectStore(STORE_ENTRIES).get(label); + req.onsuccess = () => resolve((req.result as VaultEntryRecord | undefined) ?? null); + req.onerror = () => reject(req.error); + }); + + if (!record) { + return null; + } + + const value = await decryptJson(key, record.iv, record.ciphertext); + this.resetIdleTimer(); + return value; + } + + private openDB(): Promise { + if (this.dbPromise) { + return this.dbPromise; + } + + this.dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(this.options.dbName, DB_VERSION); + + req.onupgradeneeded = (evt) => { + const db = (evt.target as IDBOpenDBRequest).result; + if (db.objectStoreNames.contains(STORE_META)) { + db.deleteObjectStore(STORE_META); + } + if (db.objectStoreNames.contains(STORE_ENTRIES)) { + db.deleteObjectStore(STORE_ENTRIES); + } + db.createObjectStore(STORE_META, { keyPath: 'key' }); + db.createObjectStore(STORE_ENTRIES, { keyPath: 'key' }); + }; + + req.onsuccess = () => { + this.db = req.result; + this.db.onclose = () => { + if (this.db === req.result) { + this.db = null; + this.dbPromise = null; + } + }; + resolve(req.result); + }; + req.onerror = () => reject(req.error); + }); + + return this.dbPromise; + } + + private async getMeta(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_META, 'readonly'); + const req = tx.objectStore(STORE_META).get(VAULT_META_KEY); + req.onsuccess = () => resolve((req.result as VaultMetaRecord | undefined) ?? null); + req.onerror = () => reject(req.error); + }); + } + + private async createMeta( + db: IDBDatabase, + passphrase: string, + iterations: number, + ): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const key = await deriveAesKey(passphrase, salt, iterations); + const check = await encryptJson(key, { ok: true }); + const meta: VaultMetaRecord = { + key: VAULT_META_KEY, + salt: bytesToBase64(salt), + checkIv: check.iv, + checkCiphertext: check.ciphertext, + iterations, + }; + + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_META, 'readwrite'); + tx.objectStore(STORE_META).put(meta); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + + return meta; + } + + private installAutoLockHooks(): void { + if (typeof window === 'undefined') { + return; + } + + window.removeEventListener('pointerdown', this.onActivity); + window.removeEventListener('keydown', this.onActivity); + window.removeEventListener('scroll', this.onActivity); + window.removeEventListener('touchstart', this.onActivity); + window.removeEventListener('mousemove', this.onActivity); + window.removeEventListener('blur', this.onBlur); + document.removeEventListener('visibilitychange', this.onVisibilityChange); + + if (this.options.idleTimeoutMs !== null) { + window.addEventListener('pointerdown', this.onActivity, { passive: true }); + window.addEventListener('keydown', this.onActivity, { passive: true }); + window.addEventListener('scroll', this.onActivity, { passive: true }); + window.addEventListener('touchstart', this.onActivity, { passive: true }); + window.addEventListener('mousemove', this.onActivity, { passive: true }); + } + + if (this.options.lockOnBlur) { + window.addEventListener('blur', this.onBlur); + } + + if (this.options.lockOnVisibilityChange) { + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + } + + private removeAutoLockHooks(): void { + if (typeof window === 'undefined') { + return; + } + + window.removeEventListener('pointerdown', this.onActivity); + window.removeEventListener('keydown', this.onActivity); + window.removeEventListener('scroll', this.onActivity); + window.removeEventListener('touchstart', this.onActivity); + window.removeEventListener('mousemove', this.onActivity); + window.removeEventListener('blur', this.onBlur); + document.removeEventListener('visibilitychange', this.onVisibilityChange); + } + + private resetIdleTimer(): void { + this.clearIdleTimer(); + if (this.options.idleTimeoutMs === null || typeof window === 'undefined') { + return; + } + + this.idleTimer = setTimeout(() => { + void this.lock(); + }, this.options.idleTimeoutMs); + } + + private clearIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + } +} diff --git a/test/vault/key-vault.test.ts b/test/vault/key-vault.test.ts new file mode 100644 index 0000000..44b1286 --- /dev/null +++ b/test/vault/key-vault.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; +import { KeyVault } from '../../src/vault'; + +type Listener = (...args: any[]) => void; + +function createEventTarget() { + const listeners = new Map>(); + return { + addEventListener(type: string, listener: Listener) { + let set = listeners.get(type); + if (!set) { + set = new Set(); + listeners.set(type, set); + } + set.add(listener); + }, + removeEventListener(type: string, listener: Listener) { + listeners.get(type)?.delete(listener); + }, + dispatchEvent(event: { type: string }) { + for (const listener of listeners.get(event.type) ?? []) { + listener(event); + } + }, + clear() { + listeners.clear(); + }, + }; +} + +describe('KeyVault', () => { + const originalIndexedDB = globalThis.indexedDB; + const originalIDBKeyRange = globalThis.IDBKeyRange; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + + let windowTarget: ReturnType & Record; + let documentTarget: ReturnType & Record; + + beforeEach(() => { + windowTarget = createEventTarget() as ReturnType & Record; + documentTarget = createEventTarget() as ReturnType & Record; + documentTarget.visibilityState = 'visible'; + + globalThis.indexedDB = new IDBFactory(); + globalThis.IDBKeyRange = IDBKeyRange; + globalThis.window = windowTarget as any; + globalThis.document = documentTarget as any; + }); + + afterEach(() => { + vi.useRealTimers(); + windowTarget?.clear?.(); + documentTarget?.clear?.(); + globalThis.indexedDB = originalIndexedDB; + globalThis.IDBKeyRange = originalIDBKeyRange; + globalThis.window = originalWindow as any; + globalThis.document = originalDocument as any; + }); + + it('unlocks, stores, and retrieves structured key material', async () => { + const vault = new KeyVault({ + dbName: 'wraith-vault-test', + iterations: 1000, + idleTimeoutMs: null, + }); + + const payload = { + spendingKey: '0x1234', + viewingKey: new Uint8Array([1, 2, 3, 4]), + spendingScalar: 42n, + nested: { label: 'primary' }, + }; + + await vault.unlock('correct horse battery staple'); + await vault.put('alice', payload); + + const stored = await vault.get('alice'); + expect(stored).toEqual(payload); + expect(stored?.viewingKey).toBeInstanceOf(Uint8Array); + expect(stored?.spendingScalar).toBe(42n); + await vault.lock(); + }); + + it('keeps ciphertext unreadable at rest', async () => { + const vault = new KeyVault({ + dbName: 'wraith-vault-test', + iterations: 1000, + idleTimeoutMs: null, + }); + + await vault.unlock('correct horse battery staple'); + await vault.put('alice', { hello: 'world' }); + + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('wraith-vault-test'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + const raw = await new Promise((resolve, reject) => { + const tx = db.transaction('entries', 'readonly'); + const req = tx.objectStore('entries').get('alice'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + expect(raw.ciphertext).not.toContain('world'); + expect(raw.ciphertext).not.toContain('hello'); + expect(raw.iv).toMatch(/^[A-Za-z0-9+/=]+$/); + await vault.lock(); + }); + + it('locks on blur and hides locked state from get/put', async () => { + const vault = new KeyVault({ + dbName: 'wraith-vault-test', + iterations: 1000, + idleTimeoutMs: null, + }); + + await vault.unlock('correct horse battery staple'); + windowTarget.dispatchEvent({ type: 'blur' }); + await Promise.resolve(); + + await expect(vault.get('alice')).rejects.toThrow('KeyVault is locked'); + await expect(vault.put('alice', { hello: 'world' })).rejects.toThrow('KeyVault is locked'); + }); + + it('auto-locks after inactivity when configured', async () => { + const vault = new KeyVault({ + dbName: 'wraith-vault-test', + iterations: 1000, + idleTimeoutMs: 10, + lockOnBlur: false, + lockOnVisibilityChange: false, + }); + + await vault.unlock('correct horse battery staple'); + await new Promise((resolve) => setTimeout(resolve, 25)); + + await expect(vault.get('alice')).rejects.toThrow('KeyVault is locked'); + await vault.lock(); + }); + + it('rejects wrong passphrases', async () => { + const vault = new KeyVault({ + dbName: 'wraith-vault-test', + iterations: 1000, + idleTimeoutMs: null, + }); + + await vault.unlock('correct horse battery staple'); + await vault.lock(); + + const secondVault = new KeyVault({ + dbName: 'wraith-vault-test', + iterations: 1000, + idleTimeoutMs: null, + }); + + await expect(secondVault.unlock('wrong passphrase')).rejects.toThrow( + 'Unable to unlock KeyVault', + ); + await secondVault.lock(); + }); + + it('rejects non-browser environments at construction time', () => { + const savedWindow = globalThis.window; + const savedDocument = globalThis.document; + const savedIndexedDB = globalThis.indexedDB; + + delete (globalThis as any).window; + delete (globalThis as any).document; + delete (globalThis as any).indexedDB; + + expect(() => new KeyVault()).toThrow('KeyVault is browser-only'); + + globalThis.window = savedWindow as any; + globalThis.document = savedDocument as any; + globalThis.indexedDB = savedIndexedDB; + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 1bce1d0..b32ca9f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ 'chains/solana/index': 'src/chains/solana/index.ts', 'chains/ckb/index': 'src/chains/ckb/index.ts', 'compat/react-native': 'src/compat/react-native.ts', + 'vault/index': 'src/vault/index.ts', }, format: ['esm', 'cjs'], dts: true,