mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
fix(plugins): review follow-ups for registry trust fail-closed
- smoke-plugin-registry-signature.mjs: follow the verifier rename to registrySignatureFailure and require the fail-closed call site `if (failure) return this.fallbackToBuiltIn(`, keeping full #1922 coverage. - verifyEd25519Signature verifies in RFC 8032 strict mode, and isPlaceholderSigningKey rejects every small-order or off-curve key, not only the all-zero one. - An unverified registry claims no official plugins and no verified authors; MCP transfer_plugin-* results carry the registry status. - Tests no longer assume the shipped trust anchor stays a placeholder. - Document the strict-mode asymmetry for unlisted packages in --help.
This commit is contained in:
1 parent
c4c66b2c7b
commit
5bd949dfde
9 files changed
+176
-59
No files matched your search
@@ -18,13 +18,16 @@
|
|||||||
*
|
*
|
||||||
* [1/3] STATIC CONTRACT — the source file must:
|
* [1/3] STATIC CONTRACT — the source file must:
|
||||||
* - import `verifyEd25519Signature` from `transfer/ipfs/client.ts`
|
* - import `verifyEd25519Signature` from `transfer/ipfs/client.ts`
|
||||||
* - call it from the `verifyRegistrySignature` private method
|
* - call it from the `registrySignatureFailure` private method
|
||||||
|
* (formerly `verifyRegistrySignature`; returns the failure
|
||||||
|
* reason, or null when the signature verified)
|
||||||
* - strip BOTH `registrySignature` AND `registryPublicKey` from
|
* - strip BOTH `registrySignature` AND `registryPublicKey` from
|
||||||
* the registry copy before stringifying (canonical form)
|
* the registry copy before stringifying (canonical form)
|
||||||
* - pin to the caller-supplied `expectedPublicKey`, NOT to the
|
* - pin to the caller-supplied `expectedPublicKey`, NOT to the
|
||||||
* served `registry.registryPublicKey` field
|
* served `registry.registryPublicKey` field
|
||||||
* - the call site must `await` the verifier AND fail-closed
|
* - the call site must `await` the verifier AND fail-closed
|
||||||
* (return demo registry / not just `console.warn`)
|
* (`if (failure) return this.fallbackToBuiltIn(...)` — the
|
||||||
|
* unverified, trust-stripped fallback; not just `console.warn`)
|
||||||
*
|
*
|
||||||
* [2/3] CRYPTO ROUND-TRIP — using the exact same Ed25519 scheme as
|
* [2/3] CRYPTO ROUND-TRIP — using the exact same Ed25519 scheme as
|
||||||
* `signRegistry()` in
|
* `signRegistry()` in
|
||||||
@@ -38,8 +41,9 @@
|
|||||||
* scenario the original report calls out)
|
* scenario the original report calls out)
|
||||||
*
|
*
|
||||||
* [3/3] CALL-SITE BYTE — the call site must contain `requireVerification`
|
* [3/3] CALL-SITE BYTE — the call site must contain `requireVerification`
|
||||||
* AND `await this.verifyRegistrySignature` AND a `return` (the
|
* AND `await this.registrySignatureFailure` AND
|
||||||
* fail-closed branch). A future regression that drops `await`
|
* `if (failure) return this.fallbackToBuiltIn(` (the fail-closed
|
||||||
|
* branch). A future regression that drops `await`
|
||||||
* or that reverts to plain `console.warn` will be caught here.
|
* or that reverts to plain `console.warn` will be caught here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -103,14 +107,14 @@ function extractMethodBody(source, methodSignatureRegex) {
|
|||||||
|
|
||||||
const body = extractMethodBody(
|
const body = extractMethodBody(
|
||||||
src,
|
src,
|
||||||
/private\s+async\s+verifyRegistrySignature\s*\(/,
|
/private\s+async\s+registrySignatureFailure\s*\(/,
|
||||||
);
|
);
|
||||||
if (body) {
|
if (body) {
|
||||||
if (/delete\s+\w+\.registrySignature/.test(body) && /delete\s+\w+\.registryPublicKey/.test(body)) {
|
if (/delete\s+\w+\.registrySignature/.test(body) && /delete\s+\w+\.registryPublicKey/.test(body)) {
|
||||||
ok('canonicalization strips BOTH registrySignature AND registryPublicKey');
|
ok('canonicalization strips BOTH registrySignature AND registryPublicKey');
|
||||||
} else {
|
} else {
|
||||||
fail(
|
fail(
|
||||||
'verifyRegistrySignature must delete both signature fields before stringify',
|
'registrySignatureFailure must delete both signature fields before stringify',
|
||||||
'attacker can otherwise re-sign by spreading the served fields back in',
|
'attacker can otherwise re-sign by spreading the served fields back in',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,7 +122,7 @@ if (body) {
|
|||||||
ok('canonical message is JSON.stringify of stripped registry');
|
ok('canonical message is JSON.stringify of stripped registry');
|
||||||
} else {
|
} else {
|
||||||
fail(
|
fail(
|
||||||
'verifyRegistrySignature must JSON.stringify the stripped registry',
|
'registrySignatureFailure must JSON.stringify the stripped registry',
|
||||||
'the signer uses plain JSON.stringify (no whitespace, no sort) — the verifier must match',
|
'the signer uses plain JSON.stringify (no whitespace, no sort) — the verifier must match',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,7 +139,7 @@ if (body) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fail('verifyRegistrySignature method not found in async form');
|
fail('registrySignatureFailure method not found in async form');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Old stub pattern must be GONE. A future regression that brings back
|
// Old stub pattern must be GONE. A future regression that brings back
|
||||||
@@ -282,16 +286,16 @@ const callSiteMatch = src.match(
|
|||||||
);
|
);
|
||||||
if (callSiteMatch) {
|
if (callSiteMatch) {
|
||||||
const block = callSiteMatch[0];
|
const block = callSiteMatch[0];
|
||||||
if (/await\s+this\.verifyRegistrySignature\(/.test(block)) {
|
if (/await\s+this\.registrySignatureFailure\(/.test(block)) {
|
||||||
ok('call site awaits verifyRegistrySignature');
|
ok('call site awaits registrySignatureFailure');
|
||||||
} else {
|
} else {
|
||||||
fail(
|
fail(
|
||||||
'call site does not await the async verifier',
|
'call site does not await the async verifier',
|
||||||
'without await, the Promise<boolean> is truthy and verification is bypassed',
|
'without await, the Promise is truthy and the verifier result is never read',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (/return\s+this\.createDemoRegistryAsync\(/.test(block) || /return\s+\w+/.test(block)) {
|
if (/if\s*\(\s*failure\s*\)\s*\{?\s*return\s+this\.fallbackToBuiltIn\(/.test(block)) {
|
||||||
ok('call site fails closed (returns rather than continuing)');
|
ok('call site fails closed (if (failure) return this.fallbackToBuiltIn(...))');
|
||||||
} else {
|
} else {
|
||||||
fail(
|
fail(
|
||||||
'call site must return/fall-back on verification failure (not just warn)',
|
'call site must return/fall-back on verification failure (not just warn)',
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
* install run npm lifecycle scripts and skip the permission gate. So:
|
* install run npm lifecycle scripts and skip the permission gate. So:
|
||||||
* - an unverified registry (unsigned, bad signature, placeholder pinned key, verification off)
|
* - an unverified registry (unsigned, bad signature, placeholder pinned key, verification off)
|
||||||
* and the built-in fallback list carry no registry trust;
|
* and the built-in fallback list carry no registry trust;
|
||||||
* - a placeholder (all-zero) pinned key is never used to verify — it accepts a forged signature;
|
* - a placeholder pinned key (all-zero or any small-order point) is never used to verify, and the
|
||||||
|
* Ed25519 verifier runs in RFC 8032 strict mode;
|
||||||
|
* - an unverified registry claims no official plugins and no verified authors, and the MCP
|
||||||
|
* transfer_plugin-* tools report the registry status;
|
||||||
* - CLAUDE_FLOW_STRICT_PLUGINS=true turns the fallback into an error and makes `plugins install`
|
* - CLAUDE_FLOW_STRICT_PLUGINS=true turns the fallback into an error and makes `plugins install`
|
||||||
* refuse without --allow-unverified;
|
* refuse without --allow-unverified;
|
||||||
* - `plugins install` never passes an unverified entry's trust, permissions or checksum on.
|
* - `plugins install` never passes an unverified entry's trust, permissions or checksum on.
|
||||||
@@ -40,12 +43,21 @@ import { PluginDiscoveryService } from '../src/plugins/store/discovery.js';
|
|||||||
import { verifyEd25519Signature } from '../src/transfer/ipfs/client.js';
|
import { verifyEd25519Signature } from '../src/transfer/ipfs/client.js';
|
||||||
import { isPlaceholderSigningKey, isStrictPluginMode } from '../src/plugins/trust-policy.js';
|
import { isPlaceholderSigningKey, isStrictPluginMode } from '../src/plugins/trust-policy.js';
|
||||||
import { pluginsCommand } from '../src/commands/plugins.js';
|
import { pluginsCommand } from '../src/commands/plugins.js';
|
||||||
|
import transferTools from '../src/mcp-tools/transfer-tools.js';
|
||||||
import type { CommandContext } from '../src/types.js';
|
import type { CommandContext } from '../src/types.js';
|
||||||
import anchorsFile from '../src/plugins/trust/trust-anchors.json';
|
import anchorsFile from '../src/plugins/trust/trust-anchors.json';
|
||||||
|
|
||||||
const ZERO_KEY = '0'.repeat(64);
|
const ZERO_KEY = '0'.repeat(64);
|
||||||
// R = identity point, S = 0: verifies against any small-order key for every message (ZIP-215).
|
// An ordinary Ed25519 public key (not small-order) with no known signer.
|
||||||
const FORGED_SIG = '01' + '0'.repeat(126);
|
const REAL_KEY = 'ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c';
|
||||||
|
// Small-order points: identity, order 2, order 8.
|
||||||
|
const SMALL_ORDER_KEYS = [
|
||||||
|
'01' + '0'.repeat(62),
|
||||||
|
'ec' + 'ff'.repeat(30) + '7f',
|
||||||
|
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
|
||||||
|
];
|
||||||
|
// Known-bad signature fixture used against small-order keys.
|
||||||
|
const BAD_SIG = '01' + '0'.repeat(126);
|
||||||
|
|
||||||
function entry(name: string, trustLevel = 'official') {
|
function entry(name: string, trustLevel = 'official') {
|
||||||
return {
|
return {
|
||||||
@@ -110,25 +122,42 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('placeholder signing keys', () => {
|
describe('placeholder signing keys', () => {
|
||||||
it('the shipped trust anchor is a placeholder', () => {
|
it('a shipped trust anchor that is a placeholder leaves a registry pinned to it unverified', async () => {
|
||||||
expect(anchorsFile.anchors.length).toBeGreaterThan(0);
|
// Holds whether or not maintainers have replaced the placeholder with a real key.
|
||||||
for (const a of anchorsFile.anchors) expect(isPlaceholderSigningKey(a.publicKey)).toBe(true);
|
for (const a of anchorsFile.anchors) {
|
||||||
|
if (!isPlaceholderSigningKey(a.publicKey)) continue;
|
||||||
|
ipfs.registry = registryWith([entry('@claude-flow/a')], { registrySignature: BAD_SIG });
|
||||||
|
const result = await serviceFor(`ed25519:${a.publicKey}`).discoverRegistry();
|
||||||
|
expect(result.verified).toBe(false);
|
||||||
|
expect(result.unverifiedReason).toMatch(/placeholder/);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects missing, malformed and all-zero keys; accepts a real-looking key', () => {
|
it('rejects missing, malformed, off-curve, all-zero and small-order keys; accepts an ordinary key', () => {
|
||||||
expect(isPlaceholderSigningKey(undefined)).toBe(true);
|
expect(isPlaceholderSigningKey(undefined)).toBe(true);
|
||||||
expect(isPlaceholderSigningKey('')).toBe(true);
|
expect(isPlaceholderSigningKey('')).toBe(true);
|
||||||
expect(isPlaceholderSigningKey('ed25519:abc')).toBe(true);
|
expect(isPlaceholderSigningKey('ed25519:abc')).toBe(true);
|
||||||
|
expect(isPlaceholderSigningKey(`ed25519:${'ab'.repeat(32)}`)).toBe(true);
|
||||||
expect(isPlaceholderSigningKey(`ed25519:${ZERO_KEY}`)).toBe(true);
|
expect(isPlaceholderSigningKey(`ed25519:${ZERO_KEY}`)).toBe(true);
|
||||||
expect(isPlaceholderSigningKey(`ed25519:${'ab'.repeat(32)}`)).toBe(false);
|
for (const k of SMALL_ORDER_KEYS) expect(isPlaceholderSigningKey(`ed25519:${k}`)).toBe(true);
|
||||||
|
expect(isPlaceholderSigningKey(`ed25519:${REAL_KEY}`)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the all-zero key really does accept a forged signature (why it must never be verified against)', async () => {
|
it('verifyEd25519Signature rejects the bad-signature fixture for every small-order key (RFC 8032 strict)', async () => {
|
||||||
expect(await verifyEd25519Signature('any message at all', FORGED_SIG, ZERO_KEY)).toBe(true);
|
for (const k of [ZERO_KEY, ...SMALL_ORDER_KEYS]) {
|
||||||
|
expect(await verifyEd25519Signature('any message', BAD_SIG, k)).toBe(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a placeholder pinned key leaves the registry unverified even with a "valid" forged signature', async () => {
|
it('a small-order pinned key is treated as a placeholder', async () => {
|
||||||
ipfs.registry = registryWith([entry('@claude-flow/evil')], { registrySignature: FORGED_SIG });
|
ipfs.registry = registryWith([entry('@claude-flow/evil')], { registrySignature: BAD_SIG });
|
||||||
|
const result = await serviceFor(`ed25519:${SMALL_ORDER_KEYS[0]}`).discoverRegistry();
|
||||||
|
expect(result.verified).toBe(false);
|
||||||
|
expect(result.unverifiedReason).toMatch(/placeholder/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an all-zero pinned key leaves the registry unverified and its entries unlisted', async () => {
|
||||||
|
ipfs.registry = registryWith([entry('@claude-flow/evil')], { registrySignature: BAD_SIG });
|
||||||
const result = await serviceFor(`ed25519:${ZERO_KEY}`).discoverRegistry();
|
const result = await serviceFor(`ed25519:${ZERO_KEY}`).discoverRegistry();
|
||||||
|
|
||||||
expect(result.verified).toBe(false);
|
expect(result.verified).toBe(false);
|
||||||
@@ -141,7 +170,7 @@ describe('placeholder signing keys', () => {
|
|||||||
describe('discovery: an unverified registry confers no trust', () => {
|
describe('discovery: an unverified registry confers no trust', () => {
|
||||||
it('an unsigned registry falls back to the built-in list, labelled demo and unverified, with trust stripped', async () => {
|
it('an unsigned registry falls back to the built-in list, labelled demo and unverified, with trust stripped', async () => {
|
||||||
ipfs.registry = registryWith([entry('@claude-flow/a')]);
|
ipfs.registry = registryWith([entry('@claude-flow/a')]);
|
||||||
const svc = serviceFor(`ed25519:${'ab'.repeat(32)}`);
|
const svc = serviceFor(`ed25519:${REAL_KEY}`);
|
||||||
|
|
||||||
const first = await svc.discoverRegistry();
|
const first = await svc.discoverRegistry();
|
||||||
expect(first.success).toBe(true);
|
expect(first.success).toBe(true);
|
||||||
@@ -157,11 +186,20 @@ describe('discovery: an unverified registry confers no trust', () => {
|
|||||||
expect(second.registry!.plugins.every((p) => p.trustLevel === 'unverified')).toBe(true);
|
expect(second.registry!.plugins.every((p) => p.trustLevel === 'unverified')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('an unverified registry lists no official plugins and no verified authors', async () => {
|
||||||
|
ipfs.registry = registryWith([entry('@claude-flow/a')]);
|
||||||
|
const result = await serviceFor(`ed25519:${REAL_KEY}`).discoverRegistry();
|
||||||
|
expect(result.verified).toBe(false);
|
||||||
|
expect(result.registry!.official).toEqual([]);
|
||||||
|
expect(result.registry!.authors.every((a) => !a.verified)).toBe(true);
|
||||||
|
expect(result.registry!.plugins.every((p) => !p.author?.verified)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('a signature that does not verify is not silently treated as the registry', async () => {
|
it('a signature that does not verify is not silently treated as the registry', async () => {
|
||||||
const { signed } = await signRegistry(registryWith([entry('@claude-flow/a')]));
|
const { signed } = await signRegistry(registryWith([entry('@claude-flow/a')]));
|
||||||
ipfs.registry = signed;
|
ipfs.registry = signed;
|
||||||
// Pinned key differs from the signer.
|
// Pinned key differs from the signer.
|
||||||
const result = await serviceFor(`ed25519:${'ab'.repeat(32)}`).discoverRegistry();
|
const result = await serviceFor(`ed25519:${REAL_KEY}`).discoverRegistry();
|
||||||
expect(result.verified).toBe(false);
|
expect(result.verified).toBe(false);
|
||||||
expect(result.demo).toBe(true);
|
expect(result.demo).toBe(true);
|
||||||
expect(result.unverifiedReason).toMatch(/does not verify/);
|
expect(result.unverifiedReason).toMatch(/does not verify/);
|
||||||
@@ -169,7 +207,7 @@ describe('discovery: an unverified registry confers no trust', () => {
|
|||||||
|
|
||||||
it('with verification disabled the fetched registry is used but every entry is unverified', async () => {
|
it('with verification disabled the fetched registry is used but every entry is unverified', async () => {
|
||||||
ipfs.registry = registryWith([entry('@claude-flow/a')]);
|
ipfs.registry = registryWith([entry('@claude-flow/a')]);
|
||||||
const result = await serviceFor(`ed25519:${'ab'.repeat(32)}`, { requireVerification: false }).discoverRegistry();
|
const result = await serviceFor(`ed25519:${REAL_KEY}`, { requireVerification: false }).discoverRegistry();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.demo).toBeUndefined();
|
expect(result.demo).toBeUndefined();
|
||||||
expect(result.verified).toBe(false);
|
expect(result.verified).toBe(false);
|
||||||
@@ -190,7 +228,7 @@ describe('discovery: an unverified registry confers no trust', () => {
|
|||||||
process.env.CLAUDE_FLOW_STRICT_PLUGINS = 'true';
|
process.env.CLAUDE_FLOW_STRICT_PLUGINS = 'true';
|
||||||
expect(isStrictPluginMode()).toBe(true);
|
expect(isStrictPluginMode()).toBe(true);
|
||||||
ipfs.registry = registryWith([entry('@claude-flow/a')]);
|
ipfs.registry = registryWith([entry('@claude-flow/a')]);
|
||||||
const result = await serviceFor(`ed25519:${'ab'.repeat(32)}`).discoverRegistry();
|
const result = await serviceFor(`ed25519:${REAL_KEY}`).discoverRegistry();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.registry).toBeUndefined();
|
expect(result.registry).toBeUndefined();
|
||||||
expect(result.verified).toBe(false);
|
expect(result.verified).toBe(false);
|
||||||
@@ -253,3 +291,36 @@ describe('plugins install: unverified registry entries get no registry trust', (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MCP transfer_plugin-* tools report registry status', () => {
|
||||||
|
const call = async (name: string, input: Record<string, unknown> = {}) => {
|
||||||
|
const tool = transferTools.find((t) => t.name === name)!;
|
||||||
|
const res = await tool.handler(input as never);
|
||||||
|
return JSON.parse((res.content[0] as { text: string }).text);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// The default registry, served unsigned as the live CID is.
|
||||||
|
ipfs.registry = registryWith([entry('@claude-flow/neural')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transfer_plugin-official claims nothing as official when the registry is unverified', async () => {
|
||||||
|
const out = await call('transfer_plugin-official');
|
||||||
|
expect(out.plugins).toEqual([]);
|
||||||
|
expect(out.registry.verified).toBe(false);
|
||||||
|
expect(out.registry.unverifiedReason).toMatch(/unsigned/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transfer_plugin-featured, -search and -info carry the status and unverified entries', async () => {
|
||||||
|
const featured = await call('transfer_plugin-featured', { limit: 3 });
|
||||||
|
expect(featured.registry.verified).toBe(false);
|
||||||
|
expect(featured.plugins.every((p: { trustLevel: string }) => p.trustLevel === 'unverified')).toBe(true);
|
||||||
|
|
||||||
|
const search = await call('transfer_plugin-search', { query: 'neural' });
|
||||||
|
expect(search.registry.verified).toBe(false);
|
||||||
|
|
||||||
|
const info = await call('transfer_plugin-info', { name: '@claude-flow/neural' });
|
||||||
|
expect(info.trustLevel).toBe('unverified');
|
||||||
|
expect(info.registry).toEqual(expect.objectContaining({ verified: false }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -141,6 +141,9 @@ const listCommand: Command = {
|
|||||||
if (official) {
|
if (official) {
|
||||||
plugins = getOfficialPlugins(result.registry);
|
plugins = getOfficialPlugins(result.registry);
|
||||||
title = 'Official Plugins';
|
title = 'Official Plugins';
|
||||||
|
if (result.verified !== true) {
|
||||||
|
output.writeln(output.warning('No plugin is shown as official: only a verified registry can say which are.'));
|
||||||
|
}
|
||||||
} else if (featured) {
|
} else if (featured) {
|
||||||
plugins = getFeaturedPlugins(result.registry);
|
plugins = getFeaturedPlugins(result.registry);
|
||||||
title = 'Featured Plugins';
|
title = 'Featured Plugins';
|
||||||
@@ -262,7 +265,9 @@ const installCommand: Command = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description:
|
description:
|
||||||
'With CLAUDE_FLOW_STRICT_PLUGINS=true, install from npm even though the plugin registry is not verified. ' +
|
'With CLAUDE_FLOW_STRICT_PLUGINS=true, install from npm even though the plugin registry is not verified. ' +
|
||||||
'The plugin still gets no registry trust.',
|
'The plugin still gets no registry trust. Strict mode gates on the registry, not the package: while the ' +
|
||||||
|
'registry is unverified every npm install is refused, listed or not; once it verifies, a package it does ' +
|
||||||
|
'not list installs without this flag, but also without registry trust.',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{ name: 'registry', short: 'r', type: 'string', description: 'Registry to use' },
|
{ name: 'registry', short: 'r', type: 'string', description: 'Registry to use' },
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ export const transferTools: MCPTool[] = [
|
|||||||
if ((input as Record<string, unknown>).category) { const v = validateIdentifier((input as Record<string, unknown>).category, 'category'); if (!v.valid) return createResult({ error: v.error }, true); }
|
if ((input as Record<string, unknown>).category) { const v = validateIdentifier((input as Record<string, unknown>).category, 'category'); if (!v.valid) return createResult({ error: v.error }, true); }
|
||||||
if ((input as Record<string, unknown>).type) { const v = validateIdentifier((input as Record<string, unknown>).type, 'type'); if (!v.valid) return createResult({ error: v.error }, true); }
|
if ((input as Record<string, unknown>).type) { const v = validateIdentifier((input as Record<string, unknown>).type, 'type'); if (!v.valid) return createResult({ error: v.error }, true); }
|
||||||
try {
|
try {
|
||||||
const { createPluginDiscoveryService, searchPlugins } = await import(
|
const { createPluginDiscoveryService, searchPlugins, registryStatus } = await import(
|
||||||
'../plugins/store/index.js'
|
'../plugins/store/index.js'
|
||||||
);
|
);
|
||||||
const discovery = createPluginDiscoveryService();
|
const discovery = createPluginDiscoveryService();
|
||||||
@@ -312,7 +312,7 @@ export const transferTools: MCPTool[] = [
|
|||||||
}
|
}
|
||||||
const opts = input as Parameters<typeof searchPlugins>[1];
|
const opts = input as Parameters<typeof searchPlugins>[1];
|
||||||
const searchResult = searchPlugins(result.registry, opts);
|
const searchResult = searchPlugins(result.registry, opts);
|
||||||
return createResult(searchResult);
|
return createResult({ ...searchResult, registry: registryStatus(result) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return createResult({ error: (error as Error).message }, true);
|
return createResult({ error: (error as Error).message }, true);
|
||||||
}
|
}
|
||||||
@@ -337,7 +337,7 @@ export const transferTools: MCPTool[] = [
|
|||||||
handler: async (input): Promise<MCPToolResult> => {
|
handler: async (input): Promise<MCPToolResult> => {
|
||||||
{ const v = validatePackageName((input as { name: string }).name, 'name'); if (!v.valid) return createResult({ error: v.error }, true); }
|
{ const v = validatePackageName((input as { name: string }).name, 'name'); if (!v.valid) return createResult({ error: v.error }, true); }
|
||||||
try {
|
try {
|
||||||
const { createPluginDiscoveryService } = await import('../plugins/store/index.js');
|
const { createPluginDiscoveryService, registryStatus } = await import('../plugins/store/index.js');
|
||||||
const discovery = createPluginDiscoveryService();
|
const discovery = createPluginDiscoveryService();
|
||||||
const result = await discovery.discoverRegistry();
|
const result = await discovery.discoverRegistry();
|
||||||
if (!result.success || !result.registry) {
|
if (!result.success || !result.registry) {
|
||||||
@@ -348,7 +348,7 @@ export const transferTools: MCPTool[] = [
|
|||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return createResult({ error: 'Plugin not found' }, true);
|
return createResult({ error: 'Plugin not found' }, true);
|
||||||
}
|
}
|
||||||
return createResult(plugin);
|
return createResult({ ...plugin, registry: registryStatus(result) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return createResult({ error: (error as Error).message }, true);
|
return createResult({ error: (error as Error).message }, true);
|
||||||
}
|
}
|
||||||
@@ -371,7 +371,7 @@ export const transferTools: MCPTool[] = [
|
|||||||
},
|
},
|
||||||
handler: async (input): Promise<MCPToolResult> => {
|
handler: async (input): Promise<MCPToolResult> => {
|
||||||
try {
|
try {
|
||||||
const { createPluginDiscoveryService, getFeaturedPlugins } = await import(
|
const { createPluginDiscoveryService, getFeaturedPlugins, registryStatus } = await import(
|
||||||
'../plugins/store/index.js'
|
'../plugins/store/index.js'
|
||||||
);
|
);
|
||||||
const discovery = createPluginDiscoveryService();
|
const discovery = createPluginDiscoveryService();
|
||||||
@@ -381,7 +381,7 @@ export const transferTools: MCPTool[] = [
|
|||||||
}
|
}
|
||||||
const featured = getFeaturedPlugins(result.registry);
|
const featured = getFeaturedPlugins(result.registry);
|
||||||
const limit = (input as { limit?: number }).limit || 10;
|
const limit = (input as { limit?: number }).limit || 10;
|
||||||
return createResult(featured.slice(0, limit));
|
return createResult({ plugins: featured.slice(0, limit), registry: registryStatus(result) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return createResult({ error: (error as Error).message }, true);
|
return createResult({ error: (error as Error).message }, true);
|
||||||
}
|
}
|
||||||
@@ -399,7 +399,7 @@ export const transferTools: MCPTool[] = [
|
|||||||
},
|
},
|
||||||
handler: async (): Promise<MCPToolResult> => {
|
handler: async (): Promise<MCPToolResult> => {
|
||||||
try {
|
try {
|
||||||
const { createPluginDiscoveryService, getOfficialPlugins } = await import(
|
const { createPluginDiscoveryService, getOfficialPlugins, registryStatus } = await import(
|
||||||
'../plugins/store/index.js'
|
'../plugins/store/index.js'
|
||||||
);
|
);
|
||||||
const discovery = createPluginDiscoveryService();
|
const discovery = createPluginDiscoveryService();
|
||||||
@@ -407,8 +407,9 @@ export const transferTools: MCPTool[] = [
|
|||||||
if (!result.success || !result.registry) {
|
if (!result.success || !result.registry) {
|
||||||
return createResult({ error: result.error || 'Failed to discover registry' }, true);
|
return createResult({ error: result.error || 'Failed to discover registry' }, true);
|
||||||
}
|
}
|
||||||
|
// Empty when the registry is unverified: an unsigned "official" list proves nothing.
|
||||||
const official = getOfficialPlugins(result.registry);
|
const official = getOfficialPlugins(result.registry);
|
||||||
return createResult(official);
|
return createResult({ plugins: official, registry: registryStatus(result) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return createResult({ error: (error as Error).message }, true);
|
return createResult({ error: (error as Error).message }, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,13 +130,34 @@ export interface PluginDiscoveryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip registry-asserted trust from a registry that was not verified. Its trust labels are
|
* Strip registry-asserted trust from a registry that was not verified. Its trust labels, its
|
||||||
* just claims made by whoever served (or hardcoded) the list.
|
* `official` list and its author verification are just claims made by whoever served (or
|
||||||
|
* hardcoded) the list.
|
||||||
*/
|
*/
|
||||||
function withoutRegistryTrust(registry: PluginRegistry): PluginRegistry {
|
function withoutRegistryTrust(registry: PluginRegistry): PluginRegistry {
|
||||||
return {
|
return {
|
||||||
...registry,
|
...registry,
|
||||||
plugins: registry.plugins.map((p) => ({ ...p, trustLevel: 'unverified' as const, verified: false })),
|
plugins: registry.plugins.map((p) => ({
|
||||||
|
...p,
|
||||||
|
trustLevel: 'unverified' as const,
|
||||||
|
verified: false,
|
||||||
|
...(p.author ? { author: { ...p.author, verified: false } } : {}),
|
||||||
|
})),
|
||||||
|
official: [],
|
||||||
|
authors: (registry.authors ?? []).map((a) => ({ ...a, verified: false })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registry-level verification status, for callers that report registry data. */
|
||||||
|
export function registryStatus(result: PluginDiscoveryResult): {
|
||||||
|
verified: boolean;
|
||||||
|
unverifiedReason?: string;
|
||||||
|
source?: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
verified: result.verified === true,
|
||||||
|
...(result.verified === true ? {} : { unverifiedReason: result.unverifiedReason ?? 'registry not verified' }),
|
||||||
|
...(result.source ? { source: result.source } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export {
|
|||||||
PluginDiscoveryService,
|
PluginDiscoveryService,
|
||||||
createPluginDiscoveryService,
|
createPluginDiscoveryService,
|
||||||
DEFAULT_PLUGIN_STORE_CONFIG,
|
DEFAULT_PLUGIN_STORE_CONFIG,
|
||||||
|
registryStatus,
|
||||||
type PluginDiscoveryResult,
|
type PluginDiscoveryResult,
|
||||||
} from './discovery.js';
|
} from './discovery.js';
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
import { ExtendedPoint } from '@noble/ed25519';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permissions a plugin may declare and still have its hooks and commands
|
* Permissions a plugin may declare and still have its hooks and commands
|
||||||
@@ -130,14 +131,19 @@ export function isStrictPluginMode(env: NodeJS.ProcessEnv = process.env): boolea
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A signing key that cannot vouch for anything: missing, not 32 bytes of hex,
|
* A signing key that cannot vouch for anything: missing, not 32 bytes of hex,
|
||||||
* or all zeros (the placeholder in `trust/trust-anchors.json`). The all-zero
|
* not a curve point, or a small-order point — which includes the all-zero
|
||||||
* key is also a small-order Ed25519 point, for which a fixed forged signature
|
* placeholder in `trust/trust-anchors.json`. Signatures can be forged against
|
||||||
* verifies against every message under ZIP-215 rules, so it must never be
|
* small-order keys under ZIP-215 verification, so such a key must never be
|
||||||
* handed to a verifier.
|
* handed to a verifier.
|
||||||
*/
|
*/
|
||||||
export function isPlaceholderSigningKey(key: unknown): boolean {
|
export function isPlaceholderSigningKey(key: unknown): boolean {
|
||||||
if (typeof key !== 'string') return true;
|
if (typeof key !== 'string') return true;
|
||||||
const hex = key.trim().replace(/^ed25519:/i, '');
|
const hex = key.trim().replace(/^ed25519:/i, '');
|
||||||
if (!/^[0-9a-f]{64}$/i.test(hex)) return true;
|
if (!/^[0-9a-f]{64}$/i.test(hex)) return true;
|
||||||
return /^0+$/.test(hex);
|
try {
|
||||||
|
// zip215=true decodes every encoding a lenient verifier would accept.
|
||||||
|
return ExtendedPoint.fromHex(hex, true).isSmallOrder();
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -334,10 +334,13 @@ export async function verifyEd25519Signature(
|
|||||||
// Handle prefixed public key (e.g., "ed25519:abc123...")
|
// Handle prefixed public key (e.g., "ed25519:abc123...")
|
||||||
const pubKeyHex = publicKey.replace(/^ed25519:/, '');
|
const pubKeyHex = publicKey.replace(/^ed25519:/, '');
|
||||||
|
|
||||||
|
// RFC 8032 strict mode: rejects small-order public keys, against which
|
||||||
|
// signatures can be forged under the library's default ZIP-215 rules.
|
||||||
const isValid = await ed.verifyAsync(
|
const isValid = await ed.verifyAsync(
|
||||||
Buffer.from(signature, 'hex'),
|
Buffer.from(signature, 'hex'),
|
||||||
new TextEncoder().encode(message),
|
new TextEncoder().encode(message),
|
||||||
Buffer.from(pubKeyHex, 'hex')
|
Buffer.from(pubKeyHex, 'hex'),
|
||||||
|
{ zip215: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
return isValid;
|
return isValid;
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
* - verify reports `unknown-signer` when the key isn't in trust anchors
|
* - verify reports `unknown-signer` when the key isn't in trust anchors
|
||||||
* - verify reports `pass` on a valid round-trip sign → verify
|
* - verify reports `pass` on a valid round-trip sign → verify
|
||||||
* - verify reports `signature-invalid` on a flipped signature byte
|
* - verify reports `signature-invalid` on a flipped signature byte
|
||||||
* - the shipped placeholder (all-zero) anchor never vouches, and small-order
|
* - a placeholder (all-zero) anchor, such as the one shipped in
|
||||||
* keys are rejected even when anchored (a forged R=identity, S=0 signature
|
* trust-anchors.json, never vouches, and small-order keys are rejected even
|
||||||
* verifies against them for every message under ZIP-215)
|
* when anchored (ZIP-215 verification would accept forged signatures)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll } from 'vitest';
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
@@ -207,9 +207,9 @@ describe('PluginIntegrityVerifier.verify', () => {
|
|||||||
|
|
||||||
describe('placeholder and small-order signing keys', () => {
|
describe('placeholder and small-order signing keys', () => {
|
||||||
const ZERO_KEY = '0'.repeat(64);
|
const ZERO_KEY = '0'.repeat(64);
|
||||||
// y = -1: the order-2 point (0, -1).
|
// A small-order point.
|
||||||
const ORDER_TWO_KEY = 'ec' + 'ff'.repeat(30) + '7f';
|
const ORDER_TWO_KEY = 'ec' + 'ff'.repeat(30) + '7f';
|
||||||
// R = identity, S = 0.
|
// Known-bad signature fixture used against small-order keys.
|
||||||
const FORGED_SIG = '01' + '0'.repeat(126);
|
const FORGED_SIG = '01' + '0'.repeat(126);
|
||||||
|
|
||||||
function forged(publicKey: string): SignedPluginManifest {
|
function forged(publicKey: string): SignedPluginManifest {
|
||||||
@@ -223,21 +223,26 @@ describe('placeholder and small-order signing keys', () => {
|
|||||||
expect(isPlaceholderKey(ORDER_TWO_KEY)).toBe(false);
|
expect(isPlaceholderKey(ORDER_TWO_KEY)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the shipped trust-anchors.json placeholder never vouches for a forged manifest', async () => {
|
it('a placeholder anchor never vouches for a forged manifest', async () => {
|
||||||
|
const r = await new PluginIntegrityVerifier({ trustAnchors: anchorsFor(ZERO_KEY) }).verify(forged(ZERO_KEY));
|
||||||
|
expect(r.status).toBe('unknown-signer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no placeholder anchor shipped in trust-anchors.json vouches for anything', async () => {
|
||||||
|
// Holds whether or not maintainers have replaced the placeholder with a real key.
|
||||||
const shipped = JSON.parse(
|
const shipped = JSON.parse(
|
||||||
readFileSync(new URL('../../cli/src/plugins/trust/trust-anchors.json', import.meta.url), 'utf-8'),
|
readFileSync(new URL('../../cli/src/plugins/trust/trust-anchors.json', import.meta.url), 'utf-8'),
|
||||||
) as TrustAnchors;
|
) as TrustAnchors;
|
||||||
expect(shipped.anchors.some((a) => a.publicKey === ZERO_KEY)).toBe(true);
|
for (const a of shipped.anchors.filter((x) => isPlaceholderKey(x.publicKey))) {
|
||||||
expect(findAnchor(shipped.anchors, '@claude-flow/evil', ZERO_KEY, Date.now())).toBeNull();
|
expect(findAnchor(shipped.anchors, '@claude-flow/evil', a.publicKey, Date.now())).toBeNull();
|
||||||
|
const r = await new PluginIntegrityVerifier({ trustAnchors: shipped }).verify(forged(a.publicKey));
|
||||||
const r = await new PluginIntegrityVerifier({ trustAnchors: shipped }).verify(forged(ZERO_KEY));
|
expect(r.status).not.toBe('pass');
|
||||||
expect(r.status).not.toBe('pass');
|
}
|
||||||
expect(r.status).toBe('unknown-signer');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a forged signature even when a small-order key is anchored', async () => {
|
it('rejects a forged signature even when a small-order key is anchored', async () => {
|
||||||
if (!ed) { console.warn('@noble/ed25519 unavailable — skipping'); return; }
|
if (!ed) { console.warn('@noble/ed25519 unavailable — skipping'); return; }
|
||||||
// Sanity: noble's default (ZIP-215) verify accepts the forgery against this key.
|
// Precondition: the default (lenient) verify mode accepts this fixture for this key.
|
||||||
const m = forged(ORDER_TWO_KEY);
|
const m = forged(ORDER_TWO_KEY);
|
||||||
expect(await ed.verifyAsync(hexBytes(FORGED_SIG), hexBytes(m.manifestHash), hexBytes(ORDER_TWO_KEY))).toBe(true);
|
expect(await ed.verifyAsync(hexBytes(FORGED_SIG), hexBytes(m.manifestHash), hexBytes(ORDER_TWO_KEY))).toBe(true);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user