aere-contracts/test/AereAgentDID.test.js
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

537 lines
24 KiB
JavaScript

const { expect } = require("chai");
const { ethers, network } = require("hardhat");
// ---------------------------------------------------------------------------
// AereAgentDID — Falcon-root DID for AI agents issuing short-lived secp256k1
// session keys with scope + spend-policy + on-chain revocation.
//
// The ROOT authority is a Falcon key held in AerePQCKeyRegistry (proof-of-possession
// enforced there). We install MockPQCPrecompile at 0x0AE1..0x0AE4 (hardhat_setCode) so
// the registry's Falcon verification runs; the real precompile encoding is proven
// separately against the LIVE mainnet precompile via eth_call. Session ISSUANCE is
// gated by a Falcon PoP over an issuance challenge; session ACTIONS are cheap secp256k1
// (ecrecover). A genuine Falcon envelope has commitment == keccak256(pk || challenge).
// ---------------------------------------------------------------------------
const PRECOMPILE = {
1: ethers.getAddress("0x0000000000000000000000000000000000000ae1"),
2: ethers.getAddress("0x0000000000000000000000000000000000000ae2"),
3: ethers.getAddress("0x0000000000000000000000000000000000000ae3"),
4: ethers.getAddress("0x0000000000000000000000000000000000000ae4"),
};
const META = {
1: { pkLen: 897, pkHeader: 0x09, esigHeader: 0x29, falcon: true },
2: { pkLen: 1793, pkHeader: 0x0a, esigHeader: 0x2a, falcon: true },
3: { pkLen: 1312, sigLen: 2420, falcon: false },
4: { pkLen: 32, sigLen: 7856, falcon: false },
};
function makeRng(seedText) {
let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText));
function 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 int(n) {
const b = bytes(4);
return (((b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]) >>> 0) % n;
}
return { bytes, int };
}
function makePubKey(scheme, rng) {
const m = META[scheme];
const raw = rng.bytes(m.pkLen);
if (m.falcon) raw[0] = m.pkHeader;
return ethers.hexlify(raw);
}
function commitmentFor(pubKey, message) {
return ethers.keccak256(ethers.concat([pubKey, message]));
}
function envelope(scheme, commitment, rng) {
const m = META[scheme];
if (m.falcon) {
const nonce = rng.bytes(40);
const esig = ethers.concat([new Uint8Array([m.esigHeader]), commitment]);
return ethers.hexlify(ethers.concat([nonce, esig]));
} else {
const sig = new Uint8Array(m.sigLen);
sig.set(ethers.getBytes(commitment), 0);
return ethers.hexlify(sig);
}
}
function genuine(scheme, pubKey, message, rng) {
return envelope(scheme, commitmentFor(pubKey, message), rng);
}
function tamperCommitment(scheme, sigHex) {
const b = ethers.getBytes(sigHex);
const idx = META[scheme].falcon ? 41 : 0;
b[idx] ^= 0x01;
return ethers.hexlify(b);
}
// secp256k1 signature (r||s||v, low-s, v in {27,28}) by `wallet` over the 32-byte digest.
function ecdsaSign(wallet, digest) {
return wallet.signingKey.sign(digest).serialized;
}
const AGENT_BOND = ethers.getAddress("0x32E0015F622a8719d1C380A87CE1a09bcd0cB86A"); // AereAgentBond (mainnet)
const AI_REPUTATION = ethers.getAddress("0x781ef746c08760aa854cDa4621d54db6734bfeBF"); // AereAIReputation
describe("AereAgentDID (Falcon-root DID + secp256k1 sessions + seeded fuzz)", function () {
let reg, did, owner, other, relayer, mockCode;
before(async function () {
[owner, other, relayer] = await ethers.getSigners();
mockCode = {};
const F = await ethers.getContractFactory("MockPQCPrecompile");
for (const scheme of [1, 2, 3, 4]) {
const mock = await F.deploy(scheme);
await mock.waitForDeployment();
mockCode[scheme] = await ethers.provider.getCode(await mock.getAddress());
}
});
async function installMocks() {
for (const scheme of [1, 2, 3, 4]) {
await network.provider.send("hardhat_setCode", [PRECOMPILE[scheme], mockCode[scheme]]);
}
}
beforeEach(async function () {
await installMocks();
const R = await ethers.getContractFactory("AerePQCKeyRegistry");
reg = await R.deploy();
await reg.waitForDeployment();
const D = await ethers.getContractFactory("AereAgentDID");
did = await D.deploy(await reg.getAddress(), AGENT_BOND, AI_REPUTATION);
await did.waitForDeployment();
});
// Register a Falcon root key under `signer`, return { rootKeyId, rootPk, scheme }.
async function registerFalconRoot(scheme, signer = owner, rng = makeRng("root-" + scheme)) {
const pk = makePubKey(scheme, rng);
const nonce = await reg.identityNonce(signer.address);
const ch = await reg.popChallenge(signer.address, scheme, pk, nonce);
const tx = await reg.connect(signer).registerKey(scheme, pk, genuine(scheme, 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 { rootKeyId: Number(log.args.keyId), rootPk: pk, scheme };
}
// Issue a session with a genuine Falcon PoP (relayed by `caller`).
async function issueSession(
root,
{ sessionAddr, scopeHash, spendCap, expiry, rng, caller = relayer, signer = owner }
) {
const nonce = (await did.getAgent(root.rootKeyId)).sessionNonce;
const challenge = await did.sessionChallenge(root.rootKeyId, nonce, sessionAddr, scopeHash, spendCap, expiry);
const pop = genuine(root.scheme, root.rootPk, challenge, rng);
const tx = await did.connect(caller).issueSession(root.rootKeyId, sessionAddr, scopeHash, spendCap, expiry, pop);
const rc = await tx.wait();
const log = rc.logs.map((l) => did.interface.parseLog(l)).find((p) => p && p.name === "SessionIssued");
return Number(log.args.sessionId);
}
// Base expiry on the ON-CHAIN latest block timestamp (evm_increaseTime in earlier
// tests advances the node clock past wall time, so Date.now() would be stale).
async function futureTs(secs = 3600) {
const b = await ethers.provider.getBlock("latest");
return b.timestamp + secs;
}
// -----------------------------------------------------------------------
// Deployment & cross-links
// -----------------------------------------------------------------------
describe("deployment", function () {
it("wires the registry and exposes the read-only cross-links", async function () {
expect(await did.keyRegistry()).to.equal(await reg.getAddress());
expect(await did.agentBond()).to.equal(AGENT_BOND);
expect(await did.aiReputation()).to.equal(AI_REPUTATION);
});
it("reverts on a zero registry", async function () {
const D = await ethers.getContractFactory("AereAgentDID");
await expect(D.deploy(ethers.ZeroAddress, AGENT_BOND, AI_REPUTATION)).to.be.revertedWithCustomError(
D,
"ZeroRegistry"
);
});
});
// -----------------------------------------------------------------------
// createAgent
// -----------------------------------------------------------------------
describe("createAgent", function () {
it("creates a DID for an ACTIVE Falcon root, callable only by the root controller", async function () {
const rng = makeRng("create");
const root = await registerFalconRoot(1, owner, rng);
// non-controller cannot create.
await expect(did.connect(other).createAgent(root.rootKeyId)).to.be.revertedWithCustomError(
did,
"NotRootController"
);
await expect(did.createAgent(root.rootKeyId)).to.emit(did, "AgentCreated");
const a = await did.getAgent(root.rootKeyId);
expect(a.controller).to.equal(owner.address);
expect(Number(a.sessionNonce)).to.equal(0);
expect(await did.agentExists(root.rootKeyId)).to.equal(true);
});
it("rejects a non-Falcon root key", async function () {
const rng = makeRng("nonfalcon");
// register an ML-DSA-44 key and try to use it as a root.
const pk = makePubKey(3, rng);
const nonce = await reg.identityNonce(owner.address);
const ch = await reg.popChallenge(owner.address, 3, pk, nonce);
const tx = await reg.registerKey(3, pk, genuine(3, pk, ch, rng));
const rc = await tx.wait();
const id = Number(
rc.logs.map((l) => reg.interface.parseLog(l)).find((p) => p && p.name === "KeyRegistered").args.keyId
);
await expect(did.createAgent(id)).to.be.revertedWithCustomError(did, "RootKeyNotFalcon");
});
it("rejects a rotated-away (non-ACTIVE) root and a duplicate DID", async function () {
const rng = makeRng("dup");
const root = await registerFalconRoot(2, owner, rng);
await did.createAgent(root.rootKeyId);
await expect(did.createAgent(root.rootKeyId)).to.be.revertedWithCustomError(did, "AgentExists");
// A separate root that we rotate away is no longer ACTIVE -> cannot create.
const root2 = await registerFalconRoot(1, owner, rng);
const np = makePubKey(1, rng);
const n = await reg.identityNonce(owner.address);
const ch = await reg.popChallenge(owner.address, 1, np, n);
await reg.rotateKey(root2.rootKeyId, 1, np, genuine(1, np, ch, rng));
await expect(did.createAgent(root2.rootKeyId)).to.be.revertedWithCustomError(did, "RootKeyNotActive");
});
});
// -----------------------------------------------------------------------
// issueSession (Falcon-PoP gated, relayable)
// -----------------------------------------------------------------------
describe("issueSession", function () {
it("issues a session with a genuine Falcon PoP, relayable by anyone", async function () {
const rng = makeRng("issue");
const root = await registerFalconRoot(1, owner, rng);
await did.createAgent(root.rootKeyId);
const session = ethers.Wallet.createRandom();
const scopeHash = ethers.keccak256(ethers.toUtf8Bytes("swap:read"));
const expiry = await futureTs();
const sid = await issueSession(root, {
sessionAddr: session.address,
scopeHash,
spendCap: 1000n,
expiry,
rng,
caller: relayer, // relayed, not the controller
});
expect(sid).to.equal(0);
const s = await did.getSession(sid);
expect(s.sessionAddr).to.equal(session.address);
expect(s.scopeHash).to.equal(scopeHash);
expect(s.spendCap).to.equal(1000n);
expect(s.revoked).to.equal(false);
expect(await did.isSessionValid(sid)).to.equal(true);
expect(Number((await did.getAgent(root.rootKeyId)).sessionNonce)).to.equal(1);
});
it("rejects a forged PoP and a stale/replayed PoP", async function () {
const rng = makeRng("issue-bad");
const root = await registerFalconRoot(2, owner, rng);
await did.createAgent(root.rootKeyId);
const session = ethers.Wallet.createRandom();
const scopeHash = ethers.ZeroHash;
const expiry = await futureTs();
const nonce = (await did.getAgent(root.rootKeyId)).sessionNonce;
const challenge = await did.sessionChallenge(root.rootKeyId, nonce, session.address, scopeHash, 500n, expiry);
const forged = tamperCommitment(2, genuine(2, root.rootPk, challenge, rng));
await expect(
did.issueSession(root.rootKeyId, session.address, scopeHash, 500n, expiry, forged)
).to.be.revertedWithCustomError(did, "SessionAuthFailed");
// A genuine PoP for the CURRENT nonce works once...
const good = genuine(2, root.rootPk, challenge, rng);
await did.issueSession(root.rootKeyId, session.address, scopeHash, 500n, expiry, good);
// ...and replaying it fails (nonce advanced -> different challenge).
await expect(
did.issueSession(root.rootKeyId, session.address, scopeHash, 500n, expiry, good)
).to.be.revertedWithCustomError(did, "SessionAuthFailed");
});
it("rejects a past expiry, a zero session address, and an unknown agent", async function () {
const rng = makeRng("issue-params");
const root = await registerFalconRoot(1, owner, rng);
await did.createAgent(root.rootKeyId);
const session = ethers.Wallet.createRandom();
const pastExpiry = 1; // in the past
const nonce = (await did.getAgent(root.rootKeyId)).sessionNonce;
const ch = await did.sessionChallenge(root.rootKeyId, nonce, session.address, ethers.ZeroHash, 1n, pastExpiry);
const pop = genuine(1, root.rootPk, ch, rng);
await expect(
did.issueSession(root.rootKeyId, session.address, ethers.ZeroHash, 1n, pastExpiry, pop)
).to.be.revertedWithCustomError(did, "InvalidSessionParams");
// unknown agent.
await expect(
did.issueSession(999, session.address, ethers.ZeroHash, 1n, await futureTs(), "0x")
).to.be.revertedWithCustomError(did, "UnknownAgent");
});
});
// -----------------------------------------------------------------------
// authorize (secp256k1 hot path: scope + spend cap + anti-replay)
// -----------------------------------------------------------------------
describe("authorize", function () {
let root, session, sid, scopeHash;
beforeEach(async function () {
const rng = makeRng("auth-setup");
root = await registerFalconRoot(1, owner, rng);
await did.createAgent(root.rootKeyId);
session = ethers.Wallet.createRandom();
scopeHash = ethers.keccak256(ethers.toUtf8Bytes("trade"));
sid = await issueSession(root, {
sessionAddr: session.address,
scopeHash,
spendCap: 100n,
expiry: await futureTs(),
rng,
});
});
async function authorize(amount, actionLabel, opts = {}) {
const scope = opts.scope ?? scopeHash;
const actionHash = ethers.keccak256(ethers.toUtf8Bytes(actionLabel));
const nonce = opts.nonce ?? (await did.getSession(sid)).actionNonce;
const digest = await did.actionDigest(sid, scope, actionHash, amount, nonce);
const wallet = opts.wallet ?? session;
const sig = ecdsaSign(wallet, digest);
return did.connect(relayer).authorize(sid, scope, actionHash, amount, sig);
}
it("authorizes a scoped action signed by the session key and records spend", async function () {
await expect(authorize(30n, "a1")).to.emit(did, "ActionAuthorized");
expect((await did.getSession(sid)).spent).to.equal(30n);
expect(Number((await did.getSession(sid)).actionNonce)).to.equal(1);
expect(await did.remainingSpend(sid)).to.equal(70n);
await authorize(70n, "a2");
expect((await did.getSession(sid)).spent).to.equal(100n);
expect(await did.remainingSpend(sid)).to.equal(0n);
});
it("enforces the spend cap (cumulative)", async function () {
await authorize(60n, "a1");
await expect(authorize(41n, "a2")).to.be.revertedWithCustomError(did, "SessionSpendExceeded");
// exactly hitting the cap is allowed.
await authorize(40n, "a3");
expect((await did.getSession(sid)).spent).to.equal(100n);
});
it("enforces scope: a non-matching scope tag reverts", async function () {
const wrongScope = ethers.keccak256(ethers.toUtf8Bytes("withdraw"));
await expect(authorize(1n, "x", { scope: wrongScope })).to.be.revertedWithCustomError(
did,
"SessionScopeViolation"
);
});
it("rejects a signature by a non-session key", async function () {
const stranger = ethers.Wallet.createRandom();
await expect(authorize(1n, "x", { wallet: stranger })).to.be.revertedWithCustomError(did, "SessionSigInvalid");
});
it("is anti-replay: reusing a consumed action nonce reverts", async function () {
// Sign at nonce 0, submit -> nonce becomes 1. Re-submitting the SAME signature
// (still nonce 0) now recovers a wrong digest -> SessionSigInvalid.
const actionHash = ethers.keccak256(ethers.toUtf8Bytes("replay"));
const digest0 = await did.actionDigest(sid, scopeHash, actionHash, 10n, 0);
const sig0 = ecdsaSign(session, digest0);
await did.authorize(sid, scopeHash, actionHash, 10n, sig0);
expect(Number((await did.getSession(sid)).actionNonce)).to.equal(1);
await expect(did.authorize(sid, scopeHash, actionHash, 10n, sig0)).to.be.revertedWithCustomError(
did,
"SessionSigInvalid"
);
});
it("rejects a malformed (wrong-length) signature", async function () {
const actionHash = ethers.keccak256(ethers.toUtf8Bytes("m"));
await expect(did.authorize(sid, scopeHash, actionHash, 1n, "0x1234")).to.be.revertedWithCustomError(
did,
"SessionSigInvalid"
);
});
it("reverts on an unknown sessionId", async function () {
await expect(did.authorize(999, scopeHash, ethers.ZeroHash, 1n, "0x" + "00".repeat(65)))
.to.be.revertedWithCustomError(did, "UnknownSession");
});
});
// -----------------------------------------------------------------------
// revocation & expiry & root-lifecycle cross-check
// -----------------------------------------------------------------------
describe("session validity: revoke / expiry / root lifecycle", function () {
let root, session, sid, scopeHash;
beforeEach(async function () {
const rng = makeRng("valid-setup");
root = await registerFalconRoot(1, owner, rng);
await did.createAgent(root.rootKeyId);
session = ethers.Wallet.createRandom();
scopeHash = ethers.keccak256(ethers.toUtf8Bytes("s"));
sid = await issueSession(root, {
sessionAddr: session.address,
scopeHash,
spendCap: 100n,
expiry: await futureTs(1000),
rng,
});
this.rng = rng;
});
async function authorizeOnce(amount) {
const actionHash = ethers.keccak256(ethers.toUtf8Bytes("act"));
const nonce = (await did.getSession(sid)).actionNonce;
const digest = await did.actionDigest(sid, scopeHash, actionHash, amount, nonce);
return did.authorize(sid, scopeHash, actionHash, amount, ecdsaSign(session, digest));
}
it("controller can fast-revoke (cheap ECDSA); a stranger cannot", async function () {
await expect(did.connect(other).revokeSession(sid)).to.be.revertedWithCustomError(did, "NotSessionRevoker");
await expect(did.connect(owner).revokeSession(sid)).to.emit(did, "SessionRevoked");
expect(await did.isSessionValid(sid)).to.equal(false);
await expect(authorizeOnce(1n)).to.be.revertedWithCustomError(did, "SessionExpiredOrInvalid");
// revoke is idempotent (no-op success on a second call).
await did.connect(owner).revokeSession(sid);
});
it("a session past its expiry is invalid", async function () {
expect(await did.isSessionValid(sid)).to.equal(true);
await network.provider.send("evm_increaseTime", [2000]);
await network.provider.send("evm_mine", []);
expect(await did.isSessionValid(sid)).to.equal(false);
await expect(authorizeOnce(1n)).to.be.revertedWithCustomError(did, "SessionExpiredOrInvalid");
});
it("REVOKING the Falcon root invalidates all its sessions", async function () {
expect(await did.isSessionValid(sid)).to.equal(true);
await reg.connect(owner).revokeKey(root.rootKeyId);
expect(await did.isSessionValid(sid)).to.equal(false);
await expect(authorizeOnce(1n)).to.be.revertedWithCustomError(did, "SessionExpiredOrInvalid");
});
it("ROTATING the Falcon root (root no longer ACTIVE) invalidates its sessions", async function () {
const rng = this.rng;
const np = makePubKey(1, rng);
const n = await reg.identityNonce(owner.address);
const ch = await reg.popChallenge(owner.address, 1, np, n);
await reg.rotateKey(root.rootKeyId, 1, np, genuine(1, np, ch, rng));
expect(await did.isSessionValid(sid)).to.equal(false);
});
});
// -----------------------------------------------------------------------
// Seeded invariant fuzz
// -----------------------------------------------------------------------
describe("seeded invariant fuzz", function () {
it("scope + spend-cap + anti-replay + revocation hold across random rounds", async function () {
const rng = makeRng("fuzz-did-v1");
const scheme = 2;
const root = await registerFalconRoot(scheme, owner, rng);
await did.createAgent(root.rootKeyId);
const ROUNDS = 24;
for (let r = 0; r < ROUNDS; r++) {
const session = ethers.Wallet.createRandom();
const scopeHash = ethers.hexlify(rng.bytes(32));
const cap = BigInt(50 + rng.int(200));
const sid = await issueSession(root, {
sessionAddr: session.address,
scopeHash,
spendCap: cap,
expiry: await futureTs(5000),
rng,
});
let spent = 0n;
// A few genuine scoped actions within cap succeed and accumulate.
const k = 1 + rng.int(3);
for (let i = 0; i < k; i++) {
const remaining = cap - spent;
if (remaining === 0n) break;
const amount = BigInt(1 + rng.int(Number(remaining > 40n ? 40n : remaining)));
const actionHash = ethers.hexlify(rng.bytes(32));
const nonce = (await did.getSession(sid)).actionNonce;
const digest = await did.actionDigest(sid, scopeHash, actionHash, amount, nonce);
await did.authorize(sid, scopeHash, actionHash, amount, ecdsaSign(session, digest));
spent += amount;
expect((await did.getSession(sid)).spent).to.equal(spent);
}
// (a) wrong scope always reverts.
{
const badScope = ethers.hexlify(rng.bytes(32));
const actionHash = ethers.hexlify(rng.bytes(32));
const nonce = (await did.getSession(sid)).actionNonce;
const digest = await did.actionDigest(sid, badScope, actionHash, 1n, nonce);
await expect(
did.authorize(sid, badScope, actionHash, 1n, ecdsaSign(session, digest))
).to.be.revertedWithCustomError(did, "SessionScopeViolation");
}
// (b) exceeding the cap always reverts.
{
const over = cap - spent + 1n;
const actionHash = ethers.hexlify(rng.bytes(32));
const nonce = (await did.getSession(sid)).actionNonce;
const digest = await did.actionDigest(sid, scopeHash, actionHash, over, nonce);
await expect(
did.authorize(sid, scopeHash, actionHash, over, ecdsaSign(session, digest))
).to.be.revertedWithCustomError(did, "SessionSpendExceeded");
}
// (c) a stranger key never authorizes. Use amount 0 so the spend check (which
// runs before the signature check) always passes, isolating the SIG failure —
// the session's cap may already be exhausted this round.
{
const stranger = ethers.Wallet.createRandom();
const actionHash = ethers.hexlify(rng.bytes(32));
const nonce = (await did.getSession(sid)).actionNonce;
const digest = await did.actionDigest(sid, scopeHash, actionHash, 0n, nonce);
await expect(
did.authorize(sid, scopeHash, actionHash, 0n, ecdsaSign(stranger, digest))
).to.be.revertedWithCustomError(did, "SessionSigInvalid");
}
// Randomly revoke ~1/3 of sessions; a revoked session never authorizes again.
if (rng.int(3) === 0) {
await did.connect(owner).revokeSession(sid);
expect(await did.isSessionValid(sid)).to.equal(false);
const actionHash = ethers.hexlify(rng.bytes(32));
const nonce = (await did.getSession(sid)).actionNonce;
const digest = await did.actionDigest(sid, scopeHash, actionHash, 1n, nonce);
await expect(
did.authorize(sid, scopeHash, actionHash, 1n, ecdsaSign(session, digest))
).to.be.revertedWithCustomError(did, "SessionExpiredOrInvalid");
}
}
// sessionsOf reflects everything issued.
const list = await did.sessionsOf(root.rootKeyId);
expect(list.length).to.equal(ROUNDS);
expect(Number(await did.sessionCount())).to.equal(ROUNDS);
});
});
});