mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-07 07:11:06 +00:00
fix(gateway): track bootstrap profile redemption
This commit is contained in:
committed by
Peter Steinberger
parent
0891253012
commit
b08d58c917
@@ -872,6 +872,18 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
callerScopes: pendingRequest.scopes ?? ["operator.admin"],
|
||||
});
|
||||
|
||||
const wsNodeReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const nodeReconnect = await connectReq(wsNodeReconnect, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: nodeClient,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(nodeReconnect.ok).toBe(true);
|
||||
wsNodeReconnect.close();
|
||||
|
||||
const wsOperatorApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const operatorApproved = await connectReq(wsOperatorApproved, {
|
||||
skipDefaultAuth: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { WebSocket } from "ws";
|
||||
import { loadConfig } from "../../../config/config.js";
|
||||
import {
|
||||
getDeviceBootstrapTokenProfile,
|
||||
redeemDeviceBootstrapTokenProfile,
|
||||
revokeDeviceBootstrapToken,
|
||||
verifyDeviceBootstrapToken,
|
||||
} from "../../../infra/device-bootstrap.js";
|
||||
@@ -32,7 +33,6 @@ import { upsertPresence } from "../../../infra/system-presence.js";
|
||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
import { rawDataToString } from "../../../infra/ws.js";
|
||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||
import {
|
||||
isBrowserOperatorUiClient,
|
||||
@@ -146,44 +146,6 @@ function resolvePinnedClientMetadata(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBootstrapProfileScopes(role: string, scopes: readonly string[]): string[] {
|
||||
if (role === "operator") {
|
||||
return scopes.filter((scope) => scope.startsWith("operator."));
|
||||
}
|
||||
return scopes.filter((scope) => !scope.startsWith("operator."));
|
||||
}
|
||||
|
||||
function pairedDeviceSatisfiesBootstrapProfile(
|
||||
pairedDevice: Awaited<ReturnType<typeof getPairedDevice>>,
|
||||
bootstrapProfile: DeviceBootstrapProfile,
|
||||
): boolean {
|
||||
if (!pairedDevice) {
|
||||
return false;
|
||||
}
|
||||
const approvedScopes = Array.isArray(pairedDevice.approvedScopes)
|
||||
? pairedDevice.approvedScopes
|
||||
: Array.isArray(pairedDevice.scopes)
|
||||
? pairedDevice.scopes
|
||||
: [];
|
||||
for (const bootstrapRole of bootstrapProfile.roles) {
|
||||
if (!hasEffectivePairedDeviceRole(pairedDevice, bootstrapRole)) {
|
||||
return false;
|
||||
}
|
||||
const requestedScopes = resolveBootstrapProfileScopes(bootstrapRole, bootstrapProfile.scopes);
|
||||
if (
|
||||
requestedScopes.length > 0 &&
|
||||
!roleScopesAllow({
|
||||
role: bootstrapRole,
|
||||
requestedScopes,
|
||||
allowedScopes: approvedScopes,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function attachGatewayWsMessageHandler(params: {
|
||||
socket: WebSocket;
|
||||
upgradeReq: IncomingMessage;
|
||||
@@ -1029,16 +991,22 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
authMethod === "bootstrap-token" &&
|
||||
bootstrapProfile &&
|
||||
bootstrapTokenCandidate &&
|
||||
device &&
|
||||
pairedDeviceSatisfiesBootstrapProfile(await getPairedDevice(device.id), bootstrapProfile)
|
||||
device
|
||||
) {
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
const redemption = await redeemDeviceBootstrapTokenProfile({
|
||||
token: bootstrapTokenCandidate,
|
||||
role,
|
||||
scopes,
|
||||
});
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
|
||||
);
|
||||
if (redemption.fullyRedeemed) {
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
token: bootstrapTokenCandidate,
|
||||
});
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
|
||||
getDeviceBootstrapTokenProfile,
|
||||
issueDeviceBootstrapToken,
|
||||
redeemDeviceBootstrapTokenProfile,
|
||||
revokeDeviceBootstrapToken,
|
||||
verifyDeviceBootstrapToken,
|
||||
} from "./device-bootstrap.js";
|
||||
@@ -107,6 +108,42 @@ describe("device bootstrap tokens", () => {
|
||||
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("persists bootstrap redemption state across verification reloads", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
redeemDeviceBootstrapTokenProfile({
|
||||
baseDir,
|
||||
token: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
recorded: true,
|
||||
fullyRedeemed: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
redeemDeviceBootstrapTokenProfile({
|
||||
baseDir,
|
||||
token: issued.token,
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
recorded: true,
|
||||
fullyRedeemed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears outstanding bootstrap tokens on demand", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const first = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
@@ -23,6 +23,7 @@ export type DeviceBootstrapTokenRecord = {
|
||||
deviceId?: string;
|
||||
publicKey?: string;
|
||||
profile?: DeviceBootstrapProfile;
|
||||
redeemedProfile?: DeviceBootstrapProfile;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
issuedAtMs: number;
|
||||
@@ -43,6 +44,12 @@ function resolvePersistedBootstrapProfile(
|
||||
return normalizeDeviceBootstrapProfile(record.profile ?? record);
|
||||
}
|
||||
|
||||
function resolvePersistedRedeemedProfile(
|
||||
record: Partial<DeviceBootstrapTokenRecord>,
|
||||
): DeviceBootstrapProfile {
|
||||
return normalizeDeviceBootstrapProfile(record.redeemedProfile);
|
||||
}
|
||||
|
||||
function resolveIssuedBootstrapProfile(params: {
|
||||
profile?: DeviceBootstrapProfileInput;
|
||||
roles?: readonly string[];
|
||||
@@ -75,6 +82,39 @@ function bootstrapProfileAllowsRequest(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBootstrapProfileScopes(role: string, scopes: readonly string[]): string[] {
|
||||
if (role === "operator") {
|
||||
return scopes.filter((scope) => scope.startsWith("operator."));
|
||||
}
|
||||
return scopes.filter((scope) => !scope.startsWith("operator."));
|
||||
}
|
||||
|
||||
function bootstrapProfileSatisfiesProfile(params: {
|
||||
actualProfile: DeviceBootstrapProfile;
|
||||
requiredProfile: DeviceBootstrapProfile;
|
||||
}): boolean {
|
||||
for (const requiredRole of params.requiredProfile.roles) {
|
||||
if (!params.actualProfile.roles.includes(requiredRole)) {
|
||||
return false;
|
||||
}
|
||||
const requiredScopes = resolveBootstrapProfileScopes(
|
||||
requiredRole,
|
||||
params.requiredProfile.scopes,
|
||||
);
|
||||
if (
|
||||
requiredScopes.length > 0 &&
|
||||
!bootstrapProfileAllowsRequest({
|
||||
allowedProfile: params.actualProfile,
|
||||
requestedRole: requiredRole,
|
||||
requestedScopes: requiredScopes,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
|
||||
const bootstrapPath = resolveBootstrapPath(baseDir);
|
||||
const rawState = (await readJsonFile<DeviceBootstrapStateFile>(bootstrapPath)) ?? {};
|
||||
@@ -94,6 +134,7 @@ async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
|
||||
state[tokenKey] = {
|
||||
token,
|
||||
profile,
|
||||
redeemedProfile: resolvePersistedRedeemedProfile(record),
|
||||
deviceId: typeof record.deviceId === "string" ? record.deviceId : undefined,
|
||||
publicKey: typeof record.publicKey === "string" ? record.publicKey : undefined,
|
||||
issuedAtMs,
|
||||
@@ -127,6 +168,7 @@ export async function issueDeviceBootstrapToken(
|
||||
token,
|
||||
ts: issuedAtMs,
|
||||
profile,
|
||||
redeemedProfile: normalizeDeviceBootstrapProfile(undefined),
|
||||
issuedAtMs,
|
||||
};
|
||||
await persistState(state, params.baseDir);
|
||||
@@ -186,6 +228,49 @@ export async function getDeviceBootstrapTokenProfile(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function redeemDeviceBootstrapTokenProfile(params: {
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: readonly string[];
|
||||
baseDir?: string;
|
||||
}): Promise<{ recorded: boolean; fullyRedeemed: boolean }> {
|
||||
return await withLock(async () => {
|
||||
const providedToken = params.token.trim();
|
||||
if (!providedToken) {
|
||||
return { recorded: false, fullyRedeemed: false };
|
||||
}
|
||||
const state = await loadState(params.baseDir);
|
||||
const found = Object.entries(state).find(([, candidate]) =>
|
||||
verifyPairingToken(providedToken, candidate.token),
|
||||
);
|
||||
if (!found) {
|
||||
return { recorded: false, fullyRedeemed: false };
|
||||
}
|
||||
const [tokenKey, record] = found;
|
||||
const issuedProfile = resolvePersistedBootstrapProfile(record);
|
||||
const redeemedProfile = normalizeDeviceBootstrapProfile({
|
||||
roles: [...resolvePersistedRedeemedProfile(record).roles, params.role],
|
||||
scopes: [
|
||||
...resolvePersistedRedeemedProfile(record).scopes,
|
||||
...resolveBootstrapProfileScopes(params.role, params.scopes),
|
||||
],
|
||||
});
|
||||
state[tokenKey] = {
|
||||
...record,
|
||||
profile: issuedProfile,
|
||||
redeemedProfile,
|
||||
};
|
||||
await persistState(state, params.baseDir);
|
||||
return {
|
||||
recorded: true,
|
||||
fullyRedeemed: bootstrapProfileSatisfiesProfile({
|
||||
actualProfile: redeemedProfile,
|
||||
requiredProfile: issuedProfile,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyDeviceBootstrapToken(params: {
|
||||
token: string;
|
||||
deviceId: string;
|
||||
|
||||
Reference in New Issue
Block a user