Files
teleport/contracts/test/StrictPQProfileGate.test.ts
zeekay 602eaaa8ca fix(BridgeV2): install-time gate refusal; move test mocks out of artifacts
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.
2026-05-13 16:09:11 -07:00

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;
});
});