// Hybrid-auth surface for the seedless PQC wallet: the Falcon-512 root produces // (a) a one-tap "upgrade to post-quantum" ATTESTATION binding the quantum-durable // root to the passkey account, recordable via AerePQCAttestation, and (b) the // USEROP signature the Falcon-owned AerePQCAccount consumes. Both end in a REAL // Falcon-512 signature the chain verifies on-chain via the live precompile. // // HONEST SCOPE. The everyday spend path is the passkey daily factor: hot, // classical secp256k1-routed, fast. The Falcon root is the QUANTUM-DURABLE // authority for the high-assurance actions (the upgrade itself, recovery, // high-value / co-signed ops). Only the ROOT is post-quantum; the hot session is // not, by design. The AERE chain's own consensus is QBFT whose headers are still // admitted on classical ECDSA commit seals until AIP-22 stage 5 (post-quantum layers // on top of it are dated in AIP-22; comment corrected 2026-10-28: it said consensus // remains classical QBFT/ECDSA): this is application-layer PQC, not post-quantum // consensus. import { keccak256, AbiCoder, getBytes, hexlify, toUtf8Bytes, concat, type BytesLike, } from 'ethers'; import { falcon512 } from '@noble/post-quantum/falcon.js'; import { SCHEME, signInternal, verifyLocal, falconDetachedToEnvelope, FALCON_NONCE_LEN, type AerePQCClient, } from '../pqc/index.js'; import { computeUserOpHash } from '../account/abi.js'; import type { PackedUserOp } from '../account/types.js'; import type { SeedlessAccountDescriptor } from './types.js'; /** keccak256("AereSeedlessPQCWallet.v1.upgrade") — the upgrade-attestation domain. */ export const UPGRADE_ATTESTATION_DOMAIN = keccak256( toUtf8Bytes('AereSeedlessPQCWallet.v1.upgrade'), ); function bytes32(v: BytesLike): string { const b = getBytes(v); if (b.length !== 32) throw new Error(`hybridAuth: expected 32-byte value, got ${b.length}`); return hexlify(b); } /** * keccak256("AerePQCAccount.validateUserOp.v1") — the userOp signing DOMAIN. The * AerePQCAccount verifies validateUserOp over abi.encodePacked(domain, userOpHash) * (64 bytes), domain-separated from the raw 32-byte EIP-1271 login surface so a * signature phished through a dapp "login" can never be replayed as a spend. This * MUST match the AerePQCAccount.USEROP_DOMAIN constant. (Adversarial-review HIGH.) */ export const PQC_ACCOUNT_USEROP_DOMAIN = keccak256( toUtf8Bytes('AerePQCAccount.validateUserOp.v1'), ); /** The exact 64-byte message the AerePQCAccount Falcon-verifies for a userOp. */ export function pqcAccountUserOpMessage(userOpHash: BytesLike): string { return concat([PQC_ACCOUNT_USEROP_DOMAIN, bytes32(userOpHash)]); } /** * The message hash committing an "upgrade to post-quantum" of a specific seedless * account. Binds the Falcon root to the everyday passkey authority AND to the * Falcon-owned PQC account, so an attestation over it cannot be replayed onto a * different account or a different daily factor: * * keccak256(abi.encode( * UPGRADE_ATTESTATION_DOMAIN, chainId, * passkeyAuthority, keccak256(falconPubKey), pqcAccount)) */ export function upgradeMessageHash(descriptor: SeedlessAccountDescriptor): string { if (!descriptor.root) { throw new Error('hybridAuth: descriptor has no Falcon root (call upgradeToPostQuantum first)'); } const enc = AbiCoder.defaultAbiCoder().encode( ['bytes32', 'uint256', 'address', 'bytes32', 'address'], [ UPGRADE_ATTESTATION_DOMAIN, descriptor.chainId, descriptor.daily.authorityAddress, keccak256(descriptor.root.publicKey), descriptor.root.pqcAccount, ], ); return keccak256(enc); } export interface UpgradeAttestation { /** The account-binding message hash (see {@link upgradeMessageHash}). */ messageHash: string; /** The AerePQCAttestation per-key challenge the Falcon signature covers. */ challenge: string; /** keyId used in the challenge derivation. */ keyId: bigint; /** per-key nonce used in the challenge derivation. */ nonce: bigint; /** The Falcon-512 signature envelope (AerePQCAttestation wire format). */ signature: Uint8Array; } /** * Build the one-tap upgrade attestation: derive the account-binding message hash, * derive the AerePQCAttestation challenge for (keyId, nonce, messageHash), and * sign that challenge with the Falcon-512 ROOT secret key. The resulting envelope * is exactly what AerePQCAttestation.attest()/verifySignature() consumes, and its * validity rests on a real Falcon-512 signature the live precompile verifies. * * `client` supplies the challenge derivation (chainId + attestation address); * it is used purely for the pure/local deriveChallenge — no network call here. * `secretKey` never leaves this process. */ export function buildUpgradeAttestation( client: AerePQCClient, descriptor: SeedlessAccountDescriptor, secretKey: Uint8Array, params: { keyId: bigint | number; nonce: bigint | number }, ): UpgradeAttestation { const messageHash = upgradeMessageHash(descriptor); const keyId = BigInt(params.keyId); const nonce = BigInt(params.nonce); const challenge = client.deriveChallenge(keyId, nonce, messageHash); const signature = signInternal(SCHEME.FALCON512, getBytes(challenge), secretKey); return { messageHash, challenge, keyId, nonce, signature }; } /** * READ-ONLY on-chain verification of an upgrade attestation: re-derives the * challenge on-chain, then asks the live Falcon-512 precompile (via * AerePQCAttestation.verifySignature, a free eth_call) whether the envelope is a * valid Falcon signature over that challenge. No transaction; no state change. */ export async function verifyUpgradeAttestationLive( client: AerePQCClient, descriptor: SeedlessAccountDescriptor, att: UpgradeAttestation, ): Promise { if (!descriptor.root) throw new Error('hybridAuth: descriptor has no Falcon root'); const onChainChallenge = await client.attestChallenge(att.keyId, att.nonce, att.messageHash); if (onChainChallenge.toLowerCase() !== att.challenge.toLowerCase()) return false; return client.verifySignature( SCHEME.FALCON512, getBytes(descriptor.root.publicKey), onChainChallenge, att.signature, ); } export interface PqcUserOpSignature { /** The ERC-4337 userOpHash (mirrors AereEntryPointV2.getUserOpHash). */ userOpHash: string; /** * The AerePQCAccount signature bytes: abi.encode(bytes nonce, bytes compSig), * where compSig is the Falcon compressed body WITHOUT the 0x29 esig header. * Set this as userOp.signature for handleOps. */ accountSignature: string; /** * The SAME Falcon signature re-cast into the AerePQCAttestation wire envelope * (nonce(40) || 0x29 || compSig), for verifying the Falcon leg via the live * precompile (verifySignature) read-only. */ attestationEnvelope: Uint8Array; /** The raw noble detached signature (0x39 || nonce(40) || compSig). */ detached: Uint8Array; } /** * Sign an ERC-4337 UserOperation for the Falcon-owned AerePQCAccount with the * quantum-durable ROOT key. Produces both the account-consumable signature * (abi.encode(nonce, compSig)) and the AerePQCAttestation-format envelope for a * free on-chain verification of the Falcon leg. * * The account verifies validateUserOp over the DOMAIN-SEPARATED message * abi.encodePacked(USEROP_DOMAIN, userOpHash) via the SAME live AereFalcon512Verifier * the precompile wraps, so a `true` from {@link verifyUserOpFalconLegLive} is exactly * the check the account performs. (isValidSignature stays raw-hash for EIP-1271 * integrators; the domain separation is what stops a login sig authorizing a spend.) */ export function signPqcAccountUserOp( op: PackedUserOp, entryPoint: string, chainId: bigint | number, secretKey: Uint8Array, ): PqcUserOpSignature { const userOpHash = computeUserOpHash(op, entryPoint, chainId); const msg = getBytes(pqcAccountUserOpMessage(userOpHash)); const detached = falcon512.sign(msg, secretKey); const expectedHdr = 0x30 | 9; // 0x39 if (detached[0] !== expectedHdr || detached.length <= 1 + FALCON_NONCE_LEN) { throw new Error('hybridAuth: unexpected Falcon detached signature shape'); } const nonce = detached.subarray(1, 1 + FALCON_NONCE_LEN); const compSig = detached.subarray(1 + FALCON_NONCE_LEN); const accountSignature = AbiCoder.defaultAbiCoder().encode( ['bytes', 'bytes'], [hexlify(nonce), hexlify(compSig)], ); const attestationEnvelope = falconDetachedToEnvelope(detached, SCHEME.FALCON512); return { userOpHash, accountSignature, attestationEnvelope, detached }; } /** Off-chain mirror of the AerePQCAccount Falcon check (fast pre-flight). * Uses the raw noble verify over the 64-byte domain-separated message (the SDK's * verifyLocal helper is locked to 32-byte messages); mirrors falcon512.sign in the * signer, and matches the on-chain AereFalcon512Verifier the account calls. */ export function verifyPqcAccountUserOpLocal( sig: PqcUserOpSignature, falconPubKey: BytesLike, ): boolean { try { return falcon512.verify( getBytes(sig.detached), getBytes(pqcAccountUserOpMessage(sig.userOpHash)), getBytes(falconPubKey), ); } catch { return false; } } /** * READ-ONLY on-chain verification of a UserOp's Falcon leg via the live precompile * (AerePQCAttestation.verifySignature). This is the exact lattice check the * AerePQCAccount runs in validateUserOp / isValidSignature — proven without * deploying the account or sending a transaction. */ export async function verifyUserOpFalconLegLive( client: AerePQCClient, sig: PqcUserOpSignature, falconPubKey: BytesLike, ): Promise { // The spend message is domain-separated (64 bytes), so it cannot go through the // 32-byte AerePQCAttestation path; verify it directly via the raw Falcon verifier, // exactly as AerePQCAccount.validateUserOp does. const [nonce, compSig] = AbiCoder.defaultAbiCoder().decode(['bytes', 'bytes'], sig.accountSignature); return client.verifyFalconRaw( falconPubKey, pqcAccountUserOpMessage(sig.userOpHash), nonce, compSig, ); }