From afdb246c89f69fa72beb69d62271e912374bded6 Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 11 Sep 2026 07:17:07 -0700 Subject: [PATCH 1/7] fix(pocket): persist pairing keys on browsers with broken X25519 cloning --- SELF_HOST.md | 18 ++ docs/specs/pocket-app.md | 50 ++++- docs/specs/pocket-app.rationale.md | 33 +++ docs/specs/remote-security-model.md | 31 ++- docs/specs/remote-security-model.rationale.md | 16 ++ docs/specs/security-remote.md | 18 +- docs/specs/security.md | 3 +- docs/stories/pairing.mdx | 8 +- lib/pocket/public/diagnostics/capabilities.js | 210 ++++++++++++++++++ lib/pocket/public/diagnostics/index.html | 46 ++++ .../public/diagnostics/manifest.webmanifest | 8 + lib/pocket/public/diagnostics/page.js | 46 ++++ lib/pocket/public/diagnostics/restart-page.js | 29 +++ lib/pocket/public/diagnostics/restart.js | 94 ++++++++ lib/pocket/public/diagnostics/style.css | 9 + .../client/capability-harness-page.test.ts | 63 ++++++ .../remote/client/capability-harness.test.ts | 96 ++++++++ lib/src/remote/client/pocket-client.ts | 4 +- lib/src/remote/client/pocket-db.test.ts | 11 +- lib/src/remote/client/pocket-db.ts | 199 ++++++++++++++++- .../client/pocket-encrypted-storage.test.ts | 178 +++++++++++++++ .../remote/client/pocket-key-storage.test.ts | 99 +++++++++ lib/src/remote/client/pocket-private-key.ts | 75 +++++++ lib/src/remote/pocket-app/App.scan.test.tsx | 17 ++ lib/src/remote/pocket-app/App.tsx | 2 + scripts/e2e-lint-selftest.mjs | 7 + scripts/e2e-lint.mjs | 9 +- scripts/spec-word-budgets.json | 8 +- 28 files changed, 1346 insertions(+), 41 deletions(-) create mode 100644 lib/pocket/public/diagnostics/capabilities.js create mode 100644 lib/pocket/public/diagnostics/index.html create mode 100644 lib/pocket/public/diagnostics/manifest.webmanifest create mode 100644 lib/pocket/public/diagnostics/page.js create mode 100644 lib/pocket/public/diagnostics/restart-page.js create mode 100644 lib/pocket/public/diagnostics/restart.js create mode 100644 lib/pocket/public/diagnostics/style.css create mode 100644 lib/src/remote/client/capability-harness-page.test.ts create mode 100644 lib/src/remote/client/capability-harness.test.ts create mode 100644 lib/src/remote/client/pocket-encrypted-storage.test.ts create mode 100644 lib/src/remote/client/pocket-key-storage.test.ts create mode 100644 lib/src/remote/client/pocket-private-key.ts diff --git a/SELF_HOST.md b/SELF_HOST.md index 867d61c78..8e106ffcf 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -468,6 +468,24 @@ 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". + +### 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 fed1923f0..7e95a7860 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -389,6 +389,33 @@ Source of truth: `isInstalledWebApp` / `requiresInstallForPush` / ## What Pocket stores +**Must verify private-key storage before a scan starts registration, sign-in, +token retirement, or pairing.** 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 diagnose a failed inline record with a separate, explicitly keyed +private-key round trip when its probe database remains open.** Reopen and use +that key; its result is diagnostic only, never a storage selection. + +**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 +427,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 +440,27 @@ in `lib/src/remote/client/pocket-client.ts`. ## Serving the built bundle +**Must serve the opt-in capability harness at `/diagnostics/index.html` from +`lib/pocket/public/diagnostics/`.** 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. +The encrypted-X25519 experiment does not change production key storage. +**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`. + +Source of truth: `runCapabilities` in +`lib/pocket/public/diagnostics/capabilities.js`; UI: +`lib/pocket/public/diagnostics/page.js`; restart: `verifyRestart` in +`lib/pocket/public/diagnostics/restart.js`. + 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..ef4e9cc8d 100644 --- a/docs/specs/pocket-app.rationale.md +++ b/docs/specs/pocket-app.rationale.md @@ -86,6 +86,31 @@ mode. [WebKit's iOS Web Push guidance](https://webkit.org/blog/13878/web-push-fo ## What Pocket stores +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 +121,14 @@ The v4 rename also drops `known-hosts` and empties `pending-deletions`: their ol ## Serving the built bundle +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..9f9630f2e 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -21,6 +21,22 @@ environment. ## Client statics +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..82ed0e96c 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,20 @@ 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 non-diagnostic production source outside the local at-rest + wrapper `lib/src/remote/client/pocket-private-key.ts`. The wire cipher remains + unchanged; `scripts/e2e-lint.mjs` and `scripts/e2e-lint-selftest.mjs` pin + the file-scoped 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/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/public/diagnostics/capabilities.js b/lib/pocket/public/diagnostics/capabilities.js new file mode 100644 index 000000000..398cb18f6 --- /dev/null +++ b/lib/pocket/public/diagnostics/capabilities.js @@ -0,0 +1,210 @@ +// Diagnostic databases are independent of Pocket's authorization database. +export const HARNESS_VERSION = '2'; +const PREFIX = 'dormouse-capability-probe-'; +const LIMIT = 8000; +const message = error => `${error?.name || 'Error'}: ${error?.message || 'failed'}`; +const assert = (ok, reason) => { if (!ok) throw new Error(reason); }; +const equal = (a, b) => { + const x = new Uint8Array(a), y = new Uint8Array(b); + assert(x.length === y.length && x.every((v, i) => v === y[i]), 'Key operation produced a different result'); +}; + +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'); + const done = new Promise((resolve, reject) => { + tx.oncomplete = resolve; + tx.onabort = tx.onerror = () => reject(tx.error || new Error('Transaction aborted')); + }); + try { + const store = tx.objectStore(storeName); + if (inline) store.put(value); else store.put(value, 'test'); + phase('commit'); + await bounded(done, () => { try { tx.abort(); } catch {} }); + } catch (error) { + try { tx.abort(); } catch {} + await done.catch(() => {}); + throw error; + } + 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', 'Experiment: AES-protected X25519 bytes, reopen, decrypt and use', async phase => { + phase('generate'); + const aes = await bounded(generate('AES-GCM')); + // Only this disposable test key is extractable, to evaluate a possible design. + const pair = await bounded(crypto.subtle.generateKey('X25519', true, ['deriveBits'])); + phase('export-test-key'); + const bytes = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + let ciphertext; + try { ciphertext = await bounded(crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aes, bytes)); } + finally { bytes.fill(0); } + const saved = await roundTrip({ id: 'test', aes, iv, ciphertext }, true, phase, report.cleanupErrors); + phase('validate-aes'); + validateKey(saved.aes, 'secret', 'AES-GCM'); + phase('decrypt'); + const clear = new Uint8Array(await bounded(crypto.subtle.decrypt({ name: 'AES-GCM', iv: saved.iv }, saved.aes, saved.ciphertext))); + let restored; + try { + phase('import'); + restored = await bounded(crypto.subtle.importKey('pkcs8', clear, 'X25519', false, ['deriveBits'])); + } finally { clear.fill(0); } + phase('use-restored-key'); + validateKey(restored, 'private', 'X25519'); + await bounded(exercise('X25519', restored, pair)); + }); + return report; +} diff --git a/lib/pocket/public/diagnostics/index.html b/lib/pocket/public/diagnostics/index.html new file mode 100644 index 000000000..e554715fc --- /dev/null +++ b/lib/pocket/public/diagnostics/index.html @@ -0,0 +1,46 @@ + + + + + + + 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

    +

    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/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/page.js b/lib/pocket/public/diagnostics/page.js new file mode 100644 index 000000000..90fe0f12a --- /dev/null +++ b/lib/pocket/public/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/public/diagnostics/restart-page.js b/lib/pocket/public/diagnostics/restart-page.js new file mode 100644 index 000000000..d14b42ed3 --- /dev/null +++ b/lib/pocket/public/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/public/diagnostics/restart.js b/lib/pocket/public/diagnostics/restart.js new file mode 100644 index 000000000..8a49cfdc4 --- /dev/null +++ b/lib/pocket/public/diagnostics/restart.js @@ -0,0 +1,94 @@ +import { HARNESS_VERSION, environment, bounded, request, openDb } from './capabilities.js'; + +// 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(); +const assert = (ok, message) => { if (!ok) throw new Error(message); }; +const equal = (a, b) => { + const x = new Uint8Array(a), y = new Uint8Array(b); + assert(x.length === y.length && x.every((v, i) => v === y[i]), 'Recovered key produced a different shared secret'); +}; + +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'); + const done = new Promise((resolve, reject) => { + tx.oncomplete = resolve; + tx.onabort = tx.onerror = () => reject(tx.error || new Error('Checkpoint transaction aborted')); + }); + try { + tx.objectStore('inline').put(record); + await bounded(done, () => { try { tx.abort(); } catch {} }); + } catch (error) { + try { tx.abort(); } catch {} + await done.catch(() => {}); + throw error; + } + } finally { db.close(); } +} + +export async function prepareRestart() { + assert(!(await read()), 'A checkpoint already exists. Verify it or remove test data before preparing another.'); + const aes = await bounded(crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])); + // Extractability is confined to a disposable, never-authorized test key. + const pair = await bounded(crypto.subtle.generateKey('X25519', true, ['deriveBits'])); + 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 clear = new Uint8Array(await bounded(crypto.subtle.exportKey('pkcs8', pair.privateKey))); + const iv = crypto.getRandomValues(new Uint8Array(12)); + let ciphertext; + try { ciphertext = await bounded(crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aes, clear)); } + finally { clear.fill(0); } + const record = { id: 'test', schema: 1, page: PAGE, preparedAt: new Date().toISOString(), + preparedEnvironment: environment(), aes, iv, ciphertext, 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', + 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 === 1, 'Unknown checkpoint format'); + 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 = 'validate-aes'; + assert(saved.aes?.type === 'secret' && saved.aes.extractable === false && + saved.aes.algorithm?.name === 'AES-GCM', 'Stored AES key is missing, extractable, or invalid'); + stage = 'decrypt'; + const clear = new Uint8Array(await bounded(crypto.subtle.decrypt({ name: 'AES-GCM', iv: saved.iv }, saved.aes, saved.ciphertext))); + let key; + try { + stage = 'import-x25519'; + key = await bounded(crypto.subtle.importKey('pkcs8', clear, 'X25519', false, ['deriveBits'])); + } finally { clear.fill(0); } + 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); + return { ...report, status: 'PASS', retained: true }; + } catch (error) { + return { ...report, status: 'FAIL', stage, error: `${error.name}: ${error.message}`, 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/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/src/remote/client/capability-harness-page.test.ts b/lib/src/remote/client/capability-harness-page.test.ts new file mode 100644 index 000000000..fff1fa3ac --- /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/public/diagnostics/restart.js', () => ({ + prepareRestart: fake.prepare, verifyRestart: fake.verify, clearRestart: fake.clear, +})); +vi.mock('../../../pocket/public/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/public/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/public/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/public/diagnostics'); + expect(assertPocketShell(root)).toBe(2); + const manifest = JSON.parse(readFileSync(resolve(root, '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/public/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..313e39d88 --- /dev/null +++ b/lib/src/remote/client/capability-harness.test.ts @@ -0,0 +1,96 @@ +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 { runCapabilities } from '../../../pocket/public/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/public/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({ 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 = await new Promise((resolve, reject) => { + const req = indexedDB.open('dormouse-capability-probe-restart-v1'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction('inline', 'readwrite'); + const store = tx.objectStore('inline'); + const req = store.get('test'); + req.onsuccess = () => { + const saved = req.result; + new Uint8Array(saved.ciphertext)[0] ^= 1; + store.put(saved); + }; + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + db.close(); + vi.resetModules(); + const second = await restartModule(); + expect(await second.verifyRestart()).toMatchObject({ status: 'FAIL', stage: 'decrypt' }); + 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('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.ts b/lib/src/remote/client/pocket-client.ts index 596007272..0c0448a87 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -760,7 +760,9 @@ 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 = this.#knownBurrows.generateKey + ? await this.#knownBurrows.generateKey(burrowId) + : await generateNoiseKeyPair(); const handshake = await createNoiseInitiator({ prologue: pairingInvitationPrologue(invitation), staticKeyPair: clientStatic, diff --git a/lib/src/remote/client/pocket-db.test.ts b/lib/src/remote/client/pocket-db.test.ts index a2e659ff8..8981c302e 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', diff --git a/lib/src/remote/client/pocket-db.ts b/lib/src/remote/client/pocket-db.ts index 6b64ac7a3..d66b6b224 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 { 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,157 @@ 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. Report the diagnostic below.'; + +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' }); + request.result.createObjectStore('separate-key'); + }; + return promisifyRequest(request); + }; + // Diagnostic only. Even a passing alternative must not enable pairing while + // the production store still writes the failing inline-record layout. + const probeSeparateKey = async (keys: CryptoKeyPair): Promise => { + let separateStage = 'separate-write-key'; + try { + const tx = db!.transaction('separate-key', 'readwrite'); + const done = promisifyTransaction(tx); + try { + tx.objectStore('separate-key').put(keys.privateKey, 'probe'); + separateStage = 'separate-commit-key'; + await done; + } catch (error) { + try { tx.abort(); } catch { /* It may have already finished. */ } + await done.catch(() => {}); + throw error; + } + db!.close(); + separateStage = 'separate-reopen-database'; + db = await open(name!); + separateStage = 'separate-read-key'; + const key = await promisifyRequest(db.transaction('separate-key') + .objectStore('separate-key').get('probe')); + separateStage = 'separate-validate-key'; + if (!key) return 'separate-read-key / missing'; + if (key.type !== 'private' || key.extractable !== false) { + return 'separate-validate-key / invalid'; + } + separateStage = 'separate-use-key'; + const algorithm = { name: 'X25519', public: keys.publicKey }; + const actual = new Uint8Array(await crypto.subtle.deriveBits(algorithm, key, 256)); + const expected = new Uint8Array(await crypto.subtle.deriveBits(algorithm, keys.privateKey, 256)); + if (actual.length !== expected.length || actual.some((byte, i) => byte !== expected[i])) { + return 'separate-compare-key-agreement / mismatch'; + } + return 'separate-key / passed'; + } catch (error) { + return `${separateStage} / ${storageErrorName(error)}`; + } + }; + try { + const generated = await generatePocketKeyPair(mode, 'probe'); + const publicKeyRaw = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', generated.publicKey))); + pair = generated; + const derive = (key: CryptoKey) => crypto.subtle.deriveBits( + { name: 'X25519', public: generated.publicKey }, key, 256, + ); + stage = 'use-original-key'; + const expected = new Uint8Array(await derive(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'); + const done = promisifyTransaction(tx); + try { + tx.objectStore(KNOWN_BURROWS_STORE).put({ + burrowId: 'probe', clientStaticKeyPair: { + privateKey: storePocketPrivateKey(pair.privateKey), publicKeyRaw, + }, + }); + stage = 'commit-record'; + await done; + } catch (error) { + try { tx.abort(); } catch { /* It may have already finished. */ } + await done.catch(() => {}); + throw error; + } + 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 derive(key)); + stage = 'compare-key-agreement'; + if (actual.length !== expected.length || actual.some((byte, i) => byte !== expected[i])) { + throw new Error('stored key changed'); + } + } catch (error) { + const failure = `${stage} / ${storageErrorName(error)}`; + const separate = mode === 'native' && db && pair && stage !== 'reopen-database' + ? ` Separate key: ${await probeSeparateKey(pair)}.` : ''; + throw new Error(`${POCKET_KEY_STORAGE_ERROR} Diagnostic: ${failure}.${separate}`); + } finally { + db?.close(); + if (name) { + // Cleanup must not mask the diagnostic or strand the UI. + try { indexedDB.deleteDatabase(name); } catch { /* Best effort. */ } + } + } +} + +let keyStorageMode: PocketKeyStorageMode | undefined; + +/** Select only a format that actually survives reopen and key agreement. */ +export async function requirePocketKeyStorage(): Promise { + keyStorageMode = undefined; + try { + await probePocketKeyStorage('native'); + keyStorageMode = 'native'; + } catch (nativeError) { + try { + await probePocketKeyStorage('encrypted'); + keyStorageMode = 'encrypted'; + } catch (encryptedError) { + // Both probe errors contain only fixed diagnostics, never browser messages. + throw new 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 +214,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; @@ -95,6 +252,8 @@ 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; put(record: KnownBurrowV1): Promise; delete(burrowId: string): Promise; @@ -254,17 +413,42 @@ 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) { + if (!keyStorageMode) await requirePocketKeyStorage(); + const pair = await generatePocketKeyPair(keyStorageMode!, burrowId); + return { + privateKey: pair.privateKey, + publicKey: new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)), + }; + }, 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); }); }, @@ -274,9 +458,10 @@ export function indexedDbKnownBurrowStore(): KnownBurrowStore { return promisifyTransaction(store.transaction); }), list: () => - withPocketStore(KNOWN_BURROWS_STORE, 'readonly', (store) => - promisifyRequest(store.getAll()), - ), + withPocketStore(KNOWN_BURROWS_STORE, 'readonly', async (store) => { + const values = await promisifyRequest(store.getAll()); + return Promise.all(values.map(restore)); + }), }; } 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..fa0e06a98 --- /dev/null +++ b/lib/src/remote/client/pocket-encrypted-storage.test.ts @@ -0,0 +1,178 @@ +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, requirePocketKeyStorage, withPocketStore, + KNOWN_BURROWS_STORE, type KnownBurrowV1, +} from './pocket-db'; +import { generatePocketKeyPair, loadPocketPrivateKey, storePocketPrivateKey } from './pocket-private-key'; +import { makeE2eHarness } from './test-e2e-harness'; +import { installPocketWorker, type WorkerScope } from '../pocket-app/sw'; + +beforeEach(() => { + 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 => new Promise((resolve, reject) => { + const request = store.get(burrowId); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + })); +} + +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'); + }); + await expect(requirePocketKeyStorage()).rejects.toThrow('Encrypted storage:'); + 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.list()).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.list())[0]!.authorization.state).toBe('pairing-required'); + await freshStore.delete(harness.burrowId); + expect(await freshStore.list()).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..f4a11c457 --- /dev/null +++ b/lib/src/remote/client/pocket-key-storage.test.ts @@ -0,0 +1,99 @@ +import { webcrypto } from 'node:crypto'; +import { IDBFactory, IDBObjectStore } from 'fake-indexeddb'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { POCKET_KEY_STORAGE_ERROR, probePocketKeyStorage as requirePocketKeyStorage } 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 requirePocketKeyStorage(); + 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(requirePocketKeyStorage()).rejects.toThrow(POCKET_KEY_STORAGE_ERROR); + expect(remove).toHaveBeenCalledOnce(); + await expect(requirePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); +}); + +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(requirePocketKeyStorage()).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(requirePocketKeyStorage()).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 requirePocketKeyStorage().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(requirePocketKeyStorage()).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(requirePocketKeyStorage()).rejects.toThrow(POCKET_KEY_STORAGE_ERROR); + await expect(requirePocketKeyStorage()).resolves.toBeUndefined(); +}); + +it('proves separate-key readback and use when only inline writes fail, but still blocks pairing', async () => { + const put = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (value, key) { + if (this.keyPath) throw new DOMException('inline clone failed', 'DataError'); + return put.call(this, value, key); + }); + await expect(requirePocketKeyStorage()).rejects.toThrow( + 'Diagnostic: write-record / DataError. Separate key: separate-key / passed.', + ); +}); + +it('does not mistake a separate-key write followed by null readback for a fix', async () => { + const put = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (_value, key) { + if (this.keyPath) throw new DOMException('inline clone failed', 'DataError'); + return put.call(this, null, key); + }); + await expect(requirePocketKeyStorage()).rejects.toThrow('Separate key: separate-read-key / missing.'); +}); + +it('rejects an unusable separate key even when its metadata looks correct', async () => { + const put = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (_value, key) { + if (this.keyPath) throw new DOMException('inline clone failed', 'DataError'); + return put.call(this, { type: 'private', extractable: false }, key); + }); + await expect(requirePocketKeyStorage()).rejects.toThrow('Separate key: separate-use-key / TypeError.'); +}); 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..afddaa78e --- /dev/null +++ b/lib/src/remote/client/pocket-private-key.ts @@ -0,0 +1,75 @@ +import { toBase64Url } from 'remote-lib-common'; + +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; + const publicKeyRaw = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))); + const context = contextFor(burrowId, publicKeyRaw); + const wrappingKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + 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: new TextEncoder().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: new TextEncoder().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/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 478045f08..32e624598 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -35,6 +35,7 @@ import { import { indexedDbKnownBurrowStore, indexedDbPendingDeletionStore, + requirePocketKeyStorage, type KnownBurrowV1, } from '../client/pocket-db'; import { @@ -422,6 +423,7 @@ export default function App({ const onScanned = useCallback( (invitation: PairingInvitation) => run('pair', async () => { + await requirePocketKeyStorage(); cancelledPairingRef.current = false; const label = deviceLabel(); let spentOnSetup = false; 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..3a06345ce 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -157,10 +157,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 non-diagnostic production source outside the local at-rest', kind: 'forbid', trees: SOURCE_TREES, + excludeFiles: ['lib/src/remote/client/pocket-private-key.ts'], + // 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 @@ -355,7 +357,8 @@ function sourceFilesUnder(trees) { /** The files a rule scans: an explicit list, or every source file under its trees. */ export function filesFor(rule) { - return rule.files ?? sourceFilesUnder(rule.trees); + return (rule.files ?? sourceFilesUnder(rule.trees)) + .filter(file => !rule.excludeFiles?.includes(file)); } export function check() { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index c3a0e8206..8426f794c 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": 6150, "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": 4750, "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, From 27903d7528dfb7af12c554f25db3418811c3121a Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 11 Sep 2026 09:41:32 -0700 Subject: [PATCH 2/7] refactor(pocket): share what the storage probe and its harness each re-spelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preflight wrote the commit-or-abort dance twice, the derive-and-compare three times, and byte equality twice by hand where `constantTimeEqual` already exists — in the one file where "did this key survive" must not drift. One `commitOrAbort` and one `deriveShared` carry all of it, and the comparison comes from the security package. `POCKET_KEY_STORAGE_ERROR` is prepended by `requirePocketKeyStorage` rather than by each probe, so the sentence the user reads appears once in a combined failure instead of twice mid-paragraph. The probe answers a diagnostic; the wrapper owns the sentence. `KnownBurrowStore.generateKey` is required rather than optional, which deletes the ternary at the one place a Client static is minted: the fallback branch was the unverified path this fix exists to close, and a store could reach it just by not implementing the method. The diagnostics harness exports `assert`, `equal`, `message` and a shared `commit`, so `restart.js` imports them from the sibling it already imports four names from rather than keeping its own copies. Also: one `TextEncoder` instead of one per encrypt and per decrypt; the three independent WebCrypto calls in the encrypted keygen run together rather than in a chain; and the AES-GCM lint exempts the at-rest wrapper through the `allow` hook it already has, rather than subtracting a file from every rule's file list — an exclusion the `exactly` rules would have silently miscounted, and which hid the file from the missing-file check. Co-Authored-By: Claude Opus 5 (1M context) --- lib/pocket/public/diagnostics/capabilities.js | 41 ++++++---- lib/pocket/public/diagnostics/restart.js | 28 ++----- .../remote/client/capability-harness.test.ts | 25 ++---- lib/src/remote/client/pocket-client.ts | 5 +- lib/src/remote/client/pocket-db.ts | 76 ++++++++++--------- .../client/pocket-encrypted-storage.test.ts | 19 +++-- .../remote/client/pocket-key-storage.test.ts | 7 +- lib/src/remote/client/pocket-private-key.ts | 17 +++-- lib/src/remote/client/test-e2e-harness.ts | 2 + scripts/e2e-lint.mjs | 14 +++- 10 files changed, 118 insertions(+), 116 deletions(-) diff --git a/lib/pocket/public/diagnostics/capabilities.js b/lib/pocket/public/diagnostics/capabilities.js index 398cb18f6..ff958422e 100644 --- a/lib/pocket/public/diagnostics/capabilities.js +++ b/lib/pocket/public/diagnostics/capabilities.js @@ -2,13 +2,33 @@ export const HARNESS_VERSION = '2'; const PREFIX = 'dormouse-capability-probe-'; const LIMIT = 8000; -const message = error => `${error?.name || 'Error'}: ${error?.message || 'failed'}`; -const assert = (ok, reason) => { if (!ok) throw new Error(reason); }; -const equal = (a, b) => { +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]), 'Key operation produced a different result'); + 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([ @@ -55,20 +75,11 @@ async function roundTrip(value, inline, phase, cleanupErrors) { phase('write'); const storeName = inline ? 'inline' : 'explicit'; const tx = db.transaction(storeName, 'readwrite'); - const done = new Promise((resolve, reject) => { - tx.oncomplete = resolve; - tx.onabort = tx.onerror = () => reject(tx.error || new Error('Transaction aborted')); - }); - try { + await commit(tx, () => { const store = tx.objectStore(storeName); if (inline) store.put(value); else store.put(value, 'test'); phase('commit'); - await bounded(done, () => { try { tx.abort(); } catch {} }); - } catch (error) { - try { tx.abort(); } catch {} - await done.catch(() => {}); - throw error; - } + }); phase('reopen'); db.close(); db = await openDb(name); diff --git a/lib/pocket/public/diagnostics/restart.js b/lib/pocket/public/diagnostics/restart.js index 8a49cfdc4..e8bb7e78b 100644 --- a/lib/pocket/public/diagnostics/restart.js +++ b/lib/pocket/public/diagnostics/restart.js @@ -1,15 +1,11 @@ -import { HARNESS_VERSION, environment, bounded, request, openDb } from './capabilities.js'; +import { + HARNESS_VERSION, assert, bounded, commit, environment, equal, message, openDb, request, +} from './capabilities.js'; // 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(); -const assert = (ok, message) => { if (!ok) throw new Error(message); }; -const equal = (a, b) => { - const x = new Uint8Array(a), y = new Uint8Array(b); - assert(x.length === y.length && x.every((v, i) => v === y[i]), 'Recovered key produced a different shared secret'); -}; - async function read() { const db = await openDb(DATABASE); try { return await bounded(request(db.transaction('inline').objectStore('inline').get('test'))); } @@ -20,18 +16,7 @@ async function write(record) { const db = await openDb(DATABASE); try { const tx = db.transaction('inline', 'readwrite'); - const done = new Promise((resolve, reject) => { - tx.oncomplete = resolve; - tx.onabort = tx.onerror = () => reject(tx.error || new Error('Checkpoint transaction aborted')); - }); - try { - tx.objectStore('inline').put(record); - await bounded(done, () => { try { tx.abort(); } catch {} }); - } catch (error) { - try { tx.abort(); } catch {} - await done.catch(() => {}); - throw error; - } + await commit(tx, () => tx.objectStore('inline').put(record), 'Checkpoint transaction aborted'); } finally { db.close(); } } @@ -81,10 +66,11 @@ export async function verifyRestart() { 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); + 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: `${error.name}: ${error.message}`, retained: true }; + return { ...report, status: 'FAIL', stage, error: message(error), retained: true }; } } diff --git a/lib/src/remote/client/capability-harness.test.ts b/lib/src/remote/client/capability-harness.test.ts index 313e39d88..1b56ce28c 100644 --- a/lib/src/remote/client/capability-harness.test.ts +++ b/lib/src/remote/client/capability-harness.test.ts @@ -3,7 +3,7 @@ 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 { runCapabilities } from '../../../pocket/public/diagnostics/capabilities.js'; +import { commit, request, runCapabilities } from '../../../pocket/public/diagnostics/capabilities.js'; beforeEach(() => { vi.stubGlobal('crypto', webcrypto); @@ -46,22 +46,13 @@ it('fails closed when a retained checkpoint ciphertext is corrupted', async () = vi.resetModules(); const first = await restartModule(); await first.prepareRestart(); - const db = await new Promise((resolve, reject) => { - const req = indexedDB.open('dormouse-capability-probe-restart-v1'); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - await new Promise((resolve, reject) => { - const tx = db.transaction('inline', 'readwrite'); - const store = tx.objectStore('inline'); - const req = store.get('test'); - req.onsuccess = () => { - const saved = req.result; - new Uint8Array(saved.ciphertext)[0] ^= 1; - store.put(saved); - }; - tx.oncomplete = () => resolve(); - tx.onabort = () => reject(tx.error); + 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.ciphertext)[0] ^= 1; + store.put(saved); }); db.close(); vi.resetModules(); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 0c0448a87..393e4336d 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, @@ -760,9 +759,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 = this.#knownBurrows.generateKey - ? await this.#knownBurrows.generateKey(burrowId) - : await generateNoiseKeyPair(); + const clientStatic = await this.#knownBurrows.generateKey(burrowId); const handshake = await createNoiseInitiator({ prologue: pairingInvitationPrologue(invitation), staticKeyPair: clientStatic, diff --git a/lib/src/remote/client/pocket-db.ts b/lib/src/remote/client/pocket-db.ts index d66b6b224..8b5cc3e63 100644 --- a/lib/src/remote/client/pocket-db.ts +++ b/lib/src/remote/client/pocket-db.ts @@ -9,7 +9,7 @@ * `docs/specs/remote-security-model.md`. */ -import { toBase64Url, type NoiseKeyPair } from 'remote-lib-common'; +import { constantTimeEqual, toBase64Url, type NoiseKeyPair } from 'remote-lib-common'; import { generatePocketKeyPair, loadPocketPrivateKey, storePocketPrivateKey, type PocketKeyStorageMode, type StoredPocketPrivateKey, @@ -41,6 +41,28 @@ export const POCKET_KEY_STORAGE_ERROR = + 'Pairing has not started. No permission dialog is expected. ' + 'Keep your existing passkey and website data. Report the diagnostic below.'; +/** 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 && [ @@ -74,16 +96,10 @@ export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native let separateStage = 'separate-write-key'; try { const tx = db!.transaction('separate-key', 'readwrite'); - const done = promisifyTransaction(tx); - try { + await commitOrAbort(tx, () => { tx.objectStore('separate-key').put(keys.privateKey, 'probe'); separateStage = 'separate-commit-key'; - await done; - } catch (error) { - try { tx.abort(); } catch { /* It may have already finished. */ } - await done.catch(() => {}); - throw error; - } + }); db!.close(); separateStage = 'separate-reopen-database'; db = await open(name!); @@ -96,10 +112,9 @@ export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native return 'separate-validate-key / invalid'; } separateStage = 'separate-use-key'; - const algorithm = { name: 'X25519', public: keys.publicKey }; - const actual = new Uint8Array(await crypto.subtle.deriveBits(algorithm, key, 256)); - const expected = new Uint8Array(await crypto.subtle.deriveBits(algorithm, keys.privateKey, 256)); - if (actual.length !== expected.length || actual.some((byte, i) => byte !== expected[i])) { + const actual = new Uint8Array(await deriveShared(keys.publicKey, key)); + const expected = new Uint8Array(await deriveShared(keys.publicKey, keys.privateKey)); + if (!constantTimeEqual(actual, expected)) { return 'separate-compare-key-agreement / mismatch'; } return 'separate-key / passed'; @@ -108,33 +123,23 @@ export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native } }; try { - const generated = await generatePocketKeyPair(mode, 'probe'); - const publicKeyRaw = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', generated.publicKey))); - pair = generated; - const derive = (key: CryptoKey) => crypto.subtle.deriveBits( - { name: 'X25519', public: generated.publicKey }, key, 256, - ); + 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 derive(pair.privateKey)); + 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'); - const done = promisifyTransaction(tx); - try { + await commitOrAbort(tx, () => { tx.objectStore(KNOWN_BURROWS_STORE).put({ burrowId: 'probe', clientStaticKeyPair: { - privateKey: storePocketPrivateKey(pair.privateKey), publicKeyRaw, + privateKey: storePocketPrivateKey(pair!.privateKey), publicKeyRaw, }, }); stage = 'commit-record'; - await done; - } catch (error) { - try { tx.abort(); } catch { /* It may have already finished. */ } - await done.catch(() => {}); - throw error; - } + }); db.close(); stage = 'reopen-database'; db = await open(name); @@ -149,16 +154,14 @@ export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native throw new Error('stored key did not survive'); } stage = 'use-reloaded-key'; - const actual = new Uint8Array(await derive(key)); + const actual = new Uint8Array(await deriveShared(pair.publicKey, key)); stage = 'compare-key-agreement'; - if (actual.length !== expected.length || actual.some((byte, i) => byte !== expected[i])) { - throw new Error('stored key changed'); - } + if (!constantTimeEqual(actual, expected)) throw new Error('stored key changed'); } catch (error) { const failure = `${stage} / ${storageErrorName(error)}`; const separate = mode === 'native' && db && pair && stage !== 'reopen-database' ? ` Separate key: ${await probeSeparateKey(pair)}.` : ''; - throw new Error(`${POCKET_KEY_STORAGE_ERROR} Diagnostic: ${failure}.${separate}`); + throw new Error(`Diagnostic: ${failure}.${separate}`); } finally { db?.close(); if (name) { @@ -182,7 +185,8 @@ export async function requirePocketKeyStorage(): Promise { keyStorageMode = 'encrypted'; } catch (encryptedError) { // Both probe errors contain only fixed diagnostics, never browser messages. - throw new Error(`${(nativeError as Error).message} Encrypted storage: ${(encryptedError as Error).message}`); + throw new Error(`${POCKET_KEY_STORAGE_ERROR} ${(nativeError as Error).message}` + + ` Encrypted storage: ${(encryptedError as Error).message}`); } } } @@ -253,7 +257,7 @@ 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; + generateKey(burrowId: string): Promise; get(burrowId: string): Promise; put(record: KnownBurrowV1): Promise; delete(burrowId: string): Promise; diff --git a/lib/src/remote/client/pocket-encrypted-storage.test.ts b/lib/src/remote/client/pocket-encrypted-storage.test.ts index fa0e06a98..a7b5b9acf 100644 --- a/lib/src/remote/client/pocket-encrypted-storage.test.ts +++ b/lib/src/remote/client/pocket-encrypted-storage.test.ts @@ -3,8 +3,8 @@ 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, requirePocketKeyStorage, withPocketStore, - KNOWN_BURROWS_STORE, type KnownBurrowV1, + indexedDbKnownBurrowStore, promisifyRequest, requirePocketKeyStorage, withPocketStore, + 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'; @@ -28,17 +28,13 @@ function breakNativeStorage() { } async function rawRecord(burrowId: string): Promise { - return withPocketStore(KNOWN_BURROWS_STORE, 'readonly', store => new Promise((resolve, reject) => { - const request = store.get(burrowId); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - })); + 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'); + 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); @@ -48,8 +44,11 @@ it('blocks pairing when neither key encoding survives storage', async () => { vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(() => { throw new DOMException('secret detail', 'QuotaExceededError'); }); - await expect(requirePocketKeyStorage()).rejects.toThrow('Encrypted storage:'); - await expect(indexedDbKnownBurrowStore().generateKey!('burrow')).rejects.toThrow('Pairing has not started'); + 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 () => { diff --git a/lib/src/remote/client/pocket-key-storage.test.ts b/lib/src/remote/client/pocket-key-storage.test.ts index f4a11c457..66b4e6e82 100644 --- a/lib/src/remote/client/pocket-key-storage.test.ts +++ b/lib/src/remote/client/pocket-key-storage.test.ts @@ -1,7 +1,7 @@ import { webcrypto } from 'node:crypto'; import { IDBFactory, IDBObjectStore } from 'fake-indexeddb'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import { POCKET_KEY_STORAGE_ERROR, probePocketKeyStorage as requirePocketKeyStorage } from './pocket-db'; +import { probePocketKeyStorage as requirePocketKeyStorage } from './pocket-db'; beforeEach(() => { vi.stubGlobal('crypto', webcrypto); @@ -25,9 +25,8 @@ it('reports a rejected key clone before pairing, and cleans up', async () => { vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(() => { throw new DOMException('Key path did not yield a value', 'DataError'); }); - await expect(requirePocketKeyStorage()).rejects.toThrow(POCKET_KEY_STORAGE_ERROR); - expect(remove).toHaveBeenCalledOnce(); await expect(requirePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); + expect(remove).toHaveBeenCalledOnce(); }); it('rejects a successful write whose private key does not survive readback', async () => { @@ -65,7 +64,7 @@ it('allows a fresh retry after a storage failure', async () => { vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementationOnce(() => { throw new DOMException('Storage unavailable', 'DataError'); }); - await expect(requirePocketKeyStorage()).rejects.toThrow(POCKET_KEY_STORAGE_ERROR); + await expect(requirePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); await expect(requirePocketKeyStorage()).resolves.toBeUndefined(); }); diff --git a/lib/src/remote/client/pocket-private-key.ts b/lib/src/remote/client/pocket-private-key.ts index afddaa78e..ce1baa55b 100644 --- a/lib/src/remote/client/pocket-private-key.ts +++ b/lib/src/remote/client/pocket-private-key.ts @@ -1,5 +1,7 @@ import { toBase64Url } from 'remote-lib-common'; +const encoder = new TextEncoder(); + export type PocketKeyStorageMode = 'native' | 'encrypted'; interface EncryptedPrivateKey { readonly format: 'aes-gcm-x25519-v1'; @@ -19,14 +21,19 @@ const contextFor = (burrowId: string, publicKeyRaw: string) => export async function generatePocketKeyPair(mode: PocketKeyStorageMode, burrowId: string): Promise { const pair = await crypto.subtle.generateKey('X25519', mode === 'encrypted', ['deriveBits']); if (mode === 'native') return pair; - const publicKeyRaw = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))); + // None of the three depends on another, and a phone pays for each round trip. + const [publicRaw, wrappingKey, pkcs8] = await Promise.all([ + crypto.subtle.exportKey('raw', pair.publicKey), + crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']), + crypto.subtle.exportKey('pkcs8', pair.privateKey), + ]); + const publicKeyRaw = toBase64Url(new Uint8Array(publicRaw)); const context = contextFor(burrowId, publicKeyRaw); - const wrappingKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); const iv = crypto.getRandomValues(new Uint8Array(12)); - const clear = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey)); + const clear = new Uint8Array(pkcs8); try { const ciphertext = await crypto.subtle.encrypt({ - name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(context), + 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 }); @@ -65,7 +72,7 @@ export async function loadPocketPrivateKey( throw new Error('Invalid encrypted Pocket private key'); } const clear = new Uint8Array(await crypto.subtle.decrypt({ - name: 'AES-GCM', iv: stored.iv, additionalData: new TextEncoder().encode(stored.context), + 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']); diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts index 3f0ceffd4..9d504bb3f 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,6 +127,7 @@ export function memoryKnownBurrows(): MemoryKnownBurrows { const records = new Map(); return { records, + generateKey: () => generateNoiseKeyPair(), get: async (burrowId) => records.get(burrowId) ?? null, put: async (record) => void records.set(record.burrowId, record), delete: async (burrowId) => void records.delete(burrowId), diff --git a/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index 3a06345ce..dc4ba274d 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 @@ -161,7 +168,7 @@ export const RULES = [ security: 'AES-GCM appears in non-diagnostic production source outside the local at-rest', kind: 'forbid', trees: SOURCE_TREES, - excludeFiles: ['lib/src/remote/client/pocket-private-key.ts'], + 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 @@ -357,8 +364,7 @@ function sourceFilesUnder(trees) { /** The files a rule scans: an explicit list, or every source file under its trees. */ export function filesFor(rule) { - return (rule.files ?? sourceFilesUnder(rule.trees)) - .filter(file => !rule.excludeFiles?.includes(file)); + return rule.files ?? sourceFilesUnder(rule.trees); } export function check() { @@ -424,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(', ')}`); From 68394aa4072b91011ad571e4b36ddb86cd221cab Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 11 Sep 2026 10:04:04 -0700 Subject: [PATCH 3/7] fix(pocket): align diagnostics with production key storage and bound probe work --- SELF_HOST.md | 3 + docs/specs/pocket-app.md | 37 ++++-- docs/specs/pocket-app.rationale.md | 17 +++ docs/specs/remote-security-model.rationale.md | 4 + .../{public => }/diagnostics/capabilities.js | 34 ++---- .../{public => }/diagnostics/index.html | 1 + lib/pocket/{public => }/diagnostics/page.js | 0 .../{public => }/diagnostics/restart-page.js | 0 .../{public => }/diagnostics/restart.js | 34 ++---- lib/scripts/assert-pocket-worker.mjs | 3 + .../client/capability-harness-page.test.ts | 14 +-- .../remote/client/capability-harness.test.ts | 37 +++++- lib/src/remote/client/pocket-client.ts | 9 +- lib/src/remote/client/pocket-db.ts | 115 ++++++++++-------- .../client/pocket-encrypted-storage.test.ts | 100 +++++++++++++++ .../remote/client/pocket-key-storage.test.ts | 29 ----- lib/src/remote/client/pocket-private-key.ts | 8 +- lib/src/remote/client/test-e2e-harness.ts | 7 ++ lib/src/remote/pocket-app/App.tsx | 3 +- lib/src/remote/pocket-app/sw.test.ts | 5 +- lib/vite.pocket.config.ts | 6 + scripts/spec-word-budgets.json | 4 +- 22 files changed, 313 insertions(+), 157 deletions(-) rename lib/pocket/{public => }/diagnostics/capabilities.js (87%) rename lib/pocket/{public => }/diagnostics/index.html (93%) rename lib/pocket/{public => }/diagnostics/page.js (100%) rename lib/pocket/{public => }/diagnostics/restart-page.js (100%) rename lib/pocket/{public => }/diagnostics/restart.js (69%) diff --git a/SELF_HOST.md b/SELF_HOST.md index 8e106ffcf..b27b32059 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -483,6 +483,9 @@ restart result**. Do not prepare again between those steps. Finish with 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 diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 86ca123b1..ce19d7851 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -389,8 +389,11 @@ Source of truth: `isInstalledWebApp` / `requiresInstallForPush` / ## What Pocket stores -**Must verify private-key storage before a scan starts registration, sign-in, -token retirement, or pairing.** Probe native storage first, then encrypted +**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 @@ -398,9 +401,13 @@ 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 diagnose a failed inline record with a separate, explicitly keyed -private-key round trip when its probe database remains open.** Reopen and use -that key; its result is diagnostic only, never a storage selection. +**Must keep the separate-key experiment out of pairing preflight.** Direct +compatibility failures to `/diagnostics/index.html`. + +**Must use metadata-only summaries for Burrow listing, push-subscription queries, +and removal.** `getSummary` and `listSummaries` omit key material and perform no +decryption/import; a corrupt key must not block listing or removal. Connection +and push decryption use full records. **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, @@ -440,12 +447,14 @@ in `lib/src/remote/client/pocket-client.ts`. ## Serving the built bundle -**Must serve the opt-in capability harness at `/diagnostics/index.html` from -`lib/pocket/public/diagnostics/`.** Test fresh keys and isolated temporary +**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. -The encrypted-X25519 experiment does not change production key storage. +**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. @@ -455,11 +464,17 @@ 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/public/diagnostics/capabilities.js`; UI: -`lib/pocket/public/diagnostics/page.js`; restart: `verifyRestart` in -`lib/pocket/public/diagnostics/restart.js`. +`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 ef4e9cc8d..ef0c72dde 100644 --- a/docs/specs/pocket-app.rationale.md +++ b/docs/specs/pocket-app.rationale.md @@ -86,6 +86,17 @@ 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. + 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 @@ -121,6 +132,12 @@ 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. A new device restart run is needed for that stronger claim. + 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 diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index 9f9630f2e..39554cb9e 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -21,6 +21,10 @@ 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. + 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 diff --git a/lib/pocket/public/diagnostics/capabilities.js b/lib/pocket/diagnostics/capabilities.js similarity index 87% rename from lib/pocket/public/diagnostics/capabilities.js rename to lib/pocket/diagnostics/capabilities.js index ff958422e..903671242 100644 --- a/lib/pocket/public/diagnostics/capabilities.js +++ b/lib/pocket/diagnostics/capabilities.js @@ -1,5 +1,9 @@ // Diagnostic databases are independent of Pocket's authorization database. -export const HARNESS_VERSION = '2'; +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'}`; @@ -192,27 +196,15 @@ export async function runCapabilities(onResult = () => {}) { }); } } - await check('encrypted-x25519', 'Experiment: AES-protected X25519 bytes, reopen, decrypt and use', async phase => { + await check('encrypted-x25519', 'Production format: AES-protected X25519 with bound context, reopen and use', async phase => { phase('generate'); - const aes = await bounded(generate('AES-GCM')); - // Only this disposable test key is extractable, to evaluate a possible design. - const pair = await bounded(crypto.subtle.generateKey('X25519', true, ['deriveBits'])); - phase('export-test-key'); - const bytes = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - let ciphertext; - try { ciphertext = await bounded(crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aes, bytes)); } - finally { bytes.fill(0); } - const saved = await roundTrip({ id: 'test', aes, iv, ciphertext }, true, phase, report.cleanupErrors); - phase('validate-aes'); - validateKey(saved.aes, 'secret', 'AES-GCM'); - phase('decrypt'); - const clear = new Uint8Array(await bounded(crypto.subtle.decrypt({ name: 'AES-GCM', iv: saved.iv }, saved.aes, saved.ciphertext))); - let restored; - try { - phase('import'); - restored = await bounded(crypto.subtle.importKey('pkcs8', clear, 'X25519', false, ['deriveBits'])); - } finally { clear.fill(0); } + 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)); diff --git a/lib/pocket/public/diagnostics/index.html b/lib/pocket/diagnostics/index.html similarity index 93% rename from lib/pocket/public/diagnostics/index.html rename to lib/pocket/diagnostics/index.html index e554715fc..dd8e86351 100644 --- a/lib/pocket/public/diagnostics/index.html +++ b/lib/pocket/diagnostics/index.html @@ -25,6 +25,7 @@

    Pocket capability checks

      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. diff --git a/lib/pocket/public/diagnostics/page.js b/lib/pocket/diagnostics/page.js similarity index 100% rename from lib/pocket/public/diagnostics/page.js rename to lib/pocket/diagnostics/page.js diff --git a/lib/pocket/public/diagnostics/restart-page.js b/lib/pocket/diagnostics/restart-page.js similarity index 100% rename from lib/pocket/public/diagnostics/restart-page.js rename to lib/pocket/diagnostics/restart-page.js diff --git a/lib/pocket/public/diagnostics/restart.js b/lib/pocket/diagnostics/restart.js similarity index 69% rename from lib/pocket/public/diagnostics/restart.js rename to lib/pocket/diagnostics/restart.js index e8bb7e78b..255bd1955 100644 --- a/lib/pocket/public/diagnostics/restart.js +++ b/lib/pocket/diagnostics/restart.js @@ -1,6 +1,10 @@ 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. @@ -22,47 +26,35 @@ async function write(record) { export async function prepareRestart() { assert(!(await read()), 'A checkpoint already exists. Verify it or remove test data before preparing another.'); - const aes = await bounded(crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])); - // Extractability is confined to a disposable, never-authorized test key. - const pair = await bounded(crypto.subtle.generateKey('X25519', true, ['deriveBits'])); + 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 clear = new Uint8Array(await bounded(crypto.subtle.exportKey('pkcs8', pair.privateKey))); - const iv = crypto.getRandomValues(new Uint8Array(12)); - let ciphertext; - try { ciphertext = await bounded(crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aes, clear)); } - finally { clear.fill(0); } - const record = { id: 'test', schema: 1, page: PAGE, preparedAt: new Date().toISOString(), - preparedEnvironment: environment(), aes, iv, ciphertext, peerPublic, expected }; + 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 === 1, 'Unknown checkpoint format'); + 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 = 'validate-aes'; - assert(saved.aes?.type === 'secret' && saved.aes.extractable === false && - saved.aes.algorithm?.name === 'AES-GCM', 'Stored AES key is missing, extractable, or invalid'); - stage = 'decrypt'; - const clear = new Uint8Array(await bounded(crypto.subtle.decrypt({ name: 'AES-GCM', iv: saved.iv }, saved.aes, saved.ciphertext))); - let key; - try { - stage = 'import-x25519'; - key = await bounded(crypto.subtle.importKey('pkcs8', clear, 'X25519', false, ['deriveBits'])); - } finally { clear.fill(0); } + 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, [])); 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 index fff1fa3ac..36f981d07 100644 --- a/lib/src/remote/client/capability-harness-page.test.ts +++ b/lib/src/remote/client/capability-harness-page.test.ts @@ -6,16 +6,16 @@ import { afterEach, expect, it, vi } from 'vitest'; 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/public/diagnostics/restart.js', () => ({ +vi.mock('../../../pocket/diagnostics/restart.js', () => ({ prepareRestart: fake.prepare, verifyRestart: fake.verify, clearRestart: fake.clear, })); -vi.mock('../../../pocket/public/diagnostics/capabilities.js', () => ({ +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/public/diagnostics/index.html'), 'utf8'); + 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 }); @@ -23,7 +23,7 @@ it('wires explicit restart preparation, verification, copy, and cleanup', async const copy = vi.fn().mockResolvedValue(undefined); vi.stubGlobal('navigator', { clipboard: { writeText: copy } }); // @ts-ignore Browser-native JavaScript entry point. - await import('../../../pocket/public/diagnostics/restart-page.js'); + 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.')); @@ -36,9 +36,9 @@ it('wires explicit restart preparation, verification, copy, and cleanup', async }); it('renders completed and failed checks and copies a report without HTML injection', async () => { - const root = resolve('pocket/public/diagnostics'); + const root = resolve('pocket/diagnostics'); expect(assertPocketShell(root)).toBe(2); - const manifest = JSON.parse(readFileSync(resolve(root, 'manifest.webmanifest'), 'utf8')); + 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]!; @@ -53,7 +53,7 @@ it('renders completed and failed checks and copies a report without HTML injecti const copy = vi.fn().mockResolvedValue(undefined); vi.stubGlobal('navigator', { clipboard: { writeText: copy } }); // @ts-ignore Browser-native JavaScript entry point. - await import('../../../pocket/public/diagnostics/page.js'); + 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); diff --git a/lib/src/remote/client/capability-harness.test.ts b/lib/src/remote/client/capability-harness.test.ts index 1b56ce28c..268d9eae1 100644 --- a/lib/src/remote/client/capability-harness.test.ts +++ b/lib/src/remote/client/capability-harness.test.ts @@ -3,7 +3,7 @@ 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/public/diagnostics/capabilities.js'; +import { commit, request, runCapabilities } from '../../../pocket/diagnostics/capabilities.js'; beforeEach(() => { vi.stubGlobal('crypto', webcrypto); @@ -21,7 +21,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); // @ts-ignore Browser-native diagnostic module. -const restartModule = () => import('../../../pocket/public/diagnostics/restart.js'); +const restartModule = () => import('../../../pocket/diagnostics/restart.js'); it('retains an isolated checkpoint, rejects same-page verification, and recovers after module restart', async () => { vi.resetModules(); @@ -32,7 +32,10 @@ it('retains an isolated checkpoint, rejects same-page verification, and recovers vi.resetModules(); const second = await restartModule(); const report = await second.verifyRestart(); - expect(report).toMatchObject({ status: 'PASS', newPageInstance: true, retained: true }); + 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 }]); @@ -51,13 +54,13 @@ it('fails closed when a retained checkpoint ciphertext is corrupted', async () = const store = tx.objectStore('inline'); const saved = await request(store.get('test')); await commit(tx, () => { - new Uint8Array(saved.ciphertext)[0] ^= 1; + 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: 'decrypt' }); + expect(await second.verifyRestart()).toMatchObject({ status: 'FAIL', stage: 'restore-production-key' }); await second.clearRestart(); }); @@ -71,6 +74,30 @@ it('runs real crypto operations, including encrypted X25519, without touching Po 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) { diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index e749a2fc5..96d1b659a 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -86,6 +86,7 @@ import { import { type KnownBurrowStore, type KnownBurrowV1, + type KnownBurrowSummary, type PendingDeletionStore, } from './pocket-db'; import { DirectEndpoint } from '../direct/direct-endpoint'; @@ -583,8 +584,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(); } /** @@ -593,7 +594,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); } @@ -624,7 +625,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] : [], ) diff --git a/lib/src/remote/client/pocket-db.ts b/lib/src/remote/client/pocket-db.ts index 8b5cc3e63..cf6ae2bc1 100644 --- a/lib/src/remote/client/pocket-db.ts +++ b/lib/src/remote/client/pocket-db.ts @@ -39,7 +39,7 @@ 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. Report the diagnostic below.'; + + '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 { @@ -86,42 +86,9 @@ export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native const request = indexedDB.open(databaseName, 1); request.onupgradeneeded = () => { request.result.createObjectStore(KNOWN_BURROWS_STORE, { keyPath: 'burrowId' }); - request.result.createObjectStore('separate-key'); }; return promisifyRequest(request); }; - // Diagnostic only. Even a passing alternative must not enable pairing while - // the production store still writes the failing inline-record layout. - const probeSeparateKey = async (keys: CryptoKeyPair): Promise => { - let separateStage = 'separate-write-key'; - try { - const tx = db!.transaction('separate-key', 'readwrite'); - await commitOrAbort(tx, () => { - tx.objectStore('separate-key').put(keys.privateKey, 'probe'); - separateStage = 'separate-commit-key'; - }); - db!.close(); - separateStage = 'separate-reopen-database'; - db = await open(name!); - separateStage = 'separate-read-key'; - const key = await promisifyRequest(db.transaction('separate-key') - .objectStore('separate-key').get('probe')); - separateStage = 'separate-validate-key'; - if (!key) return 'separate-read-key / missing'; - if (key.type !== 'private' || key.extractable !== false) { - return 'separate-validate-key / invalid'; - } - separateStage = 'separate-use-key'; - const actual = new Uint8Array(await deriveShared(keys.publicKey, key)); - const expected = new Uint8Array(await deriveShared(keys.publicKey, keys.privateKey)); - if (!constantTimeEqual(actual, expected)) { - return 'separate-compare-key-agreement / mismatch'; - } - return 'separate-key / passed'; - } catch (error) { - return `${separateStage} / ${storageErrorName(error)}`; - } - }; try { pair = await generatePocketKeyPair(mode, 'probe'); const publicKeyRaw = toBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))); @@ -159,9 +126,7 @@ export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native if (!constantTimeEqual(actual, expected)) throw new Error('stored key changed'); } catch (error) { const failure = `${stage} / ${storageErrorName(error)}`; - const separate = mode === 'native' && db && pair && stage !== 'reopen-database' - ? ` Separate key: ${await probeSeparateKey(pair)}.` : ''; - throw new Error(`Diagnostic: ${failure}.${separate}`); + throw new Error(`Diagnostic: ${failure}.`); } finally { db?.close(); if (name) { @@ -171,18 +136,39 @@ export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native } } -let keyStorageMode: PocketKeyStorageMode | undefined; +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 { - keyStorageMode = undefined; + const pending = selectedKeyStorage(); + await pending; + if (storageProbe !== pending) throw new Error(POCKET_KEY_STORAGE_ERROR); +} + +async function selectKeyStorage(): Promise { try { await probePocketKeyStorage('native'); - keyStorageMode = 'native'; + return 'native'; } catch (nativeError) { try { await probePocketKeyStorage('encrypted'); - keyStorageMode = '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}` @@ -243,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 @@ -259,6 +255,8 @@ 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; @@ -407,9 +405,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(); } @@ -430,13 +434,28 @@ export function indexedDbKnownBurrowStore(): KnownBurrowStore { }); return { async generateKey(burrowId) { - if (!keyStorageMode) await requirePocketKeyStorage(); - const pair = await generatePocketKeyPair(keyStorageMode!, burrowId); - return { - privateKey: pair.privateKey, - publicKey: new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)), - }; + 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)); diff --git a/lib/src/remote/client/pocket-encrypted-storage.test.ts b/lib/src/remote/client/pocket-encrypted-storage.test.ts index a7b5b9acf..bfc5d066f 100644 --- a/lib/src/remote/client/pocket-encrypted-storage.test.ts +++ b/lib/src/remote/client/pocket-encrypted-storage.test.ts @@ -4,13 +4,113 @@ 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 { 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(); } +}); + beforeEach(() => { + invalidatePocketKeyStorage(); vi.stubGlobal('crypto', webcrypto); vi.stubGlobal('indexedDB', new IDBFactory()); }); diff --git a/lib/src/remote/client/pocket-key-storage.test.ts b/lib/src/remote/client/pocket-key-storage.test.ts index 66b4e6e82..a13374b2c 100644 --- a/lib/src/remote/client/pocket-key-storage.test.ts +++ b/lib/src/remote/client/pocket-key-storage.test.ts @@ -67,32 +67,3 @@ it('allows a fresh retry after a storage failure', async () => { await expect(requirePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); await expect(requirePocketKeyStorage()).resolves.toBeUndefined(); }); - -it('proves separate-key readback and use when only inline writes fail, but still blocks pairing', async () => { - const put = IDBObjectStore.prototype.put; - vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (value, key) { - if (this.keyPath) throw new DOMException('inline clone failed', 'DataError'); - return put.call(this, value, key); - }); - await expect(requirePocketKeyStorage()).rejects.toThrow( - 'Diagnostic: write-record / DataError. Separate key: separate-key / passed.', - ); -}); - -it('does not mistake a separate-key write followed by null readback for a fix', async () => { - const put = IDBObjectStore.prototype.put; - vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (_value, key) { - if (this.keyPath) throw new DOMException('inline clone failed', 'DataError'); - return put.call(this, null, key); - }); - await expect(requirePocketKeyStorage()).rejects.toThrow('Separate key: separate-read-key / missing.'); -}); - -it('rejects an unusable separate key even when its metadata looks correct', async () => { - const put = IDBObjectStore.prototype.put; - vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (_value, key) { - if (this.keyPath) throw new DOMException('inline clone failed', 'DataError'); - return put.call(this, { type: 'private', extractable: false }, key); - }); - await expect(requirePocketKeyStorage()).rejects.toThrow('Separate key: separate-use-key / TypeError.'); -}); diff --git a/lib/src/remote/client/pocket-private-key.ts b/lib/src/remote/client/pocket-private-key.ts index ce1baa55b..26ec94ad6 100644 --- a/lib/src/remote/client/pocket-private-key.ts +++ b/lib/src/remote/client/pocket-private-key.ts @@ -21,16 +21,16 @@ const contextFor = (burrowId: string, publicKeyRaw: string) => export async function generatePocketKeyPair(mode: PocketKeyStorageMode, burrowId: string): Promise { const pair = await crypto.subtle.generateKey('X25519', mode === 'encrypted', ['deriveBits']); if (mode === 'native') return pair; - // None of the three depends on another, and a phone pays for each round trip. - const [publicRaw, wrappingKey, pkcs8] = await Promise.all([ + // 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']), - crypto.subtle.exportKey('pkcs8', pair.privateKey), ]); const publicKeyRaw = toBase64Url(new Uint8Array(publicRaw)); const context = contextFor(burrowId, publicKeyRaw); const iv = crypto.getRandomValues(new Uint8Array(12)); - const clear = new Uint8Array(pkcs8); + 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), diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts index fc517e26a..9c37ff444 100644 --- a/lib/src/remote/client/test-e2e-harness.ts +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -129,6 +129,13 @@ export function memoryKnownBurrows(): MemoryKnownBurrows { 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.tsx b/lib/src/remote/pocket-app/App.tsx index 9f3cf5156..45a30c7ec 100644 --- a/lib/src/remote/pocket-app/App.tsx +++ b/lib/src/remote/pocket-app/App.tsx @@ -37,6 +37,7 @@ import { indexedDbPendingDeletionStore, requirePocketKeyStorage, type KnownBurrowV1, + type KnownBurrowSummary, } from '../client/pocket-db'; import { getPushAvailability, @@ -676,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/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/spec-word-budgets.json b/scripts/spec-word-budgets.json index 8426f794c..f86990ed2 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": 6150, + "SELF_HOST.md": 6200, "docs/specs/alert.md": 6600, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 1900, @@ -13,7 +13,7 @@ "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3700, - "docs/specs/pocket-app.md": 4750, + "docs/specs/pocket-app.md": 4850, "docs/specs/relay.md": 10200, "docs/specs/remote-api.md": 4700, "docs/specs/remote-security-model.md": 4800, From bebbb58a8be0ea2b3ec0ace7e5b776985d6e2456 Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 11 Sep 2026 10:32:18 -0700 Subject: [PATCH 4/7] Address Pocket storage review and unblock runbook CI --- docs/specs/pocket-app.md | 7 ++--- docs/specs/pocket-app.rationale.md | 5 +++- docs/specs/remote-security-model.rationale.md | 4 +++ docs/specs/security-remote.md | 7 ++--- docs/specs/website-docs.md | 3 +++ lib/src/remote/client/pocket-client.test.ts | 2 ++ lib/src/remote/client/pocket-client.ts | 4 +-- .../client/pocket-encrypted-storage.test.ts | 27 +++++++++++++++++++ lib/src/remote/pocket-app/sw.ts | 6 ++--- scripts/e2e-lint.mjs | 2 +- website/scripts/generate-docs.test.js | 7 ++++- 11 files changed, 60 insertions(+), 14 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index ce19d7851..ccd8b9607 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -404,9 +404,10 @@ 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 Burrow listing, push-subscription queries, -and removal.** `getSummary` and `listSummaries` omit key material and perform no -decryption/import; a corrupt key must not block listing or removal. Connection +**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 use the selected format for new keys and decode both formats in the diff --git a/docs/specs/pocket-app.rationale.md b/docs/specs/pocket-app.rationale.md index ef0c72dde..839ebe933 100644 --- a/docs/specs/pocket-app.rationale.md +++ b/docs/specs/pocket-app.rationale.md @@ -136,7 +136,10 @@ 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. A new device restart run is needed for that stronger claim. +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, diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index 39554cb9e..1badac4a5 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -24,6 +24,10 @@ environment. 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 diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index 82ed0e96c..fbdf98de3 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -101,10 +101,11 @@ per-Burrow browser storage follows `docs/specs/remote-security-model.md` -> `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 non-diagnostic production source outside the local at-rest - wrapper `lib/src/remote/client/pocket-private-key.ts`. The wire cipher remains +- **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 - the file-scoped exception. + 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/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/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index c723e6146..6965297c7 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1267,6 +1267,8 @@ describe('push registration by capability', () => { await seedRecord(harness.knownBurrows, 'h1'); expect(harness.client.registeredPushEndpoint()).toBeNull(); + 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 96d1b659a..5ceb137d8 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -650,7 +650,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'); } @@ -824,7 +824,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(); diff --git a/lib/src/remote/client/pocket-encrypted-storage.test.ts b/lib/src/remote/client/pocket-encrypted-storage.test.ts index bfc5d066f..e0f93480c 100644 --- a/lib/src/remote/client/pocket-encrypted-storage.test.ts +++ b/lib/src/remote/client/pocket-encrypted-storage.test.ts @@ -109,6 +109,33 @@ it('lists and removes a paired record without decrypting its corrupt key', async } 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(); + 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); 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/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index dc4ba274d..06ac3996e 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -165,7 +165,7 @@ export const RULES = [ }, { rule: 'No second AEAD outside Pocket at-rest key wrapping', - security: 'AES-GCM appears in non-diagnostic production source outside the local at-rest', + security: 'AES-GCM appears in production source under', kind: 'forbid', trees: SOURCE_TREES, allow: (match, file) => file === AT_REST_KEY_WRAPPER, 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', () => { From 37be7117beac78fc0d541049ea46a07f6537c3be Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 11 Sep 2026 10:33:29 -0700 Subject: [PATCH 5/7] Name the single-format storage probe honestly in tests --- .../remote/client/pocket-key-storage.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/src/remote/client/pocket-key-storage.test.ts b/lib/src/remote/client/pocket-key-storage.test.ts index a13374b2c..ba17ad665 100644 --- a/lib/src/remote/client/pocket-key-storage.test.ts +++ b/lib/src/remote/client/pocket-key-storage.test.ts @@ -1,7 +1,7 @@ import { webcrypto } from 'node:crypto'; import { IDBFactory, IDBObjectStore } from 'fake-indexeddb'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import { probePocketKeyStorage as requirePocketKeyStorage } from './pocket-db'; +import { probePocketKeyStorage } from './pocket-db'; beforeEach(() => { vi.stubGlobal('crypto', webcrypto); @@ -12,7 +12,7 @@ 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 requirePocketKeyStorage(); + await probePocketKeyStorage(); expect(open).toHaveBeenCalledTimes(2); const name = open.mock.calls[0]![0]; expect(name).toMatch(/^dormouse-pocket-key-probe-/); @@ -25,7 +25,7 @@ it('reports a rejected key clone before pairing, and cleans up', async () => { vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(() => { throw new DOMException('Key path did not yield a value', 'DataError'); }); - await expect(requirePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); + await expect(probePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); expect(remove).toHaveBeenCalledOnce(); }); @@ -34,7 +34,7 @@ it('rejects a successful write whose private key does not survive readback', asy vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (value) { return put.call(this, { ...value, clientStaticKeyPair: { privateKey: null } }); }); - await expect(requirePocketKeyStorage()).rejects.toThrow('Diagnostic: validate-record / Error.'); + await expect(probePocketKeyStorage()).rejects.toThrow('Diagnostic: validate-record / Error.'); }); it('rejects a readback key that derives the wrong secret', async () => { @@ -43,27 +43,27 @@ it('rejects a readback key that derives the wrong secret', async () => { vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (value) { return put.call(this, { ...value, clientStaticKeyPair: { privateKey: other.privateKey } }); }); - await expect(requirePocketKeyStorage()).rejects.toThrow('Diagnostic: compare-key-agreement / Error.'); + 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 requirePocketKeyStorage().catch(error => error as Error); + 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(requirePocketKeyStorage()).rejects.toThrow('Diagnostic: generate-key / 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(requirePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); - await expect(requirePocketKeyStorage()).resolves.toBeUndefined(); + await expect(probePocketKeyStorage()).rejects.toThrow('Diagnostic: write-record / DataError.'); + await expect(probePocketKeyStorage()).resolves.toBeUndefined(); }); From 5393e75f32bf0678c6f9070942081e2d6118220a Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 11 Sep 2026 10:44:29 -0700 Subject: [PATCH 6/7] Make unreadable pairing records recoverable without exposing browser errors --- docs/specs/pocket-app.md | 4 ++++ docs/specs/pocket-app.rationale.md | 6 ++++++ lib/src/remote/client/pocket-client.test.ts | 1 + lib/src/remote/client/pocket-client.ts | 13 ++++++++++++- lib/src/remote/client/pocket-db.test.ts | 6 +++--- lib/src/remote/client/pocket-db.ts | 6 ------ .../remote/client/pocket-encrypted-storage.test.ts | 13 ++++++++++--- lib/src/remote/client/test-e2e-harness.ts | 1 - scripts/e2e-lint.mjs | 2 +- scripts/spec-word-budgets.json | 2 +- 10 files changed, 38 insertions(+), 16 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index ccd8b9607..7a9a66732 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -410,6 +410,10 @@ 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 diff --git a/docs/specs/pocket-app.rationale.md b/docs/specs/pocket-app.rationale.md index 839ebe933..994878811 100644 --- a/docs/specs/pocket-app.rationale.md +++ b/docs/specs/pocket-app.rationale.md @@ -97,6 +97,12 @@ 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 diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 6965297c7..8b33a0565 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -1267,6 +1267,7 @@ 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); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 5ceb137d8..5cf7a56db 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -867,7 +867,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: 'This browser could not read the saved pairing record. Try again, or use Scan a setup code to pair again with fresh approval. Diagnostics: /diagnostics/index.html.', + 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 8981c302e..8ce307981 100644 --- a/lib/src/remote/client/pocket-db.test.ts +++ b/lib/src/remote/client/pocket-db.test.ts @@ -218,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 cf6ae2bc1..60a3a5f5c 100644 --- a/lib/src/remote/client/pocket-db.ts +++ b/lib/src/remote/client/pocket-db.ts @@ -259,7 +259,6 @@ export interface KnownBurrowStore { listSummaries(): Promise; put(record: KnownBurrowV1): Promise; delete(burrowId: string): Promise; - list(): Promise; } /** Where {@link PendingDeliveryDeletionV1} tombstones live; faked in tests. */ @@ -480,11 +479,6 @@ export function indexedDbKnownBurrowStore(): KnownBurrowStore { store.delete(burrowId); return promisifyTransaction(store.transaction); }), - list: () => - withPocketStore(KNOWN_BURROWS_STORE, 'readonly', async (store) => { - const values = await promisifyRequest(store.getAll()); - return Promise.all(values.map(restore)); - }), }; } diff --git a/lib/src/remote/client/pocket-encrypted-storage.test.ts b/lib/src/remote/client/pocket-encrypted-storage.test.ts index e0f93480c..12cf30a60 100644 --- a/lib/src/remote/client/pocket-encrypted-storage.test.ts +++ b/lib/src/remote/client/pocket-encrypted-storage.test.ts @@ -123,6 +123,13 @@ it('re-pairs a damaged envelope only after approval, preserving the Burrow pin a await promisifyRequest(target.put(raw)); }); await expect(store.get(harness.burrowId)).rejects.toThrow(); + const failed = await harness.client.connect(harness.burrowId); + expect(failed).toMatchObject({ ok: false, pairingRequired: false }); + if (!failed.ok) expect(failed.message).toContain('Scan a setup code'); + 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 }); @@ -204,7 +211,7 @@ it('does not persist an encrypted identity when the laptop denies pairing', asyn code: shown => shown === '00' ? '01' : '00', }); expect(result.ok).toBe(false); - expect(await store.list()).toEqual([]); + expect(await store.listSummaries()).toEqual([]); } finally { harness.client.close(); harness.burrow.stop(); } }); @@ -262,9 +269,9 @@ it('pairs, reconnects after fresh module load, and decrypts worker push with the 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.list())[0]!.authorization.state).toBe('pairing-required'); + expect((await freshStore.listSummaries())[0]!.authorization.state).toBe('pairing-required'); await freshStore.delete(harness.burrowId); - expect(await freshStore.list()).toEqual([]); + expect(await freshStore.listSummaries()).toEqual([]); } finally { harness.client.close(); harness.burrow.stop(); } }); diff --git a/lib/src/remote/client/test-e2e-harness.ts b/lib/src/remote/client/test-e2e-harness.ts index 9c37ff444..25cc3c490 100644 --- a/lib/src/remote/client/test-e2e-harness.ts +++ b/lib/src/remote/client/test-e2e-harness.ts @@ -138,7 +138,6 @@ export function memoryKnownBurrows(): MemoryKnownBurrows { 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/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index 06ac3996e..24f23b82d 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -165,7 +165,7 @@ export const RULES = [ }, { rule: 'No second AEAD outside Pocket at-rest key wrapping', - security: 'AES-GCM appears in production source under', + 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, diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index f86990ed2..51a2df947 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -13,7 +13,7 @@ "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3700, - "docs/specs/pocket-app.md": 4850, + "docs/specs/pocket-app.md": 4900, "docs/specs/relay.md": 10200, "docs/specs/remote-api.md": 4700, "docs/specs/remote-security-model.md": 4800, From 487119ae766dc2b2bd1d47b138c1169f8458a95e Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 11 Sep 2026 10:52:07 -0700 Subject: [PATCH 7/7] Share the scan label in pairing-record recovery copy --- lib/src/remote/client/pocket-client.ts | 9 ++++++++- lib/src/remote/client/pocket-encrypted-storage.test.ts | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index 5cf7a56db..ed8b713a7 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -90,6 +90,7 @@ import { 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 } @@ -875,7 +882,7 @@ export class PocketClient { // pin and delivery capability, and never expose browser exception text. return { ok: false, - message: 'This browser could not read the saved pairing record. Try again, or use Scan a setup code to pair again with fresh approval. Diagnostics: /diagnostics/index.html.', + message: CONNECTION_RECORD_UNREADABLE_MESSAGE, pairingRequired: false, }; } diff --git a/lib/src/remote/client/pocket-encrypted-storage.test.ts b/lib/src/remote/client/pocket-encrypted-storage.test.ts index 12cf30a60..de61dbabb 100644 --- a/lib/src/remote/client/pocket-encrypted-storage.test.ts +++ b/lib/src/remote/client/pocket-encrypted-storage.test.ts @@ -9,6 +9,8 @@ import { } 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 () => { @@ -124,8 +126,8 @@ it('re-pairs a damaged envelope only after approval, preserving the Burrow pin a }); await expect(store.get(harness.burrowId)).rejects.toThrow(); const failed = await harness.client.connect(harness.burrowId); - expect(failed).toMatchObject({ ok: false, pairingRequired: false }); - if (!failed.ok) expect(failed.message).toContain('Scan a setup code'); + 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();