mirror of
https://github.com/luxfi/teleport.git
synced 2026-10-30 15:15:49 +00:00
454 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|