Skip to content

Commit 868375b

Browse files
committed
fix(feeds): make signed snapshot writes monotonic
1 parent e822d4d commit 868375b

3 files changed

Lines changed: 181 additions & 0 deletions

File tree

src/plugins/official-external-plugin-catalog-snapshot-store.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths
1515
import type {
1616
HostedOfficialExternalPluginCatalogMetadata,
1717
HostedOfficialExternalPluginCatalogSnapshot,
18+
HostedOfficialExternalPluginCatalogSnapshotMonotonicState,
1819
HostedOfficialExternalPluginCatalogSnapshotStore,
1920
HostedOfficialExternalPluginCatalogTrustState,
2021
} from "./official-external-plugin-catalog.js";
@@ -97,6 +98,69 @@ function rowToTrustState(
9798
};
9899
}
99100

101+
function decodeBase64Payload(payload: string): string {
102+
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
103+
return Buffer.from(normalized, "base64").toString("utf8");
104+
}
105+
106+
function readMonotonicStateFromBody(
107+
body: string,
108+
): HostedOfficialExternalPluginCatalogSnapshotMonotonicState | undefined {
109+
try {
110+
const document = JSON.parse(body) as {
111+
payload?: unknown;
112+
sequence?: unknown;
113+
generatedAt?: unknown;
114+
};
115+
const feed =
116+
typeof document.payload === "string"
117+
? (JSON.parse(decodeBase64Payload(document.payload)) as {
118+
sequence?: unknown;
119+
generatedAt?: unknown;
120+
})
121+
: document;
122+
if (typeof feed.sequence !== "number" || typeof feed.generatedAt !== "string") {
123+
return undefined;
124+
}
125+
return {
126+
mode: "signed-feed",
127+
sequence: feed.sequence,
128+
generatedAt: feed.generatedAt,
129+
};
130+
} catch {
131+
return undefined;
132+
}
133+
}
134+
135+
function isMonotonicRollback(params: {
136+
candidate: HostedOfficialExternalPluginCatalogSnapshotMonotonicState;
137+
current: HostedOfficialExternalPluginCatalogSnapshotMonotonicState;
138+
}): boolean {
139+
if (params.candidate.sequence < params.current.sequence) {
140+
return true;
141+
}
142+
if (params.candidate.sequence > params.current.sequence) {
143+
return false;
144+
}
145+
return Date.parse(params.candidate.generatedAt) < Date.parse(params.current.generatedAt);
146+
}
147+
148+
function assertSignedSnapshotWriteIsMonotonic(params: {
149+
candidate: HostedOfficialExternalPluginCatalogSnapshotMonotonicState | undefined;
150+
current: HostedCatalogSnapshotRow | undefined;
151+
}): void {
152+
if (params.candidate?.mode !== "signed-feed" || params.current?.trust_mode !== "signed") {
153+
return;
154+
}
155+
const current = readMonotonicStateFromBody(params.current.body);
156+
if (!current) {
157+
return;
158+
}
159+
if (isMonotonicRollback({ candidate: params.candidate, current })) {
160+
throw new Error("hosted catalog signed feed sequence is older than current snapshot");
161+
}
162+
}
163+
100164
function rowToSnapshot(
101165
row: HostedCatalogSnapshotRow | undefined,
102166
): HostedOfficialExternalPluginCatalogSnapshot | null {
@@ -157,6 +221,30 @@ export function createSqliteHostedOfficialExternalPluginCatalogSnapshotStore(
157221
const now = Date.now();
158222
runOpenClawStateWriteTransaction((database) => {
159223
const stateDb = getNodeSqliteKysely<HostedCatalogSnapshotDatabase>(database.db);
224+
const current = executeSqliteQueryTakeFirstSync(
225+
database.db,
226+
stateDb
227+
.selectFrom("official_external_plugin_catalog_snapshots")
228+
.select([
229+
"feed_url",
230+
"body",
231+
"status",
232+
"etag",
233+
"last_modified",
234+
"checksum",
235+
"saved_at",
236+
"trust_mode",
237+
"trust_key_id",
238+
"trust_signature_count",
239+
"trust_threshold",
240+
"trust_verified_at",
241+
])
242+
.where("feed_url", "=", snapshot.metadata.url),
243+
) as HostedCatalogSnapshotRow | undefined;
244+
assertSignedSnapshotWriteIsMonotonic({
245+
candidate: snapshot.monotonic,
246+
current,
247+
});
160248
executeSqliteQuerySync(
161249
database.db,
162250
stateDb

src/plugins/official-external-plugin-catalog.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,77 @@ describe("official external plugin catalog", () => {
759759
expect(writeSpy).not.toHaveBeenCalled();
760760
});
761761

762+
it("keeps signed SQLite snapshot replacement monotonic under concurrent writes", async () => {
763+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-signed-snapshot-race-"));
764+
const url = "https://packages.acme.example/openclaw/feed";
765+
const newer = signedHostedCatalogFeed({
766+
feed: {
767+
schemaVersion: 1,
768+
id: "openclaw-official-external-plugins",
769+
generatedAt: "2026-06-22T00:00:10.000Z",
770+
sequence: 10,
771+
entries: [
772+
{
773+
name: "@openclaw/signed-refresh-proof-v10",
774+
kind: "plugin",
775+
openclaw: { plugin: { id: "signed-refresh-proof-v10" } },
776+
},
777+
],
778+
},
779+
});
780+
const older = signedHostedCatalogFeed({
781+
privateKeyPem: newer.privateKeyPem,
782+
feed: {
783+
schemaVersion: 1,
784+
id: "openclaw-official-external-plugins",
785+
generatedAt: "2026-06-22T00:00:09.000Z",
786+
sequence: 9,
787+
entries: [
788+
{
789+
name: "@openclaw/signed-refresh-proof-v9",
790+
kind: "plugin",
791+
openclaw: { plugin: { id: "signed-refresh-proof-v9" } },
792+
},
793+
],
794+
},
795+
});
796+
const snapshotStore = createSqliteHostedOfficialExternalPluginCatalogSnapshotStore({
797+
stateDir: tempDir,
798+
});
799+
const trust = {
800+
mode: "signed" as const,
801+
signedBy: "acme-root",
802+
signatureCount: 1,
803+
threshold: 1,
804+
verifiedAt: "2026-06-22T04:05:06.000Z",
805+
};
806+
const snapshotFor = (body: string, sequence: number, generatedAt: string) => ({
807+
body,
808+
metadata: {
809+
url,
810+
status: 200,
811+
checksum: "sha256:" + crypto.createHash("sha256").update(body).digest("hex"),
812+
},
813+
savedAt: "2026-06-22T04:05:06.000Z",
814+
trust,
815+
monotonic: { mode: "signed-feed" as const, sequence, generatedAt },
816+
});
817+
818+
try {
819+
await Promise.allSettled([
820+
snapshotStore.write(snapshotFor(older.body, 9, "2026-06-22T00:00:09.000Z")),
821+
snapshotStore.write(snapshotFor(newer.body, 10, "2026-06-22T00:00:10.000Z")),
822+
]);
823+
824+
const snapshot = await snapshotStore.read(url);
825+
expect(snapshot?.body).toBe(newer.body);
826+
expect(snapshot?.trust).toMatchObject(trust);
827+
} finally {
828+
closeOpenClawStateDatabaseForTest();
829+
rmSync(tempDir, { recursive: true, force: true });
830+
}
831+
});
832+
762833
it("fails closed when a signed feed profile receives unsigned JSON", async () => {
763834
const fetchImpl = vi.fn(
764835
async () =>

src/plugins/official-external-plugin-catalog.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ export type HostedOfficialExternalPluginCatalogSnapshot = {
185185
metadata: HostedOfficialExternalPluginCatalogMetadata;
186186
savedAt: string;
187187
trust?: HostedOfficialExternalPluginCatalogTrustState;
188+
monotonic?: HostedOfficialExternalPluginCatalogSnapshotMonotonicState;
188189
};
189190

190191
export type HostedOfficialExternalPluginCatalogSnapshotStore = {
@@ -200,6 +201,12 @@ export type HostedOfficialExternalPluginCatalogTrustState = {
200201
verifiedAt: string;
201202
};
202203

204+
export type HostedOfficialExternalPluginCatalogSnapshotMonotonicState = {
205+
mode: "signed-feed";
206+
sequence: number;
207+
generatedAt: string;
208+
};
209+
203210
export type HostedOfficialExternalPluginCatalogLoadResult =
204211
| {
205212
source: "hosted";
@@ -1092,8 +1099,23 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
10921099
metadata,
10931100
savedAt: verifiedAt,
10941101
...(parsed.trust ? { trust: parsed.trust } : {}),
1102+
...(parsed.trust?.mode === "signed"
1103+
? {
1104+
monotonic: {
1105+
mode: "signed-feed",
1106+
sequence: parsed.feed.sequence,
1107+
generatedAt: parsed.feed.generatedAt,
1108+
},
1109+
}
1110+
: {}),
10951111
})
10961112
.catch((err: unknown) => {
1113+
if (
1114+
err instanceof Error &&
1115+
err.message.includes("hosted catalog signed feed sequence is older")
1116+
) {
1117+
throw err;
1118+
}
10971119
if (params?.requireSnapshotWrite) {
10981120
throw new HostedCatalogSnapshotWriteError(err);
10991121
}

0 commit comments

Comments
 (0)