Files
zeekay cf34e276b0 initial: PQAuth library + IPQVerify interface + Foundry tests
Canonical Solidity helpers for the Lux EVM's native post-quantum
precompiles. One source of truth for L1 contract authors who need to
authenticate post-quantum signers.

PQAuth wraps:
  0x012201  ML-KEM-768           (FIPS 203, key encapsulation)
  0x012202  ML-DSA verify        (FIPS 204, signatures: 44/65/87)
  0x012203  SLH-DSA verify       (FIPS 205, hash-based)
  0x012204  Corona threshold   (lattice threshold verify)
  0x002221  X-Wing hybrid KEM    (X25519 + ML-KEM-768)

API:
  verifyMLDSA44/65/87(pubkey, msgDigest, sig) -> bool
  verifySLHDSA       (pubkey, msgDigest, sig) -> bool
  verifyPermit       (pubkey, msgDigest, sig)  reverts on invalid

The msgDigest is 32 bytes, precomputed off-chain as
TupleHash256(parts, 32, "LUX_PQ_PERMIT_V1") per NIST SP 800-185 ยง5.
We refuse to implement TupleHash256/cSHAKE256 in Solidity: the EVM
lacks the primitive and emulation costs ~200k gas with high risk of
subtle field-framing bugs. Reference Go implementation:
luxfi/consensus/protocol/auth/hash.go.

Gas comparison vs ETHDILITHIUM (LGPL-3.0, vendored ML-DSA in YUL):
  ETHDILITHIUM verify:      ~8,100,000 gas
  PQAuth.verifyMLDSA65:        100,320 gas (100k base + 320 for 32-byte digest)
  => ~80x cheaper. Do not vendor or re-implement ML-DSA in user code.

IPQVerify.verifyZAuthProof reverts: reserved for the F102/F103 tx-type
0x05 path (separate multi-week project, Option B in the strict-PQ
rollout plan).

Tests (10 passing under forge):
  test_VerifyMLDSA65_ValidSignature_ReturnsTrue
  test_VerifyMLDSA65_InvalidSignature_ReturnsFalse
  test_VerifyMLDSA65_WrongPubkeyLength_Reverts
  test_VerifyMLDSA65_WrongSignatureLength_Reverts
  test_VerifyPermit_ValidSignature_NoRevert
  test_VerifyPermit_InvalidSignature_Reverts
  test_VerifySLHDSA_ValidSignature_ReturnsTrue
  test_VerifySLHDSA_InvalidSignature_ReturnsFalse
  test_VerifyZAuthProof_Reverts
  test_CanonicalAddresses

Stack: Foundry, solc 0.8.27, evm_version=cancun.
2026-05-10 21:39:29 -07:00

171 lines
7.0 KiB
Solidity

