239 lines
9.9 KiB
TypeScript
239 lines
9.9 KiB
TypeScript
// 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<boolean> {
|
|
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<boolean> {
|
|
// 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,
|
|
);
|
|
}
|