From c791e4242bc84cd962ca4766cd4431920c730e1b Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Mon, 25 May 2026 11:34:12 +0300 Subject: [PATCH] fix(gateway): gate talk secret bootstrap handoff (#85690) Merged via squash. Prepared head SHA: 9247cdab05aea1d909a9757915adb94b895a9691 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman --- CHANGELOG.md | 2 + docs/cli/qr.md | 2 +- docs/gateway/protocol.md | 13 ++-- extensions/device-pair/index.test.ts | 65 +++++++++++++----- extensions/device-pair/index.ts | 11 +++- .../device-pair/pair-command-auth.test.ts | 19 +++++- extensions/device-pair/pair-command-auth.ts | 27 ++++++-- src/gateway/server.auth.control-ui.suite.ts | 13 +++- .../server/ws-connection/message-handler.ts | 66 +++++++++---------- src/infra/device-bootstrap.test.ts | 10 +-- src/infra/device-pairing.test.ts | 5 +- src/pairing/setup-code.test.ts | 2 +- src/shared/device-bootstrap-profile.test.ts | 43 ++++++++++-- src/shared/device-bootstrap-profile.ts | 17 +++++ 14 files changed, 218 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 945838d0b0d..f4c2424e406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ Docs: https://docs.openclaw.ai - Google Vertex: support production ADC modes such as Workload Identity Federation, service-account credentials, and metadata-server ADC for the native Vertex transport. (#83971) Thanks @damianFelixPago. - Telegram: route normal `[telegram][diag]` polling diagnostics through `runtime.log` while keeping non-diag warnings and persistence failures on `runtime.error`, so healthy polling startup no longer looks like an error. Fixes #82957. (#82958) Thanks @galiniliev. +- Gateway: require Talk secret authority before setup-code handoff can include Talk secrets. (#85690) Thanks @ngutman. + ## 2026.5.25 ### Fixes diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 45e5719ba8a..abb612e35ef 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -36,7 +36,7 @@ openclaw qr --url wss://gateway.example/ws - `--token` and `--password` are mutually exclusive. - The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password. - Built-in setup-code bootstrap returns a primary `node` token with `scopes: []` plus a bounded `operator` handoff token for trusted mobile onboarding. -- The handed-off operator token is limited to `operator.approvals`, `operator.read`, and `operator.write`; `operator.admin`, `operator.pairing`, and `operator.talk.secrets` require a separate approved operator pairing or token flow. +- The handed-off operator token is limited to `operator.approvals`, `operator.read`, `operator.talk.secrets`, and `operator.write`; `operator.admin` and `operator.pairing` require a separate approved operator pairing or token flow. - Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL. - With `--remote`, OpenClaw requires either `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel`. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 8986d5adf46..8e8bec2e0a8 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -161,7 +161,7 @@ operator token: { "deviceToken": "…", "role": "operator", - "scopes": ["operator.approvals", "operator.read", "operator.write"] + "scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"] } ] } @@ -169,9 +169,11 @@ operator token: ``` The operator handoff is intentionally bounded so QR onboarding can start the -mobile operator loop without granting `operator.admin`, `operator.pairing`, or -`operator.talk.secrets`. Those scopes require a separate approved operator -pairing or token flow. Clients should persist `hello-ok.auth.deviceTokens` only +mobile operator loop without granting `operator.admin` or `operator.pairing`. +It does include `operator.talk.secrets` so the native client can read the Talk +configuration it needs after bootstrap. Broader admin and pairing scopes require +a separate approved operator pairing or token flow. Clients should persist +`hello-ok.auth.deviceTokens` only when the connect used bootstrap auth on trusted transport such as `wss://` or loopback/local pairing. @@ -705,7 +707,8 @@ rather than the pre-handshake defaults. - Built-in setup-code bootstrap returns the primary node `hello-ok.auth.deviceToken` plus a bounded operator token in `hello-ok.auth.deviceTokens` for trusted mobile handoff. The operator token - excludes `operator.admin`, `operator.pairing`, and `operator.talk.secrets`. + includes `operator.talk.secrets` for native Talk configuration reads and + excludes `operator.admin` and `operator.pairing`. - While a non-baseline setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED` details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`, and `pauseReconnect: false`. Clients should keep reconnecting with the same diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 44c16f54783..1f5f2fba6d4 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -89,6 +89,7 @@ type ApprovedPairingResult = Extract< >; type ApprovedPairingDevice = ApprovedPairingResult["device"]; const INTERNAL_PAIRING_SCOPES = ["operator.write", "operator.pairing"]; +const INTERNAL_SETUP_SCOPES = [...INTERNAL_PAIRING_SCOPES, "operator.talk.secrets"]; function createApi(params?: { config?: OpenClawPluginApi["config"]; @@ -286,7 +287,7 @@ describe("device-pair /pair qr", () => { const result = await command.handler( createCommandContext({ channel: "webchat", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); const payload = result as { text?: string; mediaUrl?: string; sensitiveMedia?: boolean }; @@ -342,6 +343,23 @@ describe("device-pair /pair qr", () => { }); }); + it("rejects qr setup for internal callers without Talk secret scope", async () => { + const command = registerPairCommand(); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "qr", + commandBody: "/pair qr", + gatewayClientScopes: INTERNAL_PAIRING_SCOPES, + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(result).toEqual({ + text: "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.", + }); + }); + it("reissues the bootstrap token if webchat QR rendering fails before falling back", async () => { pluginApiMocks.issueDeviceBootstrapToken .mockResolvedValueOnce({ @@ -358,7 +376,7 @@ describe("device-pair /pair qr", () => { const result = await command.handler( createCommandContext({ channel: "webchat", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); const text = requireText(result); @@ -478,7 +496,7 @@ describe("device-pair /pair qr", () => { const result = await command.handler( createCommandContext({ ...testCase.ctx, - gatewayClientScopes: INTERNAL_PAIRING_SCOPES, + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); const text = requireText(result); @@ -538,7 +556,7 @@ describe("device-pair /pair qr", () => { createCommandContext({ channel: "discord", senderId: "123", - gatewayClientScopes: INTERNAL_PAIRING_SCOPES, + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); const text = requireText(result); @@ -557,7 +575,7 @@ describe("device-pair /pair qr", () => { createCommandContext({ channel: "msteams", senderId: "8:orgid:123", - gatewayClientScopes: INTERNAL_PAIRING_SCOPES, + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); const text = requireText(result); @@ -678,6 +696,23 @@ describe("device-pair /pair default setup code", () => { }); }); + it("rejects setup code issuance for internal callers without Talk secret scope", async () => { + const command = registerPairCommand(); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: INTERNAL_PAIRING_SCOPES, + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(result).toEqual({ + text: "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.", + }); + }); + it("fails closed for webchat setup code issuance when scopes are absent", async () => { const command = registerPairCommand(); const result = await command.handler( @@ -749,7 +784,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); const text = requireText(result); @@ -769,7 +804,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); const text = requireText(result); @@ -789,7 +824,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); @@ -808,7 +843,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); @@ -827,7 +862,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); @@ -861,7 +896,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); @@ -890,7 +925,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); const text = requireText(result); @@ -910,7 +945,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); @@ -940,7 +975,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); @@ -967,7 +1002,7 @@ describe("device-pair /pair default setup code", () => { channel: "webchat", args: "", commandBody: "/pair", - gatewayClientScopes: ["operator.write", "operator.pairing"], + gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index daf43d8ee66..eb36a766070 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -672,8 +672,11 @@ export default definePluginEntry({ const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes) ? ctx.gatewayClientScopes : undefined; - const { buildMissingPairingScopeReply, resolvePairingCommandAuthState } = - await loadPairCommandAuthModule(); + const { + buildMissingPairingScopeReply, + buildMissingSetupHandoffScopeReply, + resolvePairingCommandAuthState, + } = await loadPairCommandAuthModule(); const authState = resolvePairingCommandAuthState({ channel: ctx.channel, gatewayClientScopes, @@ -742,6 +745,10 @@ export default definePluginEntry({ }; } + if (authState.isMissingSetupHandoffPrivilege) { + return buildMissingSetupHandoffScopeReply(); + } + const authLabelResult = resolveAuthLabel(api.config); if (authLabelResult.error) { return { text: `Error: ${authLabelResult.error}` }; diff --git a/extensions/device-pair/pair-command-auth.test.ts b/extensions/device-pair/pair-command-auth.test.ts index a021b359229..00295a2e125 100644 --- a/extensions/device-pair/pair-command-auth.test.ts +++ b/extensions/device-pair/pair-command-auth.test.ts @@ -11,6 +11,7 @@ describe("device-pair pairing command auth", () => { ).toEqual({ isInternalGatewayCaller: false, isMissingPairingPrivilege: true, + isMissingSetupHandoffPrivilege: true, approvalCallerScopes: undefined, }); }); @@ -25,6 +26,7 @@ describe("device-pair pairing command auth", () => { ).toEqual({ isInternalGatewayCaller: false, isMissingPairingPrivilege: false, + isMissingSetupHandoffPrivilege: false, approvalCallerScopes: ["operator.pairing"], }); }); @@ -38,11 +40,12 @@ describe("device-pair pairing command auth", () => { ).toEqual({ isInternalGatewayCaller: true, isMissingPairingPrivilege: true, + isMissingSetupHandoffPrivilege: true, approvalCallerScopes: [], }); }); - it("accepts pairing and admin scopes for internal callers", () => { + it("tracks pairing and setup-handoff privileges independently for internal callers", () => { expect( resolvePairingCommandAuthState({ channel: "webchat", @@ -51,8 +54,20 @@ describe("device-pair pairing command auth", () => { ).toEqual({ isInternalGatewayCaller: true, isMissingPairingPrivilege: false, + isMissingSetupHandoffPrivilege: true, approvalCallerScopes: ["operator.write", "operator.pairing"], }); + expect( + resolvePairingCommandAuthState({ + channel: "webchat", + gatewayClientScopes: ["operator.write", "operator.pairing", "operator.talk.secrets"], + }), + ).toEqual({ + isInternalGatewayCaller: true, + isMissingPairingPrivilege: false, + isMissingSetupHandoffPrivilege: false, + approvalCallerScopes: ["operator.write", "operator.pairing", "operator.talk.secrets"], + }); expect( resolvePairingCommandAuthState({ channel: "webchat", @@ -61,6 +76,7 @@ describe("device-pair pairing command auth", () => { ).toEqual({ isInternalGatewayCaller: true, isMissingPairingPrivilege: false, + isMissingSetupHandoffPrivilege: false, approvalCallerScopes: ["operator.admin"], }); }); @@ -75,6 +91,7 @@ describe("device-pair pairing command auth", () => { ).toEqual({ isInternalGatewayCaller: true, isMissingPairingPrivilege: false, + isMissingSetupHandoffPrivilege: true, approvalCallerScopes: ["operator.write", "operator.pairing"], }); }); diff --git a/extensions/device-pair/pair-command-auth.ts b/extensions/device-pair/pair-command-auth.ts index c70e4ef626a..21235fe6336 100644 --- a/extensions/device-pair/pair-command-auth.ts +++ b/extensions/device-pair/pair-command-auth.ts @@ -7,15 +7,27 @@ type PairingCommandAuthParams = { type PairingCommandAuthState = { isInternalGatewayCaller: boolean; isMissingPairingPrivilege: boolean; + isMissingSetupHandoffPrivilege: boolean; approvalCallerScopes?: readonly string[]; }; const COMMAND_OWNER_PAIRING_SCOPES = ["operator.pairing"] as const; +const PAIRING_SCOPE = "operator.pairing"; +const ADMIN_SCOPE = "operator.admin"; +const TALK_SECRETS_SCOPE = "operator.talk.secrets"; function isInternalGatewayPairingCaller(params: PairingCommandAuthParams): boolean { return params.channel === "webchat" || Array.isArray(params.gatewayClientScopes); } +function hasPairingPrivilege(scopes: readonly string[]): boolean { + return scopes.includes(PAIRING_SCOPE) || scopes.includes(ADMIN_SCOPE); +} + +function hasSetupHandoffPrivilege(scopes: readonly string[]): boolean { + return scopes.includes(TALK_SECRETS_SCOPE) || scopes.includes(ADMIN_SCOPE); +} + export function resolvePairingCommandAuthState( params: PairingCommandAuthParams, ): PairingCommandAuthState { @@ -24,13 +36,10 @@ export function resolvePairingCommandAuthState( const approvalCallerScopes = Array.isArray(params.gatewayClientScopes) ? params.gatewayClientScopes : []; - const isMissingPairingPrivilege = - !approvalCallerScopes.includes("operator.pairing") && - !approvalCallerScopes.includes("operator.admin"); - return { isInternalGatewayCaller, - isMissingPairingPrivilege, + isMissingPairingPrivilege: !hasPairingPrivilege(approvalCallerScopes), + isMissingSetupHandoffPrivilege: !hasSetupHandoffPrivilege(approvalCallerScopes), approvalCallerScopes, }; } @@ -39,6 +48,7 @@ export function resolvePairingCommandAuthState( return { isInternalGatewayCaller, isMissingPairingPrivilege: false, + isMissingSetupHandoffPrivilege: false, approvalCallerScopes: COMMAND_OWNER_PAIRING_SCOPES, }; } @@ -46,6 +56,7 @@ export function resolvePairingCommandAuthState( return { isInternalGatewayCaller, isMissingPairingPrivilege: true, + isMissingSetupHandoffPrivilege: true, approvalCallerScopes: undefined, }; } @@ -55,3 +66,9 @@ export function buildMissingPairingScopeReply(): { text: string } { text: "⚠️ This command requires operator.pairing.", }; } + +export function buildMissingSetupHandoffScopeReply(): { text: string } { + return { + text: "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.", + }; +} diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 92c43f35ec8..2e22035303e 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -1091,6 +1091,7 @@ export function registerControlUiAndPairingSuite(): void { expect(operatorHandoff?.scopes).toEqual([ "operator.approvals", "operator.read", + "operator.talk.secrets", "operator.write", ]); expect(operatorHandoff?.scopes).not.toContain("operator.admin"); @@ -1112,6 +1113,7 @@ export function registerControlUiAndPairingSuite(): void { expect(paired?.approvedScopes).toEqual([ "operator.approvals", "operator.read", + "operator.talk.secrets", "operator.write", ]); expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken); @@ -1120,6 +1122,7 @@ export function registerControlUiAndPairingSuite(): void { expect(paired?.tokens?.operator?.scopes).toEqual([ "operator.approvals", "operator.read", + "operator.talk.secrets", "operator.write", ]); @@ -1173,7 +1176,12 @@ export function registerControlUiAndPairingSuite(): void { deviceId: identity.deviceId, token: issuedOperatorToken, role: "operator", - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: [ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], }), ).resolves.toEqual({ ok: true }); await expect( @@ -1226,7 +1234,7 @@ export function registerControlUiAndPairingSuite(): void { publicKey, role: "node", roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], clientId: client.id, clientMode: client.mode, displayName: client.id, @@ -1265,6 +1273,7 @@ export function registerControlUiAndPairingSuite(): void { expect(operatorHandoff?.scopes).toEqual([ "operator.approvals", "operator.read", + "operator.talk.secrets", "operator.write", ]); expect(operatorHandoff?.scopes).not.toContain("operator.admin"); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 01a059ba301..6262028112d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -44,9 +44,9 @@ import { rawDataToString } from "../../../infra/ws.js"; import { logRejectedLargePayload } from "../../../logging/diagnostic-payload.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; import { - BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, - PAIRING_SETUP_BOOTSTRAP_PROFILE, + isPairingSetupBootstrapProfile, resolveBootstrapProfileScopesForRole, + resolveBootstrapProfileScopesForRoles, type DeviceBootstrapProfile, } from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; @@ -154,19 +154,6 @@ type SubsystemLogger = ReturnType; const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000; -function sameBootstrapProfile( - left: DeviceBootstrapProfile, - right: DeviceBootstrapProfile, -): boolean { - if (left.roles.length !== right.roles.length || left.scopes.length !== right.scopes.length) { - return false; - } - return ( - left.roles.every((role, index) => role === right.roles[index]) && - left.scopes.every((scope, index) => scope === right.scopes[index]) - ); -} - export type WsOriginCheckMetrics = { hostHeaderFallbackAccepted: number; }; @@ -1117,15 +1104,23 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar : null; const allowSilentBootstrapPairing = boundBootstrapProfile !== null && - sameBootstrapProfile(boundBootstrapProfile, PAIRING_SETUP_BOOTSTRAP_PROFILE); + isPairingSetupBootstrapProfile(boundBootstrapProfile); // This is the native QR/setup-code onboarding seam. Mobile clients // connect as node with bootstrap auth, then clear bootstrap auth and // start their operator loop only if hello-ok includes the bounded - // operator token below. Keep this limited to the exact fresh baseline - // profile; admin/pairing scopes still require an explicit owner flow. + // operator token below. Keep this limited to the exact current + // setup-code profile; admin/pairing scopes still require an explicit + // owner flow. const bootstrapPairingRoles = allowSilentBootstrapPairing ? Array.from(new Set([role, ...boundBootstrapProfile.roles])) : undefined; + const bootstrapPairingScopes = + allowSilentBootstrapPairing && bootstrapPairingRoles + ? resolveBootstrapProfileScopesForRoles( + bootstrapPairingRoles, + boundBootstrapProfile.scopes, + ) + : undefined; const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, @@ -1133,7 +1128,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar ...(bootstrapPairingRoles ? { roles: bootstrapPairingRoles, - scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], + scopes: bootstrapPairingScopes ?? [], } : {}), silent: @@ -1383,26 +1378,31 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar !isBrowserOperatorUi && !isWebchat && connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE && - pairedRoles.includes("operator") && - roleScopesAllow({ - role: "operator", - requestedScopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, - allowedScopes: pairedScopes, - }) + pairedRoles.includes("operator") ? await getBoundDeviceBootstrapProfile({ token: bootstrapTokenCandidate, deviceId: device.id, publicKey: devicePublicKey, }) : null; - if ( - retryBootstrapHandoffProfile && - sameBootstrapProfile(retryBootstrapHandoffProfile, PAIRING_SETUP_BOOTSTRAP_PROFILE) - ) { - // If the first QR bootstrap hello-ok failed to reach mobile, the - // bootstrap token is restored while the paired device already has - // node+operator grants. Preserve the same bounded handoff on retry. - handoffBootstrapProfile = retryBootstrapHandoffProfile; + if (retryBootstrapHandoffProfile) { + const retryBootstrapOperatorScopes = resolveBootstrapProfileScopesForRole( + "operator", + retryBootstrapHandoffProfile.scopes, + ); + if ( + isPairingSetupBootstrapProfile(retryBootstrapHandoffProfile) && + roleScopesAllow({ + role: "operator", + requestedScopes: retryBootstrapOperatorScopes, + allowedScopes: pairedScopes, + }) + ) { + // If the first QR bootstrap hello-ok failed to reach mobile, the + // bootstrap token is restored while the paired device already has + // node+operator grants. Preserve the same bounded handoff on retry. + handoffBootstrapProfile = retryBootstrapHandoffProfile; + } } // Metadata pinning is approval-bound. Reconnects can update access metadata diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 92b7f00549f..409f3c28ebc 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -72,7 +72,7 @@ describe("device bootstrap tokens", () => { expect(parsed[issued.token]?.issuedAtMs).toBe(Date.now()); expect(parsed[issued.token]?.profile).toEqual({ roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }); }); @@ -158,7 +158,7 @@ describe("device bootstrap tokens", () => { await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual( { roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }, ); await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull(); @@ -399,7 +399,7 @@ describe("device bootstrap tokens", () => { await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual( { roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }, ); await expect( @@ -463,7 +463,7 @@ describe("device bootstrap tokens", () => { >; expect(parsed[issued.token]?.redeemedProfile).toEqual({ roles: ["operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }); }); @@ -545,7 +545,7 @@ describe("device bootstrap tokens", () => { }), ).resolves.toEqual({ roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }); }); diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 2bff191d92f..91529c6fcba 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -1070,7 +1070,7 @@ describe("device pairing tokens", () => { publicKey: "bootstrap-public-key-operator-default", role: "node", roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], silent: true, }, baseDir, @@ -1089,6 +1089,7 @@ describe("device pairing tokens", () => { expect(paired?.tokens?.operator?.scopes).toStrictEqual([ "operator.approvals", "operator.read", + "operator.talk.secrets", "operator.write", ]); await expect( @@ -1096,7 +1097,7 @@ describe("device pairing tokens", () => { deviceId: "bootstrap-device-operator-default", token: operatorToken, role: "operator", - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], baseDir, }), ).resolves.toEqual({ ok: true }); diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index bb14a145cb1..b0cb70d6fe2 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -92,7 +92,7 @@ describe("pairing setup code", () => { baseDir: undefined, profile: { roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }, }); if (params.url) { diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts index db9f12be7c8..3c3397756e9 100644 --- a/src/shared/device-bootstrap-profile.test.ts +++ b/src/shared/device-bootstrap-profile.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, PAIRING_SETUP_BOOTSTRAP_PROFILE, + isPairingSetupBootstrapProfile, normalizeDeviceBootstrapHandoffProfile, resolveBootstrapProfileScopesForRole, resolveBootstrapProfileScopesForRoles, @@ -16,9 +17,10 @@ describe("device bootstrap profile", () => { "operator.approvals", "operator.pairing", "operator.read", + "operator.talk.secrets", "operator.write", ]), - ).toEqual(["operator.approvals", "operator.read", "operator.write"]); + ).toEqual(["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]); expect( resolveBootstrapProfileScopesForRole("node", ["node.exec", "operator.approvals"]), @@ -29,9 +31,16 @@ describe("device bootstrap profile", () => { expect( resolveBootstrapProfileScopesForRoles( ["node", "operator"], - ["node.exec", "operator.admin", "operator.approvals", "operator.read", "operator.write"], + [ + "node.exec", + "operator.admin", + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], ), - ).toEqual(["operator.approvals", "operator.read", "operator.write"]); + ).toEqual(["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]); expect( resolveBootstrapProfileScopesForRoles(["node"], ["node.exec", "operator.admin"]), @@ -48,26 +57,50 @@ describe("device bootstrap profile", () => { "operator.approvals", "operator.pairing", "operator.read", + "operator.talk.secrets", "operator.write", ], }), ).toEqual({ roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }); }); test("default setup profile carries node plus bounded operator handoff", () => { expect(PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }); }); + test("recognizes only the current setup profile", () => { + expect(isPairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true); + expect( + isPairingSetupBootstrapProfile({ + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.write"], + }), + ).toBe(false); + expect( + isPairingSetupBootstrapProfile({ + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.pairing", "operator.read", "operator.write"], + }), + ).toBe(false); + expect( + isPairingSetupBootstrapProfile({ + roles: ["node", "operator"], + scopes: ["operator.admin", "operator.approvals", "operator.read", "operator.write"], + }), + ).toBe(false); + }); + test("bootstrap handoff operator allowlist stays bounded", () => { expect([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]).toEqual([ "operator.approvals", "operator.read", + "operator.talk.secrets", "operator.write", ]); }); diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index d3eb1437a7e..cfecf1ddc2f 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -13,6 +13,7 @@ export type DeviceBootstrapProfileInput = { export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [ "operator.approvals", "operator.read", + "operator.talk.secrets", "operator.write", ] as const; @@ -26,6 +27,22 @@ export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], }; +export function isPairingSetupBootstrapProfile( + input: DeviceBootstrapProfileInput | undefined, +): boolean { + const profile = normalizeDeviceBootstrapProfile(input); + if (profile.roles.length !== PAIRING_SETUP_BOOTSTRAP_PROFILE.roles.length) { + return false; + } + if (profile.scopes.length !== PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes.length) { + return false; + } + return ( + profile.roles.every((role, index) => role === PAIRING_SETUP_BOOTSTRAP_PROFILE.roles[index]) && + profile.scopes.every((scope, index) => scope === PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes[index]) + ); +} + export function resolveBootstrapProfileScopesForRole( role: string, scopes: readonly string[],