mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(feeds): accept standard DSSE envelopes
* fix(feeds): accept standard DSSE envelopes * fix(feeds): limit beta envelopes to snapshots
This commit is contained in:
@@ -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 } };
|
||||
|
||||
Reference in New Issue
Block a user