aere-contracts/contracts/passkey/WebAuthn.sol
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

135 lines
6.7 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// OpenZeppelin v4.9 ships Base64.encode (standard) but not encodeURL — inline the URL variant.
/// @title WebAuthn — secp256r1 WebAuthn assertion verifier for AERE
/// @notice Precompile-only verification using Fusaka's RIP-7951 at address 0x100.
/// AERE chain 2800 activated Fusaka at unix 1780220351 (block 2,106,606) so the
/// precompile is unconditionally present from there onward — no FreshCryptoLib
/// fallback needed.
///
/// @dev Adapted from Coinbase Smart Wallet's WebAuthn library (base-org/webauthn-sol),
/// which adapted from Daimo's p256-verifier. Verification logic is identical;
/// we strip the FCL_ecdsa fallback path because the precompile is guaranteed on AERE.
///
/// Audited reference: https://github.com/base-org/webauthn-sol (Coinbase, 2024)
library WebAuthn {
struct WebAuthnAuth {
/// @dev Authenticator data — at minimum 37 bytes: rpIdHash(32) + flags(1) + signCount(4).
bytes authenticatorData;
/// @dev Client data JSON — the browser's WebAuthn response, contains the challenge.
string clientDataJSON;
/// @dev Index in clientDataJSON where "challenge":"..." begins.
uint256 challengeIndex;
/// @dev Index in clientDataJSON where "type":"..." begins.
uint256 typeIndex;
/// @dev secp256r1 signature r component.
uint256 r;
/// @dev secp256r1 signature s component.
uint256 s;
}
/// @dev secp256r1 curve order / 2 — used to guard against signature malleability.
/// n = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
/// n/2 below.
uint256 private constant P256_N_DIV_2 =
0x7fffffff80000000ffffffffffffffffde737d56d38bcf4279dce5617e3192a8;
/// @dev RIP-7951 secp256r1 verifier precompile address (Fusaka).
address private constant VERIFIER = address(0x100);
/// @dev Authenticator data flag bits per WebAuthn spec.
bytes1 private constant AUTH_FLAG_UP = 0x01; // User Present
bytes1 private constant AUTH_FLAG_UV = 0x04; // User Verified
/// @dev Expected "type":"webauthn.get" hash (21 bytes verbatim).
bytes32 private constant EXPECTED_TYPE_HASH = keccak256('"type":"webauthn.get"');
/// @notice Verify a WebAuthn assertion signature.
/// @param challenge The 32-byte challenge that was passed to navigator.credentials.get().
/// Must match the base64url-encoded value in clientDataJSON.
/// @param requireUV If true, require the User Verified flag to be set (Touch ID / Face ID
/// counts as UV; a simple "present" press of a YubiKey does not).
/// @param auth Parsed WebAuthn assertion from the browser.
/// @param x secp256r1 public key x-coordinate.
/// @param y secp256r1 public key y-coordinate.
/// @return ok True iff the assertion is valid.
function verify(
bytes memory challenge,
bool requireUV,
WebAuthnAuth memory auth,
uint256 x,
uint256 y
) internal view returns (bool ok) {
// 1. Malleability guard.
if (auth.s > P256_N_DIV_2) return false;
// 2. Verify clientDataJSON contains "type":"webauthn.get" at typeIndex.
bytes memory cd = bytes(auth.clientDataJSON);
if (auth.typeIndex + 21 > cd.length) return false;
bytes memory typeSlice = new bytes(21);
for (uint256 i = 0; i < 21; i++) typeSlice[i] = cd[auth.typeIndex + i];
if (keccak256(typeSlice) != EXPECTED_TYPE_HASH) return false;
// 3. Verify clientDataJSON contains the expected challenge at challengeIndex.
bytes memory expectedChallenge = bytes(
string.concat('"challenge":"', _base64url(challenge), '"')
);
uint256 elen = expectedChallenge.length;
if (auth.challengeIndex + elen > cd.length) return false;
bytes memory actualChallenge = new bytes(elen);
for (uint256 i = 0; i < elen; i++) actualChallenge[i] = cd[auth.challengeIndex + i];
if (keccak256(actualChallenge) != keccak256(expectedChallenge)) return false;
// 4. Verify authenticatorData flags.
if (auth.authenticatorData.length < 33) return false;
if (auth.authenticatorData[32] & AUTH_FLAG_UP != AUTH_FLAG_UP) return false;
if (requireUV && (auth.authenticatorData[32] & AUTH_FLAG_UV) != AUTH_FLAG_UV) return false;
// 5. Compute the message hash that was signed: sha256(authData || sha256(clientDataJSON)).
bytes32 clientDataHash = sha256(bytes(auth.clientDataJSON));
bytes32 msgHash = sha256(abi.encodePacked(auth.authenticatorData, clientDataHash));
// 6. Call the RIP-7951 precompile at 0x100.
// Input: msgHash (32) || r (32) || s (32) || x (32) || y (32) — 160 bytes total.
// Output: 32 bytes — 0x...01 if valid, empty if invalid (or 0x...00).
bytes memory args = abi.encode(msgHash, auth.r, auth.s, x, y);
(bool callOk, bytes memory ret) = VERIFIER.staticcall(args);
if (!callOk || ret.length == 0) return false;
return abi.decode(ret, (uint256)) == 1;
}
/// @dev Base64-URL encode (no padding, '-_' alphabet) — for the WebAuthn challenge.
/// Standard library implementation; gas-light because typical input is 32 bytes.
function _base64url(bytes memory data) private pure returns (string memory) {
if (data.length == 0) return "";
bytes memory table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
uint256 dataLen = data.length;
// base64url length without padding = ceil(dataLen * 4 / 3)
uint256 encodedLen = (dataLen * 4 + 2) / 3;
bytes memory result = new bytes(encodedLen);
uint256 j;
uint256 i;
for (i = 0; i + 3 <= dataLen; i += 3) {
uint256 v = (uint256(uint8(data[i])) << 16) | (uint256(uint8(data[i+1])) << 8) | uint256(uint8(data[i+2]));
result[j++] = table[(v >> 18) & 0x3f];
result[j++] = table[(v >> 12) & 0x3f];
result[j++] = table[(v >> 6) & 0x3f];
result[j++] = table[v & 0x3f];
}
uint256 rem = dataLen - i;
if (rem == 1) {
uint256 v = uint256(uint8(data[i])) << 16;
result[j++] = table[(v >> 18) & 0x3f];
result[j++] = table[(v >> 12) & 0x3f];
} else if (rem == 2) {
uint256 v = (uint256(uint8(data[i])) << 16) | (uint256(uint8(data[i+1])) << 8);
result[j++] = table[(v >> 18) & 0x3f];
result[j++] = table[(v >> 12) & 0x3f];
result[j++] = table[(v >> 6) & 0x3f];
}
return string(result);
}
}