mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user