Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions SELF_HOST.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,27 @@ plus the sleep/shutdown/logout availability limit; and the installed

## Troubleshooting boundaries

### Phone capability diagnostics
Comment thread
nedtwigg marked this conversation as resolved.

For pairing-storage failures on iOS, Android, or desktop, open
`https://<relay-origin>/diagnostics/index.html` in the affected browser and
choose **Run checks**, then **Copy results**. No setup code is needed. The
report distinguishes API presence from working crypto and storage; inspect it
before sharing because it includes browser/version information.

For persistence across app or phone restarts, use **Prepare restart test**,
close and reopen the same browser/app, then **Verify saved key** and **Copy
restart result**. Do not prepare again between those steps. Finish with
**Remove test data** in each context where you prepared a checkpoint.
Browser and installed-app results are separate evidence; a passing test on one
device is not certification of another. The diagnostic contract is
`docs/specs/pocket-app.md` -> "Serving the built bundle".
Harness v3 tests the production encrypted-key format, including authenticated
context. A v1/v2 experimental checkpoint must be removed and prepared again;
old restart reports do not establish the production-format restart result.

### Service and deployment failures

None of the three service managers runs the user's interactive shell or
PowerShell startup files, so a `PATH` that works in a terminal proves nothing
about any of them.
Expand Down
70 changes: 69 additions & 1 deletion docs/specs/pocket-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,45 @@ Source of truth: `isInstalledWebApp` / `requiresInstallForPush` /

## What Pocket stores

**Must have a successful current-page storage probe before a scan starts
registration, sign-in, token retirement, or pairing.** Share in-flight work and
cache successful selection only in memory for that page; never retain failures.
Invalidate selection after production store or key-generation failure.
Probe native storage first, then encrypted
storage only if native fails, using fresh disposable keys in Pocket's record
shape. Reopen and verify identical key agreement; reject missing or extractable
runtime keys. Both formats failing shows a storage
compatibility error without resetting pairing data. Attempt probe database
deletion on exit. (rationale)
**Must identify the failed probe stage and an allowlisted exception name;
never display browser exception messages or key material.**
**Must keep the separate-key experiment out of pairing preflight.** Direct
compatibility failures to `/diagnostics/index.html`.

**Must use metadata-only summaries for listing, push registration/queries,
removal, and re-pair identity checks.** `getSummary` and `listSummaries` omit key
material without decryption/import. Corrupt keys cannot block these operations;
re-pairing requires fresh approval and preserves the Burrow pin. Connection
and push decryption use full records.

**Must report connection-record read failures with fixed retry/scan recovery
text, never browser exception details or authorization changes.** A fresh scan
retains the pin and requires approval; a read failure grants nothing.

**Must use the selected format for new keys and decode both formats in the
shared page/worker store.** The encrypted format stores AES-GCM ciphertext,
a nonextractable per-key AES-256 key, a random 96-bit IV, and authenticated
domain/Burrow/public-key context. Generate and encrypt before the ceremony,
persist only after approval, and re-import X25519 nonextractably. Preserve its
envelope across authorization rewrites; reject corruption without generating a
replacement. Native records stay native. Older builds cannot read encrypted
records; rollback requires returning to a compatible build or pairing again.

Source of truth: `requirePocketKeyStorage` in `lib/src/remote/client/pocket-db.ts`;
tests: `lib/src/remote/client/pocket-key-storage.test.ts`,
`lib/src/remote/client/pocket-encrypted-storage.test.ts`,
`lib/src/remote/pocket-app/App.scan.test.tsx`.

