import { openDB, type IDBPDatabase, type DBSchema } from 'idb'; import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity, PersistedStreamState, PeerVerification, PeerVerificationSource, BroadcastChannelRecord, BroadcastMemberRecord, } from '@shade/core'; import { toBase64, fromBase64, constantTimeEqual, serializeSessionState, deserializeSessionState, serializeSignedPreKey, deserializeSignedPreKey, serializeOneTimePreKey, deserializeOneTimePreKey, serializeIdentityKeyPair, deserializeIdentityKeyPair, } from '@shade/core'; /** * IndexedDB-backed StorageProvider for browser-side Shade clients. * * Persists identity, prekeys, sessions, retired identities, peer * verifications and stream-resume state across tab refresh and browser * restart. Same data shapes as `@shade/storage-sqlite` so cross-adapter * import/export remains feasible. * * Usage: * ```ts * const storage = await IndexedDBStorage.create({ dbName: 'my-app-shade' }); * const manager = new ShadeSessionManager(crypto, storage); * ``` */ export class IndexedDBStorage implements StorageProvider { private constructor(private db: IDBPDatabase) {} /** * Open (or create) the IndexedDB database. Idempotent — repeated calls * with the same dbName resolve to a fresh connection sharing the same * underlying object stores. */ static async create(opts: { dbName?: string } = {}): Promise { const dbName = opts.dbName ?? 'shade'; const db = await openDB(dbName, SCHEMA_VERSION, { upgrade(db, oldVersion) { if (oldVersion < 1) { db.createObjectStore('identity', { keyPath: 'id' }); db.createObjectStore('config', { keyPath: 'key' }); db.createObjectStore('signedPreKeys', { keyPath: 'keyId' }); db.createObjectStore('oneTimePreKeys', { keyPath: 'keyId' }); db.createObjectStore('sessions', { keyPath: 'address' }); db.createObjectStore('trustedIdentities', { keyPath: 'address' }); const retired = db.createObjectStore('retiredIdentities', { keyPath: 'id', autoIncrement: true, }); retired.createIndex('byRetiredAt', 'retiredAt'); const stream = db.createObjectStore('streamStates', { keyPath: 'streamId' }); stream.createIndex('byStatus', 'status'); stream.createIndex('byPeerAddress', 'peerAddress'); stream.createIndex('byUpdatedAt', 'updatedAt'); db.createObjectStore('peerVerifications', { keyPath: 'peerAddress' }); db.createObjectStore('peerIdentityVersions', { keyPath: 'peerAddress' }); } if (oldVersion < 2) { db.createObjectStore('broadcastChannels', { keyPath: 'channelId' }); const members = db.createObjectStore('broadcastMembers', { keyPath: ['channelId', 'peerAddress'] }); members.createIndex('byChannelId', 'channelId'); } }, }); return new IndexedDBStorage(db); } /** Cleanly close the underlying connection. */ async close(): Promise { this.db.close(); } // ─── Identity ────────────────────────────────────────────── async getIdentityKeyPair(): Promise { const row = await this.db.get('identity', 1); if (!row) return null; return { signingPublicKey: fromBase64(row.signingPublicKey), signingPrivateKey: fromBase64(row.signingPrivateKey), dhPublicKey: fromBase64(row.dhPublicKey), dhPrivateKey: fromBase64(row.dhPrivateKey), }; } async saveIdentityKeyPair(kp: IdentityKeyPair): Promise { await this.db.put('identity', { id: 1, signingPublicKey: toBase64(kp.signingPublicKey), signingPrivateKey: toBase64(kp.signingPrivateKey), dhPublicKey: toBase64(kp.dhPublicKey), dhPrivateKey: toBase64(kp.dhPrivateKey), }); } async getLocalRegistrationId(): Promise { const row = await this.db.get('config', 'registrationId'); return row ? parseInt(row.value, 10) : 0; } async saveLocalRegistrationId(id: number): Promise { await this.db.put('config', { key: 'registrationId', value: String(id) }); } // ─── Signed PreKeys ─────────────────────────────────────── async getSignedPreKey(keyId: number): Promise { const row = await this.db.get('signedPreKeys', keyId); if (!row) return null; return deserializeSignedPreKey(row.dataJson); } async saveSignedPreKey(key: SignedPreKey): Promise { await this.db.put('signedPreKeys', { keyId: key.keyId, dataJson: serializeSignedPreKey(key), }); } async removeSignedPreKey(keyId: number): Promise { await this.db.delete('signedPreKeys', keyId); } // ─── One-Time PreKeys ───────────────────────────────────── async getOneTimePreKey(keyId: number): Promise { const row = await this.db.get('oneTimePreKeys', keyId); if (!row) return null; return deserializeOneTimePreKey(row.dataJson); } async saveOneTimePreKey(key: OneTimePreKey): Promise { await this.db.put('oneTimePreKeys', { keyId: key.keyId, dataJson: serializeOneTimePreKey(key), }); } async removeOneTimePreKey(keyId: number): Promise { await this.db.delete('oneTimePreKeys', keyId); } async getOneTimePreKeyCount(): Promise { return this.db.count('oneTimePreKeys'); } // ─── Sessions ───────────────────────────────────────────── async getSession(address: string): Promise { const row = await this.db.get('sessions', address); if (!row) return null; return deserializeSessionState(row.stateJson); } async saveSession(address: string, state: SessionState): Promise { await this.db.put('sessions', { address, stateJson: serializeSessionState(state) }); } async removeSession(address: string): Promise { await this.db.delete('sessions', address); } // ─── Trust ──────────────────────────────────────────────── async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { const row = await this.db.get('trustedIdentities', address); if (!row) return true; // TOFU const stored = fromBase64(row.identityKey); return constantTimeEqual(stored, identityKey); } async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise { await this.db.put('trustedIdentities', { address, identityKey: toBase64(identityKey) }); } // ─── Identity History ───────────────────────────────────── async addRetiredIdentity(identity: RetiredIdentity): Promise { // autoIncrement: omit `id` so IDB assigns one await this.db.add('retiredIdentities', { dataJson: serializeIdentityKeyPair(identity.keyPair), retiredAt: identity.retiredAt, } as RetiredIdentityRow); } async getRetiredIdentities(): Promise { // Mirror SQLite's `ORDER BY retired_at DESC` const rows = await this.db.getAllFromIndex('retiredIdentities', 'byRetiredAt'); rows.reverse(); return rows.map((r) => ({ keyPair: deserializeIdentityKeyPair(r.dataJson), retiredAt: r.retiredAt, })); } async pruneRetiredIdentities(olderThan: number): Promise { const tx = this.db.transaction('retiredIdentities', 'readwrite'); const idx = tx.store.index('byRetiredAt'); const range = IDBKeyRange.upperBound(olderThan, true); let cursor = await idx.openCursor(range); while (cursor) { await cursor.delete(); cursor = await cursor.continue(); } await tx.done; } // ─── Stream-transfer resume state ───────────────────────── async saveStreamState(state: PersistedStreamState): Promise { await this.db.put('streamStates', persistedToRow(state)); } async getStreamState(streamId: string): Promise { const row = await this.db.get('streamStates', streamId); if (!row) return null; return rowToPersisted(row); } async removeStreamState(streamId: string): Promise { await this.db.delete('streamStates', streamId); } async listActiveStreamStates(direction?: 'send' | 'receive'): Promise { const idx = this.db.transaction('streamStates').store.index('byStatus'); const active = await idx.getAll(IDBKeyRange.only('active')); const paused = await idx.getAll(IDBKeyRange.only('paused')); const merged = [...active, ...paused]; const filtered = direction === undefined ? merged : merged.filter((r) => r.direction === direction); filtered.sort((a, b) => b.updatedAt - a.updatedAt); return filtered.map(rowToPersisted); } async pruneStreamStates(olderThan: number): Promise { const tx = this.db.transaction('streamStates', 'readwrite'); const idx = tx.store.index('byUpdatedAt'); const range = IDBKeyRange.upperBound(olderThan, true); let cursor = await idx.openCursor(range); while (cursor) { const row = cursor.value; if (row.status === 'finished' || row.status === 'aborted') { await cursor.delete(); } cursor = await cursor.continue(); } await tx.done; } // ─── Peer verifications (V3.3) ──────────────────────────── async savePeerVerification(v: PeerVerification): Promise { await this.db.put('peerVerifications', { ...v }); } async getPeerVerification(address: string): Promise { const row = await this.db.get('peerVerifications', address); if (!row) return null; return { peerAddress: row.peerAddress, fingerprint: row.fingerprint, verifiedAt: row.verifiedAt, verifiedBy: row.verifiedBy as PeerVerificationSource, identityVersion: row.identityVersion, }; } async removePeerVerification(address: string): Promise { await this.db.delete('peerVerifications', address); } async getPeerIdentityVersion(address: string): Promise { const row = await this.db.get('peerIdentityVersions', address); return row ? row.version : 1; } /** * Atomic read-modify-write under one IDB transaction. SQLite's version * is a non-atomic read-then-upsert; the IDB version closes that race * because IDB transactions auto-commit only when control returns to * the event loop without pending requests. */ async bumpPeerIdentityVersion(address: string): Promise { const tx = this.db.transaction('peerIdentityVersions', 'readwrite'); const existing = await tx.store.get(address); const next = (existing ? existing.version : 1) + 1; await tx.store.put({ peerAddress: address, version: next }); await tx.done; return next; } // ─── Broadcast channels (V4.6) ──────────────────────────── async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise { await this.db.put('broadcastChannels', channelToRow(channel)); } async getBroadcastChannel(channelId: string): Promise { const row = await this.db.get('broadcastChannels', channelId); if (!row) return null; return rowToChannel(row); } async listBroadcastChannels(): Promise { const rows = await this.db.getAll('broadcastChannels'); rows.sort((a, b) => a.createdAt - b.createdAt); return rows.map(rowToChannel); } async removeBroadcastChannel(channelId: string): Promise { const tx = this.db.transaction(['broadcastChannels', 'broadcastMembers'], 'readwrite'); const memIdx = tx.objectStore('broadcastMembers').index('byChannelId'); let cursor = await memIdx.openCursor(IDBKeyRange.only(channelId)); while (cursor) { await cursor.delete(); cursor = await cursor.continue(); } await tx.objectStore('broadcastChannels').delete(channelId); await tx.done; } async saveBroadcastMember(member: BroadcastMemberRecord): Promise { await this.db.put('broadcastMembers', { ...member }); } async getBroadcastMembers(channelId: string): Promise { const rows = await this.db.getAllFromIndex( 'broadcastMembers', 'byChannelId', IDBKeyRange.only(channelId), ); rows.sort((a, b) => a.joinedAt - b.joinedAt); return rows.map((r) => ({ channelId: r.channelId, peerAddress: r.peerAddress, joinedAt: r.joinedAt, removedAt: r.removedAt, })); } async removeBroadcastMember(channelId: string, peerAddress: string): Promise { await this.db.delete('broadcastMembers', [channelId, peerAddress]); } } // ─── Schema ──────────────────────────────────────────────── const SCHEMA_VERSION = 2; interface IdentityRow { id: 1; signingPublicKey: string; signingPrivateKey: string; dhPublicKey: string; dhPrivateKey: string; } interface ConfigRow { key: string; value: string; } interface SignedPreKeyRow { keyId: number; dataJson: string; } interface OneTimePreKeyRow { keyId: number; dataJson: string; } interface SessionRow { address: string; stateJson: string; } interface TrustedIdentityRow { address: string; identityKey: string; } interface RetiredIdentityRow { id?: number; dataJson: string; retiredAt: number; } interface StreamStateRow { streamId: string; direction: 'send' | 'receive'; peerAddress: string; status: 'active' | 'paused' | 'finished' | 'aborted'; metadataJson: string; partitionJson: string; laneStateJson: string; ioDescriptorJson: string; secretEnc: Uint8Array; secretNonce: Uint8Array; overallHashState: string | null; createdAt: number; updatedAt: number; } interface PeerVerificationRow { peerAddress: string; fingerprint: string; verifiedAt: number; verifiedBy: string; identityVersion: number; } interface PeerIdentityVersionRow { peerAddress: string; version: number; } interface BroadcastChannelRow { channelId: string; ownerRole: 'sender' | 'receiver'; ownerAddress: string; label: string | null; generation: number; chainKey: Uint8Array; iteration: number; signingPublicKey: Uint8Array; signingPrivateKey: Uint8Array | null; createdAt: number; updatedAt: number; } interface BroadcastMemberRow { channelId: string; peerAddress: string; joinedAt: number; removedAt: number | null; } interface ShadeSchema extends DBSchema { identity: { key: number; value: IdentityRow }; config: { key: string; value: ConfigRow }; signedPreKeys: { key: number; value: SignedPreKeyRow }; oneTimePreKeys: { key: number; value: OneTimePreKeyRow }; sessions: { key: string; value: SessionRow }; trustedIdentities: { key: string; value: TrustedIdentityRow }; retiredIdentities: { key: number; value: RetiredIdentityRow; indexes: { byRetiredAt: number }; }; streamStates: { key: string; value: StreamStateRow; indexes: { byStatus: string; byPeerAddress: string; byUpdatedAt: number; }; }; peerVerifications: { key: string; value: PeerVerificationRow }; peerIdentityVersions: { key: string; value: PeerIdentityVersionRow }; broadcastChannels: { key: string; value: BroadcastChannelRow }; broadcastMembers: { key: [string, string]; value: BroadcastMemberRow; indexes: { byChannelId: string }; }; } // ─── Helpers ────────────────────────────────────────────── function persistedToRow(s: PersistedStreamState): StreamStateRow { return { streamId: s.streamId, direction: s.direction, peerAddress: s.peerAddress, status: s.status, metadataJson: s.metadataJson, partitionJson: s.partitionJson, laneStateJson: s.laneStateJson, ioDescriptorJson: s.ioDescriptorJson, secretEnc: s.secretEnc, secretNonce: s.secretNonce, overallHashState: s.overallHashState ?? null, createdAt: s.createdAt, updatedAt: s.updatedAt, }; } function channelToRow(c: BroadcastChannelRecord): BroadcastChannelRow { return { channelId: c.channelId, ownerRole: c.ownerRole, ownerAddress: c.ownerAddress, label: c.label ?? null, generation: c.generation, chainKey: c.chainKey, iteration: c.iteration, signingPublicKey: c.signingPublicKey, signingPrivateKey: c.signingPrivateKey ?? null, createdAt: c.createdAt, updatedAt: c.updatedAt, }; } function rowToChannel(r: BroadcastChannelRow): BroadcastChannelRecord { const out: BroadcastChannelRecord = { channelId: r.channelId, ownerRole: r.ownerRole, ownerAddress: r.ownerAddress, generation: r.generation, chainKey: r.chainKey, iteration: r.iteration, signingPublicKey: r.signingPublicKey, createdAt: r.createdAt, updatedAt: r.updatedAt, }; if (r.label !== null) out.label = r.label; if (r.signingPrivateKey !== null) out.signingPrivateKey = r.signingPrivateKey; return out; } function rowToPersisted(r: StreamStateRow): PersistedStreamState { const out: PersistedStreamState = { streamId: r.streamId, direction: r.direction, peerAddress: r.peerAddress, status: r.status, metadataJson: r.metadataJson, partitionJson: r.partitionJson, laneStateJson: r.laneStateJson, ioDescriptorJson: r.ioDescriptorJson, secretEnc: r.secretEnc, secretNonce: r.secretNonce, createdAt: r.createdAt, updatedAt: r.updatedAt, }; if (r.overallHashState !== null) out.overallHashState = r.overallHashState; return out; }