// SPDX-License-Identifier: BSD-3-Clause
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
pragma solidity ^0.8.27;
import {Test} from "forge-std/Test.sol";
import {PQAuth, PQVerify} from "../../src/pqauth/PQAuth.sol";
import {IPQVerify} from "../../src/pqauth/IPQVerify.sol";
/// @dev Mock precompile that mirrors the wire format the native ML-DSA
/// precompile expects, but returns a deterministic accept/reject
/// based on the first byte of the signature. Used to drive
/// PQAuth tests without needing an end-to-end signed vector.
contract MockMLDSAPrecompile {
fallback(bytes calldata input) external returns (bytes memory) {
// Minimum size: mode(1) + pubkey(1952) + msgLen(32) + sig(3309) + digest(32) = 5326.
// We accept iff signature[0] == 0xAA. The signature starts at offset 1 + 1952 + 32 = 1985.
require(input.length >= 1985 + 1, "MockMLDSAPrecompile: short calldata");
bool accept = input[1985] == 0xAA;
return abi.encode(accept ? uint256(1) : uint256(0));
}
}
contract MockSLHDSAPrecompile {
fallback(bytes calldata input) external returns (bytes memory) {
// Same convention: accept iff input[0] == 0xAA.
require(input.length > 0, "MockSLHDSAPrecompile: empty calldata");
bool accept = input[0] == 0xAA;
return abi.encode(accept ? uint256(1) : uint256(0));
}
}
contract PQAuthTest is Test {
PQVerify internal verifier;
bytes internal pubkey65;
bytes internal validSig65;
bytes internal invalidSig65;
bytes32 internal msgDigest;
function setUp() public {
// Etch our mock at the canonical ML-DSA precompile address so
// PQAuth.verifyMLDSA65's staticcall hits the mock.
MockMLDSAPrecompile mldsaMock = new MockMLDSAPrecompile();
vm.etch(PQAuth.MLDSA_PRECOMPILE, address(mldsaMock).code);
MockSLHDSAPrecompile slhdsaMock = new MockSLHDSAPrecompile();
vm.etch(PQAuth.SLHDSA_PRECOMPILE, address(slhdsaMock).code);
verifier = new PQVerify();
pubkey65 = new bytes(PQAuth.ML_DSA_65_PUBKEY_LEN);
validSig65 = new bytes(PQAuth.ML_DSA_65_SIG_LEN);
invalidSig65 = new bytes(PQAuth.ML_DSA_65_SIG_LEN);
// Mark valid signature with the mock's accept byte.
validSig65[0] = 0xAA;
// invalidSig65 stays all zero -> rejected by mock.
msgDigest = keccak256("LUX_PQ_PERMIT_V1_TEST_DIGEST");
}
// -----------------------------------------------------------------
// verifyMLDSA65
// -----------------------------------------------------------------
function test_VerifyMLDSA65_ValidSignature_ReturnsTrue() public view {
bool ok = verifier.verifyMLDSA65(pubkey65, msgDigest, validSig65);
assertTrue(ok, "valid signature must verify");
}
function test_VerifyMLDSA65_InvalidSignature_ReturnsFalse() public view {
bool ok = verifier.verifyMLDSA65(pubkey65, msgDigest, invalidSig65);
assertFalse(ok, "invalid signature must NOT verify");
}
function test_VerifyMLDSA65_WrongPubkeyLength_Reverts() public {
bytes memory badPubkey = new bytes(PQAuth.ML_DSA_65_PUBKEY_LEN - 1);
vm.expectRevert(
abi.encodeWithSelector(
PQAuth.InvalidPubkeyLength.selector,
PQAuth.ML_DSA_65,
PQAuth.ML_DSA_65_PUBKEY_LEN,
PQAuth.ML_DSA_65_PUBKEY_LEN - 1
)
);
verifier.verifyMLDSA65(badPubkey, msgDigest, validSig65);
}
function test_VerifyMLDSA65_WrongSignatureLength_Reverts() public {
bytes memory badSig = new bytes(PQAuth.ML_DSA_65_SIG_LEN + 7);
badSig[0] = 0xAA;
vm.expectRevert(
abi.encodeWithSelector(
PQAuth.InvalidSignatureLength.selector,
PQAuth.ML_DSA_65,
PQAuth.ML_DSA_65_SIG_LEN,
PQAuth.ML_DSA_65_SIG_LEN + 7
)
);
verifier.verifyMLDSA65(pubkey65, msgDigest, badSig);
}
// -----------------------------------------------------------------
// verifyPermit (revert on invalid)
// -----------------------------------------------------------------
function test_VerifyPermit_ValidSignature_NoRevert() public {
PermitHelper helper = new PermitHelper();
helper.runPermit(pubkey65, msgDigest, validSig65);
}
function test_VerifyPermit_InvalidSignature_Reverts() public {
PermitHelper helper = new PermitHelper();
vm.expectRevert(PQAuth.SignatureInvalid.selector);
helper.runPermit(pubkey65, msgDigest, invalidSig65);
}
// -----------------------------------------------------------------
// verifySLHDSA
// -----------------------------------------------------------------
function test_VerifySLHDSA_ValidSignature_ReturnsTrue() public view {
bytes memory slhPubkey = new bytes(64);
slhPubkey[0] = 0xAA; // mock looks at input[0] = pubkey[0]
bytes memory slhSig = new bytes(7856);
bool ok = verifier.verifySLHDSA(slhPubkey, msgDigest, slhSig);
assertTrue(ok, "valid SLH-DSA signature must verify");
}
function test_VerifySLHDSA_InvalidSignature_ReturnsFalse() public view {
bytes memory slhPubkey = new bytes(64);
// pubkey[0] = 0x00 -> mock rejects
bytes memory slhSig = new bytes(7856);
bool ok = verifier.verifySLHDSA(slhPubkey, msgDigest, slhSig);
assertFalse(ok, "invalid SLH-DSA signature must NOT verify");
}
// -----------------------------------------------------------------
// verifyZAuthProof โ€” deferred per F102/F103
// -----------------------------------------------------------------
function test_VerifyZAuthProof_Reverts() public {
vm.expectRevert(bytes("PQVerify: zAuth proof path reserved for F102/F103 (tx-type 0x05) - not yet wired"));
verifier.verifyZAuthProof(bytes32(0), bytes32(0), bytes32(0), hex"");
}
// -----------------------------------------------------------------
// Address constants โ€” sanity (canonical addresses)
// -----------------------------------------------------------------
function test_CanonicalAddresses() public pure {
assertEq(PQAuth.MLDSA_PRECOMPILE, address(uint160(0x012202)), "MLDSA addr");
assertEq(PQAuth.SLHDSA_PRECOMPILE, address(uint160(0x012203)), "SLHDSA addr");
assertEq(PQAuth.MLKEM_PRECOMPILE, address(uint160(0x012201)), "MLKEM addr");
assertEq(PQAuth.CORONA_PRECOMPILE, address(uint160(0x012204)), "Corona addr");
assertEq(PQAuth.XWING_PRECOMPILE, address(uint160(0x002221)), "XWing addr");
}
}
/// @dev Helper that exposes the PQAuth library's verifyPermit (internal
/// linkage) through an external entry point so tests can drive it
/// with calldata-typed arguments.
contract PermitHelper {
function runPermit(
bytes calldata pubkey,
bytes32 msgDigest,
bytes calldata signature
) external view {
PQAuth.verifyPermit(pubkey, msgDigest, signature);
}
}