**One module owns the IndexedDB name, its version, its upgrade, and every open**
(rationale). `dormouse-pocket` is at **v4**: `known-burrows` (`KnownBurrowV1`, keyed
by `burrowId`) and `pending-deletions` (`PendingDeliveryDeletionV1`, keyed
Expand All @@ -400,7 +439,7 @@ ordinary eviction-prone storage, which re-pairing survives
([remote-security-model.md](./remote-security-model.md) → Client static loss).

**A `KnownBurrowV1` is this Client's whole authorization state** — the pinned Burrow
static, the per-Burrow X25519 private half as a nonextractable `CryptoKey` beside
static, the per-Burrow X25519 private half decoded to a nonextractable `CryptoKey` beside
its raw public point, the paired passkey identifiers, and either
`{ paired, deliveryId }` or `pairing-required`. **Only the private half is a key
object**: a `NoiseKeyPair` wants the public half as raw bytes (rationale).
Expand All @@ -413,6 +452,35 @@ in `lib/src/remote/client/pocket-client.ts`.

## Serving the built bundle

**Must serve the opt-in capability harness at `/diagnostics/index.html`, built
from `lib/pocket/diagnostics/` as a second Pocket HTML entry.** Test fresh keys and isolated temporary
storage, report stage failures and cleanup failures, and never read pairing
data, request passkeys or media permissions, or upload results. API presence
is observational; crypto storage success requires reopening and using the key.
**Must use the production key codec for encrypted round-trip and restart tests,
including authenticated context.** Diagnostics never open production databases.
Keep primitive checks independently generated and database-isolated.
**Must keep diagnostics platform-neutral and state which browser/app context
was tested.** API presence alone never certifies Android, iOS, or desktop support.

**Must retain a restart checkpoint only on explicit preparation, in a
diagnostic-only database, until explicit cleanup.** Verification requires a new
page instance and derives the saved expected result using the recovered key;
never claim page reload proves process termination. The diagnostic manifest has
its own identity and start URL. Reports omit key material. Pinned by
`lib/src/remote/client/capability-harness.test.ts`.
**Must identify harness v3 production-format reports and reject legacy restart
checkpoints with explicit cleanup/reprepare instructions**, never silently
reclassify experimental evidence. Preserve the v1 production envelope/context.

Source of truth: `runCapabilities` in
`lib/pocket/diagnostics/capabilities.js`; UI:
`lib/pocket/diagnostics/page.js`; restart: `verifyRestart` in
`lib/pocket/diagnostics/restart.js`; codec: `generatePocketKeyPair` /
`loadPocketPrivateKey` in `lib/src/remote/client/pocket-private-key.ts`.
Both built HTML shells are checked by `assertPocketShell` in
`lib/scripts/assert-pocket-worker.mjs`.

Content types need no special-casing: `serveStatic` already answers
`application/manifest+json` for `.webmanifest` and `text/javascript` for `sw.js`.

Expand Down
59 changes: 59 additions & 0 deletions docs/specs/pocket-app.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,48 @@ mode. [WebKit's iOS Web Push guidance](https://webkit.org/blog/13878/web-push-fo

## What Pocket stores

Repeated scans measured the same browser compatibility while delaying each
attempt; a page-local successful promise shares that work without persisting an
assumption across app restarts. Actual writes can still fail after any probe,
so store failures invalidate the cache. Separate-key probing cannot select a
production format and now remains only in the diagnostic tool.

Burrow listing and push-subscription queries formerly decrypted every stored
key despite using metadata alone. Besides duplicate work, this prevented
listing/removing a record with a damaged envelope. Summary reads omit the
private-key field without interpreting it.

A connection-record read failure can be transient database unavailability or
an undecodable key, not evidence of Burrow revocation. The fixed error points
to retry or the existing Scan a setup code action. That action preserves the
pin and requires fresh approval, while marking every read failure as pairing
required would conflate local availability with an authenticated denial.

The operator confirmed successful production pairing on the affected iPhone on
September 11, 2026 after installing the encrypted fallback. No Android hardware
was tested in this investigation; the retained harness measures the device on
which it runs rather than selecting behavior from a user-agent string.

The encrypted representation keeps a per-key AES key beside its ciphertext in
the same record, so a committed record is sufficient for a fresh page or worker.
Runtime keys have a weakly held encrypted representation; reads restore that
association, preventing an authorization-only update from trying the broken
native X25519 serialization again. Neither database version nor store layout
changes; v4 native records remain readable without migration. The security
tradeoff and device restart evidence are in remote-security-model, Client statics.

The iOS 26.6.1 pairing failure reported in September 2026 occurred after local
approval, at the IndexedDB write. WebKit evaluates the inline key path on a
deserialized clone, so a failed embedded-key clone can look like a missing
`burrowId`. WebKit bug 312279 reports X25519 key storage returning null.
Generation and agreement alone do not test persistence; reopening and using the
stored key detects silent readback failure as well as a rejected write.

The phone subsequently reported `write-record / DataError` in the disposable
database. Testing a separately stored key distinguishes an inline-key check
failure from broken key deserialization; an explicit key can bypass the former
while hiding the latter until readback. This diagnostic does not migrate records.

**Why one module owns every IndexedDB open.** Two modules opening the same database can disagree about the version, and a connection held open across an upgrade blocks it indefinitely. Centralizing name, version, upgrade and open makes both states unreachable rather than merely unlikely.

**Why the `device-key` store is dropped on upgrade.** It belonged to the protocol that has been replaced, and a key nothing can use is only a credential left lying about.
Expand All @@ -96,6 +138,23 @@ The v4 rename also drops `known-hosts` and empties `pending-deletions`: their ol

## Serving the built bundle

Harness v1/v2 encrypted tests were experimental look-alikes without production
AAD. Their restart results established primitive persistence, not the shipped
envelope. Harness v3 imports the production codec through the same Vite build
as Pocket and rejects the old checkpoint schema instead of upgrading its
evidence. On September 11, 2026, the operator's new Home Screen v3 checkpoint
prepared at 17:14:19 UTC passed at 17:17:44 UTC: production format,
authenticated context, retained key, and a new page instance. This followed
the requested phone-restart sequence; the page itself cannot prove an OS reboot.

Measured on iPhone 15 Pro, Safari 26.6.1, September 2026: X25519 generation
worked, but structured cloning failed, inline IndexedDB writes raised DataError,
and explicit-key reads returned null. AES-GCM, Ed25519, and P-256 passed all
three storage/clone tests; AES-encrypted X25519 bytes also passed. A connection
reopen does not prove app-restart persistence, so the separate restart test
retains only a disposable checkpoint. Its page-instance check rules out an
in-memory retry, not OS process restoration; the user supplies that evidence.

**Why `no-cache` on the shell is load-bearing.** `emptyOutDir` deletes the previous build's hashed assets, so a browser reusing a heuristically cached `index.html` does not merely run stale code — it requests files that no longer exist and fails to boot, with no user recovery but clearing site data.

**Why the cache class is read off the request path.** An unhashed file emitted into `assets/`, or an overridden `assetsDir`, would silently mislabel a resolved path — and the platform-shaped path differs on Windows besides.
Expand Down
31 changes: 19 additions & 12 deletions docs/specs/remote-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/specs/remote-security-model.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@ environment.

## Client statics

The initial restart harness used an experimental envelope without the
production AAD binding. Its restart evidence below is primitive-level evidence;
the v3 harness uses the production codec and requires a fresh checkpoint.
The operator's v3 Home Screen report at 17:17:44 UTC on September 11, 2026
passed with authenticated context and a retained production-format key from
17:14:19 UTC. It detects a new page instance after the requested phone restart,
not process termination itself.

On September 11, 2026, an iPhone 15 Pro running Safari 26.6.1 failed X25519
structured cloning and native IndexedDB persistence in both Safari and a Home
Screen app, while X25519 agreement and AES key persistence worked. The isolated
encrypted-key checkpoint passed after app closure and the requested phone
restart, using the same checkpoint created at 07:15:58 UTC. The report proves
a new page instance; process termination is operator evidence, not detectable
by that page.

The self-host operator explicitly accepted the fallback tradeoff: same-origin
malicious JavaScript can decrypt and export the private bytes, so the previous
use-only XSS limitation no longer applies to that format. A nonextractable AES
key prevents a plain stored-byte copy from including its wrapping secret; it
does not protect against code running in the origin. Fresh paired-passkey
presence and Burrow approval remain unchanged. Zeroing application buffers is
best effort, not a claim about browser-internal or garbage-collected copies.

**Why possession is proven by the handshake rather than by a signature.** The
dead approach was an ECDSA P-256 device key signing a Burrow challenge in a
separate, domain-separated construction, checked as one term of a decision that
Expand Down
Loading