From f5f42fe557d536bfa2da4b783e32fb57dca2420b Mon Sep 17 00:00:00 2001 From: Sterister Date: Tue, 5 May 2026 17:35:02 +0200 Subject: [PATCH] release(v4.3.0): browser persistence via @shade/storage-indexeddb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship an official IndexedDB-backed StorageProvider so browser-based Shade consumers persist identity, prekeys, sessions, retired identities, peer-verification state and stream-resume rows across tab refresh and browser restart. Closes the gap that forced browser apps onto storage:"memory" (regenerated identity each load, orphaned device records server-side). - New package @shade/storage-indexeddb (4.3.0): full StorageProvider conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped in a single readwrite IDB transaction (atomic, vs SQLite's read-then-upsert race). - @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via dynamic import (lazy, optional dep — same pattern as @shade/storage-postgres). Named StorageSpec type now reused by ResolvedConfig. - Tests: 16 new tests in shade-storage-indexeddb (StorageProvider surface + peer-verifications + full E2EE conversation surviving a simulated tab reload). Run on fake-indexeddb. - Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages. - Publish scripts updated to include the new package. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 53 +++ bun.lock | 66 ++- docs/shade-storage-indexeddb.md | 191 ++++++++ packages/shade-cli/package.json | 2 +- packages/shade-core/package.json | 2 +- packages/shade-crypto-web/package.json | 2 +- packages/shade-dashboard/package.json | 2 +- packages/shade-files/package.json | 2 +- packages/shade-inbox-server/package.json | 2 +- packages/shade-inbox/package.json | 2 +- packages/shade-key-transparency/package.json | 2 +- packages/shade-keychain/package.json | 2 +- packages/shade-observability/package.json | 2 +- packages/shade-observer/package.json | 2 +- packages/shade-proto/package.json | 2 +- packages/shade-recovery/package.json | 2 +- packages/shade-sdk/package.json | 2 +- packages/shade-sdk/src/config.ts | 12 +- packages/shade-sdk/src/shade.ts | 18 +- packages/shade-server/package.json | 2 +- packages/shade-storage-encrypted/package.json | 2 +- packages/shade-storage-indexeddb/package.json | 15 + packages/shade-storage-indexeddb/src/index.ts | 1 + .../src/indexeddb-storage.ts | 435 ++++++++++++++++++ .../tests/indexeddb-storage.test.ts | 272 +++++++++++ .../tests/peer-verifications.test.ts | 99 ++++ .../shade-storage-indexeddb/tsconfig.json | 9 + packages/shade-storage-postgres/package.json | 2 +- packages/shade-storage-sqlite/package.json | 2 +- packages/shade-streams/package.json | 2 +- packages/shade-transfer/package.json | 2 +- packages/shade-transport-bridge/package.json | 2 +- packages/shade-transport-webrtc/package.json | 2 +- packages/shade-transport/package.json | 2 +- packages/shade-widgets/package.json | 2 +- scripts/publish-all.ts | 1 + scripts/publish-shade.sh | 1 + 37 files changed, 1167 insertions(+), 54 deletions(-) create mode 100644 docs/shade-storage-indexeddb.md create mode 100644 packages/shade-storage-indexeddb/package.json create mode 100644 packages/shade-storage-indexeddb/src/index.ts create mode 100644 packages/shade-storage-indexeddb/src/indexeddb-storage.ts create mode 100644 packages/shade-storage-indexeddb/tests/indexeddb-storage.test.ts create mode 100644 packages/shade-storage-indexeddb/tests/peer-verifications.test.ts create mode 100644 packages/shade-storage-indexeddb/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0040a..70fb1b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,59 @@ All notable changes to Shade are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.3.0] — 2026-05-05 — Browser persistence via `@shade/storage-indexeddb` + +Browser-based Shade consumers had no path to session persistence: the only +storage option that worked outside Node was `"memory"`, so the identity +keypair regenerated on every page load and `device:${registrationId}` +churned to a fresh address each refresh. Building a `StorageProvider` +in consumer-land meant 25+ method re-implementations per app and no +shared conformance surface. + +`4.3.0` ships an official IndexedDB adapter alongside SQLite and Postgres +so any browser-based Shade SDK consumer (dashboards, contact-list apps, +browser-extension messengers) gets persistent identity, prekeys, sessions, +retired identities, peer-verification state and stream-resume rows for +free, surviving tab refresh and browser restart. + +### Added + +#### `@shade/storage-indexeddb` (new package) +- `IndexedDBStorage.create({ dbName? })` — async open of an IDB + database (one object store per `StorageProvider` category) with + schema version 1. `dbName` defaults to `"shade"`; consumers that + run multiple Shade-backed apps on the same origin pass distinct + names (`"my-app-shade"`) so the IDB inspector groups them sensibly. +- Full `StorageProvider` conformance: identity, signed/one-time prekeys, + sessions, trusted identities, retired identities (with prune by + `retiredAt`), stream-state save/get/list/prune, peer verifications, + and the per-peer identity-version counter. +- `bumpPeerIdentityVersion` is wrapped in a single IDB `readwrite` + transaction — atomic read-modify-write, closing the race window the + SQLite adapter currently has on parallel `acceptIdentityChange` + calls. (SQL adapters will be brought in line in a follow-up.) +- Implementation dependency: `idb` (Jake Archibald's typed wrapper). + Tests run against `fake-indexeddb` for parity with the SQLite test + layout. + +#### `@shade/sdk` +- `resolveStorage()` accepts a fourth spec form: + `{ type: 'indexeddb', dbName?: string }`. Resolution goes through + a dynamic import so Node-only consumers don't pull a browser-only + adapter into their bundle (same pattern as `@shade/storage-postgres`). +- `ShadeConfig['storage']` now exports a named `StorageSpec` type + reused by `ResolvedConfig`, replacing the duplicated inline union. + +### Tests +- `packages/shade-storage-indexeddb/tests/indexeddb-storage.test.ts` — + full StorageProvider surface (identity, prekeys, sessions, trust, + retired identities, persistence across close+reopen) plus an end-to-end + `ShadeSessionManager` conversation that survives a simulated tab + reload mid-session. +- `packages/shade-storage-indexeddb/tests/peer-verifications.test.ts` — + CRUD round-trip, upsert-on-duplicate, identity-version increment + invariants, persistence across reopen. + ## [4.2.1] — 2026-05-04 — Concurrent-ratchet desync under pull-mode drainer A consumer running `shade.files.httpClient(server, { outboundQueueUrl, ... })` diff --git a/bun.lock b/bun.lock index aacb641..ea9fc31 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "packages/shade-cli": { "name": "@shade/cli", - "version": "0.4.0", + "version": "4.2.1", "bin": { "shade": "src/cli.ts", }, @@ -36,7 +36,7 @@ }, "packages/shade-core": { "name": "@shade/core", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/observability": "workspace:*", }, @@ -49,7 +49,7 @@ }, "packages/shade-crypto-web": { "name": "@shade/crypto-web", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", @@ -59,7 +59,7 @@ }, "packages/shade-dashboard": { "name": "@shade/dashboard", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/widgets": "workspace:*", "react": "^19.0.0", @@ -74,7 +74,7 @@ }, "packages/shade-files": { "name": "@shade/files", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -101,7 +101,7 @@ }, "packages/shade-inbox": { "name": "@shade/inbox", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/proto": "workspace:*", @@ -114,7 +114,7 @@ }, "packages/shade-inbox-server": { "name": "@shade/inbox-server", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/observability": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/shade-key-transparency": { "name": "@shade/key-transparency", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", @@ -144,11 +144,11 @@ }, "packages/shade-keychain": { "name": "@shade/keychain", - "version": "0.4.0", + "version": "4.2.1", }, "packages/shade-observability": { "name": "@shade/observability", - "version": "0.1.0", + "version": "4.2.1", "dependencies": { "@noble/hashes": "^2.0.1", }, @@ -166,7 +166,7 @@ }, "packages/shade-observer": { "name": "@shade/observer", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/server": "workspace:*", @@ -178,14 +178,14 @@ }, "packages/shade-proto": { "name": "@shade/proto", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", }, }, "packages/shade-recovery": { "name": "@shade/recovery", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -198,7 +198,7 @@ }, "packages/shade-sdk": { "name": "@shade/sdk", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -225,7 +225,7 @@ }, "packages/shade-server": { "name": "@shade/server", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/inbox-server": "workspace:*", @@ -245,7 +245,7 @@ }, "packages/shade-storage-encrypted": { "name": "@shade/storage-encrypted", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", @@ -261,9 +261,21 @@ "@shade/keychain", ], }, + "packages/shade-storage-indexeddb": { + "name": "@shade/storage-indexeddb", + "version": "4.2.1", + "dependencies": { + "@shade/core": "workspace:*", + "idb": "^8.0.3", + }, + "devDependencies": { + "@shade/crypto-web": "workspace:*", + "fake-indexeddb": "^6.0.0", + }, + }, "packages/shade-storage-postgres": { "name": "@shade/storage-postgres", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/inbox-server": "workspace:*", @@ -278,7 +290,7 @@ }, "packages/shade-storage-sqlite": { "name": "@shade/storage-sqlite", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -288,7 +300,7 @@ }, "packages/shade-streams": { "name": "@shade/streams", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", @@ -300,7 +312,7 @@ }, "packages/shade-transfer": { "name": "@shade/transfer", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -317,7 +329,7 @@ }, "packages/shade-transport": { "name": "@shade/transport", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -328,7 +340,7 @@ }, "packages/shade-transport-bridge": { "name": "@shade/transport-bridge", - "version": "0.1.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/server": "workspace:*", @@ -350,7 +362,7 @@ }, "packages/shade-transport-webrtc": { "name": "@shade/transport-webrtc", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/core": "workspace:*", "@shade/streams": "workspace:*", @@ -359,7 +371,7 @@ }, "packages/shade-widgets": { "name": "@shade/widgets", - "version": "0.4.0", + "version": "4.2.1", "dependencies": { "@shade/recovery": "workspace:*", "@shade/sdk": "workspace:*", @@ -568,6 +580,8 @@ "@shade/storage-encrypted": ["@shade/storage-encrypted@workspace:packages/shade-storage-encrypted"], + "@shade/storage-indexeddb": ["@shade/storage-indexeddb@workspace:packages/shade-storage-indexeddb"], + "@shade/storage-postgres": ["@shade/storage-postgres@workspace:packages/shade-storage-postgres"], "@shade/storage-sqlite": ["@shade/storage-sqlite@workspace:packages/shade-storage-sqlite"], @@ -626,6 +640,8 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -638,6 +654,8 @@ "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], diff --git a/docs/shade-storage-indexeddb.md b/docs/shade-storage-indexeddb.md new file mode 100644 index 0000000..027e245 --- /dev/null +++ b/docs/shade-storage-indexeddb.md @@ -0,0 +1,191 @@ +# Feature Request — `@shade/storage-indexeddb` + +**To**: Shade SDK team +**From**: Dispatch (browser-based Shade consumer) +**Target**: Shade SDK 4.3.x (or whichever release vehicle fits) +**Priority**: blocks all browser-based Shade apps from achieving session +persistence across tab refresh + +--- + +## Summary + +Ship an official IndexedDB-backed `StorageProvider` adapter as a new +workspace package `@shade/storage-indexeddb`, so browser-based Shade SDK +consumers can persist identity, prekeys, sessions, and peer-verification +state across tab refresh and browser restart — the same way `@shade/storage-sqlite` +does for Node and `@shade/storage-postgres` does for server deployments. + +## Problem + +Today the Shade SDK ships three storage paths: + +| spec | adapter | environment | +| --------------------------------- | ------------------------ | ---------------- | +| `"memory"` | `MemoryStorage` (in-SDK) | tests, ephemeral | +| `"sqlite:/path"` | `@shade/storage-sqlite` | Node | +| `{ type: 'postgres', url: '…' }` | `@shade/storage-postgres`| Node servers | + +There is **no browser-storage option**. The only way to run Shade in a +browser today is `storage: "memory"`, which means: + +- Identity keypair regenerates on every page load +- Sessions reset → re-enrollment after every refresh +- `getLocalRegistrationId()` returns a fresh value → `device:${id}` + address changes → server-side device record orphaned every reload + +This forces every browser-based Shade app to either (a) accept the broken +UX, or (b) build their own `StorageProvider` from scratch — duplicating +~25 methods × N consumers, with no shared conformance test surface. + +The right place to solve this is at the SDK level, exactly mirroring how +SQLite and Postgres are handled. + +## Proposed package + +`packages/shade-storage-indexeddb/` — modeled directly after +`packages/shade-storage-sqlite/`. Same package shape, same test layout, +same `@shade/core`-only runtime dependency surface. + +### Public API + +```ts +// @shade/storage-indexeddb +export class IndexedDBStorage implements StorageProvider { + /** + * Open (or create) the IndexedDB database. Idempotent — repeated calls + * with the same dbName return a connection sharing the same object stores. + */ + static async create(opts?: { dbName?: string }): Promise; + + /** + * Cleanly close the underlying connection. Future calls will reopen. + * Called by Shade.shutdown() when consumers register cleanup. + */ + async close(): Promise; + + // ─── all StorageProvider methods (identity, prekeys, sessions, + // retired identities, peer verifications, optional stream-state) ─── +} +``` + +`dbName` defaults to something like `"shade"`. Consumers like Dispatch +will pass distinct names per app (`"dispatch-dashboard-shade"`, +`"dispatch-host-ui-shade"`) so DevTools' IndexedDB inspector groups them +sensibly, even though origin-isolation already makes the data isolated. + +### SDK integration + +`@shade/sdk` `resolveStorage()` gets a fourth branch: + +```ts +if (typeof spec === 'object' && spec.type === 'indexeddb') { + const moduleId = '@shade/storage-indexeddb'; + const mod = (await import(moduleId)) as { + IndexedDBStorage: { create(opts: { dbName?: string }): Promise }; + }; + return mod.IndexedDBStorage.create({ dbName: spec.dbName }); +} +``` + +Dynamic import keeps `@shade/storage-indexeddb` an optional dependency, +matching the Postgres pattern — Node-only consumers don't need to install +a browser-only adapter. + +Consumer surface: + +```ts +const shade = await createShade({ + prekeyServer: 'https://…/shade-prekey', + storage: { type: 'indexeddb', dbName: 'my-app-shade' }, + address: 'device:user@example.com', // optional — falls back to device:${registrationId} +}); +``` + +## Implementation guidance (non-prescriptive) + +- **IDB wrapper**: suggest `idb` (Jake Archibald's thin wrapper, well-typed, + zero deps). Avoid Dexie or idb-keyval — we want full schema control to + match the SQL adapters' explicit schemas. +- **Object-store layout**: one store per StorageProvider category + (`identity`, `signedPreKeys`, `oneTimePreKeys`, `sessions`, + `trustedIdentities`, `retiredIdentities`, `peerVerifications`, + `streamStates`). Keypaths match the natural keys (`keyId`, `address`, + `streamId`). +- **Schema version**: integer, bumped on every shape change. Migrations + in `db.upgrade(...)` callback. Document schema-history alongside + the SQLite schema. +- **Concurrency**: IndexedDB transactions are auto-committing — the adapter + must keep operations within a single transaction where SQL adapters do. + Particular care for `bumpPeerIdentityVersion` (atomic read-modify-write). +- **Stream-state methods**: implement them. Browser apps will increasingly + use `@shade/transfer` for large file resume, and parity with SQLite's + capabilities matters. + +## Test expectations + +Mirror `packages/shade-storage-sqlite/tests/`: + +- `indexeddb-storage.test.ts` — full StorageProvider surface (identity, + sessions, trusted identities, retired identities) +- `indexeddb-prekey-store.test.ts` — signed + one-time prekey lifecycle +- `peer-verifications.test.ts` — verification CRUD + identity-version + bumping invariants +- `indexeddb-stream-state.test.ts` (if stream-state is implemented) + +Use **`fake-indexeddb`** for the Node test environment — it's the +established standard, supports the v3 spec, and lets us run IDB tests +in `bun test` / `vitest` / `jest` without a real browser. + +If/when Shade gains a shared `StorageProvider` conformance test suite, +this adapter should consume it directly. Until then, follow the SQLite +adapter's per-method coverage style. + +## Acceptance criteria + +1. `@shade/storage-indexeddb` published at version 4.3.0 (or whichever + matches the next Shade release) +2. `@shade/sdk` `resolveStorage()` resolves `{ type: 'indexeddb', dbName? }` + via dynamic import +3. Full StorageProvider conformance in tests (identity, prekeys, + sessions, retired identities, peer verifications, stream-state) +4. Documented in Shade docs alongside SQLite/Postgres adapters +5. README example showing browser-app integration +6. Bundle-size note: dynamic-imported IDB module shouldn't pull crypto + dependencies — adapter should be ≤ ~10 KB minified+gzipped + +## Out of scope (deferred) + +- **Encryption-at-rest** for the IDB contents — separate work item; should + match the deviceKey-AES-GCM pattern Shade already uses for `secretEnc` + in `PersistedStreamState`. This adapter ships unencrypted-at-rest in v1 + (consistent with SQLite), with the encryption layer added uniformly to + all adapters later. +- **Cross-tab BroadcastChannel sync** — IDB is shared across same-origin + tabs already; concurrent writes work via IDB transactions. Real-time + notification across tabs (e.g. "session was rotated in another tab") is + a separate concern, not storage-adapter scope. +- **Quota handling** — IDB quota for a Shade keystore is far below realistic + browser quotas. If it ever becomes relevant, add a `QuotaExceededError` + observability hook then. + +## Why this can't be done in consumer-land + +Building this in Dispatch (or any other consumer) would mean: + +- 25+ method `StorageProvider` re-implementation per consumer +- No shared conformance tests +- Schema drift across consumers — each would invent its own object-store + shape, blocking any future cross-app data import/export +- Every Shade SDK update that adjusts `StorageProvider` would force every + consumer to track and patch independently + +`@shade/storage-sqlite` and `@shade/storage-postgres` are part of the SDK +for the same reason. IndexedDB belongs alongside them. + +## What unblocks + +Shipping this unblocks Dispatch's "Slice 2.5" — persistent enrollment +across browser refresh, which today is the largest QA-friction point in +the dev loop. Any future browser-Shade consumer (web dashboard, +contact-list app, browser-extension messenger) gets persistence for free. diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 2e2aeeb..93efb62 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index 9e3da1e..e7d3d26 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index df58d1a..9d89320 100644 --- a/packages/shade-crypto-web/package.json +++ b/packages/shade-crypto-web/package.json @@ -1,6 +1,6 @@ { "name": "@shade/crypto-web", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json index bae816d..f7875bf 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index 8e27063..0eb6fcf 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/package.json b/packages/shade-inbox-server/package.json index 0e40725..735dae4 100644 --- a/packages/shade-inbox-server/package.json +++ b/packages/shade-inbox-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox-server", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index 1a11b2a..3fa77f5 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index e3b7569..c7d8357 100644 --- a/packages/shade-key-transparency/package.json +++ b/packages/shade-key-transparency/package.json @@ -1,6 +1,6 @@ { "name": "@shade/key-transparency", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-keychain/package.json b/packages/shade-keychain/package.json index 8efd31b..4b2d842 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observability/package.json b/packages/shade-observability/package.json index 7afcc16..93432f0 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observer/package.json b/packages/shade-observer/package.json index 604e50f..93e94c0 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json index 993e9c7..50a404f 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-recovery/package.json b/packages/shade-recovery/package.json index 53feed6..cbd55d2 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/package.json b/packages/shade-sdk/package.json index 097916d..08d11dc 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@shade/sdk", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/src/config.ts b/packages/shade-sdk/src/config.ts index 5a4d200..c89853f 100644 --- a/packages/shade-sdk/src/config.ts +++ b/packages/shade-sdk/src/config.ts @@ -17,11 +17,12 @@ export interface ShadeConfig { * - "memory" — in-memory only (lost on restart, good for tests) * - "sqlite:/path/to/file.db" — SQLite backend * - { type: 'postgres', url: 'postgres://...' } — PostgreSQL backend + * - { type: 'indexeddb', dbName?: 'my-app' } — browser IndexedDB * - An explicit StorageProvider instance * * Default: "memory" */ - storage?: string | StorageProvider | { type: 'postgres'; url: string }; + storage?: StorageSpec; /** * Your address on the prekey server (e.g. "alice@example.com" or "device:abc123"). @@ -96,9 +97,16 @@ export interface ShadeKTConfig { witnessMaxStored?: number; } +/** Acceptable shapes for `ShadeConfig.storage`. */ +export type StorageSpec = + | string + | StorageProvider + | { type: 'postgres'; url: string } + | { type: 'indexeddb'; dbName?: string }; + export interface ResolvedConfig { prekeyServer: string; - storage: string | StorageProvider | { type: 'postgres'; url: string }; + storage: StorageSpec; address?: string | undefined; autoReplenish: { min: number; target: number; intervalMs: number } | false; autoRotate: false | '1d' | '7d' | '30d' | '90d'; diff --git a/packages/shade-sdk/src/shade.ts b/packages/shade-sdk/src/shade.ts index 53e9c81..0f5bb6f 100644 --- a/packages/shade-sdk/src/shade.ts +++ b/packages/shade-sdk/src/shade.ts @@ -44,7 +44,7 @@ import { backupFromString, } from './backup.js'; import { computeFingerprint, deserializeIdentityKeyPair } from '@shade/core'; -import type { ResolvedConfig } from './config.js'; +import type { ResolvedConfig, StorageSpec } from './config.js'; import { ShadeControlChannel, ShadeTransferAuthenticator, @@ -1484,9 +1484,7 @@ class HttpEnvelopeTransport implements ControlEnvelopeTransport { // ─── Helpers ───────────────────────────────────────────────── -async function resolveStorage( - spec: string | StorageProvider | { type: 'postgres'; url: string }, -): Promise { +async function resolveStorage(spec: StorageSpec): Promise { if (typeof spec === 'object' && 'getIdentityKeyPair' in spec) { return spec; } @@ -1512,6 +1510,18 @@ async function resolveStorage( return mod.PostgresStorage.create(spec.url); } + if (typeof spec === 'object' && spec.type === 'indexeddb') { + // Dynamic import keeps @shade/storage-indexeddb optional — Node-only + // consumers don't need to install a browser-only adapter. + const moduleId = '@shade/storage-indexeddb'; + const mod = (await import(moduleId)) as { + IndexedDBStorage: { create(opts: { dbName?: string }): Promise }; + }; + const opts: { dbName?: string } = {}; + if (spec.dbName !== undefined) opts.dbName = spec.dbName; + return mod.IndexedDBStorage.create(opts); + } + throw new Error(`Unsupported storage spec: ${JSON.stringify(spec)}`); } diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index cdf4b2c..ea2dbe1 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json index d6eeee3..1b01418 100644 --- a/packages/shade-storage-encrypted/package.json +++ b/packages/shade-storage-encrypted/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-encrypted", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-indexeddb/package.json b/packages/shade-storage-indexeddb/package.json new file mode 100644 index 0000000..24ab832 --- /dev/null +++ b/packages/shade-storage-indexeddb/package.json @@ -0,0 +1,15 @@ +{ + "name": "@shade/storage-indexeddb", + "version": "4.3.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@shade/core": "workspace:*", + "idb": "^8.0.3" + }, + "devDependencies": { + "@shade/crypto-web": "workspace:*", + "fake-indexeddb": "^6.0.0" + } +} diff --git a/packages/shade-storage-indexeddb/src/index.ts b/packages/shade-storage-indexeddb/src/index.ts new file mode 100644 index 0000000..627c1f0 --- /dev/null +++ b/packages/shade-storage-indexeddb/src/index.ts @@ -0,0 +1 @@ +export { IndexedDBStorage } from './indexeddb-storage.js'; diff --git a/packages/shade-storage-indexeddb/src/indexeddb-storage.ts b/packages/shade-storage-indexeddb/src/indexeddb-storage.ts new file mode 100644 index 0000000..d937248 --- /dev/null +++ b/packages/shade-storage-indexeddb/src/indexeddb-storage.ts @@ -0,0 +1,435 @@ +import { openDB, type IDBPDatabase, type DBSchema } from 'idb'; +import type { + StorageProvider, + IdentityKeyPair, + SignedPreKey, + OneTimePreKey, + SessionState, + RetiredIdentity, + PersistedStreamState, + PeerVerification, + PeerVerificationSource, +} 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' }); + } + }, + }); + 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; + } +} + +// ─── Schema ──────────────────────────────────────────────── + +const SCHEMA_VERSION = 1; + +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 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 }; +} + +// ─── 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 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; +} diff --git a/packages/shade-storage-indexeddb/tests/indexeddb-storage.test.ts b/packages/shade-storage-indexeddb/tests/indexeddb-storage.test.ts new file mode 100644 index 0000000..22cbb82 --- /dev/null +++ b/packages/shade-storage-indexeddb/tests/indexeddb-storage.test.ts @@ -0,0 +1,272 @@ +import 'fake-indexeddb/auto'; +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { IndexedDBStorage } from '../src/indexeddb-storage.js'; +import { SubtleCryptoProvider, MemoryStorage as _MemoryStorage } from '@shade/crypto-web'; +import { ShadeSessionManager } from '@shade/core'; +import type { IdentityKeyPair, SignedPreKey, OneTimePreKey } from '@shade/core'; + +void _MemoryStorage; // keep import side-effect-free reference shape + +const crypto = new SubtleCryptoProvider(); + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + globalThis.crypto.getRandomValues(buf); + return buf; +} + +function uniqueDbName(): string { + return `shade-test-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +async function deleteDb(dbName: string): Promise { + await new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); + }); +} + +describe('IndexedDBStorage', () => { + let dbName: string; + let storage: IndexedDBStorage; + + beforeEach(async () => { + dbName = uniqueDbName(); + storage = await IndexedDBStorage.create({ dbName }); + }); + + afterEach(async () => { + await storage.close(); + await deleteDb(dbName); + }); + + // ─── Identity ────────────────────────────────────────────── + + describe('identity', () => { + test('returns null when no identity stored', async () => { + expect(await storage.getIdentityKeyPair()).toBeNull(); + }); + + test('save and retrieve identity keypair', async () => { + const ikp: IdentityKeyPair = { + signingPublicKey: randBytes(32), + signingPrivateKey: randBytes(32), + dhPublicKey: randBytes(32), + dhPrivateKey: randBytes(32), + }; + await storage.saveIdentityKeyPair(ikp); + const restored = await storage.getIdentityKeyPair(); + expect(restored).not.toBeNull(); + expect(restored!.signingPublicKey).toEqual(ikp.signingPublicKey); + expect(restored!.dhPrivateKey).toEqual(ikp.dhPrivateKey); + }); + + test('registration ID roundtrip', async () => { + expect(await storage.getLocalRegistrationId()).toBe(0); + await storage.saveLocalRegistrationId(42); + expect(await storage.getLocalRegistrationId()).toBe(42); + }); + }); + + // ─── Signed PreKeys ─────────────────────────────────────── + + describe('signed prekeys', () => { + test('save, get, remove', async () => { + const spk: SignedPreKey = { + keyId: 1, + keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) }, + signature: randBytes(64), + timestamp: Date.now(), + }; + await storage.saveSignedPreKey(spk); + const restored = await storage.getSignedPreKey(1); + expect(restored).not.toBeNull(); + expect(restored!.keyId).toBe(1); + expect(restored!.keyPair.publicKey).toEqual(spk.keyPair.publicKey); + + await storage.removeSignedPreKey(1); + expect(await storage.getSignedPreKey(1)).toBeNull(); + }); + }); + + // ─── One-Time PreKeys ───────────────────────────────────── + + describe('one-time prekeys', () => { + test('save, get, remove, count', async () => { + const otpk: OneTimePreKey = { + keyId: 100, + keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) }, + }; + await storage.saveOneTimePreKey(otpk); + expect(await storage.getOneTimePreKeyCount()).toBe(1); + + const restored = await storage.getOneTimePreKey(100); + expect(restored!.keyId).toBe(100); + + await storage.removeOneTimePreKey(100); + expect(await storage.getOneTimePreKeyCount()).toBe(0); + expect(await storage.getOneTimePreKey(100)).toBeNull(); + }); + }); + + // ─── Sessions ───────────────────────────────────────────── + + describe('sessions', () => { + test('save and restore session with skipped keys', async () => { + const state = { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 5 }, + receiveChain: { chainKey: randBytes(32), counter: 3 }, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: randBytes(32), + previousSendCounter: 4, + skippedKeys: new Map([['key:1', randBytes(32)]]), + }; + + await storage.saveSession('bob', state); + const restored = await storage.getSession('bob'); + expect(restored).not.toBeNull(); + expect(restored!.sendChain.counter).toBe(5); + expect(restored!.skippedKeys.size).toBe(1); + expect(restored!.skippedKeys.get('key:1')).toEqual(state.skippedKeys.get('key:1')!); + }); + + test('remove session', async () => { + await storage.saveSession('bob', { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 0 }, + receiveChain: null, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: null, + previousSendCounter: 0, + skippedKeys: new Map(), + }); + await storage.removeSession('bob'); + expect(await storage.getSession('bob')).toBeNull(); + }); + }); + + // ─── Trust ──────────────────────────────────────────────── + + describe('trust', () => { + test('TOFU: first use is trusted', async () => { + expect(await storage.isTrustedIdentity('bob', randBytes(32))).toBe(true); + }); + + test('saved identity matches', async () => { + const key = randBytes(32); + await storage.saveTrustedIdentity('bob', key); + expect(await storage.isTrustedIdentity('bob', key)).toBe(true); + expect(await storage.isTrustedIdentity('bob', randBytes(32))).toBe(false); + }); + }); + + // ─── Retired identities ─────────────────────────────────── + + describe('retired identities', () => { + test('add, list (newest first), prune', async () => { + const mk = (retiredAt: number): { keyPair: IdentityKeyPair; retiredAt: number } => ({ + keyPair: { + signingPublicKey: randBytes(32), + signingPrivateKey: randBytes(32), + dhPublicKey: randBytes(32), + dhPrivateKey: randBytes(32), + }, + retiredAt, + }); + + await storage.addRetiredIdentity(mk(100)); + await storage.addRetiredIdentity(mk(300)); + await storage.addRetiredIdentity(mk(200)); + + const list = await storage.getRetiredIdentities(); + expect(list.map((r) => r.retiredAt)).toEqual([300, 200, 100]); + + await storage.pruneRetiredIdentities(250); + const after = await storage.getRetiredIdentities(); + expect(after.map((r) => r.retiredAt)).toEqual([300]); + }); + }); + + // ─── Crash Recovery ─────────────────────────────────────── + + describe('persistence across close/reopen', () => { + test('data survives close and reopen', async () => { + const ikp: IdentityKeyPair = { + signingPublicKey: randBytes(32), + signingPrivateKey: randBytes(32), + dhPublicKey: randBytes(32), + dhPrivateKey: randBytes(32), + }; + await storage.saveIdentityKeyPair(ikp); + await storage.saveLocalRegistrationId(99); + await storage.saveSession('alice', { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 7 }, + receiveChain: null, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: null, + previousSendCounter: 0, + skippedKeys: new Map(), + }); + + // Close and reopen against the same dbName + await storage.close(); + storage = await IndexedDBStorage.create({ dbName }); + + const restored = await storage.getIdentityKeyPair(); + expect(restored!.signingPublicKey).toEqual(ikp.signingPublicKey); + expect(await storage.getLocalRegistrationId()).toBe(99); + const session = await storage.getSession('alice'); + expect(session!.sendChain.counter).toBe(7); + }); + }); + + // ─── Full E2EE with IndexedDBStorage ────────────────────── + + describe('full E2EE conversation with persistent storage', () => { + test('encrypt, close, reopen, continue conversation', async () => { + const bobDbName = uniqueDbName(); + let bobStorage = await IndexedDBStorage.create({ dbName: bobDbName }); + + try { + const alice = new ShadeSessionManager(crypto, storage); + let bob = new ShadeSessionManager(crypto, bobStorage); + await alice.initialize(); + await bob.initialize(); + + const otpks = await bob.generateOneTimePreKeys(5); + const bundle = await bob.createPreKeyBundle(); + bundle.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey }; + + await alice.initSessionFromBundle('bob', bundle); + + const env1 = await alice.encrypt('bob', 'Hello persistent!'); + expect(await bob.decrypt('alice', env1)).toBe('Hello persistent!'); + + const env2 = await bob.encrypt('alice', 'Got it!'); + expect(await alice.decrypt('bob', env2)).toBe('Got it!'); + + // "Crash" Bob — close the IDB and reopen + await bobStorage.close(); + bobStorage = await IndexedDBStorage.create({ dbName: bobDbName }); + bob = new ShadeSessionManager(crypto, bobStorage); + await bob.initialize(); + + const env3 = await alice.encrypt('bob', 'After your restart'); + expect(await bob.decrypt('alice', env3)).toBe('After your restart'); + + const env4 = await bob.encrypt('alice', 'I survived!'); + expect(await alice.decrypt('bob', env4)).toBe('I survived!'); + } finally { + await bobStorage.close(); + await deleteDb(bobDbName); + } + }); + }); +}); diff --git a/packages/shade-storage-indexeddb/tests/peer-verifications.test.ts b/packages/shade-storage-indexeddb/tests/peer-verifications.test.ts new file mode 100644 index 0000000..48323a8 --- /dev/null +++ b/packages/shade-storage-indexeddb/tests/peer-verifications.test.ts @@ -0,0 +1,99 @@ +import 'fake-indexeddb/auto'; +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { IndexedDBStorage } from '../src/index.js'; + +function uniqueDbName(): string { + return `shade-test-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +async function deleteDb(dbName: string): Promise { + await new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); + }); +} + +describe('IndexedDBStorage — peer_verifications (V3.3)', () => { + let dbName: string; + let storage: IndexedDBStorage; + + beforeEach(async () => { + dbName = uniqueDbName(); + storage = await IndexedDBStorage.create({ dbName }); + }); + + afterEach(async () => { + await storage.close(); + await deleteDb(dbName); + }); + + test('round trip: save → get → remove', async () => { + await storage.savePeerVerification({ + peerAddress: 'bob', + fingerprint: '12345 67890 12345 67890 12345 67890 12345 67890 12345 67890 12345 67890', + verifiedAt: 1_700_000_000_000, + verifiedBy: 'user', + identityVersion: 1, + }); + + const v = await storage.getPeerVerification('bob'); + expect(v).not.toBeNull(); + expect(v!.peerAddress).toBe('bob'); + expect(v!.verifiedBy).toBe('user'); + expect(v!.identityVersion).toBe(1); + + await storage.removePeerVerification('bob'); + expect(await storage.getPeerVerification('bob')).toBeNull(); + }); + + test('upsert overwrites on duplicate peer_address', async () => { + await storage.savePeerVerification({ + peerAddress: 'bob', + fingerprint: 'fp-1', + verifiedAt: 1, + verifiedBy: 'user', + identityVersion: 1, + }); + await storage.savePeerVerification({ + peerAddress: 'bob', + fingerprint: 'fp-2', + verifiedAt: 2, + verifiedBy: 'transitive', + identityVersion: 2, + }); + + const v = await storage.getPeerVerification('bob'); + expect(v!.fingerprint).toBe('fp-2'); + expect(v!.verifiedBy).toBe('transitive'); + expect(v!.identityVersion).toBe(2); + }); + + test('identity-version starts at 1 and increments via bump', async () => { + expect(await storage.getPeerIdentityVersion('alice')).toBe(1); + expect(await storage.bumpPeerIdentityVersion('alice')).toBe(2); + expect(await storage.bumpPeerIdentityVersion('alice')).toBe(3); + expect(await storage.getPeerIdentityVersion('alice')).toBe(3); + // Independent counter per peer + expect(await storage.getPeerIdentityVersion('bob')).toBe(1); + }); + + test('survives reopen', async () => { + await storage.savePeerVerification({ + peerAddress: 'bob', + fingerprint: 'fp', + verifiedAt: 42, + verifiedBy: 'user', + identityVersion: 5, + }); + await storage.bumpPeerIdentityVersion('bob'); + await storage.close(); + + storage = await IndexedDBStorage.create({ dbName }); + const v = await storage.getPeerVerification('bob'); + expect(v!.fingerprint).toBe('fp'); + expect(v!.identityVersion).toBe(5); + expect(await storage.getPeerIdentityVersion('bob')).toBe(2); + }); +}); diff --git a/packages/shade-storage-indexeddb/tsconfig.json b/packages/shade-storage-indexeddb/tsconfig.json new file mode 100644 index 0000000..2829c14 --- /dev/null +++ b/packages/shade-storage-indexeddb/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json index caa0582..24ce21f 100644 --- a/packages/shade-storage-postgres/package.json +++ b/packages/shade-storage-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-postgres", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index 15555bc..cb7a1a9 100644 --- a/packages/shade-storage-sqlite/package.json +++ b/packages/shade-storage-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-sqlite", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index 99d9a38..e43b8c7 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transfer/package.json b/packages/shade-transfer/package.json index 38ee792..d5d9048 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/package.json b/packages/shade-transport-bridge/package.json index 4217563..3bbeb44 100644 --- a/packages/shade-transport-bridge/package.json +++ b/packages/shade-transport-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-bridge", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index 95c4853..03a821a 100644 --- a/packages/shade-transport-webrtc/package.json +++ b/packages/shade-transport-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-webrtc", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json index 93dcffd..626d70d 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-widgets/package.json b/packages/shade-widgets/package.json index 0430fc1..2f6bf64 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.2.1", + "version": "4.3.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/scripts/publish-all.ts b/scripts/publish-all.ts index 5ee9c93..d0ddd81 100644 --- a/scripts/publish-all.ts +++ b/scripts/publish-all.ts @@ -29,6 +29,7 @@ const PACKAGES = [ 'shade-key-transparency', 'shade-storage-sqlite', 'shade-storage-postgres', + 'shade-storage-indexeddb', 'shade-storage-encrypted', 'shade-streams', 'shade-transport', diff --git a/scripts/publish-shade.sh b/scripts/publish-shade.sh index 6104f77..54210f3 100755 --- a/scripts/publish-shade.sh +++ b/scripts/publish-shade.sh @@ -15,6 +15,7 @@ PACKAGES=( key-transparency storage-sqlite storage-postgres + storage-indexeddb storage-encrypted streams transport