diff --git a/src/plugins/official-external-plugin-catalog-snapshot-store.ts b/src/plugins/official-external-plugin-catalog-snapshot-store.ts index f8308ed3bbae..848d8fbccea0 100644 --- a/src/plugins/official-external-plugin-catalog-snapshot-store.ts +++ b/src/plugins/official-external-plugin-catalog-snapshot-store.ts @@ -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( diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 4329e02f3b5f..ec821a5df1dc 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -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" }), diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index 3e18295808b1..43955ed33920 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -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) {