aere-contracts/contracts/pqc/AereXmssVerifier.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

271 lines
12 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/**
* @title AereXmssVerifier - on-chain RFC 8391 XMSS many-time hash-based signature verification
* @notice Verifies an XMSS signature for parameter set XMSS-SHA2_10_256 (OID 0x00000001:
* n = 32, Winternitz w = 16, tree height h = 10, hash = SHA-256) exactly per
* RFC 8391 / the XMSS reference implementation (github.com/XMSS/xmss-reference).
*
* XMSS is the honest many-time extension of the one-time WOTS+ scheme already live
* on this chain (AerePQCVerifier). A signature proves a WOTS+ one-time leaf plus a
* Merkle authentication path from that leaf up to a long-term public root. Security
* rests only on the pre-image / collision resistance of SHA-256, which Shor's
* algorithm does not break, so it is post-quantum.
*
* Verification path (RFC 8391 sections 4.1.9 / 4.1.10, single tree d = 1):
* 1. M' = H_msg(R || root || toByte(idx, 32) || M)
* 2. pk_ots = WOTS_PKFromSig(sig_ots, M', SEED, OTS-address(idx)) [67 chains]
* 3. leaf = ltree(pk_ots, SEED, LTree-address(idx)) [L-tree compress]
* 4. node = compute_root(leaf, idx, auth, SEED, HashTree-address) [h=10 Merkle path]
* 5. accept iff node == root
*
* All hashing uses the RFC 8391 keyed, address-domain-separated toolbox with
* padding_len = n = 32:
* F(KEY, M) = SHA256( toByte(0,32) || KEY || M )
* H(KEY, M) = SHA256( toByte(1,32) || KEY || M )
* H_msg(KEY, M) = SHA256( toByte(2,32) || KEY || M )
* PRF(KEY, ADRS) = SHA256( toByte(3,32) || KEY || ADRS )
* with chain/tree bitmasks and keys derived by PRF(SEED, ADRS) over the 32-byte
* hash address (layer/tree/type + OTS/L-tree/hash-tree fields), key_and_mask in {0,1,2}.
*
* SHA-256 is the EVM precompile at 0x02, so this is real hash-based cryptography
* executed on chain, not a proof-of-a-proof and no trusted prover. Validated against
* the OFFICIAL deterministic reference known-answer vector (see deployments JSON).
*
* HONEST SCOPE: XMSS is a STATEFUL many-time scheme. This contract verifies that a
* given signature is valid for a given (root, SEED); it does NOT and cannot enforce
* the signer's one-time-per-leaf state. Signing SECURITY requires the off-chain
* signer to never reuse a leaf index (idx). Reusing an index breaks WOTS+, not this
* verifier. This is a verifier, not a state manager.
*/
contract AereXmssVerifier {
// ----- XMSS-SHA2_10_256 parameters (RFC 8391 OID 0x00000001) -----
uint256 public constant N = 32; // hash output bytes
uint256 public constant W = 16; // Winternitz parameter
uint256 public constant LOG_W = 4; // log2(w)
uint256 public constant LEN1 = 64; // message base-w digits (8*n / log_w)
uint256 public constant LEN2 = 3; // checksum base-w digits
uint256 public constant LEN = 67; // total WOTS+ chains
uint256 public constant H = 10; // Merkle tree height
uint32 public constant XMSS_OID = 0x00000001; // XMSS-SHA2_10_256
// hash address types (RFC 8391 section 2.5)
uint256 internal constant ADDR_OTS = 0;
uint256 internal constant ADDR_LTREE = 1;
uint256 internal constant ADDR_HASHTREE = 2;
// ----- recorded on-chain verification results (for state-changing calls) -----
uint256 public verifyCount;
bool public lastResult;
event Verified(address indexed caller, bytes32 indexed pubRoot, bool result, uint256 index);
// ==========================================================================
// RFC 8391 keyed hash toolbox (SHA-256)
// ==========================================================================
/// @dev Pack an XMSS 32-byte hash address. layer (word0) and tree (words 1-2) are
/// always zero for the single-tree XMSS-SHA2_10_256 set; type is word3, then
/// the type-specific words 4/5/6 and key_and_mask in word7.
function _addr(uint256 typ, uint256 a4, uint256 a5, uint256 a6, uint256 km)
internal
pure
returns (bytes32)
{
return bytes32((typ << 128) | (a4 << 96) | (a5 << 64) | (a6 << 32) | km);
}
/// @dev PRF(SEED, ADRS) = SHA256( toByte(3,32) || SEED || ADRS ).
function _prf(bytes32 seed, bytes32 addr) internal pure returns (bytes32) {
return sha256(abi.encodePacked(bytes32(uint256(3)), seed, addr));
}
/// @dev RFC 8391 F: one hash-chain step. Derives a per-address key and bitmask by PRF,
/// then F(KEY, in ^ mask) = SHA256( toByte(0,32) || KEY || (in ^ mask) ).
function _f(bytes32 seed, uint256 typ, uint256 a4, uint256 a5, uint256 a6, bytes32 x)
internal
pure
returns (bytes32)
{
bytes32 key = _prf(seed, _addr(typ, a4, a5, a6, 0));
bytes32 mask = _prf(seed, _addr(typ, a4, a5, a6, 1));
return sha256(abi.encodePacked(bytes32(0), key, x ^ mask));
}
/// @dev RFC 8391 RAND_HASH / H over two n-byte children. Key + two n-byte masks by PRF,
/// then H(KEY, (L ^ m0) || (R ^ m1)) = SHA256( toByte(1,32) || KEY || (L^m0) || (R^m1) ).
function _h(
bytes32 seed,
uint256 typ,
uint256 a4,
uint256 a5,
uint256 a6,
bytes32 left,
bytes32 right
) internal pure returns (bytes32) {
bytes32 key = _prf(seed, _addr(typ, a4, a5, a6, 0));
bytes32 m0 = _prf(seed, _addr(typ, a4, a5, a6, 1));
bytes32 m1 = _prf(seed, _addr(typ, a4, a5, a6, 2));
return sha256(abi.encodePacked(bytes32(uint256(1)), key, left ^ m0, right ^ m1));
}
// ==========================================================================
// WOTS+ (leaf)
// ==========================================================================
/// @notice Derive the 67 base-16 chain lengths (64 message digits + 3 checksum digits)
/// from a 32-byte message hash, exactly per RFC 8391 chain_lengths.
function chainLengths(bytes32 m) public pure returns (uint256[67] memory d) {
uint256 csum = 0;
for (uint256 i = 0; i < 32; i++) {
uint8 b = uint8(m[i]);
uint256 hi = b >> 4;
uint256 lo = b & 0x0f;
d[2 * i] = hi;
d[2 * i + 1] = lo;
csum += (15 - hi) + (15 - lo);
}
// csum << (8 - (len2*log_w % 8)) == csum << 4, then base_w over 2 big-endian bytes.
uint256 c = csum << 4;
d[64] = (c >> 12) & 0x0f;
d[65] = (c >> 8) & 0x0f;
d[66] = (c >> 4) & 0x0f;
}
/// @dev WOTS_PKFromSig: complete each of the 67 chains from the signature value to the
/// chain end (position w-1), returning the 67 WOTS+ public-key chain values.
function _wotsPkFromSig(bytes32 seed, uint256 idxLeaf, bytes32 mhash, bytes32[67] calldata sig)
internal
pure
returns (bytes32[67] memory pk)
{
uint256[67] memory lens = chainLengths(mhash);
for (uint256 i = 0; i < LEN; i++) {
bytes32 x = sig[i];
// gen_chain: for s in [lens[i], w-1): F with hash-address s, chain-address i.
for (uint256 s = lens[i]; s < W - 1; s++) {
x = _f(seed, ADDR_OTS, idxLeaf, i, s, x);
}
pk[i] = x;
}
}
/// @dev L-tree: compress the 67 WOTS+ public-key values into a single n-byte leaf.
function _lTree(bytes32 seed, uint256 idxLeaf, bytes32[67] memory pk)
internal
pure
returns (bytes32)
{
// Work on a mutable copy sized to LEN; unused tail is ignored via the shrinking `l`.
bytes32[67] memory nodes = pk;
uint256 l = LEN;
uint256 height = 0;
while (l > 1) {
uint256 parent = l >> 1;
for (uint256 i = 0; i < parent; i++) {
nodes[i] = _h(seed, ADDR_LTREE, idxLeaf, height, i, nodes[2 * i], nodes[2 * i + 1]);
}
if (l & 1 == 1) {
nodes[parent] = nodes[l - 1];
l = parent + 1;
} else {
l = parent;
}
height++;
}
return nodes[0];
}
// ==========================================================================
// Merkle authentication path
// ==========================================================================
/// @dev compute_root: fold the leaf with the h=10 authentication path up to the root,
/// using the hash-tree address (type 2) with the correct per-level height/index.
function _computeRoot(bytes32 seed, bytes32 leaf, uint256 leafIdx, bytes32[10] calldata auth)
internal
pure
returns (bytes32)
{
bytes32 left;
bytes32 right;
if (leafIdx & 1 == 1) {
left = auth[0];
right = leaf;
} else {
left = leaf;
right = auth[0];
}
uint256 li = leafIdx;
for (uint256 i = 0; i < H - 1; i++) {
li >>= 1;
bytes32 node = _h(seed, ADDR_HASHTREE, 0, i, li, left, right);
if (li & 1 == 1) {
left = auth[i + 1];
right = node;
} else {
left = node;
right = auth[i + 1];
}
}
li >>= 1;
return _h(seed, ADDR_HASHTREE, 0, H - 1, li, left, right);
}
// ==========================================================================
// Verify
// ==========================================================================
/**
* @notice Verify an XMSS-SHA2_10_256 signature (RFC 8391) as a pure/free view call.
* @param pubRoot the long-term XMSS public root (first 32 bytes of the XMSS public key)
* @param pubSeed the XMSS public SEED (last 32 bytes of the XMSS public key)
* @param idx the leaf index used by the signer (0 .. 2^h - 1)
* @param R the per-signature randomizer (from the signature)
* @param message the signed message bytes
* @param wotsSig the 67 WOTS+ one-time-signature chain values
* @param auth the h = 10 Merkle authentication-path nodes
* @return ok true iff the recomputed root equals pubRoot
*/
function verify(
bytes32 pubRoot,
bytes32 pubSeed,
uint32 idx,
bytes32 R,
bytes calldata message,
bytes32[67] calldata wotsSig,
bytes32[10] calldata auth
) public pure returns (bool ok) {
// M' = H_msg( toByte(2,32) || R || root || toByte(idx, 32) || M )
bytes32 mhash = sha256(
abi.encodePacked(bytes32(uint256(2)), R, pubRoot, bytes32(uint256(idx)), message)
);
uint256 idxLeaf = uint256(idx) & ((1 << H) - 1);
bytes32[67] memory wpk = _wotsPkFromSig(pubSeed, idxLeaf, mhash, wotsSig);
bytes32 leaf = _lTree(pubSeed, idxLeaf, wpk);
bytes32 root = _computeRoot(pubSeed, leaf, idxLeaf, auth);
return root == pubRoot;
}
/**
* @notice State-changing wrapper: verifies and records the result on-chain (event + counter).
* XMSS verification is SHA-256-heavy but far cheaper than a lattice verify, so this
* lands under the Fusaka EIP-7825 2^24 (16,777,216) per-transaction gas cap and runs
* as an ordinary transaction. `verify()` remains the free view for pre-flight.
*/
function verifyAndRecord(
bytes32 pubRoot,
bytes32 pubSeed,
uint32 idx,
bytes32 R,
bytes calldata message,
bytes32[67] calldata wotsSig,
bytes32[10] calldata auth
) external returns (bool ok) {
ok = verify(pubRoot, pubSeed, idx, R, message, wotsSig, auth);
lastResult = ok;
verifyCount += 1;
emit Verified(msg.sender, pubRoot, ok, verifyCount);
}
}