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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 20 additions & 93 deletions .github/audit/canaries/pocket-presence-challenge-unchecked.patch
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,18 @@ Expect: lib/src/remote/client/pocket-client.ts
Source: reverses part of #1223 (Pocket signed the challenge the Relay's `begin` answered instead of deriving its own)

diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts
index bb9c28ef3..d560251d5 100644
index b7a91c6..25d98e3 100644
--- a/lib/src/remote/client/pocket-client.test.ts
+++ b/lib/src/remote/client/pocket-client.test.ts
@@ -31,7 +31,6 @@ import {
generateNoiseKeyPair,
hashPasskeyPublicKey,
parsePairingInvitationUrl,
- presenceChallenge,
pushEndpointFingerprint,
randomBase64Url,
toBase64Url,
@@ -49,7 +48,6 @@ import {
@@ -51,7 +51,6 @@ import {
BurrowIdentityMismatchError,
PAIRING_DENIAL_MESSAGES,
PASSKEY_UNAVAILABLE_MESSAGE,
- PRESENCE_CHALLENGE_MISMATCH_MESSAGE,
PocketClient,
SessionExpiredError,
SessionSupersededError,
@@ -73,15 +71,7 @@ import {
} from '../direct/test-fake-peer';
import type { DirectPeerLike } from '../direct/direct-peer';
import type { RemoteTimer } from '../ws';
-import {
- createTestAuthenticator,
- openConnectionSession,
- readOutcome,
- sendE2eFrame,
- settle,
- testRoutingId,
- type TestAuthenticator,
-} from '../test-e2e-client';
+import { createTestAuthenticator, settle, type TestAuthenticator } from '../test-e2e-client';
import { PasskeyAlreadyRegisteredError, type WebAuthnClient } from './webauthn';
import {
ACCOUNT_ID,
@@ -2003,99 +1993,6 @@ describe('the presence proof', () => {
@@ -2005,12 +2004,6 @@ describe('the presence proof', () => {
return { client, prompts, finished };
}

Expand All @@ -50,78 +25,30 @@ index bb9c28ef3..d560251d5 100644
- challenge: await presenceChallenge({ ...binding, handshakeHash: randomBase64Url(32) }, answer.relayNonce),
- });
-
/**
* A paired phone, past the window its pairing opened, and another connection
* on that phone's own paired static which the phone never opened: the binding
@@ -2078,38 +2071,6 @@ describe('the presence proof', () => {
return { harness, binding, nonce, present };
}

- it('refuses a challenge bound to another connection before any prompt, so that one gains no presence', async () => {
- let burrowClock = Date.now();
- const harness = await makeE2eHarness({ burrowNow: () => burrowClock });
- const { burrowId, relay, noiseStatic, authenticator } = harness;
- await harness.pairAndApprove(await harness.mintInvitation());
- // Past the window the pairing opened, so the connection needs a proof.
- burrowClock += PRESENCE_WINDOW_MAX_MS + 1;
-
- // Another connection on this phone's own paired static, which this phone never opened.
- const record = harness.knownBurrows.records.get(burrowId)!;
- const clientStatic = {
- privateKey: record.clientStaticKeyPair.privateKey,
- publicKey: fromBase64Url(record.clientStaticKeyPair.publicKeyRaw),
- };
- const open = (clientId: string, connectionId: string) =>
- openConnectionSession({
- socket: relay.burrowSocket,
- burrowId,
- clientId,
- connectionId,
- clientStatic,
- burrowStaticPublicKey: noiseStatic.publicKey,
- });
- const other = { clientId: testRoutingId(), connectionId: testRoutingId() };
- const otherSession = await open(other.clientId, other.connectionId);
- const otherBinding: PresenceBinding = {
- kind: 'connection',
- burrowId,
- connectionId: other.connectionId,
- burrowChallenge: otherSession.burrowChallenge,
- handshakeHash: toBase64Url(otherSession.session.handshakeHash),
- passkeyCredentialId: authenticator.credentialId,
- };
- const otherNonce = randomBase64Url(32);
- const { harness, binding, nonce, present } = await anotherConnectionOnThePairedStatic();
- const phone = await behindRelay(harness, async (answer) => ({
- ...answer,
- challenge: await presenceChallenge(otherBinding, otherNonce),
- challenge: await presenceChallenge(binding, nonce),
- }));
-
- await expect(phone.client.connect(burrowId)).rejects.toThrow(PRESENCE_CHALLENGE_MISMATCH_MESSAGE);
- await expect(phone.client.connect(harness.burrowId)).rejects.toThrow(PRESENCE_CHALLENGE_MISMATCH_MESSAGE);
- expect(phone.prompts).toEqual([]);
- expect(phone.finished).toEqual([]);
-
- // The other connection presents whatever reached `finish`: with no tap, the
- // Burrow admits it to nothing and opens no window for the static.
- sendE2eFrame(relay.burrowSocket, {
- clientId: other.clientId,
- burrowId,
- kind: 'connection',
- id: other.connectionId,
- step: 'transport',
- ct: toBase64Url(
- otherSession.session.sendControl({
- presence: {
- binding: otherBinding,
- relayNonce: otherNonce,
- accountId: ACCOUNT_ID,
- passkeyCredentialId: authenticator.credentialId,
- passkeyPublicKey: authenticator.publicKey,
- assertion: phone.finished[0],
- },
- }),
- ),
- });
- expect(
- await readOutcome(relay.burrowSocket, otherSession.session, 'connection', other.connectionId),
- ).toMatchObject({ ok: false });
- expect((await open(testRoutingId(), testRoutingId())).offer).toBe('none');
- // The other connection presents whatever reached `finish`: with no tap, it gains nothing.
- await present(phone.finished[0]);
-
- // An honest `begin` still connects, with the one prompt it needs.
- const honest = await behindRelay(harness, async (answer) => answer);
- expect(await honest.client.connect(burrowId)).toEqual({ ok: true, burrowLabel: BURROW_LABEL });
- expect(await honest.client.connect(harness.burrowId)).toEqual({ ok: true, burrowLabel: BURROW_LABEL });
- expect(honest.prompts).toHaveLength(1);
- });
-
Expand All @@ -141,7 +68,7 @@ index bb9c28ef3..d560251d5 100644
const harness = await makeE2eHarness();
const phone = await behindRelay(
diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts
index 79f5c541b..e65c42a3d 100644
index 33381cf..352a714 100644
--- a/lib/src/remote/client/pocket-client.ts
+++ b/lib/src/remote/client/pocket-client.ts
@@ -38,7 +38,6 @@ import {
Expand All @@ -152,7 +79,7 @@ index 79f5c541b..e65c42a3d 100644
pushEndpointFingerprint,
pushSubscriptionDeletePath,
randomBase64Url,
@@ -354,14 +353,6 @@ export const CONNECTION_DENIAL_MESSAGES: Record<ConnectionDenialCode, string> =
@@ -356,14 +355,6 @@ export const CONNECTION_DENIAL_MESSAGES: Record<ConnectionDenialCode, string> =
'burrow-error': 'The computer could not finish the connection.',
};

Expand All @@ -167,7 +94,7 @@ index 79f5c541b..e65c42a3d 100644
/**
* What a ceremony that simply never answered reports.
*
@@ -1160,12 +1151,6 @@ export class PocketClient {
@@ -1170,12 +1161,6 @@ export class PocketClient {
* authenticates no other pairing or connection; the Relay's `finish` proves
* nothing to the Burrow, which recomputes the challenge and verifies the same
* assertion itself.
Expand All @@ -180,7 +107,7 @@ index 79f5c541b..e65c42a3d 100644
*/
async #provePresence(binding: PresenceBinding): Promise<PresenceProofV1> {
const auth = this.#auth();
@@ -1174,13 +1159,7 @@ export class PocketClient {
@@ -1184,13 +1169,7 @@ export class PocketClient {
{ binding },
auth,
);
Expand Down
99 changes: 99 additions & 0 deletions .github/audit/canaries/pocket-signin-challenge-underived.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
Domain: remote
Rule: S271 — `signin` must sign `signinChallenge` of the Relay's challenge, never its bytes
Expect: lib/src/remote/client/pocket-client.ts
Source: reverses part of #1243 (Pocket signed the challenge the Relay's sign-in `begin` answered as given)

diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts
index b7a91c6..5a2ab8e 100644
--- a/lib/src/remote/client/pocket-client.test.ts
+++ b/lib/src/remote/client/pocket-client.test.ts
@@ -34,12 +34,10 @@ import {
presenceChallenge,
pushEndpointFingerprint,
randomBase64Url,
- signinChallenge,
toBase64Url,
type PasskeyAssertion,
type PresenceBinding,
type ReauthBeginResponse,
- type SigninFinishRequest,
type TerminalDataEvent,
} from 'remote-lib-common';

@@ -2125,44 +2123,6 @@ describe('the presence proof', () => {
await pairing;
expect(phone.prompts.map((prompt) => prompt.allowCredentials)).toEqual([[harness.authenticator.credentialId]]);
});
-
- it('signs only a sign-in challenge it derived, so a sign-in tap is never presence for another connection', async () => {
- const { harness, binding, nonce, present } = await anotherConnectionOnThePairedStatic();
- const relayChosen = await presenceChallenge(binding, nonce);
-
- // A Relay that answers sign-in's `begin` with that connection's presence challenge.
- const prompts: string[] = [];
- const finishes: SigninFinishRequest[] = [];
- const fetch = (async (url: string, init?: RequestInit) => {
- const path = new URL(url, 'http://test').pathname;
- if (path === '/api/signin/begin') {
- return { ok: true, status: 200, json: async () => ({ challenge: relayChosen, rpId: RP_ID }) } as Response;
- }
- if (path === '/api/signin/finish') finishes.push(JSON.parse(init!.body as string) as SigninFinishRequest);
- return harness.fetch(url, init);
- }) as typeof globalThis.fetch;
- const phone = new PocketClient({
- wsBase: 'ws://test',
- fetch,
- webauthn: {
- ...fakeWebAuthn,
- getAssertion: (challenge) => {
- prompts.push(challenge);
- return harness.authenticator.assert(challenge, ORIGIN);
- },
- },
- createWebSocket: () => harness.relay.openClientSocket(),
- knownBurrows: memoryKnownBurrows(),
- pendingDeletions: memoryPendingDeletions(),
- storage: memoryStorage(),
- });
- await phone.signin();
- expect(prompts).toEqual([await signinChallenge(relayChosen)]);
- expect(finishes.map((finish) => finish.challenge)).toEqual([relayChosen]);
-
- // That connection presents the sign-in assertion as its presence proof.
- await present(finishes[0]!.assertion);
- });
});

/**
diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts
index 33381cf..3a96c14 100644
--- a/lib/src/remote/client/pocket-client.ts
+++ b/lib/src/remote/client/pocket-client.ts
@@ -43,7 +43,6 @@ import {
pushSubscriptionDeletePath,
randomBase64Url,
samplePairingCode,
- signinChallenge,
toBase64Url,
type ConnectionDenialCode,
type ConnectionRequestV1,
@@ -606,17 +605,10 @@ export class PocketClient {
}
}

- /**
- * Sign in with a discoverable passkey; the session is kept across launches.
- *
- * **The authenticator signs `signinChallenge(begin.challenge)`, never
- * `begin`'s bytes**, so a Relay cannot pass off a presence challenge for a
- * connection of its own as a sign-in and present the tap to a Burrow.
- */
+ /** Sign in with a discoverable passkey; the session is kept across launches. */
async signin(): Promise<SigninFinishResponse> {
const begin = await this.#api<SigninBeginResponse>(API_ROUTES.signinBegin, {});
- const challenge = await signinChallenge(begin.challenge);
- const assertion = await this.#webauthn.getAssertion(challenge, begin.rpId);
+ const assertion = await this.#webauthn.getAssertion(begin.challenge, begin.rpId);
const request: SigninFinishRequest = { challenge: begin.challenge, assertion };
const finish = await this.#api<SigninFinishResponse>(API_ROUTES.signinFinish, request);
this.#setSession({
2 changes: 1 addition & 1 deletion docs/specs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Source of truth: `relay/src/state.ts`, including `MAX_PUSH_SUBSCRIPTIONS_TOTAL`;
No WebAuthn library (rationale): registration requests `attestation: 'none'` and reads the SPKI key from `response.getPublicKey()`. **Assertions go through `verifyPasskeyAssertion` in `remote-lib-common`, the Burrow's own verifier**, so Relay and Burrow cannot disagree on a valid assertion.

- **Registration** (`checkRegistration`, shared with the Hosted Relay) **must redeem the challenge before the origin check**, so a wrong-origin registration still burns it, and requires a key that imports as ECDSA P-256. A credential id already stored is a 409, so re-registration cannot displace a stored key.
- **Must verify sign-in against the stored passkey under the Relay's UV policy**, consuming the challenge first; re-auth is `docs/specs/remote-security-model.md` -> "Presence proofs". An unknown credential is 404 on both. Both Relays refuse with the error constants in `remote-lib-common/src/remote/wire.ts`.
- **Must verify sign-in against the stored passkey under the Relay's UV policy**, over `signinChallenge` of the `begin` challenge `finish` names (`docs/specs/remote-security-model.md` -> "Presence proofs"), consuming that challenge first; re-auth is `docs/specs/remote-security-model.md` -> "Presence proofs". An unknown credential is 404 on both. Both Relays refuse with the error constants in `remote-lib-common/src/remote/wire.ts`.
- **Setup and sign-in each get their own `ChallengeIssuer`**, so a challenge minted for one cannot redeem in the other; each is capped (`MAX_PENDING_CHALLENGES`) as well as swept (rationale). Re-auth uses `PresenceNonceStore`.
- **The browser's `clientDataJSON.challenge` is canonicalized by decoded bytes before lookup**, so a padded serialization redeems without weakening single use.

Expand Down
3 changes: 2 additions & 1 deletion docs/specs/remote-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ Source of truth: `remote-lib-common/src/security/acl.ts` (the schema and `Burrow
- **`POST /api/reauth/begin` takes a required, kind-tagged binding** and answers the derived challenge over a one-use Relay nonce ([relay.md](./relay.md) owns both routes). **No binding, or no nonce to `finish`, is a 400** (rationale). `finish` consumes the nonce, recomputes the challenge, verifies the assertion against the **stored** key for that exact credential, and **extends nothing** — not the session's life, not the relay socket.
- **`PresenceProofV1` travels only inside the first Client→Burrow transport payload**, carrying the binding, the Relay nonce, `accountId` (as the Relay answered sign-in), the passkey credential id, its canonical SPKI public key, and the assertion. The Burrow recomputes the challenge with the same builder, requires **every binding field to equal what it built from its own state**, verifies RP ID, origin, presence/verification policy, and signature against the *presented* key, and hashes that key for the ACL. **A Relay success flag is never evidence**, and **the verifier never throws**, including missing WebCrypto or rejected digest operations — a rejection is an ordinary denial (rationale).
- **Every proof is fresh and single-use**: any restart — dropped transport, consumed challenge, failed handshake, later attempt — needs a new handshake, Burrow challenge, Relay nonce, and authenticator operation; one prompt per pairing, one per connection outside a [presence window](#presence-window), two on a self-hosted first run (rationale). A window ride replays no proof; it carries none.
- **A sign-in assertion is never a presence assertion.** Pocket signs `signinChallenge(challenge)`, base64url `SHA-256(lengthPrefixedConcat(domain, challenge's bytes))` under `dormouse/signin/v1`, never `POST /api/signin/begin`'s challenge as given, and `finish` names that challenge beside the assertion; both Relays verify against the derivation (`docs/specs/security-remote.md` -> "Trust boundary").
- **The Relay session is authentication-plane only.** Its bearer token ([relay.md](./relay.md)) is never reusable proof of presence for a Burrow, and **has no app-session signing key beside it** (rationale).

Source of truth: `presenceChallenge` / `isPresenceBinding` in `remote-lib-common/src/security/presence.ts`, `verifyPresenceProof` in `remote-lib-common/src/security/e2e-ceremony.ts`, `#provePresence` in `lib/src/remote/client/pocket-client.ts`, `relay/src/app.ts`.
Source of truth: `presenceChallenge` / `signinChallenge` / `isPresenceBinding` in `remote-lib-common/src/security/presence.ts`, `verifyPresenceProof` in `remote-lib-common/src/security/e2e-ceremony.ts`, `#provePresence` in `lib/src/remote/client/pocket-client.ts`, `relay/src/app.ts`.

## Presence window

Expand Down
Loading
Loading