fix(feeds): accept standard DSSE envelopes

* fix(feeds): accept standard DSSE envelopes

* fix(feeds): limit beta envelopes to snapshots
This commit is contained in:
Gio Della-Libera
2026-07-23 13:40:24 -07:00
committed by GitHub
parent f9708083f8
commit 03c9b22762
4 changed files with 192 additions and 24 deletions
@@ -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" });
});
});
@@ -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<string>();
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<OfficialExternalPluginCatalogEnvelopeSignature>[];
} | 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<OfficialExternalPluginCatalogEnvelopeSignature> =>
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<LegacyOfficialExternalPluginCatalogEnvelopeSignature> =>
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<string>();
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,
@@ -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",
@@ -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 } };