sdk-js/src/wallet/hybridAuth.ts

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,
);
}