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.
419 lines
20 KiB
JavaScript
419 lines
20 KiB
JavaScript
// AerePQ-Screen: institution-grade post-quantum compliance passport.
|
|
//
|
|
// Exercises the three new compliance contracts end to end over Aere's LIVE PQC primitives:
|
|
// - AereTrustRegistry federated accredited-issuer trust list (eIDAS-2.0-in-spirit)
|
|
// - AereVerifiableCredential W3C VC 2.0 anchor + verify, POST-QUANTUM (Falcon-512) issuer signature
|
|
// - AereBitstringStatusList W3C Bitstring Status List revocation + zk non-revocation stub
|
|
//
|
|
// Aere has no PQC precompile in the local Hardhat EVM, so we install the repo's MockPQCPrecompile at
|
|
// 0x0AE1 (Falcon-512) via hardhat_setCode, exactly as the erc8004-adapters / AereRecoveryRegistry
|
|
// tests do. The mock parses the input at the LIVE precompile's spec offsets and accepts iff
|
|
// commitment == keccak256(pk || message), so a positive result proves the Trust Registry routed the
|
|
// correct input to the correct precompile and enforces fail-closed semantics. The REAL precompile
|
|
// path (0x0AE1 accepting a genuine Falcon-512 signature) is proven separately against live mainnet
|
|
// 2800 via eth_call, as AerePQCKeyRegistry / AerePQCAttestation already are.
|
|
//
|
|
// Run: npx hardhat test test/aere-pq-screen.test.js
|
|
|
|
const { expect } = require("chai");
|
|
const { ethers, network } = require("hardhat");
|
|
|
|
const FALCON = ethers.getAddress("0x0000000000000000000000000000000000000ae1"); // Falcon-512 precompile
|
|
const SCHEME_FALCON512 = 1;
|
|
const REG_STATUS_ACTIVE = 1;
|
|
const NO_KEY = (1n << 256n) - 1n; // type(uint256).max sentinel
|
|
|
|
const TYPE_KYC = ethers.id("KYCCredential");
|
|
const TYPE_ADDRESS = ethers.id("ProofOfAddressCredential");
|
|
|
|
// ---- deterministic seeded RNG (keccak hash-chain) so the test is fully reproducible ----
|
|
function makeRng(seedText) {
|
|
let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText));
|
|
return {
|
|
bytes(n) {
|
|
const out = new Uint8Array(n);
|
|
let i = 0;
|
|
while (i < n) {
|
|
seed = ethers.keccak256(seed);
|
|
const chunk = ethers.getBytes(seed);
|
|
for (let j = 0; j < chunk.length && i < n; j++, i++) out[i] = chunk[j];
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeFalconPubKey(rng) {
|
|
const raw = rng.bytes(897);
|
|
raw[0] = 0x09; // Falcon-512 public-key header byte
|
|
return ethers.hexlify(raw);
|
|
}
|
|
|
|
function commitmentFor(pubKey, message) {
|
|
return ethers.keccak256(ethers.concat([pubKey, message]));
|
|
}
|
|
|
|
// A genuine Falcon-512 signature envelope under the mock's semantics: nonce(40) || esig(0x29 || commit).
|
|
function genuineFalcon(pubKey, message, rng) {
|
|
const nonce = rng.bytes(40);
|
|
const esig = ethers.concat([new Uint8Array([0x29]), commitmentFor(pubKey, message)]);
|
|
return ethers.hexlify(ethers.concat([nonce, esig]));
|
|
}
|
|
|
|
// Flip one byte inside the commitment region (a genuine forgery the precompile must reject).
|
|
function tamperFalcon(sigHex) {
|
|
const b = ethers.getBytes(sigHex);
|
|
b[41] ^= 0x01; // Falcon commitment starts at offset 41 (40 nonce + 1 header)
|
|
return ethers.hexlify(b);
|
|
}
|
|
|
|
async function futureTs(secs) {
|
|
const b = await ethers.provider.getBlock("latest");
|
|
return b.timestamp + secs;
|
|
}
|
|
|
|
describe("AerePQ-Screen (post-quantum compliance passport)", function () {
|
|
let owner, issuerA, issuerB, holder, relayer;
|
|
let mockCode;
|
|
let reg, trust, statusList, vc, sp1;
|
|
|
|
before(async function () {
|
|
[owner, issuerA, issuerB, holder, relayer] = await ethers.getSigners();
|
|
const F = await ethers.getContractFactory("MockPQCPrecompile");
|
|
const mock = await F.deploy(SCHEME_FALCON512);
|
|
await mock.waitForDeployment();
|
|
mockCode = await ethers.provider.getCode(await mock.getAddress());
|
|
});
|
|
|
|
async function installFalconMock() {
|
|
await network.provider.send("hardhat_setCode", [FALCON, mockCode]);
|
|
}
|
|
|
|
// Register a Falcon-512 key under `signer` in the live AerePQCKeyRegistry (proof-of-possession).
|
|
async function registerFalconKey(signer, rng) {
|
|
const pk = makeFalconPubKey(rng);
|
|
const nonce = await reg.identityNonce(signer.address);
|
|
const ch = await reg.popChallenge(signer.address, SCHEME_FALCON512, pk, nonce);
|
|
const tx = await reg.connect(signer).registerKey(SCHEME_FALCON512, pk, genuineFalcon(pk, ch, rng));
|
|
const rc = await tx.wait();
|
|
const log = rc.logs.map((l) => reg.interface.parseLog(l)).find((p) => p && p.name === "KeyRegistered");
|
|
return { keyId: Number(log.args.keyId), pk };
|
|
}
|
|
|
|
beforeEach(async function () {
|
|
await installFalconMock();
|
|
|
|
reg = await (await ethers.getContractFactory("AerePQCKeyRegistry")).deploy();
|
|
await reg.waitForDeployment();
|
|
|
|
sp1 = await (await ethers.getContractFactory("MockSp1Verifier")).deploy();
|
|
await sp1.waitForDeployment();
|
|
|
|
trust = await (await ethers.getContractFactory("AereTrustRegistry")).deploy(await reg.getAddress());
|
|
await trust.waitForDeployment();
|
|
|
|
statusList = await (await ethers.getContractFactory("AereBitstringStatusList")).deploy(await sp1.getAddress());
|
|
await statusList.waitForDeployment();
|
|
|
|
vc = await (await ethers.getContractFactory("AereVerifiableCredential")).deploy(
|
|
await trust.getAddress(),
|
|
await statusList.getAddress()
|
|
);
|
|
await vc.waitForDeployment();
|
|
});
|
|
|
|
// Accredit issuerA for KYC with a Falcon key; create a Revocation list it controls.
|
|
async function accreditAndList(rngSeed = "issuerA") {
|
|
const rng = makeRng(rngSeed);
|
|
const key = await registerFalconKey(issuerA, rng);
|
|
const tx = await trust.accreditIssuer(issuerA.address, "did:aere:2800:issuerA", [TYPE_KYC], key.keyId);
|
|
const rc = await tx.wait();
|
|
const issuerId = Number(
|
|
rc.logs.map((l) => trust.interface.parseLog(l)).find((p) => p && p.name === "IssuerAccredited").args.issuerId
|
|
);
|
|
// issuerA creates its revocation status list (capacity 1024).
|
|
const lt = await statusList.connect(issuerA).createList(0 /* Revocation */, 1024);
|
|
const lrc = await lt.wait();
|
|
const listId = Number(
|
|
lrc.logs.map((l) => statusList.interface.parseLog(l)).find((p) => p && p.name === "ListCreated").args.listId
|
|
);
|
|
return { rng, issuerId, listId, pk: key.pk, keyId: key.keyId };
|
|
}
|
|
|
|
async function makeCredential(ctx, overrides = {}) {
|
|
const c = {
|
|
issuerId: overrides.issuerId ?? ctx.issuerId,
|
|
subjectDidHash: overrides.subjectDidHash ?? ethers.keccak256(ethers.toUtf8Bytes("did:aere:2800:holder-1")),
|
|
credentialType: overrides.credentialType ?? TYPE_KYC,
|
|
validFrom: overrides.validFrom ?? (await futureTs(-60)),
|
|
validUntil: overrides.validUntil ?? (await futureTs(365 * 24 * 3600)),
|
|
statusListId: overrides.statusListId ?? ctx.listId,
|
|
statusIndex: overrides.statusIndex ?? 42,
|
|
claimsHash: overrides.claimsHash ?? ethers.id("kyc-claims-blob"),
|
|
};
|
|
const digest = await vc.credentialDigest(c);
|
|
const sig = genuineFalcon(ctx.pk, digest, ctx.rng);
|
|
return { c, digest, sig };
|
|
}
|
|
|
|
// =========================================================================
|
|
// 1. Wiring + accreditation lifecycle (append-only, fail-closed)
|
|
// =========================================================================
|
|
describe("AereTrustRegistry (federated accredited issuers)", function () {
|
|
it("accredits an issuer with a scope + Falcon key, and reports accreditation fail-closed", async function () {
|
|
const ctx = await accreditAndList();
|
|
expect(await trust.issuerCount()).to.equal(1n);
|
|
expect(await trust.issuerStatus(ctx.issuerId)).to.equal(1); // Active
|
|
expect(await trust.isAccredited(ctx.issuerId, TYPE_KYC)).to.equal(true);
|
|
// Fail-closed: out-of-scope type, and unknown issuer id.
|
|
expect(await trust.isAccredited(ctx.issuerId, TYPE_ADDRESS)).to.equal(false);
|
|
expect(await trust.isAccredited(9999, TYPE_KYC)).to.equal(false);
|
|
expect(await trust.pqcKeyOf(ctx.issuerId)).to.equal(BigInt(ctx.keyId));
|
|
});
|
|
|
|
it("only the owner may accredit / suspend / revoke", async function () {
|
|
const rng = makeRng("noauth");
|
|
const key = await registerFalconKey(issuerA, rng);
|
|
// OZ v4 Ownable reverts with the string "Ownable: caller is not the owner".
|
|
await expect(
|
|
trust.connect(issuerB).accreditIssuer(issuerA.address, "did:aere:2800:x", [TYPE_KYC], key.keyId)
|
|
).to.be.reverted;
|
|
});
|
|
|
|
it("keeps an append-only status history across suspend / reinstate / revoke", async function () {
|
|
const ctx = await accreditAndList();
|
|
expect(await trust.statusHistoryLength(ctx.issuerId)).to.equal(1n); // Active at accreditation
|
|
await trust.suspendIssuer(ctx.issuerId, ethers.id("audit-lapsed"));
|
|
await trust.reinstateIssuer(ctx.issuerId, ethers.id("audit-restored"));
|
|
await trust.revokeIssuer(ctx.issuerId, ethers.id("accreditation-withdrawn"));
|
|
expect(await trust.statusHistoryLength(ctx.issuerId)).to.equal(4n);
|
|
expect((await trust.statusHistoryAt(ctx.issuerId, 3)).status).to.equal(3); // Revoked
|
|
// Terminal: revoked cannot be reinstated or re-revoked.
|
|
await expect(trust.reinstateIssuer(ctx.issuerId, ethers.ZeroHash)).to.be.revertedWithCustomError(
|
|
trust,
|
|
"NotSuspended"
|
|
);
|
|
await expect(trust.revokeIssuer(ctx.issuerId, ethers.ZeroHash)).to.be.revertedWithCustomError(
|
|
trust,
|
|
"AlreadyRevoked"
|
|
);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// 2. Issue + verify a credential with a POST-QUANTUM issuer signature
|
|
// =========================================================================
|
|
describe("AereVerifiableCredential (issue / verify / revoke)", function () {
|
|
it("verifies and anchors a valid PQC-signed credential (0x0AE1), relayable by anyone", async function () {
|
|
const ctx = await accreditAndList();
|
|
const { c, digest, sig } = await makeCredential(ctx);
|
|
|
|
// Full W3C verification of the presented credential passes.
|
|
expect(await vc.verifyPresented(c, sig)).to.equal(true);
|
|
expect(await vc.verifyCredentialSignature(c, sig)).to.equal(true);
|
|
|
|
// Anchor it (relayed by a third party; authorship is the Falcon signature).
|
|
await expect(vc.connect(relayer).anchorCredential(c, sig))
|
|
.to.emit(vc, "CredentialAnchored")
|
|
.withArgs(digest, ctx.issuerId, TYPE_KYC, c.subjectDidHash, ctx.listId, 42);
|
|
|
|
expect(await vc.isAnchored(digest)).to.equal(true);
|
|
expect(await vc.isValid(digest)).to.equal(true);
|
|
// Cannot double-anchor.
|
|
await expect(vc.anchorCredential(c, sig)).to.be.revertedWithCustomError(vc, "AlreadyAnchored");
|
|
});
|
|
|
|
it("REVOKES via the Bitstring Status List: verification flips to false", async function () {
|
|
const ctx = await accreditAndList();
|
|
const { c, digest, sig } = await makeCredential(ctx);
|
|
await vc.anchorCredential(c, sig);
|
|
expect(await vc.isValid(digest)).to.equal(true);
|
|
expect(await vc.isRevoked(c)).to.equal(false);
|
|
|
|
// The issuer flips the credential's status bit (revocation is terminal).
|
|
await statusList.connect(issuerA).revoke(ctx.listId, 42);
|
|
|
|
expect(await statusList.statusOf(ctx.listId, 42)).to.equal(true);
|
|
expect(await vc.isRevoked(c)).to.equal(true);
|
|
expect(await vc.verifyPresented(c, sig)).to.equal(false);
|
|
expect(await vc.isValid(digest)).to.equal(false);
|
|
|
|
// Revocation cannot be undone on a Revocation-purpose list.
|
|
await expect(statusList.connect(issuerA).setStatus(ctx.listId, 42, false)).to.be.revertedWithCustomError(
|
|
statusList,
|
|
"RevocationIsTerminal"
|
|
);
|
|
});
|
|
|
|
it("a non-accredited issuer (wrong scope) cannot issue that credential type", async function () {
|
|
const ctx = await accreditAndList();
|
|
// issuerB is accredited only for ProofOfAddress, not KYC.
|
|
const rngB = makeRng("issuerB");
|
|
const keyB = await registerFalconKey(issuerB, rngB);
|
|
const txB = await trust.accreditIssuer(issuerB.address, "did:aere:2800:issuerB", [TYPE_ADDRESS], keyB.keyId);
|
|
const rcB = await txB.wait();
|
|
const issuerBId = Number(
|
|
rcB.logs.map((l) => trust.interface.parseLog(l)).find((p) => p && p.name === "IssuerAccredited").args.issuerId
|
|
);
|
|
|
|
// A KYC credential from issuerB fails accreditation, even with a genuine signature.
|
|
const ctxB = { rng: rngB, issuerId: issuerBId, listId: ctx.listId, pk: keyB.pk, keyId: keyB.keyId };
|
|
const { c, sig } = await makeCredential(ctxB, { statusIndex: 7 });
|
|
expect(await vc.verifyPresented(c, sig)).to.equal(false);
|
|
await expect(vc.anchorCredential(c, sig)).to.be.revertedWithCustomError(vc, "NotAccredited");
|
|
});
|
|
|
|
it("REJECTS a tampered PQC signature fail-closed (nothing anchored)", async function () {
|
|
const ctx = await accreditAndList();
|
|
const { c, sig } = await makeCredential(ctx, { statusIndex: 11 });
|
|
const bad = tamperFalcon(sig);
|
|
|
|
expect(await vc.verifyCredentialSignature(c, bad)).to.equal(false);
|
|
expect(await vc.verifyPresented(c, bad)).to.equal(false);
|
|
await expect(vc.anchorCredential(c, bad)).to.be.revertedWithCustomError(vc, "SignatureInvalid");
|
|
expect(await vc.isAnchored(await vc.credentialDigest(c))).to.equal(false);
|
|
});
|
|
|
|
it("fails closed when the issuer is SUSPENDED after anchoring, and recovers on reinstatement", async function () {
|
|
const ctx = await accreditAndList();
|
|
const { c, digest, sig } = await makeCredential(ctx, { statusIndex: 21 });
|
|
await vc.anchorCredential(c, sig);
|
|
expect(await vc.isValid(digest)).to.equal(true);
|
|
|
|
await trust.suspendIssuer(ctx.issuerId, ethers.id("under-review"));
|
|
expect(await vc.isValid(digest)).to.equal(false);
|
|
expect(await vc.verifyPresented(c, sig)).to.equal(false);
|
|
|
|
await trust.reinstateIssuer(ctx.issuerId, ethers.id("cleared"));
|
|
expect(await vc.isValid(digest)).to.equal(true);
|
|
});
|
|
|
|
it("rejects an expired credential and binds the did:aere subject DID on anchor", async function () {
|
|
const ctx = await accreditAndList();
|
|
// Expired window.
|
|
const { c: expiredC, sig: expiredSig } = await makeCredential(ctx, {
|
|
validFrom: await futureTs(-100),
|
|
validUntil: await futureTs(-10),
|
|
statusIndex: 31,
|
|
});
|
|
expect(await vc.verifyPresented(expiredC, expiredSig)).to.equal(false);
|
|
await expect(vc.anchorCredential(expiredC, expiredSig)).to.be.revertedWithCustomError(vc, "Expired");
|
|
|
|
// did:aere binding on anchor.
|
|
const subjectDid = "did:aere:2800:holder-42";
|
|
const { c, digest, sig } = await makeCredential(ctx, {
|
|
subjectDidHash: ethers.keccak256(ethers.toUtf8Bytes(subjectDid)),
|
|
statusIndex: 33,
|
|
});
|
|
await expect(vc.anchorCredentialWithDid(c, subjectDid, sig))
|
|
.to.emit(vc, "CredentialAnchoredWithDid")
|
|
.withArgs(digest, subjectDid);
|
|
// A DID that does not hash to the bound subject is rejected.
|
|
const { c: c2, sig: sig2 } = await makeCredential(ctx, {
|
|
subjectDidHash: ethers.keccak256(ethers.toUtf8Bytes("did:aere:2800:holder-99")),
|
|
statusIndex: 34,
|
|
});
|
|
await expect(vc.anchorCredentialWithDid(c2, "did:aere:2800:someone-else", sig2)).to.be.revertedWithCustomError(
|
|
vc,
|
|
"DidMismatch"
|
|
);
|
|
// A non-did:aere subject is rejected even when the hash matches.
|
|
const nonAere = "did:web:example.com";
|
|
const { c: c3, sig: sig3 } = await makeCredential(ctx, {
|
|
subjectDidHash: ethers.keccak256(ethers.toUtf8Bytes(nonAere)),
|
|
statusIndex: 35,
|
|
});
|
|
await expect(vc.anchorCredentialWithDid(c3, nonAere, sig3)).to.be.revertedWithCustomError(vc, "NotAereDid");
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// 3. Bitstring Status List + zk non-revocation stub (shape + freshness binding)
|
|
// =========================================================================
|
|
describe("AereBitstringStatusList (bitset + zk non-revocation stub)", function () {
|
|
// L1 (LOW) fix: verifyProof returns void, so a NON-ZERO but code-less verifier makes the
|
|
// try/verifyProof in verifyNonRevocation succeed silently (a revoked credential proves "not
|
|
// revoked" = fail-OPEN). The constructor must reject a code-less verifier, while still allowing
|
|
// the zero address (which keeps the zk path fail-closed OFF until configured).
|
|
it("rejects a non-zero code-less non-revocation verifier at construction, but allows zero (L1 guard)", async function () {
|
|
const F = await ethers.getContractFactory("AereBitstringStatusList");
|
|
// A non-zero EOA verifier has no code.
|
|
await expect(F.deploy(holder.address)).to.be.revertedWithCustomError(statusList, "VerifierHasNoCode");
|
|
// The zero address is allowed: the zk non-revocation path stays fail-closed OFF.
|
|
const off = await F.deploy(ethers.ZeroAddress);
|
|
await off.waitForDeployment();
|
|
expect(await off.nonRevocationConfigured()).to.equal(false);
|
|
// Control: the real (code-bearing) mock SP1 gateway deploys fine.
|
|
const ok = await F.deploy(await sp1.getAddress());
|
|
await ok.waitForDeployment();
|
|
expect(await ok.NON_REVOCATION_VERIFIER()).to.equal(await sp1.getAddress());
|
|
});
|
|
|
|
it("packs status bits and enforces controller-only mutation", async function () {
|
|
await statusList.connect(issuerA).createList(0, 1024); // listId 0
|
|
expect(await statusList.statusOf(0, 5)).to.equal(false);
|
|
await statusList.connect(issuerA).setStatus(0, 5, true);
|
|
expect(await statusList.statusOf(0, 5)).to.equal(true);
|
|
// A non-controller cannot mutate.
|
|
await expect(statusList.connect(issuerB).setStatus(0, 6, true)).to.be.revertedWithCustomError(
|
|
statusList,
|
|
"NotController"
|
|
);
|
|
// No silent no-op: re-setting the same value reverts.
|
|
await expect(statusList.connect(issuerA).setStatus(0, 5, true)).to.be.revertedWithCustomError(
|
|
statusList,
|
|
"NoStatusChange"
|
|
);
|
|
});
|
|
|
|
it("verifies a zk non-revocation proof against a FRESH committed root (mock SP1), fail-closed on staleness", async function () {
|
|
// Configure the network non-revocation program (vkey). Verifier is the MockSp1Verifier.
|
|
const vkey = ethers.id("aere-nonrevocation-v1-PLACEHOLDER"); // [MEASURE] real vkey once circuit is built
|
|
await statusList.setNonRevocationVKey(vkey);
|
|
expect(await statusList.nonRevocationConfigured()).to.equal(true);
|
|
|
|
await statusList.connect(issuerA).createList(0, 1024); // listId 0
|
|
// Controller publishes a commitment to the current bitstring at the current epoch.
|
|
const root = ethers.id("bitstring-root-epoch-0");
|
|
await statusList.connect(issuerA).publishStatusRoot(0, root);
|
|
|
|
// Build public values in the exact ABI shape the contract decodes.
|
|
const publicValues = ethers.AbiCoder.defaultAbiCoder().encode(
|
|
["uint256", "address", "uint256", "bytes32", "bytes32", "uint8"],
|
|
[
|
|
(await ethers.provider.getNetwork()).chainId,
|
|
await statusList.getAddress(),
|
|
0, // listId
|
|
root, // statusRoot must equal the published root
|
|
ethers.id("holder-credential-commitment"), // credentialCommitment (bound in-circuit)
|
|
0, // purpose = Revocation
|
|
]
|
|
);
|
|
const proof = "0x1234"; // opaque; the mock accepts by (vkey, publicValues, proof) marker
|
|
const marker = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(
|
|
["bytes32", "bytes", "bytes"],
|
|
[vkey, publicValues, proof]
|
|
));
|
|
await sp1.setValid(marker, true);
|
|
|
|
expect(await statusList.verifyNonRevocation(0, publicValues, proof)).to.equal(true);
|
|
|
|
// Mutate a bit after publishing the root: now rootEpoch != epoch, so the proof is STALE -> false.
|
|
await statusList.connect(issuerA).setStatus(0, 3, true);
|
|
expect(await statusList.verifyNonRevocation(0, publicValues, proof)).to.equal(false);
|
|
});
|
|
|
|
it("reverts NonRevocationNotConfigured before a vkey is set (never mistaken for not-revoked)", async function () {
|
|
await statusList.connect(issuerA).createList(0, 1024);
|
|
const pv = ethers.AbiCoder.defaultAbiCoder().encode(
|
|
["uint256", "address", "uint256", "bytes32", "bytes32", "uint8"],
|
|
[(await ethers.provider.getNetwork()).chainId, await statusList.getAddress(), 0, ethers.ZeroHash, ethers.ZeroHash, 0]
|
|
);
|
|
await expect(statusList.verifyNonRevocation(0, pv, "0x")).to.be.revertedWithCustomError(
|
|
statusList,
|
|
"NonRevocationNotConfigured"
|
|
);
|
|
});
|
|
});
|
|
});
|