aere-contracts/test/helpers/shutter-crypto.js
Aere Network acac2f00a6
Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The unpublished line of work joins the sanitized public line
The published line and the local line had no common ancestor: the public one
carried the redaction pass, the local one carried three weeks of corrections
that never shipped. This commit ports the local work onto the public line,
keeps every public redaction, and extends the same discretion to seven client
mentions that were still named in published comments.

Carried: LICENSE year and LICENSING.md; the measured burn figures replacing
the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is
a share of validator coinbase revenue, which is zero today); 'audited' removed
from next to Bouncy Castle; citation paths rewritten to published form with
CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and
ownership comments brought down to what the code does; the AerePyth repair;
the shutter test helper the tests cite; runnable package.json entries; the CI
file split into a GitHub/Gitea twin pair with a real measured test-run status;
and the .gitignore hardening written after a compiled artifact leaked a local
path in a sibling repository. A false '2-of-3 multisig' description of the
owner account is corrected to what the chain measures: an externally owned
account. The self-audit findings catalog stays unpublished pending an explicit
decision.
2026-08-15 13:59:30 +03:00

237 lines
9.6 KiB
JavaScript

// shutter-crypto.js
//
// REAL threshold-BLS (BLS12-381) helpers for AereShutterMempoolV2, the
// application-level Shutter-style anti-MEV encrypted mempool PoC on AERE
// chain 2800. Uses @noble/curves (already in the repo node_modules).
//
// Scheme (Boldyreva threshold BLS + hashed-ElGamal encryption):
// - Committee master secret s, Shamir-shared (t,N) over the scalar field Fr.
// - Group public key PK = s * g2 (G2, the "encryption pubkey").
// - Keyper pubkeys P_i = s_i * g2 (G2, published VSS commitments).
// - Epoch identity H1 = hashToCurve(epoch tag) in G1.
// - Epoch decrypt key DK = s * H1 (G1, threshold signature).
// - Keyper share sig_i = s_i * H1 (G1).
// - DK reconstruction DK = sum_{i in S} lambda_i * sig_i (Lagrange at 0).
//
// On-chain the pairing precompile (EIP-2537, addr 0x0f, VERIFIED live on 2800)
// checks:
// share valid : e(sig_i, g2) == e(H1, P_i) <=> e(sig_i,g2)*e(-H1,P_i)==1
// DK valid : e(DK, g2) == e(H1, PK) <=> e(DK, g2)*e(-H1,PK )==1
//
// Encryption (hashed-ElGamal to the epoch), decryptable only with DK:
// gt = e(H1, PK) in GT ; pick random r ; U = r*g2 ;
// mask = keccak(gt^r) ; C = plaintext XOR mask ; ciphertext = (U, C).
// Decrypt: gt^r = e(DK, U) ; mask = keccak(e(DK,U)) ; plaintext = C XOR mask.
// (The GT->bytes masking is off-chain; on-chain we bind the plaintext to a
// keccak commitment. DK correctness itself is pairing-verified on-chain.)
const { bls12_381 } = require("@noble/curves/bls12-381");
const { keccak_256 } = require("@noble/hashes/sha3");
const { randomBytes } = require("crypto");
const G1 = bls12_381.G1.ProjectivePoint;
const G2 = bls12_381.G2.ProjectivePoint;
const Fr = bls12_381.fields.Fr;
const Fp2 = bls12_381.fields.Fp2;
const Fp12 = bls12_381.fields.Fp12;
const R = Fr.ORDER;
const DST = "AERE-SHUTTER-V2-BLS12381G1-XMD:KECCAK-256_SSWU_RO_"; // documented epoch-identity DST
/* ----------------------------- serialization ----------------------------- */
function fpTo64Hex(x) {
// 48-byte big-endian field element, left-padded to 64 bytes (EIP-2537).
const h = x.toString(16).padStart(96, "0");
return "00".repeat(16) + h;
}
function g1Hex(P) {
const a = P.toAffine();
return "0x" + fpTo64Hex(a.x) + fpTo64Hex(a.y); // 128 bytes
}
function g2Hex(P) {
const a = P.toAffine();
// EIP-2537 Fp2 ordering: (c0, c1) for x then y.
return (
"0x" +
fpTo64Hex(a.x.c0) + fpTo64Hex(a.x.c1) +
fpTo64Hex(a.y.c0) + fpTo64Hex(a.y.c1)
); // 256 bytes
}
// EIP-2537 -> noble point parsers (64-byte fields, last 48 bytes are the value)
function chunkVal(chunk) { return BigInt("0x" + chunk.slice(32)); }
function eip2537ToG1(h) {
h = h.startsWith("0x") ? h.slice(2) : h;
return G1.fromAffine({ x: chunkVal(h.slice(0, 128)), y: chunkVal(h.slice(128, 256)) });
}
function eip2537ToG2(h) {
h = h.startsWith("0x") ? h.slice(2) : h;
const xc0 = chunkVal(h.slice(0, 128)), xc1 = chunkVal(h.slice(128, 256));
const yc0 = chunkVal(h.slice(256, 384)), yc1 = chunkVal(h.slice(384, 512));
return G2.fromAffine({ x: Fp2.fromBigTuple([xc0, xc1]), y: Fp2.fromBigTuple([yc0, yc1]) });
}
function randScalar() {
// uniform-ish nonzero scalar in [1, R-1]
let x = 0n;
while (x === 0n) x = BigInt("0x" + randomBytes(48).toString("hex")) % R;
return x;
}
/* --------------------------- polynomial / shamir -------------------------- */
function evalPoly(coeffs, x) {
// Horner in Fr
let acc = 0n;
for (let i = coeffs.length - 1; i >= 0; i--) acc = Fr.add(Fr.mul(acc, x), coeffs[i]);
return acc;
}
// Lagrange coefficient lambda_i for interpolation AT 0 over index set S (1-based x = index).
function lagrangeAtZero(indices, i) {
let num = 1n, den = 1n;
const xi = BigInt(i);
for (const j of indices) {
if (j === i) continue;
const xj = BigInt(j);
num = Fr.mul(num, Fr.sub(0n, xj)); // (0 - xj)
den = Fr.mul(den, Fr.sub(xi, xj)); // (xi - xj)
}
return Fr.mul(num, Fr.inv(den));
}
/* ------------------------------ committee setup --------------------------- */
// Trusted-dealer Shamir setup for the PoC. Returns committee material.
function setupCommittee(t, N) {
const coeffs = [randScalar()]; // a0 = master secret s
for (let k = 1; k < t; k++) coeffs.push(randScalar());
const s = coeffs[0];
const shares = []; // {index, secret}
const pubkeys = []; // P_i hex (G2), index-aligned to keypers[i] (i=0..N-1 -> x=i+1)
for (let i = 1; i <= N; i++) {
const si = evalPoly(coeffs, BigInt(i));
shares.push({ index: i, secret: si });
pubkeys.push(g2Hex(G2.BASE.multiply(si)));
}
const PKpoint = G2.BASE.multiply(s);
const PK = g2Hex(PKpoint); // group pubkey (encryption key)
return { t, N, s, shares, pubkeys, PK, PKpoint, g2Gen: g2Hex(G2.BASE) };
}
/* ------------------------------- epoch identity --------------------------- */
function epochIdentity(epochId) {
const tag = Buffer.from(`AERE-SHUTTER-EPOCH:${epochId}`, "utf8");
const H1 = bls12_381.G1.hashToCurve(tag, { DST }); // RFC9380 hash-to-curve, cofactor-cleared
const P = G1.fromHex(H1.toHex(true)); // normalize to ProjectivePoint
return { H1: g1Hex(P), H1neg: g1Hex(P.negate()), point: P };
}
/* ------------------------------- shares / DK ------------------------------ */
function makeShare(secret_si, epochPoint) {
return g1Hex(epochPoint.multiply(secret_si)); // sig_i = s_i * H1 (G1)
}
function reconstructDK(epochPoint, committee, subsetIndices) {
// subsetIndices are 1-based keyper x-coords (length >= t)
let acc = G1.ZERO;
for (const i of subsetIndices) {
const share = committee.shares.find((s) => s.index === i);
const lam = lagrangeAtZero(subsetIndices, i);
const sig_i = epochPoint.multiply(share.secret); // = s_i*H1
acc = acc.add(sig_i.multiply(lam));
}
return { DK: g1Hex(acc), point: acc }; // should equal s*H1
}
/* ------------------------------- encryption ------------------------------- */
function fp12Bytes(z) {
// deterministic flatten of an Fp12 element to bytes (12 * 48-byte coords).
const flat = [
z.c0.c0.c0, z.c0.c0.c1, z.c0.c1.c0, z.c0.c1.c1, z.c0.c2.c0, z.c0.c2.c1,
z.c1.c0.c0, z.c1.c0.c1, z.c1.c1.c0, z.c1.c1.c1, z.c1.c2.c0, z.c1.c2.c1,
];
return Buffer.concat(flat.map((x) => Buffer.from(x.toString(16).padStart(96, "0"), "hex")));
}
function maskFromGT(gt, len) {
// expand keccak(gt-bytes || counter) to len bytes
const base = fp12Bytes(gt);
const out = Buffer.alloc(len);
let off = 0, ctr = 0;
while (off < len) {
const blk = Buffer.from(keccak_256(Buffer.concat([base, Buffer.from([ctr & 0xff])])));
const n = Math.min(32, len - off);
blk.copy(out, off, 0, n);
off += n; ctr++;
}
return out;
}
// Encrypt plaintext (Buffer) to the epoch. Returns ciphertext bytes = U(256) || C(len).
function encrypt(committee, epochId, plaintext) {
const { point: H1 } = epochIdentity(epochId);
const PKpt = committee.PKpoint || eip2537ToG2(committee.PK);
const r = randScalar();
const U = G2.BASE.multiply(r); // ephemeral
const gt = bls12_381.pairing(H1, PKpt); // e(H1, PK)
const gtr = Fp12.pow(gt, r); // gt^r
const mask = maskFromGT(gtr, plaintext.length);
const C = Buffer.from(plaintext.map((b, i) => b ^ mask[i]));
const Ubuf = Buffer.from(g2Hex(U).slice(2), "hex");
return { ciphertext: "0x" + Buffer.concat([Ubuf, C]).toString("hex"), U: g2Hex(U) };
}
// Decrypt with reconstructed DK point. ciphertext hex -> plaintext Buffer.
function decrypt(dkPoint, ciphertextHex) {
const buf = Buffer.from(ciphertextHex.slice(2), "hex");
const Ubuf = buf.subarray(0, 256);
const C = buf.subarray(256);
const U = eip2537ToG2(Ubuf.toString("hex"));
const gtr = bls12_381.pairing(dkPoint, U); // e(DK, U) == gt^r
const mask = maskFromGT(gtr, C.length);
return Buffer.from(C.map((b, i) => b ^ mask[i]));
}
/* ----------------------------- commit / merkle ---------------------------- */
const { AbiCoder, keccak256, solidityPacked } = require("ethers");
const abi = AbiCoder.defaultAbiCoder();
function commitmentOf(plaintextHex, openingHex) {
return keccak256(abi.encode(["bytes", "bytes32"], [plaintextHex, openingHex]));
}
// Merkle over ordered positions. leaf = keccak(bytes.concat(keccak(abi.encode(epochId,pos,commitment)))).
function leafOf(epochId, pos, commitment) {
const inner = keccak256(abi.encode(["uint64", "uint256", "bytes32"], [epochId, pos, commitment]));
return keccak256(solidityPacked(["bytes32"], [inner]));
}
function hashPair(a, b) {
const [x, y] = a.toLowerCase() <= b.toLowerCase() ? [a, b] : [b, a]; // OZ sorted pairs
return keccak256(solidityPacked(["bytes32", "bytes32"], [x, y]));
}
// Build an OZ-compatible sorted-pair Merkle tree; returns {root, proofs[]}.
function buildMerkle(leaves) {
if (leaves.length === 1) return { root: leaves[0], proofs: [[]] };
let layer = leaves.slice();
const layers = [layer];
while (layer.length > 1) {
const next = [];
for (let i = 0; i < layer.length; i += 2) {
if (i + 1 === layer.length) next.push(layer[i]);
else next.push(hashPair(layer[i], layer[i + 1]));
}
layer = next;
layers.push(layer);
}
const root = layers[layers.length - 1][0];
const proofs = leaves.map((_, idx) => {
const proof = [];
let index = idx;
for (let l = 0; l < layers.length - 1; l++) {
const cur = layers[l];
const pair = index ^ 1;
if (pair < cur.length) proof.push(cur[pair]);
index = Math.floor(index / 2);
}
return proof;
});
return { root, proofs };
}
module.exports = {
G1, G2, Fr, DST,
fpTo64Hex, g1Hex, g2Hex, randScalar, eip2537ToG1, eip2537ToG2,
setupCommittee, epochIdentity, makeShare, reconstructDK,
encrypt, decrypt, commitmentOf, leafOf, buildMerkle,
};