From eb77ce56905159786d2533af26039ca2bb57a2a5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 11:40:55 -0700 Subject: [PATCH] refactor(infra): split device pairing lifecycle modules (#124734) --- config/max-lines-baseline.txt | 1 - src/cli/devices-cli.runtime.ts | 4 +- src/cli/devices-cli.test.ts | 6 + src/commands/doctor-device-pairing.test.ts | 9 +- src/commands/doctor-device-pairing.ts | 6 +- src/gateway/control-ui.http.test.ts | 8 +- src/gateway/control-ui.ts | 3 +- src/gateway/device-authz.test-helpers.ts | 9 +- src/gateway/device-pairing-prune.test.ts | 2 +- .../gateway-cli-backend.live-helpers.ts | 7 +- .../node-reapproval-coordinator.test.ts | 3 +- src/gateway/probe.auth.integration.test.ts | 3 +- src/gateway/server-methods/devices.test.ts | 19 +- src/gateway/server-methods/devices.ts | 14 +- src/gateway/server-methods/nodes.test.ts | 5 +- .../server.auth.compat-baseline.test.ts | 9 +- ...th.control-ui.bootstrap-lifecycle.suite.ts | 10 +- ...rver.auth.control-ui.device-token.suite.ts | 2 +- ...r.auth.control-ui.fixtures.test-support.ts | 3 +- ....auth.control-ui.mobile-bootstrap.suite.ts | 7 +- ...r.auth.control-ui.owner-bootstrap.suite.ts | 4 +- .../server.auth.control-ui.pairing.suite.ts | 3 +- src/gateway/server.auth.test-helpers.ts | 3 +- ...rver.device-pair-approve-supersede.test.ts | 7 +- .../server.device-token-rotate-authz.test.ts | 7 +- ...server.node-invoke-approval-bypass.test.ts | 3 +- .../server.node-pairing-rate-limit.test.ts | 3 +- .../server.node-pairing.test-support.ts | 3 +- .../server.roles-allowlist-update.test.ts | 3 +- src/gateway/server.sessions-send.test.ts | 3 +- .../server.shared-auth-rotation.test.ts | 9 +- ...silent-scope-upgrade-reconnect.poc.test.ts | 16 +- .../server/ws-connection/connect-auth.ts | 2 +- .../connect-device-pairing.test.ts | 3 +- .../ws-connection/connect-device-pairing.ts | 2 + .../ws-connection/connect-device-tokens.ts | 2 +- .../ws-connection/connect-node-pairing-ssh.ts | 3 +- src/gateway/startup-local-cli-pairing.test.ts | 7 +- src/gateway/startup-local-cli-pairing.ts | 7 +- src/gateway/test-helpers.server.ts | 7 +- src/gateway/watch-node-http.test.ts | 4 +- src/gateway/watch-node-http.ts | 5 +- src/infra/device-pairing-approval.ts | 503 ++++++++ src/infra/device-pairing-churn.test.ts | 2 +- src/infra/device-pairing-migration.test.ts | 2 +- src/infra/device-pairing-node.test.ts | 2 +- src/infra/device-pairing-prune.test.ts | 3 +- src/infra/device-pairing-state.ts | 180 +++ src/infra/device-pairing-tokens.ts | 421 ++++++ src/infra/device-pairing.test.ts | 11 +- src/infra/device-pairing.ts | 1141 +---------------- src/infra/node-pairing-migration.test.ts | 3 +- src/plugin-sdk/device-bootstrap.ts | 3 +- 53 files changed, 1307 insertions(+), 1200 deletions(-) create mode 100644 src/infra/device-pairing-approval.ts create mode 100644 src/infra/device-pairing-state.ts create mode 100644 src/infra/device-pairing-tokens.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 62980a97423e..12c8c6e04222 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -706,7 +706,6 @@ src/infra/backup-create.ts src/infra/clawhub-install-trust.ts src/infra/command-explainer/extract.ts src/infra/device-pairing.test.ts -src/infra/device-pairing.ts src/infra/diagnostic-events.ts src/infra/exec-approval-forwarder.ts src/infra/exec-approvals-allow-always.test.ts diff --git a/src/cli/devices-cli.runtime.ts b/src/cli/devices-cli.runtime.ts index 526d1815edb5..4e01be657e40 100644 --- a/src/cli/devices-cli.runtime.ts +++ b/src/cli/devices-cli.runtime.ts @@ -24,8 +24,10 @@ import { isLoopbackHost } from "../gateway/net.js"; import { approveDevicePairing, formatDevicePairingForbiddenMessage, +} from "../infra/device-pairing-approval.js"; +import { summarizeDeviceTokens } from "../infra/device-pairing-tokens.js"; +import { listDevicePairing, - summarizeDeviceTokens, type PairedDevice as InfraPairedDevice, } from "../infra/device-pairing.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index c40e85d12ed3..136abe412b0e 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -47,7 +47,13 @@ vi.mock("./progress.js", () => ({ vi.mock("../infra/device-pairing.js", () => ({ listDevicePairing: mocks.listDevicePairing, +})); + +vi.mock("../infra/device-pairing-approval.js", () => ({ approveDevicePairing: mocks.approveDevicePairing, +})); + +vi.mock("../infra/device-pairing-tokens.js", () => ({ summarizeDeviceTokens: mocks.summarizeDeviceTokens, })); diff --git a/src/commands/doctor-device-pairing.test.ts b/src/commands/doctor-device-pairing.test.ts index 2efa49957f92..00ec431a2a6a 100644 --- a/src/commands/doctor-device-pairing.test.ts +++ b/src/commands/doctor-device-pairing.test.ts @@ -8,12 +8,9 @@ import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, } from "../infra/device-identity.js"; -import { - approveDevicePairing, - requestDevicePairing, - revokeDeviceToken, - rotateDeviceToken, -} from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { revokeDeviceToken, rotateDeviceToken } from "../infra/device-pairing-tokens.js"; +import { requestDevicePairing } from "../infra/device-pairing.js"; import { withEnvAsync } from "../test-utils/env.js"; import { withTempDir } from "../test-utils/temp-dir.js"; diff --git a/src/commands/doctor-device-pairing.ts b/src/commands/doctor-device-pairing.ts index e9b2788d5a5e..3a4eccd5edf6 100644 --- a/src/commands/doctor-device-pairing.ts +++ b/src/commands/doctor-device-pairing.ts @@ -10,10 +10,12 @@ import { callGateway } from "../gateway/call.js"; import { loadDeviceAuthTokens } from "../infra/device-auth-store.js"; import { loadDeviceIdentityIfPresent } from "../infra/device-identity.js"; import { - listApprovedPairedDeviceRoles, - listDevicePairingReadOnly, summarizeDeviceTokens, type DeviceAuthTokenSummary, +} from "../infra/device-pairing-tokens.js"; +import { + listApprovedPairedDeviceRoles, + listDevicePairingReadOnly, type DevicePairingPendingRequest, type PairedDevice, } from "../infra/device-pairing.js"; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 9c36b382b0c6..0f379a1379b1 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -12,11 +12,9 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { normalizeAssistantIdentity } from "../../ui/src/lib/assistant-identity.ts"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - approveDevicePairing, - ensureDeviceToken, - requestDevicePairing, -} from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { ensureDeviceToken } from "../infra/device-pairing-tokens.js"; +import { requestDevicePairing } from "../infra/device-pairing.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index a467b557cbc6..4a12786f404d 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -15,7 +15,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { matchRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js"; import { readFileDescriptorBounded } from "../infra/boundary-file-read.js"; import { resolveDevInstallGitBranch } from "../infra/dev-install-branch.js"; -import { listDevicePairing, verifyDeviceToken } from "../infra/device-pairing.js"; +import { verifyDeviceToken } from "../infra/device-pairing-tokens.js"; +import { listDevicePairing } from "../infra/device-pairing.js"; import { readFileWindowFully } from "../infra/file-read.js"; import { openLocalFileSafely, FsSafeError } from "../infra/fs-safe.js"; import { safeFileURLToPath } from "../infra/local-file-access.js"; diff --git a/src/gateway/device-authz.test-helpers.ts b/src/gateway/device-authz.test-helpers.ts index dbca6d3c2132..7ebc3991a15a 100644 --- a/src/gateway/device-authz.test-helpers.ts +++ b/src/gateway/device-authz.test-helpers.ts @@ -9,12 +9,9 @@ import { publicKeyRawBase64UrlFromPem, type DeviceIdentity, } from "../infra/device-identity.js"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, - rotateDeviceToken, -} from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { rotateDeviceToken } from "../infra/device-pairing-tokens.js"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; import { trackConnectChallengeNonce } from "./test-helpers.js"; export function resolveDeviceIdentityPath(name: string): string { diff --git a/src/gateway/device-pairing-prune.test.ts b/src/gateway/device-pairing-prune.test.ts index 5f676be88367..f057895c5dd7 100644 --- a/src/gateway/device-pairing-prune.test.ts +++ b/src/gateway/device-pairing-prune.test.ts @@ -1,12 +1,12 @@ // Covers gateway-side cleanup when silent pairing supersedes stale sibling records. import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; import { approveNodePairing, listNodePairing, requestNodePairing, } from "../infra/device-pairing-node.js"; import { - approveDevicePairing, listDevicePairing, removePairedDeviceRole, requestDevicePairing, diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts index 44c3c9ee5db5..68320497f86f 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -15,11 +15,8 @@ import { publicKeyRawBase64UrlFromPem, type DeviceIdentity, } from "../infra/device-identity.js"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, -} from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; diff --git a/src/gateway/node-reapproval-coordinator.test.ts b/src/gateway/node-reapproval-coordinator.test.ts index 787f3289bb07..8b964ccf5430 100644 --- a/src/gateway/node-reapproval-coordinator.test.ts +++ b/src/gateway/node-reapproval-coordinator.test.ts @@ -1,5 +1,6 @@ // Covers paired-node reapproval reuse and changed-surface write limits. import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; import { approveNodePairing, beginNodePairingConnect, @@ -7,7 +8,7 @@ import { releaseNodePairingCleanupClaim, requestNodePairing, } from "../infra/device-pairing-node.js"; -import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js"; +import { requestDevicePairing } from "../infra/device-pairing.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { createNodeReapprovalCoordinator } from "./node-reapproval-coordinator.js"; diff --git a/src/gateway/probe.auth.integration.test.ts b/src/gateway/probe.auth.integration.test.ts index 153f50a6303f..c2bb8625fdb3 100644 --- a/src/gateway/probe.auth.integration.test.ts +++ b/src/gateway/probe.auth.integration.test.ts @@ -13,7 +13,8 @@ const { probeGateway } = await import("./probe.js"); const { storeDeviceAuthToken } = await import("../infra/device-auth-store.js"); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); -const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); +const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); +const { requestDevicePairing } = await import("../infra/device-pairing.js"); await import("./server.js"); let gatewayHarness: Awaited>; diff --git a/src/gateway/server-methods/devices.test.ts b/src/gateway/server-methods/devices.test.ts index bb36f21aade5..096609064e78 100644 --- a/src/gateway/server-methods/devices.test.ts +++ b/src/gateway/server-methods/devices.test.ts @@ -51,15 +51,30 @@ vi.mock("../../infra/device-pairing.js", async () => { ); return { ...actual, - approveDevicePairing: approveDevicePairingMock, getPairedDevice: getPairedDeviceMock, getPendingDevicePairing: getPendingDevicePairingMock, listDevicePairing: listDevicePairingMock, removePairedDevice: removePairedDeviceMock, rejectDevicePairing: rejectDevicePairingMock, + updatePairedDeviceMetadata: updatePairedDeviceMetadataMock, + }; +}); + +vi.mock("../../infra/device-pairing-approval.js", async () => { + const actual = await vi.importActual( + "../../infra/device-pairing-approval.js", + ); + return { ...actual, approveDevicePairing: approveDevicePairingMock }; +}); + +vi.mock("../../infra/device-pairing-tokens.js", async () => { + const actual = await vi.importActual( + "../../infra/device-pairing-tokens.js", + ); + return { + ...actual, revokeDeviceToken: revokeDeviceTokenMock, rotateDeviceToken: rotateDeviceTokenMock, - updatePairedDeviceMetadata: updatePairedDeviceMetadataMock, }; }); diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index b36e681cae68..638e17c28fc1 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -13,17 +13,21 @@ import { import { approveDevicePairing, formatDevicePairingForbiddenMessage, +} from "../../infra/device-pairing-approval.js"; +import { + type RevokeDeviceTokenDenyReason, + type RotateDeviceTokenDenyReason, + revokeDeviceToken, + rotateDeviceToken, + summarizeDeviceTokens, +} from "../../infra/device-pairing-tokens.js"; +import { getPairedDevice, getPendingDevicePairing, listDevicePairing, removePairedDevice, type DeviceAuthToken, - type RevokeDeviceTokenDenyReason, - type RotateDeviceTokenDenyReason, rejectDevicePairing, - revokeDeviceToken, - rotateDeviceToken, - summarizeDeviceTokens, updatePairedDeviceMetadata, } from "../../infra/device-pairing.js"; import type { DiagnosticSecurityEventInput } from "../../infra/diagnostic-events.js"; diff --git a/src/gateway/server-methods/nodes.test.ts b/src/gateway/server-methods/nodes.test.ts index 1ab453bbd5e0..717bb3f9e153 100644 --- a/src/gateway/server-methods/nodes.test.ts +++ b/src/gateway/server-methods/nodes.test.ts @@ -4,6 +4,7 @@ import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES, } from "../../../packages/gateway-protocol/src/client-info.js"; +import { approveDevicePairing } from "../../infra/device-pairing-approval.js"; import { captureNodePairingGeneration, captureNodePairingState, @@ -11,12 +12,10 @@ import { resolveCurrentPairedDeviceNodeBinding, } from "../../infra/device-pairing-node-state.js"; import { approveNodePairing, requestNodePairing } from "../../infra/device-pairing-node.js"; +import { revokeDeviceToken, rotateDeviceToken } from "../../infra/device-pairing-tokens.js"; import { - approveDevicePairing, listDevicePairing, requestDevicePairing, - revokeDeviceToken, - rotateDeviceToken, withPairedDeviceRecords, } from "../../infra/device-pairing.js"; import { diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index 8d07fe1b5fe2..98c3c0b88714 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -240,8 +240,9 @@ describe("gateway auth compatibility baseline", () => { ); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveDevicePairing, requestDevicePairing, rotateDeviceToken } = - await import("../infra/device-pairing.js"); + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { rotateDeviceToken } = await import("../infra/device-pairing-tokens.js"); + const { requestDevicePairing } = await import("../infra/device-pairing.js"); const identity = loadOrCreateDeviceIdentity({ path: identityPath }); const pending = await requestDevicePairing({ @@ -438,8 +439,8 @@ describe("gateway auth compatibility baseline", () => { try { const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveDevicePairing, requestDevicePairing } = - await import("../infra/device-pairing.js"); + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { requestDevicePairing } = await import("../infra/device-pairing.js"); const nonce = await readConnectChallengeNonce(ws); const identityPath = path.join( os.tmpdir(), diff --git a/src/gateway/server.auth.control-ui.bootstrap-lifecycle.suite.ts b/src/gateway/server.auth.control-ui.bootstrap-lifecycle.suite.ts index 7fd1cbf94500..e077936bacb6 100644 --- a/src/gateway/server.auth.control-ui.bootstrap-lifecycle.suite.ts +++ b/src/gateway/server.auth.control-ui.bootstrap-lifecycle.suite.ts @@ -21,8 +21,8 @@ export function registerControlUiBootstrapLifecycleSuite(): void { const { issueDevicePairSetupBootstrapToken, verifyDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveBootstrapDevicePairing, requestDevicePairing } = - await import("../infra/device-pairing.js"); + const { approveBootstrapDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { requestDevicePairing } = await import("../infra/device-pairing.js"); const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } = await import("../shared/device-bootstrap-profile.js"); const { server, port, prevToken } = await startControlUiServer("secret"); @@ -222,7 +222,8 @@ export function registerControlUiBootstrapLifecycleSuite(): void { test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { listDevicePairing } = await import("../infra/device-pairing.js"); const reconcileModule = await import("./node-connect-reconcile.js"); const reconcileSpy = vi .spyOn(reconcileModule, "reconcileNodePairingOnConnect") @@ -301,7 +302,8 @@ export function registerControlUiBootstrapLifecycleSuite(): void { test("requires approval for bootstrap-auth role upgrades on already-paired devices", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { getPairedDevice, listDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { server, port, prevToken } = await startControlUiServer("secret"); diff --git a/src/gateway/server.auth.control-ui.device-token.suite.ts b/src/gateway/server.auth.control-ui.device-token.suite.ts index c5155516d72f..e1feec9c6059 100644 --- a/src/gateway/server.auth.control-ui.device-token.suite.ts +++ b/src/gateway/server.auth.control-ui.device-token.suite.ts @@ -174,7 +174,7 @@ export function registerControlUiDeviceTokenSuite(): void { }); test("rejects revoked device token", async () => { - const { revokeDeviceToken } = await import("../infra/device-pairing.js"); + const { revokeDeviceToken } = await import("../infra/device-pairing-tokens.js"); const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); const { identity, deviceToken, deviceIdentityPath } = await ensurePairedDeviceTokenForCurrentIdentity(ws); diff --git a/src/gateway/server.auth.control-ui.fixtures.test-support.ts b/src/gateway/server.auth.control-ui.fixtures.test-support.ts index a4fbfdea5fb5..815373a01dfb 100644 --- a/src/gateway/server.auth.control-ui.fixtures.test-support.ts +++ b/src/gateway/server.auth.control-ui.fixtures.test-support.ts @@ -122,7 +122,8 @@ export const seedApprovedOperatorReadPairing = async (params: { scopes?: string[]; }): Promise<{ identityPath: string; identity: { deviceId: string } }> => { const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { requestDevicePairing } = await import("../infra/device-pairing.js"); const { identityPath, identity } = await createOperatorIdentityFixture(params.identityPrefix); const scopes = params.scopes ?? ["operator.read"]; const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); diff --git a/src/gateway/server.auth.control-ui.mobile-bootstrap.suite.ts b/src/gateway/server.auth.control-ui.mobile-bootstrap.suite.ts index b189741e1c0d..478c97c21f9f 100644 --- a/src/gateway/server.auth.control-ui.mobile-bootstrap.suite.ts +++ b/src/gateway/server.auth.control-ui.mobile-bootstrap.suite.ts @@ -179,8 +179,8 @@ export function registerControlUiMobileBootstrapSuite(): void { const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } = await import("../shared/device-bootstrap-profile.js"); - const { getPairedDevice, listDevicePairing, verifyDeviceToken } = - await import("../infra/device-pairing.js"); + const { verifyDeviceToken } = await import("../infra/device-pairing-tokens.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity } = await createOperatorIdentityFixture( @@ -433,7 +433,8 @@ export function registerControlUiMobileBootstrapSuite(): void { ]); expect(operatorHandoff?.scopes).not.toContain("operator.admin"); - const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js"); + const { verifyDeviceToken } = await import("../infra/device-pairing-tokens.js"); + const { getPairedDevice } = await import("../infra/device-pairing.js"); const paired = await getPairedDevice(identity.deviceId); expect(paired?.approvedScopes).not.toContain("operator.admin"); expect(paired?.tokens?.operator?.scopes).not.toContain("operator.admin"); diff --git a/src/gateway/server.auth.control-ui.owner-bootstrap.suite.ts b/src/gateway/server.auth.control-ui.owner-bootstrap.suite.ts index b8a05bf2d247..23bf28479f71 100644 --- a/src/gateway/server.auth.control-ui.owner-bootstrap.suite.ts +++ b/src/gateway/server.auth.control-ui.owner-bootstrap.suite.ts @@ -17,8 +17,8 @@ import { export function registerControlUiOwnerBootstrapSuite(): void { test("silently approves host-authorized control ui owner bootstrap tokens", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { getPairedDevice, listDevicePairing, verifyDeviceToken } = - await import("../infra/device-pairing.js"); + const { verifyDeviceToken } = await import("../infra/device-pairing-tokens.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } = await import("../shared/device-bootstrap-profile.js"); const { resolveSharedGatewaySessionGeneration } = diff --git a/src/gateway/server.auth.control-ui.pairing.suite.ts b/src/gateway/server.auth.control-ui.pairing.suite.ts index 4cc7a23c17cb..e86c08098b74 100644 --- a/src/gateway/server.auth.control-ui.pairing.suite.ts +++ b/src/gateway/server.auth.control-ui.pairing.suite.ts @@ -374,7 +374,8 @@ export function registerControlUiPairingSuite(): void { test("allows operator shared auth with legacy paired metadata", async () => { const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { getPairedDevice, listDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); const { identityPath, identity } = await createOperatorIdentityFixture( "openclaw-device-legacy-meta-", diff --git a/src/gateway/server.auth.test-helpers.ts b/src/gateway/server.auth.test-helpers.ts index 27560042b91a..03c7fe4b2a4d 100644 --- a/src/gateway/server.auth.test-helpers.ts +++ b/src/gateway/server.auth.test-helpers.ts @@ -219,7 +219,8 @@ function resolveGatewayTokenOrEnv(): string { } async function approvePendingPairingIfNeeded() { - const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { listDevicePairing } = await import("../infra/device-pairing.js"); const list = await listDevicePairing(); const pending = list.pending.at(0); if (!pending?.requestId) { diff --git a/src/gateway/server.device-pair-approve-supersede.test.ts b/src/gateway/server.device-pair-approve-supersede.test.ts index 26d5a43d6f90..463d3125c153 100644 --- a/src/gateway/server.device-pair-approve-supersede.test.ts +++ b/src/gateway/server.device-pair-approve-supersede.test.ts @@ -2,11 +2,8 @@ * Tests device-pair approval superseding behavior in the gateway server. */ import { describe, expect, test } from "vitest"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, -} from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; import { installGatewayTestHooks } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); diff --git a/src/gateway/server.device-token-rotate-authz.test.ts b/src/gateway/server.device-token-rotate-authz.test.ts index 2894f95cd0a2..e8e8fca93989 100644 --- a/src/gateway/server.device-token-rotate-authz.test.ts +++ b/src/gateway/server.device-token-rotate-authz.test.ts @@ -2,11 +2,8 @@ // rights, approved node reconnects, and invoke continuity after token changes. import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { WebSocket } from "ws"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, -} from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index 01fff95aa1c7..a80061822717 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -287,7 +287,8 @@ describe("node.invoke approval bypass", () => { }); const approveAllPendingPairings = async () => { - const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { listDevicePairing } = await import("../infra/device-pairing.js"); const { approveNodePairing, listNodePairing } = await import("../infra/device-pairing-node.js"); const deviceList = await listDevicePairing(); for (const pending of deviceList.pending) { diff --git a/src/gateway/server.node-pairing-rate-limit.test.ts b/src/gateway/server.node-pairing-rate-limit.test.ts index 5136e27fa507..997027cbd523 100644 --- a/src/gateway/server.node-pairing-rate-limit.test.ts +++ b/src/gateway/server.node-pairing-rate-limit.test.ts @@ -10,12 +10,13 @@ import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, } from "../infra/device-identity.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; import { approveNodePairing, listNodePairing, requestNodePairing, } from "../infra/device-pairing-node.js"; -import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js"; +import { requestDevicePairing } from "../infra/device-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { connectReq, diff --git a/src/gateway/server.node-pairing.test-support.ts b/src/gateway/server.node-pairing.test-support.ts index fea15c99fa2b..277911872c49 100644 --- a/src/gateway/server.node-pairing.test-support.ts +++ b/src/gateway/server.node-pairing.test-support.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe } from "vitest"; -import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { requestDevicePairing } from "../infra/device-pairing.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { startServerWithClient } from "./test-helpers.js"; diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index bc75237fc8a8..6991fd902c14 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -7,8 +7,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import type { DeviceIdentity } from "../infra/device-identity.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; import { approveNodePairing, requestNodePairing } from "../infra/device-pairing-node.js"; -import { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; +import { listDevicePairing } from "../infra/device-pairing.js"; import { readRestartSentinel } from "../infra/restart-sentinel.js"; import { SUPERVISOR_HINT_ENV_VARS } from "../infra/supervisor-markers.js"; import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js"; diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index cf12a8f209fe..19454b4f2874 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -131,7 +131,8 @@ async function emitLifecycleAssistantReply(params: { beforeAll(async () => { envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PORT", "OPENCLAW_GATEWAY_TOKEN"]); gatewayPort = await getGatewayTestPort(); - const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { requestDevicePairing } = await import("../infra/device-pairing.js"); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const identity = loadOrCreateDeviceIdentity(); diff --git a/src/gateway/server.shared-auth-rotation.test.ts b/src/gateway/server.shared-auth-rotation.test.ts index 059f149db33d..5b08954045b6 100644 --- a/src/gateway/server.shared-auth-rotation.test.ts +++ b/src/gateway/server.shared-auth-rotation.test.ts @@ -53,8 +53,10 @@ async function openDeviceTokenWsWithDetails( const identityPath = path.join(os.tmpdir(), `openclaw-shared-auth-${process.pid}-${port}.sqlite`); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { approveDevicePairing, ensureDeviceToken, requestDevicePairing, rotateDeviceToken } = - await import("../infra/device-pairing.js"); + const { approveDevicePairing } = await import("../infra/device-pairing-approval.js"); + const { ensureDeviceToken, rotateDeviceToken } = + await import("../infra/device-pairing-tokens.js"); + const { requestDevicePairing } = await import("../infra/device-pairing.js"); const client = params.browserClient ? { id: "openclaw-control-ui", @@ -209,7 +211,8 @@ async function expectIssuerTaggedDeviceToken(params: { token: string; issuerGeneration: string; }) { - const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js"); + const { verifyDeviceToken } = await import("../infra/device-pairing-tokens.js"); + const { getPairedDevice } = await import("../infra/device-pairing.js"); const paired = await getPairedDevice(params.deviceId); expect(paired?.tokens?.operator?.issuer).toEqual({ kind: "shared-gateway-auth", diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index 9157d1ab8cfb..783df3000321 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -6,12 +6,10 @@ import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, } from "../infra/device-identity.js"; +import * as devicePairingApprovalModule from "../infra/device-pairing-approval.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; import * as devicePairingModule from "../infra/device-pairing.js"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, -} from "../infra/device-pairing.js"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { callGateway } from "./call.js"; import { @@ -305,7 +303,7 @@ describe("gateway silent scope-upgrade reconnect", () => { const loaded = loadDeviceIdentity("silent-reconnect-race"); let ws: WebSocket | undefined; - const approveOriginal = devicePairingModule.approveDevicePairing; + const approveOriginal = devicePairingApprovalModule.approveDevicePairing; let simulatedRace = false; const forwardApprove = async (requestId: string, optionsOrBaseDir?: unknown) => { if (optionsOrBaseDir && typeof optionsOrBaseDir === "object") { @@ -317,7 +315,7 @@ describe("gateway silent scope-upgrade reconnect", () => { return await approveOriginal(requestId); }; const approveSpy = vi - .spyOn(devicePairingModule, "approveDevicePairing") + .spyOn(devicePairingApprovalModule, "approveDevicePairing") .mockImplementation(async (requestId: string, optionsOrBaseDir?: unknown) => { if (simulatedRace) { return await forwardApprove(requestId, optionsOrBaseDir); @@ -353,7 +351,7 @@ describe("gateway silent scope-upgrade reconnect", () => { let ws: WebSocket | undefined; const approveSpy = vi - .spyOn(devicePairingModule, "approveDevicePairing") + .spyOn(devicePairingApprovalModule, "approveDevicePairing") .mockImplementation(async (requestId: string) => { await devicePairingModule.rejectDevicePairing(requestId); return null; @@ -400,7 +398,7 @@ describe("gateway silent scope-upgrade reconnect", () => { let replacementRequestId = ""; const approveSpy = vi - .spyOn(devicePairingModule, "approveDevicePairing") + .spyOn(devicePairingApprovalModule, "approveDevicePairing") .mockImplementation(async (_requestId: string) => { const replacement = await devicePairingModule.requestDevicePairing({ deviceId: loaded.identity.deviceId, diff --git a/src/gateway/server/ws-connection/connect-auth.ts b/src/gateway/server/ws-connection/connect-auth.ts index b8b44d502988..932456ef6059 100644 --- a/src/gateway/server/ws-connection/connect-auth.ts +++ b/src/gateway/server/ws-connection/connect-auth.ts @@ -8,7 +8,7 @@ import { getDeviceBootstrapTokenProfile, verifyDeviceBootstrapToken, } from "../../../infra/device-bootstrap.js"; -import { verifyDeviceToken } from "../../../infra/device-pairing.js"; +import { verifyDeviceToken } from "../../../infra/device-pairing-tokens.js"; import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js"; import type { GatewayAuthResult } from "../../auth.js"; import { formatForLog } from "../../ws-log.js"; diff --git a/src/gateway/server/ws-connection/connect-device-pairing.test.ts b/src/gateway/server/ws-connection/connect-device-pairing.test.ts index d8771de7a702..3f4d72b73cbd 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.test.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.test.ts @@ -6,7 +6,8 @@ import { } from "../../../../packages/gateway-protocol/src/client-info.js"; import { replaceConfigFile } from "../../../config/config.js"; import type { GatewayAuthConfig } from "../../../config/types.gateway.js"; -import { ensureDeviceToken, getPairedDevice } from "../../../infra/device-pairing.js"; +import { ensureDeviceToken } from "../../../infra/device-pairing-tokens.js"; +import { getPairedDevice } from "../../../infra/device-pairing.js"; import { loadDeviceIdentity, openTrackedWs, diff --git a/src/gateway/server/ws-connection/connect-device-pairing.ts b/src/gateway/server/ws-connection/connect-device-pairing.ts index a9909521dbb8..b963be91edc1 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.ts @@ -14,6 +14,8 @@ import { getBoundDeviceBootstrapProfile } from "../../../infra/device-bootstrap. import { approveBootstrapDevicePairing, approveDevicePairing, +} from "../../../infra/device-pairing-approval.js"; +import { getPairedDevice, hasEffectivePairedDeviceRole, listApprovedPairedDeviceRoles, diff --git a/src/gateway/server/ws-connection/connect-device-tokens.ts b/src/gateway/server/ws-connection/connect-device-tokens.ts index 520145c38e0a..ca5e9de01709 100644 --- a/src/gateway/server/ws-connection/connect-device-tokens.ts +++ b/src/gateway/server/ws-connection/connect-device-tokens.ts @@ -1,5 +1,5 @@ // Gateway WebSocket device authorization issues the session and bootstrap handoff tokens. -import { ensureDeviceToken } from "../../../infra/device-pairing.js"; +import { ensureDeviceToken } from "../../../infra/device-pairing-tokens.js"; import { resolveBootstrapProfileScopesForRole } from "../../../shared/device-bootstrap-profile.js"; import type { AuthenticatedGatewayConnect, diff --git a/src/gateway/server/ws-connection/connect-node-pairing-ssh.ts b/src/gateway/server/ws-connection/connect-node-pairing-ssh.ts index 1f1989da0fb2..83933277aa57 100644 --- a/src/gateway/server/ws-connection/connect-node-pairing-ssh.ts +++ b/src/gateway/server/ws-connection/connect-node-pairing-ssh.ts @@ -1,6 +1,7 @@ import type { ConnectPairingRequiredReason } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; // Gateway WebSocket node pairing can finish a fresh capability-free request over SSH. -import { approveDevicePairing, getPairedDevice } from "../../../infra/device-pairing.js"; +import { approveDevicePairing } from "../../../infra/device-pairing-approval.js"; +import { getPairedDevice } from "../../../infra/device-pairing.js"; import { planNodePairingSshVerify, startNodePairingSshVerify, diff --git a/src/gateway/startup-local-cli-pairing.test.ts b/src/gateway/startup-local-cli-pairing.test.ts index 6a1510e3b703..09200ab39520 100644 --- a/src/gateway/startup-local-cli-pairing.test.ts +++ b/src/gateway/startup-local-cli-pairing.test.ts @@ -4,11 +4,8 @@ import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, } from "../infra/device-identity.js"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, -} from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { READ_SCOPE } from "./operator-scopes.js"; diff --git a/src/gateway/startup-local-cli-pairing.ts b/src/gateway/startup-local-cli-pairing.ts index 52c8588a47f2..bb1c2a7fb663 100644 --- a/src/gateway/startup-local-cli-pairing.ts +++ b/src/gateway/startup-local-cli-pairing.ts @@ -7,11 +7,8 @@ import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, } from "../infra/device-identity.js"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, -} from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { ADMIN_SCOPE } from "./operator-scopes.js"; diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 2eca8ae78769..d4458710f758 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -25,11 +25,8 @@ import { publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; -import { - approveDevicePairing, - getPairedDevice, - requestDevicePairing, -} from "../infra/device-pairing.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; +import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js"; import { resetGatewaySuspendCoordinatorForLifecycleRestart } from "../infra/gateway-suspend-coordinator.js"; import { resetGatewayRestartStateForInProcessRestart, diff --git a/src/gateway/watch-node-http.test.ts b/src/gateway/watch-node-http.test.ts index 8cc3849eccf5..8dca4c872840 100644 --- a/src/gateway/watch-node-http.test.ts +++ b/src/gateway/watch-node-http.test.ts @@ -23,14 +23,14 @@ import { publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; +import { approveDevicePairing } from "../infra/device-pairing-approval.js"; import { listNodePairing } from "../infra/device-pairing-node.js"; import { loadDevicePairSetupCompletionRecord } from "../infra/device-pairing-store.js"; +import { revokeDeviceToken } from "../infra/device-pairing-tokens.js"; import { - approveDevicePairing, getPairedDevice, requestDevicePairing, resolveNodePairingState, - revokeDeviceToken, } from "../infra/device-pairing.js"; import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; diff --git a/src/gateway/watch-node-http.ts b/src/gateway/watch-node-http.ts index 6a36be896062..b407b0ea78f5 100644 --- a/src/gateway/watch-node-http.ts +++ b/src/gateway/watch-node-http.ts @@ -24,6 +24,7 @@ import { deriveDeviceIdFromPublicKey, normalizeDevicePublicKeyBase64Url, } from "../infra/device-identity.js"; +import { approveBootstrapDevicePairing } from "../infra/device-pairing-approval.js"; import { captureAuthenticatedNodePairingState } from "../infra/device-pairing-node-state.js"; import { approveNodePairing, @@ -35,13 +36,11 @@ import { recordPairedNodeDisconnection, type RequestNodePairingResult, } from "../infra/device-pairing-node.js"; +import { ensureDeviceToken, verifyDeviceToken } from "../infra/device-pairing-tokens.js"; import { - approveBootstrapDevicePairing, - ensureDeviceToken, getPairedDevice, requestDevicePairing, resolveNodePairingState, - verifyDeviceToken, } from "../infra/device-pairing.js"; import { pruneMapToMaxSize } from "../infra/map-size.js"; import { isNodePairingSetupBootstrapProfile } from "../shared/device-bootstrap-profile.js"; diff --git a/src/infra/device-pairing-approval.ts b/src/infra/device-pairing-approval.ts new file mode 100644 index 000000000000..bd0b1e95c033 --- /dev/null +++ b/src/infra/device-pairing-approval.ts @@ -0,0 +1,503 @@ +// Owner and bootstrap approval flows for pending device pairing requests. +import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; +import { + resolveDeviceProfileRoleScopes, + resolveDeviceProfileScopes, + type DeviceBootstrapProfile, +} from "../shared/device-bootstrap-profile.js"; +import { + resolveMissingRequestedScope, + resolveScopeOutsideRequestedRoles, +} from "../shared/operator-scope-compat.js"; +import { + loadDevicePairingState, + mergeDevicePairingRoles, + mergeDevicePairingScopes, + preserveDeviceRoleScopes, + resolveRequestedDeviceRoles, + sameDevicePairingStringSet, + withDevicePairingLock, +} from "./device-pairing-state.js"; +import { persistDevicePairingStoreState as persistState } from "./device-pairing-store.js"; +import { createDeviceAuthToken, resolveRoleTokenScopes } from "./device-pairing-tokens.js"; +import { clearNodePairingGenerationBins, resolveNodePairingGeneration } from "./device-pairing.js"; +import type { + DeviceAuthToken, + DevicePairingPendingRequest, + PairedDevice, + PairedDeviceApprovalKind, +} from "./device-pairing.types.js"; +import { generatePairingToken } from "./pairing-token.js"; + +const OPERATOR_ROLE = "operator"; +const OPERATOR_SCOPE_PREFIX = "operator."; + +/** Paired-device access metadata refreshed when an existing device reconnects. */ +type DevicePairingAccessMetadata = Pick< + PairedDevice, + "displayName" | "remoteIp" | "lastSeenAtMs" | "lastSeenReason" +>; + +/** Authorization failure categories for owner approval and bootstrap approval flows. */ +type DevicePairingForbiddenReason = + | "caller-scopes-required" + | "caller-missing-scope" + | "scope-outside-requested-roles" + | "bootstrap-role-not-allowed" + | "bootstrap-scope-not-allowed"; + +/** Structured forbidden result with the missing/disallowed role or scope when known. */ +type DevicePairingForbiddenResult = { + status: "forbidden"; + reason: DevicePairingForbiddenReason; + scope?: string; + role?: string; +}; + +/** Pairing approval outcome: approved, forbidden with reason, or request not found. */ +type ApproveDevicePairingResult = + | { + status: "approved"; + requestId: string; + device: PairedDevice; + /** Existing connected node transports must be retired before success is returned. */ + nodePairingGenerationChanged?: true; + } + | DevicePairingForbiddenResult + | null; + +/** Format a device-pairing authorization failure for CLI/API callers. */ +export function formatDevicePairingForbiddenMessage(result: DevicePairingForbiddenResult): string { + switch (result.reason) { + case "caller-scopes-required": + return `missing scope: ${result.scope ?? "callerScopes-required"}`; + case "caller-missing-scope": + return `missing scope: ${result.scope ?? "unknown"}`; + case "scope-outside-requested-roles": + return `invalid scope for requested roles: ${result.scope ?? "unknown"}`; + case "bootstrap-role-not-allowed": + return `bootstrap profile does not allow role: ${result.role ?? "unknown"}`; + case "bootstrap-scope-not-allowed": + return `bootstrap profile does not allow scope: ${result.scope ?? "unknown"}`; + } + throw new Error("Unsupported device pairing forbidden reason"); +} + +// Interactive approvals must stay sticky: a later silent repair/re-approve of the +// same device id cannot downgrade an owner/bootstrap record into prune-eligible +// state. Pre-provenance records (approvedVia undefined) may have been approved by +// an owner, so a non-interactive re-approve must keep them protected (undefined). +function mergeApprovalKind( + existing: PairedDevice | undefined, + incoming: PairedDeviceApprovalKind, +): PairedDeviceApprovalKind | undefined { + if (incoming === "owner" || !existing) { + return incoming; + } + if (existing.approvedVia === undefined) { + return incoming === "bootstrap" ? "bootstrap" : undefined; + } + if (existing.approvedVia === "owner" || existing.approvedVia === "bootstrap") { + return existing.approvedVia; + } + return incoming; +} + +function buildApprovedPairedDevice(params: { + pending: DevicePairingPendingRequest; + existing: PairedDevice | undefined; + roles: string[] | undefined; + approvedScopes: string[] | undefined; + tokens: Record; + now: number; + approvedVia: PairedDeviceApprovalKind; + accessMetadata?: DevicePairingAccessMetadata; +}): PairedDevice { + return { + deviceId: params.pending.deviceId, + publicKey: params.pending.publicKey, + displayName: params.accessMetadata?.displayName ?? params.pending.displayName, + platform: params.pending.platform, + deviceFamily: params.pending.deviceFamily, + clientId: params.pending.clientId, + clientMode: params.pending.clientMode, + browserOrigin: params.pending.browserOrigin, + role: params.pending.role, + roles: params.roles, + scopes: params.approvedScopes, + approvedScopes: params.approvedScopes, + remoteIp: params.accessMetadata?.remoteIp ?? params.pending.remoteIp, + tokens: params.tokens, + approvedVia: mergeApprovalKind(params.existing, params.approvedVia), + // Node capability approvals ride on the device record; device repair or + // role re-approval must not silently revoke an approved node surface. + ...(params.existing?.nodeSurface ? { nodeSurface: params.existing.nodeSurface } : {}), + ...(params.existing?.pendingNodeSurface + ? { pendingNodeSurface: params.existing.pendingNodeSurface } + : {}), + // Operator-assigned label is owner-side state; device repair or role + // re-approval must not silently drop it. + ...(params.existing?.operatorLabel ? { operatorLabel: params.existing.operatorLabel } : {}), + createdAtMs: params.existing?.createdAtMs ?? params.now, + approvedAtMs: params.now, + lastSeenAtMs: params.accessMetadata?.lastSeenAtMs ?? params.existing?.lastSeenAtMs, + lastSeenReason: params.accessMetadata?.lastSeenReason ?? params.existing?.lastSeenReason, + }; +} + +function resolveApprovedTokenScopes(params: { + role: string; + pending: DevicePairingPendingRequest; + existingToken?: DeviceAuthToken; + approvedScopes?: string[]; + existing?: PairedDevice; +}): string[] { + const pendingScopes = resolveRoleTokenScopes(params.role, params.pending.scopes); + if (pendingScopes.length > 0) { + const approvedBaseline = resolveRoleTokenScopes( + params.role, + params.existing?.approvedScopes ?? params.existing?.scopes, + ); + const requestedScopeDelta = + params.existingToken && approvedBaseline.length > 0 + ? pendingScopes.filter((scope) => !approvedBaseline.includes(scope)) + : pendingScopes; + if (requestedScopeDelta.length === 0 && params.existingToken) { + return resolveRoleTokenScopes(params.role, params.existingToken.scopes); + } + return resolveRoleTokenScopes( + params.role, + mergeDevicePairingScopes(params.existingToken?.scopes, requestedScopeDelta), + ); + } + return resolveRoleTokenScopes( + params.role, + params.existingToken?.scopes ?? + params.approvedScopes ?? + params.existing?.approvedScopes ?? + params.existing?.scopes, + ); +} + +/** Approve a pending request with optional caller-scope checks for operator grants. */ +export async function approveDevicePairing( + requestId: string, + baseDir?: string, +): Promise; +export async function approveDevicePairing( + requestId: string, + options: { + callerScopes?: readonly string[]; + accessMetadata?: DevicePairingAccessMetadata; + approvedVia?: Extract< + PairedDeviceApprovalKind, + "owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified" + >; + /** + * Replace the pending scopes only for a brand-new operator device, or — under + * trusted-proxy approval — for a known operator device re-requesting with its + * already-paired public key. The live role set is rechecked under the pairing + * lock so a merged request cannot inherit non-operator access through browser + * auto-approval. + */ + autoApproveNewDeviceScopes?: readonly string[]; + }, + baseDir?: string, +): Promise; +export async function approveDevicePairing( + requestId: string, + optionsOrBaseDir?: + | { + callerScopes?: readonly string[]; + accessMetadata?: DevicePairingAccessMetadata; + approvedVia?: Extract< + PairedDeviceApprovalKind, + "owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified" + >; + autoApproveNewDeviceScopes?: readonly string[]; + } + | string, + maybeBaseDir?: string, +): Promise { + const options = + typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined + ? undefined + : optionsOrBaseDir; + const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir; + return await approveDevicePairingWithOptions(requestId, options, baseDir); +} + +async function approveDevicePairingWithOptions( + requestId: string, + options: + | { + callerScopes?: readonly string[]; + accessMetadata?: DevicePairingAccessMetadata; + approvedVia?: Extract< + PairedDeviceApprovalKind, + "owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified" + >; + autoApproveNewDeviceScopes?: readonly string[]; + } + | undefined, + baseDir?: string, +): Promise { + return await withDevicePairingLock(async () => { + const state = await loadDevicePairingState(baseDir); + const pendingRecord = state.pendingById[requestId]; + if (!pendingRecord) { + return null; + } + const autoApproveScopes = options?.autoApproveNewDeviceScopes; + const requestedRoles = resolveRequestedDeviceRoles(pendingRecord); + const knownDevice = state.pairedByDeviceId[pendingRecord.deviceId]; + // Trusted-proxy connects carry an SSO-authenticated user, and the connect + // handshake has already proven possession of the pending public key. A + // matching key on the paired record is therefore the same physical device + // re-requesting (typically a scope upgrade) and may auto-approve; a key + // mismatch is a real repair — possibly a deviceId squat — and stays a + // manual owner decision. + const trustedProxySameKeyDevice = + options?.approvedVia === "trusted-proxy" && + knownDevice !== undefined && + knownDevice.publicKey === pendingRecord.publicKey; + if ( + autoApproveScopes && + (((pendingRecord.isRepair || knownDevice) && !trustedProxySameKeyDevice) || + !sameDevicePairingStringSet(requestedRoles, [OPERATOR_ROLE])) + ) { + return null; + } + const pending = autoApproveScopes + ? { ...pendingRecord, scopes: [...autoApproveScopes] } + : pendingRecord; + const requestedScopes = normalizeDeviceAuthScopes(pending.scopes); + const roleMismatchScope = resolveScopeOutsideRequestedRoles({ + requestedRoles, + requestedScopes, + }); + if (roleMismatchScope) { + return { + status: "forbidden", + reason: "scope-outside-requested-roles", + scope: roleMismatchScope, + }; + } + const now = Date.now(); + const existing = state.pairedByDeviceId[pending.deviceId]; + const roles = mergeDevicePairingRoles( + existing?.roles, + existing?.role, + pending.roles, + pending.role, + ); + const approvedScopes = mergeDevicePairingScopes( + existing?.approvedScopes ?? existing?.scopes, + pending.scopes, + ); + const previousNodeGeneration = resolveNodePairingGeneration(existing ?? null); + const tokens = existing?.tokens ? { ...existing.tokens } : {}; + const nextTokenScopesByRole = new Map(); + for (const roleForToken of requestedRoles) { + const existingToken = tokens[roleForToken]; + const nextScopes = resolveApprovedTokenScopes({ + role: roleForToken, + pending, + existingToken, + approvedScopes, + existing, + }); + nextTokenScopesByRole.set(roleForToken, nextScopes); + if (roleForToken === OPERATOR_ROLE && nextScopes.length > 0) { + const callerRequiredScopes = + mergeDevicePairingScopes( + resolveRoleTokenScopes(roleForToken, pending.scopes), + nextScopes, + ) ?? nextScopes; + if (!options?.callerScopes) { + return { + status: "forbidden", + reason: "caller-scopes-required", + scope: callerRequiredScopes[0], + }; + } + const missingScope = resolveMissingRequestedScope({ + role: OPERATOR_ROLE, + requestedScopes: callerRequiredScopes, + allowedScopes: options.callerScopes, + }); + if (missingScope) { + return { status: "forbidden", reason: "caller-missing-scope", scope: missingScope }; + } + } + } + for (const [roleForToken, nextScopes] of nextTokenScopesByRole) { + const existingToken = tokens[roleForToken]; + const tokenNow = Date.now(); + tokens[roleForToken] = { + token: generatePairingToken(), + role: roleForToken, + scopes: nextScopes, + createdAtMs: existingToken?.createdAtMs ?? tokenNow, + rotatedAtMs: existingToken ? tokenNow : undefined, + revokedAtMs: undefined, + lastUsedAtMs: existingToken?.lastUsedAtMs, + }; + } + const device = buildApprovedPairedDevice({ + pending, + existing, + roles, + approvedScopes, + tokens, + now, + approvedVia: options?.approvedVia ?? "owner", + accessMetadata: options?.accessMetadata, + }); + const nextNodeGeneration = resolveNodePairingGeneration(device); + const nodePairingGenerationChanged = Boolean( + previousNodeGeneration && previousNodeGeneration.key !== nextNodeGeneration?.key, + ); + clearNodePairingGenerationBins(device, previousNodeGeneration); + const installationIdentityChanged = Boolean( + existing && existing.publicKey !== device.publicKey, + ); + delete state.pendingById[requestId]; + state.pairedByDeviceId[device.deviceId] = device; + persistState( + state, + baseDir, + "both", + installationIdentityChanged ? { clearApnsNodeIds: [device.deviceId] } : undefined, + ); + return { + status: "approved", + requestId, + device, + ...(nodePairingGenerationChanged ? { nodePairingGenerationChanged: true as const } : {}), + }; + }); +} + +/** Approve a pending request through a bounded bootstrap profile handoff. */ +export async function approveBootstrapDevicePairing( + requestId: string, + bootstrapProfile: DeviceBootstrapProfile, + baseDir?: string, +): Promise; +export async function approveBootstrapDevicePairing( + requestId: string, + bootstrapProfile: DeviceBootstrapProfile, + options: { accessMetadata?: DevicePairingAccessMetadata }, + baseDir?: string, +): Promise; +export async function approveBootstrapDevicePairing( + requestId: string, + bootstrapProfile: DeviceBootstrapProfile, + optionsOrBaseDir?: { accessMetadata?: DevicePairingAccessMetadata } | string, + maybeBaseDir?: string, +): Promise { + const options = + typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined + ? undefined + : optionsOrBaseDir; + const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir; + const approvedRoles = mergeDevicePairingRoles(bootstrapProfile.roles) ?? []; + const approvedScopes = resolveDeviceProfileScopes(bootstrapProfile, approvedRoles); + return await withDevicePairingLock(async () => { + const state = await loadDevicePairingState(baseDir); + const pending = state.pendingById[requestId]; + if (!pending) { + return null; + } + const requestedRoles = resolveRequestedDeviceRoles(pending); + const missingRole = requestedRoles.find((role) => !approvedRoles.includes(role)); + if (missingRole) { + return { status: "forbidden", reason: "bootstrap-role-not-allowed", role: missingRole }; + } + const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) => + scope.startsWith(OPERATOR_SCOPE_PREFIX), + ); + const missingScope = resolveMissingRequestedScope({ + role: OPERATOR_ROLE, + requestedScopes: requestedOperatorScopes, + allowedScopes: approvedScopes, + }); + if (missingScope) { + return { status: "forbidden", reason: "bootstrap-scope-not-allowed", scope: missingScope }; + } + + const now = Date.now(); + const existing = state.pairedByDeviceId[pending.deviceId]; + const grantedRoles = requestedRoles; + const grantedScopes = resolveDeviceProfileScopes( + bootstrapProfile, + grantedRoles, + pending.scopes ?? [], + ); + const grantedRoleSet = new Set(grantedRoles); + const preservedExistingScopes = ( + mergeDevicePairingRoles(existing?.roles, existing?.role) ?? [] + ).flatMap((existingRole) => + grantedRoleSet.has(existingRole) + ? [] + : preserveDeviceRoleScopes(existingRole, existing?.approvedScopes ?? existing?.scopes), + ); + const roles = mergeDevicePairingRoles( + existing?.roles, + existing?.role, + pending.roles, + pending.role, + ); + const nextApprovedScopes = mergeDevicePairingScopes(preservedExistingScopes, grantedScopes); + const previousNodeGeneration = resolveNodePairingGeneration(existing ?? null); + const tokens = existing?.tokens ? { ...existing.tokens } : {}; + for (const roleForToken of grantedRoles) { + const existingToken = tokens[roleForToken]; + const tokenScopes = + roleForToken === OPERATOR_ROLE + ? resolveDeviceProfileRoleScopes(bootstrapProfile, roleForToken, grantedScopes) + : []; + tokens[roleForToken] = createDeviceAuthToken({ + role: roleForToken, + scopes: tokenScopes, + existing: existingToken, + now, + ...(existingToken ? { rotatedAtMs: now } : {}), + }); + } + + const device = buildApprovedPairedDevice({ + pending, + existing, + roles, + approvedScopes: nextApprovedScopes, + tokens, + now, + approvedVia: "bootstrap", + accessMetadata: options?.accessMetadata, + }); + const nextNodeGeneration = resolveNodePairingGeneration(device); + const nodePairingGenerationChanged = Boolean( + previousNodeGeneration && previousNodeGeneration.key !== nextNodeGeneration?.key, + ); + clearNodePairingGenerationBins(device, previousNodeGeneration); + const installationIdentityChanged = Boolean( + existing && existing.publicKey !== device.publicKey, + ); + delete state.pendingById[requestId]; + state.pairedByDeviceId[device.deviceId] = device; + persistState( + state, + baseDir, + "both", + installationIdentityChanged ? { clearApnsNodeIds: [device.deviceId] } : undefined, + ); + return { + status: "approved", + requestId, + device, + ...(nodePairingGenerationChanged ? { nodePairingGenerationChanged: true as const } : {}), + }; + }); +} diff --git a/src/infra/device-pairing-churn.test.ts b/src/infra/device-pairing-churn.test.ts index 7f841a2ca5ba..00c015d82232 100644 --- a/src/infra/device-pairing-churn.test.ts +++ b/src/infra/device-pairing-churn.test.ts @@ -2,8 +2,8 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { approveDevicePairing } from "./device-pairing-approval.js"; import { - approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing, diff --git a/src/infra/device-pairing-migration.test.ts b/src/infra/device-pairing-migration.test.ts index 18a793205fbf..f3011fc87fb2 100644 --- a/src/infra/device-pairing-migration.test.ts +++ b/src/infra/device-pairing-migration.test.ts @@ -4,9 +4,9 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { approveDevicePairing } from "./device-pairing-approval.js"; import { migrateLegacyDevicePairingStore } from "./device-pairing-migration.js"; import { - approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing, diff --git a/src/infra/device-pairing-node.test.ts b/src/infra/device-pairing-node.test.ts index 7b3ab2bedfff..17beabf7e35e 100644 --- a/src/infra/device-pairing-node.test.ts +++ b/src/infra/device-pairing-node.test.ts @@ -3,6 +3,7 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { createDeferred } from "../../test/helpers/promise.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { approveDevicePairing } from "./device-pairing-approval.js"; import { approveNodePairing, beginNodePairingConnect, @@ -17,7 +18,6 @@ import { updatePairedNodeBins, } from "./device-pairing-node.js"; import { - approveDevicePairing, getPairedDevice, requestDevicePairing, resolveNodePairingGeneration, diff --git a/src/infra/device-pairing-prune.test.ts b/src/infra/device-pairing-prune.test.ts index 446c49b2d927..0046693c663d 100644 --- a/src/infra/device-pairing-prune.test.ts +++ b/src/infra/device-pairing-prune.test.ts @@ -2,9 +2,8 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { approveBootstrapDevicePairing, approveDevicePairing } from "./device-pairing-approval.js"; import { - approveBootstrapDevicePairing, - approveDevicePairing, getPairedDevice, listDevicePairing, pruneSupersededSilentPairedDevices, diff --git a/src/infra/device-pairing-state.ts b/src/infra/device-pairing-state.ts new file mode 100644 index 000000000000..525dd6f94e2a --- /dev/null +++ b/src/infra/device-pairing-state.ts @@ -0,0 +1,180 @@ +// Shared snapshot, lock, and normalization owner for device pairing domain modules. +import { expectDefined } from "@openclaw/normalization-core"; +import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; +import { loadDevicePairingStoreStateReadOnly } from "./device-pairing-store-readonly.js"; +import { + loadDevicePairingStoreState, + type DevicePairingStoreState, +} from "./device-pairing-store.js"; +import type { DeviceAuthToken, PairedDevice } from "./device-pairing.types.js"; +import { createAsyncLock, pruneExpiredPending } from "./pairing-files.js"; + +const PAIRING_PENDING_TTL_MS = 5 * 60 * 1000; +const withLock = createAsyncLock(); + +function pruneExpiredPairingState(state: DevicePairingStoreState): void { + const now = Date.now(); + pruneExpiredPending(state.pendingById, now, PAIRING_PENDING_TTL_MS); + // Pending node-surface requests share the pairing TTL; requests refresh + // their ts on reconnect so an actively retrying node keeps one alive. + for (const device of Object.values(state.pairedByDeviceId)) { + if (device.pendingNodeSurface && now - device.pendingNodeSurface.ts > PAIRING_PENDING_TTL_MS) { + delete device.pendingNodeSurface; + } + } +} + +/** Run one pairing mutation under the process-wide device pairing lock. */ +export async function withDevicePairingLock(operate: () => Promise): Promise { + return await withLock(operate); +} + +/** Load one mutable pairing snapshot with expired pending state removed. */ +export async function loadDevicePairingState(baseDir?: string): Promise { + const state = loadDevicePairingStoreState(baseDir); + pruneExpiredPairingState(state); + return state; +} + +/** Load one read-only pairing snapshot with expired pending state removed. */ +export async function loadDevicePairingStateReadOnly( + baseDir?: string, +): Promise { + const state = loadDevicePairingStoreStateReadOnly(baseDir); + pruneExpiredPairingState(state); + return state; +} + +/** Return whether one pending pairing timestamp is beyond the shared TTL. */ +export function isPairingRequestExpired(timestampMs: number, nowMs = Date.now()): boolean { + return nowMs - timestampMs > PAIRING_PENDING_TTL_MS; +} + +/** Resolve the expiry timestamp for one pending pairing request. */ +export function resolvePairingRequestExpiry(timestampMs: number): number { + return timestampMs + PAIRING_PENDING_TTL_MS; +} + +/** Normalize a device id at pairing state boundaries. */ +export function normalizeDevicePairingId(deviceId: string) { + return deviceId.trim(); +} + +/** Normalize one requested or approved pairing role. */ +export function normalizeDevicePairingRole(role: string | undefined): string | null { + const trimmed = role?.trim(); + return trimmed ? trimmed : null; +} + +/** Merge pairing roles while preserving first-seen order. */ +export function mergeDevicePairingRoles( + ...items: Array +): string[] | undefined { + const roles = new Set(); + for (const item of items) { + for (const role of normalizeUniqueSingleOrTrimmedStringList(item)) { + roles.add(role); + } + } + if (roles.size === 0) { + return undefined; + } + return [...roles]; +} + +/** Merge pairing scopes while preserving first-seen order and explicit emptiness. */ +export function mergeDevicePairingScopes( + ...items: Array +): string[] | undefined { + const scopes = new Set(); + let sawExplicitScopeList = false; + for (const item of items) { + if (!Array.isArray(item)) { + continue; + } + sawExplicitScopeList = true; + for (const scope of normalizeUniqueSingleOrTrimmedStringList(item)) { + scopes.add(scope); + } + } + if (scopes.size === 0) { + return sawExplicitScopeList ? [] : undefined; + } + return [...scopes]; +} + +/** Preserve only approval scopes owned by one pairing role. */ +export function preserveDeviceRoleScopes(role: string, scopes: string[] | undefined): string[] { + return normalizeUniqueSingleOrTrimmedStringList(scopes).filter((scope) => + role === "operator" ? scope.startsWith("operator.") : !scope.startsWith("operator."), + ); +} + +/** Compare pairing role or scope lists as unordered sets. */ +export function sameDevicePairingStringSet( + left: readonly string[], + right: readonly string[], +): boolean { + if (left.length !== right.length) { + return false; + } + const rightSet = new Set(right); + for (const value of left) { + if (!rightSet.has(value)) { + return false; + } + } + return true; +} + +/** Resolve the normalized role set requested by a pairing record. */ +export function resolveRequestedDeviceRoles(input: { role?: string; roles?: string[] }): string[] { + return mergeDevicePairingRoles(input.roles, input.role) ?? []; +} + +/** Clone a paired device's role-token map before mutation. */ +export function cloneDevicePairingTokens(device: PairedDevice): Record { + return device.tokens ? { ...device.tokens } : {}; +} + +/** Refresh one compatible pending request or replace a superseded request set atomically. */ +export function reconcilePendingPairingRequests< + TPending extends { requestId: string }, + TIncoming, +>(params: { + pendingById: Record; + existing: readonly TPending[]; + incoming: TIncoming; + canRefreshSingle: (existing: TPending, incoming: TIncoming) => boolean; + refreshSingle: (existing: TPending, incoming: TIncoming) => TPending; + buildReplacement: (params: { existing: readonly TPending[]; incoming: TIncoming }) => TPending; + persist: () => void; +}): { status: "pending"; request: TPending; created: boolean } { + if ( + params.existing.length === 1 && + params.canRefreshSingle( + expectDefined(params.existing[0], "existing entry at 0"), + params.incoming, + ) + ) { + const refreshed = params.refreshSingle( + expectDefined(params.existing[0], "existing entry at 0"), + params.incoming, + ); + params.pendingById[refreshed.requestId] = refreshed; + params.persist(); + return { status: "pending", request: refreshed, created: false }; + } + + for (const existing of params.existing) { + delete params.pendingById[existing.requestId]; + } + + const request = params.buildReplacement({ + existing: params.existing, + incoming: params.incoming, + }); + params.pendingById[request.requestId] = request; + params.persist(); + return { status: "pending", request, created: true }; +} diff --git a/src/infra/device-pairing-tokens.ts b/src/infra/device-pairing-tokens.ts new file mode 100644 index 000000000000..f868cd2cfc4b --- /dev/null +++ b/src/infra/device-pairing-tokens.ts @@ -0,0 +1,421 @@ +// Device token issuance, verification, rotation, and revocation for paired devices. +import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; +import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js"; +import { + cloneDevicePairingTokens, + loadDevicePairingState, + normalizeDevicePairingId, + normalizeDevicePairingRole, + withDevicePairingLock, +} from "./device-pairing-state.js"; +import { + persistDevicePairingStoreState as persistState, + type DevicePairingStoreState, +} from "./device-pairing-store.js"; +import { + clearNodePairingGenerationBins, + listApprovedPairedDeviceRoles, + resolveNodePairingGeneration, +} from "./device-pairing.js"; +import type { DeviceAuthToken, PairedDevice } from "./device-pairing.types.js"; +import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; + +const OPERATOR_SCOPE_PREFIX = "operator."; +const SHARED_GATEWAY_AUTH_ISSUER_KIND = "shared-gateway-auth"; +const BROWSER_DEVICE_CLIENT_IDS = new Set(["openclaw-control-ui", "webchat-ui"]); +const BROWSER_DEVICE_CLIENT_MODE = "webchat"; + +/** Redacted token metadata safe for list/status responses. */ +export type DeviceAuthTokenSummary = { + role: string; + scopes: string[]; + createdAtMs: number; + rotatedAtMs?: number; + revokedAtMs?: number; + lastUsedAtMs?: number; +}; + +/** Deny reasons returned when rotating an existing paired-device token. */ +export type RotateDeviceTokenDenyReason = + | "unknown-device-or-role" + | "missing-approved-scope-baseline" + | "scope-outside-approved-baseline" + | "caller-missing-scope"; + +/** Token rotation result with the replacement token entry on success. */ +type RotateDeviceTokenResult = + | { ok: true; entry: DeviceAuthToken } + | { ok: false; reason: RotateDeviceTokenDenyReason; scope?: string }; + +export type RevokeDeviceTokenDenyReason = "unknown-device-or-role" | "caller-missing-scope"; + +/** Token revocation result with the revoked entry on success. */ +type RevokeDeviceTokenResult = + | { ok: true; entry: DeviceAuthToken } + | { ok: false; reason: RevokeDeviceTokenDenyReason; scope?: string }; + +function getPairedDeviceFromState( + state: DevicePairingStoreState, + deviceId: string, +): PairedDevice | null { + return state.pairedByDeviceId[normalizeDevicePairingId(deviceId)] ?? null; +} + +function isBrowserRelatedPairedDevice(device: Pick) { + const clientMode = device.clientMode?.trim().toLowerCase(); + if (clientMode === BROWSER_DEVICE_CLIENT_MODE) { + return true; + } + const clientId = device.clientId?.trim().toLowerCase(); + return clientId ? BROWSER_DEVICE_CLIENT_IDS.has(clientId) : false; +} + +function deviceTokenIssuerMatches( + entry: DeviceAuthToken, + issuer: DeviceAuthToken["issuer"] | undefined, +): boolean { + if (!issuer) { + return !entry.issuer; + } + return entry.issuer?.kind === issuer.kind && entry.issuer.generation === issuer.generation; +} + +/** Build one freshly generated role token while preserving requested lifecycle fields. */ +export function createDeviceAuthToken(params: { + role: string; + scopes: string[]; + issuer?: DeviceAuthToken["issuer"]; + existing?: DeviceAuthToken; + preserveExistingIssuer?: boolean; + now: number; + rotatedAtMs?: number; +}): DeviceAuthToken { + return { + token: generatePairingToken(), + role: params.role, + scopes: params.scopes, + issuer: params.issuer ?? (params.preserveExistingIssuer ? params.existing?.issuer : undefined), + createdAtMs: params.existing?.createdAtMs ?? params.now, + rotatedAtMs: params.rotatedAtMs, + revokedAtMs: undefined, + lastUsedAtMs: params.existing?.lastUsedAtMs, + }; +} + +/** Select scopes owned by one device-token role. */ +export function resolveRoleTokenScopes(role: string, scopes: string[] | undefined): string[] { + const normalized = normalizeDeviceAuthScopes(scopes); + if (role === "operator") { + return normalized.filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX)); + } + return normalized.filter((scope) => !scope.startsWith(OPERATOR_SCOPE_PREFIX)); +} + +function resolveApprovedDeviceScopeBaseline(device: PairedDevice): string[] | null { + const baseline = device.approvedScopes ?? device.scopes; + if (!Array.isArray(baseline)) { + return null; + } + return normalizeDeviceAuthScopes(baseline); +} + +function scopesWithinApprovedDeviceBaseline(params: { + role: string; + scopes: readonly string[]; + approvedScopes: readonly string[] | null; +}): boolean { + if (!params.approvedScopes) { + return false; + } + return roleScopesAllow({ + role: params.role, + requestedScopes: params.scopes, + allowedScopes: params.approvedScopes, + }); +} + +/** Summarize token metadata without exposing bearer token strings. */ +export function summarizeDeviceTokens( + tokens: Record | undefined, +): DeviceAuthTokenSummary[] | undefined { + if (!tokens) { + return undefined; + } + const summaries = Object.values(tokens) + .map((token) => ({ + role: token.role, + scopes: token.scopes, + createdAtMs: token.createdAtMs, + rotatedAtMs: token.rotatedAtMs, + revokedAtMs: token.revokedAtMs, + lastUsedAtMs: token.lastUsedAtMs, + })) + .toSorted((a, b) => a.role.localeCompare(b.role)); + return summaries.length > 0 ? summaries : undefined; +} + +/** Verify a device role token, scope it to the approval baseline, and mark last use. */ +export async function verifyDeviceToken(params: { + deviceId: string; + token: string; + role: string; + scopes: string[]; + requiredSharedGatewaySessionGeneration?: string; + baseDir?: string; +}): Promise<{ ok: boolean; reason?: string; issuer?: DeviceAuthToken["issuer"] }> { + return await withDevicePairingLock(async () => { + const state = await loadDevicePairingState(params.baseDir); + const device = getPairedDeviceFromState(state, params.deviceId); + if (!device) { + return { ok: false, reason: "device-not-paired" }; + } + const role = normalizeDevicePairingRole(params.role); + if (!role) { + return { ok: false, reason: "role-missing" }; + } + const entry = device.tokens?.[role]; + if (!entry) { + return { ok: false, reason: "token-missing" }; + } + if (entry.revokedAtMs) { + return { ok: false, reason: "token-revoked" }; + } + if (!verifyPairingToken(params.token, entry.token)) { + return { ok: false, reason: "token-mismatch" }; + } + if ( + entry.issuer?.kind === SHARED_GATEWAY_AUTH_ISSUER_KIND && + entry.issuer.generation !== params.requiredSharedGatewaySessionGeneration + ) { + return { ok: false, reason: "issuer-generation-stale" }; + } + if ( + !entry.issuer && + params.requiredSharedGatewaySessionGeneration !== undefined && + isBrowserRelatedPairedDevice(device) + ) { + return { ok: false, reason: "legacy-browser-token" }; + } + const approvedScopes = resolveApprovedDeviceScopeBaseline(device); + if ( + !scopesWithinApprovedDeviceBaseline({ + role, + scopes: entry.scopes, + approvedScopes, + }) + ) { + return { ok: false, reason: "scope-mismatch" }; + } + const requestedScopes = normalizeDeviceAuthScopes(params.scopes); + if (!roleScopesAllow({ role, requestedScopes, allowedScopes: entry.scopes })) { + return { ok: false, reason: "scope-mismatch" }; + } + const now = Date.now(); + entry.lastUsedAtMs = now; + device.tokens ??= {}; + device.tokens[role] = entry; + device.lastSeenAtMs = now; + device.lastSeenReason = "device-token-auth"; + state.pairedByDeviceId[device.deviceId] = device; + persistState(state, params.baseDir, "paired"); + return entry.issuer ? { ok: true, issuer: entry.issuer } : { ok: true }; + }); +} + +/** Return a reusable token for a role or issue one within the approved scope baseline. */ +export async function ensureDeviceToken(params: { + deviceId: string; + role: string; + scopes: string[]; + issuer?: DeviceAuthToken["issuer"]; + baseDir?: string; +}): Promise { + return await withDevicePairingLock(async () => { + const state = await loadDevicePairingState(params.baseDir); + const requestedScopes = normalizeDeviceAuthScopes(params.scopes); + const context = resolveDeviceTokenUpdateContext({ + state, + deviceId: params.deviceId, + role: params.role, + }); + if (!context) { + return null; + } + const { device, role, tokens, existing } = context; + const previousNodeGeneration = resolveNodePairingGeneration(device); + const approvedScopes = resolveApprovedDeviceScopeBaseline(device); + if ( + !scopesWithinApprovedDeviceBaseline({ + role, + scopes: requestedScopes, + approvedScopes, + }) + ) { + return null; + } + if (existing && !existing.revokedAtMs) { + const existingWithinApproved = scopesWithinApprovedDeviceBaseline({ + role, + scopes: existing.scopes, + approvedScopes, + }); + const issuerAllowsReuse = deviceTokenIssuerMatches(existing, params.issuer); + if ( + existingWithinApproved && + issuerAllowsReuse && + roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes }) + ) { + return existing; + } + } + const now = Date.now(); + const next = createDeviceAuthToken({ + role, + scopes: requestedScopes, + issuer: params.issuer, + existing, + now, + rotatedAtMs: existing ? now : undefined, + }); + tokens[role] = next; + device.tokens = tokens; + clearNodePairingGenerationBins(device, previousNodeGeneration); + state.pairedByDeviceId[device.deviceId] = device; + persistState(state, params.baseDir, "paired"); + return next; + }); +} + +function resolveDeviceTokenUpdateContext(params: { + state: DevicePairingStoreState; + deviceId: string; + role: string; +}): { + device: PairedDevice; + role: string; + tokens: Record; + existing: DeviceAuthToken | undefined; +} | null { + const device = getPairedDeviceFromState(params.state, params.deviceId); + if (!device) { + return null; + } + const role = normalizeDevicePairingRole(params.role); + if (!role) { + return null; + } + // Token issuance and rotation must stay inside the role set that pairing + // approval recorded for this device. + if (!listApprovedPairedDeviceRoles(device).includes(role)) { + return null; + } + const tokens = cloneDevicePairingTokens(device); + const existing = tokens[role]; + return { device, role, tokens, existing }; +} + +/** Rotate a role token inside the device's approved scope baseline. */ +export async function rotateDeviceToken(params: { + deviceId: string; + role: string; + scopes?: string[]; + callerScopes?: readonly string[]; + baseDir?: string; +}): Promise { + return await withDevicePairingLock(async () => { + const state = await loadDevicePairingState(params.baseDir); + const context = resolveDeviceTokenUpdateContext({ + state, + deviceId: params.deviceId, + role: params.role, + }); + if (!context) { + return { ok: false, reason: "unknown-device-or-role" }; + } + const { device, role, tokens, existing } = context; + const previousNodeGeneration = resolveNodePairingGeneration(device); + const requestedScopes = normalizeDeviceAuthScopes( + params.scopes ?? existing?.scopes ?? device.scopes, + ); + const approvedScopes = resolveApprovedDeviceScopeBaseline(device); + if (!approvedScopes) { + return { ok: false, reason: "missing-approved-scope-baseline" }; + } + if ( + !scopesWithinApprovedDeviceBaseline({ + role, + scopes: requestedScopes, + approvedScopes, + }) + ) { + return { ok: false, reason: "scope-outside-approved-baseline" }; + } + if (params.callerScopes) { + const missingScope = resolveMissingRequestedScope({ + role, + requestedScopes, + allowedScopes: params.callerScopes, + }); + if (missingScope) { + return { ok: false, reason: "caller-missing-scope", scope: missingScope }; + } + } + const now = Date.now(); + const next = createDeviceAuthToken({ + role, + scopes: requestedScopes, + existing, + preserveExistingIssuer: true, + now, + rotatedAtMs: now, + }); + tokens[role] = next; + device.tokens = tokens; + clearNodePairingGenerationBins(device, previousNodeGeneration); + state.pairedByDeviceId[device.deviceId] = device; + persistState(state, params.baseDir, "paired"); + return { ok: true, entry: next }; + }); +} + +/** Revoke one active role token after optional caller-scope authorization. */ +export async function revokeDeviceToken(params: { + deviceId: string; + role: string; + callerScopes?: readonly string[]; + baseDir?: string; +}): Promise { + return await withDevicePairingLock(async () => { + const state = await loadDevicePairingState(params.baseDir); + const context = resolveDeviceTokenUpdateContext({ + state, + deviceId: params.deviceId, + role: params.role, + }); + if (!context || !context.existing) { + return { ok: false, reason: "unknown-device-or-role" }; + } + const { device, role, tokens, existing } = context; + const previousNodeGeneration = resolveNodePairingGeneration(device); + const targetScopes = normalizeDeviceAuthScopes( + Array.isArray(existing.scopes) ? existing.scopes : device.scopes, + ); + if (params.callerScopes) { + const missingScope = resolveMissingRequestedScope({ + role, + requestedScopes: targetScopes, + allowedScopes: params.callerScopes, + }); + if (missingScope) { + return { ok: false, reason: "caller-missing-scope", scope: missingScope }; + } + } + const entry = { ...existing, revokedAtMs: Date.now() }; + tokens[role] = entry; + device.tokens = tokens; + clearNodePairingGenerationBins(device, previousNodeGeneration); + state.pairedByDeviceId[device.deviceId] = device; + persistState(state, params.baseDir, "paired"); + return { ok: true, entry }; + }); +} diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 0755c16a6300..a8a5a3a897c7 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -8,6 +8,7 @@ import { import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js"; +import { approveBootstrapDevicePairing, approveDevicePairing } from "./device-pairing-approval.js"; import { approveNodePairing, requestNodePairing, @@ -19,9 +20,12 @@ import { persistDevicePairingStoreState, } from "./device-pairing-store.js"; import { - approveBootstrapDevicePairing, - approveDevicePairing, ensureDeviceToken, + revokeDeviceToken, + rotateDeviceToken, + verifyDeviceToken, +} from "./device-pairing-tokens.js"; +import { getPairedDevice, hasEffectivePairedDeviceRole, listEffectivePairedDeviceRoles, @@ -30,11 +34,8 @@ import { requestDevicePairing, rejectDevicePairing, resolveNodePairingGeneration, - revokeDeviceToken, - rotateDeviceToken, updatePairedDeviceMetadata, updatePairedDevicePresence, - verifyDeviceToken, withPairedDeviceRecords, type PairedDevice, } from "./device-pairing.js"; diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 277a6c9a2190..89017d76e2c9 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -1,22 +1,25 @@ -// Manages device pairing requests, approvals, and token issuance. +// Manages device pairing requests, records, metadata, and node pairing state. import { createHash, randomUUID } from "node:crypto"; -import { expectDefined } from "@openclaw/normalization-core"; -import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; -import { - resolveDeviceProfileRoleScopes, - resolveDeviceProfileScopes, - type DeviceBootstrapProfile, -} from "../shared/device-bootstrap-profile.js"; -import { - resolveMissingRequestedScope, - resolveScopeOutsideRequestedRoles, - roleScopesAllow, -} from "../shared/operator-scope-compat.js"; +import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { revokeDeviceBootstrapTokensForDevice } from "./device-bootstrap.js"; -import { loadDevicePairingStoreStateReadOnly } from "./device-pairing-store-readonly.js"; import { - loadDevicePairingStoreState, + cloneDevicePairingTokens, + isPairingRequestExpired, + loadDevicePairingState, + loadDevicePairingStateReadOnly, + mergeDevicePairingRoles, + mergeDevicePairingScopes, + normalizeDevicePairingId, + normalizeDevicePairingRole, + preserveDeviceRoleScopes, + reconcilePendingPairingRequests, + resolvePairingRequestExpiry, + resolveRequestedDeviceRoles, + sameDevicePairingStringSet, + withDevicePairingLock as withLock, +} from "./device-pairing-state.js"; +import { loadPairedDevicePairingStoreRecord, persistDevicePairingStoreState as persistState, updatePairedDevicePresenceInTransaction, @@ -26,10 +29,7 @@ import type { DevicePairingPendingRecord, DevicePairingPendingRequest, PairedDevice, - PairedDeviceApprovalKind, } from "./device-pairing.types.js"; -import { createAsyncLock, pruneExpiredPending } from "./pairing-files.js"; -import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; export type { DeviceAuthToken, @@ -65,35 +65,6 @@ type RequestDevicePairingResult = { superseded?: DevicePairingSupersededRequest[]; }; -/** Redacted token metadata safe for list/status responses. */ -export type DeviceAuthTokenSummary = { - role: string; - scopes: string[]; - createdAtMs: number; - rotatedAtMs?: number; - revokedAtMs?: number; - lastUsedAtMs?: number; -}; - -/** Deny reasons returned when rotating an existing paired-device token. */ -export type RotateDeviceTokenDenyReason = - | "unknown-device-or-role" - | "missing-approved-scope-baseline" - | "scope-outside-approved-baseline" - | "caller-missing-scope"; - -/** Token rotation result with the replacement token entry on success. */ -type RotateDeviceTokenResult = - | { ok: true; entry: DeviceAuthToken } - | { ok: false; reason: RotateDeviceTokenDenyReason; scope?: string }; - -export type RevokeDeviceTokenDenyReason = "unknown-device-or-role" | "caller-missing-scope"; - -/** Token revocation result with the revoked entry on success. */ -type RevokeDeviceTokenResult = - | { ok: true; entry: DeviceAuthToken } - | { ok: false; reason: RevokeDeviceTokenDenyReason; scope?: string }; - /** Metadata fields a device may refresh without changing approval or token state. */ type PairedDeviceMetadataPatch = Pick< PairedDevice, @@ -107,102 +78,12 @@ type PairedDeviceMetadataPatch = Pick< | "lastSeenReason" >; -/** Paired-device access metadata refreshed when an existing device reconnects. */ -type DevicePairingAccessMetadata = Pick< - PairedDevice, - "displayName" | "remoteIp" | "lastSeenAtMs" | "lastSeenReason" ->; - /** Combined pending/paired view returned by pairing list APIs. */ type DevicePairingList = { pending: DevicePairingPendingRequest[]; paired: PairedDevice[]; }; -/** Authorization failure categories for owner approval and bootstrap approval flows. */ -type DevicePairingForbiddenReason = - | "caller-scopes-required" - | "caller-missing-scope" - | "scope-outside-requested-roles" - | "bootstrap-role-not-allowed" - | "bootstrap-scope-not-allowed"; - -/** Structured forbidden result with the missing/disallowed role or scope when known. */ -type DevicePairingForbiddenResult = { - status: "forbidden"; - reason: DevicePairingForbiddenReason; - scope?: string; - role?: string; -}; - -/** Pairing approval outcome: approved, forbidden with reason, or request not found. */ -type ApproveDevicePairingResult = - | { - status: "approved"; - requestId: string; - device: PairedDevice; - /** Existing connected node transports must be retired before success is returned. */ - nodePairingGenerationChanged?: true; - } - | DevicePairingForbiddenResult - | null; - -type DevicePairingStateFile = { - pendingById: Record; - pairedByDeviceId: Record; -}; - -const PAIRING_PENDING_TTL_MS = 5 * 60 * 1000; -const OPERATOR_ROLE = "operator"; -const OPERATOR_SCOPE_PREFIX = "operator."; -const SHARED_GATEWAY_AUTH_ISSUER_KIND = "shared-gateway-auth"; -const BROWSER_DEVICE_CLIENT_IDS = new Set(["openclaw-control-ui", "webchat-ui"]); -const BROWSER_DEVICE_CLIENT_MODE = "webchat"; - -const withLock = createAsyncLock(); -/** Format a device-pairing authorization failure for CLI/API callers. */ -export function formatDevicePairingForbiddenMessage(result: DevicePairingForbiddenResult): string { - switch (result.reason) { - case "caller-scopes-required": - return `missing scope: ${result.scope ?? "callerScopes-required"}`; - case "caller-missing-scope": - return `missing scope: ${result.scope ?? "unknown"}`; - case "scope-outside-requested-roles": - return `invalid scope for requested roles: ${result.scope ?? "unknown"}`; - case "bootstrap-role-not-allowed": - return `bootstrap profile does not allow role: ${result.role ?? "unknown"}`; - case "bootstrap-scope-not-allowed": - return `bootstrap profile does not allow scope: ${result.scope ?? "unknown"}`; - } - throw new Error("Unsupported device pairing forbidden reason"); -} - -async function loadState(baseDir?: string): Promise { - const state: DevicePairingStateFile = loadDevicePairingStoreState(baseDir); - const now = Date.now(); - pruneExpiredPending(state.pendingById, now, PAIRING_PENDING_TTL_MS); - // Pending node-surface requests share the pairing TTL; requests refresh - // their ts on reconnect so an actively retrying node keeps one alive. - for (const device of Object.values(state.pairedByDeviceId)) { - if (device.pendingNodeSurface && now - device.pendingNodeSurface.ts > PAIRING_PENDING_TTL_MS) { - delete device.pendingNodeSurface; - } - } - return state; -} - -async function loadStateReadOnly(baseDir?: string): Promise { - const state: DevicePairingStateFile = loadDevicePairingStoreStateReadOnly(baseDir); - const now = Date.now(); - pruneExpiredPending(state.pendingById, now, PAIRING_PENDING_TTL_MS); - for (const device of Object.values(state.pairedByDeviceId)) { - if (device.pendingNodeSurface && now - device.pendingNodeSurface.ts > PAIRING_PENDING_TTL_MS) { - delete device.pendingNodeSurface; - } - } - return state; -} - /** * Internal seam for the paired-device node-surface module: run one * operation against the paired-device records under the shared pairing lock. @@ -217,7 +98,7 @@ export async function withPairedDeviceRecords( ) => { value: T; persist: boolean } | Promise<{ value: T; persist: boolean }>, ): Promise { return await withLock(async () => { - const state = await loadState(baseDir); + const state = await loadDevicePairingState(baseDir); const outcome = await operate(state.pairedByDeviceId); if (outcome.persist) { persistState(state, baseDir, "paired"); @@ -226,35 +107,13 @@ export async function withPairedDeviceRecords( }); } -function normalizeDeviceId(deviceId: string) { - return deviceId.trim(); -} - -function normalizeRole(role: string | undefined): string | null { - const trimmed = role?.trim(); - return trimmed ? trimmed : null; -} - -function mergeRoles(...items: Array): string[] | undefined { - const roles = new Set(); - for (const item of items) { - for (const role of normalizeUniqueSingleOrTrimmedStringList(item)) { - roles.add(role); - } - } - if (roles.size === 0) { - return undefined; - } - return [...roles]; -} - function listActiveTokenRoles( tokens: Record | undefined, ): string[] | undefined { if (!tokens) { return undefined; } - return mergeRoles( + return mergeDevicePairingRoles( Object.values(tokens) .filter((entry) => !entry.revokedAtMs) .map((entry) => entry.role), @@ -267,7 +126,7 @@ export function listApprovedPairedDeviceRoles( ): string[] { // Approved roles come from the pairing record itself. This is the durable // contract the owner approved, independent of any currently active tokens. - return mergeRoles(device.roles, device.role) ?? []; + return mergeDevicePairingRoles(device.roles, device.role) ?? []; } /** List active-token roles, bounded by the durable approved pairing roles. */ @@ -291,7 +150,7 @@ export function hasEffectivePairedDeviceRole( device: Pick, role: string, ): boolean { - const normalized = normalizeRole(role); + const normalized = normalizeDevicePairingRole(role); if (!normalized) { return false; } @@ -373,41 +232,6 @@ export function resolveNodePairingState(device: PairedDevice | null): NodePairin return { identity, generation: resolveNodePairingGeneration(device) }; } -function mergeScopes(...items: Array): string[] | undefined { - const scopes = new Set(); - let sawExplicitScopeList = false; - for (const item of items) { - if (!Array.isArray(item)) { - continue; - } - sawExplicitScopeList = true; - for (const scope of normalizeUniqueSingleOrTrimmedStringList(item)) { - scopes.add(scope); - } - } - if (scopes.size === 0) { - return sawExplicitScopeList ? [] : undefined; - } - return [...scopes]; -} - -function sameStringSet(left: readonly string[], right: readonly string[]): boolean { - if (left.length !== right.length) { - return false; - } - const rightSet = new Set(right); - for (const value of left) { - if (!rightSet.has(value)) { - return false; - } - } - return true; -} - -function resolveRequestedRoles(input: { role?: string; roles?: string[] }): string[] { - return mergeRoles(input.roles, input.role) ?? []; -} - function resolveRequestedScopes(input: { scopes?: string[] }): string[] { return normalizeDeviceAuthScopes(input.scopes); } @@ -422,12 +246,15 @@ function samePendingApprovalSnapshot( if (existing.browserOrigin !== incoming.browserOrigin) { return false; } - if (normalizeRole(existing.role) !== normalizeRole(incoming.role)) { + if (normalizeDevicePairingRole(existing.role) !== normalizeDevicePairingRole(incoming.role)) { return false; } if ( - !sameStringSet(resolveRequestedRoles(existing), resolveRequestedRoles(incoming)) || - !sameStringSet(resolveRequestedScopes(existing), resolveRequestedScopes(incoming)) + !sameDevicePairingStringSet( + resolveRequestedDeviceRoles(existing), + resolveRequestedDeviceRoles(incoming), + ) || + !sameDevicePairingStringSet(resolveRequestedScopes(existing), resolveRequestedScopes(incoming)) ) { return false; } @@ -457,11 +284,11 @@ function incomingApprovalCoveredByExisting( if (existing.browserOrigin !== incoming.browserOrigin) { return false; } - if (normalizeRole(existing.role) !== normalizeRole(incoming.role)) { + if (normalizeDevicePairingRole(existing.role) !== normalizeDevicePairingRole(incoming.role)) { return false; } - const incomingRoles = resolveRequestedRoles(incoming); - if (!isStringSubset(incomingRoles, resolveRequestedRoles(existing))) { + const incomingRoles = resolveRequestedDeviceRoles(incoming); + if (!isStringSubset(incomingRoles, resolveRequestedDeviceRoles(existing))) { return false; } const existingScopes = resolveRequestedScopes(existing); @@ -529,7 +356,7 @@ function buildPendingDevicePairingRequest(params: { isRepair: boolean; req: Omit; }): DevicePairingPendingRequest { - const role = normalizeRole(params.req.role) ?? undefined; + const role = normalizeDevicePairingRole(params.req.role) ?? undefined; return { requestId: params.requestId ?? randomUUID(), deviceId: params.deviceId, @@ -541,8 +368,8 @@ function buildPendingDevicePairingRequest(params: { clientMode: params.req.clientMode, browserOrigin: params.req.browserOrigin, role, - roles: mergeRoles(params.req.roles, role), - scopes: mergeScopes(params.req.scopes), + roles: mergeDevicePairingRoles(params.req.roles, role), + scopes: mergeDevicePairingScopes(params.req.scopes), remoteIp: params.req.remoteIp, silent: params.req.silent, isRepair: params.isRepair, @@ -550,198 +377,8 @@ function buildPendingDevicePairingRequest(params: { }; } -function newToken() { - return generatePairingToken(); -} - -function getPairedDeviceFromState( - state: DevicePairingStateFile, - deviceId: string, -): PairedDevice | null { - return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null; -} - -function cloneDeviceTokens(device: PairedDevice): Record { - return device.tokens ? { ...device.tokens } : {}; -} - -function isBrowserRelatedPairedDevice(device: Pick) { - const clientMode = device.clientMode?.trim().toLowerCase(); - if (clientMode === BROWSER_DEVICE_CLIENT_MODE) { - return true; - } - const clientId = device.clientId?.trim().toLowerCase(); - return clientId ? BROWSER_DEVICE_CLIENT_IDS.has(clientId) : false; -} - -function deviceTokenIssuerMatches( - entry: DeviceAuthToken, - issuer: DeviceAuthToken["issuer"] | undefined, -): boolean { - if (!issuer) { - return !entry.issuer; - } - return entry.issuer?.kind === issuer.kind && entry.issuer.generation === issuer.generation; -} - -function buildDeviceAuthToken(params: { - role: string; - scopes: string[]; - issuer?: DeviceAuthToken["issuer"]; - existing?: DeviceAuthToken; - preserveExistingIssuer?: boolean; - now: number; - rotatedAtMs?: number; -}): DeviceAuthToken { - return { - token: newToken(), - role: params.role, - scopes: params.scopes, - issuer: params.issuer ?? (params.preserveExistingIssuer ? params.existing?.issuer : undefined), - createdAtMs: params.existing?.createdAtMs ?? params.now, - rotatedAtMs: params.rotatedAtMs, - revokedAtMs: undefined, - lastUsedAtMs: params.existing?.lastUsedAtMs, - }; -} - -// Interactive approvals must stay sticky: a later silent repair/re-approve of the -// same device id cannot downgrade an owner/bootstrap record into prune-eligible -// state. Pre-provenance records (approvedVia undefined) may have been approved by -// an owner, so a non-interactive re-approve must keep them protected (undefined). -function mergeApprovalKind( - existing: PairedDevice | undefined, - incoming: PairedDeviceApprovalKind, -): PairedDeviceApprovalKind | undefined { - if (incoming === "owner" || !existing) { - return incoming; - } - if (existing.approvedVia === undefined) { - return incoming === "bootstrap" ? "bootstrap" : undefined; - } - if (existing.approvedVia === "owner" || existing.approvedVia === "bootstrap") { - return existing.approvedVia; - } - return incoming; -} - -function buildApprovedPairedDevice(params: { - pending: DevicePairingPendingRequest; - existing: PairedDevice | undefined; - roles: string[] | undefined; - approvedScopes: string[] | undefined; - tokens: Record; - now: number; - approvedVia: PairedDeviceApprovalKind; - accessMetadata?: DevicePairingAccessMetadata; -}): PairedDevice { - return { - deviceId: params.pending.deviceId, - publicKey: params.pending.publicKey, - displayName: params.accessMetadata?.displayName ?? params.pending.displayName, - platform: params.pending.platform, - deviceFamily: params.pending.deviceFamily, - clientId: params.pending.clientId, - clientMode: params.pending.clientMode, - browserOrigin: params.pending.browserOrigin, - role: params.pending.role, - roles: params.roles, - scopes: params.approvedScopes, - approvedScopes: params.approvedScopes, - remoteIp: params.accessMetadata?.remoteIp ?? params.pending.remoteIp, - tokens: params.tokens, - approvedVia: mergeApprovalKind(params.existing, params.approvedVia), - // Node capability approvals ride on the device record; device repair or - // role re-approval must not silently revoke an approved node surface. - ...(params.existing?.nodeSurface ? { nodeSurface: params.existing.nodeSurface } : {}), - ...(params.existing?.pendingNodeSurface - ? { pendingNodeSurface: params.existing.pendingNodeSurface } - : {}), - // Operator-assigned label is owner-side state; device repair or role - // re-approval must not silently drop it. - ...(params.existing?.operatorLabel ? { operatorLabel: params.existing.operatorLabel } : {}), - createdAtMs: params.existing?.createdAtMs ?? params.now, - approvedAtMs: params.now, - lastSeenAtMs: params.accessMetadata?.lastSeenAtMs ?? params.existing?.lastSeenAtMs, - lastSeenReason: params.accessMetadata?.lastSeenReason ?? params.existing?.lastSeenReason, - }; -} - -function resolveRoleScopedDeviceTokenScopes(role: string, scopes: string[] | undefined): string[] { - const normalized = normalizeDeviceAuthScopes(scopes); - if (role === "operator") { - return normalized.filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX)); - } - return normalized.filter((scope) => !scope.startsWith(OPERATOR_SCOPE_PREFIX)); -} - -function preserveRoleScopedApprovalScopes(role: string, scopes: string[] | undefined): string[] { - return normalizeUniqueSingleOrTrimmedStringList(scopes).filter((scope) => - role === OPERATOR_ROLE - ? scope.startsWith(OPERATOR_SCOPE_PREFIX) - : !scope.startsWith(OPERATOR_SCOPE_PREFIX), - ); -} - -function resolveApprovedTokenScopes(params: { - role: string; - pending: DevicePairingPendingRequest; - existingToken?: DeviceAuthToken; - approvedScopes?: string[]; - existing?: PairedDevice; -}): string[] { - const pendingScopes = resolveRoleScopedDeviceTokenScopes(params.role, params.pending.scopes); - if (pendingScopes.length > 0) { - const approvedBaseline = resolveRoleScopedDeviceTokenScopes( - params.role, - params.existing?.approvedScopes ?? params.existing?.scopes, - ); - const requestedScopeDelta = - params.existingToken && approvedBaseline.length > 0 - ? pendingScopes.filter((scope) => !approvedBaseline.includes(scope)) - : pendingScopes; - if (requestedScopeDelta.length === 0 && params.existingToken) { - return resolveRoleScopedDeviceTokenScopes(params.role, params.existingToken.scopes); - } - return resolveRoleScopedDeviceTokenScopes( - params.role, - mergeScopes(params.existingToken?.scopes, requestedScopeDelta), - ); - } - return resolveRoleScopedDeviceTokenScopes( - params.role, - params.existingToken?.scopes ?? - params.approvedScopes ?? - params.existing?.approvedScopes ?? - params.existing?.scopes, - ); -} - -function resolveApprovedDeviceScopeBaseline(device: PairedDevice): string[] | null { - const baseline = device.approvedScopes ?? device.scopes; - if (!Array.isArray(baseline)) { - return null; - } - return normalizeDeviceAuthScopes(baseline); -} - -function scopesWithinApprovedDeviceBaseline(params: { - role: string; - scopes: readonly string[]; - approvedScopes: readonly string[] | null; -}): boolean { - if (!params.approvedScopes) { - return false; - } - return roleScopesAllow({ - role: params.role, - requestedScopes: params.scopes, - allowedScopes: params.approvedScopes, - }); -} - export async function listDevicePairing(baseDir?: string): Promise { - const state = await loadState(baseDir); + const state = await loadDevicePairingState(baseDir); const pending = Object.values(state.pendingById) .map(toPublicPendingDevicePairingRequest) .toSorted((a, b) => b.ts - a.ts); @@ -753,7 +390,7 @@ export async function listDevicePairing(baseDir?: string): Promise { - const state = await loadStateReadOnly(baseDir); + const state = await loadDevicePairingStateReadOnly(baseDir); const pending = Object.values(state.pendingById) .map(toPublicPendingDevicePairingRequest) .toSorted((a, b) => b.ts - a.ts); @@ -768,11 +405,8 @@ export async function getPairedDevice( deviceId: string, baseDir?: string, ): Promise { - const device = loadPairedDevicePairingStoreRecord(normalizeDeviceId(deviceId), baseDir); - if ( - device?.pendingNodeSurface && - Date.now() - device.pendingNodeSurface.ts > PAIRING_PENDING_TTL_MS - ) { + const device = loadPairedDevicePairingStoreRecord(normalizeDevicePairingId(deviceId), baseDir); + if (device?.pendingNodeSurface && isPairingRequestExpired(device.pendingNodeSurface.ts)) { delete device.pendingNodeSurface; } return device; @@ -783,68 +417,19 @@ export async function getPendingDevicePairing( requestId: string, baseDir?: string, ): Promise { - const state = await loadState(baseDir); + const state = await loadDevicePairingState(baseDir); const pending = state.pendingById[requestId]; return pending ? toPublicPendingDevicePairingRequest(pending) : null; } -/** Result shape for creating or refreshing a pending pairing request. */ -type PendingPairingRequestResult = { - status: "pending"; - request: TPending; - created: boolean; -}; - -/** Refresh one compatible pending request or replace a superseded request set atomically. */ -function reconcilePendingPairingRequests< - TPending extends { requestId: string }, - TIncoming, ->(params: { - pendingById: Record; - existing: readonly TPending[]; - incoming: TIncoming; - canRefreshSingle: (existing: TPending, incoming: TIncoming) => boolean; - refreshSingle: (existing: TPending, incoming: TIncoming) => TPending; - buildReplacement: (params: { existing: readonly TPending[]; incoming: TIncoming }) => TPending; - persist: () => void; -}): PendingPairingRequestResult { - if ( - params.existing.length === 1 && - params.canRefreshSingle( - expectDefined(params.existing[0], "existing entry at 0"), - params.incoming, - ) - ) { - const refreshed = params.refreshSingle( - expectDefined(params.existing[0], "existing entry at 0"), - params.incoming, - ); - params.pendingById[refreshed.requestId] = refreshed; - params.persist(); - return { status: "pending", request: refreshed, created: false }; - } - - for (const existing of params.existing) { - delete params.pendingById[existing.requestId]; - } - - const request = params.buildReplacement({ - existing: params.existing, - incoming: params.incoming, - }); - params.pendingById[request.requestId] = request; - params.persist(); - return { status: "pending", request, created: true }; -} - /** Create or refresh a pending device pairing request for owner approval. */ export async function requestDevicePairing( req: Omit, baseDir?: string, ): Promise { return await withLock(async () => { - const state = await loadState(baseDir); - const deviceId = normalizeDeviceId(req.deviceId); + const state = await loadDevicePairingState(baseDir); + const deviceId = normalizeDevicePairingId(req.deviceId); if (!deviceId) { throw new Error("deviceId required"); } @@ -863,12 +448,12 @@ export async function requestDevicePairing( refreshPendingDevicePairingRequest(existing, incoming, isRepair), buildReplacement: ({ existing, incoming }) => { const latestPending = existing[0]; - const mergedRoles = mergeRoles( + const mergedRoles = mergeDevicePairingRoles( ...existing.flatMap((pending) => [pending.roles, pending.role]), incoming.roles, incoming.role, ); - const mergedScopes = mergeScopes( + const mergedScopes = mergeDevicePairingScopes( ...existing.map((pending) => pending.scopes), incoming.scopes, ); @@ -877,7 +462,7 @@ export async function requestDevicePairing( isRepair, req: { ...incoming, - role: normalizeRole(incoming.role) ?? latestPending?.role, + role: normalizeDevicePairingRole(incoming.role) ?? latestPending?.role, roles: mergedRoles, scopes: mergedScopes, // Preserve interactive visibility when superseding pending requests: @@ -901,334 +486,19 @@ export async function requestDevicePairing( const publicResult = { ...result, request: toPublicPendingDevicePairingRequest(result.request), - expiresAtMs: (result.request.refreshedAtMs ?? result.request.ts) + PAIRING_PENDING_TTL_MS, + expiresAtMs: resolvePairingRequestExpiry(result.request.refreshedAtMs ?? result.request.ts), }; return superseded.length > 0 ? { ...publicResult, superseded } : publicResult; }); } -/** Approve a pending request with optional caller-scope checks for operator grants. */ -export async function approveDevicePairing( - requestId: string, - baseDir?: string, -): Promise; -export async function approveDevicePairing( - requestId: string, - options: { - callerScopes?: readonly string[]; - accessMetadata?: DevicePairingAccessMetadata; - approvedVia?: Extract< - PairedDeviceApprovalKind, - "owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified" - >; - /** - * Replace the pending scopes only for a brand-new operator device, or — under - * trusted-proxy approval — for a known operator device re-requesting with its - * already-paired public key. The live role set is rechecked under the pairing - * lock so a merged request cannot inherit non-operator access through browser - * auto-approval. - */ - autoApproveNewDeviceScopes?: readonly string[]; - }, - baseDir?: string, -): Promise; -export async function approveDevicePairing( - requestId: string, - optionsOrBaseDir?: - | { - callerScopes?: readonly string[]; - accessMetadata?: DevicePairingAccessMetadata; - approvedVia?: Extract< - PairedDeviceApprovalKind, - "owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified" - >; - autoApproveNewDeviceScopes?: readonly string[]; - } - | string, - maybeBaseDir?: string, -): Promise { - const options = - typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined - ? undefined - : optionsOrBaseDir; - const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir; - return await approveDevicePairingWithOptions(requestId, options, baseDir); -} - -async function approveDevicePairingWithOptions( - requestId: string, - options: - | { - callerScopes?: readonly string[]; - accessMetadata?: DevicePairingAccessMetadata; - approvedVia?: Extract< - PairedDeviceApprovalKind, - "owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified" - >; - autoApproveNewDeviceScopes?: readonly string[]; - } - | undefined, - baseDir?: string, -): Promise { - return await withLock(async () => { - const state = await loadState(baseDir); - const pendingRecord = state.pendingById[requestId]; - if (!pendingRecord) { - return null; - } - const autoApproveScopes = options?.autoApproveNewDeviceScopes; - const requestedRoles = resolveRequestedRoles(pendingRecord); - const knownDevice = state.pairedByDeviceId[pendingRecord.deviceId]; - // Trusted-proxy connects carry an SSO-authenticated user, and the connect - // handshake has already proven possession of the pending public key. A - // matching key on the paired record is therefore the same physical device - // re-requesting (typically a scope upgrade) and may auto-approve; a key - // mismatch is a real repair — possibly a deviceId squat — and stays a - // manual owner decision. - const trustedProxySameKeyDevice = - options?.approvedVia === "trusted-proxy" && - knownDevice !== undefined && - knownDevice.publicKey === pendingRecord.publicKey; - if ( - autoApproveScopes && - (((pendingRecord.isRepair || knownDevice) && !trustedProxySameKeyDevice) || - !sameStringSet(requestedRoles, [OPERATOR_ROLE])) - ) { - return null; - } - const pending = autoApproveScopes - ? { ...pendingRecord, scopes: [...autoApproveScopes] } - : pendingRecord; - const requestedScopes = normalizeDeviceAuthScopes(pending.scopes); - const roleMismatchScope = resolveScopeOutsideRequestedRoles({ - requestedRoles, - requestedScopes, - }); - if (roleMismatchScope) { - return { - status: "forbidden", - reason: "scope-outside-requested-roles", - scope: roleMismatchScope, - }; - } - const now = Date.now(); - const existing = state.pairedByDeviceId[pending.deviceId]; - const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); - const approvedScopes = mergeScopes( - existing?.approvedScopes ?? existing?.scopes, - pending.scopes, - ); - const previousNodeGeneration = resolveNodePairingGeneration(existing ?? null); - const tokens = existing?.tokens ? { ...existing.tokens } : {}; - const nextTokenScopesByRole = new Map(); - for (const roleForToken of requestedRoles) { - const existingToken = tokens[roleForToken]; - const nextScopes = resolveApprovedTokenScopes({ - role: roleForToken, - pending, - existingToken, - approvedScopes, - existing, - }); - nextTokenScopesByRole.set(roleForToken, nextScopes); - if (roleForToken === OPERATOR_ROLE && nextScopes.length > 0) { - const callerRequiredScopes = - mergeScopes( - resolveRoleScopedDeviceTokenScopes(roleForToken, pending.scopes), - nextScopes, - ) ?? nextScopes; - if (!options?.callerScopes) { - return { - status: "forbidden", - reason: "caller-scopes-required", - scope: callerRequiredScopes[0], - }; - } - const missingScope = resolveMissingRequestedScope({ - role: OPERATOR_ROLE, - requestedScopes: callerRequiredScopes, - allowedScopes: options.callerScopes, - }); - if (missingScope) { - return { status: "forbidden", reason: "caller-missing-scope", scope: missingScope }; - } - } - } - for (const [roleForToken, nextScopes] of nextTokenScopesByRole) { - const existingToken = tokens[roleForToken]; - const tokenNow = Date.now(); - tokens[roleForToken] = { - token: newToken(), - role: roleForToken, - scopes: nextScopes, - createdAtMs: existingToken?.createdAtMs ?? tokenNow, - rotatedAtMs: existingToken ? tokenNow : undefined, - revokedAtMs: undefined, - lastUsedAtMs: existingToken?.lastUsedAtMs, - }; - } - const device = buildApprovedPairedDevice({ - pending, - existing, - roles, - approvedScopes, - tokens, - now, - approvedVia: options?.approvedVia ?? "owner", - accessMetadata: options?.accessMetadata, - }); - const nextNodeGeneration = resolveNodePairingGeneration(device); - const nodePairingGenerationChanged = Boolean( - previousNodeGeneration && previousNodeGeneration.key !== nextNodeGeneration?.key, - ); - clearNodePairingGenerationBins(device, previousNodeGeneration); - const installationIdentityChanged = Boolean( - existing && existing.publicKey !== device.publicKey, - ); - delete state.pendingById[requestId]; - state.pairedByDeviceId[device.deviceId] = device; - persistState( - state, - baseDir, - "both", - installationIdentityChanged ? { clearApnsNodeIds: [device.deviceId] } : undefined, - ); - return { - status: "approved", - requestId, - device, - ...(nodePairingGenerationChanged ? { nodePairingGenerationChanged: true as const } : {}), - }; - }); -} - -/** Approve a pending request through a bounded bootstrap profile handoff. */ -export async function approveBootstrapDevicePairing( - requestId: string, - bootstrapProfile: DeviceBootstrapProfile, - baseDir?: string, -): Promise; -export async function approveBootstrapDevicePairing( - requestId: string, - bootstrapProfile: DeviceBootstrapProfile, - options: { accessMetadata?: DevicePairingAccessMetadata }, - baseDir?: string, -): Promise; -export async function approveBootstrapDevicePairing( - requestId: string, - bootstrapProfile: DeviceBootstrapProfile, - optionsOrBaseDir?: { accessMetadata?: DevicePairingAccessMetadata } | string, - maybeBaseDir?: string, -): Promise { - const options = - typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined - ? undefined - : optionsOrBaseDir; - const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir; - const approvedRoles = mergeRoles(bootstrapProfile.roles) ?? []; - const approvedScopes = resolveDeviceProfileScopes(bootstrapProfile, approvedRoles); - return await withLock(async () => { - const state = await loadState(baseDir); - const pending = state.pendingById[requestId]; - if (!pending) { - return null; - } - const requestedRoles = resolveRequestedRoles(pending); - const missingRole = requestedRoles.find((role) => !approvedRoles.includes(role)); - if (missingRole) { - return { status: "forbidden", reason: "bootstrap-role-not-allowed", role: missingRole }; - } - const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) => - scope.startsWith(OPERATOR_SCOPE_PREFIX), - ); - const missingScope = resolveMissingRequestedScope({ - role: OPERATOR_ROLE, - requestedScopes: requestedOperatorScopes, - allowedScopes: approvedScopes, - }); - if (missingScope) { - return { status: "forbidden", reason: "bootstrap-scope-not-allowed", scope: missingScope }; - } - - const now = Date.now(); - const existing = state.pairedByDeviceId[pending.deviceId]; - const grantedRoles = requestedRoles; - const grantedScopes = resolveDeviceProfileScopes( - bootstrapProfile, - grantedRoles, - pending.scopes ?? [], - ); - const grantedRoleSet = new Set(grantedRoles); - const preservedExistingScopes = (mergeRoles(existing?.roles, existing?.role) ?? []).flatMap( - (existingRole) => - grantedRoleSet.has(existingRole) - ? [] - : preserveRoleScopedApprovalScopes( - existingRole, - existing?.approvedScopes ?? existing?.scopes, - ), - ); - const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); - const nextApprovedScopes = mergeScopes(preservedExistingScopes, grantedScopes); - const previousNodeGeneration = resolveNodePairingGeneration(existing ?? null); - const tokens = existing?.tokens ? { ...existing.tokens } : {}; - for (const roleForToken of grantedRoles) { - const existingToken = tokens[roleForToken]; - const tokenScopes = - roleForToken === OPERATOR_ROLE - ? resolveDeviceProfileRoleScopes(bootstrapProfile, roleForToken, grantedScopes) - : []; - tokens[roleForToken] = buildDeviceAuthToken({ - role: roleForToken, - scopes: tokenScopes, - existing: existingToken, - now, - ...(existingToken ? { rotatedAtMs: now } : {}), - }); - } - - const device = buildApprovedPairedDevice({ - pending, - existing, - roles, - approvedScopes: nextApprovedScopes, - tokens, - now, - approvedVia: "bootstrap", - accessMetadata: options?.accessMetadata, - }); - const nextNodeGeneration = resolveNodePairingGeneration(device); - const nodePairingGenerationChanged = Boolean( - previousNodeGeneration && previousNodeGeneration.key !== nextNodeGeneration?.key, - ); - clearNodePairingGenerationBins(device, previousNodeGeneration); - const installationIdentityChanged = Boolean( - existing && existing.publicKey !== device.publicKey, - ); - delete state.pendingById[requestId]; - state.pairedByDeviceId[device.deviceId] = device; - persistState( - state, - baseDir, - "both", - installationIdentityChanged ? { clearApnsNodeIds: [device.deviceId] } : undefined, - ); - return { - status: "approved", - requestId, - device, - ...(nodePairingGenerationChanged ? { nodePairingGenerationChanged: true as const } : {}), - }; - }); -} - /** Reject a pending request and revoke matching bootstrap tokens for that device. */ export async function rejectDevicePairing( requestId: string, baseDir?: string, ): Promise<{ requestId: string; deviceId: string } | null> { return await withLock(async () => { - const state = await loadState(baseDir); + const state = await loadDevicePairingState(baseDir); const pending = state.pendingById[requestId]; if (!pending) { return null; @@ -1250,8 +520,8 @@ export async function removePairedDevice( baseDir?: string, ): Promise<{ deviceId: string } | null> { return await withLock(async () => { - const state = await loadState(baseDir); - const normalized = normalizeDeviceId(deviceId); + const state = await loadDevicePairingState(baseDir); + const normalized = normalizeDevicePairingId(deviceId); if (!normalized || !state.pairedByDeviceId[normalized]) { return null; } @@ -1308,8 +578,8 @@ export async function pruneSupersededSilentPairedDevices(params: { nowMs?: number; }): Promise { return await withLock(async () => { - const state = await loadState(params.baseDir); - const anchor = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; + const state = await loadDevicePairingState(params.baseDir); + const anchor = state.pairedByDeviceId[normalizeDevicePairingId(params.deviceId)]; if (!anchor || anchor.approvedVia !== "silent") { return []; } @@ -1364,15 +634,15 @@ export async function removePairedDeviceRole(params: { baseDir?: string; }): Promise<{ deviceId: string; role: string; removedDevice: boolean } | null> { return await withLock(async () => { - const state = await loadState(params.baseDir); - const normalizedDeviceId = normalizeDeviceId(params.deviceId); - const role = normalizeRole(params.role); + const state = await loadDevicePairingState(params.baseDir); + const normalizedDeviceId = normalizeDevicePairingId(params.deviceId); + const role = normalizeDevicePairingRole(params.role); const device = state.pairedByDeviceId[normalizedDeviceId]; if (!device || !role || !listApprovedPairedDeviceRoles(device).includes(role)) { return null; } - const tokens = cloneDeviceTokens(device); + const tokens = cloneDevicePairingTokens(device); delete tokens[role]; const remainingRoles = listApprovedPairedDeviceRoles(device).filter((entry) => entry !== role); if (remainingRoles.length === 0) { @@ -1392,7 +662,7 @@ export async function removePairedDeviceRole(params: { if (pending.deviceId !== normalizedDeviceId) { continue; } - const pendingRoles = resolveRequestedRoles(pending); + const pendingRoles = resolveRequestedDeviceRoles(pending); if (!pendingRoles.includes(role)) { continue; } @@ -1402,10 +672,8 @@ export async function removePairedDeviceRole(params: { continue; } const pendingScopes = Array.isArray(pending.scopes) - ? mergeScopes( - ...nextPendingRoles.map((entry) => - preserveRoleScopedApprovalScopes(entry, pending.scopes), - ), + ? mergeDevicePairingScopes( + ...nextPendingRoles.map((entry) => preserveDeviceRoleScopes(entry, pending.scopes)), ) : undefined; state.pendingById[requestId] = { @@ -1418,8 +686,8 @@ export async function removePairedDeviceRole(params: { const scopeBaseline = device.approvedScopes ?? device.scopes; const preservedScopes = Array.isArray(scopeBaseline) - ? mergeScopes( - ...remainingRoles.map((entry) => preserveRoleScopedApprovalScopes(entry, scopeBaseline)), + ? mergeDevicePairingScopes( + ...remainingRoles.map((entry) => preserveDeviceRoleScopes(entry, scopeBaseline)), ) : undefined; const next: PairedDevice = { @@ -1450,8 +718,8 @@ export async function updatePairedDeviceMetadata( baseDir?: string, ): Promise { return await withLock(async () => { - const state = await loadState(baseDir); - const normalizedDeviceId = normalizeDeviceId(deviceId); + const state = await loadDevicePairingState(baseDir); + const normalizedDeviceId = normalizeDevicePairingId(deviceId); const existing = state.pairedByDeviceId[normalizedDeviceId]; if (!existing) { return false; @@ -1520,290 +788,3 @@ export async function updatePairedDevicePresence( return updated; }); } - -/** Summarize token metadata without exposing bearer token strings. */ -export function summarizeDeviceTokens( - tokens: Record | undefined, -): DeviceAuthTokenSummary[] | undefined { - if (!tokens) { - return undefined; - } - const summaries = Object.values(tokens) - .map((token) => ({ - role: token.role, - scopes: token.scopes, - createdAtMs: token.createdAtMs, - rotatedAtMs: token.rotatedAtMs, - revokedAtMs: token.revokedAtMs, - lastUsedAtMs: token.lastUsedAtMs, - })) - .toSorted((a, b) => a.role.localeCompare(b.role)); - return summaries.length > 0 ? summaries : undefined; -} - -/** Verify a device role token, scope it to the approval baseline, and mark last use. */ -export async function verifyDeviceToken(params: { - deviceId: string; - token: string; - role: string; - scopes: string[]; - requiredSharedGatewaySessionGeneration?: string; - baseDir?: string; -}): Promise<{ ok: boolean; reason?: string; issuer?: DeviceAuthToken["issuer"] }> { - return await withLock(async () => { - const state = await loadState(params.baseDir); - const device = getPairedDeviceFromState(state, params.deviceId); - if (!device) { - return { ok: false, reason: "device-not-paired" }; - } - const role = normalizeRole(params.role); - if (!role) { - return { ok: false, reason: "role-missing" }; - } - const entry = device.tokens?.[role]; - if (!entry) { - return { ok: false, reason: "token-missing" }; - } - if (entry.revokedAtMs) { - return { ok: false, reason: "token-revoked" }; - } - if (!verifyPairingToken(params.token, entry.token)) { - return { ok: false, reason: "token-mismatch" }; - } - if ( - entry.issuer?.kind === SHARED_GATEWAY_AUTH_ISSUER_KIND && - entry.issuer.generation !== params.requiredSharedGatewaySessionGeneration - ) { - return { ok: false, reason: "issuer-generation-stale" }; - } - if ( - !entry.issuer && - params.requiredSharedGatewaySessionGeneration !== undefined && - isBrowserRelatedPairedDevice(device) - ) { - return { ok: false, reason: "legacy-browser-token" }; - } - const approvedScopes = resolveApprovedDeviceScopeBaseline(device); - if ( - !scopesWithinApprovedDeviceBaseline({ - role, - scopes: entry.scopes, - approvedScopes, - }) - ) { - return { ok: false, reason: "scope-mismatch" }; - } - const requestedScopes = normalizeDeviceAuthScopes(params.scopes); - if (!roleScopesAllow({ role, requestedScopes, allowedScopes: entry.scopes })) { - return { ok: false, reason: "scope-mismatch" }; - } - const now = Date.now(); - entry.lastUsedAtMs = now; - device.tokens ??= {}; - device.tokens[role] = entry; - device.lastSeenAtMs = now; - device.lastSeenReason = "device-token-auth"; - state.pairedByDeviceId[device.deviceId] = device; - persistState(state, params.baseDir, "paired"); - return entry.issuer ? { ok: true, issuer: entry.issuer } : { ok: true }; - }); -} - -/** Return a reusable token for a role or issue one within the approved scope baseline. */ -export async function ensureDeviceToken(params: { - deviceId: string; - role: string; - scopes: string[]; - issuer?: DeviceAuthToken["issuer"]; - baseDir?: string; -}): Promise { - return await withLock(async () => { - const state = await loadState(params.baseDir); - const requestedScopes = normalizeDeviceAuthScopes(params.scopes); - const context = resolveDeviceTokenUpdateContext({ - state, - deviceId: params.deviceId, - role: params.role, - }); - if (!context) { - return null; - } - const { device, role, tokens, existing } = context; - const previousNodeGeneration = resolveNodePairingGeneration(device); - const approvedScopes = resolveApprovedDeviceScopeBaseline(device); - if ( - !scopesWithinApprovedDeviceBaseline({ - role, - scopes: requestedScopes, - approvedScopes, - }) - ) { - return null; - } - if (existing && !existing.revokedAtMs) { - const existingWithinApproved = scopesWithinApprovedDeviceBaseline({ - role, - scopes: existing.scopes, - approvedScopes, - }); - const issuerAllowsReuse = deviceTokenIssuerMatches(existing, params.issuer); - if ( - existingWithinApproved && - issuerAllowsReuse && - roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes }) - ) { - return existing; - } - } - const now = Date.now(); - const next = buildDeviceAuthToken({ - role, - scopes: requestedScopes, - issuer: params.issuer, - existing, - now, - rotatedAtMs: existing ? now : undefined, - }); - tokens[role] = next; - device.tokens = tokens; - clearNodePairingGenerationBins(device, previousNodeGeneration); - state.pairedByDeviceId[device.deviceId] = device; - persistState(state, params.baseDir, "paired"); - return next; - }); -} - -function resolveDeviceTokenUpdateContext(params: { - state: DevicePairingStateFile; - deviceId: string; - role: string; -}): { - device: PairedDevice; - role: string; - tokens: Record; - existing: DeviceAuthToken | undefined; -} | null { - const device = getPairedDeviceFromState(params.state, params.deviceId); - if (!device) { - return null; - } - const role = normalizeRole(params.role); - if (!role) { - return null; - } - // Token issuance and rotation must stay inside the role set that pairing - // approval recorded for this device. - if (!listApprovedPairedDeviceRoles(device).includes(role)) { - return null; - } - const tokens = cloneDeviceTokens(device); - const existing = tokens[role]; - return { device, role, tokens, existing }; -} - -/** Rotate a role token inside the device's approved scope baseline. */ -export async function rotateDeviceToken(params: { - deviceId: string; - role: string; - scopes?: string[]; - callerScopes?: readonly string[]; - baseDir?: string; -}): Promise { - return await withLock(async () => { - const state = await loadState(params.baseDir); - const context = resolveDeviceTokenUpdateContext({ - state, - deviceId: params.deviceId, - role: params.role, - }); - if (!context) { - return { ok: false, reason: "unknown-device-or-role" }; - } - const { device, role, tokens, existing } = context; - const previousNodeGeneration = resolveNodePairingGeneration(device); - const requestedScopes = normalizeDeviceAuthScopes( - params.scopes ?? existing?.scopes ?? device.scopes, - ); - const approvedScopes = resolveApprovedDeviceScopeBaseline(device); - if (!approvedScopes) { - return { ok: false, reason: "missing-approved-scope-baseline" }; - } - if ( - !scopesWithinApprovedDeviceBaseline({ - role, - scopes: requestedScopes, - approvedScopes, - }) - ) { - return { ok: false, reason: "scope-outside-approved-baseline" }; - } - if (params.callerScopes) { - const missingScope = resolveMissingRequestedScope({ - role, - requestedScopes, - allowedScopes: params.callerScopes, - }); - if (missingScope) { - return { ok: false, reason: "caller-missing-scope", scope: missingScope }; - } - } - const now = Date.now(); - const next = buildDeviceAuthToken({ - role, - scopes: requestedScopes, - existing, - preserveExistingIssuer: true, - now, - rotatedAtMs: now, - }); - tokens[role] = next; - device.tokens = tokens; - clearNodePairingGenerationBins(device, previousNodeGeneration); - state.pairedByDeviceId[device.deviceId] = device; - persistState(state, params.baseDir, "paired"); - return { ok: true, entry: next }; - }); -} - -/** Revoke one active role token after optional caller-scope authorization. */ -export async function revokeDeviceToken(params: { - deviceId: string; - role: string; - callerScopes?: readonly string[]; - baseDir?: string; -}): Promise { - return await withLock(async () => { - const state = await loadState(params.baseDir); - const context = resolveDeviceTokenUpdateContext({ - state, - deviceId: params.deviceId, - role: params.role, - }); - if (!context || !context.existing) { - return { ok: false, reason: "unknown-device-or-role" }; - } - const { device, role, tokens, existing } = context; - const previousNodeGeneration = resolveNodePairingGeneration(device); - const targetScopes = normalizeDeviceAuthScopes( - Array.isArray(existing.scopes) ? existing.scopes : device.scopes, - ); - if (params.callerScopes) { - const missingScope = resolveMissingRequestedScope({ - role, - requestedScopes: targetScopes, - allowedScopes: params.callerScopes, - }); - if (missingScope) { - return { ok: false, reason: "caller-missing-scope", scope: missingScope }; - } - } - const entry = { ...existing, revokedAtMs: Date.now() }; - tokens[role] = entry; - device.tokens = tokens; - clearNodePairingGenerationBins(device, previousNodeGeneration); - state.pairedByDeviceId[device.deviceId] = device; - persistState(state, params.baseDir, "paired"); - return { ok: true, entry }; - }); -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/infra/node-pairing-migration.test.ts b/src/infra/node-pairing-migration.test.ts index 6d9cf5ddf676..617fad973be5 100644 --- a/src/infra/node-pairing-migration.test.ts +++ b/src/infra/node-pairing-migration.test.ts @@ -3,13 +3,14 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { approveDevicePairing } from "./device-pairing-approval.js"; import { approveNodePairing, listNodePairing, recordPairedNodeConnection, requestNodePairing, } from "./device-pairing-node.js"; -import { approveDevicePairing, getPairedDevice, requestDevicePairing } from "./device-pairing.js"; +import { getPairedDevice, requestDevicePairing } from "./device-pairing.js"; import { migrateLegacyNodePairingStore } from "./node-pairing-migration.js"; import { resolvePairingPaths } from "./pairing-files.js"; diff --git a/src/plugin-sdk/device-bootstrap.ts b/src/plugin-sdk/device-bootstrap.ts index a8a9efee556a..1cf69a44c2f4 100644 --- a/src/plugin-sdk/device-bootstrap.ts +++ b/src/plugin-sdk/device-bootstrap.ts @@ -1,6 +1,7 @@ // Shared bootstrap/pairing helpers for plugins that provision remote devices. -export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; +export { approveDevicePairing } from "../infra/device-pairing-approval.js"; +export { listDevicePairing } from "../infra/device-pairing.js"; export { clearDeviceBootstrapTokens, issueDeviceBootstrapToken,