fix(gateway): gate talk secret bootstrap handoff (#85690)

Merged via squash.

Prepared head SHA: 9247cdab05
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
Nimrod Gutman
2026-05-25 11:34:12 +03:00
committed by GitHub
parent 35dcd42c9d
commit c791e4242b
14 changed files with 218 additions and 77 deletions

View File

@@ -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

View File

@@ -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`.

View File

@@ -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

View File

@@ -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,
}),
);

View File

@@ -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}` };

View File

@@ -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"],
});
});

View File

@@ -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.",
};
}

View File

@@ -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");

View File

@@ -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<typeof createSubsystemLogger>;
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

View File

@@ -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"],
});
});

View File

@@ -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 });

View File

@@ -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) {

View File

@@ -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",
]);
});

View File

@@ -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[],