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
83 changes: 77 additions & 6 deletions companion/src/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ export interface PairingWindow {
attemptsLeft: number;
}

/** A successful redemption kept only long enough for the *same* phone request
* to recover from a lost HTTP response on another advertised address.
*
* The device token remains memory-only here (the durable file still contains
* only its digest), and a replay needs both the original high-entropy pairing
* credential and the client-generated request id. Older clients that omit a
* request id retain the original exactly-once behaviour. */
interface PairingReplay {
requestId: string;
credentialHash: string;
expiresAt: number;
result: { device: PublicDevice; token: string };
}

const DEVICES_FILE = join(DATA_DIR, "devices.json");
export const PAIRING_TTL_MS = 120_000;
export const MAX_PAIRING_ATTEMPTS = 5;
Expand Down Expand Up @@ -113,6 +127,8 @@ function normalizeDevice(record: Partial<DeviceRecord> & { id: string; tokenHash
export class DeviceRegistry {
private devices: DeviceRecord[] = [];
private window: PairingWindow | null = null;
private replay: PairingReplay | null = null;
private replayExpiryTimer: ReturnType<typeof setTimeout> | null = null;
private lastSeenWrites = new Map<string, number>();

/** Load the paired fleet, normalising as it goes.
Expand Down Expand Up @@ -167,6 +183,7 @@ export class DeviceRegistry {
/** Open a fresh window, replacing any that was already open. The code is
* from `randomInt`, not `Math.random` — it is a credential for two minutes. */
openPairing(): PairingWindow {
this.clearReplay();
this.window = {
code: String(randomInt(0, 1_000_000)).padStart(6, "0"),
token: `omb_pair_${randomBytes(32).toString("base64url")}`,
Expand All @@ -178,16 +195,52 @@ export class DeviceRegistry {

closePairing() {
this.window = null;
this.clearReplay();
}

/** Erase the only in-memory copy of a successfully issued device token.
* The timer matters even if nobody ever calls `redeem` again: an expired
* recovery window must not leave a raw bearer sitting in a long-lived
* desktop process. */
private clearReplay() {
this.replay = null;
if (this.replayExpiryTimer) clearTimeout(this.replayExpiryTimer);
this.replayExpiryTimer = null;
}

/** Redeem either pairing credential for a device token.
*
* The token is returned exactly once, here. There is no endpoint that can
* read it back — a phone that loses it pairs again. */
redeem(credential: string, name: unknown): { device: PublicDevice; token: string } | { error: string } {
* Old clients receive the token exactly once. A client that supplies a
* request id may repeat that same logical redemption until the pairing
* window's original expiry, which is just enough to survive losing the
* response while changing routes. There is no general token-read endpoint. */
redeem(
credential: string,
name: unknown,
pairRequestId?: unknown,
): { device: PublicDevice; token: string } | { error: string } {
const presented = String(credential ?? "");
const requestId =
typeof pairRequestId === "string" && /^[A-Za-z0-9._-]{16,128}$/.test(pairRequestId)
? pairRequestId
: null;

// A route can die after the registry committed the device but before the
// phone received the response. Retrying the same logical request through
// another advertised address must return the same device, not burn a
// second slot or turn a successful pairing into a misleading 401.
if (this.replay && this.replay.expiresAt <= Date.now()) this.clearReplay();
if (
requestId &&
this.replay &&
sameCredential(this.replay.requestId, requestId) &&
sameDigest(this.replay.credentialHash, sha256(presented))
) {
return this.replay.result;
}

const window = this.pairing();
if (!window) return { error: "no pairing is in progress — open Companion settings on your computer" };
const presented = String(credential ?? "");
if (!sameCredential(window.code, presented) && !sameCredential(window.token, presented)) {
window.attemptsLeft -= 1;
// A burned window is the whole point: without this, six digits is a
Expand All @@ -204,7 +257,9 @@ export class DeviceRegistry {
// attempts. The window survives, so removing a phone and retyping the
// same code still works.
if (this.devices.length >= MAX_DEVICES) return { error: "too many paired devices — remove one first" };
this.closePairing();
// Consume the window without clearing a possible replay. `closePairing`
// is the explicit cancel operation and intentionally clears both.
this.window = null;

const token = `omb_${randomBytes(32).toString("base64url")}`;
const device: DeviceRecord = {
Expand All @@ -228,7 +283,23 @@ export class DeviceRegistry {
return { error: `could not save the pairing: ${(e as Error).message}` };
}
const { tokenHash, ...pub } = device;
return { device: pub, token };
const result = { device: pub, token };
if (requestId) {
this.replay = {
requestId,
credentialHash: sha256(presented),
expiresAt: window.expiresAt,
result,
};
this.replayExpiryTimer = setTimeout(
() => this.clearReplay(),
Math.max(0, window.expiresAt - Date.now()),
);
// A two-minute recovery window is not a reason for a deliberately
// stopped companion process to stay alive.
this.replayExpiryTimer.unref?.();
}
return result;
}

/** Resolve a bearer token to its device, or null. */
Expand Down
2 changes: 1 addition & 1 deletion companion/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const companion = createServer(
// `authenticate` also stamps lastSeenAt, which is what makes the control
// page able to say when a phone was last heard from.
authenticate: (token) => devices.authenticate(token),
redeem: (code, deviceName) => devices.redeem(code, deviceName),
redeem: (code, deviceName, pairRequestId) => devices.redeem(code, deviceName, pairRequestId),
serverName: machineName,
// Recomputed per pairing rather than cached: addresses change when the
// machine joins another network, and a pairing is exactly the moment the
Expand Down
7 changes: 6 additions & 1 deletion companion/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ProxyOptions {
redeem: (
code: string,
deviceName: unknown,
pairRequestId?: unknown,
) => { token: string; device: unknown } | { error: string };
/** What the phone should call this computer in its connection list. */
serverName: () => string;
Expand Down Expand Up @@ -165,7 +166,11 @@ export function createProxyHandler(options: ProxyOptions) {
(body) => {
// New clients redeem the high-entropy credential carried by the QR.
// `code` remains accepted for manual entry and older mobile builds.
const result = options.redeem(String(body.credential ?? body.code ?? ""), body.deviceName);
const result = options.redeem(
String(body.credential ?? body.code ?? ""),
body.deviceName,
body.pairRequestId,
);
if ("error" in result) return sendJson(res, 401, { error: result.error });
// `hosts` rides along whichever way the phone paired — QR, typed
// address, or discovery — so every paired device learns the full
Expand Down
58 changes: 58 additions & 0 deletions companion/test/devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,64 @@ describe("DeviceRegistry", () => {
expect(registry.count()).toBe(1);
});

it("replays one logical redemption without creating an orphan device", () => {
const registry = new DeviceRegistry();
const { token: credential } = registry.openPairing();
const requestId = "4c825d5b-cf40-4db7-aac5-2455f805a8ec";

const first = registry.redeem(credential, "iPhone", requestId);
const replay = registry.redeem(credential, "iPhone", requestId);

expect(first).toHaveProperty("token");
expect(replay).toEqual(first);
expect(registry.count()).toBe(1);
// Possessing only one half of the replay key is not enough.
expect(registry.redeem(credential, "iPhone", "different-request-id")).toMatchObject({
error: expect.stringContaining("no pairing"),
});
expect(registry.redeem("omb_pair_wrong", "iPhone", requestId)).toMatchObject({
error: expect.stringContaining("no pairing"),
});
});

it("forgets a redemption replay when a fresh pairing window opens", () => {
const registry = new DeviceRegistry();
const { token: firstCredential } = registry.openPairing();
const requestId = "4c825d5b-cf40-4db7-aac5-2455f805a8ec";
expect(registry.redeem(firstCredential, "iPhone", requestId)).toHaveProperty("token");

registry.openPairing();
expect(registry.redeem(firstCredential, "iPhone", requestId)).toMatchObject({
error: expect.stringContaining("not right"),
});
});

it("actively erases a redemption replay at the original window expiry", () => {
vi.useFakeTimers();
try {
const registry = new DeviceRegistry();
const { token: credential } = registry.openPairing();
const requestId = "4c825d5b-cf40-4db7-aac5-2455f805a8ec";
expect(registry.redeem(credential, "iPhone", requestId)).toHaveProperty("token");
const memory = registry as unknown as {
replay: unknown;
replayExpiryTimer: unknown;
};
expect(memory.replay).not.toBeNull();
expect(memory.replayExpiryTimer).not.toBeNull();

vi.advanceTimersByTime(PAIRING_TTL_MS + 1);
expect(memory.replay).toBeNull();
expect(memory.replayExpiryTimer).toBeNull();
expect(registry.redeem(credential, "iPhone", requestId)).toMatchObject({
error: expect.stringContaining("no pairing"),
});
expect(registry.count()).toBe(1);
} finally {
vi.useRealTimers();
}
});

it("keeps cloud desktop access off until enabled for that device", () => {
const registry = new DeviceRegistry();
const { token, device } = pair(registry);
Expand Down
21 changes: 19 additions & 2 deletions companion/test/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ describe("pairing, end to end", () => {
createProxyHandler({
harnessPort: HARNESS_PORT,
authenticate: (t) => registry.authenticate(t ?? undefined),
redeem: (code, deviceName) => registry.redeem(code, deviceName),
redeem: (code, deviceName, pairRequestId) => registry.redeem(code, deviceName, pairRequestId),
serverName: () => "Ada's computer",
hosts: () => ["macbook.tail1234.ts.net", "192.168.1.42", "openmausbot-abcd1234.local"],
}),
Expand Down Expand Up @@ -591,10 +591,16 @@ describe("pairing, end to end", () => {
expect(wrong.status).toBe(401);

// The QR token is redeemed exactly once and never forwarded upstream.
const pairRequestId = "4c825d5b-cf40-4db7-aac5-2455f805a8ec";
const pairBody = JSON.stringify({
credential: opened.token,
deviceName: "Ada's iPhone",
pairRequestId,
});
const res = await fetch(`${base}/api/pair`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ credential: opened.token, deviceName: "Ada's iPhone" }),
body: pairBody,
});
expect(res.status).toBe(201);
// SAFETY: a 201 from /api/pair carries exactly this shape — the
Expand All @@ -606,6 +612,17 @@ describe("pairing, end to end", () => {
// by typed address learns the other ways to reach this computer too.
expect(body.hosts).toEqual(["macbook.tail1234.ts.net", "192.168.1.42", "openmausbot-abcd1234.local"]);

// Losing the first response after it reached the Mac must not strand an
// orphan device. The same logical request can arrive through a fallback
// address and receives the exact token already committed to disk.
const replay = await fetch(`${base}/api/pair`, {
method: "POST",
headers: { "content-type": "application/json" },
body: pairBody,
});
expect(replay.status).toBe(201);
expect((await replay.json() as { token: string }).token).toBe(body.token);

// and the token works on the real API, through the real proxy
const bots = await fetch(`${base}/api/bots`, { headers: { authorization: `Bearer ${body.token}` } });
expect(bots.status).toBe(200);
Expand Down
33 changes: 28 additions & 5 deletions ios/App/PairingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ struct PairingView: View {
@State private var manualAddress = ""
@State private var code = ""
@State private var scannedCredential: String?
/// Stable across Retry. If the Mac committed a device but the response
/// was lost, repeating this same logical request recovers its token
/// instead of creating an orphan device.
@State private var pairRequestId: String?
@State private var chosen: Connection?
@State private var pairing = false
@State private var failure: String?
Expand Down Expand Up @@ -365,6 +369,7 @@ struct PairingView: View {
}
choiceGeneration += 1
scannedCredential = nil
pairRequestId = nil
chosen = connection
} label: {
Text("Connect to Address")
Expand Down Expand Up @@ -493,6 +498,7 @@ struct PairingView: View {
chosen = nil
code = ""
scannedCredential = nil
pairRequestId = nil
failure = nil
}
.font(.caption.weight(.semibold))
Expand Down Expand Up @@ -536,6 +542,7 @@ struct PairingView: View {
choiceGeneration += 1
let generation = choiceGeneration
failure = nil
pairRequestId = nil
do {
let resolved = try await discovery.resolve(service)
guard generation == choiceGeneration else { return }
Expand All @@ -551,20 +558,35 @@ struct PairingView: View {
failure = nil
defer { pairing = false }
let cameFromScanner = scannedCredential != nil
let requestId = pairRequestId ?? UUID().uuidString
pairRequestId = requestId
do {
try await session.pair(
with: connection,
credential: credential,
deviceName: Self.deviceName()
deviceName: Self.deviceName(),
pairRequestId: requestId
)
pairRequestId = nil
} catch {
if cameFromScanner {
failure = "\(error.localizedDescription) Start pairing again on your computer and rescan the new QR code."
chosen = nil
scannedCredential = nil
if error is PairingRouteError {
// The same request id makes Retry safe whether no route
// was reached or the Mac committed the device and its
// response was lost while the route changed.
failure = error.localizedDescription
} else {
failure = "\(error.localizedDescription) Start pairing again on your computer and rescan the new QR code."
chosen = nil
scannedCredential = nil
pairRequestId = nil
}
} else {
failure = error.localizedDescription
code = ""
if !(error is PairingRouteError) {
code = ""
pairRequestId = nil
}
}
}
}
Expand All @@ -574,6 +596,7 @@ struct PairingView: View {
choiceGeneration += 1
chosen = invite.connection
scannedCredential = invite.credential
pairRequestId = UUID().uuidString
code = ""
failure = nil
session.consumePairingInvite()
Expand Down
20 changes: 14 additions & 6 deletions ios/App/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,20 +156,28 @@ final class Session: ObservableObject {
/// Redeem a one-time pairing credential. On success the device token goes
/// to the keychain and the connection to defaults — deliberately apart,
/// so the thing that gets backed up is never the credential.
func pair(with connection: Connection, credential: String, deviceName: String) async throws {
let paired = try await CompanionClient.pair(
func pair(
with connection: Connection,
credential: String,
deviceName: String,
pairRequestId: String
) async throws {
let outcome = try await CompanionClient.pairFirstReachable(
connection: connection,
credential: credential,
deviceName: deviceName
deviceName: deviceName,
pairRequestId: pairRequestId
)
let paired = outcome.response
// prefer the name the computer calls itself over the Bonjour label
var stored = connection
var stored = outcome.connection
if !paired.serverName.isEmpty { stored.name = paired.serverName }
// The computer knows every address it answers on, and what it says at
// redeem time beats whatever the invite carried. Then the host that
// just redeemed the code leads: it demonstrably works from here.
if let hosts = paired.hosts, !hosts.isEmpty { stored.hosts = hosts }
stored.promote(stored.host)
if let hosts = paired.hosts, !hosts.isEmpty { stored.hosts = Array(hosts.prefix(8)) }
stored.promote(outcome.connection.host)
stored.hosts = Array(stored.orderedHosts.prefix(8))

try Keychain.save(paired.token, for: stored.id)
UserDefaults.standard.set(try? JSONEncoder().encode(stored), forKey: Self.connectionKey)
Expand Down
Loading
Loading