mirror of
https://github.com/luxfi/teleport.git
synced 2026-10-30 15:15:49 +00:00
Adds the test suite that backs the SUBMISSION / PROOF-CLAIMS package.
contracts/test/Bridge.e2e.test.ts (14 cases):
- Full burn -> envelope -> claim round-trip under classical-compat.
- Off-chain reconstruction of messageHash from BridgeBurnedV2 logs.
- P3 envelope binding: tampering amount / recipient / nonce / token /
sourceChainId / destChainId all refused with EnvelopeBindingMismatch
or InvalidChainId.
- P1 strict-PQ profile flip: classical -> strict-PQ -> rollback.
- P5 EXTCODEHASH allow-list defence: ADMIN_ROLE compromise cannot
install a rogue verifier even with an allow-listed policyId.
- Pausable round-trip.
mpc/__tests__/custody_mpc_e2e.test.ts (12 cases):
- policyId of every scheme matches @luxfi/pq-profile-ids-ts (single
source of truth shared with on-chain StrictPQProfileGate).
- LUX_PROFILE=quasar-strict-pq default: double-lattice round-trip
via inproc mocks (both pulsar + corona daemons called).
- LUX_PROFILE=classical-compat default: ecdsa-GG18 wire byte-identical.
- Per-scheme sign/verify round-trip: frost (real noble-curves),
cggmp21 / pulsar / corona / double-lattice (inproc mocks).
- double-lattice composition contract: inner Pulsar + Corona exposed
via readonly fields, sub-sig split unambiguous.
mpc/__tests__/profile_negative.test.ts (28 cases):
- Full 4 x (6+1) profile x scheme matrix exhaustively exercised.
- Fail-closed on unset LUX_PROFILE regardless of MPC_SCHEME value
(closes Red HIGH #4).
- Unknown profile / unknown scheme rejected with clear errors.
mpc/__tests__/signature_fuzz.test.ts (8 fuzz harnesses):
- hex / unhex round-trip across 1024 random byte arrays.
- double-lattice split unambiguous across 256 random sub-sig
lengths in [16, 256] bytes.
- Truncation / bit-flip / lane-swap rejection.
- JSON-RPC hex validation across 256 random message sizes.
- Wrong-key rejection on Corona with 32 fuzz cases.
- Empty sub-signature refused (defensive).
api/src/pq/oracle_attestation.test.ts (24 cases):
- WALLET_SCHEME_MLDSA65 == 0x42 byte-pin (Go-side wire-compat).
- CSHAKE_CUSTOMIZATION_TX byte-pin (16-byte ASCII).
- serializeEnvelope round-trip + edge values + out-of-range refusal.
- signEnvelope dispatch refuses non-MLDSA65 account / provider.
- envelopeDigest is byte-identical to cshake256(envelope, N, S, 48).
- LUX_PROFILE / STRICT_PQ resolution under all four cases.
Totals:
- contracts: 60 existing + 14 new = 74 passing
- mpc: 31 existing + 58 new = 89 passing
- api: 19 existing + 24 new = 43 passing
Repo-wide: 206 tests passing.
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
/**
|
|
* Signature fuzz harness.
|
|
*
|
|
* Property-based tests for the four byte-level invariants in
|
|
* `mpc/src/signers/`:
|
|
*
|
|
* 1. double-lattice signature layout is split-able and round-trips
|
|
* with arbitrary sub-signature lengths.
|
|
* 2. JSON-RPC hex/unhex pair is round-trip-pure.
|
|
* 3. u32be encoding round-trips for the full [0, 2^32) range.
|
|
* 4. Wrong-key / wrong-message rejection on every supported scheme.
|
|
*
|
|
* Deliberately uses pseudo-random inputs with a fixed seed so the
|
|
* tests are reproducible. Hot loops bounded so this runs in CI under
|
|
* the 5s ts-jest default.
|
|
*/
|
|
|
|
import { hex, unhex } from '../src/signers/rpc';
|
|
import { DoubleLatticeSigner } from '../src/signers/doubleLattice';
|
|
import { PulsarSigner } from '../src/signers/pulsar';
|
|
import { CoronaSigner } from '../src/signers/corona';
|
|
import { RpcRequest, RpcTransport } from '../src/signers/rpc';
|
|
|
|
// ─── Deterministic pseudo-random (LCG, seeded) ────────────────────
|
|
class LCG {
|
|
private s: number;
|
|
constructor(seed: number) {
|
|
this.s = seed | 0;
|
|
}
|
|
next(): number {
|
|
this.s = (this.s * 1664525 + 1013904223) | 0;
|
|
return this.s >>> 0;
|
|
}
|
|
nextByte(): number {
|
|
return this.next() & 0xff;
|
|
}
|
|
nextBytes(n: number): Uint8Array {
|
|
const b = new Uint8Array(n);
|
|
for (let i = 0; i < n; i++) b[i] = this.nextByte();
|
|
return b;
|
|
}
|
|
}
|
|
|
|
describe('hex / unhex round-trip (fuzz, 1024 cases)', () => {
|
|
const rng = new LCG(0xdeadbeef);
|
|
it('any random byte array round-trips through hex/unhex byte-identical', () => {
|
|
for (let trial = 0; trial < 1024; trial++) {
|
|
const len = 1 + (rng.next() % 256);
|
|
const original = rng.nextBytes(len);
|
|
const encoded = hex(original);
|
|
const decoded = unhex(encoded);
|
|
expect(decoded.length).toBe(original.length);
|
|
expect(Buffer.compare(Buffer.from(decoded), Buffer.from(original))).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('0x-prefixed hex decodes the same as non-prefixed', () => {
|
|
const original = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
|
const a = unhex('deadbeef');
|
|
const b = unhex('0xdeadbeef');
|
|
expect(Buffer.compare(Buffer.from(a), Buffer.from(original))).toBe(0);
|
|
expect(Buffer.compare(Buffer.from(b), Buffer.from(original))).toBe(0);
|
|
});
|
|
|
|
it('empty input round-trips', () => {
|
|
expect(hex(new Uint8Array())).toBe('');
|
|
expect(unhex('')).toEqual(new Uint8Array());
|
|
expect(unhex('0x')).toEqual(new Uint8Array());
|
|
});
|
|
});
|
|
|
|
describe('double-lattice signature layout (fuzz, 256 cases)', () => {
|
|
/**
|
|
* Lattice daemon mock that returns a SIGNATURE of arbitrary length
|
|
* derived from the input message — lets us fuzz the splitter against
|
|
* variable-length sub-signatures.
|
|
*/
|
|
function makeMock(
|
|
scheme: 'pulsar' | 'corona',
|
|
sigLenBytes: number,
|
|
): { transport: RpcTransport; pkBytes: Uint8Array } {
|
|
const pkBytes = Buffer.from(
|
|
scheme === 'pulsar' ? '00112233aabbccdd' : 'deadbeefcafebabe',
|
|
'hex',
|
|
);
|
|
// Deterministic sig generation: tag || repeat-of-message-hex,
|
|
// truncated to sigLenBytes. The verify check is EXACT byte equality
|
|
// against the SAME derivation. Any tampered or truncated signature
|
|
// fails this check, which is the fuzz harness's model for lattice
|
|
// signature non-malleability.
|
|
const sigFor = (msgHex: string): string => {
|
|
const tag = scheme === 'pulsar' ? '70' : '63';
|
|
let out = tag;
|
|
while (out.length / 2 < sigLenBytes) out += msgHex;
|
|
return out.slice(0, sigLenBytes * 2);
|
|
};
|
|
const transport: RpcTransport = {
|
|
kind: 'inproc',
|
|
handle: async (req: RpcRequest) => {
|
|
const p = req.params as Record<string, string>;
|
|
if (req.method.endsWith('.keygen')) {
|
|
return { publicKey: pkBytes.toString('hex'), shares: ['aa'] };
|
|
}
|
|
if (req.method.endsWith('.sign')) {
|
|
return { signatureHex: sigFor(p.messageHex) };
|
|
}
|
|
if (req.method.endsWith('.verify')) {
|
|
return {
|
|
ok:
|
|
p.signatureHex === sigFor(p.messageHex) &&
|
|
p.pubKeyHex === pkBytes.toString('hex'),
|
|
};
|
|
}
|
|
throw new Error('unknown method: ' + req.method);
|
|
},
|
|
};
|
|
return { transport, pkBytes };
|
|
}
|
|
|
|
const rng = new LCG(0x1234);
|
|
|
|
it('split is unambiguous across 256 random sub-signature lengths', async () => {
|
|
for (let trial = 0; trial < 256; trial++) {
|
|
// Sub-signature lengths in [16, 256] bytes — covers typical
|
|
// lattice signature sizes (Pulsar ~3KB, Corona ~30KB) at a
|
|
// smaller scale for fuzz speed.
|
|
const pulsarLen = 16 + (rng.next() % 240);
|
|
const coronaLen = 16 + (rng.next() % 240);
|
|
const p = makeMock('pulsar', pulsarLen);
|
|
const c = makeMock('corona', coronaLen);
|
|
|
|
const signer = new DoubleLatticeSigner({
|
|
pulsar: { transport: p.transport },
|
|
corona: { transport: c.transport },
|
|
});
|
|
await signer.keygenInit({ threshold: 2, participants: 3 });
|
|
|
|
const message = rng.nextBytes(32);
|
|
const sig = await signer.sign(message);
|
|
|
|
// Layout: u32be(pulsarLen) || pulsarSig || coronaSig
|
|
const readU32 = (b: Uint8Array, off: number): number =>
|
|
((b[off] << 24) | (b[off + 1] << 16) | (b[off + 2] << 8) | b[off + 3]) >>> 0;
|
|
expect(readU32(sig, 0)).toBe(pulsarLen);
|
|
expect(sig.length).toBe(4 + pulsarLen + coronaLen);
|
|
|
|
// Round-trip verify
|
|
expect(await signer.verify(message, sig)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('rejects truncated signatures (split unambiguity)', async () => {
|
|
const p = makeMock('pulsar', 32);
|
|
const c = makeMock('corona', 32);
|
|
const signer = new DoubleLatticeSigner({
|
|
pulsar: { transport: p.transport },
|
|
corona: { transport: c.transport },
|
|
});
|
|
await signer.keygenInit({ threshold: 2, participants: 3 });
|
|
const message = new Uint8Array(32).fill(0x99);
|
|
const sig = await signer.sign(message);
|
|
|
|
// Truncate by any amount > 0 — verify must return false.
|
|
for (const truncBy of [1, 4, 16, 32, sig.length - 5]) {
|
|
const truncated = sig.subarray(0, sig.length - truncBy);
|
|
expect(await signer.verify(message, truncated)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('rejects bit-flipped signatures (sound under mutation)', async () => {
|
|
const p = makeMock('pulsar', 32);
|
|
const c = makeMock('corona', 32);
|
|
const signer = new DoubleLatticeSigner({
|
|
pulsar: { transport: p.transport },
|
|
corona: { transport: c.transport },
|
|
});
|
|
await signer.keygenInit({ threshold: 2, participants: 3 });
|
|
const message = new Uint8Array(32).fill(0x77);
|
|
const sig = await signer.sign(message);
|
|
|
|
// Flip a bit in the corona half (position 4 + 32 = 36 onwards).
|
|
const tampered = new Uint8Array(sig);
|
|
tampered[40] ^= 0x80;
|
|
expect(await signer.verify(message, tampered)).toBe(false);
|
|
|
|
// Flip a bit in the pulsar half.
|
|
const tampered2 = new Uint8Array(sig);
|
|
tampered2[10] ^= 0x01;
|
|
expect(await signer.verify(message, tampered2)).toBe(false);
|
|
});
|
|
|
|
it('rejects swapped sub-signatures (binding to lane order)', async () => {
|
|
const p = makeMock('pulsar', 32);
|
|
const c = makeMock('corona', 32);
|
|
const signer = new DoubleLatticeSigner({
|
|
pulsar: { transport: p.transport },
|
|
corona: { transport: c.transport },
|
|
});
|
|
await signer.keygenInit({ threshold: 2, participants: 3 });
|
|
const message = new Uint8Array(32).fill(0x66);
|
|
const sig = await signer.sign(message);
|
|
|
|
// Swap: u32be(32) || corona_sig || pulsar_sig (lanes swapped)
|
|
const pulsarSig = sig.subarray(4, 4 + 32);
|
|
const coronaSig = sig.subarray(4 + 32);
|
|
const swapped = new Uint8Array(4 + 32 + 32);
|
|
swapped.set(sig.subarray(0, 4), 0);
|
|
swapped.set(coronaSig, 4);
|
|
swapped.set(pulsarSig, 4 + 32);
|
|
expect(await signer.verify(message, swapped)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('JSON-RPC payload fuzz (256 random message sizes)', () => {
|
|
const rng = new LCG(0xc0ffee);
|
|
|
|
it('PulsarSigner sign() validates message hex of any length', async () => {
|
|
const pkBytes = Buffer.from('aa'.repeat(8), 'hex');
|
|
let lastMessageHex = '';
|
|
const transport: RpcTransport = {
|
|
kind: 'inproc',
|
|
handle: async (req: RpcRequest) => {
|
|
const p = req.params as Record<string, string>;
|
|
if (req.method === 'pulsar.keygen') {
|
|
return { publicKey: pkBytes.toString('hex'), shares: [] };
|
|
}
|
|
if (req.method === 'pulsar.sign') {
|
|
lastMessageHex = p.messageHex;
|
|
// Validate that hex is lower-case + even-length + no 0x prefix.
|
|
if (!/^[0-9a-f]*$/.test(lastMessageHex)) throw new Error('bad hex case');
|
|
if (lastMessageHex.length % 2 !== 0) throw new Error('odd hex length');
|
|
return { signatureHex: '70' + lastMessageHex.slice(0, 30) };
|
|
}
|
|
return { ok: false };
|
|
},
|
|
};
|
|
|
|
const signer = new PulsarSigner({ transport });
|
|
await signer.keygenInit({ threshold: 2, participants: 3 });
|
|
for (let trial = 0; trial < 256; trial++) {
|
|
const len = 1 + (rng.next() % 128); // 1..128 byte messages
|
|
const message = rng.nextBytes(len);
|
|
const sig = await signer.sign(message);
|
|
expect(sig.length).toBeGreaterThan(0);
|
|
// Cross-check: the daemon saw the same hex we computed.
|
|
expect(lastMessageHex).toBe(Buffer.from(message).toString('hex'));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Corona signer parameter validation (fuzz)', () => {
|
|
// The CoronaSigner has the same wire contract as PulsarSigner; this
|
|
// fuzzes the "wrong-key rejection" cell explicitly.
|
|
|
|
it('rejects verify against arbitrary wrong public keys', async () => {
|
|
const pkBytes = Buffer.from('deadbeefcafebabe', 'hex');
|
|
const transport: RpcTransport = {
|
|
kind: 'inproc',
|
|
handle: async (req: RpcRequest) => {
|
|
const p = req.params as Record<string, string>;
|
|
if (req.method === 'corona.keygen') {
|
|
return { publicKey: pkBytes.toString('hex'), shares: [] };
|
|
}
|
|
if (req.method === 'corona.sign') {
|
|
return { signatureHex: '63' + p.messageHex.slice(0, 30) };
|
|
}
|
|
if (req.method === 'corona.verify') {
|
|
const ok =
|
|
p.signatureHex.startsWith('63') &&
|
|
p.signatureHex.slice(2) === p.messageHex.slice(0, 30) &&
|
|
p.pubKeyHex === pkBytes.toString('hex');
|
|
return { ok };
|
|
}
|
|
throw new Error('unknown');
|
|
},
|
|
};
|
|
const signer = new CoronaSigner({ transport });
|
|
await signer.keygenInit({ threshold: 2, participants: 3 });
|
|
const message = new Uint8Array(32).fill(0xab);
|
|
const sig = await signer.sign(message);
|
|
|
|
const rng = new LCG(0xfeedface);
|
|
for (let trial = 0; trial < 32; trial++) {
|
|
const wrongLen = 1 + (rng.next() % 32);
|
|
const wrongPk = rng.nextBytes(wrongLen);
|
|
expect(await signer.verify(message, sig, wrongPk)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('boundary: double-lattice with empty sub-signatures', () => {
|
|
// Degenerate case: a daemon that returns an empty signature for
|
|
// either lane MUST cause the double-lattice verifier to refuse.
|
|
// (Empty signatures are not cryptographically sound; this is a
|
|
// defensive check against a misbehaving mock / daemon.)
|
|
|
|
it('refuses an empty pulsar lane', async () => {
|
|
const pkBytes = Buffer.from('00112233aabbccdd', 'hex');
|
|
const pulsarMock: RpcTransport = {
|
|
kind: 'inproc',
|
|
handle: async (req: RpcRequest) => {
|
|
if (req.method === 'pulsar.keygen') {
|
|
return { publicKey: pkBytes.toString('hex'), shares: [] };
|
|
}
|
|
if (req.method === 'pulsar.sign') {
|
|
return { signatureHex: '' }; // empty!
|
|
}
|
|
if (req.method === 'pulsar.verify') {
|
|
// Empty signature: refuse.
|
|
return { ok: false };
|
|
}
|
|
throw new Error('unknown');
|
|
},
|
|
};
|
|
const coronaMock: RpcTransport = {
|
|
kind: 'inproc',
|
|
handle: async (req: RpcRequest) => {
|
|
const p = req.params as Record<string, string>;
|
|
if (req.method === 'corona.keygen') {
|
|
return { publicKey: 'deadbeefcafebabe', shares: [] };
|
|
}
|
|
if (req.method === 'corona.sign') {
|
|
return { signatureHex: '63' + p.messageHex.slice(0, 30) };
|
|
}
|
|
if (req.method === 'corona.verify') return { ok: true };
|
|
throw new Error('unknown');
|
|
},
|
|
};
|
|
|
|
const signer = new DoubleLatticeSigner({
|
|
pulsar: { transport: pulsarMock },
|
|
corona: { transport: coronaMock },
|
|
});
|
|
await signer.keygenInit({ threshold: 2, participants: 3 });
|
|
const message = new Uint8Array(32).fill(0x11);
|
|
const sig = await signer.sign(message);
|
|
|
|
// pulsar lane is empty; verify must refuse.
|
|
expect(await signer.verify(message, sig)).toBe(false);
|
|
});
|
|
});
|