fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)

* fix(pairing): restore qr bootstrap onboarding handoff

* fix(pairing): tighten bootstrap handoff follow-ups

* fix(pairing): migrate legacy gateway device auth

* fix(pairing): narrow qr bootstrap handoff scope

* fix(pairing): clear ios tls trust on onboarding reset

* fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)
This commit is contained in:
Nimrod Gutman
2026-03-31 21:11:35 +03:00
committed by GitHub
parent 693d17c4a2
commit 69fe999373
15 changed files with 694 additions and 48 deletions

View File

@@ -2,7 +2,10 @@ import type { IncomingMessage } from "node:http";
import os from "node:os";
import type { WebSocket } from "ws";
import { loadConfig } from "../../../config/config.js";
import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js";
import {
revokeDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "../../../infra/device-bootstrap.js";
import {
deriveDeviceIdFromPublicKey,
normalizeDevicePublicKeyBase64Url,
@@ -752,6 +755,7 @@ export function attachGatewayWsMessageHandler(params: {
};
const requirePairing = async (
reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade",
existingPairedDevice: Awaited<ReturnType<typeof getPairedDevice>> | null = null,
) => {
const pairingStateAllowsRequestedAccess = (
pairedCandidate: Awaited<ReturnType<typeof getPairedDevice>>,
@@ -786,11 +790,23 @@ export function attachGatewayWsMessageHandler(params: {
isWebchat,
reason,
});
// QR bootstrap onboarding is node-only and single-use. When a fresh device presents
// a valid bootstrap token for the baseline node profile, complete pairing in the same
// handshake so iOS does not get stuck retrying with an already-consumed bootstrap token.
const allowSilentBootstrapPairing =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice;
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
...clientPairingMetadata,
silent: reason === "scope-upgrade" ? false : allowSilentLocalPairing,
silent:
reason === "scope-upgrade"
? false
: allowSilentLocalPairing || allowSilentBootstrapPairing,
});
const context = buildRequestContext();
let approved: Awaited<ReturnType<typeof approveDevicePairing>> | undefined;
@@ -815,6 +831,16 @@ export function attachGatewayWsMessageHandler(params: {
callerScopes: scopes,
});
if (approved?.status === "approved") {
if (allowSilentBootstrapPairing && bootstrapTokenCandidate) {
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
});
if (!revoked.removed) {
logGateway.warn(
`bootstrap token revoke skipped after silent auto-approval device=${approved.device.deviceId}`,
);
}
}
logGateway.info(
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
@@ -879,7 +905,7 @@ export function attachGatewayWsMessageHandler(params: {
const paired = await getPairedDevice(device.id);
const isPaired = paired?.publicKey === devicePublicKey;
if (!isPaired) {
const ok = await requirePairing("not-paired");
const ok = await requirePairing("not-paired", paired);
if (!ok) {
return;
}
@@ -899,7 +925,7 @@ export function attachGatewayWsMessageHandler(params: {
logGateway.warn(
`security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} client=${connectParams.client.id} conn=${connId}`,
);
const ok = await requirePairing("metadata-upgrade");
const ok = await requirePairing("metadata-upgrade", paired);
if (!ok) {
return;
}
@@ -920,13 +946,13 @@ export function attachGatewayWsMessageHandler(params: {
const allowedRoles = new Set(pairedRoles);
if (allowedRoles.size === 0) {
logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes);
const ok = await requirePairing("role-upgrade");
const ok = await requirePairing("role-upgrade", paired);
if (!ok) {
return;
}
} else if (!allowedRoles.has(role)) {
logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes);
const ok = await requirePairing("role-upgrade");
const ok = await requirePairing("role-upgrade", paired);
if (!ok) {
return;
}
@@ -935,7 +961,7 @@ export function attachGatewayWsMessageHandler(params: {
if (scopes.length > 0) {
if (pairedScopes.length === 0) {
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
const ok = await requirePairing("scope-upgrade");
const ok = await requirePairing("scope-upgrade", paired);
if (!ok) {
return;
}
@@ -947,7 +973,7 @@ export function attachGatewayWsMessageHandler(params: {
});
if (!scopesAllowed) {
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
const ok = await requirePairing("scope-upgrade");
const ok = await requirePairing("scope-upgrade", paired);
if (!ok) {
return;
}