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
4 changes: 2 additions & 2 deletions .github/audit/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ Read `docs/specs/hosted.md`, `docs/specs/one-time.md` (its "Wire contract", "Hos

Check the installed `pgstencil`, `@pgstencil/auth`, and `@pgstencil/stripe` packages against "Deployment boundary" by reading each `dist/provenance.json`, `pnpm-lock.yaml` and `pnpm-workspace.yaml` (no `.npmrc` is tracked, so the registry is npm's default), and Hosted's runtime imports. Audit no package code, except that the `FAIL IF`s which name the packed adapter (CSRF and CORS, cookie attributes and JSON scrubbing, the revoked-login callback) may read `@pgstencil/auth`'s `dist/` to verify how Hosted configures it.

Verify npm's signed SLSA provenance for each installed package/version. Run `npm audit signatures --json --include-attestations` in `hosted/`, as the production `verify` job does. A package counts only from `verified`, never from `invalid` or `missing`. Decode the verified SLSA DSSE payload and the Fulcio certificate in that bundle and judge S119's certificate and payload clauses against them, and its job clause against that step, the payload's `externalParameters.workflow` and `resolvedDependencies` included. npm verifies the signature and subject digest, but the payload's workflow claim alone is not the signer identity. Then check the commit against pgstencil `main` and its audit:
Verify npm's signed SLSA provenance for each installed package/version. Run `npm audit signatures --json --include-attestations` in `hosted/`, as the production `verify` job does. A package counts only from `verified`, never from `invalid` or `missing`. Decode the verified SLSA DSSE payload and the Fulcio certificate in that bundle and judge S119's certificate and payload clauses against them, and its job clause against that step and `verifyAttestations`, the payload's `externalParameters.workflow` and `resolvedDependencies` included. npm verifies the signature and subject digest, but the payload's workflow claim alone is not the signer identity. Then check the commit against pgstencil `main` and its audit:

```sh
gh api repos/diffplug/pgstencil/compare/<commit>...main --jq .status
gh api --paginate 'repos/diffplug/pgstencil/commits/<commit>/check-runs?check_name=security-audit&per_page=100' \
--jq '.check_runs[] | .conclusion'
```

Judge both answers by S120. A commit can carry several `security-audit` runs; a `cancelled` one is a manual dispatch that was stopped, not a verdict. `diffplug/pgstencil`'s own `security-audit` workflow audits the released code against its `SECURITY.md`; beyond the adapter reads above, do not audit the installed packages' contents here — audit how `hosted/` configures the adapter. Distinguish tested code from pending production configuration; do not treat local provider simulations as live OAuth acceptance, and treat a checked-in placeholder as no evidence about an external control. Production activation is staged under the spec's `## Future`: while it sits below the fold there is no check here, so report its state as INFO under `### Qualitative findings`.
Judge both answers by S120, and its job clause against `verifyReleaseAudit`. A commit can carry several `security-audit` runs; a `cancelled` one is a manual dispatch that was stopped, not a verdict. `diffplug/pgstencil`'s own `security-audit` workflow audits the released code against its `SECURITY.md`; beyond the adapter reads above, do not audit the installed packages' contents here — audit how `hosted/` configures the adapter. Distinguish tested code from pending production configuration; do not treat local provider simulations as live OAuth acceptance, and treat a checked-in placeholder as no evidence about an external control. Production activation is staged under the spec's `## Future`: while it sits below the fold there is no check here, so report its state as INFO under `### Qualitative findings`.

## Work streams

Expand Down
11 changes: 4 additions & 7 deletions .github/workflows/hosted-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,14 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm test:hosted
- run: pnpm build:hosted
- name: Require accepted package provenance
run: node --input-type=module -e 'import { verifyPackages } from "./hosted/scripts/production.mjs"; await verifyPackages();'
# npm exits nonzero for a failure anywhere in Hosted's tree; only pgstencil's packages gate here.
- name: Require npm's registry signature and provenance attestation on each pgstencil package
- name: Require each pgstencil package's provenance, npm signature, signer, and source commit, and that commit on main with a passing audit
working-directory: hosted
run: |
npm audit signatures --json --include-attestations > "$RUNNER_TEMP/signatures.json" || true
jq -e '["pgstencil", "@pgstencil/auth", "@pgstencil/stripe"] as $ours
| [.invalid[], .missing[] | select(.name as $name | $ours | index($name))] == []
and ($ours - [.verified[] | select(.attestations.provenance) | .name] == [])' \
"$RUNNER_TEMP/signatures.json"
node scripts/pgstencil-provenance.mjs "$RUNNER_TEMP/signatures.json"
env:
GH_TOKEN: ${{ github.token }}
# Every Worker's static files, each under its own `dist/<worker>/`.
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,11 @@ The pre-launch sign-in gate is "Identity and login".

Begin refuses an absent or mismatched origin with `ORIGIN_MISMATCH_ERROR`. The verification link points to the account's `/enroll` page, with the user code in its fragment and voice purpose in its query. `redeemed` names the Burrow whose answer was lost; 409 names the account page at the abuse ceiling `MAX_BURROWS_PER_ACCOUNT` (`docs/specs/pricing.md` -> "Checkout and entitlement").

- **Never write from begin.** The device code is 32 bytes, the bearer shape: its expiry in its leading bytes, the rest random. The user code is `enrollUserCode` under `RELAY_ENROLL_SECRET` for remote enrollment, or that secret suffixed with `:voice` for managed voice (rationale). The confirmation digits are `enrollConfirmDigits` of the user code under `RELAY_ENROLL_SECRET`; the link carries the user code, never the digits.
- **Never write from begin.** The device code is 32 bytes, the bearer shape, which a Burrow holds opaque: its expiry in its leading bytes, then random bytes, then the tag `signEnrollDeviceCode` writes. The tag and the user code (`enrollUserCode`) are under `RELAY_ENROLL_SECRET` for remote enrollment, or that secret suffixed with `:voice` for managed voice (rationale). The confirmation digits are `enrollConfirmDigits` of the user code under `RELAY_ENROLL_SECRET`; the link carries the user code, never the digits.
- **Must redeem only an approval whose typed digits are its code's confirmation digits, compared at the poll** (rationale). Any other answers `mismatch` to every poll until it expires: one attempt per code. The first such poll stamps the live, unredeemed approval's `mismatchedAt` for its approver's waiting page.
- **Must store the approval alone** (`dormouse_relay_enrollment_approvals`): it cannot tell an issued code from any well-formed one, so it approves any; one no Burrow redeems expires (rationale).
- **Never admit a begin or poll request carrying `Origin`** (403): only a Node Burrow calls them. Then 429 with `Retry-After` past `RELAY_ENROLL_BEGIN_LIMIT` (10 a minute per address) or `RELAY_ENROLL_POLL_LIMIT` (60), before the body limit and any database read.
- **Must answer an expired device code from the code alone**, reading no database.
- **Must answer `expired` from the code alone, reading no database, for a device code past its expiry or without its purpose's tag** (rationale). A code begun before the tag existed answers `expired`, as it would have within its ten minutes.
- **Must redeem in one statement** that marks the user code's live unredeemed approval redeemed and inserts the Burrow owned by its `userId`, under the account's lock with the cap check, so two polls mint one Burrow. The entitlement is rechecked first.
- **Must store the label a remote poll carries, reduced by `reduceBurrowLabel` (64 code points, no control, format, or separator characters), never refusing the poll over it**; the column's CHECK refuses what the reduction strips.
- **Must keep a redeemed approval until it expires**, swept hourly, so a poll whose `enrolled` answer was lost reads `redeemed`. **Never key it to the Burrow**: removing that Burrow leaves it redeemed; only an approval after it expires replaces it.
Expand Down
1 change: 1 addition & 0 deletions docs/specs/hosted.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ A `RelayRoom` that opened a Postgres connection through Hyperdrive could not be

- Begin stores nothing (2026-10-01): a stored request needed a cap, and `begin` is unauthenticated, so the cap could only be global, which a few /64s at the per-address limit could fill, locking every Burrow out of enrolling. With the expiry inside the device code and the user code derived from it, begin costs one HMAC and grows nothing.
- User code derivation: the HMAC key keeps the user code unpredictable from the device code, so no one can mint a device code for a code a victim is about to approve without about 30⁸ ≈ 6.6 × 10¹¹ begins, against 10 a minute per address. Two live device codes sharing a user code is a 40-bit collision; the approval then redeems for whichever polls first, as owned by the approver.
- Device code tag (2026-11): a user code is about 39 bits and keys the approval, so a poll's lookup should see only codes Hosted issued; a tag lets it tell, with begin still writing nothing. That holds trying user codes to `RELAY_ENROLL_BEGIN_LIMIT`, since one issued code derives one user code, and turns away everything else without the database; it does not widen the user code. Twelve bytes leave sixteen random and four of expiry in the bearer shape every shipped Burrow already holds. A code from before the tag cannot stay valid without leaving untagged codes valid for everyone: its Burrow reads the `expired` it already handles, and that window closes ten minutes after the deploy.
- Approvals table bound: the approval route is its only writer, authenticated, admin-only, and limited to 10 attempts a minute per account, each approval living 10 minutes, so one account holds at most about 100 live approvals.
- A passkey outlives its Burrow (2026-10-20): Pocket signs in to every Burrow of the account with one passkey, retiring a later computer's setup code rather than registering again, and each Burrow's pairings pin that passkey, so removing the computer whose code added it would sign the phone out of the others and force it to pair with each again. The account page removes a passkey on its own instead, naming the computer that added it while that computer is enrolled.
- Confirmation digits (2026-10-20): the user code alone was passive, so a person who clicked a stranger's link and Approve without comparing handed that stranger an enrollment or a voice token. The digits make approving active, as pairing's are: only someone looking at the computer that asked can type them, and a link never carries them. They are derived rather than stored so begin still writes nothing, and checked at the poll because the account Worker never holds `RELAY_ENROLL_SECRET` (`docs/specs/security-hosted.md` -> "Origin boundary"); the account page therefore cannot say a typo was wrong, and the desktop reports it. One attempt at 1/100 per code, the approval limit bounding attempts per account. Desktops through v1.3.0 show no digits, so they cannot finish a Hosted approval: a path without digits would be a downgrade anyone could choose.
Expand Down
4 changes: 3 additions & 1 deletion docs/specs/pocket-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ A framed page (`docs/specs/remote-api.md` -> "Frames") runs at a frame origin: a
- **Never count on a framed page's own browser storage surviving a relaunch** (rationale); a built-in Tool keeps none (`docs/specs/dor-tools-builtin.md` -> "Editing files").
- **The page's `WebSocket` is shimmed by a tunneled grant alone, ahead of the page's own scripts**, since no worker sees a socket: a same-origin one goes as a message to the controlling worker, and any other stays native. **A shimmed socket must take a send's bytes at the call, keep its close behind them, and close when its page goes away**, since nothing else tells the session.

**The frame origins sit on a registrable domain of their own, `HOSTED_FRAME_DOMAIN`**, so a framed page cannot set a cookie on `dormouse.sh`.
**The frame origins sit on a registrable domain of their own, `HOSTED_FRAME_DOMAIN`**, so a framed page cannot set a cookie on `dormouse.sh`. They share that domain with one another until it is on the Public Suffix List (`docs/specs/security.md` -> "Known gaps").

- **The bootstrap page must expire every cookie it can see on the parent domain before the frame's page loads**, leaving this origin's own.

- **Must declare `capabilities.frames` only at Hosted's production origin** (`frameDomainFor`), from Pocket and the one-time page alike: the frame Worker lets no other page frame it, and a preview has no wildcard to serve one on.
- **Must let frames in through Pocket's and the one-time page's CSPs only for that domain**; a self-host Relay's Pocket names none. The one-time page's own CSP sandbox must grant what `IFRAME_SANDBOX` does, since a frame's is at most its parent's.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Production configuration (`pnpm --filter relay start`, containers, installers):

**The Relay itself always speaks plain HTTP**: WebAuthn needs a secure context, so `localhost` serves development and a real phone needs TLS in front (`tailscale serve` for selfhost; any reverse proxy works).

**Must bind loopback when the TLS proxy is local**, since every interface also publishes the plaintext port to the LAN and the tailnet. The selfhost installers set `DORMOUSE_BIND_HOST`; the default stays unbound for containers; every developer and test entrypoint binds loopback itself, and an explicit value wins. Binding loopback is containment, not admission: "HTTP API" owns the gates, `docs/specs/security-local.md` -> "Loopback Listeners" the admission rule, which `scripts/loopback-lint.mjs` does not check for this socket (rationale).
**Must bind loopback when the TLS proxy is local**, since every interface also publishes the plaintext port to the LAN and the tailnet. The selfhost installers set `DORMOUSE_BIND_HOST`; the default stays unbound for containers, with a startup warning when the origin is `https:`; every developer and test entrypoint binds loopback itself, and an explicit value wins. Binding loopback is containment, not admission: "HTTP API" owns the gates, `docs/specs/security-local.md` -> "Loopback Listeners" the admission rule, which `scripts/loopback-lint.mjs` does not check for this socket (rationale).

**`DORMOUSE_ORIGIN` is normalized to a bare origin once, in `readConfig`**, and every WebAuthn, enrollment, and pairing-URL compare uses that string; anything but an `http`/`https` URL with a host is a `ConfigError` naming the variable (rationale).

Expand Down
Loading
Loading