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.
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
/**
|
|
* Custody-MPC end-to-end tests.
|
|
*
|
|
* Exercises the six-scheme threshold-signer bus end-to-end:
|
|
*
|
|
* 1. Resolve scheme from LUX_PROFILE.
|
|
* 2. Construct signer via getSigner().
|
|
* 3. (Where possible) run keygenInit -> sign -> verify round-trip.
|
|
* 4. Verify policyId matches the canonical @luxfi/pq-profile-ids-ts
|
|
* constants — this is the byte-level invariant shared with the
|
|
* on-chain StrictPQProfileGate.
|
|
*
|
|
* For lattice / daemon-backed signers (cggmp21, pulsar, corona,
|
|
* double-lattice), inproc mocks replicate the wire format mpcd would
|
|
* expose. For local schemes (ecdsa via gg18, frost via @noble/curves),
|
|
* the test runs against the real in-process implementation.
|
|
*
|
|
* This file is the "all-six-schemes-round-trip" integration test that
|
|
* mirrors §4.3 of SPEC.md.
|
|
*/
|
|
|
|
import { ethers } from 'ethers';
|
|
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
import {
|
|
getSigner,
|
|
resolveSchemeFromEnv,
|
|
POLICY_ID,
|
|
EcdsaSigner,
|
|
FrostSigner,
|
|
Cggmp21Signer,
|
|
PulsarSigner,
|
|
CoronaSigner,
|
|
DoubleLatticeSigner,
|
|
SchemeName,
|
|
} from '../src/signers';
|
|
import { RpcRequest, RpcTransport } from '../src/signers/rpc';
|
|
import {
|
|
SCHEME_ECDSA_GG18,
|
|
SCHEME_ECDSA_CGGMP21,
|
|
SCHEME_FROST_RFC9591,
|
|
SCHEME_PULSAR_M_LWE,
|
|
SCHEME_CORONA_R_LWE,
|
|
SCHEME_DOUBLE_LATTICE,
|
|
} from '@luxfi/pq-profile-ids-ts';
|
|
|
|
// ─── Lattice daemon mock (shared across pulsar/corona/double-lattice) ───
|
|
function makeLatticeMock(scheme: 'pulsar' | 'corona'): {
|
|
transport: RpcTransport;
|
|
calls: RpcRequest[];
|
|
} {
|
|
const calls: RpcRequest[] = [];
|
|
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>;
|
|
if (req.method.endsWith('.keygen')) {
|
|
return { publicKey: pkBytes.toString('hex'), shares: ['aa', 'bb', 'cc'] };
|
|
}
|
|
if (req.method.endsWith('.sign')) {
|
|
const tag = scheme === 'pulsar' ? '70' : '63';
|
|
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 method: ' + req.method);
|
|
},
|
|
};
|
|
return { transport, calls };
|
|
}
|
|
|
|
function makeCggmp21Mock(): { transport: RpcTransport; calls: RpcRequest[] } {
|
|
const calls: RpcRequest[] = [];
|
|
const pkBytes = Buffer.from('aa'.repeat(33), 'hex');
|
|
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 method: ' + req.method);
|
|
},
|
|
};
|
|
return { transport, calls };
|
|
}
|
|
|
|
const m32 = (b: number) => new Uint8Array(32).fill(b);
|
|
|
|
describe('e2e custody MPC bus — policyId matches canonical @luxfi/pq-profile-ids-ts', () => {
|
|
// Single source of truth check: every policyId returned by the
|
|
// signer must match the same canonical constant the on-chain gate
|
|
// imports from @luxfi/pq-profile-ids. A drift between the two sides
|
|
// is the kind of bug that "passes tests locally but breaks in prod"
|
|
// — this test is the latch.
|
|
const expectedMap: Record<SchemeName, string> = {
|
|
ecdsa: SCHEME_ECDSA_GG18,
|
|
cggmp21: SCHEME_ECDSA_CGGMP21,
|
|
frost: SCHEME_FROST_RFC9591,
|
|
pulsar: SCHEME_PULSAR_M_LWE,
|
|
corona: SCHEME_CORONA_R_LWE,
|
|
'double-lattice': SCHEME_DOUBLE_LATTICE,
|
|
};
|
|
|
|
it.each(Object.entries(expectedMap))(
|
|
'scheme %s reports policyId %s',
|
|
(scheme, expected) => {
|
|
expect(POLICY_ID[scheme as SchemeName]).toBe(expected);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('e2e custody MPC bus — LUX_PROFILE drives scheme selection', () => {
|
|
// Under quasar-strict-pq the default is double-lattice. Construct
|
|
// it via the bus AND through getSigner() — both must produce a
|
|
// working signer with the right policyId.
|
|
it('strict-PQ default round-trip: double-lattice via inproc mocks', async () => {
|
|
const scheme = resolveSchemeFromEnv({ LUX_PROFILE: 'quasar-strict-pq' } as never);
|
|
expect(scheme).toBe('double-lattice');
|
|
|
|
const pulsarMock = makeLatticeMock('pulsar');
|
|
const coronaMock = makeLatticeMock('corona');
|
|
const signer = getSigner({
|
|
scheme: 'double-lattice',
|
|
config: {
|
|
pulsar: { transport: pulsarMock.transport },
|
|
corona: { transport: coronaMock.transport },
|
|
},
|
|
});
|
|
await signer.keygenInit!({ threshold: 2, participants: 3 });
|
|
|
|
const message = m32(0x55);
|
|
const sig = await signer.sign(message);
|
|
expect(await signer.verify(message, sig)).toBe(true);
|
|
|
|
// Both daemons MUST have been called.
|
|
expect(pulsarMock.calls.map((c) => c.method)).toEqual(
|
|
expect.arrayContaining(['pulsar.keygen', 'pulsar.sign', 'pulsar.verify']),
|
|
);
|
|
expect(coronaMock.calls.map((c) => c.method)).toEqual(
|
|
expect.arrayContaining(['corona.keygen', 'corona.sign', 'corona.verify']),
|
|
);
|
|
});
|
|
|
|
it('classical-compat default: ecdsa-GG18 wire format byte-identical', async () => {
|
|
const scheme = resolveSchemeFromEnv({ LUX_PROFILE: 'classical-compat' } as never);
|
|
expect(scheme).toBe('ecdsa');
|
|
|
|
// The signer constructor does not fail on missing binary — the
|
|
// verify path is exercised directly with a real ECDSA sig.
|
|
const wallet = ethers.Wallet.createRandom();
|
|
const compressedPub = ethers.getBytes(
|
|
secp256k1.getPublicKey(ethers.getBytes(wallet.privateKey), true) as Uint8Array,
|
|
);
|
|
const signer = getSigner({
|
|
scheme: 'ecdsa',
|
|
config: {
|
|
binPath: '/nonexistent/gg18_sign_client',
|
|
signingManager: 'http://localhost:8000',
|
|
keyStore: 'keys.store',
|
|
cwd: '/tmp',
|
|
publicKey: compressedPub,
|
|
},
|
|
});
|
|
expect(signer.policyId).toBe(SCHEME_ECDSA_GG18);
|
|
|
|
const message = m32(0x42);
|
|
const sig = await wallet.signingKey.sign(message);
|
|
expect(await signer.verify(message, ethers.getBytes(sig.serialized), compressedPub)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('e2e custody MPC bus — sign/verify round-trip for every scheme', () => {
|
|
// For each scheme that can be exercised in-process (or with a
|
|
// wire-format inproc mock), run the full keygenInit -> sign ->
|
|
// verify path. This is the "all six lanes work end-to-end" test.
|
|
|
|
it('frost: full noble-curves in-process round-trip', async () => {
|
|
const signer = getSigner({
|
|
scheme: 'frost',
|
|
config: { threshold: 2, participants: 3 },
|
|
});
|
|
const message = m32(0x01);
|
|
const sig = await signer.sign(message);
|
|
expect(sig.length).toBe(65);
|
|
expect(await signer.verify(message, sig)).toBe(true);
|
|
});
|
|
|
|
it('cggmp21: inproc mock round-trip', async () => {
|
|
const { transport } = makeCggmp21Mock();
|
|
const signer = getSigner({ scheme: 'cggmp21', config: { transport } });
|
|
await signer.keygenInit!({ threshold: 2, participants: 3 });
|
|
const message = m32(0x02);
|
|
const sig = await signer.sign(message);
|
|
expect(await signer.verify(message, sig)).toBe(true);
|
|
});
|
|
|
|
it('pulsar: inproc mock round-trip', async () => {
|
|
const { transport } = makeLatticeMock('pulsar');
|
|
const signer = getSigner({ scheme: 'pulsar', config: { transport } });
|
|
await signer.keygenInit!({ threshold: 2, participants: 3 });
|
|
const message = m32(0x03);
|
|
const sig = await signer.sign(message);
|
|
expect(await signer.verify(message, sig)).toBe(true);
|
|
});
|
|
|
|
it('corona: inproc mock round-trip', async () => {
|
|
const { transport } = makeLatticeMock('corona');
|
|
const signer = getSigner({ scheme: 'corona', config: { transport } });
|
|
await signer.keygenInit!({ threshold: 2, participants: 3 });
|
|
const message = m32(0x04);
|
|
const sig = await signer.sign(message);
|
|
expect(await signer.verify(message, sig)).toBe(true);
|
|
});
|
|
|
|
it('double-lattice: composition round-trip + length-prefix layout', async () => {
|
|
const pulsarMock = makeLatticeMock('pulsar');
|
|
const coronaMock = makeLatticeMock('corona');
|
|
const signer = getSigner({
|
|
scheme: 'double-lattice',
|
|
config: {
|
|
pulsar: { transport: pulsarMock.transport },
|
|
corona: { transport: coronaMock.transport },
|
|
},
|
|
});
|
|
await signer.keygenInit!({ threshold: 2, participants: 3 });
|
|
const message = m32(0x05);
|
|
const sig = await signer.sign(message);
|
|
|
|
// Layout: u32_be(len) || pulsar_sig || corona_sig
|
|
// Each mock returns 32-hex = 16 bytes per sub-signature.
|
|
const pulsarLen = (sig[0] << 24) | (sig[1] << 16) | (sig[2] << 8) | sig[3];
|
|
expect(pulsarLen).toBe(16);
|
|
expect(sig.length).toBe(4 + 16 + 16);
|
|
|
|
expect(await signer.verify(message, sig)).toBe(true);
|
|
|
|
// Composed public key: u32_be(len_pulsar) || pulsar_pk || corona_pk
|
|
const keys = await signer.keygenFinish!();
|
|
expect(keys.publicKey.length).toBe(4 + 8 + 8);
|
|
const pkPulsarLen =
|
|
(keys.publicKey[0] << 24) |
|
|
(keys.publicKey[1] << 16) |
|
|
(keys.publicKey[2] << 8) |
|
|
keys.publicKey[3];
|
|
expect(pkPulsarLen).toBe(8);
|
|
});
|
|
});
|
|
|
|
describe('e2e custody MPC bus — profile gate negative tests', () => {
|
|
// The off-chain mirror of the on-chain refusal: an explicit PQ
|
|
// scheme under classical-compat throws, and vice versa.
|
|
|
|
it('throws on PQ scheme under classical-compat (all three PQ schemes)', () => {
|
|
for (const scheme of ['pulsar', 'corona', 'double-lattice'] as const) {
|
|
expect(() =>
|
|
resolveSchemeFromEnv({
|
|
LUX_PROFILE: 'classical-compat',
|
|
MPC_SCHEME: scheme,
|
|
} as never),
|
|
).toThrow(/incompatible/);
|
|
}
|
|
});
|
|
|
|
it('throws on classical scheme under quasar-strict-pq (all three classical schemes)', () => {
|
|
for (const scheme of ['ecdsa', 'cggmp21', 'frost'] as const) {
|
|
expect(() =>
|
|
resolveSchemeFromEnv({
|
|
LUX_PROFILE: 'quasar-strict-pq',
|
|
MPC_SCHEME: scheme,
|
|
} as never),
|
|
).toThrow(/incompatible/);
|
|
}
|
|
});
|
|
|
|
it('fails-closed on unset LUX_PROFILE (no implicit ECDSA default)', () => {
|
|
// Red HIGH #4 closure: missing env used to mean classical signing
|
|
// on a strict-PQ deployment. There is no implicit default; the
|
|
// resolver throws explicit remediation text.
|
|
expect(() => resolveSchemeFromEnv({} as never)).toThrow(/LUX_PROFILE must be set/);
|
|
});
|
|
});
|
|
|
|
describe('e2e custody MPC bus — composition contract: signer can be re-bound by public key', () => {
|
|
// double-lattice exposes the inner Pulsar / Corona via readonly
|
|
// fields. Composition (not inheritance) means a caller can also
|
|
// independently verify a sub-signature against the combined
|
|
// signature's split.
|
|
|
|
it('exposes inner pulsar + corona; sub-sig split is unambiguous', async () => {
|
|
const p = makeLatticeMock('pulsar');
|
|
const c = makeLatticeMock('corona');
|
|
const signer = new DoubleLatticeSigner({
|
|
pulsar: { transport: p.transport },
|
|
corona: { transport: c.transport },
|
|
});
|
|
await signer.keygenInit({ threshold: 2, participants: 3 });
|
|
expect(signer.pulsar).toBeInstanceOf(PulsarSigner);
|
|
expect(signer.corona).toBeInstanceOf(CoronaSigner);
|
|
|
|
const message = m32(0x88);
|
|
const sig = await signer.sign(message);
|
|
|
|
const pulsarLen = (sig[0] << 24) | (sig[1] << 16) | (sig[2] << 8) | sig[3];
|
|
const pulsarSig = sig.subarray(4, 4 + pulsarLen);
|
|
const coronaSig = sig.subarray(4 + pulsarLen);
|
|
|
|
// Verify each lane via the exposed inner signer.
|
|
expect(await signer.pulsar.verify(message, pulsarSig)).toBe(true);
|
|
expect(await signer.corona.verify(message, coronaSig)).toBe(true);
|
|
});
|
|
});
|