mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(marketplaces): allow trusted feed key rotation
* fix(marketplaces): allow trusted feed key rotation * fix(feeds): reject same-sequence payload changes * fix: fail closed on signed feed monotonicity --------- Co-authored-by: Gio Della-Libera <giodl@microsoft.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
/** Persists hosted official external plugin catalog snapshots in OpenClaw state. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import {
|
||||
HostedCatalogSignedFeedMonotonicityError,
|
||||
type HostedOfficialExternalPluginCatalogMetadata,
|
||||
type HostedOfficialExternalPluginCatalogSnapshot,
|
||||
type HostedOfficialExternalPluginCatalogSnapshotMonotonicState,
|
||||
@@ -51,6 +53,7 @@ type HostedCatalogSnapshotDatabase = Pick<
|
||||
type StoredHostedCatalogMonotonicState = {
|
||||
sequence: number;
|
||||
generatedAt?: string;
|
||||
payloadSha256: string;
|
||||
};
|
||||
|
||||
function resolveStoreEnv(
|
||||
@@ -117,9 +120,11 @@ function readMonotonicStateFromBody(body: string): StoredHostedCatalogMonotonicS
|
||||
sequence?: unknown;
|
||||
generatedAt?: unknown;
|
||||
};
|
||||
const payload =
|
||||
typeof document.payload === "string" ? decodeBase64Payload(document.payload) : body;
|
||||
const feed =
|
||||
typeof document.payload === "string"
|
||||
? (JSON.parse(decodeBase64Payload(document.payload)) as {
|
||||
? (JSON.parse(payload) as {
|
||||
sequence?: unknown;
|
||||
generatedAt?: unknown;
|
||||
})
|
||||
@@ -131,11 +136,15 @@ function readMonotonicStateFromBody(body: string): StoredHostedCatalogMonotonicS
|
||||
typeof feed.generatedAt !== "string" ||
|
||||
parseOfficialExternalPluginCatalogTimestamp(feed.generatedAt) === undefined
|
||||
) {
|
||||
return { sequence: feed.sequence };
|
||||
return {
|
||||
sequence: feed.sequence,
|
||||
payloadSha256: createHash("sha256").update(payload).digest("hex"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
sequence: feed.sequence,
|
||||
generatedAt: feed.generatedAt,
|
||||
payloadSha256: createHash("sha256").update(payload).digest("hex"),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -152,7 +161,7 @@ function isMonotonicRollback(params: {
|
||||
if (params.candidate.sequence > params.current.sequence) {
|
||||
return false;
|
||||
}
|
||||
if (params.current.generatedAt === undefined) {
|
||||
if (params.candidate.generatedAt === undefined || params.current.generatedAt === undefined) {
|
||||
return false;
|
||||
}
|
||||
return Date.parse(params.candidate.generatedAt) < Date.parse(params.current.generatedAt);
|
||||
@@ -160,6 +169,7 @@ function isMonotonicRollback(params: {
|
||||
|
||||
function assertSignedSnapshotWriteIsMonotonic(params: {
|
||||
candidate: HostedOfficialExternalPluginCatalogSnapshotMonotonicState | undefined;
|
||||
candidateBody: string;
|
||||
current: HostedCatalogSnapshotRow | undefined;
|
||||
}): void {
|
||||
if (params.candidate?.mode !== "signed-feed" || params.current?.trust_mode !== "signed") {
|
||||
@@ -170,7 +180,21 @@ function assertSignedSnapshotWriteIsMonotonic(params: {
|
||||
return;
|
||||
}
|
||||
if (isMonotonicRollback({ candidate: params.candidate, current })) {
|
||||
throw new Error("hosted catalog signed feed sequence is older than current snapshot");
|
||||
throw new HostedCatalogSignedFeedMonotonicityError(
|
||||
"hosted catalog signed feed sequence is older than current snapshot",
|
||||
);
|
||||
}
|
||||
if (params.candidate.sequence !== current.sequence || current.generatedAt === undefined) {
|
||||
return;
|
||||
}
|
||||
const candidate = readMonotonicStateFromBody(params.candidateBody);
|
||||
if (
|
||||
candidate?.sequence === params.candidate.sequence &&
|
||||
candidate.payloadSha256 !== current.payloadSha256
|
||||
) {
|
||||
throw new HostedCatalogSignedFeedMonotonicityError(
|
||||
"hosted catalog signed feed payload changed without a sequence increment",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,11 +212,20 @@ function rowToSnapshot(
|
||||
...(row.last_modified ? { lastModified: row.last_modified } : {}),
|
||||
};
|
||||
const trust = rowToTrustState(row);
|
||||
const storedMonotonic = trust ? readMonotonicStateFromBody(row.body) : undefined;
|
||||
const monotonic = storedMonotonic
|
||||
? {
|
||||
mode: "signed-feed" as const,
|
||||
sequence: storedMonotonic.sequence,
|
||||
...(storedMonotonic.generatedAt ? { generatedAt: storedMonotonic.generatedAt } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
body: row.body,
|
||||
metadata,
|
||||
savedAt: row.saved_at,
|
||||
...(trust ? { trust } : {}),
|
||||
...(monotonic ? { monotonic } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,6 +289,7 @@ export function createSqliteHostedOfficialExternalPluginCatalogSnapshotStore(
|
||||
) as HostedCatalogSnapshotRow | undefined;
|
||||
assertSignedSnapshotWriteIsMonotonic({
|
||||
candidate: snapshot.monotonic,
|
||||
candidateBody: snapshot.body,
|
||||
current,
|
||||
});
|
||||
executeSqliteQuerySync(
|
||||
|
||||
@@ -95,6 +95,7 @@ function hostedCatalogFeed(params: {
|
||||
function signedHostedCatalogFeed(params: {
|
||||
feed: OfficialExternalPluginCatalogFeed;
|
||||
privateKeyPem?: string;
|
||||
keyId?: string;
|
||||
}): { body: string; privateKeyPem: string; publicKeyPem: string } {
|
||||
const keys = params.privateKeyPem
|
||||
? {
|
||||
@@ -125,7 +126,7 @@ function signedHostedCatalogFeed(params: {
|
||||
payload: payloadBytes.toString("base64url"),
|
||||
signatures: [
|
||||
{
|
||||
keyid: "acme-root",
|
||||
keyid: params.keyId ?? "acme-root",
|
||||
sig: crypto
|
||||
.sign(null, signingInput, crypto.createPrivateKey(keys.privateKeyPem))
|
||||
.toString("base64url"),
|
||||
@@ -154,14 +155,14 @@ function toLegacyBetaSignedEnvelope(body: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function signedCatalogConfig(publicKeyPem: string): HostedCatalogConfig {
|
||||
function signedCatalogConfig(publicKeyPem: string, keyId = "acme-root"): HostedCatalogConfig {
|
||||
return {
|
||||
feeds: {
|
||||
acme: {
|
||||
url: "https://packages.acme.example/openclaw/feed",
|
||||
verification: {
|
||||
mode: "signed",
|
||||
keys: [{ keyId: "acme-root", publicKey: publicKeyPem }],
|
||||
keys: [{ keyId, publicKey: publicKeyPem }],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -545,6 +546,54 @@ describe("official external plugin catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects signed payload changes at the same sequence while allowing re-signing", async () => {
|
||||
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-signed-snapshot-equivocation-"));
|
||||
const feed = hostedCatalogFeed({ sequence: 10, pluginName: "@openclaw/signed-v10" });
|
||||
const original = signedHostedCatalogFeed({ feed });
|
||||
const resigned = signedHostedCatalogFeed({ feed, keyId: "acme-rotated" });
|
||||
const conflictingFeed = hostedCatalogFeed({
|
||||
sequence: 10,
|
||||
pluginName: "@openclaw/conflicting-v10",
|
||||
});
|
||||
const conflicting = signedHostedCatalogFeed({ feed: conflictingFeed });
|
||||
const snapshotStore = createSqliteHostedOfficialExternalPluginCatalogSnapshotStore({
|
||||
stateDir,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(resigned.body).not.toBe(original.body);
|
||||
await snapshotStore.write(
|
||||
signedHostedCatalogSnapshot({
|
||||
body: original.body,
|
||||
monotonic: { sequence: feed.sequence, generatedAt: feed.generatedAt },
|
||||
}),
|
||||
);
|
||||
await snapshotStore.write(
|
||||
signedHostedCatalogSnapshot({
|
||||
body: resigned.body,
|
||||
monotonic: { sequence: feed.sequence, generatedAt: feed.generatedAt },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
snapshotStore.write(
|
||||
signedHostedCatalogSnapshot({
|
||||
body: conflicting.body,
|
||||
monotonic: {
|
||||
sequence: conflictingFeed.sequence,
|
||||
generatedAt: conflictingFeed.generatedAt,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("payload changed without a sequence increment");
|
||||
await expect(
|
||||
snapshotStore.read("https://packages.acme.example/openclaw/feed"),
|
||||
).resolves.toMatchObject({ body: resigned.body });
|
||||
} finally {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces malformed signed SQLite snapshot metadata with a valid snapshot", async () => {
|
||||
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-signed-snapshot-repair-"));
|
||||
const url = "https://packages.acme.example/openclaw/feed";
|
||||
@@ -694,6 +743,52 @@ describe("official external plugin catalog", () => {
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains the accepted snapshot when a signed payload changes at the same sequence", async () => {
|
||||
const acceptedFeed = hostedCatalogFeed({
|
||||
sequence: 10,
|
||||
pluginName: "@openclaw/signed-v10",
|
||||
});
|
||||
const accepted = signedHostedCatalogFeed({ feed: acceptedFeed });
|
||||
const conflicting = signedHostedCatalogFeed({
|
||||
feed: hostedCatalogFeed({ sequence: 10, pluginName: "@openclaw/conflicting-v10" }),
|
||||
privateKeyPem: accepted.privateKeyPem,
|
||||
});
|
||||
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-signed-equivocation-load-"));
|
||||
const snapshotStore = createSqliteHostedOfficialExternalPluginCatalogSnapshotStore({
|
||||
stateDir,
|
||||
});
|
||||
const catalogConfig = signedCatalogConfig(accepted.publicKeyPem);
|
||||
|
||||
try {
|
||||
const initial = await loadHostedCatalog({
|
||||
feedProfile: "acme",
|
||||
catalogConfig,
|
||||
fetchImpl: vi.fn(async () => new Response(accepted.body, { status: 200 })),
|
||||
snapshotStore,
|
||||
});
|
||||
expect(initial.source).toBe("hosted");
|
||||
|
||||
const result = await loadHostedCatalog({
|
||||
feedProfile: "acme",
|
||||
catalogConfig,
|
||||
fetchImpl: vi.fn(async () => new Response(conflicting.body, { status: 200 })),
|
||||
snapshotStore,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("hosted-snapshot");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["@openclaw/signed-v10"]);
|
||||
if (result.source === "hosted-snapshot") {
|
||||
expect(result.error).toContain("payload changed without a sequence increment");
|
||||
}
|
||||
await expect(
|
||||
snapshotStore.read("https://packages.acme.example/openclaw/feed"),
|
||||
).resolves.toMatchObject({ body: accepted.body });
|
||||
} finally {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed feed timestamps before rollback handling", async () => {
|
||||
const malformed = signedHostedCatalogFeed({
|
||||
feed: {
|
||||
@@ -785,6 +880,129 @@ describe("official external plugin catalog", () => {
|
||||
await expect(snapshotStore.read(url)).resolves.toMatchObject({ body: current.body });
|
||||
});
|
||||
|
||||
it("uses accepted monotonic metadata when trusted signing keys rotate", async () => {
|
||||
const previous = signedHostedCatalogFeed({
|
||||
feed: hostedCatalogFeed({ sequence: 8, pluginName: "@openclaw/signed-v8" }),
|
||||
keyId: "acme-root-2026-q2",
|
||||
});
|
||||
const current = signedHostedCatalogFeed({
|
||||
feed: hostedCatalogFeed({ sequence: 9, pluginName: "@openclaw/signed-v9" }),
|
||||
keyId: "acme-root-2026-q3",
|
||||
});
|
||||
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-signed-key-rotation-"));
|
||||
const snapshotStore = createSqliteHostedOfficialExternalPluginCatalogSnapshotStore({
|
||||
stateDir,
|
||||
});
|
||||
|
||||
try {
|
||||
const acceptedPrevious = await loadHostedCatalog({
|
||||
feedProfile: "acme",
|
||||
catalogConfig: signedCatalogConfig(previous.publicKeyPem, "acme-root-2026-q2"),
|
||||
fetchImpl: vi.fn(async () => new Response(previous.body, { status: 200 })),
|
||||
now: () => new Date("2026-06-22T00:00:08.000Z"),
|
||||
snapshotStore,
|
||||
});
|
||||
expect(acceptedPrevious.source).toBe("hosted");
|
||||
|
||||
const acceptedCurrent = await loadHostedCatalog({
|
||||
feedProfile: "acme",
|
||||
catalogConfig: signedCatalogConfig(current.publicKeyPem, "acme-root-2026-q3"),
|
||||
fetchImpl: vi.fn(async () => new Response(current.body, { status: 200 })),
|
||||
now: () => new Date("2026-06-22T00:00:09.000Z"),
|
||||
snapshotStore,
|
||||
});
|
||||
|
||||
expect(acceptedCurrent.source, JSON.stringify(acceptedCurrent)).toBe("hosted");
|
||||
expect(acceptedCurrent.entries.map((entry) => entry.name)).toEqual(["@openclaw/signed-v9"]);
|
||||
if (acceptedCurrent.source === "hosted") {
|
||||
expect(acceptedCurrent.trust?.signedBy).toBe("acme-root-2026-q3");
|
||||
}
|
||||
|
||||
const rolledBack = signedHostedCatalogFeed({
|
||||
feed: hostedCatalogFeed({ sequence: 7, pluginName: "@openclaw/signed-v7" }),
|
||||
keyId: "acme-root-2026-q4",
|
||||
});
|
||||
const rejectedRollback = await loadHostedCatalog({
|
||||
feedProfile: "acme",
|
||||
catalogConfig: signedCatalogConfig(rolledBack.publicKeyPem, "acme-root-2026-q4"),
|
||||
fetchImpl: vi.fn(async () => new Response(rolledBack.body, { status: 200 })),
|
||||
now: () => new Date("2026-06-22T00:00:10.000Z"),
|
||||
snapshotStore,
|
||||
});
|
||||
|
||||
expect(rejectedRollback.source).toBe("bundled-fallback");
|
||||
expect(rejectedRollback.entries).toEqual([]);
|
||||
if (rejectedRollback.source === "bundled-fallback") {
|
||||
expect(rejectedRollback.error).toContain("signed feed sequence is older");
|
||||
expect(rejectedRollback.error).toContain("snapshot fallback failed");
|
||||
}
|
||||
|
||||
const retainedCurrent = await loadHostedCatalog({
|
||||
feedProfile: "acme",
|
||||
catalogConfig: signedCatalogConfig(current.publicKeyPem, "acme-root-2026-q3"),
|
||||
offline: true,
|
||||
snapshotStore,
|
||||
});
|
||||
expect(retainedCurrent.source).toBe("hosted-snapshot");
|
||||
expect(retainedCurrent.entries.map((entry) => entry.name)).toEqual(["@openclaw/signed-v9"]);
|
||||
} finally {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("repairs malformed timestamp snapshots after trusted signing keys rotate", async () => {
|
||||
const malformed = signedHostedCatalogFeed({
|
||||
feed: {
|
||||
...hostedCatalogFeed({ sequence: 10, pluginName: "@openclaw/malformed-current" }),
|
||||
generatedAt: "not-a-date",
|
||||
},
|
||||
keyId: "acme-root-2026-q2",
|
||||
});
|
||||
const repairedFeed = hostedCatalogFeed({
|
||||
sequence: 10,
|
||||
pluginName: "@openclaw/repaired-current",
|
||||
});
|
||||
const repaired = signedHostedCatalogFeed({
|
||||
feed: repairedFeed,
|
||||
keyId: "acme-root-2026-q3",
|
||||
});
|
||||
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-signed-rotation-repair-"));
|
||||
const snapshotStore = createSqliteHostedOfficialExternalPluginCatalogSnapshotStore({
|
||||
stateDir,
|
||||
});
|
||||
|
||||
try {
|
||||
await snapshotStore.write(
|
||||
signedHostedCatalogSnapshot({
|
||||
body: malformed.body,
|
||||
monotonic: { sequence: 10, generatedAt: "not-a-date" },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
snapshotStore.read("https://packages.acme.example/openclaw/feed"),
|
||||
).resolves.toMatchObject({
|
||||
monotonic: { mode: "signed-feed", sequence: 10 },
|
||||
});
|
||||
|
||||
const result = await loadHostedCatalog({
|
||||
feedProfile: "acme",
|
||||
catalogConfig: signedCatalogConfig(repaired.publicKeyPem, "acme-root-2026-q3"),
|
||||
fetchImpl: vi.fn(async () => new Response(repaired.body, { status: 200 })),
|
||||
snapshotStore,
|
||||
});
|
||||
|
||||
expect(result.source, JSON.stringify(result)).toBe("hosted");
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["@openclaw/repaired-current"]);
|
||||
await expect(
|
||||
snapshotStore.read("https://packages.acme.example/openclaw/feed"),
|
||||
).resolves.toMatchObject({ body: repaired.body });
|
||||
} finally {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed for unsigned signed-profile responses and re-verifies offline snapshots", async () => {
|
||||
const signed = signedHostedCatalogFeed({
|
||||
feed: hostedCatalogFeed({ sequence: 8, pluginName: "@openclaw/signed-offline" }),
|
||||
|
||||
@@ -205,10 +205,17 @@ export type HostedOfficialExternalPluginCatalogTrustState = {
|
||||
verifiedAt: string;
|
||||
};
|
||||
|
||||
export class HostedCatalogSignedFeedMonotonicityError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "HostedCatalogSignedFeedMonotonicityError";
|
||||
}
|
||||
}
|
||||
|
||||
export type HostedOfficialExternalPluginCatalogSnapshotMonotonicState = {
|
||||
mode: "signed-feed";
|
||||
sequence: number;
|
||||
generatedAt: string;
|
||||
generatedAt?: string;
|
||||
};
|
||||
|
||||
export type HostedOfficialExternalPluginCatalogLoadResult =
|
||||
@@ -1054,21 +1061,33 @@ async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
if (snapshotStore && parsed.trust?.mode === "signed") {
|
||||
const currentSnapshot = await snapshotStore.read(url.href);
|
||||
if (currentSnapshot?.trust?.mode === "signed") {
|
||||
// Only an authenticated invalid-timestamp payload is repairable. Signature
|
||||
// and trust failures must remain fail-closed so rollback checks cannot be bypassed.
|
||||
const current = await parseHostedCatalogFeedBody({
|
||||
body: currentSnapshot.body,
|
||||
verification: source.verification,
|
||||
verifiedAt: currentSnapshot.trust.verifiedAt,
|
||||
allowLegacyBetaEnvelope: true,
|
||||
}).catch((err: unknown) => {
|
||||
if (err instanceof HostedCatalogFeedTimestampError) {
|
||||
return { feed: { sequence: err.sequence } };
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (isHostedCatalogSignedFeedRollback({ candidate: parsed.feed, current: current.feed })) {
|
||||
throw new Error("hosted catalog signed feed sequence is older than current snapshot");
|
||||
const current =
|
||||
currentSnapshot.monotonic?.mode === "signed-feed"
|
||||
? currentSnapshot.monotonic
|
||||
: (
|
||||
await parseHostedCatalogFeedBody({
|
||||
body: currentSnapshot.body,
|
||||
verification: source.verification,
|
||||
verifiedAt: currentSnapshot.trust.verifiedAt,
|
||||
allowLegacyBetaEnvelope: true,
|
||||
}).catch((err: unknown) => {
|
||||
// Only an authenticated invalid-timestamp payload is repairable. Signature
|
||||
// and trust failures must remain fail-closed so rollback checks cannot be bypassed.
|
||||
if (err instanceof HostedCatalogFeedTimestampError) {
|
||||
return { feed: { sequence: err.sequence } };
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
).feed;
|
||||
if (
|
||||
isHostedCatalogSignedFeedRollback({
|
||||
candidate: parsed.feed,
|
||||
current,
|
||||
})
|
||||
) {
|
||||
throw new HostedCatalogSignedFeedMonotonicityError(
|
||||
"hosted catalog signed feed sequence is older than current snapshot",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1096,10 +1115,7 @@ async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
|
||||
: {}),
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("hosted catalog signed feed sequence is older")
|
||||
) {
|
||||
if (err instanceof HostedCatalogSignedFeedMonotonicityError) {
|
||||
throw err;
|
||||
}
|
||||
if (params?.requireSnapshotWrite) {
|
||||
|
||||
Reference in New Issue
Block a user