diff --git a/src/plugins/official-external-plugin-catalog-envelope.test.ts b/src/plugins/official-external-plugin-catalog-envelope.test.ts index 20b0d1600544..73c7221c7580 100644 --- a/src/plugins/official-external-plugin-catalog-envelope.test.ts +++ b/src/plugins/official-external-plugin-catalog-envelope.test.ts @@ -57,13 +57,11 @@ function signedEnvelope(params: { const payload = payloadBytes.toString(params.encoding ?? "base64url"); const input = signingInput(payloadType, payloadBytes); return { - schemaVersion: 1, payloadType, payload, signatures: params.keys.map((key) => ({ - keyId: key.keyId, - algorithm: "ed25519", - signature: crypto.sign(null, input, key.privateKey).toString("base64url"), + keyid: key.keyId, + sig: crypto.sign(null, input, key.privateKey).toString("base64url"), })), }; } @@ -167,7 +165,7 @@ describe("official external plugin catalog signed envelopes", () => { ...envelope, signatures: Array.from({ length: 17 }, (_, index) => ({ ...signature, - keyId: `key-${index}`, + keyid: `key-${index}`, })), }, { trustedKeys }, @@ -198,11 +196,91 @@ describe("official external plugin catalog signed envelopes", () => { }); it("rejects malformed envelopes before verification", () => { + const key = createSigningKey("catalog-root"); + const envelope = signedEnvelope({ keys: [key] }); + const signature = envelope.signatures[0]; + expect(signature).toBeDefined(); + if (!signature) { + throw new Error("expected a generated signature"); + } expect( verifyOfficialExternalPluginCatalogSignedEnvelope( - { schemaVersion: 1, payloadType: PAYLOAD_TYPE, payload: "", signatures: [] }, + { payloadType: PAYLOAD_TYPE, payload: "", signatures: [] }, { trustedKeys: [] }, ), ).toMatchObject({ ok: false, error: "invalid-envelope" }); + expect( + verifyOfficialExternalPluginCatalogSignedEnvelope( + { ...envelope, signatures: [{ sig: signature.sig }] }, + { trustedKeys: [{ keyId: key.keyId, publicKey: exportPublicKey(key) }] }, + ), + ).toMatchObject({ ok: false, error: "invalid-envelope" }); + }); + + it("ignores unrecognized DSSE envelope and signature fields", () => { + const key = createSigningKey("catalog-root"); + const envelope = signedEnvelope({ keys: [key] }); + const signature = envelope.signatures[0]; + expect(signature).toBeDefined(); + if (!signature) { + throw new Error("expected a generated signature"); + } + + expect( + verifyOfficialExternalPluginCatalogSignedEnvelope( + { + ...envelope, + futureEnvelopeField: true, + signatures: [{ ...signature, futureSignatureField: true }], + }, + { trustedKeys: [{ keyId: key.keyId, publicKey: exportPublicKey(key) }] }, + ), + ).toMatchObject({ ok: true, signedBy: key.keyId }); + }); + + it("accepts beta legacy field names only for persisted snapshots", () => { + const key = createSigningKey("catalog-root"); + const envelope = signedEnvelope({ keys: [key] }); + const signature = envelope.signatures[0]; + expect(signature).toBeDefined(); + if (!signature) { + throw new Error("expected a generated signature"); + } + const legacySignature = { + keyId: signature.keyid, + algorithm: "ed25519", + signature: signature.sig, + }; + const trustedKeys = [{ keyId: key.keyId, publicKey: exportPublicKey(key) }]; + + const legacyEnvelope = { + ...envelope, + schemaVersion: 1, + signatures: [legacySignature], + }; + + expect( + verifyOfficialExternalPluginCatalogSignedEnvelope(legacyEnvelope, { trustedKeys }), + ).toMatchObject({ ok: false, error: "invalid-envelope" }); + expect( + verifyOfficialExternalPluginCatalogSignedEnvelope(legacyEnvelope, { + trustedKeys, + allowLegacyBetaEnvelope: true, + }), + ).toMatchObject({ ok: true, signedBy: key.keyId }); + const mixedEnvelope = { + ...envelope, + schemaVersion: 1, + signatures: [signature, legacySignature], + }; + expect( + verifyOfficialExternalPluginCatalogSignedEnvelope(mixedEnvelope, { trustedKeys }), + ).toMatchObject({ ok: false, error: "invalid-envelope" }); + expect( + verifyOfficialExternalPluginCatalogSignedEnvelope(mixedEnvelope, { + trustedKeys, + allowLegacyBetaEnvelope: true, + }), + ).toMatchObject({ ok: false, error: "invalid-envelope" }); }); }); diff --git a/src/plugins/official-external-plugin-catalog-envelope.ts b/src/plugins/official-external-plugin-catalog-envelope.ts index 34b11661e818..a3eb8e03ca92 100644 --- a/src/plugins/official-external-plugin-catalog-envelope.ts +++ b/src/plugins/official-external-plugin-catalog-envelope.ts @@ -13,6 +13,10 @@ const OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PAYLOAD_TYPE = const OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_SIGNATURES = 16; type OfficialExternalPluginCatalogEnvelopeSignature = { + keyid?: string; + sig?: string; +}; +type LegacyOfficialExternalPluginCatalogEnvelopeSignature = { keyId?: string; algorithm?: string; signature?: string; @@ -54,9 +58,12 @@ export function verifyOfficialExternalPluginCatalogSignedEnvelope( params: { trustedKeys: readonly OfficialExternalPluginCatalogTrustedSigningKey[]; threshold?: number; + allowLegacyBetaEnvelope?: boolean; }, ): OfficialExternalPluginCatalogEnvelopeVerificationResult { - const envelope = parseOfficialExternalPluginCatalogSignedEnvelope(raw); + const envelope = parseOfficialExternalPluginCatalogSignedEnvelope(raw, { + allowLegacyBetaEnvelope: params.allowLegacyBetaEnvelope === true, + }); if (!envelope) { return { ok: false, @@ -87,7 +94,7 @@ export function verifyOfficialExternalPluginCatalogSignedEnvelope( const trustedSignatureKeyIds: string[] = []; const trustedSignaturePublicKeys = new Set(); for (const envelopeSignature of envelope.signatures) { - const keyId = envelopeSignature.keyId; + const keyId = envelopeSignature.keyid; const trustedKey = params.trustedKeys.find((candidate) => candidate.keyId === keyId); if (!trustedKey || trustedSignatureKeyIds.includes(trustedKey.keyId)) { continue; @@ -100,7 +107,7 @@ export function verifyOfficialExternalPluginCatalogSignedEnvelope( verifyEd25519SignatureBytes({ publicKey: trustedKey.publicKey, payload: signingInput, - signatureBase64Url: envelopeSignature.signature, + signatureBase64Url: envelopeSignature.sig, }) ) { trustedSignatureKeyIds.push(trustedKey.keyId); @@ -134,7 +141,7 @@ export function verifyOfficialExternalPluginCatalogSignedEnvelope( }; } const hasKnownKey = envelope.signatures.some((signature) => - params.trustedKeys.some((key) => key.keyId === signature.keyId), + params.trustedKeys.some((key) => key.keyId === signature.keyid), ); return hasKnownKey ? { @@ -152,12 +159,15 @@ export function verifyOfficialExternalPluginCatalogSignedEnvelope( }; } -function parseOfficialExternalPluginCatalogSignedEnvelope(raw: unknown): { +function parseOfficialExternalPluginCatalogSignedEnvelope( + raw: unknown, + params: { allowLegacyBetaEnvelope: boolean }, +): { payloadType: string; payload: string; signatures: readonly Required[]; } | null { - if (!isRecord(raw) || raw.schemaVersion !== 1) { + if (!isRecord(raw)) { return null; } const payloadType = raw.payloadType; @@ -172,15 +182,43 @@ function parseOfficialExternalPluginCatalogSignedEnvelope(raw: unknown): { if (signatures.length > OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_SIGNATURES) { return null; } - const parsedSignatures = signatures.filter( + // Hosted Feed v1 requires keyid even though generic DSSE makes it optional: + // trust thresholds and rotation are resolved against configured key ids. + const standardSignatures = signatures.filter( (signature): signature is Required => isRecord(signature) && - typeof signature.keyId === "string" && - signature.keyId.trim().length > 0 && - signature.algorithm === "ed25519" && - typeof signature.signature === "string" && - signature.signature.trim().length > 0, + typeof signature.keyid === "string" && + signature.keyid.trim().length > 0 && + typeof signature.sig === "string" && + signature.sig.trim().length > 0, ); + // Beta releases briefly persisted this pre-DSSE field shape. It remains an + // all-or-nothing snapshot read path only; live publishers must use DSSE. + const legacySignatures = + raw.schemaVersion === 1 + ? signatures + .filter( + ( + signature, + ): signature is Required => + isRecord(signature) && + typeof signature.keyId === "string" && + signature.keyId.trim().length > 0 && + signature.algorithm === "ed25519" && + typeof signature.signature === "string" && + signature.signature.trim().length > 0, + ) + .map((signature) => ({ keyid: signature.keyId, sig: signature.signature })) + : []; + if (standardSignatures.length > 0 && legacySignatures.length > 0) { + return null; + } + const parsedSignatures = + standardSignatures.length > 0 + ? standardSignatures + : params.allowLegacyBetaEnvelope + ? legacySignatures + : []; if (parsedSignatures.length === 0) { return null; } @@ -189,10 +227,10 @@ function parseOfficialExternalPluginCatalogSignedEnvelope(raw: unknown): { } const keyIds = new Set(); for (const signature of parsedSignatures) { - if (keyIds.has(signature.keyId)) { + if (keyIds.has(signature.keyid)) { return null; } - keyIds.add(signature.keyId); + keyIds.add(signature.keyid); } return { payloadType, diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 9f80e9851365..4329e02f3b5f 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -121,14 +121,12 @@ function signedHostedCatalogFeed(params: { ]); return { body: JSON.stringify({ - schemaVersion: 1, payloadType: HOSTED_CATALOG_PAYLOAD_TYPE, payload: payloadBytes.toString("base64url"), signatures: [ { - keyId: "acme-root", - algorithm: "ed25519", - signature: crypto + keyid: "acme-root", + sig: crypto .sign(null, signingInput, crypto.createPrivateKey(keys.privateKeyPem)) .toString("base64url"), }, @@ -138,6 +136,24 @@ function signedHostedCatalogFeed(params: { }; } +function toLegacyBetaSignedEnvelope(body: string): string { + const envelope = JSON.parse(body) as { + payloadType: string; + payload: string; + signatures: Array<{ keyid: string; sig: string }>; + }; + return JSON.stringify({ + payloadType: envelope.payloadType, + payload: envelope.payload, + schemaVersion: 1, + signatures: envelope.signatures.map((signature) => ({ + keyId: signature.keyid, + algorithm: "ed25519", + signature: signature.sig, + })), + }); +} + function signedCatalogConfig(publicKeyPem: string): HostedCatalogConfig { return { feeds: { @@ -831,6 +847,38 @@ describe("official external plugin catalog", () => { } }); + it("accepts beta envelopes only from persisted snapshots", async () => { + const signed = signedHostedCatalogFeed({ + feed: hostedCatalogFeed({ sequence: 8, pluginName: "@openclaw/legacy-snapshot" }), + }); + const legacyBody = toLegacyBetaSignedEnvelope(signed.body); + const catalogConfig = signedCatalogConfig(signed.publicKeyPem); + + const live = await loadHostedCatalog({ + feedProfile: "acme", + catalogConfig, + fetchImpl: vi.fn(async () => new Response(legacyBody, { status: 200 })), + snapshotStore: null, + }); + + expect(live.source).toBe("bundled-fallback"); + if (live.source === "bundled-fallback") { + expect(live.error).toContain("signed envelope is malformed"); + } + + const offline = await loadHostedCatalog({ + feedProfile: "acme", + catalogConfig, + offline: true, + snapshotStore: createInMemoryHostedCatalogSnapshotStore([ + signedHostedCatalogSnapshot({ body: legacyBody }), + ]), + }); + + expect(offline.source).toBe("hosted-snapshot"); + expect(offline.entries.map((entry) => entry.name)).toEqual(["@openclaw/legacy-snapshot"]); + }); + it.each([ [ "off-allowlist hosts", diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index 165e1501034b..3e18295808b1 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -672,6 +672,7 @@ async function parseHostedCatalogFeedBody(params: { body: string; verification?: OfficialExternalPluginCatalogFeedVerification; verifiedAt: string; + allowLegacyBetaEnvelope?: boolean; }): Promise<{ feed: OfficialExternalPluginCatalogFeed; trust?: HostedOfficialExternalPluginCatalogTrustState; @@ -684,6 +685,7 @@ async function parseHostedCatalogFeedBody(params: { const verification = verifyOfficialExternalPluginCatalogSignedEnvelope(raw, { trustedKeys: params.verification.keys, threshold, + ...(params.allowLegacyBetaEnvelope ? { allowLegacyBetaEnvelope: true } : {}), }); if (!verification.ok) { const invalidTimestampSequence = @@ -768,6 +770,7 @@ async function loadHostedCatalogSnapshotResult(params: { body: params.snapshot.body, verification: params.verification, verifiedAt: params.snapshot.trust?.verifiedAt ?? params.snapshot.savedAt, + allowLegacyBetaEnvelope: true, }); return { source: "hosted-snapshot", @@ -1057,6 +1060,7 @@ async function loadHostedOfficialExternalPluginCatalogEntries(params?: { body: currentSnapshot.body, verification: source.verification, verifiedAt: currentSnapshot.trust.verifiedAt, + allowLegacyBetaEnvelope: true, }).catch((err: unknown) => { if (err instanceof HostedCatalogFeedTimestampError) { return { feed: { sequence: err.sequence } };