diff --git a/SELF_HOST.md b/SELF_HOST.md index 867d61c78..b27b32059 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -468,6 +468,27 @@ plus the sleep/shutdown/logout availability limit; and the installed ## Troubleshooting boundaries +### Phone capability diagnostics + +For pairing-storage failures on iOS, Android, or desktop, open +`https:///diagnostics/index.html` in the affected browser and +choose **Run checks**, then **Copy results**. No setup code is needed. The +report distinguishes API presence from working crypto and storage; inspect it +before sharing because it includes browser/version information. + +For persistence across app or phone restarts, use **Prepare restart test**, +close and reopen the same browser/app, then **Verify saved key** and **Copy +restart result**. Do not prepare again between those steps. Finish with +**Remove test data** in each context where you prepared a checkpoint. +Browser and installed-app results are separate evidence; a passing test on one +device is not certification of another. The diagnostic contract is +`docs/specs/pocket-app.md` -> "Serving the built bundle". +Harness v3 tests the production encrypted-key format, including authenticated +context. A v1/v2 experimental checkpoint must be removed and prepared again; +old restart reports do not establish the production-format restart result. + +### Service and deployment failures + None of the three service managers runs the user's interactive shell or PowerShell startup files, so a `PATH` that works in a terminal proves nothing about any of them. diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index cc419bfb5..7a9a66732 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -389,6 +389,45 @@ Source of truth: `isInstalledWebApp` / `requiresInstallForPush` / ## What Pocket stores +**Must have a successful current-page storage probe before a scan starts +registration, sign-in, token retirement, or pairing.** Share in-flight work and +cache successful selection only in memory for that page; never retain failures. +Invalidate selection after production store or key-generation failure. +Probe native storage first, then encrypted +storage only if native fails, using fresh disposable keys in Pocket's record +shape. Reopen and verify identical key agreement; reject missing or extractable +runtime keys. Both formats failing shows a storage +compatibility error without resetting pairing data. Attempt probe database +deletion on exit. (rationale) +**Must identify the failed probe stage and an allowlisted exception name; +never display browser exception messages or key material.** +**Must keep the separate-key experiment out of pairing preflight.** Direct +compatibility failures to `/diagnostics/index.html`. + +**Must use metadata-only summaries for listing, push registration/queries, +removal, and re-pair identity checks.** `getSummary` and `listSummaries` omit key +material without decryption/import. Corrupt keys cannot block these operations; +re-pairing requires fresh approval and preserves the Burrow pin. Connection +and push decryption use full records. + +**Must report connection-record read failures with fixed retry/scan recovery +text, never browser exception details or authorization changes.** A fresh scan +retains the pin and requires approval; a read failure grants nothing. + +**Must use the selected format for new keys and decode both formats in the +shared page/worker store.** The encrypted format stores AES-GCM ciphertext, +a nonextractable per-key AES-256 key, a random 96-bit IV, and authenticated +domain/Burrow/public-key context. Generate and encrypt before the ceremony, +persist only after approval, and re-import X25519 nonextractably. Preserve its +envelope across authorization rewrites; reject corruption without generating a +replacement. Native records stay native. Older builds cannot read encrypted +records; rollback requires returning to a compatible build or pairing again. + +Source of truth: `requirePocketKeyStorage` in `lib/src/remote/client/pocket-db.ts`; +tests: `lib/src/remote/client/pocket-key-storage.test.ts`, +`lib/src/remote/client/pocket-encrypted-storage.test.ts`, +`lib/src/remote/pocket-app/App.scan.test.tsx`. + **One module owns the IndexedDB name, its version, its upgrade, and every open** (rationale). `dormouse-pocket` is at **v4**: `known-burrows` (`KnownBurrowV1`, keyed by `burrowId`) and `pending-deletions` (`PendingDeliveryDeletionV1`, keyed @@ -400,7 +439,7 @@ ordinary eviction-prone storage, which re-pairing survives ([remote-security-model.md](./remote-security-model.md) → Client static loss). **A `KnownBurrowV1` is this Client's whole authorization state** — the pinned Burrow -static, the per-Burrow X25519 private half as a nonextractable `CryptoKey` beside +static, the per-Burrow X25519 private half decoded to a nonextractable `CryptoKey` beside its raw public point, the paired passkey identifiers, and either `{ paired, deliveryId }` or `pairing-required`. **Only the private half is a key object**: a `NoiseKeyPair` wants the public half as raw bytes (rationale). @@ -413,6 +452,35 @@ in `lib/src/remote/client/pocket-client.ts`. ## Serving the built bundle +**Must serve the opt-in capability harness at `/diagnostics/index.html`, built +from `lib/pocket/diagnostics/` as a second Pocket HTML entry.** Test fresh keys and isolated temporary +storage, report stage failures and cleanup failures, and never read pairing +data, request passkeys or media permissions, or upload results. API presence +is observational; crypto storage success requires reopening and using the key. +**Must use the production key codec for encrypted round-trip and restart tests, +including authenticated context.** Diagnostics never open production databases. +Keep primitive checks independently generated and database-isolated. +**Must keep diagnostics platform-neutral and state which browser/app context +was tested.** API presence alone never certifies Android, iOS, or desktop support. + +**Must retain a restart checkpoint only on explicit preparation, in a +diagnostic-only database, until explicit cleanup.** Verification requires a new +page instance and derives the saved expected result using the recovered key; +never claim page reload proves process termination. The diagnostic manifest has +its own identity and start URL. Reports omit key material. Pinned by +`lib/src/remote/client/capability-harness.test.ts`. +**Must identify harness v3 production-format reports and reject legacy restart +checkpoints with explicit cleanup/reprepare instructions**, never silently +reclassify experimental evidence. Preserve the v1 production envelope/context. + +Source of truth: `runCapabilities` in +`lib/pocket/diagnostics/capabilities.js`; UI: +`lib/pocket/diagnostics/page.js`; restart: `verifyRestart` in +`lib/pocket/diagnostics/restart.js`; codec: `generatePocketKeyPair` / +`loadPocketPrivateKey` in `lib/src/remote/client/pocket-private-key.ts`. +Both built HTML shells are checked by `assertPocketShell` in +`lib/scripts/assert-pocket-worker.mjs`. + Content types need no special-casing: `serveStatic` already answers `application/manifest+json` for `.webmanifest` and `text/javascript` for `sw.js`. diff --git a/docs/specs/pocket-app.rationale.md b/docs/specs/pocket-app.rationale.md index 48d4d8c12..994878811 100644 --- a/docs/specs/pocket-app.rationale.md +++ b/docs/specs/pocket-app.rationale.md @@ -86,6 +86,48 @@ mode. [WebKit's iOS Web Push guidance](https://webkit.org/blog/13878/web-push-fo ## What Pocket stores +Repeated scans measured the same browser compatibility while delaying each +attempt; a page-local successful promise shares that work without persisting an +assumption across app restarts. Actual writes can still fail after any probe, +so store failures invalidate the cache. Separate-key probing cannot select a +production format and now remains only in the diagnostic tool. + +Burrow listing and push-subscription queries formerly decrypted every stored +key despite using metadata alone. Besides duplicate work, this prevented +listing/removing a record with a damaged envelope. Summary reads omit the +private-key field without interpreting it. + +A connection-record read failure can be transient database unavailability or +an undecodable key, not evidence of Burrow revocation. The fixed error points +to retry or the existing Scan a setup code action. That action preserves the +pin and requires fresh approval, while marking every read failure as pairing +required would conflate local availability with an authenticated denial. + +The operator confirmed successful production pairing on the affected iPhone on +September 11, 2026 after installing the encrypted fallback. No Android hardware +was tested in this investigation; the retained harness measures the device on +which it runs rather than selecting behavior from a user-agent string. + +The encrypted representation keeps a per-key AES key beside its ciphertext in +the same record, so a committed record is sufficient for a fresh page or worker. +Runtime keys have a weakly held encrypted representation; reads restore that +association, preventing an authorization-only update from trying the broken +native X25519 serialization again. Neither database version nor store layout +changes; v4 native records remain readable without migration. The security +tradeoff and device restart evidence are in remote-security-model, Client statics. + +The iOS 26.6.1 pairing failure reported in September 2026 occurred after local +approval, at the IndexedDB write. WebKit evaluates the inline key path on a +deserialized clone, so a failed embedded-key clone can look like a missing +`burrowId`. WebKit bug 312279 reports X25519 key storage returning null. +Generation and agreement alone do not test persistence; reopening and using the +stored key detects silent readback failure as well as a rejected write. + +The phone subsequently reported `write-record / DataError` in the disposable +database. Testing a separately stored key distinguishes an inline-key check +failure from broken key deserialization; an explicit key can bypass the former +while hiding the latter until readback. This diagnostic does not migrate records. + **Why one module owns every IndexedDB open.** Two modules opening the same database can disagree about the version, and a connection held open across an upgrade blocks it indefinitely. Centralizing name, version, upgrade and open makes both states unreachable rather than merely unlikely. **Why the `device-key` store is dropped on upgrade.** It belonged to the protocol that has been replaced, and a key nothing can use is only a credential left lying about. @@ -96,6 +138,23 @@ The v4 rename also drops `known-hosts` and empties `pending-deletions`: their ol ## Serving the built bundle +Harness v1/v2 encrypted tests were experimental look-alikes without production +AAD. Their restart results established primitive persistence, not the shipped +envelope. Harness v3 imports the production codec through the same Vite build +as Pocket and rejects the old checkpoint schema instead of upgrading its +evidence. On September 11, 2026, the operator's new Home Screen v3 checkpoint +prepared at 17:14:19 UTC passed at 17:17:44 UTC: production format, +authenticated context, retained key, and a new page instance. This followed +the requested phone-restart sequence; the page itself cannot prove an OS reboot. + +Measured on iPhone 15 Pro, Safari 26.6.1, September 2026: X25519 generation +worked, but structured cloning failed, inline IndexedDB writes raised DataError, +and explicit-key reads returned null. AES-GCM, Ed25519, and P-256 passed all +three storage/clone tests; AES-encrypted X25519 bytes also passed. A connection +reopen does not prove app-restart persistence, so the separate restart test +retains only a disposable checkpoint. Its page-instance check rules out an +in-memory retry, not OS process restoration; the user supplies that evidence. + **Why `no-cache` on the shell is load-bearing.** `emptyOutDir` deletes the previous build's hashed assets, so a browser reusing a heuristically cached `index.html` does not merely run stale code — it requests files that no longer exist and fails to boot, with no user recovery but clearing site data. **Why the cache class is read off the request path.** An unhashed file emitted into `assets/`, or an overridden `assetsDir`, would silently mislabel a resolved path — and the platform-shaped path differs on Windows besides. diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index a1b573566..2c7f0d15a 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -13,7 +13,7 @@ The trust model for remote control: three primitives between the Client access to no Burrow. * **Each Client pairs explicitly, one-to-one, with each Burrow** — the Burrow keeps its own local ACL of approved Clients, each identified by a per-Burrow X25519 - static generated in the browser and stored non-extractably. + static generated in the browser; storage follows Client statics below. Account compromise is therefore insufficient for burrow access ([Security Guarantees](#security-guarantees)). `docs/specs/security-remote.md` -> "Remote Control" @@ -89,18 +89,26 @@ Source of truth: `verifyPasskeyAssertion` / `hashPasskeyPublicKey` in A Client static is long-lived Client identity — the capability the Burrow actually authorizes. -**One X25519 keypair per Burrow, generated at scan time** (rationale), persisted -non-extractably in that Burrow's local record only after the Burrow approves, never -shared between Burrows. The raw 32-byte public half, base64url, is the Client +**Must generate one X25519 keypair per Burrow at scan time and persist it only +after approval, never shared between Burrows.** (rationale) The raw 32-byte public +half, base64url, is the Client identifier on the ACL; **Noise IK proves possession of the private half** (rationale). -It is durable across restarts and non-extractable through normal browser APIs, -but **active XSS can *use* it**, browser or OS compromise defeats the model, and -clearing browser data destroys it ([Client static loss](#client-static-loss)). +**Must prefer a directly persisted nonextractable private key.** Only a failed +native storage probe may select AES-256-GCM-encrypted PKCS#8 with a per-key +nonextractable AES key, after that format passes reopen and key agreement. +**Must import recovered X25519 keys nonextractably, never persist plaintext +private bytes, and leave existing native records unchanged.** (rationale) -Source of truth: `generateNoiseKeyPair` in -`remote-lib-common/src/security/noise.ts`; what Pocket stores is +Active XSS can use either format and can extract the X25519 private bytes in +the encrypted format. Nonextractability is therefore not a universal at-rest +guarantee; browser/OS compromise defeats both formats. A stolen static still +requires its paired passkey's fresh presence proof. Clearing browser data +destroys either format ([Client static loss](#client-static-loss)). + +Source of truth: `generatePocketKeyPair` in +`lib/src/remote/client/pocket-private-key.ts`; what Pocket stores is [pocket-app.md](./pocket-app.md). ## Burrow Authorization @@ -602,9 +610,8 @@ Onboarding changes with security surface are staged in the ### Device verification Two properties of the shipped Pocket client are observable only on a real iOS -device, and both are load-bearing: an X25519 `CryptoKey` surviving a structured -clone into IndexedDB (a Client static that does not is one the phone loses on -every reload), and `getUserMedia` working inside a Home Screen web app (without +device, and both are load-bearing: the selected Client-static storage format +surviving an app and phone restart, and `getUserMedia` working inside a Home Screen web app (without it the install has only the paste field). ### Revocation propagation diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index f3a039707..1badac4a5 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -21,6 +21,30 @@ environment. ## Client statics +The initial restart harness used an experimental envelope without the +production AAD binding. Its restart evidence below is primitive-level evidence; +the v3 harness uses the production codec and requires a fresh checkpoint. +The operator's v3 Home Screen report at 17:17:44 UTC on September 11, 2026 +passed with authenticated context and a retained production-format key from +17:14:19 UTC. It detects a new page instance after the requested phone restart, +not process termination itself. + +On September 11, 2026, an iPhone 15 Pro running Safari 26.6.1 failed X25519 +structured cloning and native IndexedDB persistence in both Safari and a Home +Screen app, while X25519 agreement and AES key persistence worked. The isolated +encrypted-key checkpoint passed after app closure and the requested phone +restart, using the same checkpoint created at 07:15:58 UTC. The report proves +a new page instance; process termination is operator evidence, not detectable +by that page. + +The self-host operator explicitly accepted the fallback tradeoff: same-origin +malicious JavaScript can decrypt and export the private bytes, so the previous +use-only XSS limitation no longer applies to that format. A nonextractable AES +key prevents a plain stored-byte copy from including its wrapping secret; it +does not protect against code running in the origin. Fresh paired-passkey +presence and Burrow approval remain unchanged. Zeroing application buffers is +best effort, not a claim about browser-internal or garbage-collected copies. + **Why possession is proven by the handshake rather than by a signature.** The dead approach was an ECDSA P-256 device key signing a Burrow challenge in a separate, domain-separated construction, checked as one term of a decision that diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index 30e326c17..fbdf98de3 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -34,7 +34,7 @@ no plaintext relay route, and no reader for any of the pre-cutover frames. | Setup password | one endpoint, `/api/burrow/enroll`, and thence a `burrowToken` | it registers **no** passkey — `/api/setup/*` takes a Burrow-minted setup token and nothing else — so it reaches an owner passkey only via the next row. `/api/burrow/enroll` accepts one other credential, the installer's enrollment offer: owner-only *at rest*, the whole of what the file mode protects, checked by possession over HTTPS rather than local identity, so a leaked token redeems remotely — bounded single-use, 24-hour expiry, permanently disabled by the first Burrow enrollment. Still **no Burrow access** | | `burrowToken` | the Burrow's own relay traffic and, transitively, **account takeover**: it mints setup tokens at `/api/burrow/setup-token`, the only thing that registers an owner passkey | bounded three ways — single-use and dead 5 minutes after minting; revoking the Burrow (deleting its row from `burrows.json`) stops minting immediately *and* kills already-minted tokens, re-checked at both setup gates; a signed-in phone retires an unused token at `/api/setup/retire`. Still **no Burrow access**: pairing runs Noise IK against an invitation keypair the Burrow never sent anywhere (rationale) | | Synced or stolen passkey | sign-in, and the ability to *ask* | the paired Client static is missing, so `BurrowAcl` answers `client-not-paired` | -| Client static | use of the key in place, and only through a compromised browser or OS, or XSS in the Pocket origin | the key is not extractable, connecting still needs a fresh passkey assertion, and it authorizes exactly one Burrow | +| Client static | use in place; encrypted fallback also permits private-byte extraction by compromised same-origin code | connecting still needs the paired passkey's fresh assertion, and it authorizes exactly one Burrow | **The only path into a Burrow's ACL is a human typing, on that Burrow, two digits displayed on the phone that is asking**, and the Burrow gets the comparison exactly once. **The @@ -91,8 +91,21 @@ are a silent no-op. Rows carry only what is additional. **Without explicit modes these files inherit the umask and end up world-readable**, handing live burrow tokens to any other local account on a shared machine. The Client's -per-Burrow statics are the exception that needs no file protection: non-extractable -`CryptoKey`s in IndexedDB, never exported. +per-Burrow browser storage follows `docs/specs/remote-security-model.md` -> +"Client statics". + +- **FAIL IF** Pocket persists plaintext Client private bytes, uses an extractable + AES wrapping key, selects encrypted storage without a failed native probe and + a passing encrypted reopen/use probe, or treats a corrupt encrypted record as + permission to generate a replacement identity. Read + `lib/src/remote/client/pocket-private-key.ts` and + `lib/src/remote/client/pocket-db.ts`; pinned by + `lib/src/remote/client/pocket-encrypted-storage.test.ts`. +- **FAIL IF** AES-GCM appears in production source under `remote-lib-common/src/`, + `lib/src/`, or `relay/src/` outside the local at-rest + wrapper `lib/src/remote/client/pocket-private-key.ts`. The wire cipher is + unchanged; `scripts/e2e-lint.mjs` and `scripts/e2e-lint-selftest.mjs` pin + this exception. - **FAIL IF** `relay/src/state.ts` stops creating `$DORMOUSE_STATE_DIR` mode `0o700`, or stops writing every file through `writeAtomic` at mode `0o600`. The "every file" clause is a negative search over `relay/src/`: no `writeFile`, `appendFile`, or `createWriteStream` may target the state directory outside `writeAtomic`. A cheap default, not a cross-platform guarantee; the installer's directory permissions below protect the installed Relay's state (rationale). - **FAIL IF** `FileBurrowStateStore` (`lib/src/host/remote/burrow-state-store.ts`) stops creating its directory `0o700` and writing `0o600` on non-Windows platforms, or if `VsCodeBurrowStateStore` stops keeping the **enrollment** in `SecretStorage`. The ACL's home in `globalState` is deliberate and is not a finding; the enrollment's is what carries `burrowToken`. diff --git a/docs/specs/security.md b/docs/specs/security.md index 23aae10a7..6b107af8f 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -69,7 +69,8 @@ run this knows what they are taking on. Code's own storage under its modes, never a transcript ([Persisted state](./security-local.md#persisted-state)). - **A compromised browser or operating system, on either end.** Active XSS in - the Pocket origin can *use* the phone's key without extracting it. Exactly + the Pocket origin can use the phone's key and, with encrypted fallback storage, + extract its private bytes ([Client statics](./remote-security-model.md#client-statics)). Exactly two endpoints are trusted: the distributed Burrow binaries and the exact Pocket artifact the origin serves ([Trust Model](./remote-security-model.md#trust-model)). - **Traffic analysis.** The Relay sees who talks to whom, when, how often, and diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index 3c76c1ff5..6118972ab 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -428,6 +428,9 @@ own `reason`. What survives is the runbook — prerequisites, what the installer does, the definition of done, the six checkpoints, official references, troubleshooting boundaries, and keeping the relay up while the laptop sleeps. +**Must preserve published subsections and withhold removed subsections.** +`website/scripts/generate-docs.test.js`. + **Must** keep every withheld section present in `SELF_HOST.md`. `applyDelta` owns this: a rule matching nothing fails the build naming the rule, so a renamed section is a decision rather than a silent republication of what the diff --git a/docs/stories/pairing.mdx b/docs/stories/pairing.mdx index 5c2796cc4..486ad3102 100644 --- a/docs/stories/pairing.mdx +++ b/docs/stories/pairing.mdx @@ -264,10 +264,10 @@ Either way, that one action creates the account's first passkey (or signs in with a synced one) and moves straight into pairing. Three things happen that you cannot see: -- A **per-Burrow key** is minted — a non-extractable X25519 keypair, held in - memory until the laptop approves and only then written to IndexedDB. This - browser can agree with it and can never export it. It is this browser's - identity *for this laptop*; a different laptop gets a different key. +- A **per-Burrow key** is minted and held in memory until the laptop approves, + then written to IndexedDB. Storage and the encrypted fallback's security + tradeoff follow `docs/specs/remote-security-model.md` → Client statics. + It identifies this browser for this laptop; another laptop gets another key. - **The code is spent either way.** A phone with no passkey redeems it to register one; a phone that already has one retires it at `POST /api/setup/retire`, so a photograph of the laptop's screen cannot diff --git a/lib/pocket/diagnostics/capabilities.js b/lib/pocket/diagnostics/capabilities.js new file mode 100644 index 000000000..903671242 --- /dev/null +++ b/lib/pocket/diagnostics/capabilities.js @@ -0,0 +1,213 @@ +// Diagnostic databases are independent of Pocket's authorization database. +import { toBase64Url } from 'remote-lib-common'; +import { + generatePocketKeyPair, loadPocketPrivateKey, storePocketPrivateKey, +} from '../../src/remote/client/pocket-private-key'; +export const HARNESS_VERSION = '3'; +const PREFIX = 'dormouse-capability-probe-'; +const LIMIT = 8000; +export const message = error => `${error?.name || 'Error'}: ${error?.message || 'failed'}`; +export const assert = (ok, reason) => { if (!ok) throw new Error(reason); }; +export const equal = (a, b, reason = 'Key operation produced a different result') => { + const x = new Uint8Array(a), y = new Uint8Array(b); + assert(x.length === y.length && x.every((v, i) => v === y[i]), reason); +}; + +/** + * Write inside `tx` and wait for it to commit, aborting on a throw so a failed + * check leaves no half-open transaction behind. `write` runs inside the try, so + * a clone that the `put` itself refuses takes the same path as a failed commit. + */ +export async function commit(tx, write, reason = 'Transaction aborted') { + const done = new Promise((resolve, reject) => { + tx.oncomplete = resolve; + tx.onabort = tx.onerror = () => reject(tx.error || new Error(reason)); + }); + try { + write(); + await bounded(done, () => { try { tx.abort(); } catch {} }); + } catch (error) { + try { tx.abort(); } catch {} + await done.catch(() => {}); + throw error; + } +} + +export function bounded(operation, cleanup = () => {}) { + let timer; + return Promise.race([ + operation, + new Promise((_, reject) => { timer = setTimeout(() => { + cleanup(); + reject(new Error('Timed out after 8 seconds')); + }, LIMIT); }), + ]).finally(() => clearTimeout(timer)); +} + +export function request(req) { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export function openDb(name) { + const req = indexedDB.open(name, 1); + let abandoned = false; + req.onupgradeneeded = () => { + req.result.createObjectStore('inline', { keyPath: 'id' }); + req.result.createObjectStore('explicit'); + }; + const promise = new Promise((resolve, reject) => { + req.onsuccess = () => { + if (abandoned) { req.result.close(); return; } + req.result.onversionchange = () => req.result.close(); + resolve(req.result); + }; + req.onerror = () => reject(req.error); + req.onblocked = () => { abandoned = true; reject(new Error('Diagnostic database open blocked')); }; + }); + return bounded(promise, () => { abandoned = true; }); +} + +async function roundTrip(value, inline, phase, cleanupErrors) { + const name = PREFIX + crypto.randomUUID(); + let db; + try { + phase('open'); + db = await openDb(name); + phase('write'); + const storeName = inline ? 'inline' : 'explicit'; + const tx = db.transaction(storeName, 'readwrite'); + await commit(tx, () => { + const store = tx.objectStore(storeName); + if (inline) store.put(value); else store.put(value, 'test'); + phase('commit'); + }); + phase('reopen'); + db.close(); + db = await openDb(name); + phase('read'); + const saved = await bounded(request(db.transaction(storeName).objectStore(storeName).get('test'))); + phase('validate'); + assert(saved != null, 'Readback returned null or undefined'); + return saved; + } finally { + db?.close(); + try { await bounded(request(indexedDB.deleteDatabase(name))); } + catch (error) { cleanupErrors.push(message(error)); } + } +} + +function validateKey(key, type, algorithm) { + assert(key?.type === type, 'Readback key has the wrong type or is missing'); + assert(key.extractable === false, 'Readback private/secret key is extractable'); + assert(key.algorithm?.name === algorithm, 'Readback key has the wrong algorithm'); +} + +async function exercise(algorithm, key, pair) { + const data = new Uint8Array([1, 3, 5, 7]); + if (algorithm === 'AES-GCM') { + const params = { name: algorithm, iv: crypto.getRandomValues(new Uint8Array(12)) }; + const ciphertext = await crypto.subtle.encrypt(params, key, data); + equal(await crypto.subtle.decrypt(params, pair, ciphertext), data); + } else if (algorithm === 'X25519') { + const params = { name: algorithm, public: pair.publicKey }; + equal(await crypto.subtle.deriveBits(params, key, 256), + await crypto.subtle.deriveBits(params, pair.privateKey, 256)); + } else { + const params = algorithm === 'ECDSA' ? { name: algorithm, hash: 'SHA-256' } : algorithm; + const signature = await crypto.subtle.sign(params, key, data); + assert(await crypto.subtle.verify(params, pair.publicKey, signature, data), 'Stored key signature did not verify'); + } +} + +function generate(algorithm) { + if (algorithm === 'AES-GCM') return crypto.subtle.generateKey({ name: algorithm, length: 256 }, false, ['encrypt', 'decrypt']); + if (algorithm === 'ECDSA') return crypto.subtle.generateKey({ name: algorithm, namedCurve: 'P-256' }, false, ['sign', 'verify']); + return crypto.subtle.generateKey(algorithm, false, algorithm === 'X25519' ? ['deriveBits'] : ['sign', 'verify']); +} + +export function environment() { + return { + userAgent: navigator.userAgent, + secureContext: globalThis.isSecureContext === true, + displayMode: globalThis.matchMedia?.('(display-mode: standalone)').matches || navigator.standalone === true ? 'standalone' : 'browser', + apiPresenceOnly: { + webCrypto: !!globalThis.crypto?.subtle, indexedDB: typeof indexedDB !== 'undefined', + structuredClone: typeof structuredClone === 'function', + webAuthn: typeof PublicKeyCredential !== 'undefined', + serviceWorker: 'serviceWorker' in navigator, pushManager: typeof PushManager !== 'undefined', + notificationPermission: typeof Notification === 'undefined' ? 'unavailable' : Notification.permission, + camera: !!navigator.mediaDevices?.getUserMedia, + webRTC: typeof RTCPeerConnection !== 'undefined', clipboard: !!navigator.clipboard?.writeText, + persistentStorage: !!navigator.storage?.persist, + }, + }; +} + +export async function runCapabilities(onResult = () => {}) { + const report = { version: HARNESS_VERSION, at: new Date().toISOString(), environment: environment(), results: [], cleanupErrors: [] }; + const check = async (id, label, operation) => { + let stage = 'start'; + const phase = next => { stage = next; }; + const started = performance.now(); + let result; + try { await operation(phase); result = { id, label, status: 'PASS' }; } + catch (error) { result = { id, label, status: 'FAIL', stage, error: message(error) }; } + result.ms = Math.round(performance.now() - started); + report.results.push(result); + onResult(result); + }; + for (const name of ['localStorage', 'sessionStorage']) { + await check(name, `${name}: disposable text round trip`, async phase => { + const id = PREFIX + crypto.randomUUID(); + phase('write/read'); + try { globalThis[name].setItem(id, 'test'); assert(globalThis[name].getItem(id) === 'test', 'Readback mismatch'); } + finally { globalThis[name].removeItem(id); } + }); + } + for (const inline of [true, false]) { + await check(`plain-${inline}`, `IndexedDB: plain record, ${inline ? 'inline' : 'explicit'} key`, async phase => { + const saved = await roundTrip({ id: 'test', value: 'ok' }, inline, phase, report.cleanupErrors); + assert(saved.id === 'test' && saved.value === 'ok', 'Plain record changed'); + }); + } + for (const algorithm of ['AES-GCM', 'X25519', 'Ed25519', 'ECDSA']) { + const label = algorithm === 'ECDSA' ? 'P-256 ECDSA' : algorithm; + for (const mode of ['memory', 'clone', 'inline', 'explicit']) { + await check(`${algorithm}-${mode}`, `${label}: ${mode === 'memory' ? 'generate and use' : mode === 'clone' ? 'structured clone and use' : `${mode} storage, reopen and use`}`, async phase => { + phase('generate'); + const pair = await bounded(generate(algorithm)); + const key = pair.privateKey || pair; + let saved = key; + if (mode === 'clone') { + phase('clone'); + saved = structuredClone({ id: 'test', key }).key; + } else if (mode === 'inline') { + saved = (await roundTrip({ id: 'test', key }, true, phase, report.cleanupErrors)).key; + } else if (mode === 'explicit') { + saved = await roundTrip(key, false, phase, report.cleanupErrors); + } + phase('validate-key'); + validateKey(saved, algorithm === 'AES-GCM' ? 'secret' : 'private', algorithm); + phase('use-key'); + await bounded(exercise(algorithm, saved, pair)); + }); + } + } + await check('encrypted-x25519', 'Production format: AES-protected X25519 with bound context, reopen and use', async phase => { + phase('generate'); + const pair = await bounded(generatePocketKeyPair('encrypted', 'diagnostic')); + const publicKeyRaw = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))); + const saved = await roundTrip({ + id: 'test', envelope: storePocketPrivateKey(pair.privateKey), publicKeyRaw, + }, true, phase, report.cleanupErrors); + phase('restore-production-key'); + const restored = await bounded(loadPocketPrivateKey(saved.envelope, 'diagnostic', saved.publicKeyRaw)); + phase('use-restored-key'); + validateKey(restored, 'private', 'X25519'); + await bounded(exercise('X25519', restored, pair)); + }); + return report; +} diff --git a/lib/pocket/diagnostics/index.html b/lib/pocket/diagnostics/index.html new file mode 100644 index 000000000..dd8e86351 --- /dev/null +++ b/lib/pocket/diagnostics/index.html @@ -0,0 +1,47 @@ + + + + + + + Pocket capability checks + + + + + + + + +
+

