sdk-js/examples/pqc-identity-e2e.mjs
Liviu 6d6c18ae41 SDK 0.16.12: the address book and typed clients catch up with the live chain, and client mentions get the same discretion as everywhere else
The published repository served the 0.3.0 line from May while the install
page already pointed here. This carries the uncommitted line: 35+ typed
contract clients over the canonical address book, the compliance surface,
and the hybrid PQC wallet helpers.
2026-08-15 14:01:27 +03:00

166 lines
10 KiB
JavaScript

// AERE post-quantum identity — end-to-end example (chain 2800).
//
// One runnable script that stitches the shipped PQC pieces into a single front
// door and DRIVES it against the LIVE read-only RPC:
//
// 1. Generate a Falcon-512 quantum-durable ROOT keypair client-side, and
// client-side-encrypt the secret under a passphrase (no seed phrase, the
// secret never leaves this process unencrypted).
// 2. Register the root public key in AerePQCKeyRegistry with on-chain
// PROOF-OF-POSSESSION — derive the PoP challenge locally and CROSS-CHECK it
// against the live `popChallenge` view (SDK<->contract parity, proven live).
// 3. Open an AereAgentDID rooted in that Falcon key, then issue a short-lived,
// revocable secp256k1 SESSION key (scope + spend cap + expiry), gated by a
// Falcon proof-of-possession. Authorize an action with the session key.
// 4. Set a quantum-durable M-of-N guardian committee (AerePQCSocialRecoveryModule)
// and derive the recovery challenge a guardian would PQC-sign.
//
// READ-ONLY / DRY-RUN: every write flow is CONSTRUCTED and PRINTED (the exact
// calldata a wallet would broadcast) but nothing is signed with a funded key and
// nothing is sent. The live RPC is used only for eth_call reads and the parity
// cross-checks. No private key is ever committed or transmitted.
//
// Run:
// cd aerenew/sdk-js && npm run build
// node examples/pqc-identity-e2e.mjs
// AERE_RPC=https://rpc.aere.network node examples/pqc-identity-e2e.mjs
import {
JsonRpcProvider, Wallet, keccak256, toUtf8Bytes, hexlify, getBytes,
} from 'ethers';
import { AERE_MAINNET } from '../dist/addresses.js';
import { generateFalconRoot, encryptFalconSecretKey } from '../dist/wallet/falconKeystore.js';
import { AerePQCKeyRegistryClient } from '../dist/pqc/AerePQCKeyRegistryClient.js';
import { AereAgentDIDClient } from '../dist/pqc/AereAgentDIDClient.js';
import { AerePQCSocialRecoveryClient, recoveryChallenge as deriveRecoveryChallenge } from '../dist/account/AerePQCSocialRecoveryClient.js';
import { SCHEME, SCHEME_NAME, verifyLocal } from '../dist/pqc/index.js';
const RPC = process.env.AERE_RPC || AERE_MAINNET.rpc;
const line = (s = '') => console.log(s);
const short = (h, n = 10) => (h.length > 2 * n + 2 ? `${h.slice(0, n + 2)}${h.slice(-n)}` : h);
const ok = (b) => (b ? 'OK' : 'MISMATCH');
const provider = new JsonRpcProvider(RPC);
const net = await provider.getNetwork();
const block = await provider.getBlockNumber();
line('AERE post-quantum identity — end-to-end (read-only)');
line(`RPC ${RPC} · chainId ${net.chainId} · block ${block}`);
line('This script sends NO transactions. It reads live state and prints the txs a wallet WOULD send.');
// The identity the keys bind to. Any address works for the read-only flow — the
// PoP challenge commits to this address, and the account that pays gas is the owner.
// Override with AERE_OWNER=0x... to bind to your own address.
const OWNER = process.env.AERE_OWNER || Wallet.createRandom().address;
line(`\nowner (identity) ${OWNER}`);
// ── 1. Falcon-512 root, generated + encrypted client-side ────────────────────
line('\n=== 1. Falcon-512 quantum-durable root (client-side) ===');
const root = generateFalconRoot();
line(`scheme ${SCHEME_NAME[root.scheme]} (id ${root.scheme})`);
line(`public key ${root.publicKey.length} bytes ${short(hexlify(root.publicKey))}`);
const keystore = await encryptFalconSecretKey(root, 'correct horse battery staple');
line(`encrypted keystore ${keystore.cipher} / ${keystore.kdf} (${keystore.kdfIterations} iters)`);
line(` ciphertext ${short(keystore.ciphertext)} (secret key never stored in the clear)`);
// ── 2. Register the root key with on-chain proof-of-possession ───────────────
line('\n=== 2. Register root key with proof-of-possession (AerePQCKeyRegistry) ===');
const reg = new AerePQCKeyRegistryClient(provider);
line(`registry ${reg.address}`);
line(`keyCount [live] ${await reg.keyCount()}`);
const idNonce = await reg.identityNonce(OWNER);
line(`identityNonce(owner) ${idNonce}`);
// Local PoP challenge, then cross-check it against the live contract view.
const localPop = reg.derivePopChallenge(OWNER, root.scheme, root.publicKey, idNonce);
const chainPop = await reg.popChallenge(OWNER, root.scheme, root.publicKey, idNonce);
line(`PoP challenge (local) ${short(localPop)}`);
line(`PoP challenge (on-chain)${short(chainPop)} parity: ${ok(localPop.toLowerCase() === chainPop.toLowerCase())}`);
// Build + locally verify the PoP envelope, then show the registerKey tx.
const pop = reg.buildRegisterKeyPoP(OWNER, root.scheme, root.secretKey, root.publicKey, idNonce);
const popValid = verifyLocal(root.scheme, getBytes(pop.challenge), pop.signature, root.publicKey);
line(`PoP signature ${pop.signature.length} bytes verifyLocal: ${ok(popValid)}`);
const registerCalldata = reg.contract.interface.encodeFunctionData(
'registerKey', [root.scheme, hexlify(root.publicKey), hexlify(pop.signature)],
);
line(`registerKey tx to ${reg.address}`);
line(` calldata ${(registerCalldata.length - 2) / 2} bytes ${short(registerCalldata)}`);
line(' (broadcast: reg.registerKeyWithPoP(signer, scheme, secretKey, pubKey) — signer is the owner)');
// The keyId this registration would take (registry is append-only from keyCount).
const rootKeyId = await reg.keyCount();
line(` -> would take keyId ${rootKeyId}`);
// ── 3. Agent DID: Falcon root + revocable secp256k1 session key ──────────────
line('\n=== 3. Agent DID + session key (AereAgentDID) ===');
const did = new AereAgentDIDClient(provider);
line(`agent DID ${did.address}`);
line(`sessionCount [live] ${await did.sessionCount()}`);
line(`agentExists(keyId ${rootKeyId}) ${await did.agentExists(rootKeyId)}`);
const createAgentCalldata = did.contract.interface.encodeFunctionData('createAgent', [rootKeyId]);
line(`createAgent tx to ${did.address} calldata ${short(createAgentCalldata)}`);
// Fresh secp256k1 session key (hot key, disposable). Generated locally.
const session = Wallet.createRandom();
const sessionParams = {
rootKeyId,
sessionAddr: session.address,
scopeHash: keccak256(toUtf8Bytes('aere.agent.session.v1:inference')),
spendCap: 10n ** 18n, // 1 AERE cumulative
expiry: BigInt(Math.floor(Date.now() / 1000) + 30 * 24 * 3600), // +30 days
};
line(`session key ${session.address} (secp256k1, revocable)`);
line(` scope / cap / expiry ${short(sessionParams.scopeHash)} / 1 AERE / ${sessionParams.expiry}`);
// Session issuance is Falcon-PoP-gated. Cross-check the issuance challenge (explicit nonce 0).
const localSess = did.deriveSessionChallenge(sessionParams, 0n);
const chainSess = await did.sessionChallenge(sessionParams, 0n);
line(`issuance challenge local ${short(localSess)} parity: ${ok(localSess.toLowerCase() === chainSess.toLowerCase())}`);
const issue = did.buildIssueSessionPoP(sessionParams, root.secretKey, root.scheme, 0n);
line(`issueSession popSig ${issue.popSig.length} bytes (Falcon PoP by the root)`);
const issueCalldata = did.contract.interface.encodeFunctionData('issueSession', [
sessionParams.rootKeyId, sessionParams.sessionAddr, sessionParams.scopeHash,
sessionParams.spendCap, sessionParams.expiry, hexlify(issue.popSig),
]);
line(`issueSession tx to ${did.address} calldata ${(issueCalldata.length - 2) / 2} bytes`);
// Authorize an action with the SESSION key (cheap ecrecover hot path).
const sessionId = await did.sessionCount(); // append-only
const actionHash = keccak256(toUtf8Bytes('POST /v1/inference {"model":"aere-1"}'));
const amount = 5n * 10n ** 17n; // 0.5 AERE
const localDigest = did.deriveActionDigest(sessionId, sessionParams.scopeHash, actionHash, amount, 0n);
const chainDigest = await did.actionDigest(sessionId, sessionParams.scopeHash, actionHash, amount, 0n);
line(`action digest local ${short(localDigest)} parity: ${ok(localDigest.toLowerCase() === chainDigest.toLowerCase())}`);
const auth = did.buildAuthorization(sessionId, sessionParams.scopeHash, actionHash, amount, 0n, session.privateKey);
line(`authorize sig ${short(auth.signature)} (secp256k1 over the raw digest)`);
const authCalldata = did.contract.interface.encodeFunctionData('authorize', [
sessionId, sessionParams.scopeHash, actionHash, amount, auth.signature,
]);
line(`authorize tx to ${did.address} calldata ${short(authCalldata)}`);
// ── 4. Quantum-durable M-of-N guardians (AerePQCSocialRecoveryModule) ────────
line('\n=== 4. PQC social-recovery guardians (AerePQCSocialRecoveryModule) ===');
// The module is a repo contract + client but is NOT yet deployed to mainnet, so
// there is no live address to read; MODULE_ADDR is a placeholder for the calldata.
const MODULE_ADDR = process.env.AERE_PQC_RECOVERY_MODULE || '0x' + '00'.repeat(20);
const rec = new AerePQCSocialRecoveryClient(MODULE_ADDR, provider);
// Three guardian keys, each a DISTINCT active AerePQCKeyRegistry key NOT owned by
// the account (here illustrative keyIds; on-chain they must be registered first).
const guardianKeyIds = [11n, 12n, 13n];
const threshold = 2n;
line(`module (undeployed) ${MODULE_ADDR}`);
line(`committee ${threshold}-of-${guardianKeyIds.length} guardian keyIds [${guardianKeyIds.join(', ')}]`);
const installData = rec.encodeOnInstall(guardianKeyIds, threshold);
line(`onInstall tx calldata ${(installData.length - 2) / 2} bytes ${short(installData)}`);
line(`addGuardianKey(14) tx ${short(rec.encodeAddGuardianKey(14n))}`);
line(`setThreshold(3) tx ${short(rec.encodeSetThreshold(3n))}`);
// A recovery: the challenge each guardian PQC-signs to swap the account's root owner.
const account = OWNER;
const newOwner = Wallet.createRandom().address;
const round = 0n; // first round for a fresh account
const chal = deriveRecoveryChallenge(MODULE_ADDR, account, newOwner, round, net.chainId);
line(`recovery: newOwner ${newOwner} round ${round}`);
line(`recovery challenge ${short(chal)} (each of ${threshold} guardians PQC-signs this)`);
line(' legs are built with rec.buildLegs(account, newOwner, [{guardianIndex, scheme, secretKey}...])');
line(' then submitted via rec.scheduleRecovery(...) — permissionless, 48h timelock, then executeRecovery');
line('\nDone. Live cross-checks (PoP / issuance / action digest) matched the on-chain views.');
line('Nothing was signed with a funded key and no transaction was sent.');