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:
Dragan Spiridonov committed 2026-10-10 16:51:15 +02:00
1 parent c4c66b2c7b
commit 5bd949dfde
9 files changed
+176 -59

No files matched your search

+17 -13
View File
@@ -18,13 +18,16 @@
*
* [1/3] STATIC CONTRACT — the source file must:
* - 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
* the registry copy before stringifying (canonical form)
* - pin to the caller-supplied `expectedPublicKey`, NOT to the
* served `registry.registryPublicKey` field
* - 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
* `signRegistry()` in
@@ -38,8 +41,9 @@
* scenario the original report calls out)
*
* [3/3] CALL-SITE BYTE — the call site must contain `requireVerification`
* AND `await this.verifyRegistrySignature` AND a `return` (the
* fail-closed branch). A future regression that drops `await`
* AND `await this.registrySignatureFailure` AND
* `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.
*/
@@ -103,14 +107,14 @@ function extractMethodBody(source, methodSignatureRegex) {
const body = extractMethodBody(
src,
/private\s+async\s+verifyRegistrySignature\s*\(/,
/private\s+async\s+registrySignatureFailure\s*\(/,
);
if (body) {
if (/delete\s+\w+\.registrySignature/.test(body) && /delete\s+\w+\.registryPublicKey/.test(body)) {
ok('canonicalization strips BOTH registrySignature AND registryPublicKey');
} else {
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',
);
}
@@ -118,7 +122,7 @@ if (body) {
ok('canonical message is JSON.stringify of stripped registry');
} else {
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',
);
}
@@ -135,7 +139,7 @@ if (body) {
);
}
} 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
@@ -282,16 +286,16 @@ const callSiteMatch = src.match(
);
if (callSiteMatch) {
const block = callSiteMatch[0];
if (/await\s+this\.verifyRegistrySignature\(/.test(block)) {
ok('call site awaits verifyRegistrySignature');
if (/await\s+this\.registrySignatureFailure\(/.test(block)) {
ok('call site awaits registrySignatureFailure');
} else {
fail(
'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)) {
ok('call site fails closed (returns rather than continuing)');
if (/if\s*\(\s*failure\s*\)\s*\{?\s*return\s+this\.fallbackToBuiltIn\(/.test(block)) {
ok('call site fails closed (if (failure) return this.fallbackToBuiltIn(...))');
} else {
fail(
'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:
* - an unverified registry (unsigned, bad signature, placeholder pinned key, verification off)
* 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`
* refuse without --allow-unverified;
* - `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 { isPlaceholderSigningKey, isStrictPluginMode } from '../src/plugins/trust-policy.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 anchorsFile from '../src/plugins/trust/trust-anchors.json';
const ZERO_KEY = '0'.repeat(64);
// R = identity point, S = 0: verifies against any small-order key for every message (ZIP-215).
const FORGED_SIG = '01' + '0'.repeat(126);
// An ordinary Ed25519 public key (not small-order) with no known signer.
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') {
return {
@@ -110,25 +122,42 @@ afterEach(() => {
});
describe('placeholder signing keys', () => {
it('the shipped trust anchor is a placeholder', () => {
expect(anchorsFile.anchors.length).toBeGreaterThan(0);
for (const a of anchorsFile.anchors) expect(isPlaceholderSigningKey(a.publicKey)).toBe(true);
it('a shipped trust anchor that is a placeholder leaves a registry pinned to it unverified', async () => {
// Holds whether or not maintainers have replaced the placeholder with a real key.
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('')).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:${'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 () => {
expect(await verifyEd25519Signature('any message at all', FORGED_SIG, ZERO_KEY)).toBe(true);
it('verifyEd25519Signature rejects the bad-signature fixture for every small-order key (RFC 8032 strict)', async () => {
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 () => {
ipfs.registry = registryWith([entry('@claude-flow/evil')], { registrySignature: FORGED_SIG });
it('a small-order pinned key is treated as a placeholder', async () => {
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();
expect(result.verified).toBe(false);
@@ -141,7 +170,7 @@ describe('placeholder signing keys', () => {
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 () => {
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();
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);
});
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 () => {
const { signed } = await signRegistry(registryWith([entry('@claude-flow/a')]));
ipfs.registry = signed;
// 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.demo).toBe(true);
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 () => {
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.demo).toBeUndefined();
expect(result.verified).toBe(false);
@@ -190,7 +228,7 @@ describe('discovery: an unverified registry confers no trust', () => {
process.env.CLAUDE_FLOW_STRICT_PLUGINS = 'true';
expect(isStrictPluginMode()).toBe(true);
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.registry).toBeUndefined();
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 }));
});
});
+6 -1
View File
@@ -141,6 +141,9 @@ const listCommand: Command = {
if (official) {
plugins = getOfficialPlugins(result.registry);
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) {
plugins = getFeaturedPlugins(result.registry);
title = 'Featured Plugins';
@@ -262,7 +265,9 @@ const installCommand: Command = {
type: 'boolean',
description:
'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,
},
{ 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>).type) { const v = validateIdentifier((input as Record<string, unknown>).type, 'type'); if (!v.valid) return createResult({ error: v.error }, true); }
try {
const { createPluginDiscoveryService, searchPlugins } = await import(
const { createPluginDiscoveryService, searchPlugins, registryStatus } = await import(
'../plugins/store/index.js'
);
const discovery = createPluginDiscoveryService();
@@ -312,7 +312,7 @@ export const transferTools: MCPTool[] = [
}
const opts = input as Parameters<typeof searchPlugins>[1];
const searchResult = searchPlugins(result.registry, opts);
return createResult(searchResult);
return createResult({ ...searchResult, registry: registryStatus(result) });
} catch (error) {
return createResult({ error: (error as Error).message }, true);
}
@@ -337,7 +337,7 @@ export const transferTools: MCPTool[] = [
handler: async (input): Promise<MCPToolResult> => {
{ const v = validatePackageName((input as { name: string }).name, 'name'); if (!v.valid) return createResult({ error: v.error }, true); }
try {
const { createPluginDiscoveryService } = await import('../plugins/store/index.js');
const { createPluginDiscoveryService, registryStatus } = await import('../plugins/store/index.js');
const discovery = createPluginDiscoveryService();
const result = await discovery.discoverRegistry();
if (!result.success || !result.registry) {
@@ -348,7 +348,7 @@ export const transferTools: MCPTool[] = [
if (!plugin) {
return createResult({ error: 'Plugin not found' }, true);
}
return createResult(plugin);
return createResult({ ...plugin, registry: registryStatus(result) });
} catch (error) {
return createResult({ error: (error as Error).message }, true);
}
@@ -371,7 +371,7 @@ export const transferTools: MCPTool[] = [
},
handler: async (input): Promise<MCPToolResult> => {
try {
const { createPluginDiscoveryService, getFeaturedPlugins } = await import(
const { createPluginDiscoveryService, getFeaturedPlugins, registryStatus } = await import(
'../plugins/store/index.js'
);
const discovery = createPluginDiscoveryService();
@@ -381,7 +381,7 @@ export const transferTools: MCPTool[] = [
}
const featured = getFeaturedPlugins(result.registry);
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) {
return createResult({ error: (error as Error).message }, true);
}
@@ -399,7 +399,7 @@ export const transferTools: MCPTool[] = [
},
handler: async (): Promise<MCPToolResult> => {
try {
const { createPluginDiscoveryService, getOfficialPlugins } = await import(
const { createPluginDiscoveryService, getOfficialPlugins, registryStatus } = await import(
'../plugins/store/index.js'
);
const discovery = createPluginDiscoveryService();
@@ -407,8 +407,9 @@ export const transferTools: MCPTool[] = [
if (!result.success || !result.registry) {
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);
return createResult(official);
return createResult({ plugins: official, registry: registryStatus(result) });
} catch (error) {
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
* just claims made by whoever served (or hardcoded) the list.
* Strip registry-asserted trust from a registry that was not verified. Its trust labels, its
* `official` list and its author verification are just claims made by whoever served (or
* hardcoded) the list.
*/
function withoutRegistryTrust(registry: PluginRegistry): PluginRegistry {
return {
...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,
createPluginDiscoveryService,
DEFAULT_PLUGIN_STORE_CONFIG,
registryStatus,
type PluginDiscoveryResult,
} from './discovery.js';
@@ -7,6 +7,7 @@
*/
import { createHash } from 'crypto';
import { ExtendedPoint } from '@noble/ed25519';
/**
* 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,
* or all zeros (the placeholder in `trust/trust-anchors.json`). The all-zero
* key is also a small-order Ed25519 point, for which a fixed forged signature
* verifies against every message under ZIP-215 rules, so it must never be
* not a curve point, or a small-order point — which includes the all-zero
* placeholder in `trust/trust-anchors.json`. Signatures can be forged against
* small-order keys under ZIP-215 verification, so such a key must never be
* handed to a verifier.
*/
export function isPlaceholderSigningKey(key: unknown): boolean {
if (typeof key !== 'string') return true;
const hex = key.trim().replace(/^ed25519:/i, '');
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...")
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(
Buffer.from(signature, 'hex'),
new TextEncoder().encode(message),
Buffer.from(pubKeyHex, 'hex')
Buffer.from(pubKeyHex, 'hex'),
{ zip215: false }
);
return isValid;
@@ -10,9 +10,9 @@
* - verify reports `unknown-signer` when the key isn't in trust anchors
* - verify reports `pass` on a valid round-trip sign → verify
* - verify reports `signature-invalid` on a flipped signature byte
* - the shipped placeholder (all-zero) anchor never vouches, and small-order
* keys are rejected even when anchored (a forged R=identity, S=0 signature
* verifies against them for every message under ZIP-215)
* - a placeholder (all-zero) anchor, such as the one shipped in
* trust-anchors.json, never vouches, and small-order keys are rejected even
* when anchored (ZIP-215 verification would accept forged signatures)
*/
import { describe, it, expect, beforeAll } from 'vitest';
@@ -207,9 +207,9 @@ describe('PluginIntegrityVerifier.verify', () => {
describe('placeholder and small-order signing keys', () => {
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';
// R = identity, S = 0.
// Known-bad signature fixture used against small-order keys.
const FORGED_SIG = '01' + '0'.repeat(126);
function forged(publicKey: string): SignedPluginManifest {
@@ -223,21 +223,26 @@ describe('placeholder and small-order signing keys', () => {
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(
readFileSync(new URL('../../cli/src/plugins/trust/trust-anchors.json', import.meta.url), 'utf-8'),
) as TrustAnchors;
expect(shipped.anchors.some((a) => a.publicKey === ZERO_KEY)).toBe(true);
expect(findAnchor(shipped.anchors, '@claude-flow/evil', ZERO_KEY, Date.now())).toBeNull();
const r = await new PluginIntegrityVerifier({ trustAnchors: shipped }).verify(forged(ZERO_KEY));
expect(r.status).not.toBe('pass');
expect(r.status).toBe('unknown-signer');
for (const a of shipped.anchors.filter((x) => isPlaceholderKey(x.publicKey))) {
expect(findAnchor(shipped.anchors, '@claude-flow/evil', a.publicKey, Date.now())).toBeNull();
const r = await new PluginIntegrityVerifier({ trustAnchors: shipped }).verify(forged(a.publicKey));
expect(r.status).not.toBe('pass');
}
});
it('rejects a forged signature even when a small-order key is anchored', async () => {
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);
expect(await ed.verifyAsync(hexBytes(FORGED_SIG), hexBytes(m.manifestHash), hexBytes(ORDER_TWO_KEY))).toBe(true);