mirror of
https://github.com/luxfi/teleport.git
synced 2026-10-30 15:15:49 +00:00
Three coordinated fixes for Red HIGH cluster #2/#5/#8:
1. BridgeV2.setActiveProfile now enforces the strict-PQ allow-list at
install time. Under PROFILE_QUASAR_STRICT_PQ the verifier MUST be
non-zero AND its policyId() MUST be in the PQ allow-list
(STARK_FRI_SHA3_PQ or MLDSA65_FIPS204). An admin (or compromised
admin key) can no longer point the slot at address(0) or a
spoofed/classical verifier and defer the check to claim time.
2. BridgeV2.setP3QVerifier now enforces a tighter allow-list: the P3Q
slot is specifically for STARK/FRI proofs, so only
POLICY_STARK_FRI_SHA3_PQ is admissible (ML-DSA-65 belongs in the
envelope cert set, not the P3Q slot). address(0) is still permitted
and clears the slot (claimWithProof then reverts P3QVerifierUnset).
3. StrictPQProfileGate.refuseUnderStrictPQ flipped from deny-list to
allow-list semantics, mirroring the upstream pq-profile-ids v0.1.1
OnlyStrictPQ change. Re-exports POLICY_STARK_FRI_SHA3_PQ and
POLICY_MLDSA65_FIPS204 + adds VerifierUnsetUnderStrictPQ.
4. Test-only Solidity (MockVerifier, NonPQP3QStub,
StrictPQProfileGateHarness, P3QVerifierStubExport) moved out of
contracts/contracts/test/ — that directory is inside paths.sources
and was therefore compiled into the deployment artifact tree. The
replacement lives in contracts/test-contracts/ and is re-added to
the source path list only when HARDHAT_INCLUDE_TEST_CONTRACTS=1 or
npm_lifecycle_event === "test". Production CI/CD never sets either,
so MockVerifier(POLICY_STARK_FRI_SHA3_PQ) can no longer ship as a
spoofed verifier in the artifact set.
Tests:
- BridgeV2.test.ts: +3 new cases (refuse address(0), refuse spoofed
policy at install time, address(0) clears P3Q slot) + 1 production
artifact snapshot test that compiles in an isolated sandbox and
asserts banned names (MockVerifier, NonPQP3QStub, *Harness) are
absent while production contracts (BridgeV2, Bridge, ERC20B,
Warp2VerifierStub) are present. 11/11 pass.
- StrictPQProfileGate.test.ts: +3 new cases (allow-list refusal of
spoofed policy, refuse address(0) under strict-PQ, classical
no-op for address(0)). 7/7 pass.
- Bridge.test.ts: unchanged. 36/36 pass.
- mpc/__tests__/signers.test.ts: unchanged. 31/31 pass.
After this fix, the strict-PQ profile is code-enforced — admin
discipline is no longer the load-bearing security control.
107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
import { expect } from "chai";
|
|
import hre, { ethers } from "hardhat";
|
|
|
|
describe("StrictPQProfileGate", function () {
|
|
async function deploy() {
|
|
const Harness = await hre.ethers.getContractFactory(
|
|
"StrictPQProfileGateHarness"
|
|
);
|
|
const harness = await Harness.deploy();
|
|
await harness.waitForDeployment();
|
|
|
|
const Mock = await hre.ethers.getContractFactory("MockVerifier");
|
|
|
|
async function mock(policyName: string) {
|
|
const policyId = ethers.id(policyName);
|
|
const v = await Mock.deploy(policyId);
|
|
await v.waitForDeployment();
|
|
return v;
|
|
}
|
|
|
|
const groth16 = await mock("ProofPolicyGroth16BN254");
|
|
const plonk = await mock("ProofPolicyPLONKBN254");
|
|
const kzg = await mock("ProofPolicyKZGBLS12381");
|
|
const stark = await mock("ProofPolicySTARKFRISHA3PQ");
|
|
const mldsa = await mock("ProofPolicyMLDSA65FIPS204");
|
|
|
|
return { harness, groth16, plonk, kzg, stark, mldsa };
|
|
}
|
|
|
|
it("allows all backends under classical-compat profile", async () => {
|
|
const { harness, groth16, plonk, kzg, stark, mldsa } = await deploy();
|
|
const p = await harness.profileClassical();
|
|
for (const v of [groth16, plonk, kzg, stark, mldsa]) {
|
|
await expect(harness.check(p, await v.getAddress())).to.not.be.reverted;
|
|
}
|
|
});
|
|
|
|
it("refuses BN254/BLS12-381 backends under strict-PQ profile", async () => {
|
|
const { harness, groth16, plonk, kzg } = await deploy();
|
|
const p = await harness.profileStrictPQ();
|
|
|
|
await expect(harness.check(p, await groth16.getAddress()))
|
|
.to.be.revertedWithCustomError(harness, "VerifierRefusedUnderStrictPQ")
|
|
.withArgs(p, await harness.policyGroth16BN254());
|
|
|
|
await expect(harness.check(p, await plonk.getAddress()))
|
|
.to.be.revertedWithCustomError(harness, "VerifierRefusedUnderStrictPQ")
|
|
.withArgs(p, await harness.policyPLONKBN254());
|
|
|
|
await expect(harness.check(p, await kzg.getAddress()))
|
|
.to.be.revertedWithCustomError(harness, "VerifierRefusedUnderStrictPQ")
|
|
.withArgs(p, await harness.policyKZGBLS12381());
|
|
});
|
|
|
|
it("allows PQ backends under strict-PQ profile", async () => {
|
|
const { harness, stark, mldsa } = await deploy();
|
|
const p = await harness.profileStrictPQ();
|
|
await expect(harness.check(p, await stark.getAddress())).to.not.be.reverted;
|
|
await expect(harness.check(p, await mldsa.getAddress())).to.not.be.reverted;
|
|
});
|
|
|
|
it("reverts on unknown profile", async () => {
|
|
const { harness, stark } = await deploy();
|
|
const unknown = ethers.id("PROFILE_NONSENSE");
|
|
await expect(harness.check(unknown, await stark.getAddress()))
|
|
.to.be.revertedWithCustomError(harness, "UnknownProfile")
|
|
.withArgs(unknown);
|
|
});
|
|
|
|
it("refuses spoofed-policy verifier under strict-PQ (allow-list, not deny-list)", async () => {
|
|
// Red HIGH #2: a verifier returning an unknown bytes32 from
|
|
// `policyId()` previously slipped past the deny-list. Allow-list
|
|
// gating refuses it explicitly.
|
|
const { harness } = await deploy();
|
|
const Mock = await hre.ethers.getContractFactory("MockVerifier");
|
|
const bogus = ethers.id("ProofPolicyBogusUnknown");
|
|
const v = await Mock.deploy(bogus);
|
|
await v.waitForDeployment();
|
|
|
|
const p = await harness.profileStrictPQ();
|
|
await expect(harness.check(p, await v.getAddress()))
|
|
.to.be.revertedWithCustomError(harness, "VerifierRefusedUnderStrictPQ")
|
|
.withArgs(p, bogus);
|
|
});
|
|
|
|
it("refuses address(0) verifier under strict-PQ (no silent no-op)", async () => {
|
|
// Red HIGH #5: address(0) under strict-PQ used to be a silent
|
|
// no-op — strict-PQ was unenforceable in that state. The gate
|
|
// now refuses it explicitly.
|
|
const { harness } = await deploy();
|
|
const p = await harness.profileStrictPQ();
|
|
await expect(harness.check(p, ethers.ZeroAddress))
|
|
.to.be.revertedWithCustomError(harness, "VerifierUnsetUnderStrictPQ")
|
|
.withArgs(p);
|
|
});
|
|
|
|
it("classical-compat is unchanged: address(0) and any backend allowed", async () => {
|
|
const { harness, groth16, stark } = await deploy();
|
|
const p = await harness.profileClassical();
|
|
// address(0) under classical-compat is fine — the slot is allowed
|
|
// to be empty, the gate is a no-op there.
|
|
await expect(harness.check(p, ethers.ZeroAddress)).to.not.be.reverted;
|
|
await expect(harness.check(p, await groth16.getAddress())).to.not.be.reverted;
|
|
await expect(harness.check(p, await stark.getAddress())).to.not.be.reverted;
|
|
});
|
|
});
|