Pocket capability checks

+

For iOS, Android, and desktop browsers: run checks in the browser or installed app where pairing fails. Browser tabs and installed apps may have different storage and capabilities. No setup code is needed.

+

Test this browser with disposable keys and storage. Your Pocket pairings and passkeys are never opened or changed. Results stay here until you copy or download them.

+

These tests do not request camera, notification, or passkey permission. The encrypted-key experiment is a capability test, not a change to Pocket security.

+ + + +

Ready. No setup code needed.

+
    +
    Full report
    +

    Restart persistence test

    +

    Harness v3 tests the production encrypted-key format, including its authenticated context. Old checkpoints must be removed and prepared again; earlier restart results tested an experimental format.

    +

    For the installed-app test, use your browser menu to install or add this page to your Home Screen as Pocket Key Test, then open that new icon. If installation is unavailable, test in the same browser tab instead. Keep your existing Pocket app installed.

    +
      +
    1. Tap Prepare restart test here. It retains one disposable encrypted key, not a pairing.
    2. +
    3. Fully close this app using the app switcher, then reopen the same icon. Do not prepare again.
    4. +
    5. Tap Verify saved key, then Copy restart result. You can repeat after a phone restart.
    6. +
    7. When finished, tap Remove test data in each browser/app where you prepared a test.
    8. +
    +

    A new page instance is checked, but only you can confirm that you force-quit or restarted the phone. Reload alone is not proof of either. If the app resumes the old page, tap Reload test page.

    + + + + + +

    No restart operation has run in this page.

    + + Back to Pocket +
    + + diff --git a/lib/pocket/diagnostics/page.js b/lib/pocket/diagnostics/page.js new file mode 100644 index 000000000..90fe0f12a --- /dev/null +++ b/lib/pocket/diagnostics/page.js @@ -0,0 +1,46 @@ +import { HARNESS_VERSION, runCapabilities } from './capabilities.js'; +const run = document.getElementById('run'); +const copy = document.getElementById('copy'); +const download = document.getElementById('download'); +const status = document.getElementById('status'); +const results = document.getElementById('results'); +const report = document.getElementById('report'); +status.textContent = `Ready. Harness v${HARNESS_VERSION}. No setup code needed.`; +run.onclick = async () => { + run.disabled = true; + copy.disabled = download.disabled = true; + results.replaceChildren(); + report.value = ''; + status.textContent = 'Running checks. Keep this page open.'; + try { + const data = await runCapabilities(result => { + const row = document.createElement('li'); + const title = document.createElement('strong'); + title.textContent = `${result.status}: ${result.label}`; + row.append(title); + if (result.error) row.append(document.createTextNode(`${result.stage}: ${result.error}`)); + results.append(row); + status.textContent = `Completed ${results.children.length} checks...`; + }); + report.value = JSON.stringify(data, null, 2); + const failed = data.results.filter(result => result.status === 'FAIL').length; + status.textContent = `Done: ${data.results.length - failed} passed, ${failed} failed. ${data.cleanupErrors.length ? 'Some test storage could not be removed; see report.' : 'Disposable test storage removed.'}`; + copy.disabled = download.disabled = false; + } catch (error) { + status.textContent = `Harness could not finish: ${error.message}`; + } finally { run.disabled = false; } +}; +copy.onclick = async () => { + try { await navigator.clipboard.writeText(report.value); status.textContent = 'Results copied. Paste them into the conversation.'; } + catch { + document.querySelector('details').open = true; + report.focus(); report.select(); + status.textContent = 'Select and copy the report below.'; + } +}; +download.onclick = () => { + const url = URL.createObjectURL(new Blob([report.value], { type: 'application/json' })); + const link = document.createElement('a'); + link.href = url; link.download = 'pocket-capabilities.json'; + link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); +}; diff --git a/lib/pocket/diagnostics/restart-page.js b/lib/pocket/diagnostics/restart-page.js new file mode 100644 index 000000000..d14b42ed3 --- /dev/null +++ b/lib/pocket/diagnostics/restart-page.js @@ -0,0 +1,29 @@ +import { prepareRestart, verifyRestart, clearRestart } from './restart.js'; +const status = document.getElementById('restart-status'); +const report = document.getElementById('restart-report'); +const buttons = [...document.querySelectorAll('[data-restart-action]')]; +for (const [id, action] of [ + ['prepare-restart', prepareRestart], + ['verify-restart', verifyRestart], + ['clear-restart', clearRestart], +]) { + document.getElementById(id).onclick = async () => { + buttons.forEach(button => { button.disabled = true; }); + status.textContent = 'Working...'; + try { + const data = await action(); + report.value = JSON.stringify(data, null, 2); + status.textContent = data.status === 'PREPARED' + ? 'Prepared. Fully close this app, reopen its Home Screen icon, then tap Verify saved key. Do not prepare again.' + : data.status === 'REMOVED' ? 'Disposable restart checkpoint removed. Pocket data unchanged.' + : data.status === 'PASS' ? 'PASS: saved key recovered and used in a new page instance. Copy restart result.' + : `FAIL: ${data.stage}: ${data.error}`; + } catch (error) { status.textContent = `Could not finish: ${error.message}`; } + finally { buttons.forEach(button => { button.disabled = false; }); } + }; +} +document.getElementById('reload-restart').onclick = () => location.reload(); +document.getElementById('copy-restart').onclick = async () => { + try { await navigator.clipboard.writeText(report.value); status.textContent = 'Restart result copied.'; } + catch { report.focus(); report.select(); status.textContent = 'Select and copy the restart report below.'; } +}; diff --git a/lib/pocket/diagnostics/restart.js b/lib/pocket/diagnostics/restart.js new file mode 100644 index 000000000..255bd1955 --- /dev/null +++ b/lib/pocket/diagnostics/restart.js @@ -0,0 +1,72 @@ +import { + HARNESS_VERSION, assert, bounded, commit, environment, equal, message, openDb, request, +} from './capabilities.js'; +import { toBase64Url } from 'remote-lib-common'; +import { + generatePocketKeyPair, loadPocketPrivateKey, storePocketPrivateKey, +} from '../../src/remote/client/pocket-private-key'; + +// This fixed diagnostic-only database retains one disposable checkpoint until +// explicit cleanup. Never open Pocket's authorization database or export keys. +const DATABASE = 'dormouse-capability-probe-restart-v1'; +const PAGE = crypto.randomUUID(); +async function read() { + const db = await openDb(DATABASE); + try { return await bounded(request(db.transaction('inline').objectStore('inline').get('test'))); } + finally { db.close(); } +} + +async function write(record) { + const db = await openDb(DATABASE); + try { + const tx = db.transaction('inline', 'readwrite'); + await commit(tx, () => tx.objectStore('inline').put(record), 'Checkpoint transaction aborted'); + } finally { db.close(); } +} + +export async function prepareRestart() { + assert(!(await read()), 'A checkpoint already exists. Verify it or remove test data before preparing another.'); + const pair = await bounded(generatePocketKeyPair('encrypted', 'diagnostic-restart')); + const publicKeyRaw = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))); + const peer = await bounded(crypto.subtle.generateKey('X25519', false, ['deriveBits'])); + const expected = await bounded(crypto.subtle.deriveBits({ name: 'X25519', public: peer.publicKey }, pair.privateKey, 256)); + const peerPublic = await bounded(crypto.subtle.exportKey('raw', peer.publicKey)); + const record = { id: 'test', schema: 2, page: PAGE, preparedAt: new Date().toISOString(), + preparedEnvironment: environment(), envelope: storePocketPrivateKey(pair.privateKey), + publicKeyRaw, peerPublic, expected }; + await write(record); + return { status: 'PREPARED', preparedAt: record.preparedAt, environment: record.preparedEnvironment }; +} + +export async function verifyRestart() { + const report = { version: HARNESS_VERSION, test: 'encrypted-x25519-restart', + format: 'aes-gcm-x25519-v1', authenticatedContext: true, + at: new Date().toISOString(), environment: environment(), + restartEvidence: 'A new page instance is detectable; force-quit or OS restart requires user confirmation.' }; + let stage = 'read-checkpoint'; + try { + const saved = await read(); + assert(saved != null, 'No checkpoint found in this browser/app. Prepare it here before closing this app; Safari and Home Screen storage may differ.'); + assert(saved.schema === 2, 'Legacy or unknown checkpoint: remove test data and prepare a new production-format test.'); + report.preparedAt = saved.preparedAt; + report.preparedEnvironment = saved.preparedEnvironment; + report.newPageInstance = saved.page !== PAGE; + stage = 'check-new-page'; + assert(report.newPageInstance, 'This is still the page that prepared the checkpoint. Close and reopen the app; if it resumes this page, use Reload test page.'); + stage = 'restore-production-key'; + const key = await bounded(loadPocketPrivateKey(saved.envelope, 'diagnostic-restart', saved.publicKeyRaw)); + assert(key.type === 'private' && key.extractable === false, 'Recovered private key is invalid'); + stage = 'derive-and-compare'; + const peer = await bounded(crypto.subtle.importKey('raw', saved.peerPublic, 'X25519', false, [])); + equal(await bounded(crypto.subtle.deriveBits({ name: 'X25519', public: peer }, key, 256)), + saved.expected, 'Recovered key produced a different shared secret'); + return { ...report, status: 'PASS', retained: true }; + } catch (error) { + return { ...report, status: 'FAIL', stage, error: message(error), retained: true }; + } +} + +export async function clearRestart() { + await bounded(request(indexedDB.deleteDatabase(DATABASE))); + return { status: 'REMOVED', test: 'encrypted-x25519-restart' }; +} diff --git a/lib/pocket/public/diagnostics/manifest.webmanifest b/lib/pocket/public/diagnostics/manifest.webmanifest new file mode 100644 index 000000000..f66b2f428 --- /dev/null +++ b/lib/pocket/public/diagnostics/manifest.webmanifest @@ -0,0 +1,8 @@ +{ + "id": "/diagnostics/", + "name": "Pocket Key Test", + "short_name": "Pocket Key Test", + "start_url": "/diagnostics/index.html", + "scope": "/diagnostics/", + "display": "standalone" +} diff --git a/lib/pocket/public/diagnostics/style.css b/lib/pocket/public/diagnostics/style.css new file mode 100644 index 000000000..6e1184d8c --- /dev/null +++ b/lib/pocket/public/diagnostics/style.css @@ -0,0 +1,9 @@ +:root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; background: Canvas; color: CanvasText; } +body { margin: 0; } +main { max-width: 48rem; margin: auto; padding: 1.25rem; } +h1 { font-size: 1.6rem; } +button { font: inherit; min-height: 44px; padding: .5rem .8rem; margin: .25rem .25rem .25rem 0; } +li { margin: .6rem 0; overflow-wrap: anywhere; } +li strong { display: block; } +textarea { box-sizing: border-box; width: 100%; min-height: 18rem; font: .85rem/1.4 monospace; } +a { color: LinkText; } diff --git a/lib/scripts/assert-pocket-worker.mjs b/lib/scripts/assert-pocket-worker.mjs index a66c6bfb6..dba3ade6d 100644 --- a/lib/scripts/assert-pocket-worker.mjs +++ b/lib/scripts/assert-pocket-worker.mjs @@ -167,6 +167,9 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { console.log(`pocket worker ok: ${WORKER_FILE}, ${bytes} bytes, classic and self-contained`); const scripts = assertPocketShell(outDir); console.log(`pocket shell ok: ${SHELL_FILE}, ${scripts} script(s), all same-origin and external`); + const diagnosticScripts = assertPocketShell(join(outDir, 'diagnostics')); + if (diagnosticScripts === 0) throw new Error('the diagnostic shell has no executable entry'); + console.log(`diagnostic shell ok: ${diagnosticScripts} script(s), all same-origin and external`); } catch (error) { console.error(`pocket build check failed: ${error.message}`); process.exit(1); diff --git a/lib/src/remote/client/capability-harness-page.test.ts b/lib/src/remote/client/capability-harness-page.test.ts new file mode 100644 index 000000000..36f981d07 --- /dev/null +++ b/lib/src/remote/client/capability-harness-page.test.ts @@ -0,0 +1,63 @@ +/** @vitest-environment jsdom */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +// @ts-ignore Shared JavaScript build assertion. +import { assertPocketShell } from '../../../scripts/assert-pocket-worker.mjs'; + +const fake = vi.hoisted(() => ({ run: vi.fn(), prepare: vi.fn(), verify: vi.fn(), clear: vi.fn() })); +vi.mock('../../../pocket/diagnostics/restart.js', () => ({ + prepareRestart: fake.prepare, verifyRestart: fake.verify, clearRestart: fake.clear, +})); +vi.mock('../../../pocket/diagnostics/capabilities.js', () => ({ + HARNESS_VERSION: '1', runCapabilities: fake.run, +})); +afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren(); }); + +it('wires explicit restart preparation, verification, copy, and cleanup', async () => { + const html = readFileSync(resolve('pocket/diagnostics/index.html'), 'utf8'); + document.body.innerHTML = html.match(/([\s\S]*)<\/body>/)![1]!; + fake.prepare.mockResolvedValue({ status: 'PREPARED' }); + fake.verify.mockResolvedValue({ status: 'PASS', newPageInstance: true }); + fake.clear.mockResolvedValue({ status: 'REMOVED' }); + const copy = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText: copy } }); + // @ts-ignore Browser-native JavaScript entry point. + await import('../../../pocket/diagnostics/restart-page.js'); + expect(fake.prepare).not.toHaveBeenCalled(); + document.getElementById('prepare-restart')!.click(); + await vi.waitFor(() => expect(document.getElementById('restart-status')!.textContent).toContain('Prepared.')); + document.getElementById('verify-restart')!.click(); + await vi.waitFor(() => expect(document.getElementById('restart-status')!.textContent).toContain('PASS:')); + document.getElementById('copy-restart')!.click(); + expect(JSON.parse(copy.mock.calls[0]![0])).toMatchObject({ status: 'PASS' }); + document.getElementById('clear-restart')!.click(); + await vi.waitFor(() => expect(document.getElementById('restart-status')!.textContent).toContain('checkpoint removed')); +}); + +it('renders completed and failed checks and copies a report without HTML injection', async () => { + const root = resolve('pocket/diagnostics'); + expect(assertPocketShell(root)).toBe(2); + const manifest = JSON.parse(readFileSync(resolve('pocket/public/diagnostics/manifest.webmanifest'), 'utf8')); + expect(manifest).toMatchObject({ id: '/diagnostics/', start_url: '/diagnostics/index.html', display: 'standalone' }); + const html = readFileSync(resolve(root, 'index.html'), 'utf8'); + document.body.innerHTML = html.match(/([\s\S]*)<\/body>/)![1]!; + const rows = [ + { id: 'aes', label: 'AES-GCM', status: 'PASS' }, + { id: 'x', label: 'X25519', status: 'FAIL', stage: 'write', error: ' DataError' }, + ]; + fake.run.mockImplementation(async onResult => { + rows.forEach(onResult); + return { results: rows, cleanupErrors: [] }; + }); + const copy = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText: copy } }); + // @ts-ignore Browser-native JavaScript entry point. + await import('../../../pocket/diagnostics/page.js'); + document.getElementById('run')!.click(); + await vi.waitFor(() => expect(document.getElementById('status')!.textContent).toContain('1 passed, 1 failed')); + expect(document.querySelectorAll('#results li')).toHaveLength(2); + expect(document.querySelector('#results img')).toBeNull(); + document.getElementById('copy')!.click(); + expect(JSON.parse(copy.mock.calls[0]![0]).results).toEqual(rows); +}); diff --git a/lib/src/remote/client/capability-harness.test.ts b/lib/src/remote/client/capability-harness.test.ts new file mode 100644 index 000000000..268d9eae1 --- /dev/null +++ b/lib/src/remote/client/capability-harness.test.ts @@ -0,0 +1,114 @@ +import { webcrypto } from 'node:crypto'; +import { IDBFactory, IDBObjectStore } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +// Public diagnostic module is intentionally standalone and browser-native. +// @ts-ignore JavaScript artifact has no separate type declaration. +import { commit, request, runCapabilities } from '../../../pocket/diagnostics/capabilities.js'; + +beforeEach(() => { + vi.stubGlobal('crypto', webcrypto); + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { userAgent: 'Node control (not iOS)' }); + for (const storage of ['localStorage', 'sessionStorage']) { + const values = new Map(); + vi.stubGlobal(storage, { + setItem: (key: string, value: string) => values.set(key, value), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + }); + } +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +// @ts-ignore Browser-native diagnostic module. +const restartModule = () => import('../../../pocket/diagnostics/restart.js'); + +it('retains an isolated checkpoint, rejects same-page verification, and recovers after module restart', async () => { + vi.resetModules(); + const first = await restartModule(); + expect(await first.prepareRestart()).toMatchObject({ status: 'PREPARED' }); + expect(await first.verifyRestart()).toMatchObject({ status: 'FAIL', stage: 'check-new-page' }); + await expect(first.prepareRestart()).rejects.toThrow('already exists'); + vi.resetModules(); + const second = await restartModule(); + const report = await second.verifyRestart(); + expect(report).toMatchObject({ + version: '3', format: 'aes-gcm-x25519-v1', authenticatedContext: true, + status: 'PASS', newPageInstance: true, retained: true, + }); + expect(JSON.stringify(report)).not.toMatch(/ciphertext|peerPublic|expected|privateKey/); + expect(await second.verifyRestart()).toMatchObject({ status: 'PASS' }); + expect(await indexedDB.databases()).toEqual([{ name: 'dormouse-capability-probe-restart-v1', version: 1 }]); + await second.clearRestart(); + expect(await indexedDB.databases()).toEqual([]); + expect(await second.verifyRestart()).toMatchObject({ status: 'FAIL', stage: 'read-checkpoint' }); + await second.clearRestart(); +}); + +it('fails closed when a retained checkpoint ciphertext is corrupted', async () => { + vi.resetModules(); + const first = await restartModule(); + await first.prepareRestart(); + const db: IDBDatabase = await request(indexedDB.open('dormouse-capability-probe-restart-v1')); + const tx = db.transaction('inline', 'readwrite'); + const store = tx.objectStore('inline'); + const saved = await request(store.get('test')); + await commit(tx, () => { + new Uint8Array(saved.envelope.ciphertext)[0] ^= 1; + store.put(saved); + }); + db.close(); + vi.resetModules(); + const second = await restartModule(); + expect(await second.verifyRestart()).toMatchObject({ status: 'FAIL', stage: 'restore-production-key' }); + await second.clearRestart(); +}); + +it('runs real crypto operations, including encrypted X25519, without touching Pocket state', async () => { + const open = vi.spyOn(indexedDB, 'open'); + const report = await runCapabilities(); + expect(report.results).toHaveLength(21); + expect(report.results.filter((row: { status: string }) => row.status !== 'PASS')).toEqual([]); + expect(report.cleanupErrors).toEqual([]); + expect(open.mock.calls.every(([name]) => name.startsWith('dormouse-capability-probe-'))).toBe(true); + expect(await indexedDB.databases()).toEqual([]); +}); + +it.each(['legacy', 'context', 'public-key'])('rejects a %s checkpoint without replacing it', async change => { + vi.resetModules(); + const first = await restartModule(); + await first.prepareRestart(); + const db: IDBDatabase = await request(indexedDB.open('dormouse-capability-probe-restart-v1')); + const tx = db.transaction('inline', 'readwrite'); + const store = tx.objectStore('inline'); + const saved = await request(store.get('test')); + if (change === 'legacy') saved.schema = 1; + if (change === 'context') saved.envelope.context = 'wrong context'; + if (change === 'public-key') saved.publicKeyRaw = 'wrong public key'; + await commit(tx, () => store.put(saved)); + db.close(); + vi.resetModules(); + const second = await restartModule(); + const report = await second.verifyRestart(); + expect(report).toMatchObject({ + status: 'FAIL', stage: change === 'legacy' ? 'read-checkpoint' : 'restore-production-key', + }); + if (change === 'legacy') expect(report.error).toContain('prepare a new production-format test'); + await expect(second.prepareRestart()).rejects.toThrow('already exists'); + await second.clearRestart(); +}); + +it('continues after simulated WebKit X25519 failures and distinguishes missing readback', async () => { + const put = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (value, key) { + if (value?.key?.algorithm?.name === 'X25519') throw new DOMException('clone failed', 'DataError'); + if (value?.algorithm?.name === 'X25519') return put.call(this, null, key); + return put.call(this, value, key); + }); + const report = await runCapabilities(); + const byId = (id: string) => report.results.find((row: { id: string }) => row.id === id); + expect(byId('X25519-inline')).toMatchObject({ status: 'FAIL', stage: 'write' }); + expect(byId('X25519-explicit')).toMatchObject({ status: 'FAIL', stage: 'validate' }); + expect(byId('encrypted-x25519')).toMatchObject({ status: 'PASS' }); + expect(await indexedDB.databases()).toEqual([]); +}); diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index c723e6146..8b33a0565 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1267,6 +1267,9 @@ describe('push registration by capability', () => { await seedRecord(harness.knownBurrows, 'h1'); expect(harness.client.registeredPushEndpoint()).toBeNull(); + // Push registration needs only metadata, even if the private key will not decode. + vi.spyOn(harness.knownBurrows, 'get').mockRejectedValue(new Error('unreadable private key')); + await harness.client.subscribeToPush('h1', SUBSCRIPTION); const call = harness.calls.find((c) => c.url.endsWith('/api/push/subscribe'))!; diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index c045d7939..ed8b713a7 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -28,7 +28,6 @@ import { createNoiseInitiator, e2eConnectionPrologue, fromBase64Url, - generateNoiseKeyPair, hashPasskeyPublicKey, isConnectionOutcomeV1, isE2eRelayToClientFrame, @@ -87,9 +86,11 @@ import { import { type KnownBurrowStore, type KnownBurrowV1, + type KnownBurrowSummary, type PendingDeletionStore, } from './pocket-db'; import { DirectEndpoint } from '../direct/direct-endpoint'; +import { SCAN_LABEL } from '../setup-copy'; import type { DirectPeerFactory } from '../direct/direct-peer'; import { realTimer, type RemoteTimer, type RemoteWebSocket } from '../ws'; @@ -311,6 +312,12 @@ export const CONNECTION_DENIAL_MESSAGES: Record = export const BURROW_UNAVAILABLE_MESSAGE = 'The computer did not answer. Check that it is awake and connected, then try again.'; +/** Fixed local-read recovery copy: browser errors can contain private details. */ +export const CONNECTION_RECORD_UNREADABLE_MESSAGE = + 'This browser could not read the saved pairing record. ' + + `Try again, or use ${SCAN_LABEL} to pair again with fresh approval. ` + + 'Diagnostics: /diagnostics/index.html.'; + /** Where a pairing ended, as the UI reports it. */ export type PairingResult = | { readonly ok: true; readonly record: KnownBurrowV1 } @@ -584,8 +591,8 @@ export class PocketClient { // --- The pinned Burrows ---------------------------------------------------- /** Every Burrow this browser holds a record for, paired or not. */ - listKnownBurrows(): Promise { - return this.#knownBurrows.list(); + listKnownBurrows(): Promise { + return this.#knownBurrows.listSummaries(); } /** @@ -594,7 +601,7 @@ export class PocketClient { * push row nothing can name again. */ async forgetBurrow(burrowId: string): Promise { - const record = await this.#knownBurrows.get(burrowId); + const record = await this.#knownBurrows.getSummary(burrowId); if (record?.authorization.state === 'paired') { await this.#tombstone(burrowId, record.authorization.deliveryId); } @@ -625,7 +632,7 @@ export class PocketClient { * capability for (`docs/specs/relay.md` → Web Push). */ async listPushSubscribedBurrows(): Promise { - const deliveryIds = (await this.#knownBurrows.list()) + const deliveryIds = (await this.#knownBurrows.listSummaries()) .flatMap((record) => record.authorization.state === 'paired' ? [record.authorization.deliveryId] : [], ) @@ -650,7 +657,7 @@ export class PocketClient { burrowId: string, subscription: PushSubscriptionPayload, ): Promise { - const record = await this.#knownBurrows.get(burrowId); + const record = await this.#knownBurrows.getSummary(burrowId); if (record?.authorization.state !== 'paired') { throw new Error('this phone is not paired with that computer'); } @@ -761,7 +768,7 @@ export class PocketClient { const deadline = this.#now() + DEFAULT_PAIRING_TTL_MS; const { burrowId, inviteId } = invitation; const route = { kind: 'pairing', id: inviteId, burrowId } as const; - const clientStatic = await generateNoiseKeyPair(); + const clientStatic = await this.#knownBurrows.generateKey(burrowId); const handshake = await createNoiseInitiator({ prologue: pairingInvitationPrologue(invitation), staticKeyPair: clientStatic, @@ -824,7 +831,7 @@ export class PocketClient { ) { return { ok: false, message: PAIRING_DENIAL_MESSAGES['burrow-error'] }; } - const existing = await this.#knownBurrows.get(burrowId); + const existing = await this.#knownBurrows.getSummary(burrowId); if (existing && existing.burrowStaticPublicKey !== outcome.burrowStaticPublicKey) { // Terminal, and the old record is untouched — see BurrowIdentityMismatchError. throw new BurrowIdentityMismatchError(); @@ -867,7 +874,18 @@ export class PocketClient { */ async connect(burrowId: string): Promise { await this.#ensureSocket(); - const record = await this.#knownBurrows.get(burrowId); + let record: KnownBurrowV1 | null; + try { + record = await this.#knownBurrows.get(burrowId); + } catch { + // A local read failure is not an authenticated revocation. Preserve the + // pin and delivery capability, and never expose browser exception text. + return { + ok: false, + message: CONNECTION_RECORD_UNREADABLE_MESSAGE, + pairingRequired: false, + }; + } if (!record) { return { ok: false, message: CONNECTION_DENIAL_MESSAGES['pairing-required'], pairingRequired: true }; } diff --git a/lib/src/remote/client/pocket-db.test.ts b/lib/src/remote/client/pocket-db.test.ts index a2e659ff8..8ce307981 100644 --- a/lib/src/remote/client/pocket-db.test.ts +++ b/lib/src/remote/client/pocket-db.test.ts @@ -2,10 +2,9 @@ * Pocket's IndexedDB layout (`docs/specs/pocket-app.md` → "What Pocket * stores"): the v4 upgrade, and the two stores it leaves behind. * - * `fake-indexeddb` structured-clones what it is handed, and a `CryptoKey` is - * not cloneable there, so the records below carry plain stand-ins where the - * real ones carry keys. What is under test is the database shape and the store - * operations, not what a browser does with key material. + * These schema tests use plain key stand-ins. `pocket-key-storage.test.ts` + * exercises real keys with Node's structured clone; neither emulates WebKit's + * platform-specific key serialization. */ import 'fake-indexeddb/auto'; @@ -37,7 +36,9 @@ function knownBurrow(burrowId: string, overrides: Partial = {}): burrowStaticPublicKey: 'aG9zdC1zdGF0aWM', clientStaticKeyPair: { // A stand-in: see the file header. - privateKey: { kind: 'private' } as unknown as CryptoKey, + privateKey: { + type: 'private', extractable: false, algorithm: { name: 'X25519' }, usages: ['deriveBits'], + } as unknown as CryptoKey, publicKeyRaw: 'Y2xpZW50LXN0YXRpYw', }, passkeyCredentialId: 'cred-1', @@ -217,19 +218,19 @@ describe('the pocket database', () => { await store.put(knownBurrow('burrow-2', { authorization: { state: 'pairing-required' } })); expect((await store.get('burrow-1'))?.label).toBe('Laptop'); expect((await store.get('burrow-2'))?.authorization).toEqual({ state: 'pairing-required' }); - expect((await store.list()).map((record) => record.burrowId).sort()).toEqual([ + expect((await store.listSummaries()).map((record) => record.burrowId).sort()).toEqual([ 'burrow-1', 'burrow-2', ]); // Keyed by `burrowId`, so a second put for the same Burrow replaces it. await store.put(knownBurrow('burrow-1', { label: 'Renamed' })); - expect(await store.list()).toHaveLength(2); + expect(await store.listSummaries()).toHaveLength(2); expect((await store.get('burrow-1'))?.label).toBe('Renamed'); await store.delete('burrow-1'); expect(await store.get('burrow-1')).toBeNull(); - expect(await store.list()).toHaveLength(1); + expect(await store.listSummaries()).toHaveLength(1); }); it('files a pending deletion under burrowId:deliveryId', async () => { diff --git a/lib/src/remote/client/pocket-db.ts b/lib/src/remote/client/pocket-db.ts index 6b64ac7a3..60a3a5f5c 100644 --- a/lib/src/remote/client/pocket-db.ts +++ b/lib/src/remote/client/pocket-db.ts @@ -9,6 +9,12 @@ * `docs/specs/remote-security-model.md`. */ +import { constantTimeEqual, toBase64Url, type NoiseKeyPair } from 'remote-lib-common'; +import { + generatePocketKeyPair, loadPocketPrivateKey, storePocketPrivateKey, + type PocketKeyStorageMode, type StoredPocketPrivateKey, +} from './pocket-private-key'; + export const POCKET_DB_NAME = 'dormouse-pocket'; /** @@ -30,6 +36,147 @@ export const DEVICE_KEY_STORE = 'device-key'; export const KNOWN_BURROWS_STORE = 'known-burrows'; export const PENDING_DELETIONS_STORE = 'pending-deletions'; +export const POCKET_KEY_STORAGE_ERROR = + 'This browser could not save and reload the private key needed for pairing. ' + + 'Pairing has not started. No permission dialog is expected. ' + + 'Keep your existing passkey and website data. Run /diagnostics/index.html and report the results.'; + +/** One side of an X25519 agreement against `publicKey`; the probe's only measurement. */ +function deriveShared(publicKey: CryptoKey, key: CryptoKey): Promise { + return crypto.subtle.deriveBits({ name: 'X25519', public: publicKey }, key, 256); +} + +/** + * Write inside `tx` and wait for it to commit, aborting on a throw so a failed + * probe leaves no half-open transaction behind. `write` runs inside the try, so + * a `DataCloneError` on the `put` itself takes the same path as a failed commit. + */ +async function commitOrAbort(tx: IDBTransaction, write: () => void): Promise { + const done = promisifyTransaction(tx); + try { + write(); + await done; + } catch (error) { + try { tx.abort(); } catch { /* It may have already finished. */ } + await done.catch(() => {}); + throw error; + } +} + +function storageErrorName(error: unknown): string { + // Fixed names only: browser error messages can contain private details. + return error instanceof Error && [ + 'DataError', 'DataCloneError', 'SecurityError', 'QuotaExceededError', + 'InvalidAccessError', 'InvalidStateError', 'NotSupportedError', + 'OperationError', 'AbortError', 'UnknownError', 'TypeError', + ].includes(error.name) ? error.name : 'Error'; +} + +/** + * WebKit checks inline keys on a clone, so a failed embedded-key clone can + * appear to be a missing burrowId. Reopen and use the key, not just write it. + * The disposable database never opens or resets the user's pairing records. + */ +export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native'): Promise { + let db: IDBDatabase | undefined; + let name: string | undefined; + let stage = 'generate-key'; + let pair: CryptoKeyPair | undefined; + const open = (databaseName: string) => { + const request = indexedDB.open(databaseName, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(KNOWN_BURROWS_STORE, { keyPath: 'burrowId' }); + }; + return promisifyRequest(request); + }; + try { + pair = await generatePocketKeyPair(mode, 'probe'); + const publicKeyRaw = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))); + stage = 'use-original-key'; + const expected = new Uint8Array(await deriveShared(pair.publicKey, pair.privateKey)); + stage = 'open-database'; + name = `dormouse-pocket-key-probe-${crypto.randomUUID()}`; + db = await open(name); + stage = 'write-record'; + const tx = db.transaction(KNOWN_BURROWS_STORE, 'readwrite'); + await commitOrAbort(tx, () => { + tx.objectStore(KNOWN_BURROWS_STORE).put({ + burrowId: 'probe', clientStaticKeyPair: { + privateKey: storePocketPrivateKey(pair!.privateKey), publicKeyRaw, + }, + }); + stage = 'commit-record'; + }); + db.close(); + stage = 'reopen-database'; + db = await open(name); + stage = 'read-record'; + const saved = await promisifyRequest(db.transaction(KNOWN_BURROWS_STORE) + .objectStore(KNOWN_BURROWS_STORE).get('probe')); + stage = 'validate-record'; + const key = saved?.clientStaticKeyPair?.privateKey + ? await loadPocketPrivateKey(saved.clientStaticKeyPair.privateKey, 'probe', publicKeyRaw) + : undefined; + if (saved?.burrowId !== 'probe' || key?.type !== 'private' || key.extractable !== false) { + throw new Error('stored key did not survive'); + } + stage = 'use-reloaded-key'; + const actual = new Uint8Array(await deriveShared(pair.publicKey, key)); + stage = 'compare-key-agreement'; + if (!constantTimeEqual(actual, expected)) throw new Error('stored key changed'); + } catch (error) { + const failure = `${stage} / ${storageErrorName(error)}`; + throw new Error(`Diagnostic: ${failure}.`); + } finally { + db?.close(); + if (name) { + // Cleanup must not mask the diagnostic or strand the UI. + try { indexedDB.deleteDatabase(name); } catch { /* Best effort. */ } + } + } +} + +let storageProbe: Promise | undefined; + +/** A storage/key failure invalidates compatibility evidence for this page. */ +export function invalidatePocketKeyStorage(): void { + storageProbe = undefined; +} + +function selectedKeyStorage(): Promise { + if (storageProbe) return storageProbe; + const pending = selectKeyStorage(); + storageProbe = pending; + // Attach the rejection handler immediately; do not retain failed evidence. + void pending.catch(() => { + if (storageProbe === pending) invalidatePocketKeyStorage(); + }); + return pending; +} + +/** Select only a format that actually survives reopen and key agreement. */ +export async function requirePocketKeyStorage(): Promise { + const pending = selectedKeyStorage(); + await pending; + if (storageProbe !== pending) throw new Error(POCKET_KEY_STORAGE_ERROR); +} + +async function selectKeyStorage(): Promise { + try { + await probePocketKeyStorage('native'); + return 'native'; + } catch (nativeError) { + try { + await probePocketKeyStorage('encrypted'); + return 'encrypted'; + } catch (encryptedError) { + // Both probe errors contain only fixed diagnostics, never browser messages. + throw new Error(`${POCKET_KEY_STORAGE_ERROR} ${(nativeError as Error).message}` + + ` Encrypted storage: ${(encryptedError as Error).message}`); + } + } +} + /** * What {@link KNOWN_BURROWS_STORE} was called before the Burrow rename. Dropped * at v4 rather than re-keyed: every record in it names a `hostId` this build has @@ -57,7 +204,7 @@ export type KnownBurrowAuthorization = * One Burrow this Client has paired with, keyed by `burrowId`. * * The Client static is per Burrow and never shared between them, and its private - * half is a nonextractable `CryptoKey` stored directly — never exported. + * half is nonextractable at runtime. The store owns its at-rest encoding. */ export interface KnownBurrowV1 { readonly burrowId: string; @@ -82,6 +229,16 @@ export interface KnownBurrowV1 { readonly authorization: KnownBurrowAuthorization; } +/** Display/push/removal metadata; never includes either private-key encoding. */ +export type KnownBurrowSummary = Omit; + +export function summarizeKnownBurrow( + record: KnownBurrowSummary & { readonly clientStaticKeyPair: unknown }, +): KnownBurrowSummary { + const { clientStaticKeyPair: _key, ...summary } = record; + return summary; +} + /** * A delivery mapping this Client owes the Relay a deletion for, written * *before* the `KnownBurrowV1` forgets the id — the id is the only handle that @@ -95,10 +252,13 @@ export interface PendingDeliveryDeletionV1 { /** Where {@link KnownBurrowV1} records live; faked in tests. */ export interface KnownBurrowStore { + /** Production stores generate a key in a verified, persistable format. */ + generateKey(burrowId: string): Promise; get(burrowId: string): Promise; + getSummary(burrowId: string): Promise; + listSummaries(): Promise; put(record: KnownBurrowV1): Promise; delete(burrowId: string): Promise; - list(): Promise; } /** Where {@link PendingDeliveryDeletionV1} tombstones live; faked in tests. */ @@ -244,9 +404,15 @@ export async function withPocketStore( mode: IDBTransactionMode, run: (store: IDBObjectStore) => Promise, ): Promise { - const db = await openPocketDb(); + const db = await openPocketDb().catch(error => { + invalidatePocketKeyStorage(); + throw error; + }); try { return await run(db.transaction(storeName, mode).objectStore(storeName)); + } catch (error) { + invalidatePocketKeyStorage(); + throw error; } finally { db.close(); } @@ -254,17 +420,57 @@ export async function withPocketStore( /** The IndexedDB-backed {@link KnownBurrowStore}. */ export function indexedDbKnownBurrowStore(): KnownBurrowStore { + type StoredRecord = Omit & { + clientStaticKeyPair: { privateKey: StoredPocketPrivateKey; publicKeyRaw: string }; + }; + const restore = async (value: StoredRecord): Promise => ({ + ...value, + clientStaticKeyPair: { + ...value.clientStaticKeyPair, + privateKey: await loadPocketPrivateKey(value.clientStaticKeyPair.privateKey, + value.burrowId, value.clientStaticKeyPair.publicKeyRaw), + }, + }); return { + async generateKey(burrowId) { + try { + const pending = selectedKeyStorage(); + const mode = await pending; + if (storageProbe !== pending) throw new Error(POCKET_KEY_STORAGE_ERROR); + const pair = await generatePocketKeyPair(mode, burrowId); + return { + privateKey: pair.privateKey, + publicKey: new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)), + }; + } catch (error) { + invalidatePocketKeyStorage(); + throw error; + } + }, + getSummary: (burrowId) => + withPocketStore(KNOWN_BURROWS_STORE, 'readonly', async store => { + const value = await promisifyRequest(store.get(burrowId)); + return value ? summarizeKnownBurrow(value) : null; + }), + listSummaries: () => + withPocketStore(KNOWN_BURROWS_STORE, 'readonly', async store => + (await promisifyRequest(store.getAll())).map(summarizeKnownBurrow)), get: (burrowId) => withPocketStore(KNOWN_BURROWS_STORE, 'readonly', async (store) => { - const value = await promisifyRequest(store.get(burrowId)); - return value ?? null; + const value = await promisifyRequest(store.get(burrowId)); + return value ? restore(value) : null; }), async put(record) { // Before the first write, per the storage-durability rule. await requestPersistenceOnce(); await withPocketStore(KNOWN_BURROWS_STORE, 'readwrite', (store) => { - store.put(record); + store.put({ + ...record, + clientStaticKeyPair: { + ...record.clientStaticKeyPair, + privateKey: storePocketPrivateKey(record.clientStaticKeyPair.privateKey), + }, + }); return promisifyTransaction(store.transaction); }); }, @@ -273,10 +479,6 @@ export function indexedDbKnownBurrowStore(): KnownBurrowStore { store.delete(burrowId); return promisifyTransaction(store.transaction); }), - list: () => - withPocketStore(KNOWN_BURROWS_STORE, 'readonly', (store) => - promisifyRequest(store.getAll()), - ), }; } diff --git a/lib/src/remote/client/pocket-encrypted-storage.test.ts b/lib/src/remote/client/pocket-encrypted-storage.test.ts new file mode 100644 index 000000000..de61dbabb --- /dev/null +++ b/lib/src/remote/client/pocket-encrypted-storage.test.ts @@ -0,0 +1,313 @@ +import { webcrypto } from 'node:crypto'; +import { IDBFactory, IDBObjectStore } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { fromBase64Url, generateNoiseKeyPair, sealPush, toBase64Url, utf8Encode } from 'remote-lib-common'; +import { + indexedDbKnownBurrowStore, promisifyRequest, requirePocketKeyStorage, withPocketStore, + invalidatePocketKeyStorage, + KNOWN_BURROWS_STORE, POCKET_KEY_STORAGE_ERROR, type KnownBurrowV1, +} from './pocket-db'; +import { generatePocketKeyPair, loadPocketPrivateKey, storePocketPrivateKey } from './pocket-private-key'; +import { makeE2eHarness } from './test-e2e-harness'; +import { CONNECTION_RECORD_UNREADABLE_MESSAGE } from './pocket-client'; +import { SCAN_LABEL } from '../setup-copy'; +import { installPocketWorker, type WorkerScope } from '../pocket-app/sw'; + +it('does not export private bytes if parallel wrapping-key setup fails', async () => { + const generate = crypto.subtle.generateKey.bind(crypto.subtle); + const exportKey = vi.spyOn(crypto.subtle, 'exportKey'); + vi.spyOn(crypto.subtle, 'generateKey').mockImplementation((algorithm, extractable, usages) => { + if (typeof algorithm === 'object' && algorithm.name === 'AES-GCM') { + return Promise.reject(new Error('AES unavailable')); + } + return generate(algorithm, extractable, usages); + }); + await expect(generatePocketKeyPair('encrypted', 'probe')).rejects.toThrow('AES unavailable'); + expect(exportKey.mock.calls.some(([format]) => format === 'pkcs8')).toBe(false); +}); + +it.each(['encrypt', 'importKey'] as const)('clears exported private bytes when %s fails', async operation => { + const original = crypto.subtle.exportKey.bind(crypto.subtle); + let clear: Uint8Array | undefined; + vi.spyOn(crypto.subtle, 'exportKey').mockImplementation(async (format, key) => { + const result = await original(format, key); + if (format === 'pkcs8') clear = new Uint8Array(result as ArrayBuffer); + return result; + }); + vi.spyOn(crypto.subtle, operation).mockRejectedValue(new Error('injected failure')); + await expect(generatePocketKeyPair('encrypted', 'probe')).rejects.toThrow('injected failure'); + expect(clear?.byteLength).toBeGreaterThan(0); + expect(clear?.every(byte => byte === 0)).toBe(true); +}); + +it('shares one successful probe across concurrent scans, but probes again in a fresh module', async () => { + const open = vi.spyOn(indexedDB, 'open'); + await Promise.all([requirePocketKeyStorage(), requirePocketKeyStorage(), requirePocketKeyStorage()]); + expect(open).toHaveBeenCalledTimes(2); + await requirePocketKeyStorage(); + expect(open).toHaveBeenCalledTimes(2); + vi.resetModules(); + const fresh = await import('./pocket-db'); + await fresh.requirePocketKeyStorage(); + expect(open).toHaveBeenCalledTimes(4); +}); + +it('does not memoize failure and invalidates successful evidence on a store failure', async () => { + const put = vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(() => { + throw new DOMException('injected failure', 'QuotaExceededError'); + }); + await expect(requirePocketKeyStorage()).rejects.toThrow('Encrypted storage:'); + put.mockRestore(); + await requirePocketKeyStorage(); + const open = vi.spyOn(indexedDB, 'open'); + await expect(withPocketStore(KNOWN_BURROWS_STORE, 'readonly', async () => { + throw new DOMException('injected failure', 'UnknownError'); + })).rejects.toThrow(); + open.mockClear(); + await requirePocketKeyStorage(); + expect(open).toHaveBeenCalledTimes(2); +}); + +it('does not let an invalidated in-flight probe authorize a scan', async () => { + const checking = requirePocketKeyStorage(); + invalidatePocketKeyStorage(); + await expect(checking).rejects.toThrow('Pairing has not started'); + await expect(requirePocketKeyStorage()).resolves.toBeUndefined(); +}); + +it('invalidates cached compatibility when opening the production database fails', async () => { + await requirePocketKeyStorage(); + const open = vi.spyOn(indexedDB, 'open').mockImplementationOnce(() => { + throw new DOMException('storage denied', 'SecurityError'); + }); + await expect(indexedDbKnownBurrowStore().listSummaries()).rejects.toThrow('storage denied'); + open.mockRestore(); + const retried = vi.spyOn(indexedDB, 'open'); + await requirePocketKeyStorage(); + expect(retried).toHaveBeenCalledTimes(2); +}); + +it('lists and removes a paired record without decrypting its corrupt key', async () => { + breakNativeStorage(); + const store = indexedDbKnownBurrowStore(); + const harness = await makeE2eHarness({ deps: { knownBurrows: store } }); + try { + expect(await harness.pairAndApprove(await harness.mintInvitation())).toMatchObject({ ok: true }); + const raw = await rawRecord(harness.burrowId); + raw.clientStaticKeyPair.privateKey.ciphertext = new ArrayBuffer(16); + await withPocketStore(KNOWN_BURROWS_STORE, 'readwrite', async target => { + await promisifyRequest(target.put(raw)); + }); + const decrypt = vi.spyOn(crypto.subtle, 'decrypt'); + const importKey = vi.spyOn(crypto.subtle, 'importKey'); + const summaries = await harness.client.listKnownBurrows(); + expect(summaries).toHaveLength(1); + expect(summaries[0]).not.toHaveProperty('clientStaticKeyPair'); + expect(await store.getSummary(harness.burrowId)).toEqual(summaries[0]); + await harness.client.forgetBurrow(harness.burrowId); + expect(await store.listSummaries()).toEqual([]); + expect(decrypt).not.toHaveBeenCalled(); + expect(importKey).not.toHaveBeenCalled(); + } finally { harness.client.close(); harness.burrow.stop(); } +}); + +it('re-pairs a damaged envelope only after approval, preserving the Burrow pin and retiring the old delivery id', async () => { + breakNativeStorage(); + const store = indexedDbKnownBurrowStore(); + const harness = await makeE2eHarness({ deps: { knownBurrows: store } }); + try { + expect(await harness.pairAndApprove(await harness.mintInvitation())).toMatchObject({ ok: true }); + const raw = await rawRecord(harness.burrowId); + const oldPublic = raw.clientStaticKeyPair.publicKeyRaw; + const oldDelivery = raw.authorization.deliveryId; + raw.clientStaticKeyPair.privateKey.ciphertext = new ArrayBuffer(16); + await withPocketStore(KNOWN_BURROWS_STORE, 'readwrite', async target => { + await promisifyRequest(target.put(raw)); + }); + await expect(store.get(harness.burrowId)).rejects.toThrow(); + const failed = await harness.client.connect(harness.burrowId); + expect(failed).toEqual({ ok: false, pairingRequired: false, message: CONNECTION_RECORD_UNREADABLE_MESSAGE }); + expect(CONNECTION_RECORD_UNREADABLE_MESSAGE).toContain(SCAN_LABEL); + const read = vi.spyOn(store, 'get').mockRejectedValueOnce(new DOMException('private browser details', 'OperationError')); + expect(await harness.client.connect(harness.burrowId)).toEqual(failed); + read.mockRestore(); + expect(harness.savedAcl).toHaveLength(1); + expect((await rawRecord(harness.burrowId)).clientStaticKeyPair.publicKeyRaw).toBe(oldPublic); + + expect(await harness.pairAndApprove(await harness.mintInvitation())).toMatchObject({ ok: true }); + const restored = (await store.get(harness.burrowId))!; + expect(restored.burrowStaticPublicKey).toBe(raw.burrowStaticPublicKey); + expect(restored.clientStaticKeyPair.publicKeyRaw).not.toBe(oldPublic); + expect(await harness.pendingDeletions.list()).toContainEqual(expect.objectContaining({ deliveryId: oldDelivery })); + await harness.client.retirePendingDeletions(); + expect(harness.calls.some(call => call.method === 'DELETE' && call.url.endsWith(oldDelivery))).toBe(true); + expect(await harness.client.connect(harness.burrowId)).toMatchObject({ ok: true }); + } finally { harness.client.close(); harness.burrow.stop(); } +}); + +beforeEach(() => { + invalidatePocketKeyStorage(); + vi.stubGlobal('crypto', webcrypto); + vi.stubGlobal('indexedDB', new IDBFactory()); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +function breakNativeStorage() { + const put = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (value, key) { + if (value?.clientStaticKeyPair?.privateKey?.algorithm?.name === 'X25519') { + throw new DOMException('WebKit clone failure', 'DataError'); + } + if (value?.algorithm?.name === 'X25519') return put.call(this, null, key); + return put.call(this, value, key); + }); +} + +async function rawRecord(burrowId: string): Promise { + return withPocketStore(KNOWN_BURROWS_STORE, 'readonly', store => promisifyRequest(store.get(burrowId))); +} + +it('prefers native storage when it works and never exports a private key', async () => { + const exportKey = vi.spyOn(crypto.subtle, 'exportKey'); + await requirePocketKeyStorage(); + const pair = await indexedDbKnownBurrowStore().generateKey('burrow'); + expect(pair.privateKey.extractable).toBe(false); + expect(storePocketPrivateKey(pair.privateKey as CryptoKey)).toBe(pair.privateKey); + expect(exportKey.mock.calls.every(([format]) => format === 'raw')).toBe(true); +}); + +it('blocks pairing when neither key encoding survives storage', async () => { + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(() => { + throw new DOMException('secret detail', 'QuotaExceededError'); + }); + const failure = await requirePocketKeyStorage().catch((error: Error) => error); + expect(failure.message).toContain('Encrypted storage:'); + // Both probes failed, and the sentence the user reads says so once, not twice. + expect(failure.message.split(POCKET_KEY_STORAGE_ERROR)).toHaveLength(2); + await expect(indexedDbKnownBurrowStore().generateKey('burrow')).rejects.toThrow('Pairing has not started'); +}); + +it('rejects silent loss of the encrypted wrapping key during the preflight', async () => { + const put = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (value, key) { + if (value?.clientStaticKeyPair?.privateKey?.format) { + const changed = structuredClone(value); + changed.clientStaticKeyPair.privateKey.wrappingKey = null; + return put.call(this, changed, key); + } + if (value?.clientStaticKeyPair?.privateKey?.algorithm?.name === 'X25519') { + throw new DOMException('clone failed', 'DataError'); + } + return put.call(this, value, key); + }); + await expect(requirePocketKeyStorage()).rejects.toThrow('Encrypted storage:'); +}); + +it('does not persist an encrypted identity when the laptop denies pairing', async () => { + breakNativeStorage(); + await requirePocketKeyStorage(); + const store = indexedDbKnownBurrowStore(); + const harness = await makeE2eHarness({ deps: { knownBurrows: store } }); + try { + const result = await harness.pairAndApprove(await harness.mintInvitation(), { + code: shown => shown === '00' ? '01' : '00', + }); + expect(result.ok).toBe(false); + expect(await store.listSummaries()).toEqual([]); + } finally { harness.client.close(); harness.burrow.stop(); } +}); + +it('pairs, reconnects after fresh module load, and decrypts worker push with the encrypted record', async () => { + breakNativeStorage(); + await requirePocketKeyStorage(); + const store = indexedDbKnownBurrowStore(); + const harness = await makeE2eHarness({ deps: { knownBurrows: store } }); + try { + expect(await harness.pairAndApprove(await harness.mintInvitation())).toMatchObject({ ok: true }); + const raw = await rawRecord(harness.burrowId); + expect(raw.clientStaticKeyPair.privateKey).toMatchObject({ + format: 'aes-gcm-x25519-v1', wrappingKey: { extractable: false }, + }); + expect(raw.clientStaticKeyPair.privateKey.algorithm).toBeUndefined(); + expect(await harness.client.connect(harness.burrowId)).toMatchObject({ ok: true }); + harness.client.close(); + + // Drop module-local key/envelope maps. A worker or new page must recover + // using IndexedDB alone, not an in-memory association. + vi.resetModules(); + const reloaded = await import('./pocket-db'); + const freshStore = reloaded.indexedDbKnownBurrowStore(); + const record = (await freshStore.get(harness.burrowId))!; + expect(record.clientStaticKeyPair.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('pkcs8', record.clientStaticKeyPair.privateKey)).rejects.toThrow(); + const newHarness = await makeE2eHarness({ + burrowId: harness.burrowId, authenticator: harness.authenticator, + noiseStatic: harness.noiseStatic, loadAcl: () => harness.savedAcl, + deps: { knownBurrows: freshStore }, + }); + try { expect(await newHarness.client.connect(harness.burrowId)).toMatchObject({ ok: true }); } + finally { newHarness.client.close(); newHarness.burrow.stop(); } + + const burrowKey = await crypto.subtle.importKey('pkcs8', + new Uint8Array(fromBase64Url(harness.noiseStatic.privateKeyPkcs8)), 'X25519', false, ['deriveBits']); + const sealed = await sealPush({ + burrowStaticPrivateKey: burrowKey, + clientStaticPublicKey: fromBase64Url(record.clientStaticKeyPair.publicKeyRaw), + plaintext: utf8Encode(JSON.stringify({ title: 'Saved key works', body: 'Worker decrypted', tag: 'test' })), + }); + const listeners = new Map(); + const showNotification = vi.fn(async () => {}); + installPocketWorker({ + addEventListener: (type: string, listener: unknown) => listeners.set(type, listener), + skipWaiting: () => {}, clients: { claim: async () => {}, matchAll: async () => [] }, + registration: { showNotification }, + } as unknown as WorkerScope, freshStore); + let work: Promise = Promise.resolve(); + listeners.get('push')({ + data: { json: () => ({ burrowId: harness.burrowId, ...sealed }) }, + waitUntil: (promise: Promise) => { work = promise; }, + }); + await work; + expect(showNotification).toHaveBeenCalledWith('Saved key works', expect.objectContaining({ body: 'Worker decrypted' })); + await freshStore.put({ ...record, authorization: { state: 'pairing-required' } }); + expect((await rawRecord(harness.burrowId)).clientStaticKeyPair.privateKey.format).toBe('aes-gcm-x25519-v1'); + expect((await freshStore.listSummaries())[0]!.authorization.state).toBe('pairing-required'); + await freshStore.delete(harness.burrowId); + expect(await freshStore.listSummaries()).toEqual([]); + } finally { harness.client.close(); harness.burrow.stop(); } +}); + +it('rejects ciphertext damage, wrong Burrow/public context, unknown formats, and extractable wrapping keys', async () => { + const pair = await generatePocketKeyPair('encrypted', 'burrow'); + const publicRaw = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))); + const envelope = storePocketPrivateKey(pair.privateKey); + if (!('format' in envelope)) throw new Error('Expected encrypted envelope'); + const another = storePocketPrivateKey((await generatePocketKeyPair('encrypted', 'other')).privateKey); + if (!('format' in another)) throw new Error('Expected second envelope'); + expect(another.wrappingKey).not.toBe(envelope.wrappingKey); + expect(another.iv).not.toEqual(envelope.iv); + await expect(crypto.subtle.exportKey('raw', envelope.wrappingKey)).rejects.toThrow(); + await expect(loadPocketPrivateKey(envelope, 'other', publicRaw)).rejects.toThrow(); + await expect(loadPocketPrivateKey(envelope, 'burrow', 'wrong')).rejects.toThrow(); + const damaged = structuredClone(envelope); + new Uint8Array(damaged.ciphertext)[0] ^= 1; + await expect(loadPocketPrivateKey(damaged, 'burrow', publicRaw)).rejects.toThrow(); + await expect(loadPocketPrivateKey({ ...envelope, format: 'unknown' } as any, 'burrow', publicRaw)).rejects.toThrow(); + const wrappingKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + await expect(loadPocketPrivateKey({ ...envelope, wrappingKey }, 'burrow', publicRaw)).rejects.toThrow(); +}); + +it('keeps legacy native records usable without changing their encoding', async () => { + const pair = await generateNoiseKeyPair(); + const record: KnownBurrowV1 = { + burrowId: 'legacy', accountId: 'owner', label: 'Laptop', burrowStaticPublicKey: 'pin', + clientStaticKeyPair: { privateKey: pair.privateKey as CryptoKey, publicKeyRaw: toBase64Url(pair.publicKey) }, + passkeyCredentialId: 'cred', passkeyPublicKeyHash: 'hash', authorization: { state: 'pairing-required' }, + }; + const store = indexedDbKnownBurrowStore(); + await store.put(record); + const restored = (await store.get('legacy'))!; + expect(restored.clientStaticKeyPair.privateKey.algorithm.name).toBe('X25519'); + await store.put(restored); + expect((await rawRecord('legacy')).clientStaticKeyPair.privateKey.algorithm.name).toBe('X25519'); +}); diff --git a/lib/src/remote/client/pocket-key-storage.test.ts b/lib/src/remote/client/pocket-key-storage.test.ts new file mode 100644 index 000000000..ba17ad665 --- /dev/null +++ b/lib/src/remote/client/pocket-key-storage.test.ts @@ -0,0 +1,69 @@ +import { webcrypto } from 'node:crypto'; +import { IDBFactory, IDBObjectStore } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { probePocketKeyStorage } from './pocket-db'; + +beforeEach(() => { + vi.stubGlobal('crypto', webcrypto); + vi.stubGlobal('indexedDB', new IDBFactory()); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +it('round-trips and uses a real nonextractable key, then deletes only its probe database', async () => { + const open = vi.spyOn(indexedDB, 'open'); + const remove = vi.spyOn(indexedDB, 'deleteDatabase'); + await probePocketKeyStorage(); + expect(open).toHaveBeenCalledTimes(2); + const name = open.mock.calls[0]![0]; + expect(name).toMatch(/^dormouse-pocket-key-probe-/); + expect(open.mock.calls[1]![0]).toBe(name); + expect(remove).toHaveBeenCalledExactlyOnceWith(name); +}); + +it('reports a rejected key clone before pairing, and cleans up', async () => { + const remove = vi.spyOn(indexedDB, 'deleteDatabase'); + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(() => { + throw new DOMException('Key path did not yield a value', 'DataError'); + }); + await expect(probePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); + expect(remove).toHaveBeenCalledOnce(); +}); + +it('rejects a successful write whose private key does not survive readback', async () => { + const put = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (value) { + return put.call(this, { ...value, clientStaticKeyPair: { privateKey: null } }); + }); + await expect(probePocketKeyStorage()).rejects.toThrow('Diagnostic: validate-record / Error.'); +}); + +it('rejects a readback key that derives the wrong secret', async () => { + const other = await crypto.subtle.generateKey('X25519', false, ['deriveBits']); + const put = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (value) { + return put.call(this, { ...value, clientStaticKeyPair: { privateKey: other.privateKey } }); + }); + await expect(probePocketKeyStorage()).rejects.toThrow('Diagnostic: compare-key-agreement / Error.'); +}); + +it('identifies read failures without echoing browser error contents', async () => { + vi.spyOn(IDBObjectStore.prototype, 'get').mockImplementation(() => { + throw new DOMException('private browser details', 'UnknownError'); + }); + const error = await probePocketKeyStorage().catch(error => error as Error); + expect(error.message).toContain('Diagnostic: read-record / UnknownError.'); + expect(error.message).not.toContain('private browser details'); +}); + +it('distinguishes generation failure from storage failure', async () => { + vi.spyOn(crypto.subtle, 'generateKey').mockRejectedValue(new DOMException('unsupported', 'NotSupportedError')); + await expect(probePocketKeyStorage()).rejects.toThrow('Diagnostic: generate-key / NotSupportedError.'); +}); + +it('allows a fresh retry after a storage failure', async () => { + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementationOnce(() => { + throw new DOMException('Storage unavailable', 'DataError'); + }); + await expect(probePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); + await expect(probePocketKeyStorage()).resolves.toBeUndefined(); +}); diff --git a/lib/src/remote/client/pocket-private-key.ts b/lib/src/remote/client/pocket-private-key.ts new file mode 100644 index 000000000..26ec94ad6 --- /dev/null +++ b/lib/src/remote/client/pocket-private-key.ts @@ -0,0 +1,82 @@ +import { toBase64Url } from 'remote-lib-common'; + +const encoder = new TextEncoder(); + +export type PocketKeyStorageMode = 'native' | 'encrypted'; +interface EncryptedPrivateKey { + readonly format: 'aes-gcm-x25519-v1'; + readonly wrappingKey: CryptoKey; + readonly iv: Uint8Array; + readonly ciphertext: ArrayBuffer; + readonly context: string; +} +export type StoredPocketPrivateKey = CryptoKey | EncryptedPrivateKey; + +// Keep the encrypted representation with its runtime key, including after a +// worker read. Authorization-only rewrites must not serialize X25519 again. +const envelopes = new WeakMap(); +const contextFor = (burrowId: string, publicKeyRaw: string) => + JSON.stringify(['dormouse/pocket-private-key/v1', burrowId, publicKeyRaw]); + +export async function generatePocketKeyPair(mode: PocketKeyStorageMode, burrowId: string): Promise { + const pair = await crypto.subtle.generateKey('X25519', mode === 'encrypted', ['deriveBits']); + if (mode === 'native') return pair; + // Finish fallible setup before exporting private bytes. Promise.all rejection + // must never strand a successful private export outside its cleanup scope. + const [publicRaw, wrappingKey] = await Promise.all([ + crypto.subtle.exportKey('raw', pair.publicKey), + crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']), + ]); + const publicKeyRaw = toBase64Url(new Uint8Array(publicRaw)); + const context = contextFor(burrowId, publicKeyRaw); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const clear = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey)); + try { + const ciphertext = await crypto.subtle.encrypt({ + name: 'AES-GCM', iv, additionalData: encoder.encode(context), + }, wrappingKey, clear); + const privateKey = await crypto.subtle.importKey('pkcs8', clear, 'X25519', false, ['deriveBits']); + envelopes.set(privateKey, { format: 'aes-gcm-x25519-v1', wrappingKey, iv, ciphertext, context }); + return { privateKey, publicKey: pair.publicKey }; + } finally { + // Best effort only: JavaScript/WebCrypto may retain internal copies. + clear.fill(0); + } +} + +export function storePocketPrivateKey(key: CryptoKey): StoredPocketPrivateKey { + validatePrivateKey(key); + return envelopes.get(key) ?? key; +} + +function validatePrivateKey(key: CryptoKey): void { + if (key?.type !== 'private' || key.extractable !== false || + key.algorithm?.name !== 'X25519' || !key.usages.includes('deriveBits')) { + throw new Error('Invalid Pocket private key'); + } +} + +export async function loadPocketPrivateKey( + stored: StoredPocketPrivateKey, burrowId: string, publicKeyRaw: string, +): Promise { + if (!stored || !('format' in stored)) { + validatePrivateKey(stored as CryptoKey); + return stored as CryptoKey; + } + if (stored.format !== 'aes-gcm-x25519-v1' || + stored.context !== contextFor(burrowId, publicKeyRaw) || + stored.wrappingKey?.type !== 'secret' || stored.wrappingKey.extractable !== false || + stored.wrappingKey.algorithm.name !== 'AES-GCM' || + (stored.wrappingKey.algorithm as AesKeyAlgorithm).length !== 256 || + !(stored.iv instanceof Uint8Array) || stored.iv.byteLength !== 12) { + throw new Error('Invalid encrypted Pocket private key'); + } + const clear = new Uint8Array(await crypto.subtle.decrypt({ + name: 'AES-GCM', iv: stored.iv, additionalData: encoder.encode(stored.context), + }, stored.wrappingKey, stored.ciphertext)); + try { + const key = await crypto.subtle.importKey('pkcs8', clear, 'X25519', false, ['deriveBits']); + envelopes.set(key, stored); + return key; + } finally { clear.fill(0); } +} diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts index 918b8edf4..25cc3c490 100644 --- a/lib/src/remote/client/test-e2e-harness.ts +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -23,6 +23,7 @@ import { REMOTE_EVENTS, REMOTE_METHODS, SELFHOST_ACCOUNT_ID, + generateNoiseKeyPair, mintNoiseStaticKeyPair, presenceChallenge, randomBase64Url, @@ -126,10 +127,17 @@ export function memoryKnownBurrows(): MemoryKnownBurrows { const records = new Map(); return { records, + generateKey: () => generateNoiseKeyPair(), get: async (burrowId) => records.get(burrowId) ?? null, + getSummary: async (burrowId) => { + const value = records.get(burrowId); + if (!value) return null; + const { clientStaticKeyPair: _key, ...summary } = value; + return summary; + }, + listSummaries: async () => [...records.values()].map(({ clientStaticKeyPair: _key, ...summary }) => summary), put: async (record) => void records.set(record.burrowId, record), delete: async (burrowId) => void records.delete(burrowId), - list: async () => [...records.values()], }; } diff --git a/lib/src/remote/pocket-app/App.scan.test.tsx b/lib/src/remote/pocket-app/App.scan.test.tsx index ef81da5ad..f77dbc068 100644 --- a/lib/src/remote/pocket-app/App.scan.test.tsx +++ b/lib/src/remote/pocket-app/App.scan.test.tsx @@ -45,6 +45,7 @@ import { import { setNativeFieldValue } from '../../lib/dom'; const fake = vi.hoisted(() => ({ + keyStorage: vi.fn<() => Promise>(), noiseSupported: true as boolean, /** The path callback `App` registered, so a case can report a cutover. */ onTransportPath: null as @@ -88,6 +89,11 @@ vi.mock('../client/push-subscribe', () => ({ subscribeToPushInBrowser: () => Promise.reject(new Error('not under test')), })); +vi.mock('../client/pocket-db', async (importOriginal) => ({ + ...(await importOriginal()), + requirePocketKeyStorage: () => fake.keyStorage(), +})); + // Only `PocketClient` is doubled: the error classes and their messages are the // real ones, so a test asserting on what the screen says is asserting on what // ships rather than on a string this file made up. @@ -181,6 +187,7 @@ async function knownBurrow(burrowId: string, label = 'First laptop'): Promise sharedInvitationUrl(location.origin); beforeEach(() => { + fake.keyStorage.mockReset().mockResolvedValue(undefined); fake.noiseSupported = true; fake.hasPriorUse = false; fake.sessionToken = null; @@ -266,6 +273,16 @@ describe('the capability gate', () => { }); describe('a first run, from the scan to the terminal', () => { + it('stops failed key storage before registration, token retirement, or pairing', async () => { + fake.keyStorage.mockRejectedValue(new Error('Private key storage is unavailable')); + await boot(); + await pasteCode((await invitationUrl()).url); + expect(alertText(container)).toContain('Private key storage is unavailable'); + expect(fake.setup).not.toHaveBeenCalled(); + expect(fake.signin).not.toHaveBeenCalled(); + expect(fake.retireSetupToken).not.toHaveBeenCalled(); + expect(fake.pair).not.toHaveBeenCalled(); + }); it('registers with the scanned token, pairs, shows the code, and connects', async () => { const { url, invitation } = await invitationUrl(); let releasePair!: (result: PairingResult) => void; diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx index 89960c3ee..45a30c7ec 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -35,7 +35,9 @@ import { import { indexedDbKnownBurrowStore, indexedDbPendingDeletionStore, + requirePocketKeyStorage, type KnownBurrowV1, + type KnownBurrowSummary, } from '../client/pocket-db'; import { getPushAvailability, @@ -417,6 +419,7 @@ export default function App({ const onScanned = useCallback( (invitation: PairingInvitation) => run('pair', async () => { + await requirePocketKeyStorage(); cancelledPairingRef.current = false; const label = deviceLabel(); let spentOnSetup = false; @@ -674,7 +677,7 @@ function Waiting(): React.ReactElement { } /** One pinned record as the list renders it. */ -function toBurrowView(record: KnownBurrowV1, online: boolean): BurrowView { +function toBurrowView(record: KnownBurrowSummary, online: boolean): BurrowView { return { burrowId: record.burrowId, label: record.label || record.burrowId, diff --git a/lib/src/remote/pocket-app/sw.test.ts b/lib/src/remote/pocket-app/sw.test.ts index d4a516e04..87b60983e 100644 --- a/lib/src/remote/pocket-app/sw.test.ts +++ b/lib/src/remote/pocket-app/sw.test.ts @@ -73,11 +73,8 @@ async function harness(): Promise { const records = new Map(); records.set(BURROW_ID, knownBurrow(burrowStatic, clientStatic, { state: 'paired', deliveryId: 'd', approvedAt: 1 })); - const store: KnownBurrowStore = { + const store: Pick = { get: async (burrowId) => records.get(burrowId) ?? null, - put: async (record) => void records.set(record.burrowId, record), - delete: async (burrowId) => void records.delete(burrowId), - list: async () => [...records.values()], }; const listeners = new Map(); diff --git a/lib/src/remote/pocket-app/sw.ts b/lib/src/remote/pocket-app/sw.ts index cda1515de..29031639a 100644 --- a/lib/src/remote/pocket-app/sw.ts +++ b/lib/src/remote/pocket-app/sw.ts @@ -60,7 +60,7 @@ export interface PocketNotification { */ export async function notificationForPush( payload: unknown, - store: KnownBurrowStore, + store: Pick, ): Promise { try { return (await openNotification(payload, store)) ?? GENERIC_PUSH_NOTIFICATION; @@ -72,7 +72,7 @@ export async function notificationForPush( /** The readable case, or `null` for every way it can fail to be one. */ async function openNotification( payload: unknown, - store: KnownBurrowStore, + store: Pick, ): Promise { if (!payload || typeof payload !== 'object') return null; const envelope = payload as { burrowId?: unknown }; @@ -123,7 +123,7 @@ function notificationOptions(notification: PocketNotification): NotificationOpti } /** Wire this worker's four handlers onto `scope`. */ -export function installPocketWorker(scope: WorkerScope, store: KnownBurrowStore): void { +export function installPocketWorker(scope: WorkerScope, store: Pick): void { scope.addEventListener('install', () => { // Nothing to precache, so there is no reason to wait for the old worker. scope.skipWaiting(); diff --git a/lib/vite.pocket.config.ts b/lib/vite.pocket.config.ts index e3d807dc4..f7443059d 100644 --- a/lib/vite.pocket.config.ts +++ b/lib/vite.pocket.config.ts @@ -80,6 +80,12 @@ export default defineConfig({ }, }, build: { + rolldownOptions: { + input: { + pocket: fileURLToPath(new URL('./pocket/index.html', import.meta.url)), + diagnostics: fileURLToPath(new URL('./pocket/diagnostics/index.html', import.meta.url)), + }, + }, outDir: fileURLToPath(new URL("./dist-pocket", import.meta.url)), emptyOutDir: true, }, diff --git a/scripts/e2e-lint-selftest.mjs b/scripts/e2e-lint-selftest.mjs index 9f1e7ec99..bd053646f 100644 --- a/scripts/e2e-lint-selftest.mjs +++ b/scripts/e2e-lint-selftest.mjs @@ -81,6 +81,13 @@ for (const rule of RULES) { } const security = readFileSync(join(repoRoot, SECURITY_SPEC), 'utf8'); +// A file-scoped storage exception must not become a directory-scoped escape. +selftest.withAppended( + 'lib/src/remote/client/pocket-db.ts', + "\nconst __selftest = { name: 'AES-GCM' };\n", + 'AES-GCM in the module beside the at-rest wrapper stays green', +); + for (const line of new Set(RULES.map((rule) => rule.security))) { if (!security.includes(line)) { selftest.weak.push(`${SECURITY_SPEC} does not contain the line a rule names: "${line}"`); diff --git a/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index 3273029c1..24f23b82d 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -108,6 +108,13 @@ const FRAME_MODULES = [ /** The three shipped source trees, scanned whole for the dependency rules. */ const SOURCE_TREES = ['remote-lib-common/src/', 'lib/src/', 'relay/src/']; +/** + * The one file the AES-GCM ban excuses, as `docs/specs/security-remote.md` -> + * "Credentials at rest" names it. Excused by path rather than dropped from the + * scan, so a rename that leaves the cipher behind turns the rule red. + */ +const AT_REST_KEY_WRAPPER = 'lib/src/remote/client/pocket-private-key.ts'; + /** * One entry per structural property. Every rule states the `SECURITY_SPEC` * line it enforces in `security`, which must still appear in that file — as a @@ -157,10 +164,12 @@ export const RULES = [ violation: '\nexport interface SelftestOptions {\n readonly pattern: string;\n}\n', }, { - rule: 'No second AEAD anywhere in the shipped source', - security: 'no negotiation, no cipher or pattern selector', + rule: 'No second AEAD outside Pocket at-rest key wrapping', + security: 'AES-GCM appears in production source under `remote-lib-common/src/`', kind: 'forbid', trees: SOURCE_TREES, + allow: (match, file) => file === AT_REST_KEY_WRAPPER, + // The one exception encrypts local private-key storage, never wire data. // `AES-GCM` is the substitution the Noise suite exists to refuse: it *is* in // shipping WebCrypto, which is exactly what makes it the tempting one, and // the protocol name is part of the transcript so swapping it is a different @@ -421,7 +430,7 @@ export function check() { failures.push(`${rule.rule}\n ${file}: missing`); continue; } - const hits = (text.match(rule.pattern) ?? []).filter((m) => !rule.allow?.(m)); + const hits = (text.match(rule.pattern) ?? []).filter((m) => !rule.allow?.(m, file)); total += hits.length; if (rule.kind === 'forbid' && hits.length > 0) { failures.push(`${rule.rule}\n ${file}: ${[...new Set(hits)].join(', ')}`); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index c3a0e8206..51a2df947 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,7 +1,7 @@ { "AGENTS.md": 3350, "SECURITY.md": 200, - "SELF_HOST.md": 6000, + "SELF_HOST.md": 6200, "docs/specs/alert.md": 6600, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 1900, @@ -13,14 +13,14 @@ "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3700, - "docs/specs/pocket-app.md": 4400, + "docs/specs/pocket-app.md": 4900, "docs/specs/relay.md": 10200, "docs/specs/remote-api.md": 4700, - "docs/specs/remote-security-model.md": 4750, + "docs/specs/remote-security-model.md": 4800, "docs/specs/security-audit.md": 1750, "docs/specs/security-ci.md": 2500, "docs/specs/security-local.md": 2550, - "docs/specs/security-remote.md": 5800, + "docs/specs/security-remote.md": 5850, "docs/specs/security-supply-chain.md": 1200, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1000, diff --git a/website/scripts/generate-docs.test.js b/website/scripts/generate-docs.test.js index f7af7472c..d1e5f3ae5 100644 --- a/website/scripts/generate-docs.test.js +++ b/website/scripts/generate-docs.test.js @@ -111,7 +111,12 @@ describe('self-host runbook', () => { for (const id of ['mechanism-map', 'invariants', 'mechanical-traps', 'operator-surface-and-test-hooks']) { expect(ids, `#${id} outlived its parent section`).not.toContain(id); } - expect(data.selfhost.headings.every((h) => h.depth === 2)).toBe(true); + }); + + it('preserves subsections of the published troubleshooting section', () => { + for (const id of ['phone-capability-diagnostics', 'service-and-deployment-failures']) { + expect(data.selfhost.headings).toContainEqual(expect.objectContaining({ id, depth: 3 })); + } }); it('keeps every checkpoint the runbook walks through', () => {