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
2 changes: 2 additions & 0 deletions docs/specs/one-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ at most one session**, on `ClientSessionCore`, direct or not at all.

- **Never open a socket outside `connectOnce`**, which runs once per client
and opens none for an expired link.
- **Must end steps 1–2 at the room's hard deadline**, WebCrypto and the
socket's open included; step 3 runs on the direct deadline instead.
- **Never import store, passkey, push, or worker code** (the static's rule:
`docs/specs/remote-security-model.md` -> "One-time connection").
- Every protocol-v1 method refuses until both directions are direct (Direct
Expand Down
12 changes: 12 additions & 0 deletions lib/src/remote/client/one-time-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ONE_TIME_DENIAL_CODES,
DIRECT_ONLY_DEADLINE_MS,
DIRECT_SETUP_TIMEOUT_MS,
ONE_TIME_EXPIRY_GRACE_MS,
ONE_TIME_LINK_TTL_MS,
RELAY_PING,
RELAY_PING_INTERVAL_MS,
Expand Down Expand Up @@ -538,6 +539,17 @@ describe('OneTimeClient: the confirmation', () => {
message: ONE_TIME_UNREACHABLE_MESSAGE,
});
});

it('ends a socket that never opens at the room’s hard deadline, as the link expiring', async () => {
const client = makeClient({ open: false });
const burrow = await ScriptedBurrow.create();
const result = client.connectOnce(burrow.link, LABEL, () => {});
await flushUntil(() => sockets[0]);
clock.advance(burrow.link.expiry * 1000 + ONE_TIME_EXPIRY_GRACE_MS - clock.now());
expect(await result).toEqual({ ok: false, message: ONE_TIME_LINK_EXPIRED_MESSAGE });
expect(phoneSocket().closeCode).toBe(1000);
expect(clock.armed).toBe(0);
});
});

describe('OneTimeClient: the direct path', () => {
Expand Down
23 changes: 20 additions & 3 deletions lib/src/remote/client/one-time-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ export class OneTimeClient implements RemoteAdapterClient {
#route: OneTimeRoute | null = null;
/** The link `connectOnce` was given, so a close can tell whether it had expired. */
#link: OneTimeLink | null = null;
/**
* Cancels the timer armed at the room's hard deadline, which bounds every
* wait before the outcome — WebCrypto and the socket's open included.
*/
#cancelDeadline: (() => void) | null = null;

/** The rendezvous socket, while this client still reads it; closed at the switch and at the end. */
readonly #rendezvous: RendezvousHold;
Expand Down Expand Up @@ -230,6 +235,10 @@ export class OneTimeClient implements RemoteAdapterClient {
this.#link = link;
// The room's hard deadline: no answer can arrive after it.
const deadline = link.expiry * 1000 + ONE_TIME_EXPIRY_GRACE_MS;
this.#cancelDeadline = this.#setTimer(
() => this.#fail(ONE_TIME_LINK_EXPIRED_MESSAGE),
Math.max(0, deadline - this.#now()),
);
try {
const session = await this.#handshake(link, route, deadline);
const code = samplePairingCode();
Expand All @@ -248,6 +257,8 @@ export class OneTimeClient implements RemoteAdapterClient {
if (this.#failure !== null) throw new Error(this.#failure);
// The digits have done their job: the screen moves on to the direct path.
onConfirmed?.();
// From here the direct path's own deadline bounds the wait.
this.#clearDeadline();
this.#phase = 'connecting';
// After the outcome and never before: `establish` builds the direct
// path, and a peer connection that existed ahead of authorization would
Expand Down Expand Up @@ -307,9 +318,9 @@ export class OneTimeClient implements RemoteAdapterClient {
/**
* One wait in the ceremony that the core does not own — WebCrypto, the
* socket's open, the switch — cut short by the first failure: a room that
* closed, a direct path given up, {@link close}. **Checked again after it
* settles**, since a step can finish in the same turn as the failure that
* makes its result moot.
* closed, the room's hard deadline, a direct path given up, {@link close}.
* **Checked again after it settles**, since a step can finish in the same
* turn as the failure that makes its result moot.
*/
async #race<T>(step: Promise<T>): Promise<T> {
const value = await Promise.race([step, this.#interrupted]);
Expand All @@ -325,6 +336,11 @@ export class OneTimeClient implements RemoteAdapterClient {
this.#core.rejectAll(new Error(message));
}

#clearDeadline(): void {
this.#cancelDeadline?.();
this.#cancelDeadline = null;
}

/** End the attempt with `message`, and release everything. */
#finish(message: string): OneTimeResult {
this.#failure = message;
Expand Down Expand Up @@ -513,6 +529,7 @@ export class OneTimeClient implements RemoteAdapterClient {
#teardown(): void {
if (this.#phase === 'ended') return;
this.#phase = 'ended';
this.#clearDeadline();
this.#rendezvous.close();
this.#core.endSession(this.#failure ?? ONE_TIME_ENDED_MESSAGE, { notifyGone: false });
}
Expand Down
Loading