Skip to content

Commit bbc021f

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/ci-39457380049
2 parents 0ee2d13 + 90d84e3 commit bbc021f

41 files changed

Lines changed: 883 additions & 241 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
Domain: hosted
2+
Rule: S100 — or a removed passkey's sessions survive its removal
3+
Expect: hosted/server/relay-api.ts
4+
Source: synthetic; a session no longer names the passkey that signed it in, so removing that passkey on the account page leaves its sessions live
5+
6+
diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts
7+
index b227f87c4..918689c6e 100644
8+
--- a/hosted/server/relay-api.ts
9+
+++ b/hosted/server/relay-api.ts
10+
@@ -754,17 +754,14 @@ export async function admit(
11+
});
12+
}
13+
14+
-/**
15+
- * A fresh session for `userId`, within its account's cap, signed in by
16+
- * `credentialId`: removing that passkey ends it.
17+
- */
18+
-async function mintSession(db: Client, userId: string, credentialId: string) {
19+
+/** A fresh session for `userId`, within its account's cap. */
20+
+async function mintSession(db: Client, userId: string, _credentialId: string) {
21+
const sessionToken = randomBase64Url(RELAY_BEARER_BYTE_LENGTH);
22+
const expiresAt = await admit(
23+
db,
24+
SESSIONS,
25+
userId,
26+
- { tokenHash: digest(sessionToken), userId, credentialId },
27+
+ { tokenHash: digest(sessionToken), userId },
28+
RELAY_SESSION_TTL_MS,
29+
);
30+
return { sessionToken, expiresAt: expiresAt! };
31+
diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts
32+
index f26bd4613..6f64d96af 100644
33+
--- a/hosted/server/tests/relay.test.ts
34+
+++ b/hosted/server/tests/relay.test.ts
35+
@@ -454,32 +454,20 @@ test("a passkey names the computer whose code registered it and outlives it; rem
36+
expect(await minter(phone)).toEqual([{ burrowId: null }]);
37+
expect((await f.call("GET", API_ROUTES.burrows, { bearer: setupSession })).status).toBe(200);
38+
39+
- // Every session a passkey signed in, setup's included, names it.
40+
const signedIn = (await f.session(phone)).sessionToken;
41+
const other = (await f.session(tablet)).sessionToken;
42+
- expect(
43+
- await f.sql(`SELECT "credentialId" FROM dormouse_relay_sessions WHERE "tokenHash" = ANY($1) ORDER BY 1`, [
44+
- [digest(setupSession), digest(signedIn)],
45+
- ]),
46+
- ).toEqual([{ credentialId: phone.credentialId }, { credentialId: phone.credentialId }]);
47+
const pending = await f.call("POST", API_ROUTES.reauthBegin, {
48+
bearer: signedIn,
49+
body: { binding: f.pairing(desktop.burrowId, phone) },
50+
});
51+
expect(pending.status).toBe(200);
52+
53+
- // Removing the passkey, as the account page does, ends its sessions and
54+
- // their nonces, and it signs in to nothing again; the other passkey's stand.
55+
+ // Removing the passkey, as the account page does, and it signs in to
56+
+ // nothing again; the other passkey's sessions stand.
57+
await f.sql(`DELETE FROM dormouse_relay_passkeys WHERE "credentialId" = $1 AND "userId" = $2`, [
58+
phone.credentialId,
59+
owner,
60+
]);
61+
- for (const bearer of [setupSession, signedIn])
62+
- expect(await f.call("GET", API_ROUTES.burrows, { bearer })).toMatchObject({
63+
- status: 401,
64+
- json: { error: UNAUTHORIZED_ERROR },
65+
- });
66+
- expect(await f.sql(`SELECT nonce FROM dormouse_relay_presence_nonces`)).toEqual([]);
67+
expect((await f.signin(phone)).status).toBe(404);
68+
expect((await f.call("GET", API_ROUTES.burrows, { bearer: other })).status).toBe(200);
69+
});

‎.github/audit/canaries/pocket-presence-challenge-unchecked.patch‎

Lines changed: 20 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,18 @@ Expect: lib/src/remote/client/pocket-client.ts
44
Source: reverses part of #1223 (Pocket signed the challenge the Relay's `begin` answered instead of deriving its own)
55

66
diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts
7-
index bb9c28ef3..d560251d5 100644
7+
index b7a91c6..25d98e3 100644
88
--- a/lib/src/remote/client/pocket-client.test.ts
99
+++ b/lib/src/remote/client/pocket-client.test.ts
10-
@@ -31,7 +31,6 @@ import {
11-
generateNoiseKeyPair,
12-
hashPasskeyPublicKey,
13-
parsePairingInvitationUrl,
14-
- presenceChallenge,
15-
pushEndpointFingerprint,
16-
randomBase64Url,
17-
toBase64Url,
18-
@@ -49,7 +48,6 @@ import {
10+
@@ -51,7 +51,6 @@ import {
1911
BurrowIdentityMismatchError,
2012
PAIRING_DENIAL_MESSAGES,
2113
PASSKEY_UNAVAILABLE_MESSAGE,
2214
- PRESENCE_CHALLENGE_MISMATCH_MESSAGE,
2315
PocketClient,
2416
SessionExpiredError,
2517
SessionSupersededError,
26-
@@ -73,15 +71,7 @@ import {
27-
} from '../direct/test-fake-peer';
28-
import type { DirectPeerLike } from '../direct/direct-peer';
29-
import type { RemoteTimer } from '../ws';
30-
-import {
31-
- createTestAuthenticator,
32-
- openConnectionSession,
33-
- readOutcome,
34-
- sendE2eFrame,
35-
- settle,
36-
- testRoutingId,
37-
- type TestAuthenticator,
38-
-} from '../test-e2e-client';
39-
+import { createTestAuthenticator, settle, type TestAuthenticator } from '../test-e2e-client';
40-
import { PasskeyAlreadyRegisteredError, type WebAuthnClient } from './webauthn';
41-
import {
42-
ACCOUNT_ID,
43-
@@ -2003,99 +1993,6 @@ describe('the presence proof', () => {
18+
@@ -2005,12 +2004,6 @@ describe('the presence proof', () => {
4419
return { client, prompts, finished };
4520
}
4621

@@ -50,78 +25,30 @@ index bb9c28ef3..d560251d5 100644
5025
- challenge: await presenceChallenge({ ...binding, handshakeHash: randomBase64Url(32) }, answer.relayNonce),
5126
- });
5227
-
28+
/**
29+
* A paired phone, past the window its pairing opened, and another connection
30+
* on that phone's own paired static which the phone never opened: the binding
31+
@@ -2078,38 +2071,6 @@ describe('the presence proof', () => {
32+
return { harness, binding, nonce, present };
33+
}
34+
5335
- it('refuses a challenge bound to another connection before any prompt, so that one gains no presence', async () => {
54-
- let burrowClock = Date.now();
55-
- const harness = await makeE2eHarness({ burrowNow: () => burrowClock });
56-
- const { burrowId, relay, noiseStatic, authenticator } = harness;
57-
- await harness.pairAndApprove(await harness.mintInvitation());
58-
- // Past the window the pairing opened, so the connection needs a proof.
59-
- burrowClock += PRESENCE_WINDOW_MAX_MS + 1;
60-
-
61-
- // Another connection on this phone's own paired static, which this phone never opened.
62-
- const record = harness.knownBurrows.records.get(burrowId)!;
63-
- const clientStatic = {
64-
- privateKey: record.clientStaticKeyPair.privateKey,
65-
- publicKey: fromBase64Url(record.clientStaticKeyPair.publicKeyRaw),
66-
- };
67-
- const open = (clientId: string, connectionId: string) =>
68-
- openConnectionSession({
69-
- socket: relay.burrowSocket,
70-
- burrowId,
71-
- clientId,
72-
- connectionId,
73-
- clientStatic,
74-
- burrowStaticPublicKey: noiseStatic.publicKey,
75-
- });
76-
- const other = { clientId: testRoutingId(), connectionId: testRoutingId() };
77-
- const otherSession = await open(other.clientId, other.connectionId);
78-
- const otherBinding: PresenceBinding = {
79-
- kind: 'connection',
80-
- burrowId,
81-
- connectionId: other.connectionId,
82-
- burrowChallenge: otherSession.burrowChallenge,
83-
- handshakeHash: toBase64Url(otherSession.session.handshakeHash),
84-
- passkeyCredentialId: authenticator.credentialId,
85-
- };
86-
- const otherNonce = randomBase64Url(32);
36+
- const { harness, binding, nonce, present } = await anotherConnectionOnThePairedStatic();
8737
- const phone = await behindRelay(harness, async (answer) => ({
8838
- ...answer,
89-
- challenge: await presenceChallenge(otherBinding, otherNonce),
39+
- challenge: await presenceChallenge(binding, nonce),
9040
- }));
9141
-
92-
- await expect(phone.client.connect(burrowId)).rejects.toThrow(PRESENCE_CHALLENGE_MISMATCH_MESSAGE);
42+
- await expect(phone.client.connect(harness.burrowId)).rejects.toThrow(PRESENCE_CHALLENGE_MISMATCH_MESSAGE);
9343
- expect(phone.prompts).toEqual([]);
9444
- expect(phone.finished).toEqual([]);
9545
-
96-
- // The other connection presents whatever reached `finish`: with no tap, the
97-
- // Burrow admits it to nothing and opens no window for the static.
98-
- sendE2eFrame(relay.burrowSocket, {
99-
- clientId: other.clientId,
100-
- burrowId,
101-
- kind: 'connection',
102-
- id: other.connectionId,
103-
- step: 'transport',
104-
- ct: toBase64Url(
105-
- otherSession.session.sendControl({
106-
- presence: {
107-
- binding: otherBinding,
108-
- relayNonce: otherNonce,
109-
- accountId: ACCOUNT_ID,
110-
- passkeyCredentialId: authenticator.credentialId,
111-
- passkeyPublicKey: authenticator.publicKey,
112-
- assertion: phone.finished[0],
113-
- },
114-
- }),
115-
- ),
116-
- });
117-
- expect(
118-
- await readOutcome(relay.burrowSocket, otherSession.session, 'connection', other.connectionId),
119-
- ).toMatchObject({ ok: false });
120-
- expect((await open(testRoutingId(), testRoutingId())).offer).toBe('none');
46+
- // The other connection presents whatever reached `finish`: with no tap, it gains nothing.
47+
- await present(phone.finished[0]);
12148
-
12249
- // An honest `begin` still connects, with the one prompt it needs.
12350
- const honest = await behindRelay(harness, async (answer) => answer);
124-
- expect(await honest.client.connect(burrowId)).toEqual({ ok: true, burrowLabel: BURROW_LABEL });
51+
- expect(await honest.client.connect(harness.burrowId)).toEqual({ ok: true, burrowLabel: BURROW_LABEL });
12552
- expect(honest.prompts).toHaveLength(1);
12653
- });
12754
-
@@ -141,7 +68,7 @@ index bb9c28ef3..d560251d5 100644
14168
const harness = await makeE2eHarness();
14269
const phone = await behindRelay(
14370
diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts
144-
index 79f5c541b..e65c42a3d 100644
71+
index 33381cf..352a714 100644
14572
--- a/lib/src/remote/client/pocket-client.ts
14673
+++ b/lib/src/remote/client/pocket-client.ts
14774
@@ -38,7 +38,6 @@ import {
@@ -152,7 +79,7 @@ index 79f5c541b..e65c42a3d 100644
15279
pushEndpointFingerprint,
15380
pushSubscriptionDeletePath,
15481
randomBase64Url,
155-
@@ -354,14 +353,6 @@ export const CONNECTION_DENIAL_MESSAGES: Record<ConnectionDenialCode, string> =
82+
@@ -356,14 +355,6 @@ export const CONNECTION_DENIAL_MESSAGES: Record<ConnectionDenialCode, string> =
15683
'burrow-error': 'The computer could not finish the connection.',
15784
};
15885

@@ -167,7 +94,7 @@ index 79f5c541b..e65c42a3d 100644
16794
/**
16895
* What a ceremony that simply never answered reports.
16996
*
170-
@@ -1160,12 +1151,6 @@ export class PocketClient {
97+
@@ -1170,12 +1161,6 @@ export class PocketClient {
17198
* authenticates no other pairing or connection; the Relay's `finish` proves
17299
* nothing to the Burrow, which recomputes the challenge and verifies the same
173100
* assertion itself.
@@ -180,7 +107,7 @@ index 79f5c541b..e65c42a3d 100644
180107
*/
181108
async #provePresence(binding: PresenceBinding): Promise<PresenceProofV1> {
182109
const auth = this.#auth();
183-
@@ -1174,13 +1159,7 @@ export class PocketClient {
110+
@@ -1184,13 +1169,7 @@ export class PocketClient {
184111
{ binding },
185112
auth,
186113
);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
Domain: remote
2+
Rule: S271 — `signin` must sign `signinChallenge` of the Relay's challenge, never its bytes
3+
Expect: lib/src/remote/client/pocket-client.ts
4+
Source: reverses part of #1243 (Pocket signed the challenge the Relay's sign-in `begin` answered as given)
5+
6+
diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts
7+
index b7a91c6..5a2ab8e 100644
8+
--- a/lib/src/remote/client/pocket-client.test.ts
9+
+++ b/lib/src/remote/client/pocket-client.test.ts
10+
@@ -34,12 +34,10 @@ import {
11+
presenceChallenge,
12+
pushEndpointFingerprint,
13+
randomBase64Url,
14+
- signinChallenge,
15+
toBase64Url,
16+
type PasskeyAssertion,
17+
type PresenceBinding,
18+
type ReauthBeginResponse,
19+
- type SigninFinishRequest,
20+
type TerminalDataEvent,
21+
} from 'remote-lib-common';
22+
23+
@@ -2125,44 +2123,6 @@ describe('the presence proof', () => {
24+
await pairing;
25+
expect(phone.prompts.map((prompt) => prompt.allowCredentials)).toEqual([[harness.authenticator.credentialId]]);
26+
});
27+
-
28+
- it('signs only a sign-in challenge it derived, so a sign-in tap is never presence for another connection', async () => {
29+
- const { harness, binding, nonce, present } = await anotherConnectionOnThePairedStatic();
30+
- const relayChosen = await presenceChallenge(binding, nonce);
31+
-
32+
- // A Relay that answers sign-in's `begin` with that connection's presence challenge.
33+
- const prompts: string[] = [];
34+
- const finishes: SigninFinishRequest[] = [];
35+
- const fetch = (async (url: string, init?: RequestInit) => {
36+
- const path = new URL(url, 'http://test').pathname;
37+
- if (path === '/api/signin/begin') {
38+
- return { ok: true, status: 200, json: async () => ({ challenge: relayChosen, rpId: RP_ID }) } as Response;
39+
- }
40+
- if (path === '/api/signin/finish') finishes.push(JSON.parse(init!.body as string) as SigninFinishRequest);
41+
- return harness.fetch(url, init);
42+
- }) as typeof globalThis.fetch;
43+
- const phone = new PocketClient({
44+
- wsBase: 'ws://test',
45+
- fetch,
46+
- webauthn: {
47+
- ...fakeWebAuthn,
48+
- getAssertion: (challenge) => {
49+
- prompts.push(challenge);
50+
- return harness.authenticator.assert(challenge, ORIGIN);
51+
- },
52+
- },
53+
- createWebSocket: () => harness.relay.openClientSocket(),
54+
- knownBurrows: memoryKnownBurrows(),
55+
- pendingDeletions: memoryPendingDeletions(),
56+
- storage: memoryStorage(),
57+
- });
58+
- await phone.signin();
59+
- expect(prompts).toEqual([await signinChallenge(relayChosen)]);
60+
- expect(finishes.map((finish) => finish.challenge)).toEqual([relayChosen]);
61+
-
62+
- // That connection presents the sign-in assertion as its presence proof.
63+
- await present(finishes[0]!.assertion);
64+
- });
65+
});
66+
67+
/**
68+
diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts
69+
index 33381cf..3a96c14 100644
70+
--- a/lib/src/remote/client/pocket-client.ts
71+
+++ b/lib/src/remote/client/pocket-client.ts
72+
@@ -43,7 +43,6 @@ import {
73+
pushSubscriptionDeletePath,
74+
randomBase64Url,
75+
samplePairingCode,
76+
- signinChallenge,
77+
toBase64Url,
78+
type ConnectionDenialCode,
79+
type ConnectionRequestV1,
80+
@@ -606,17 +605,10 @@ export class PocketClient {
81+
}
82+
}
83+
84+
- /**
85+
- * Sign in with a discoverable passkey; the session is kept across launches.
86+
- *
87+
- * **The authenticator signs `signinChallenge(begin.challenge)`, never
88+
- * `begin`'s bytes**, so a Relay cannot pass off a presence challenge for a
89+
- * connection of its own as a sign-in and present the tap to a Burrow.
90+
- */
91+
+ /** Sign in with a discoverable passkey; the session is kept across launches. */
92+
async signin(): Promise<SigninFinishResponse> {
93+
const begin = await this.#api<SigninBeginResponse>(API_ROUTES.signinBegin, {});
94+
- const challenge = await signinChallenge(begin.challenge);
95+
- const assertion = await this.#webauthn.getAssertion(challenge, begin.rpId);
96+
+ const assertion = await this.#webauthn.getAssertion(begin.challenge, begin.rpId);
97+
const request: SigninFinishRequest = { challenge: begin.challenge, assertion };
98+
const finish = await this.#api<SigninFinishResponse>(API_ROUTES.signinFinish, request);
99+
this.#setSession({

‎.github/audit/hosted.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Read `docs/specs/hosted.md`, `docs/specs/one-time.md` (its "Wire contract", "Hos
1212

1313
Check the installed `pgstencil` and `@pgstencil/auth` 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.
1414

15-
Verify npm's signed SLSA provenance for each installed package/version. Use a temporary npm consumer of the exact locked versions and `npm audit signatures --json --include-attestations` (npm does not audit a pnpm-only install). 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 every clause of S119 against them, 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:
15+
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:
1616

1717
```sh
1818
gh api repos/diffplug/pgstencil/compare/<commit>...main --jq .status

‎.github/workflows/hosted-production.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ jobs:
3030
- run: pnpm build:hosted
3131
- name: Require accepted package provenance
3232
run: node --input-type=module -e 'import { verifyPackages } from "./hosted/scripts/production.mjs"; await verifyPackages();'
33+
# npm exits nonzero for a failure anywhere in Hosted's tree; only pgstencil's packages gate here.
34+
- name: Require npm's registry signature and provenance attestation on each pgstencil package
35+
working-directory: hosted
36+
run: |
37+
npm audit signatures --json --include-attestations > "$RUNNER_TEMP/signatures.json" || true
38+
jq -e '[.invalid[], .missing[] | select(.name == "pgstencil" or .name == "@pgstencil/auth")] == []
39+
and (["pgstencil", "@pgstencil/auth"] - [.verified[] | select(.attestations.provenance) | .name] == [])' \
40+
"$RUNNER_TEMP/signatures.json"
3341
# Every Worker's static files, each under its own `dist/<worker>/`.
3442
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
3543
with:

0 commit comments

Comments
 (0)