Files

454 lines
16 KiB
TypeScript

/**
* Per-scheme threshold-signer tests.
*
* Every scheme is exercised via the same five-step contract from the
* task spec:
* 1. construct
* 2. sign produces non-empty output of expected length
* 3. verify(sign(m)) round-trip == true
* 4. verify(sign(m), m') == false (forgery rejection)
* 5. verify with wrong key == false
*
* Schemes with daemon dependencies (pulsar, corona, double-lattice)
* run against an inproc mock that lockstep-replicates the wire format
* a real daemon would expose.
*/
import { ethers } from 'ethers';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import {
getSigner,
resolveSchemeFromEnv,
isSchemeName,
VALID_SCHEMES,
POLICY_ID,
EcdsaSigner,
FrostSigner,
Cggmp21Signer,
PulsarSigner,
CoronaSigner,
DoubleLatticeSigner,
} from '../src/signers';
import { RpcRequest, RpcTransport } from '../src/signers/rpc';
const msg = (b: number) => new Uint8Array(32).fill(b);
// The old defaultMpcdUrl / MPCD_URL / THRESHOLD_DAEMON_URL test block
// was removed 2026-06-04 when the HTTP transport was deleted alongside
// the Go dispatcher's HTTP path. Tests now pass an inproc transport
// directly; production callers wire the ZAP JS client (open blocker).
describe('registry', () => {
it('lists all six schemes', () => {
expect(VALID_SCHEMES).toHaveLength(6);
for (const s of ['ecdsa', 'cggmp21', 'frost', 'pulsar', 'corona', 'double-lattice']) {
expect(VALID_SCHEMES).toContain(s);
expect(isSchemeName(s)).toBe(true);
expect(POLICY_ID[s as keyof typeof POLICY_ID]).toBeTruthy();
}
});
it('rejects unknown MPC_SCHEME explicitly (with LUX_PROFILE set)', () => {
expect(() =>
resolveSchemeFromEnv({
LUX_PROFILE: 'classical-compat',
MPC_SCHEME: 'bogus',
} as never),
).toThrow(/is not one of/);
});
it('throws when LUX_PROFILE is unset (no silent ECDSA fallback)', () => {
// Red HIGH #4: missing env config used to mean classical signing on
// a strict-PQ deployment. Fail-closed: require explicit LUX_PROFILE.
expect(() => resolveSchemeFromEnv({} as never)).toThrow(/LUX_PROFILE must be set/);
expect(() => resolveSchemeFromEnv({ MPC_SCHEME: 'ecdsa' } as never)).toThrow(
/LUX_PROFILE must be set/,
);
expect(() =>
resolveSchemeFromEnv({ MPC_SCHEME: 'double-lattice' } as never),
).toThrow(/LUX_PROFILE must be set/);
});
it('accepts every valid name case-insensitively (after trim) with profile set', () => {
expect(
resolveSchemeFromEnv({
LUX_PROFILE: 'classical-compat',
MPC_SCHEME: ' FROST ',
} as never),
).toBe('frost');
// double-lattice is PQ so pair it with strict-PQ.
expect(
resolveSchemeFromEnv({
LUX_PROFILE: 'quasar-strict-pq',
MPC_SCHEME: 'Double-Lattice',
} as never),
).toBe('double-lattice');
});
});
describe('LUX_PROFILE → MPC_SCHEME derivation', () => {
it('LUX_PROFILE=quasar-strict-pq defaults to double-lattice', () => {
expect(resolveSchemeFromEnv({ LUX_PROFILE: 'quasar-strict-pq' } as never)).toBe(
'double-lattice',
);
});
it('LUX_PROFILE=classical-compat defaults to ecdsa', () => {
expect(resolveSchemeFromEnv({ LUX_PROFILE: 'classical-compat' } as never)).toBe('ecdsa');
});
it('LUX_PROFILE=quasar-strict-pq accepts pulsar/corona/double-lattice MPC_SCHEME', () => {
expect(
resolveSchemeFromEnv({
LUX_PROFILE: 'quasar-strict-pq',
MPC_SCHEME: 'pulsar',
} as never),
).toBe('pulsar');
});
it('LUX_PROFILE=quasar-strict-pq + classical MPC_SCHEME throws clear error', () => {
expect(() =>
resolveSchemeFromEnv({
LUX_PROFILE: 'quasar-strict-pq',
MPC_SCHEME: 'ecdsa',
} as never),
).toThrow(/incompatible/);
});
it('LUX_PROFILE=classical-compat + PQ MPC_SCHEME throws clear error (symmetric)', () => {
// Red HIGH #4 second leg: under classical-compat a PQ scheme used to
// be silently accepted. Symmetric refusal now closes that gap.
for (const scheme of ['pulsar', 'corona', 'double-lattice']) {
expect(() =>
resolveSchemeFromEnv({
LUX_PROFILE: 'classical-compat',
MPC_SCHEME: scheme,
} as never),
).toThrow(/incompatible/);
}
});
it('LUX_PROFILE=classical-compat accepts classical schemes (ecdsa/cggmp21/frost)', () => {
for (const scheme of ['ecdsa', 'cggmp21', 'frost']) {
expect(
resolveSchemeFromEnv({
LUX_PROFILE: 'classical-compat',
MPC_SCHEME: scheme,
} as never),
).toBe(scheme);
}
});
it('LUX_PROFILE bogus throws explicit error', () => {
expect(() => resolveSchemeFromEnv({ LUX_PROFILE: 'bogus' } as never)).toThrow(
/is not one of/,
);
});
it('LUX_PROFILE=hybrid permits any valid scheme and warns', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
// Default scheme under hybrid is ecdsa.
expect(resolveSchemeFromEnv({ LUX_PROFILE: 'hybrid' } as never)).toBe('ecdsa');
expect(spy).toHaveBeenCalledWith(expect.stringContaining("scheme='ecdsa'"));
spy.mockClear();
// Explicit override accepted.
expect(
resolveSchemeFromEnv({
LUX_PROFILE: 'hybrid',
MPC_SCHEME: 'pulsar',
} as never),
).toBe('pulsar');
expect(spy).toHaveBeenCalledWith(expect.stringContaining("scheme='pulsar'"));
} finally {
spy.mockRestore();
}
});
it('non-hybrid profiles do NOT emit the hybrid warning', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
resolveSchemeFromEnv({ LUX_PROFILE: 'classical-compat', MPC_SCHEME: 'frost' } as never);
resolveSchemeFromEnv({ LUX_PROFILE: 'quasar-strict-pq' } as never);
expect(spy).not.toHaveBeenCalled();
} finally {
spy.mockRestore();
}
});
});
describe('ecdsa signer (legacy gg18 wire format preserved)', () => {
// We do not have a live gg18_sign_client binary in unit tests, but we
// can still verify (a) the verify() path uses ecrecover correctly and
// (b) the signer reports the correct scheme + policyId.
const wallet = ethers.Wallet.createRandom();
const compressedPubkey = ethers.getBytes(
secp256k1.getPublicKey(ethers.getBytes(wallet.privateKey), true) as Uint8Array,
);
const signer = new EcdsaSigner({
binPath: '/nonexistent/gg18_sign_client',
signingManager: 'http://localhost:8000',
keyStore: 'keys.store',
cwd: '/tmp',
publicKey: compressedPubkey,
});
it('reports scheme and policyId', () => {
expect(signer.scheme).toBe('ecdsa');
expect(signer.policyId).toBe('ECDSA-SECP256K1-GG18');
});
it('verifies a real ECDSA signature produced offline', async () => {
const m = msg(1);
const sig = await wallet.signingKey.sign(m);
const sigBytes = ethers.getBytes(sig.serialized);
expect(sigBytes.length).toBe(65);
expect(await signer.verify(m, sigBytes, compressedPubkey)).toBe(true);
});
it('rejects forged ECDSA signature', async () => {
const m = msg(1);
const m2 = msg(2);
const sig = await wallet.signingKey.sign(m);
expect(await signer.verify(m2, ethers.getBytes(sig.serialized), compressedPubkey)).toBe(
false,
);
});
it('rejects ECDSA signature under wrong key', async () => {
const m = msg(1);
const other = ethers.Wallet.createRandom();
const otherPub = ethers.getBytes(
secp256k1.getPublicKey(ethers.getBytes(other.privateKey), true) as Uint8Array,
);
const sig = await wallet.signingKey.sign(m);
expect(await signer.verify(m, ethers.getBytes(sig.serialized), otherPub)).toBe(false);
});
});
describe('frost signer (RFC 9591, secp256k1)', () => {
const signer = new FrostSigner({ threshold: 2, participants: 3 });
it('reports scheme and policyId', () => {
expect(signer.scheme).toBe('frost');
expect(signer.policyId).toBe('SCHNORR-SECP256K1-FROST-RFC9591');
});
it('signs and verifies a 65-byte FROST signature', async () => {
const m = msg(42);
const sig = await signer.sign(m);
expect(sig.length).toBe(65);
expect(await signer.verify(m, sig)).toBe(true);
});
it('rejects forgery (verify with different message)', async () => {
const m = msg(42);
const m2 = msg(43);
const sig = await signer.sign(m);
expect(await signer.verify(m2, sig)).toBe(false);
});
it('rejects verify under a different group key', async () => {
const m = msg(7);
const sig = await signer.sign(m);
const other = new FrostSigner({ threshold: 2, participants: 3 });
expect(await signer.verify(m, sig, other.publicKey())).toBe(false);
});
it('factory yields a FROST signer', async () => {
const s = getSigner({
scheme: 'frost',
config: { threshold: 2, participants: 3 },
});
const m = msg(9);
const sig = await s.sign(m);
expect(await s.verify(m, sig)).toBe(true);
});
});
describe('cggmp21 signer (JSON-RPC wire client → mpcd)', () => {
// Mock the mpcd-embedded threshold dispatcher for wire-format assertions.
function makeMock(): {
transport: RpcTransport;
calls: RpcRequest[];
} {
const calls: RpcRequest[] = [];
const pkBytes = Buffer.from('aa'.repeat(33), 'hex'); // 33-byte compressed sec
const transport: RpcTransport = {
kind: 'inproc',
handle: async (req) => {
calls.push(req);
const p = req.params as Record<string, string>;
if (req.method === 'cggmp21.keygen') {
return { publicKey: pkBytes.toString('hex'), shares: ['aa', 'bb'] };
}
if (req.method === 'cggmp21.sign') {
return { signatureHex: '01' + p.messageHex.slice(0, 30) };
}
if (req.method === 'cggmp21.verify') {
const ok =
p.signatureHex.startsWith('01') &&
p.signatureHex.slice(2) === p.messageHex.slice(0, 30) &&
p.pubKeyHex === pkBytes.toString('hex');
return { ok };
}
throw new Error('unknown rpc method: ' + req.method);
},
};
return { transport, calls };
}
it('reports scheme and policyId', () => {
const s = new Cggmp21Signer({});
expect(s.scheme).toBe('cggmp21');
expect(s.policyId).toBe('ECDSA-SECP256K1-CGGMP21');
});
it('signs and verifies via mocked daemon', async () => {
const { transport, calls } = makeMock();
const s = new Cggmp21Signer({ transport });
await s.keygenInit({ threshold: 2, participants: 3 });
const m = msg(0xc1);
const sig = await s.sign(m);
expect(await s.verify(m, sig)).toBe(true);
// Forgery
const m2 = msg(0xc2);
expect(await s.verify(m2, sig)).toBe(false);
// Wire format assertions
expect(calls[0].method).toBe('cggmp21.keygen');
expect(calls[1].method).toBe('cggmp21.sign');
expect(calls[2].method).toBe('cggmp21.verify');
});
});
/**
* Mock pulsar/corona daemons. The handler asserts the wire format
* passing through (so this test catches accidental drift on the TS
* side) and replays a deterministic signature shaped exactly as the
* Go daemon would produce — 16 bytes for sigs, 8 bytes for pks.
*/
function makeLatticeMock(scheme: 'pulsar' | 'corona'): {
transport: RpcTransport;
calls: RpcRequest[];
} {
const calls: RpcRequest[] = [];
// Keep a deterministic pk per (scheme) so verify can succeed.
const pkBytes = Buffer.from(scheme === 'pulsar' ? '00112233aabbccdd' : 'deadbeefcafebabe', 'hex');
const transport: RpcTransport = {
kind: 'inproc',
handle: async (req) => {
calls.push(req);
const p = req.params as Record<string, string>;
// Validate wire format on each call.
if (req.method.endsWith('.keygen')) {
return {
publicKey: pkBytes.toString('hex'),
shares: ['aa', 'bb', 'cc'],
};
}
if (req.method.endsWith('.sign')) {
if (!/^[0-9a-f]+$/.test(p.messageHex)) throw new Error('bad messageHex');
if (!/^[0-9a-f]+$/.test(p.pubKeyHex)) throw new Error('bad pubKeyHex');
// Deterministic "signature": SHA256-ish prefix + msg suffix — but
// we want round-trip to succeed, so we just echo the message tag
// and validate it matches on verify().
const tag = scheme === 'pulsar' ? '70' : '63'; // 'p' / 'c'
return { signatureHex: tag + p.messageHex.slice(0, 30) };
}
if (req.method.endsWith('.verify')) {
const tag = scheme === 'pulsar' ? '70' : '63';
const ok =
p.signatureHex.startsWith(tag) &&
p.signatureHex.slice(2) === p.messageHex.slice(0, 30) &&
p.pubKeyHex === pkBytes.toString('hex');
return { ok };
}
throw new Error('unknown rpc method: ' + req.method);
},
};
return { transport, calls };
}
describe('pulsar signer (JSON-RPC wire client)', () => {
it('signs and verifies via mocked daemon', async () => {
const { transport, calls } = makeLatticeMock('pulsar');
const s = new PulsarSigner({ transport });
await s.keygenInit({ threshold: 2, participants: 3 });
expect(s.scheme).toBe('pulsar');
expect(s.policyId).toBe('PULSAR-M-LWE-FIPS204T');
const m = msg(0xa1);
const sig = await s.sign(m);
expect(sig.length).toBeGreaterThan(0);
expect(await s.verify(m, sig)).toBe(true);
// Forgery: sign m, try to verify m2 — daemon mock checks the tag,
// a non-matching message must yield ok=false.
const m2 = msg(0xa2);
expect(await s.verify(m2, sig)).toBe(false);
// Wire format: assert keygen was called with expected method name.
expect(calls[0].method).toBe('pulsar.keygen');
expect(calls[1].method).toBe('pulsar.sign');
expect(calls[2].method).toBe('pulsar.verify');
});
it('rejects verify against wrong pubkey', async () => {
const { transport } = makeLatticeMock('pulsar');
const s = new PulsarSigner({ transport });
await s.keygenInit({ threshold: 2, participants: 3 });
const m = msg(1);
const sig = await s.sign(m);
const wrong = new Uint8Array([1, 2, 3, 4]);
expect(await s.verify(m, sig, wrong)).toBe(false);
});
});
describe('corona signer (JSON-RPC wire client)', () => {
it('signs and verifies via mocked daemon', async () => {
const { transport, calls } = makeLatticeMock('corona');
const s = new CoronaSigner({ transport });
await s.keygenInit({ threshold: 2, participants: 3 });
expect(s.scheme).toBe('corona');
expect(s.policyId).toBe(POLICY_ID.corona);
const m = msg(7);
const sig = await s.sign(m);
expect(await s.verify(m, sig)).toBe(true);
expect(await s.verify(msg(8), sig)).toBe(false);
expect(calls[1].method).toBe('corona.sign');
});
});
describe('double-lattice signer (pulsar ⊕ corona)', () => {
it('signs, verifies, rejects forgery, rejects wrong key', async () => {
const p = makeLatticeMock('pulsar');
const c = makeLatticeMock('corona');
const s = new DoubleLatticeSigner({
pulsar: { transport: p.transport },
corona: { transport: c.transport },
});
await s.keygenInit({ threshold: 2, participants: 3 });
expect(s.scheme).toBe('double-lattice');
expect(s.policyId).toBe('DOUBLE-LATTICE-PULSAR+CORONA');
const m = msg(0xab);
const sig = await s.sign(m);
// Wire: u32_be(len) || pulsar_sig || corona_sig — both 16-hex == 8 bytes.
expect(sig.length).toBeGreaterThan(4 + 4);
const pulsarLen = (sig[0] << 24) | (sig[1] << 16) | (sig[2] << 8) | sig[3];
expect(pulsarLen).toBe(16); // mock returns 32-hex = 16 bytes
expect(await s.verify(m, sig)).toBe(true);
// Forgery
const m2 = msg(0xac);
expect(await s.verify(m2, sig)).toBe(false);
// Build a combined pk and verify against it (round-trips composed key).
const keys = await s.keygenFinish();
expect(await s.verify(m, sig, keys.publicKey)).toBe(true);
// Wrong combined pk
const badPk = new Uint8Array(keys.publicKey);
badPk[badPk.length - 1] ^= 0xff;
expect(await s.verify(m, sig, badPk)).toBe(false);
});
});