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 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.
237 lines
9.6 KiB
JavaScript
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,
|
|
};
|