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
9 changes: 8 additions & 1 deletion docs/specs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,14 @@ away.
under the app-data dir in standalone, `SecretStorage` in VS Code — then opens
and maintains `GET /ws/host`. `hostToken` is a bearer credential and never
enters a webview realm. Enrollment is refused outright for a server outside
this build's allowlist (above), before the password leaves the machine.
this build's allowlist (above), before the password leaves the machine. A 200
that is not an enrollment fails the exchange: the response goes through the
same `isEnrollment` guard every *read* of an enrollment uses, and a body that
misses a field or sends one with the wrong type throws naming those fields
rather than minting a record with an `undefined` in the `ConnectionPolicy` the
Host authenticates passkeys against — one the store would reject on the next
read, un-enrolling the machine at the next launch. Source of truth:
`lib/src/remote/host/enrollment.ts`.
**Order matters, and the store goes first.** The `hostToken` this exchange
mints exists nowhere else and cannot be minted again from the same password
exchange, so the save is awaited before any Host is stopped: a failed write
Expand Down
2 changes: 1 addition & 1 deletion lib/src/host/remote/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ export class RemoteHostService {

async #startHost(enrollment: HostEnrollment): Promise<void> {
if (this.#disposed) return;
// Never two. Callers are serialized (see `#lifecycle`), but a Host left in
// Never two. Callers are serialized (see `#serialize`), but a Host left in
// `#host` here would be dropped without its socket being closed, so the
// replacement is explicit rather than implied by the assignment below.
this.#stopHost();
Expand Down
55 changes: 55 additions & 0 deletions lib/src/remote/host/enrollment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,61 @@ describe('remote-host enrollment', () => {
await expect(performEnrollment('https://dormouse.example', 'wrong', 'x')).rejects.toThrow(/401/);
});

it('refuses a 200 whose body is not an enrollment', async () => {
// A version skew or a proxy that rewrote the body. Minting from it would
// hand the Host an `undefined` in the `ConnectionPolicy` it authenticates
// passkeys against, and persist a record that `isEnrollment` rejects on the
// next read — the machine un-enrolls itself at the next launch with nothing
// in the log to explain it. Name the missing fields instead.
stubLocalStorage();
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(
JSON.stringify({ hostId: 'host-abc', hostToken: 'tok-xyz' }), // no origin/rpId
{ status: 200, headers: { 'content-type': 'application/json' } },
),
),
);

await expect(performEnrollment('https://dormouse.example', 'hunter2', 'x')).rejects.toThrow(
/missing or invalid: origin, rpId/,
);
});

it('refuses a 200 whose fields are the wrong type', async () => {
// `hostId: null` type-checks as `HostEnrollResponse` only because the body
// is cast, not parsed; the guard is what actually rejects it. It is present
// in the body, so the error says "missing or invalid" rather than "missing".
stubLocalStorage();
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(
JSON.stringify({ hostId: null, hostToken: 'tok-xyz', origin: 'o', rpId: 'r' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
),
);

await expect(performEnrollment('https://dormouse.example', 'hunter2', 'x')).rejects.toThrow(
/missing or invalid: hostId/,
);
});

it('refuses a 200 that is not JSON at all', async () => {
// A captive portal or a proxy error page served with a 200.
stubLocalStorage();
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('<html>not your server</html>', { status: 200 })),
);

await expect(performEnrollment('https://dormouse.example', 'hunter2', 'x')).rejects.toThrow(
/did not answer JSON/,
);
});

it('clears and rejects malformed persisted enrollment', () => {
// What a webview that enrolled before the service existed still holds, and
// hands over once (`activation.ts` → adoption).
Expand Down
55 changes: 47 additions & 8 deletions lib/src/remote/host/enrollment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ const ENROLL_TIMEOUT_MS = 10_000;

/**
* `POST /api/host/enroll` with the setup password and map the response to an
* enrollment. Throws with the server's status text on failure so the caller
* (console hook / settings UI) can surface it.
* enrollment. Throws with the server's status text on failure — or with what the
* response was missing when it answered 200 with something that is not one — so
* the caller (console hook / settings UI) can surface it. What this returns has
* passed {@link isEnrollment}, so the mint site and every read agree on what an
* enrollment is.
*
* Persists nothing: the service that ran it decides where the credentials live
* (`lib/src/host/remote/host-state-store.ts`), while the exchange itself is one
Expand Down Expand Up @@ -94,12 +97,48 @@ export async function performEnrollment(
const detail = await response.text().catch(() => '');
throw new Error(`host enroll failed (${response.status})${detail ? `: ${detail}` : ''}`);
}
const body = (await response.json()) as HostEnrollResponse;
return {
// The response body is untrusted like any other, so it goes through the same
// guard every *read* of an enrollment uses. Without it a server that answers
// 200 with a field missing — a version skew, a reverse proxy that rewrote the
// body — mints an enrollment that is accepted here and rejected by
// `isEnrollment` on the next read: the Host runs for this session with an
// `undefined` in the `ConnectionPolicy` it authenticates passkeys against, and
// the machine silently un-enrolls itself at the next launch with nothing in
// the log to explain it. Failing the exchange instead keeps the old Host
// running and names what the server got wrong.
let body: unknown;
try {
body = await response.json();
} catch (error) {
throw new Error(`host enroll failed: the server did not answer JSON (${errorMessage(error)})`);
}
const enrolled = body as Partial<HostEnrollResponse> | null;
const enrollment = {
serverUrl: base,
hostId: body.hostId,
hostToken: body.hostToken,
origin: body.origin,
rpId: body.rpId,
hostId: enrolled?.hostId,
hostToken: enrolled?.hostToken,
origin: enrolled?.origin,
rpId: enrolled?.rpId,
};
if (!isEnrollment(enrollment)) {
throw new Error(
`host enroll failed: the server's response is missing or invalid: ${missingEnrollmentFields(enrollment).join(', ')}`,
);
}
return enrollment;
}

/**
* Which `HostEnrollResponse` fields the server left out or sent with the wrong
* type, for the error above. The list mirrors {@link isEnrollment} minus
* `serverUrl`, which is set locally and can never be the one at fault.
*/
function missingEnrollmentFields(enrollment: Record<string, unknown>): string[] {
return (['hostId', 'hostToken', 'origin', 'rpId'] as const).filter(
(field) => typeof enrollment[field] !== 'string',
);
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}