mirror of
https://github.com/luxfi/pq-profile-ids.git
synced 2026-10-30 15:21:52 +00:00
Flip `_refuseUnderStrictPQ` from deny-list to allow-list semantics.
Under `PROFILE_QUASAR_STRICT_PQ`, a verifier is admissible iff its
`policyId()` is in `{POLICY_STARK_FRI_SHA3_PQ, POLICY_MLDSA65_FIPS204}`.
A spoofed verifier returning an unknown bytes32 from `policyId()` (not
classical, not PQ) is now refused — the previous deny-list let any
unrecognised value pass.
Replace the early-return-on-zero-address branch with an explicit
revert (`VerifierUnsetUnderStrictPQ`) under strict-PQ. A zero
verifier under strict-PQ is a silent no-op state and was exactly the
attack vector Red flagged: admin + setActiveProfile(STRICT_PQ, 0)
disabled the gate while keeping the profile bytes32 set. Now refused.
Classical-compat still allows address(0) — that profile's gate is a
no-op by design.
Adds three tests:
- testStrictPQ_RefusesZeroAddress
- testClassicalCompat_AllowsZeroAddress
- testStrictPQ_RefusesSpoofedPolicy
Removes the obsolete testUnconfiguredSlot_IsNoOp (asserted old behaviour).
Forge: 14 passed / 14 total.
Closes Red HIGH cluster #2/#5/#8 at the foundation layer.
Version: 0.1.0 → 0.1.1.
171 lines
6.4 KiB
Solidity
171 lines
6.4 KiB
Solidity
// SPDX-License-Identifier: MIT OR Apache-2.0
|
|
pragma solidity ^0.8.20;
|
|
|
|
import "forge-std/Test.sol";
|
|
import "../src/PQProfileIDs.sol";
|
|
import "../src/OnlyStrictPQ.sol";
|
|
import "../src/IPolicyId.sol";
|
|
|
|
contract MockPolicy is IPolicyId {
|
|
bytes32 private immutable _id;
|
|
constructor(bytes32 id) { _id = id; }
|
|
function policyId() external view returns (bytes32) { return _id; }
|
|
}
|
|
|
|
contract Harness is OnlyStrictPQ {
|
|
function check(bytes32 profile, IPolicyId v) external view {
|
|
_refuseUnderStrictPQ(profile, v);
|
|
}
|
|
|
|
function gated(bytes32 profile, IPolicyId v) external view onlyStrictPQ(profile, v) returns (uint256) {
|
|
return 42;
|
|
}
|
|
}
|
|
|
|
contract PQProfileIDsTest is Test {
|
|
Harness h;
|
|
MockPolicy groth16;
|
|
MockPolicy plonk;
|
|
MockPolicy kzg;
|
|
MockPolicy stark;
|
|
MockPolicy mldsa;
|
|
|
|
function setUp() public {
|
|
h = new Harness();
|
|
groth16 = new MockPolicy(PQProfileIDs.POLICY_GROTH16_BN254);
|
|
plonk = new MockPolicy(PQProfileIDs.POLICY_PLONK_BN254);
|
|
kzg = new MockPolicy(PQProfileIDs.POLICY_KZG_BLS12381);
|
|
stark = new MockPolicy(PQProfileIDs.POLICY_STARK_FRI_SHA3_PQ);
|
|
mldsa = new MockPolicy(PQProfileIDs.POLICY_MLDSA65_FIPS204);
|
|
}
|
|
|
|
// KAT: re-derive each constant from the canonical UTF-8 input.
|
|
function testKAT_Profiles() public pure {
|
|
assertEq(PQProfileIDs.PROFILE_CLASSICAL_COMPAT, keccak256("PROFILE_CLASSICAL_COMPAT"));
|
|
assertEq(PQProfileIDs.PROFILE_QUASAR_STRICT_PQ, keccak256("PROFILE_QUASAR_STRICT_PQ"));
|
|
assertEq(PQProfileIDs.PROFILE_HYBRID, keccak256("PROFILE_HYBRID"));
|
|
}
|
|
|
|
function testKAT_Policies() public pure {
|
|
assertEq(PQProfileIDs.POLICY_GROTH16_BN254, keccak256("ProofPolicyGroth16BN254"));
|
|
assertEq(PQProfileIDs.POLICY_PLONK_BN254, keccak256("ProofPolicyPLONKBN254"));
|
|
assertEq(PQProfileIDs.POLICY_KZG_BLS12381, keccak256("ProofPolicyKZGBLS12381"));
|
|
assertEq(PQProfileIDs.POLICY_STARK_FRI_SHA3_PQ, keccak256("ProofPolicySTARKFRISHA3PQ"));
|
|
assertEq(PQProfileIDs.POLICY_MLDSA65_FIPS204, keccak256("ProofPolicyMLDSA65FIPS204"));
|
|
}
|
|
|
|
function testClassicalCompat_AllowsEverything() public view {
|
|
bytes32 p = PQProfileIDs.PROFILE_CLASSICAL_COMPAT;
|
|
h.check(p, groth16);
|
|
h.check(p, plonk);
|
|
h.check(p, kzg);
|
|
h.check(p, stark);
|
|
h.check(p, mldsa);
|
|
}
|
|
|
|
function testStrictPQ_RefusesGroth16() public {
|
|
vm.expectRevert(
|
|
abi.encodeWithSelector(
|
|
OnlyStrictPQ.VerifierRefusedUnderStrictPQ.selector,
|
|
PQProfileIDs.PROFILE_QUASAR_STRICT_PQ,
|
|
PQProfileIDs.POLICY_GROTH16_BN254
|
|
)
|
|
);
|
|
h.check(PQProfileIDs.PROFILE_QUASAR_STRICT_PQ, groth16);
|
|
}
|
|
|
|
function testStrictPQ_RefusesPLONK() public {
|
|
vm.expectRevert(
|
|
abi.encodeWithSelector(
|
|
OnlyStrictPQ.VerifierRefusedUnderStrictPQ.selector,
|
|
PQProfileIDs.PROFILE_QUASAR_STRICT_PQ,
|
|
PQProfileIDs.POLICY_PLONK_BN254
|
|
)
|
|
);
|
|
h.check(PQProfileIDs.PROFILE_QUASAR_STRICT_PQ, plonk);
|
|
}
|
|
|
|
function testStrictPQ_RefusesKZG() public {
|
|
vm.expectRevert(
|
|
abi.encodeWithSelector(
|
|
OnlyStrictPQ.VerifierRefusedUnderStrictPQ.selector,
|
|
PQProfileIDs.PROFILE_QUASAR_STRICT_PQ,
|
|
PQProfileIDs.POLICY_KZG_BLS12381
|
|
)
|
|
);
|
|
h.check(PQProfileIDs.PROFILE_QUASAR_STRICT_PQ, kzg);
|
|
}
|
|
|
|
function testStrictPQ_AllowsSTARK() public view {
|
|
h.check(PQProfileIDs.PROFILE_QUASAR_STRICT_PQ, stark);
|
|
}
|
|
|
|
function testStrictPQ_AllowsMLDSA() public view {
|
|
h.check(PQProfileIDs.PROFILE_QUASAR_STRICT_PQ, mldsa);
|
|
}
|
|
|
|
function testUnknownProfile_Reverts() public {
|
|
bytes32 bogus = keccak256("PROFILE_BOGUS");
|
|
vm.expectRevert(abi.encodeWithSelector(OnlyStrictPQ.UnknownProfile.selector, bogus));
|
|
h.check(bogus, stark);
|
|
}
|
|
|
|
function testHybridProfile_IsRejectedToday() public {
|
|
vm.expectRevert(
|
|
abi.encodeWithSelector(OnlyStrictPQ.UnknownProfile.selector, PQProfileIDs.PROFILE_HYBRID)
|
|
);
|
|
h.check(PQProfileIDs.PROFILE_HYBRID, stark);
|
|
}
|
|
|
|
function testStrictPQ_RefusesZeroAddress() public {
|
|
// Red HIGH cluster #2/#5/#8: under strict-PQ a zero verifier slot
|
|
// is NOT a silent no-op. The old early-return-on-zero let admin
|
|
// (or compromised key) flip strict-PQ + verifier=0 to disable the
|
|
// gate while keeping the profile bytes32 "set". Now we refuse.
|
|
vm.expectRevert(
|
|
abi.encodeWithSelector(
|
|
OnlyStrictPQ.VerifierUnsetUnderStrictPQ.selector,
|
|
PQProfileIDs.PROFILE_QUASAR_STRICT_PQ
|
|
)
|
|
);
|
|
h.check(PQProfileIDs.PROFILE_QUASAR_STRICT_PQ, IPolicyId(address(0)));
|
|
}
|
|
|
|
function testClassicalCompat_AllowsZeroAddress() public view {
|
|
// Classical-compat: gate is a no-op, zero slot is admissible
|
|
// (the classical L1 EVM counterparty is itself classical).
|
|
h.check(PQProfileIDs.PROFILE_CLASSICAL_COMPAT, IPolicyId(address(0)));
|
|
}
|
|
|
|
function testStrictPQ_RefusesSpoofedPolicy() public {
|
|
// Allow-list (not deny-list): a verifier returning an unrecognised
|
|
// bytes32 — neither classical nor PQ — is refused. The old deny-
|
|
// list would let any unknown id pass; the new allow-list closes
|
|
// that gap (Red HIGH cluster #2 attack vector: spoofed policyId).
|
|
bytes32 bogus = keccak256("ProofPolicyBogusUnknown");
|
|
MockPolicy spoofed = new MockPolicy(bogus);
|
|
vm.expectRevert(
|
|
abi.encodeWithSelector(
|
|
OnlyStrictPQ.VerifierRefusedUnderStrictPQ.selector,
|
|
PQProfileIDs.PROFILE_QUASAR_STRICT_PQ,
|
|
bogus
|
|
)
|
|
);
|
|
h.check(PQProfileIDs.PROFILE_QUASAR_STRICT_PQ, spoofed);
|
|
}
|
|
|
|
function testModifierForm_AllowsAndRefuses() public {
|
|
// Allow under classical-compat.
|
|
assertEq(h.gated(PQProfileIDs.PROFILE_CLASSICAL_COMPAT, groth16), 42);
|
|
// Refuse under strict-PQ.
|
|
vm.expectRevert(
|
|
abi.encodeWithSelector(
|
|
OnlyStrictPQ.VerifierRefusedUnderStrictPQ.selector,
|
|
PQProfileIDs.PROFILE_QUASAR_STRICT_PQ,
|
|
PQProfileIDs.POLICY_GROTH16_BN254
|
|
)
|
|
);
|
|
h.gated(PQProfileIDs.PROFILE_QUASAR_STRICT_PQ, groth16);
|
|
}
|
|
}
|