diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 7a438d9b9..889091fec 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -12,7 +12,7 @@ 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/...main --jq .status @@ -20,7 +20,7 @@ gh api --paginate 'repos/diffplug/pgstencil/commits//check-runs?check_na --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 diff --git a/.github/workflows/hosted-production.yml b/.github/workflows/hosted-production.yml index 68f00a132..897483748 100644 --- a/.github/workflows/hosted-production.yml +++ b/.github/workflows/hosted-production.yml @@ -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//`. - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index f4c8feb94..f64b7dd0d 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -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. diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 0a571487c..5656df587 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -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. diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 5454362c1..a919241f3 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -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. diff --git a/docs/specs/relay.md b/docs/specs/relay.md index faf5712f3..0444b1e14 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -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). diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 6934babc0..70604a907 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -10,7 +10,7 @@ | Guarantee | Feature | Pinned by | | --- | --- | --- | | **Each Hosted Worker answers only its own origin and runs only its own code.** No third-party script, analytics, or framing; sign-in cookies stay on the account origin, and its session never reaches browser storage. | One-time link, Hosted phone | `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts` | -| **A framed page runs on a domain with nothing else on it.** That domain's Worker holds no credential and sets no cookie, and only Pocket can frame what it serves. | Hosted phone | `hosted/scripts/production.test.mjs`, `hosted/server/tests/frame-origin.test.ts` | +| **A framed page runs on a domain with nothing else of Hosted's on it.** That domain's Worker holds no credential and sets no cookie, and only Pocket can frame what it serves. Framed pages share the domain with one another (`docs/specs/security.md` -> "Known gaps"). | Hosted phone | `hosted/scripts/production.test.mjs`, `hosted/server/tests/frame-origin.test.ts` | - **FAIL IF** `S77` a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421; inspect `workerApp` in `hosted/server/worker-app.ts`. - **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `cookieLogin` / `cookieAdmin` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `accountRoutes` in `hosted/server/account-routes.ts`, and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale). @@ -81,7 +81,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **FAIL IF** `S98` a query reading a Burrow, passkey, presence nonce, or setup token is not scoped to the caller's account (the session's user, the Burrow token's owner, or the setup token's Burrow's owner; `RelayRows` by its consumer's account check; sign-in excepted), a passkey registers to any account but the minting Burrow's owner, or a setup challenge redeems with another Burrow's token. - **FAIL IF** `S99` a setup token, challenge, or presence nonce is checked and spent in separate statements, so two concurrent redeemers can both win, or a retire misses a setup token a `finish` claimed, a refused `finish` gives its token a later expiry or a second use rather than clearing that claim or spending the token, or a passkey insert does not first re-read the claim `FOR UPDATE`. - **FAIL IF** `S100` a Burrow-authenticated request, setup redemption, enrollment redemption, sign-in, or session-gated request skips rechecking the owner's entitlement (`entitledSql` on its user row) in that request, so a de-entitled account's session acts or a sign-in mints one; or a removed Burrow's row, setup tokens, or setup challenges survive its removal, or removal reaches another account's Burrow; or a removed passkey's sessions survive its removal, a session is stored naming no passkey, or removal reaches another account's passkey. -- **FAIL IF** `S101` a route that presents no credential writes a row other than a sign-in challenge (enrollment begin writes none); setup routes write only under a live setup token and a poll only under an approval; a device code's expiry is read from anywhere but its own leading bytes, or a code past it reaches the database; or a user code is anything but the purpose-bound derivation in `docs/specs/hosted.md` -> "Burrow enrollment". +- **FAIL IF** `S101` a route that presents no credential writes a row other than a sign-in challenge (enrollment begin writes none); setup routes write only under a live setup token and a poll only under an approval; a device code's expiry is read from anywhere but its own leading bytes, or a code past it or without its purpose's tag (`isSignedEnrollDeviceCode` in `remote-lib-common/src/remote/enroll-code.ts`) reaches the database; or a user code is anything but the purpose-bound derivation in `docs/specs/hosted.md` -> "Burrow enrollment". - **FAIL IF** `S102` an approval is redeemed other than in the one statement that marks it redeemed and inserts only its purpose's credential (Burrow or voice token), or a redeemed approval redeems again (removing its Burrow included), so two polls can mint two Burrows; the Burrow is owned by anyone but the approval's `userId`; a poll redeems an approval for another purpose or a user code its device code does not derive; an approval replaces a live one; or a poll or approval treats an approval past its `expiresAt` as live (an approval may only replace it). - **FAIL IF** `S276` a poll redeems an approval whose typed digits are not `enrollConfirmDigits` of its user code under `RELAY_ENROLL_SECRET`, in its read and in its redeeming statement both; `mismatchedAt` is stamped other than by a poll under a live, unredeemed approval or lets an approval redeem; another account reads an approval's mismatch or redemption; begin answers digits derived otherwise; the approval route stores one without exactly two digits; or a `verificationUrl` carries them. - **FAIL IF** `S103` enrollment begin or poll admits a request carrying `Origin`, or either reaches the database before its per-address limit. @@ -106,12 +106,12 @@ Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.t | Guarantee | Feature | Pinned by | | --- | --- | --- | -| **Hosted runs only pgstencil releases that passed pgstencil's own audit.** A production deploy carries no test controls and first verifies each pgstencil package's provenance and npm attestation; a preview reaches no real provider. | One-time link, Hosted phone | `hosted/scripts/production.test.mjs`, `hosted/server/tests/artifacts.test.ts` | +| **Hosted runs only pgstencil releases that passed pgstencil's own audit.** A production deploy carries no test controls and first verifies each pgstencil package's provenance and npm attestation, that pgstencil's release workflow signed it from that commit, and that the commit is on pgstencil's `main` with a passing audit; a preview reaches no real provider. | One-time link, Hosted phone | `hosted/scripts/production.test.mjs`, `hosted/scripts/pgstencil-provenance.test.mjs`, `hosted/server/tests/artifacts.test.ts` | - **FAIL IF** `S117` a production Worker exposes the captured-email inbox or deterministic clock controls, or imports the testing injection module; inspect `hosted/server/worker.ts`, `hosted/server/relay-worker.ts`, `hosted/server/voice-worker.ts`, the build configuration, and `hosted/server/tests/worker-entry.ts`. - **FAIL IF** `S118` any installed pgstencil package (core, auth, stripe) lacks `dist/provenance.json`, records `dirty`, names a commit that is not 40 lowercase hex or differs from another's; `pnpm-lock.yaml` resolves any of them from outside npm or without an integrity hash; or a runtime import depends on a sibling pgstencil checkout. Inspect `verifyPackages` in `hosted/scripts/production.mjs`, `hosted/server/tests/artifacts.test.ts`, and Hosted runtime imports. -- **FAIL IF** `S119` the production `verify` job, which `deploy` needs, can pass while `npm audit signatures --include-attestations` finds any installed pgstencil package's registry signature or SLSA provenance attestation missing or invalid; or, beyond what npm checks (rationale), any of their verified attestations has a Fulcio certificate SAN not naming `diffplug/pgstencil` `.github/workflows/release.yml` on `refs/heads/main`, a source-repository digest (OID `1.3.6.1.4.1.57264.1.13`) other than `dist/provenance.json`'s commit, or a signed subject/payload that disagrees with the installed package, certificate, or commit. -- **FAIL IF** `S120` that commit is not on pgstencil `main` (`gh api repos/diffplug/pgstencil/compare/...main`, status `ahead` or `identical`), or its `security-audit` check runs (`gh api repos/diffplug/pgstencil/commits//check-runs`) include no `success`, or any conclusion other than `success` and `cancelled`. pgstencil audits the released code; Dormouse audits only how Hosted configures it. +- **FAIL IF** `S119` the production `verify` job, which `deploy` needs, can pass while `npm audit signatures --include-attestations` finds any installed pgstencil package's registry signature or SLSA provenance attestation missing or invalid; or, beyond what npm checks (rationale), any of their verified attestations has a Fulcio certificate SAN not naming `diffplug/pgstencil` `.github/workflows/release.yml` on `refs/heads/main`, a source-repository digest (OID `1.3.6.1.4.1.57264.1.13`) other than `dist/provenance.json`'s commit, or a signed subject/payload that disagrees with the installed package, certificate, or commit; inspect `verifyAttestations` in `hosted/scripts/pgstencil-provenance.mjs` and its step in `.github/workflows/hosted-production.yml`. +- **FAIL IF** `S120` that commit is not on pgstencil `main` (`gh api repos/diffplug/pgstencil/compare/...main`, status `ahead` or `identical`), or its `security-audit` check runs (`gh api repos/diffplug/pgstencil/commits//check-runs`) include no `success`, or any conclusion other than `success` and `cancelled`; or the `verify` job can pass while either holds (`verifyReleaseAudit` there). pgstencil audits the released code; Dormouse audits only how Hosted configures it. - **FAIL IF** `S121` the local email inbox accepts a foreign Host or Origin or cross-site Fetch Metadata; inspect `allowedDevRequest` in `hosted/server/dev-host-guard.ts`, including the upgrade guard in `hosted/server/dev.ts`. - **FAIL IF** `S122` the production deploy can proceed without `preflight` establishing, for each Worker's one and only Hyperdrive, disabled caching, the migration URL's host, port, and database, and exactly that Worker's role as its user (the relay's and voice's `hosted/server/runtime-roles.sql` roles, never the migration role), the three distinct; or each Worker's required secrets on its own script. Inspect `PRODUCTION`, `productionConfigs`, and `preflight` in `hosted/scripts/production.mjs` and the preflight's ordering ahead of the backup in `.github/workflows/hosted-production.yml`. diff --git a/docs/specs/security-hosted.rationale.md b/docs/specs/security-hosted.rationale.md index 26acf5b8c..4dcda5b45 100644 --- a/docs/specs/security-hosted.rationale.md +++ b/docs/specs/security-hosted.rationale.md @@ -20,4 +20,5 @@ Cookie reads (2026-10-11): pgstencil provenance at deploy time (2026-10-20): - Renovate opens a pgstencil bump as soon as a release is approved (`.github/renovate.json`), and a production deploy can follow its merge within hours, between two nightly audits. The `verify` job therefore asks npm for each package's registry signature and provenance attestation before every deploy. -- npm verifies the signature, the certificate chain, and the subject against the registry's tarball, not who signed: the payload's workflow claim is the signer's own statement. Reading the certificate's SAN and source-repository digest, and the commit's place on `main` and its audit, means decoding the bundle and calling the GitHub API, so that stays in the nightly audit rather than in a deploy script. +- npm verifies the signature, the certificate chain, and the subject against the registry's tarball, not who signed: the payload's workflow claim is the signer's own statement. pgstencil's `release.yml` stages only a `main` commit whose `security-audit` passed, and its npm trusted publisher names that workflow, but those are its settings, which this repository cannot read and a hand publish would skip (0.2.0 was one). So the `verify` job reads the signer, source commit, and audit itself (2026-11), rather than leaving a mismatch for that night's audit to find. +- `verify` holds a commit to S120 as written, so any failed `security-audit` on it refuses a deploy, a flake included, until that run is rerun or a new release ships. A rerun replaces the failed run in the API's default (`latest`) view. diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index 0fb617e02..e958aa084 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -135,7 +135,7 @@ Pocket uses relative API URLs at the configured origin; Burrow HTTP runs in Node | Guarantee | Feature | Pinned by | | --- | --- | --- | -| **The self-host HTTPS origin may be public; its plaintext backend may not.** Terminal access still needs local Burrow approval. | Self-hosted Relay | `relay/test/config.test.mjs`, `scripts/deploy-lint.mjs` | +| **Under a self-host installer, the HTTPS origin may be public; its plaintext backend may not.** Terminal access still needs local Burrow approval. A Relay started by hand with no bind host listens on every interface and warns that it does. | Self-hosted Relay | `relay/test/config.test.mjs`, `relay/test/bind-host.test.mjs`, `scripts/deploy-lint.mjs` | `scripts/deploy-lint.mjs` checks that every installer still holds the controls this section and "Credentials at rest" name (`AGENTS.md` lint table); whether each is *correct* is this audit's (rationale). @@ -160,7 +160,7 @@ The HTTPS origin may be public. Tailnet-only Serve is the installer default and | --- | --- | --- | | **Push notifications are opt-in, and a push is sealed to the one phone that receives it.** | Hosted phone, Self-hosted Relay | `lib/src/remote/pocket-app/App.push.test.tsx`, `relay/test/push.test.mjs`, `remote-lib-common/test/push-seal.test.mjs` | | **Push, when enabled, cannot be aimed back into the tailnet.** | Self-hosted Relay | `relay/test/push-endpoint.test.mjs`, `relay/test/push.test.mjs` | -| **A phone reaches only pages on the laptop itself, and only ones it already shows.** A frame opens at the page a browser pane or Tool embeds, never at an address the phone names and never one off that machine, through the same proxy rules as that pane, and closes when the page goes. | One-time link, Hosted phone, Self-hosted Relay | `lib/src/remote/burrow/frame-tunnel.test.ts`, `lib/src/host/remote/frame-host.test.ts`, `lib/src/host/iframe-proxy.test.ts` | +| **A phone reaches only pages on the laptop itself, and only at origins it already shows.** A frame opens at the page a browser pane or Tool embeds, never at an address the phone names and never one off that machine, through the same proxy rules as that pane, and closes when its surface leaves that page's origin. | One-time link, Hosted phone, Self-hosted Relay | `lib/src/remote/burrow/frame-tunnel.test.ts`, `lib/src/host/remote/frame-host.test.ts`, `lib/src/host/iframe-proxy.test.ts` | | **Only a phone this computer paired adds a push registration; a revoked one can keep or re-add a row that never receives a push.** A signed-in session that never paired cannot register, or displace a paired phone's registration. | Hosted phone, Self-hosted Relay | `relay/test/push.test.mjs`, `hosted/server/tests/relay.test.ts` | **The relay is a dumb ciphertext pipe**: it routes `e2e` envelopes within one Client↔Burrow binding and decodes nothing. Once a Burrow has decrypted them, both directions carry untrusted bytes — inbound, `terminal.write` is keystrokes into a real shell and the ACL is the entire gate; outbound, notification text is Pane-derived, so it is **bounded on the Burrow before sealing and re-bounded at the render sink** (rationale). @@ -182,7 +182,7 @@ The HTTPS origin may be public. Tailnet-only Serve is the installer default and | Guarantee | Feature | Pinned by | | --- | --- | --- | -| **Under Local networks only, terminal traffic crosses only the networks you allow.** Handshakes, pairing, alerts, and managed voice still pass through Hosted from any network. | One-time link, Hosted phone | `lib/src/host/remote/local-networks.test.ts`, `lib/src/remote/burrow/burrow-direct-only.test.ts`, `lib/src/host/remote/outbound.test.ts`, `scripts/outbound-lint.mjs` | +| **Under Local networks only, terminal traffic crosses only the address ranges you allow.** Handshakes, pairing, alerts, and managed voice still pass through Hosted from any network. | One-time link, Hosted phone | `lib/src/host/remote/local-networks.test.ts`, `lib/src/remote/burrow/burrow-direct-only.test.ts`, `lib/src/host/remote/outbound.test.ts`, `scripts/outbound-lint.mjs` | | **Under Anywhere, a paired phone may relay through Hosted, end-to-end encrypted.** Cloudflare STUN sees this computer's public address as a phone connects. | One-time link, Hosted phone | `lib/src/remote/burrow/burrow-direct-only.test.ts`, `lib/src/host/remote/service.test.ts`, `lib/src/host/remote/outbound.test.ts`, `scripts/outbound-lint.mjs` | **An authorized session can leave the Relay for a WebRTC data channel, carrying what it already carried**: the same Noise session, counters, and bounds. `docs/specs/remote-api.md` -> "Direct path" owns the design and `docs/specs/remote-security-model.md` -> "Direct path" why it adds no trust layer. diff --git a/docs/specs/security.md b/docs/specs/security.md index 3523072a1..528cdadbb 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -4,7 +4,7 @@ > - Owns the features Dormouse offers, what it does not defend, the gaps it knows about, and how all of it is checked. Each guarantee sits beside the checks that hold it, in the domain spec, and is gathered here when published. > - Defers every mechanism to the spec that owns it, and every audited check to the specs the audit's domains own ([Domains](./security-audit.md#domains)). Published at `https://dormouse.sh/security`; `docs/specs/website-docs.md` owns which entries each page shows. -A modern terminal must have a conservative security stance. Dormouse won't even check whether an update is available unless you give it permission to check. If you opt in to Dormouse's push notifications and remote access, you can restrict their terminal traffic to networks you name, such as your VPN. +A modern terminal must have a conservative security stance. Dormouse won't even check whether an update is available unless you give it permission to check. If you opt in to Dormouse's push notifications and remote access, you can restrict their terminal traffic to address ranges you name, such as your VPN's. Dormouse holds shells, source trees, credentials, and local files. Its **dependencies and release pipeline** determine what code reaches a machine; **remote control** admits an authorized phone as a person at the keyboard; **loopback listeners** receive requests from pages in the user's browser. @@ -65,6 +65,8 @@ Source of truth: root scripts in `package.json`; native jobs in `.github/workflo - *Hosted phone, Self-hosted Relay.* **Forged attention notices, when push is enabled.** The Relay or the browser's push service can make Pocket show its content-free "a terminal needs attention" notice at will ([Push sealing](./remote-security-model.md#push-sealing)). - *Hosted phone, Self-hosted Relay.* **Per-Burrow unlinkability, when push is enabled.** One push endpoint per browser lets the Relay see every Burrow one phone registered ([Residual metadata](./remote-security-model.md#residual-metadata)). - *Hosted phone, Self-hosted Relay.* **Phone-key durability.** Clearing site data means pairing again. Nothing is compromised; a lost key authorized nothing on its own ([Client static loss](./remote-security-model.md#client-static-loss)). +- *One-time link, Hosted phone.* **Local networks only checks addresses, not which network holds them.** A network reusing a range you allow counts as allowed, and a machine on an allowed range can forward a phone in. That session is still end-to-end encrypted and needs its phone's approval; what leaks is its metadata on that network ([Direct path](./security-remote.md#direct-path)). +- *Self-hosted Relay.* **A Relay you start without an installer listens on every interface unless you set its bind host.** It warns at startup when its origin is `https:`; what it exposes is the same routes with the same gates, past any network limit on the HTTPS origin ([Network posture (self-hosted)](./security-remote.md#network-posture-self-hosted)). - *One-time link, Hosted phone, Self-hosted Relay.* **Availability.** Remote terminal access needs an online Burrow and, for new relay-backed sessions, an available Relay ([Goals](./remote-security-model.md#goals); [keeping it up](../../SELF_HOST.md#keeping-the-relay-up-while-the-laptop-sleeps)). A signed-in session on the account, a revoked phone's included, can also spend the handshake budget that admits new connections and pairings for as long as it keeps at it; sessions already open stay up ([Burrow bounds](./remote-security-model.md#burrow-bounds)). - *Hosted phone.* **The voice provider keeps what managed voice speaks until Dormouse deletes it.** ElevenLabs stores each spoken label in its speech history; Dormouse deletes it there, usually within minutes, but no bound is guaranteed, and deleting it need not erase the provider's backups or moderation records ([Account boundary](./security-hosted.md#account-boundary)). - *Hosted phone.* **Whichever account first approves a live Hosted enrollment code, with the two digits its computer shows, owns that computer**; yours is then refused as already approved. Approving takes those digits, so a link alone approves nothing, but a stranger can read you theirs. That account still pairs no phone without the two digits typed here ([Trust boundary](./security-remote.md#trust-boundary)). Approving a stranger's code lets their computer add passkeys to your account, filling it and signing you out, until you remove them on the account page; a voice code spends your daily voice allowance until you turn Natural Voice off. @@ -80,6 +82,7 @@ Gaps rather than accepted risks: we intend to close them. - **Shell-specific Windows command quoting does not protect every native batch handoff.** PowerShell can strip quotes around a no-space argument before a `.cmd` target parses it, turning an ampersand into command syntax. This affects argv Tools launched in new Sessions, preview retargets, and restored commands; falling back from portable quoting to a new Session does not repair it. The portable form used for paste and existing-prompt commands refuses these arguments ([Dor Tool configuration](./security-local.md#dor-tool-configuration)). - **A page in a browser pane can take the keyboard without a click.** Its messages to Dormouse never focus it, but the browser lets a frame focus itself: Chromium (VS Code, the Windows app) at any time, WebKit (the macOS and Linux apps) once it has been clicked. What you type then goes to that page until you click elsewhere ([Browser panes](./security-local.md#browser-panes)). - **Browser-pane scripts share loopback cookies across grant ports.** HTTP and WebSocket cookie headers are stripped, but `document.cookie` remains shared; cookie-authenticated iframe pages are unsupported ([Loopback Listeners](./security-local.md#loopback-listeners)). +- *One-time link, Hosted phone.* **Framed pages share one domain's cookies until that domain is on the Public Suffix List.** A framed page can leave a cookie on `dormouseusercontent.com` for another frame's page in that browser to read, and every frame request carries it. A one-time link's frames share it with Pocket's. No page's server sees these cookies, and a frame's bootstrap page expires those it can see before its page loads, so what remains is a value a sibling sets later or on a deeper path, and frames that fail to load, until the phone's cookies are cleared, if a page leaves more than the frame host accepts ([Origin boundary](./security-hosted.md#origin-boundary)). - **Windows screenshots and pasted clipboard images are only as private as the temp directory they land in:** private by default, exposed if it is shared or loosened ([Browser panes](./security-local.md#browser-panes)). - **Neither VS Code's peer-link token and endpoint, its Tool trust receipts, nor the `recovery.json` beside them carries a Windows ACL applied by Dormouse.** They are written owner-only by unix mode, which Windows makes a no-op; standalone locks its state directory instead ([Persisted state](./security-local.md#persisted-state)). - *One-time link, Hosted phone, Self-hosted Relay.* **No independent third party has reviewed remote control's cryptography.** Dormouse's Noise integration, passkey (WebAuthn) channel binding, key storage, and push sealing are checked only by this project's own tests, lints, and nightly audits, which AI agents run ([Noise suite](./remote-security-model.md#noise-suite)). diff --git a/hosted/scripts/pgstencil-provenance.mjs b/hosted/scripts/pgstencil-provenance.mjs new file mode 100644 index 000000000..cf16dcc70 --- /dev/null +++ b/hosted/scripts/pgstencil-provenance.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * What the production `verify` job holds an installed pgstencil release to + * beyond npm's own verdict (`docs/specs/security-hosted.md` -> "Deployment + * boundary"): who signed it, from which commit, and that commit's audit. + * + * node hosted/scripts/pgstencil-provenance.mjs + */ +import assert from "node:assert/strict"; +import { X509Certificate } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { PGSTENCIL_PACKAGES, verifyPackages } from "./production.mjs"; + +const REPOSITORY = "diffplug/pgstencil"; +const WORKFLOW = ".github/workflows/release.yml"; +const REF = "refs/heads/main"; +const SIGNER = `URI:https://github.com/${REPOSITORY}/${WORKFLOW}@${REF}`; +const PROVENANCE_PREDICATE = "https://slsa.dev/provenance/v1"; +/** Fulcio's source-repository digest, 1.3.6.1.4.1.57264.1.13, as DER. */ +const SOURCE_DIGEST_OID = Buffer.from("2b0601040183bf30010d", "hex"); + +/** The DER values directly inside `der`. */ +function derChildren(der) { + const children = []; + for (let at = 0; at < der.length; ) { + const tag = der[at++]; + let length = der[at++]; + if (length & 0x80) { + const bytes = length & 0x7f; + length = der.subarray(at, at + bytes).reduce((sum, byte) => sum * 256 + byte, 0); + at += bytes; + } + children.push({ tag, value: der.subarray(at, at + length) }); + at += length; + } + return children; +} + +/** Fulcio's source-repository digest in a certificate's extensions, or `null`. */ +function sourceDigestOf(certificate) { + const [tbs] = derChildren(derChildren(certificate)[0].value); + // `[3]`, the extensions of an X.509 v3 TBSCertificate. + const extensions = derChildren(tbs.value).find(({ tag }) => tag === 0xa3); + if (!extensions) return null; + for (const { value } of derChildren(derChildren(extensions.value)[0].value)) { + const fields = derChildren(value); + if (fields[0].value.equals(SOURCE_DIGEST_OID)) { + return derChildren(fields.at(-1).value)[0].value.toString("utf8"); + } + } + return null; +} + +const certificateOf = ({ verificationMaterial: material }) => + Buffer.from(material.certificate?.rawBytes ?? material.x509CertificateChain?.certificates[0].rawBytes, "base64"); + +const purlOf = (name, version) => `pkg:npm/${name.replace("@", "%40")}@${version}`; + +/** Holds one verified provenance `bundle` of `name@version` to `commit`. */ +function verifyProvenance(name, version, bundle, commit) { + const release = `${name}@${version}`; + const der = certificateOf(bundle); + assert.equal(new X509Certificate(der).subjectAltName, SIGNER, `${release} was signed by another identity`); + assert.equal(sourceDigestOf(der), commit, `${release}'s certificate names another commit`); + const { subject, predicate } = JSON.parse(Buffer.from(bundle.dsseEnvelope.payload, "base64").toString("utf8")); + assert.deepEqual(subject.map((one) => one.name), [purlOf(name, version)], `${release}'s provenance names another package`); + const { externalParameters, resolvedDependencies } = predicate.buildDefinition; + const { ref, repository, path } = externalParameters.workflow; + assert.deepEqual( + { ref, repository, path }, + { ref: REF, repository: `https://github.com/${REPOSITORY}`, path: WORKFLOW }, + `${release}'s provenance names another workflow`, + ); + const sources = resolvedDependencies.map((one) => one.digest?.gitCommit).filter(Boolean); + assert.ok(sources.length && sources.every((source) => source === commit), `${release}'s provenance names another commit`); +} + +/** + * Holds `report`, what `npm audit signatures --json --include-attestations` + * printed, to `commit`, the one `dist/provenance.json` names. A package counts + * only from `verified`, and never with an entry in `invalid` or `missing`. npm + * has verified each bundle's signature, chain, and subject digest; who signed is + * not its question, and the payload's workflow is only the signer's own claim. + */ +export function verifyAttestations(report, commit) { + for (const { name } of PGSTENCIL_PACKAGES) { + for (const refusal of ["invalid", "missing"]) { + assert.ok(!report[refusal]?.some((entry) => entry.name === name), `npm reports ${name} ${refusal}`); + } + const entries = (report.verified ?? []).filter((entry) => entry.name === name); + assert.ok(entries.length, `${name} has no verified attestation`); + for (const { version, attestationBundles = [] } of entries) { + const provenance = attestationBundles.filter(({ predicateType }) => predicateType === PROVENANCE_PREDICATE); + assert.ok(provenance.length, `${name}@${version} has no verified provenance attestation`); + for (const { bundle } of provenance) verifyProvenance(name, version, bundle, commit); + } + } +} + +/** `path` of GitHub's API, as JSON, by `fetcher`; anything but a 200 refuses. */ +const githubJson = async (path, fetcher, token) => { + const response = await fetcher(`https://api.github.com/${path}`, { + headers: { accept: "application/vnd.github+json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, + signal: AbortSignal.timeout(60_000), + }); + assert.equal(response.status, 200, `GitHub answered ${path} with ${response.status}`); + return response.json(); +}; + +/** + * Holds `commit` to pgstencil's `main` and its `security-audit` check: at least + * one `success`, and nothing but `success` and `cancelled`, a stopped manual run. + * A run still going has no conclusion, and so refuses. + */ +export async function verifyReleaseAudit(commit, fetcher = fetch, token = process.env.GH_TOKEN) { + const { status } = await githubJson(`repos/${REPOSITORY}/compare/${commit}...main`, fetcher, token); + assert.ok(status === "ahead" || status === "identical", `pgstencil commit ${commit} is not on main (${status})`); + const conclusions = []; + for (let page = 1; ; page++) { + const { check_runs: runs } = await githubJson( + `repos/${REPOSITORY}/commits/${commit}/check-runs?check_name=security-audit&per_page=100&page=${page}`, + fetcher, + token, + ); + conclusions.push(...runs.map((run) => run.conclusion)); + if (runs.length < 100) break; + } + assert.ok(conclusions.includes("success"), `pgstencil commit ${commit} has no passing security-audit`); + const others = conclusions.filter((conclusion) => conclusion !== "success" && conclusion !== "cancelled"); + assert.deepEqual(others, [], `pgstencil commit ${commit} has a security-audit that did not pass`); +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const commit = await verifyPackages(); + verifyAttestations(JSON.parse(await readFile(process.argv[2], "utf8")), commit); + await verifyReleaseAudit(commit); + console.log(`pgstencil ${commit}: release identity, attestations, and security-audit verified.`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/hosted/scripts/pgstencil-provenance.test.mjs b/hosted/scripts/pgstencil-provenance.test.mjs new file mode 100644 index 000000000..edf08df69 --- /dev/null +++ b/hosted/scripts/pgstencil-provenance.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { verifyAttestations, verifyReleaseAudit } from "./pgstencil-provenance.mjs"; + +const COMMIT = "a".repeat(40); +/** Self-signed, shaped as Fulcio's: release identity and source digest, as each name says. */ +const CERTIFICATE = { + released: "MIIB/jCCAaSgAwIBAgIUDwKr0SmuxaVfCTvhWudBBGzCAYgwCgYIKoZIzj0EAwIwFzEVMBMGA1UECgwMdGVzdCBmaXh0dXJlMB4XDTI2MTEwMzAzNDExMVoXDTM2MTAzMTAzNDExMVowFzEVMBMGA1UECgwMdGVzdCBmaXh0dXJlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu21QrOqIG2w3mZ4RmGTMbe1ule9slKAA7r4tFLnGAamA9/jxTou6AEO1nODH5iAEaAklGDr51I9z5vd/9RQnrKOBzTCByjAdBgNVHQ4EFgQUkbvrOg694Anc7Gc7Wf+EvI59T44wDwYDVR0TAQH/BAUwAwEB/zBeBgNVHREEVzBVhlNodHRwczovL2dpdGh1Yi5jb20vZGlmZnBsdWcvcGdzdGVuY2lsLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAENBCoMKGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEwCgYIKoZIzj0EAwIDSAAwRQIgAxRD267myBff0DQMy3p0qHXuqJ4btzO8JdJ5g/c7JqgCIQDmXT2ej31/ABeGceThD0WpcumoY/t2kJshjzKiFaSf0A==", + otherBranch: "MIICADCCAaWgAwIBAgIUaq+kHPDuXXVuEPZaEDrwYAAHVz4wCgYIKoZIzj0EAwIwFzEVMBMGA1UECgwMdGVzdCBmaXh0dXJlMB4XDTI2MTEwMzAzNDExMVoXDTM2MTAzMTAzNDExMVowFzEVMBMGA1UECgwMdGVzdCBmaXh0dXJlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJBuoScjpynpu7sV1LKQeklpb3Dh5CZU3YogF7IJEV40M2hIF2MMH2b0QRgVzypnQug5aYY+ljdUdev/c/Hz38qOBzjCByzAdBgNVHQ4EFgQUdFLnYImviGAUBoqZF4LqOscaj5owDwYDVR0TAQH/BAUwAwEB/zBfBgNVHREEWDBWhlRodHRwczovL2dpdGh1Yi5jb20vZGlmZnBsdWcvcGdzdGVuY2lsLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvb3RoZXIwOAYKKwYBBAGDvzABDQQqDChhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhMAoGCCqGSM49BAMCA0kAMEYCIQCAg3Q2dVw4ppLG2Y3HvSd0V3cx+2I0UjIABcZ+z9z8gAIhANMTT5hH0O5n5lsKyobP9Plt+cFKPMsJhdL5XCNfA9uI", + otherCommit: "MIIB/jCCAaSgAwIBAgIUIUrKV+1MBzP7HD70kP11vHHCrdowCgYIKoZIzj0EAwIwFzEVMBMGA1UECgwMdGVzdCBmaXh0dXJlMB4XDTI2MTEwMzAzNDExMVoXDTM2MTAzMTAzNDExMVowFzEVMBMGA1UECgwMdGVzdCBmaXh0dXJlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4zdGAWI6aDZjZqTyE1D0PxmrV/LapDPz4pShhkTzPOZHwOmVWiit5ELDEFvFspo1GN4W0BUioeQcoptKFn3dL6OBzTCByjAdBgNVHQ4EFgQUMD24BTZ3vawFsblU0HO1iJkZZvcwDwYDVR0TAQH/BAUwAwEB/zBeBgNVHREEVzBVhlNodHRwczovL2dpdGh1Yi5jb20vZGlmZnBsdWcvcGdzdGVuY2lsLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAENBCoMKGJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIwCgYIKoZIzj0EAwIDSAAwRQIgeeTVkBzkds5App5J4iMD4cxCIqGBPLGV9FakCFCDE7oCIQD0bsNRqjLcD6Z+c8c7UfbAlbn483zbn+4ASUR7rqKAcA==", + noDigest: "MIIBxDCCAWqgAwIBAgIUW4leO3jO0MP6rAPbkiz9rtVFJLAwCgYIKoZIzj0EAwIwFzEVMBMGA1UECgwMdGVzdCBmaXh0dXJlMB4XDTI2MTEwMzAzNDExMVoXDTM2MTAzMTAzNDExMVowFzEVMBMGA1UECgwMdGVzdCBmaXh0dXJlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYg+TCcEEzq+CTekJAa8hTa6z1eft1XcINRzGwDTh6iGj5H5qYuiLF8TigTGnlTOUDJnepQVRjnd6Zf2wTz9SmaOBkzCBkDAdBgNVHQ4EFgQUwGvInS/NbO+Xaees6YgZVKREoo8wDwYDVR0TAQH/BAUwAwEB/zBeBgNVHREEVzBVhlNodHRwczovL2dpdGh1Yi5jb20vZGlmZnBsdWcvcGdzdGVuY2lsLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjAKBggqhkjOPQQDAgNIADBFAiBJDpaJrpSL5U9J59ER94AxntV5+BGBNI74LUn4f7QWPQIhAPtmnx4qHPDzaOMOtMMZCfxDXtwSYiu7Z9zhIPgErBHn", +}; + +const payload = (name, change = {}) => + Buffer.from( + JSON.stringify({ + subject: [{ name: change.subject ?? `pkg:npm/${name.replace("@", "%40")}@1.2.3`, digest: { sha512: "x" } }], + predicate: { + buildDefinition: { + externalParameters: { + workflow: { + ref: change.ref ?? "refs/heads/main", + repository: "https://github.com/diffplug/pgstencil", + path: ".github/workflows/release.yml", + }, + }, + resolvedDependencies: [{ uri: "git+https://example.test", digest: { gitCommit: change.commit ?? COMMIT } }], + }, + }, + }), + ).toString("base64"); + +const provenance = (name, change = {}) => ({ + predicateType: "https://slsa.dev/provenance/v1", + bundle: { + verificationMaterial: { certificate: { rawBytes: change.certificate ?? CERTIFICATE.released } }, + dsseEnvelope: { payload: payload(name, change) }, + }, +}); + +/** npm's report for all three packages, with `change` applied to `@pgstencil/auth`'s provenance. */ +const report = (change) => ({ + verified: ["pgstencil", "@pgstencil/auth", "@pgstencil/stripe"].map((name) => ({ + name, + version: "1.2.3", + attestationBundles: [ + { predicateType: "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", bundle: {} }, + provenance(name, name === "@pgstencil/auth" ? change : {}), + ], + })), +}); + +test("verifyAttestations admits a release its own workflow signed from the commit the packages name", () => { + verifyAttestations(report(), COMMIT); + // The other shape a sigstore bundle carries its certificate in. + const chained = report(); + for (const { attestationBundles } of chained.verified) { + const { bundle } = attestationBundles[1]; + bundle.verificationMaterial = { + x509CertificateChain: { certificates: [{ rawBytes: bundle.verificationMaterial.certificate.rawBytes }] }, + }; + } + verifyAttestations(chained, COMMIT); +}); + +test("verifyAttestations refuses each thing a release could disagree on", () => { + const refused = (change, message) => assert.throws(() => verifyAttestations(report(change), COMMIT), { message }); + refused({ certificate: CERTIFICATE.otherBranch }, /signed by another identity/); + refused({ certificate: CERTIFICATE.otherCommit }, /certificate names another commit/); + refused({ certificate: CERTIFICATE.noDigest }, /certificate names another commit/); + refused({ subject: "pkg:npm/%40pgstencil/auth@9.9.9" }, /provenance names another package/); + refused({ ref: "refs/heads/other" }, /provenance names another workflow/); + refused({ commit: "b".repeat(40) }, /provenance names another commit/); + // The installed packages name another commit than a release that is otherwise fine. + assert.throws(() => verifyAttestations(report(), "b".repeat(40)), /certificate names another commit/); +}); + +test("verifyAttestations refuses a package with no verified provenance", () => { + const without = (edit) => { + const edited = report(); + edit(edited.verified.find(({ name }) => name === "@pgstencil/stripe")); + return edited; + }; + assert.throws(() => verifyAttestations({}, COMMIT), /pgstencil has no verified attestation/); + for (const refusal of ["invalid", "missing"]) { + assert.throws( + () => verifyAttestations({ ...report(), [refusal]: [{ name: "@pgstencil/auth" }] }, COMMIT), + new RegExp(`npm reports @pgstencil/auth ${refusal}`), + ); + } + // Another package's trouble in Hosted's tree does not gate here. + verifyAttestations({ ...report(), invalid: [{ name: "left-pad" }], missing: [{ name: "left-pad" }] }, COMMIT); + assert.throws( + () => verifyAttestations({ verified: report().verified.slice(0, 2) }, COMMIT), + /@pgstencil\/stripe has no verified attestation/, + ); + assert.throws( + () => verifyAttestations(without((entry) => entry.attestationBundles.pop()), COMMIT), + /no verified provenance attestation/, + ); + assert.throws( + () => verifyAttestations(without((entry) => delete entry.attestationBundles), COMMIT), + /no verified provenance attestation/, + ); +}); + +/** GitHub, answering for `COMMIT` as `answers` says. */ +const github = ({ status = "ahead", conclusions = ["success"], code = 200 } = {}) => { + const asked = []; + const fetcher = async (url, init) => { + asked.push({ url, authorization: init.headers.authorization }); + const page = Number(new URL(url).searchParams.get("page")); + const body = url.includes("/compare/") + ? { status } + : { check_runs: conclusions.slice((page - 1) * 100, page * 100).map((conclusion) => ({ conclusion })) }; + return new Response(JSON.stringify(body), { status: code }); + }; + return { fetcher, asked }; +}; + +test("verifyReleaseAudit admits a commit on main whose audit passed", async () => { + for (const answers of [ + {}, + { status: "identical" }, + { conclusions: ["cancelled", "success"] }, + { conclusions: [...Array(100).fill("cancelled"), "success"] }, + ]) { + await verifyReleaseAudit(COMMIT, github(answers).fetcher, "token"); + } + const { fetcher, asked } = github(); + await verifyReleaseAudit(COMMIT, fetcher, "token"); + assert.deepEqual(asked, [ + { url: `https://api.github.com/repos/diffplug/pgstencil/compare/${COMMIT}...main`, authorization: "Bearer token" }, + { + url: `https://api.github.com/repos/diffplug/pgstencil/commits/${COMMIT}/check-runs?check_name=security-audit&per_page=100&page=1`, + authorization: "Bearer token", + }, + ]); +}); + +test("verifyReleaseAudit refuses a commit off main, unaudited, not passed, or unanswered", async () => { + const refused = (answers, message) => + assert.rejects(verifyReleaseAudit(COMMIT, github(answers).fetcher, "token"), { message }); + await refused({ status: "behind" }, /is not on main/); + await refused({ status: "diverged" }, /is not on main/); + await refused({ conclusions: [] }, /no passing security-audit/); + await refused({ conclusions: ["cancelled"] }, /no passing security-audit/); + await refused({ conclusions: ["success", "failure"] }, /did not pass/); + await refused({ conclusions: ["success", null] }, /did not pass/); + await refused({ conclusions: [...Array(100).fill("success"), "failure"] }, /did not pass/); + await refused({ code: 403 }, /answered .* with 403/); +}); diff --git a/hosted/scripts/production.mjs b/hosted/scripts/production.mjs index efe32a8cc..8e927d369 100644 --- a/hosted/scripts/production.mjs +++ b/hosted/scripts/production.mjs @@ -196,15 +196,19 @@ export async function productionSmoke(configs, sha, options = {}) { ...options, }); } -/** Each pgstencil package Hosted installs, by an entrypoint beside its `dist/provenance.json`. */ -const PGSTENCIL_PACKAGES = ["pgstencil", "@pgstencil/auth/better-auth", "@pgstencil/stripe"]; +/** Each pgstencil package Hosted installs: its npm name, and an entrypoint beside its `dist/provenance.json`. */ +export const PGSTENCIL_PACKAGES = [ + { name: "pgstencil", entrypoint: "pgstencil" }, + { name: "@pgstencil/auth", entrypoint: "@pgstencil/auth/better-auth" }, + { name: "@pgstencil/stripe", entrypoint: "@pgstencil/stripe" }, +]; /** The installed package's `dist/provenance.json`, read beside its resolved entrypoint. */ const installedProvenance = (name) => readFile(new URL("./provenance.json", import.meta.resolve(name)), "utf8"); export async function verifyPackages(readProvenance = installedProvenance) { const commits = []; - for (const name of PGSTENCIL_PACKAGES) { - const provenance = JSON.parse(await readProvenance(name)); + for (const { entrypoint } of PGSTENCIL_PACKAGES) { + const provenance = JSON.parse(await readProvenance(entrypoint)); assert.match( provenance.commit, /^[a-f0-9]{40}$/, @@ -221,6 +225,7 @@ export async function verifyPackages(readProvenance = installedProvenance) { commits.every((commit) => commit === commits[0]), "Production requires accepted, clean pgstencil provenance", ); + return commits[0]; } /** * Before the backup, so a release whose Cloudflare or Postgres side is not diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs index 09aa91564..2889f1be6 100644 --- a/hosted/scripts/production.test.mjs +++ b/hosted/scripts/production.test.mjs @@ -556,7 +556,7 @@ test("verifyPackages admits only clean pgstencil packages built from one commit" const reader = ({ all = {}, ...one } = {}) => async (name) => JSON.stringify({ commit, dirty: false, ...all, ...one[name] }); const refused = failure("Production requires accepted, clean pgstencil provenance"); - await verifyPackages(reader()); + assert.equal(await verifyPackages(reader()), commit); await assert.rejects(verifyPackages(reader({ all: { commit: "f".repeat(39) } })), refused); await assert.rejects(verifyPackages(reader({ all: { commit: "F".repeat(40) } })), refused); await assert.rejects(verifyPackages(reader({ all: { commit: undefined } })), refused); diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index f1a236199..14d704d5e 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -30,6 +30,8 @@ import { checkRegistration, enrollConfirmDigits, enrollUserCode, + isSignedEnrollDeviceCode, + signEnrollDeviceCode, fromBase64Url, isPresenceBinding, isRelayBearer, @@ -95,8 +97,8 @@ export const MAX_SESSIONS_PER_ACCOUNT = 32; export const MAX_SETUP_CHALLENGES_PER_BURROW = MAX_TOKENS_PER_BURROW; /** - * A device code's leading bytes: its expiry, big-endian epoch seconds; the - * rest of its 32 are random. + * A device code's leading bytes: its expiry, big-endian epoch seconds. The rest + * of its 32 are random, then the tag `signEnrollDeviceCode` writes. */ const DEVICE_CODE_EXPIRY_BYTES = 4; @@ -502,8 +504,8 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { // --- Enrollment: device code, approved on the account origin -------------- - // Begin writes nothing: the device code carries its own expiry, the user - // code is derived from it and the digits from that, so an unauthenticated + // Begin writes nothing: the device code carries its own expiry and tag, the + // user code is derived from it and the digits from that, so an unauthenticated // caller grows no table. app.post(API_ROUTES.burrowEnrollBegin, async (c) => { const body = await readJson<{ origin?: unknown; purpose?: unknown }>(c); @@ -520,7 +522,9 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { const expiresAtS = Math.floor((Date.now() + ENROLLMENT_TTL_MS) / 1000); const bytes = crypto.getRandomValues(new Uint8Array(RELAY_BEARER_BYTE_LENGTH)); new DataView(bytes.buffer).setUint32(0, expiresAtS); - const userCode = await userCodeOf(c.env, bytes, purpose); + const key = enrollKey(c.env, purpose); + await signEnrollDeviceCode(key, bytes); + const userCode = await enrollUserCode(key, bytes); const res: BurrowEnrollBeginResponse = { purpose, deviceCode: toBase64Url(bytes), @@ -544,10 +548,13 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { const voiceAnswer = (res: BurrowVoicePollResponse) => c.json(res); if (!isRelayBearer(deviceCode)) return answer({ status: "expired" }); const bytes = fromBase64Url(deviceCode); - // Expired by its own clock, without the database. + // Expired by its own clock, or never issued for this purpose: neither reaches + // the database, so a poll looks up only a user code begin handed out. const expiresAtS = new DataView(bytes.buffer, bytes.byteOffset, DEVICE_CODE_EXPIRY_BYTES).getUint32(0); if (expiresAtS * 1000 <= Date.now()) return answer({ status: "expired" }); - const userCode = await userCodeOf(c.env, bytes, purpose); + const key = enrollKey(c.env, purpose); + if (!(await isSignedEnrollDeviceCode(key, bytes))) return answer({ status: "expired" }); + const userCode = await enrollUserCode(key, bytes); const confirmDigits = await enrollConfirmDigits(enrollSecret(c.env), userCode); return database(c, async (db) => { // The read stamps a live, unredeemed approval typed with other digits, @@ -819,9 +826,9 @@ function enrollSecret(env: RelayEnv): string { return env.RELAY_ENROLL_SECRET; } -/** A device code's user code, under its purpose's key. */ -function userCodeOf(env: RelayEnv, deviceCode: Uint8Array, purpose: "remote" | "voice") { +/** The key a device code's tag and user code are under: one per purpose. */ +function enrollKey(env: RelayEnv, purpose: "remote" | "voice") { const secret = enrollSecret(env); - return enrollUserCode(purpose === "voice" ? `${secret}:voice` : secret, deviceCode); + return purpose === "voice" ? `${secret}:voice` : secret; } const invalidSetup = (c: Context) => c.json({ error: SETUP_TOKEN_INVALID_ERROR }, 401); diff --git a/hosted/server/tests/pocket.test.ts b/hosted/server/tests/pocket.test.ts index f6c2d6575..f23565db4 100644 --- a/hosted/server/tests/pocket.test.ts +++ b/hosted/server/tests/pocket.test.ts @@ -10,6 +10,7 @@ import { isRelayBearer, HOSTED_FRAME_DOMAIN, pocketContentSecurityPolicy, + signEnrollDeviceCode, toBase64Url, } from "remote-lib-common"; import { RUNS_NOTHING_POLICY, oneTimePagePolicy } from "../headers"; @@ -198,9 +199,14 @@ test("only a Node Burrow begins or polls an enrollment, per-address limited, and expect(isRelayBearer(deviceCode) && isEnrollUserCode(userCode)).toBe(true); const expired = fromBase64Url(deviceCode); expired.set([0, 0, 0, 1]); - expect( - await (await post(API_ROUTES.burrowEnrollPoll, { deviceCode: toBase64Url(expired) }, { "cf-connecting-ip": "192.0.2.4" })).json(), - ).toEqual({ status: "expired" }); + await signEnrollDeviceCode(TEST_ENROLL_SECRET, expired); + const untagged = fromBase64Url(deviceCode); + untagged[31] ^= 1; + for (const refused of [expired, untagged]) { + expect( + await (await post(API_ROUTES.burrowEnrollPoll, { deviceCode: toBase64Url(refused) }, { "cf-connecting-ip": "192.0.2.4" })).json(), + ).toEqual({ status: "expired" }); + } // Neither answer below reads the database either. A mismatched origin spends // the address's budget, and a matching one is refused once it is spent. const beginLimit = limitOf("RELAY_ENROLL_BEGIN_LIMIT"); diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index 1168ffae2..6fca4421f 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -18,6 +18,8 @@ import { UNMINTED_DELIVERY_ID_ERROR, enrollConfirmDigits, enrollUserCode, + signEnrollDeviceCode, + RELAY_BEARER_BYTE_LENGTH, fromBase64Url, generateNoiseKeyPair, mintPushDeliveryId, @@ -1027,13 +1029,20 @@ test("every unauthenticated route that reads or writes a row is rate limited per } }); -/** `deviceCode` with its expiry set to `expiresAtS`, the rest kept. */ +/** `deviceCode` with its expiry set to `expiresAtS`, tag left as it was. */ function withExpiry(deviceCode: string, expiresAtS: number) { const bytes = fromBase64Url(deviceCode); new DataView(bytes.buffer, bytes.byteOffset).setUint32(0, expiresAtS); return toBase64Url(bytes); } +/** A device code Hosted issued, past its expiry: its tag is good, its clock is not. */ +async function issuedAndExpired(deviceCode: string) { + const bytes = fromBase64Url(withExpiry(deviceCode, Math.floor(Date.now() / 1000) - 1)); + await signEnrollDeviceCode(TEST_ENROLL_SECRET, bytes); + return toBase64Url(bytes); +} + test("a device-code enrollment: begin stores nothing, pending until approved, then one Burrow owned by its approver, once", async ({ onTestFinished, }) => { @@ -1057,7 +1066,7 @@ test("a device-code enrollment: begin stores nothing, pending until approved, th expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_enrollment_approvals`)).toEqual([{ n: 0 }]); expect(await f.poll(begun.deviceCode)).toMatchObject({ status: 200, json: { status: "pending" } }); - for (const unknown of ["short", undefined, withExpiry(begun.deviceCode, Math.floor(Date.now() / 1000) - 1)]) + for (const unknown of ["short", undefined, await issuedAndExpired(begun.deviceCode)]) expect(await f.poll(unknown)).toMatchObject({ status: 200, json: { status: "expired" } }); await f.approve(begun.userCode, owner); @@ -1164,15 +1173,24 @@ test("an approval redeems only the device code its user code is derived from", a await f.approve(a.userCode, owner); // Code A's approval never redeems device code B. expect((await f.poll(b.deviceCode)).json).toEqual({ status: "pending" }); - // A forged code of the minted shape: A with a later expiry, A with a flipped - // random byte, and a code whose user code is A's under another secret. - const flipped = fromBase64Url(a.deviceCode); - flipped[31] ^= 1; - const forged = [withExpiry(a.deviceCode, Math.floor(a.expiresAt / 1000) + 60), toBase64Url(flipped)]; + // A code of the minted shape Hosted did not issue is expired, by its tag, even + // with an approval waiting on the user code it derives: A with a later expiry, + // A with a flipped random or tag byte, and a code from nowhere. + const flip = (index: number) => { + const bytes = fromBase64Url(a.deviceCode); + bytes[index] ^= 1; + return toBase64Url(bytes); + }; + const nowhere = crypto.getRandomValues(new Uint8Array(RELAY_BEARER_BYTE_LENGTH)); + new DataView(nowhere.buffer).setUint32(0, Math.floor(a.expiresAt / 1000)); + const forged = [withExpiry(a.deviceCode, Math.floor(a.expiresAt / 1000) + 60), flip(10), flip(31), toBase64Url(nowhere)]; for (const deviceCode of forged) { expect(isRelayBearer(deviceCode)).toBe(true); - expect((await f.poll(deviceCode)).json, deviceCode).toEqual({ status: "pending" }); + await f.approve(await enrollUserCode(TEST_ENROLL_SECRET, fromBase64Url(deviceCode)), owner); + expect((await f.poll(deviceCode)).json, deviceCode).toEqual({ status: "expired" }); } + // Issued for one purpose, it is no code for the other. + expect((await f.poll(b.deviceCode, "voice")).json).toEqual({ status: "expired" }); const other = await f.begin(); const underWrongSecret = await enrollUserCode(`${TEST_ENROLL_SECRET}-wrong`, fromBase64Url(other.deviceCode)); await f.approve(underWrongSecret, owner); @@ -2062,7 +2080,7 @@ test("voice approval is independent of remote enrollment and cannot be redeemed expect((await f.poll(voice.deviceCode, "voice")).json).toEqual({ status: "pending" }); await f.sql(`DELETE FROM dormouse_relay_enrollment_approvals`); await f.approve(voice.userCode, owner, "voice"); - expect((await f.poll(voice.deviceCode)).json).toEqual({ status: "pending" }); + expect((await f.poll(voice.deviceCode)).json).toEqual({ status: "expired" }); const outcomes = await Promise.all([f.poll(voice.deviceCode, "voice"), f.poll(voice.deviceCode, "voice")]); expect(outcomes.map((r) => r.json!.status).sort()).toEqual(["enabled", "redeemed"]); const token = outcomes.find((r) => r.json!.status === "enabled")!.json!.voiceToken as string; diff --git a/lib/src/remote/frame-origin/bootstrap.test.ts b/lib/src/remote/frame-origin/bootstrap.test.ts index d74f16d11..7602efd5f 100644 --- a/lib/src/remote/frame-origin/bootstrap.test.ts +++ b/lib/src/remote/frame-origin/bootstrap.test.ts @@ -3,8 +3,44 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; type Listener = (event: unknown) => void; -async function load() { +/** One cookie as a browser files it: a write matching all three of its key fields replaces it. */ +interface Stored { + name: string; + value: string; + domain: string; + partitioned: boolean; +} + +/** + * `document.cookie` as RFC 6265bis parses a write: no `=` is a nameless cookie + * holding the whole pair, and an empty name with an empty value is dropped. + */ +function cookieJar(seed: Stored[]) { + const jar = [...seed]; + return { + jar, + get cookie() { + return jar.map(({ name, value }) => (name ? `${name}=${value}` : value)).join('; '); + }, + set cookie(line: string) { + const [pair, ...attributes] = line.split('; '); + const at = pair.indexOf('='); + const [name, value] = at < 0 ? ['', pair] : [pair.slice(0, at), pair.slice(at + 1)]; + if (!name && !value) return; + const domain = attributes.find((one) => one.startsWith('Domain='))?.slice('Domain='.length) ?? ''; + const partitioned = attributes.includes('Partitioned'); + const held = jar.findIndex((one) => one.name === name && one.domain === domain && one.partitioned === partitioned); + if (held >= 0) jar.splice(held, 1); + if (!attributes.includes('Max-Age=0')) jar.push({ name, value, domain, partitioned }); + }, + }; +} + +async function load(cookies: Stored[] = []) { vi.resetModules(); + const page = cookieJar(cookies); + vi.stubGlobal('location', { hostname: 'aaaa1111.frames.example' }); + vi.stubGlobal('document', page); const parentWindow = {}; const listeners: { window?: Listener; worker?: Listener } = {}; vi.stubGlobal('window', { @@ -20,7 +56,7 @@ async function load() { }, }); await import('./bootstrap'); - return { parentWindow, connect: listeners.window!, fromWorker: listeners.worker! }; + return { parentWindow, connect: listeners.window!, fromWorker: listeners.worker!, jar: page.jar }; } const port = () => ({ postMessage: vi.fn(), close: vi.fn() }); @@ -29,6 +65,20 @@ const CONNECT = { kind: 'dormouse-frame-connect' }; afterEach(() => vi.unstubAllGlobals()); describe('the bootstrap page', () => { + it('expires what it can see on the parent domain alone, nameless and partitioned cookies included, before the frame page loads', async () => { + const parent = { domain: 'frames.example', partitioned: false }; + const own = { name: 'left', value: 'mine', domain: '', partitioned: false }; + const { jar } = await load([ + { ...parent, name: 'left', value: 'by-a-sibling' }, + { ...parent, name: 'a', value: 'b=c' }, + { ...parent, name: '', value: 'nameless' }, + { ...parent, name: 'kept', value: '1', partitioned: true }, + { ...parent, name: '', value: 'x=y', partitioned: true }, + own, + ]); + expect(jar).toEqual([own]); + }); + it('takes its port from the parent alone, and only the first', async () => { const { parentWindow, connect, fromWorker } = await load(); const foreign = port(); diff --git a/lib/src/remote/frame-origin/bootstrap.ts b/lib/src/remote/frame-origin/bootstrap.ts index d2d5f8d76..2fc495bee 100644 --- a/lib/src/remote/frame-origin/bootstrap.ts +++ b/lib/src/remote/frame-origin/bootstrap.ts @@ -9,6 +9,30 @@ import { FRAME_WORKER_PATH } from 'remote-lib-common'; import type { BootstrapConnect, BootstrapReady, RelayMessage } from './protocol'; +/** + * Every frame origin sits under one domain, so a page can leave cookies there for + * its siblings' pages to read and its siblings' requests to carry. This page + * loads before the frame's own, so that page starts without them, and a cookie + * of the same name on this origin alone stays, since naming a `Domain` touches + * only that domain's. A cookie on a deeper path stays too: this page cannot see it. + * Partitioned cookies are a jar of their own, so each name goes from both. Once + * browsers carry the domain's Public Suffix List entry, none of this has work to do. + */ +function dropParentDomainCookies(): void { + const parent = location.hostname.slice(location.hostname.indexOf('.') + 1); + // `name=` for a named cookie. A nameless one is any value with no `=`, since an + // empty name and value is no cookie at all, and is always tried: its value may + // hold an `=` of its own and read here as a name. + const named = document.cookie.split('; ').filter((cookie) => cookie.includes('=')).map((cookie) => `${cookie.slice(0, cookie.indexOf('='))}=`); + for (const pair of new Set(['-', ...named])) { + for (const jar of ['', '; Partitioned']) { + document.cookie = `${pair}; Domain=${parent}; Path=/; Max-Age=0; Secure; SameSite=None${jar}`; + } + } +} + +dropParentDomainCookies(); + let connected: MessagePort | null = null; const ready = navigator.serviceWorker.register(FRAME_WORKER_PATH).then(() => navigator.serviceWorker.ready); diff --git a/relay/src/start.ts b/relay/src/start.ts index 24a6b7474..bb9457020 100644 --- a/relay/src/start.ts +++ b/relay/src/start.ts @@ -126,6 +126,17 @@ export async function startRelay(config: RelayConfig, existingServer?: Server) { server = existingServer; announce(server); } else { + // An `https:` origin with no bind host is either a container, whose namespace + // bounds the port, or a local TLS proxy beside a plaintext port on every + // interface. Only the operator can tell which, so the default stays and this + // says so. + if (!bindHost && new URL(origin).protocol === 'https:') { + console.warn( + `DORMOUSE_BIND_HOST is unset, so plain HTTP on port ${port} listens on every interface beside the ` + + `HTTPS origin ${origin}. Set DORMOUSE_BIND_HOST=127.0.0.1 if the TLS proxy is on this machine; ` + + 'in a container, whose namespace bounds the port, nothing needs changing.', + ); + } server = serve( { fetch: app.fetch, port, ...(bindHost ? { hostname: bindHost } : {}) }, () => announce(server), diff --git a/relay/test/bind-host.test.mjs b/relay/test/bind-host.test.mjs index 11cee24ce..f29f63367 100644 --- a/relay/test/bind-host.test.mjs +++ b/relay/test/bind-host.test.mjs @@ -9,7 +9,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { networkInterfaces } from 'node:os'; -import { startRelay } from './spawn-relay.mjs'; +import { startRelay, stopRelay } from './spawn-relay.mjs'; /** A non-loopback IPv4 of this machine, or undefined on an isolated runner. */ function externalIpv4() { @@ -65,3 +65,27 @@ test('without DORMOUSE_BIND_HOST the Relay still listens on every interface', as assert.equal(await reachable('127.0.0.1', port), true); assert.equal(await reachable(external, port), true, 'the container default must be preserved'); }); + +/** What the Relay wrote to stderr from its start until it stopped. */ +async function startupWarnings(env) { + const { child } = await startRelay(env); + let stderr = ''; + child.stderr.on('data', (chunk) => (stderr += chunk)); + const closed = new Promise((resolve) => child.on('close', resolve)); + await stopRelay(child); + await closed; + return stderr; +} + +test('an https origin warns of a plaintext port on every interface only while the bind host is unset', async () => { + const origin = 'https://relay.example.test'; + assert.match( + await startupWarnings({ DORMOUSE_BIND_HOST: '', DORMOUSE_ORIGIN: origin }), + /DORMOUSE_BIND_HOST is unset/, + ); + assert.doesNotMatch( + await startupWarnings({ DORMOUSE_BIND_HOST: '127.0.0.1', DORMOUSE_ORIGIN: origin }), + /DORMOUSE_BIND_HOST/, + ); + assert.doesNotMatch(await startupWarnings({ DORMOUSE_BIND_HOST: '' }), /DORMOUSE_BIND_HOST/); +}); diff --git a/remote-lib-common/src/remote/enroll-code.ts b/remote-lib-common/src/remote/enroll-code.ts index 33918c879..f366551d1 100644 --- a/remote-lib-common/src/remote/enroll-code.ts +++ b/remote-lib-common/src/remote/enroll-code.ts @@ -1,13 +1,16 @@ /** * The codes of Hosted's device-code approval (`docs/specs/hosted.md` -> "Burrow - * enrollment"): the user code, which the link carries to the account page and - * keys the approval, derived from the device code; and the two confirmation - * digits Dormouse shows and the user types there, derived from the user code. + * enrollment"): the tag that shows Hosted issued a device code; the user code, + * which the link carries to the account page and keys the approval, derived + * from the device code; and the two confirmation digits Dormouse shows and the + * user types there, derived from the user code. * - * Imports nothing, so the account frontend takes it without the rest of this - * package. + * Imports only `bytes.ts`, so the account frontend takes it without the rest of + * this package. */ +import { concatBytes, constantTimeEqual, utf8Encode } from '../security/bytes.js'; + /** * Crockford base32 without `0`, `1` (nor the `O`, `I`, `L`, `U` Crockford * already drops): thirty characters no one reads as another. @@ -54,6 +57,37 @@ interface HmacSubtle { sign(algorithm: 'HMAC', key: unknown, data: Uint8Array): Promise; } +const defaultSubtle = () => (globalThis as unknown as { crypto: { subtle: HmacSubtle } }).crypto.subtle; + +/** A device code's trailing bytes, the {@link enrollDeviceCodeTag} of the rest. */ +const DEVICE_CODE_TAG_BYTES = 12; +/** A different length than any device code, so a tag's MAC is never a user code's. */ +const DEVICE_CODE_TAG_LABEL = 'enroll-device-code:'; + +/** The tag of `deviceCode`, over all but its trailing {@link DEVICE_CODE_TAG_BYTES}. */ +async function enrollDeviceCodeTag(secret: string, deviceCode: Uint8Array): Promise { + const signed = deviceCode.subarray(0, deviceCode.length - DEVICE_CODE_TAG_BYTES); + const mac = await hmac(secret, concatBytes(utf8Encode(DEVICE_CODE_TAG_LABEL), signed), defaultSubtle()); + return mac.subarray(0, DEVICE_CODE_TAG_BYTES); +} + +/** Writes `deviceCode`'s tag over its trailing bytes, its issue being what the tag shows. */ +export async function signEnrollDeviceCode( + secret: string, + deviceCode: Uint8Array, +): Promise { + deviceCode.set(await enrollDeviceCodeTag(secret, deviceCode), deviceCode.length - DEVICE_CODE_TAG_BYTES); +} + +/** Whether `deviceCode` carries the tag only `secret`'s holder can write. */ +export async function isSignedEnrollDeviceCode( + secret: string, + deviceCode: Uint8Array, +): Promise { + const carried = deviceCode.subarray(deviceCode.length - DEVICE_CODE_TAG_BYTES); + return constantTimeEqual(await enrollDeviceCodeTag(secret, deviceCode), carried); +} + /** * The user code of `deviceCode`: `HMAC-SHA-256(secret, deviceCode)` read five * bits at a time, most significant first, into @@ -64,7 +98,7 @@ interface HmacSubtle { export async function enrollUserCode( secret: string, deviceCode: Uint8Array, - subtle: HmacSubtle = (globalThis as unknown as { crypto: { subtle: HmacSubtle } }).crypto.subtle, + subtle: HmacSubtle = defaultSubtle(), ): Promise { const mac = await hmac(secret, deviceCode, subtle); let code = ''; @@ -89,7 +123,7 @@ export async function enrollUserCode( export async function enrollConfirmDigits( secret: string, userCode: string, - subtle: HmacSubtle = (globalThis as unknown as { crypto: { subtle: HmacSubtle } }).crypto.subtle, + subtle: HmacSubtle = defaultSubtle(), ): Promise { const mac = await hmac(secret, new TextEncoder().encode(`confirm:${userCode}`), subtle); for (let byte = 0; byte + 1 < mac.length; byte += 2) { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 0e24c4073..ede432f2c 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,12 +12,12 @@ "docs/specs/dor-tools-builtin.md": 1800, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 6650, + "docs/specs/hosted.md": 6700, "docs/specs/layout.md": 7400, "docs/specs/mobile-terminal-ui.md": 1800, "docs/specs/mouse-and-clipboard.md": 4450, "docs/specs/one-time.md": 2700, - "docs/specs/pocket-app.md": 4350, + "docs/specs/pocket-app.md": 4400, "docs/specs/pricing.md": 2750, "docs/specs/relay.md": 5850, "docs/specs/remote-api.md": 5650, @@ -26,11 +26,11 @@ "docs/specs/reopen.md": 1200, "docs/specs/security-audit.md": 5400, "docs/specs/security-ci.md": 3400, - "docs/specs/security-hosted.md": 4600, + "docs/specs/security-hosted.md": 4650, "docs/specs/security-local.md": 7200, - "docs/specs/security-remote.md": 8200, + "docs/specs/security-remote.md": 8250, "docs/specs/security-supply-chain.md": 1650, - "docs/specs/security.md": 3150, + "docs/specs/security.md": 3350, "docs/specs/shortcuts.md": 1200, "docs/specs/standalone.md": 6750, "docs/specs/terminal-context.md": 1100,