diff --git a/.changeset/angry-waves-film.md b/.changeset/angry-waves-film.md new file mode 100644 index 00000000000..aa316f9551e --- /dev/null +++ b/.changeset/angry-waves-film.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`WebAuthn`: Add a library for verifying WebAuthn Authentication Assertions. diff --git a/.changeset/petite-seas-shake.md b/.changeset/petite-seas-shake.md new file mode 100644 index 00000000000..d3db0a3273b --- /dev/null +++ b/.changeset/petite-seas-shake.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`SignerWebAuthn`: Add an abstract signer that verifies WebAuthn signatures, with a P256 fallback. diff --git a/.changeset/tender-dolls-nail.md b/.changeset/tender-dolls-nail.md new file mode 100644 index 00000000000..328ae8fb304 --- /dev/null +++ b/.changeset/tender-dolls-nail.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7913WebAuthnVerifier`: Add an ERC-7913 verifier that verifies WebAuthn Authentication Assertions for P256 identities. diff --git a/contracts/mocks/account/AccountMock.sol b/contracts/mocks/account/AccountMock.sol index 2bca448748f..6a8ae7e750b 100644 --- a/contracts/mocks/account/AccountMock.sol +++ b/contracts/mocks/account/AccountMock.sol @@ -16,6 +16,7 @@ import {AbstractSigner} from "../../utils/cryptography/signers/AbstractSigner.so import {SignerECDSA} from "../../utils/cryptography/signers/SignerECDSA.sol"; import {SignerP256} from "../../utils/cryptography/signers/SignerP256.sol"; import {SignerRSA} from "../../utils/cryptography/signers/SignerRSA.sol"; +import {SignerWebAuthn} from "../../utils/cryptography/signers/SignerWebAuthn.sol"; import {SignerERC7702} from "../../utils/cryptography/signers/SignerERC7702.sol"; import {SignerERC7913} from "../../utils/cryptography/signers/SignerERC7913.sol"; import {MultiSignerERC7913} from "../../utils/cryptography/signers/MultiSignerERC7913.sol"; @@ -70,6 +71,17 @@ abstract contract AccountRSAMock is Account, SignerRSA, ERC7739, ERC7821, ERC721 } } +abstract contract AccountWebAuthnMock is Account, SignerWebAuthn, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { + /// @inheritdoc ERC7821 + function _erc7821AuthorizedExecutor( + address caller, + bytes32 mode, + bytes calldata executionData + ) internal view virtual override returns (bool) { + return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData); + } +} + abstract contract AccountERC7702Mock is Account, SignerERC7702, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { /// @inheritdoc ERC7821 function _erc7821AuthorizedExecutor( diff --git a/contracts/utils/cryptography/README.adoc b/contracts/utils/cryptography/README.adoc index 37a982448ec..6c222d7c6ba 100644 --- a/contracts/utils/cryptography/README.adoc +++ b/contracts/utils/cryptography/README.adoc @@ -13,12 +13,14 @@ A collection of contracts and libraries that implement various signature validat * {MerkleProof}: Functions for verifying https://en.wikipedia.org/wiki/Merkle_tree[Merkle Tree] proofs. * {EIP712}: Contract with functions to allow processing signed typed structure data according to https://eips.ethereum.org/EIPS/eip-712[EIP-712]. * {ERC7739Utils}: Utilities library that implements a defensive rehashing mechanism to prevent replayability of smart contract signatures based on ERC-7739. + * {WebAuthn}: Library for verifying WebAuthn Authentication Assertions. * {AbstractSigner}: Abstract contract for internal signature validation in smart contracts. * {ERC7739}: An abstract contract to validate signatures following the rehashing scheme from {ERC7739Utils}. * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. * {SignerERC7702}: Implementation of {AbstractSigner} that validates signatures using the contract's own address as the signer, useful for delegated accounts following EIP-7702. + * {SignerWebAuthn}: Implementation of {SignerP256} that supports WebAuthn * {SignerERC7913}, {MultiSignerERC7913}, {MultiSignerERC7913Weighted}: Implementations of {AbstractSigner} that validate signatures based on ERC-7913. Including a simple and weighted multisignature scheme. - * {ERC7913P256Verifier}, {ERC7913RSAVerifier}: Ready to use ERC-7913 signature verifiers for P256 and RSA keys. + * {ERC7913P256Verifier}, {ERC7913RSAVerifier}, {ERC7913WebAuthnVerifier}: Ready to use ERC-7913 signature verifiers for P256, RSA keys and WebAuthn. == Utils @@ -40,6 +42,8 @@ A collection of contracts and libraries that implement various signature validat {{ERC7739Utils}} +{{WebAuthn}} + == Abstract Signers {{AbstractSigner}} @@ -65,3 +69,5 @@ A collection of contracts and libraries that implement various signature validat {{ERC7913P256Verifier}} {{ERC7913RSAVerifier}} + +{{ERC7913WebAuthnVerifier}} diff --git a/contracts/utils/cryptography/SignatureChecker.sol b/contracts/utils/cryptography/SignatureChecker.sol index 340f0be9eb8..ebf6f92cf7e 100644 --- a/contracts/utils/cryptography/SignatureChecker.sol +++ b/contracts/utils/cryptography/SignatureChecker.sol @@ -6,7 +6,7 @@ pragma solidity ^0.8.24; import {ECDSA} from "./ECDSA.sol"; import {IERC1271} from "../../interfaces/IERC1271.sol"; import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol"; -import {Bytes} from "../../utils/Bytes.sol"; +import {Bytes} from "../Bytes.sol"; /** * @dev Signature verification helper that can be used instead of `ECDSA.recover` to seamlessly support: diff --git a/contracts/utils/cryptography/WebAuthn.sol b/contracts/utils/cryptography/WebAuthn.sol new file mode 100644 index 00000000000..2a2e5ea2fc3 --- /dev/null +++ b/contracts/utils/cryptography/WebAuthn.sol @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {P256} from "./P256.sol"; +import {Base64} from "../Base64.sol"; +import {Bytes} from "../Bytes.sol"; +import {Strings} from "../Strings.sol"; + +/** + * @dev Library for verifying WebAuthn Authentication Assertions. + * + * WebAuthn enables strong authentication for smart contracts using + * https://docs.openzeppelin.com/contracts/5.x/api/utils#P256[P256] + * as an alternative to traditional secp256k1 ECDSA signatures. This library verifies + * signatures generated during WebAuthn authentication ceremonies as specified in the + * https://www.w3.org/TR/webauthn-2/[WebAuthn Level 2 standard]. + * + * For blockchain use cases, the following WebAuthn validations are intentionally omitted: + * + * * Origin validation: Origin verification in `clientDataJSON` is omitted as blockchain + * contexts rely on authenticator and dapp frontend enforcement. Standard authenticators + * implement proper origin validation. + * * RP ID hash validation: Verification of `rpIdHash` in authenticatorData against expected + * RP ID hash is omitted. This is typically handled by platform-level security measures. + * Including an expiry timestamp in signed data is recommended for enhanced security. + * * Signature counter: Verification of signature counter increments is omitted. While + * useful for detecting credential cloning, on-chain operations typically include nonce + * protection, making this check redundant. + * * Extension outputs: Extension output value verification is omitted as these are not + * essential for core authentication security in blockchain applications. + * * Attestation: Attestation object verification is omitted as this implementation + * focuses on authentication (`webauthn.get`) rather than registration ceremonies. + * + * Inspired by: + * + * * https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol[daimo-eth implementation] + * * https://github.com/base/webauthn-sol/blob/main/src/WebAuthn.sol[base implementation] + */ +library WebAuthn { + struct WebAuthnAuth { + bytes32 r; /// The r value of secp256r1 signature + bytes32 s; /// The s value of secp256r1 signature + uint256 challengeIndex; /// The index at which "challenge":"..." occurs in `clientDataJSON`. + uint256 typeIndex; /// The index at which "type":"..." occurs in `clientDataJSON`. + /// The WebAuthn authenticator data. + /// https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-authenticatordata + bytes authenticatorData; + /// The WebAuthn client data JSON. + /// https://www.w3.org/TR/webauthn-2/#dom-authenticatorresponse-clientdatajson + string clientDataJSON; + } + + /// @dev Bit 0 of the authenticator data flags: "User Present" bit. + bytes1 internal constant AUTH_DATA_FLAGS_UP = 0x01; + /// @dev Bit 2 of the authenticator data flags: "User Verified" bit. + bytes1 internal constant AUTH_DATA_FLAGS_UV = 0x04; + /// @dev Bit 3 of the authenticator data flags: "Backup Eligibility" bit. + bytes1 internal constant AUTH_DATA_FLAGS_BE = 0x08; + /// @dev Bit 4 of the authenticator data flags: "Backup State" bit. + bytes1 internal constant AUTH_DATA_FLAGS_BS = 0x10; + + /** + * @dev Performs standard verification of a WebAuthn Authentication Assertion. + */ + function verify( + bytes memory challenge, + WebAuthnAuth memory auth, + bytes32 qx, + bytes32 qy + ) internal view returns (bool) { + return verify(challenge, auth, qx, qy, true); + } + + /** + * @dev Performs verification of a WebAuthn Authentication Assertion. This variants allow the caller to select + * whether of not to require the UV flag (step 17). + * + * Verifies: + * + * 1. Type is "webauthn.get" (see {_validateExpectedTypeHash}) + * 2. Challenge matches the expected value (see {_validateChallenge}) + * 3. Cryptographic signature is valid for the given public key + * 4. confirming physical user presence during authentication + * 5. (if `requireUV` is true) confirming stronger user authentication (biometrics/PIN) + * 6. Backup Eligibility (`BE`) and Backup State (BS) bits relationship is valid + */ + function verify( + bytes memory challenge, + WebAuthnAuth memory auth, + bytes32 qx, + bytes32 qy, + bool requireUV + ) internal view returns (bool) { + // Verify authenticator data has sufficient length (37 bytes minimum): + // - 32 bytes for rpIdHash + // - 1 byte for flags + // - 4 bytes for signature counter + return + auth.authenticatorData.length > 36 && + _validateExpectedTypeHash(auth.clientDataJSON, auth.typeIndex) && // 11 + _validateChallenge(auth.clientDataJSON, auth.challengeIndex, challenge) && // 12 + _validateUserPresentBitSet(auth.authenticatorData[32]) && // 16 + (!requireUV || _validateUserVerifiedBitSet(auth.authenticatorData[32])) && // 17 + _validateBackupEligibilityAndState(auth.authenticatorData[32]) && // Consistency check + // P256.verify handles signature malleability internally + P256.verify( + sha256( + abi.encodePacked( + auth.authenticatorData, + sha256(bytes(auth.clientDataJSON)) // 19 + ) + ), + auth.r, + auth.s, + qx, + qy + ); // 20 + } + + /** + * @dev Validates that the https://www.w3.org/TR/webauthn-2/#type[Type] field in the client data JSON is set to + * "webauthn.get". + * + * Step 11 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. + */ + function _validateExpectedTypeHash( + string memory clientDataJSON, + uint256 typeIndex + ) private pure returns (bool success) { + assembly ("memory-safe") { + success := and( + // clientDataJson.length >= typeIndex + 21 + gt(mload(clientDataJSON), add(typeIndex, 20)), + eq( + // get 32 bytes starting at index typexIndex in clientDataJSON, and keep the leftmost 21 bytes + and(mload(add(add(clientDataJSON, 0x20), typeIndex)), shl(88, not(0))), + // solhint-disable-next-line quotes + '"type":"webauthn.get"' + ) + ) + } + } + + /** + * @dev Validates that the challenge in the client data JSON matches the `expectedChallenge`. + * + * Step 12 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. + */ + function _validateChallenge( + string memory clientDataJSON, + uint256 challengeIndex, + bytes memory challenge + ) private pure returns (bool) { + // solhint-disable-next-line quotes + string memory expectedChallenge = string.concat('"challenge":"', Base64.encodeURL(challenge), '"'); + string memory actualChallenge = string( + Bytes.slice(bytes(clientDataJSON), challengeIndex, challengeIndex + bytes(expectedChallenge).length) + ); + + return Strings.equal(actualChallenge, expectedChallenge); + } + + /** + * @dev Validates that the https://www.w3.org/TR/webauthn-2/#up[User Present (UP)] bit is set. + * + * Step 16 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. + * + * NOTE: Required by WebAuthn spec but may be skipped for platform authenticators + * (Touch ID, Windows Hello) in controlled environments. Enforce for public-facing apps. + */ + function _validateUserPresentBitSet(bytes1 flags) private pure returns (bool) { + return (flags & AUTH_DATA_FLAGS_UP) == AUTH_DATA_FLAGS_UP; + } + + /** + * @dev Validates that the https://www.w3.org/TR/webauthn-2/#uv[User Verified (UV)] bit is set. + * + * Step 17 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. + * + * The UV bit indicates whether the user was verified using a stronger identification method + * (biometrics, PIN, password). While optional, requiring UV=1 is recommended for: + * + * * High-value transactions and sensitive operations + * * Account recovery and critical settings changes + * * Privileged operations + * + * NOTE: For routine operations or when using hardware authenticators without verification capabilities, + * `UV=0` may be acceptable. The choice of whether to require UV represents a security vs. usability + * tradeoff - for blockchain applications handling valuable assets, requiring UV is generally safer. + */ + function _validateUserVerifiedBitSet(bytes1 flags) private pure returns (bool) { + return (flags & AUTH_DATA_FLAGS_UV) == AUTH_DATA_FLAGS_UV; + } + + /** + * @dev Validates the relationship between Backup Eligibility (`BE`) and Backup State (`BS`) bits + * according to the WebAuthn specification. + * + * The function enforces that if a credential is backed up (`BS=1`), it must also be eligible + * for backup (`BE=1`). This prevents unauthorized credential backup and ensures compliance + * with the WebAuthn spec. + * + * Returns true in these valid states: + * + * * `BE=1`, `BS=0`: Credential is eligible but not backed up + * * `BE=1`, `BS=1`: Credential is eligible and backed up + * * `BE=0`, `BS=0`: Credential is not eligible and not backed up + * + * Returns false only when `BE=0` and `BS=1`, which is an invalid state indicating + * a credential that's backed up but not eligible for backup. + * + * NOTE: While the WebAuthn spec defines this relationship between `BE` and `BS` bits, + * validating it is not explicitly required as part of the core verification procedure. + * Some implementations may choose to skip this check for broader authenticator + * compatibility or when the application's threat model doesn't consider credential + * syncing a major risk. + */ + function _validateBackupEligibilityAndState(bytes1 flags) private pure returns (bool) { + return (flags & AUTH_DATA_FLAGS_BE) == AUTH_DATA_FLAGS_BE || (flags & AUTH_DATA_FLAGS_BS) == 0; + } + + /** + * @dev Verifies that calldata bytes (`input`) represents a valid `WebAuthnAuth` object. If encoding is valid, + * returns true and the calldata view at the object. Otherwise, returns false and an invalid calldata object. + * + * NOTE: The returned `auth` object should not be accessed if `success` is false. Trying to access the data may + * cause revert/panic. + */ + function tryDecodeAuth(bytes calldata input) internal pure returns (bool success, WebAuthnAuth calldata auth) { + assembly ("memory-safe") { + auth := input.offset + } + + // Minimum length to hold 6 objects (32 bytes each) + if (input.length < 0xC0) return (false, auth); + + // Get offset of non-value-type elements relative to the input buffer + uint256 authenticatorDataOffset = uint256(bytes32(input[0x80:])); + uint256 clientDataJSONOffset = uint256(bytes32(input[0xa0:])); + + // The elements length (at the offset) should be 32 bytes long. We check that this is within the + // buffer bounds. Since we know input.length is at least 32, we can subtract with no overflow risk. + if (input.length - 0x20 < authenticatorDataOffset || input.length - 0x20 < clientDataJSONOffset) + return (false, auth); + + // Get the lengths. offset + 32 is bounded by input.length so it does not overflow. + uint256 authenticatorDataLength = uint256(bytes32(input[authenticatorDataOffset:])); + uint256 clientDataJSONLength = uint256(bytes32(input[clientDataJSONOffset:])); + + // Check that the input buffer is long enough to store the non-value-type elements + // Since we know input.length is at least xxxOffset + 32, we can subtract with no overflow risk. + if ( + input.length - authenticatorDataOffset - 0x20 < authenticatorDataLength || + input.length - clientDataJSONOffset - 0x20 < clientDataJSONLength + ) return (false, auth); + + return (true, auth); + } +} diff --git a/contracts/utils/cryptography/signers/SignerWebAuthn.sol b/contracts/utils/cryptography/signers/SignerWebAuthn.sol new file mode 100644 index 00000000000..8eb7dd237fd --- /dev/null +++ b/contracts/utils/cryptography/signers/SignerWebAuthn.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SignerP256} from "./SignerP256.sol"; +import {WebAuthn} from "../WebAuthn.sol"; + +/** + * @dev Implementation of {SignerP256} that supports WebAuthn authentication assertions. + * + * This contract enables signature validation using WebAuthn authentication assertions, + * leveraging the P256 public key stored in the contract. It allows for both WebAuthn + * and raw P256 signature validation, providing compatibility with both signature types. + * + * The signature is expected to be an abi-encoded {WebAuthn-WebAuthnAuth} struct. + * + * Example usage: + * + * ```solidity + * contract MyAccountWebAuthn is Account, SignerWebAuthn, Initializable { + * function initialize(bytes32 qx, bytes32 qy) public initializer { + * _setSigner(qx, qy); + * } + * } + * ``` + * + * IMPORTANT: Failing to call {_setSigner} either during construction (if used standalone) + * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable. + */ +abstract contract SignerWebAuthn is SignerP256 { + /** + * @dev Validates a raw signature using the WebAuthn authentication assertion. + * + * In case the signature can't be validated, it falls back to the + * {SignerP256-_rawSignatureValidation} method for raw P256 signature validation by passing + * the raw `r` and `s` values from the signature. + */ + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + (bytes32 qx, bytes32 qy) = signer(); + (bool decodeSuccess, WebAuthn.WebAuthnAuth calldata auth) = WebAuthn.tryDecodeAuth(signature); + + return + decodeSuccess + ? WebAuthn.verify(abi.encodePacked(hash), auth, qx, qy) + : super._rawSignatureValidation(hash, signature); + } +} diff --git a/contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sol b/contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sol index 60091c9768f..bf3dc3a73a5 100644 --- a/contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sol +++ b/contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.20; -import {P256} from "../../../utils/cryptography/P256.sol"; +import {P256} from "../P256.sol"; import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol"; /** diff --git a/contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sol b/contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sol index 07f58c898e6..7d47c63ff55 100644 --- a/contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sol +++ b/contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.20; -import {RSA} from "../../../utils/cryptography/RSA.sol"; +import {RSA} from "../RSA.sol"; import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol"; /** diff --git a/contracts/utils/cryptography/verifiers/ERC7913WebAuthnVerifier.sol b/contracts/utils/cryptography/verifiers/ERC7913WebAuthnVerifier.sol new file mode 100644 index 00000000000..a5fb5b829b9 --- /dev/null +++ b/contracts/utils/cryptography/verifiers/ERC7913WebAuthnVerifier.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {WebAuthn} from "../WebAuthn.sol"; +import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol"; + +/** + * @dev ERC-7913 signature verifier that supports WebAuthn authentication assertions. + * + * This verifier enables the validation of WebAuthn signatures using P256 public keys. + * The key is expected to be a 64-byte concatenation of the P256 public key coordinates (qx || qy). + * The signature is expected to be an abi-encoded {WebAuthn-WebAuthnAuth} struct. + * + * Uses {WebAuthn-verifyMinimal} for signature verification, which performs the essential + * WebAuthn checks: type validation, challenge matching, and cryptographic signature verification. + * + * NOTE: Wallets that may require default P256 validation may install a P256 verifier separately. + */ +contract ERC7913WebAuthnVerifier is IERC7913SignatureVerifier { + /// @inheritdoc IERC7913SignatureVerifier + function verify(bytes calldata key, bytes32 hash, bytes calldata signature) public view virtual returns (bytes4) { + (bool decodeSuccess, WebAuthn.WebAuthnAuth calldata auth) = WebAuthn.tryDecodeAuth(signature); + + return + decodeSuccess && + key.length == 0x40 && + WebAuthn.verify(abi.encodePacked(hash), auth, bytes32(key[0x00:0x20]), bytes32(key[0x20:0x40])) + ? IERC7913SignatureVerifier.verify.selector + : bytes4(0xFFFFFFFF); + } +} diff --git a/test/account/AccountERC7913.test.js b/test/account/AccountERC7913.test.js index 297a5ebf5d5..3554ba5e459 100644 --- a/test/account/AccountERC7913.test.js +++ b/test/account/AccountERC7913.test.js @@ -3,7 +3,7 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { getDomain } = require('../helpers/eip712'); const { ERC4337Helper } = require('../helpers/erc4337'); -const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../helpers/signers'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey, WebAuthnSigningKey } = require('../helpers/signers'); const { PackedUserOperation } = require('../helpers/eip712-types'); const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior'); @@ -14,6 +14,7 @@ const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior'); const signerECDSA = ethers.Wallet.createRandom(); const signerP256 = new NonNativeSigner(P256SigningKey.random()); const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); +const signerWebAuthn = new NonNativeSigner(WebAuthnSigningKey.random()); // Minimal fixture common to the different signer verifiers async function fixture() { @@ -24,6 +25,7 @@ async function fixture() { // ERC-7913 verifiers const verifierP256 = await ethers.deployContract('ERC7913P256Verifier'); const verifierRSA = await ethers.deployContract('ERC7913RSAVerifier'); + const verifierWebAuthn = await ethers.deployContract('ERC7913WebAuthnVerifier'); // ERC-4337 env const helper = new ERC4337Helper(); @@ -47,6 +49,7 @@ async function fixture() { helper, verifierP256, verifierRSA, + verifierWebAuthn, domain, target, beneficiary, @@ -113,4 +116,23 @@ describe('AccountERC7913', function () { shouldBehaveLikeERC1271({ erc7739: true }); shouldBehaveLikeERC7821(); }); + + // Using WebAuthn key with an ERC-7913 verifier + describe('WebAuthn key', function () { + beforeEach(async function () { + this.signer = signerWebAuthn; + this.mock = await this.makeMock( + ethers.concat([ + this.verifierWebAuthn.target, + this.signer.signingKey.publicKey.qx, + this.signer.signingKey.publicKey.qy, + ]), + ); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); }); diff --git a/test/account/AccountWebAuthn.test.js b/test/account/AccountWebAuthn.test.js new file mode 100644 index 00000000000..ea1d5564d28 --- /dev/null +++ b/test/account/AccountWebAuthn.test.js @@ -0,0 +1,88 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner, P256SigningKey, WebAuthnSigningKey } = require('../helpers/signers'); +const { PackedUserOperation } = require('../helpers/eip712-types'); + +const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior'); +const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior'); +const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior'); + +const webAuthnSigner = new NonNativeSigner(WebAuthnSigningKey.random()); +const p256Signer = new NonNativeSigner(P256SigningKey.random()); + +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-4337 account + const helper = new ERC4337Helper(); + + const webAuthnMock = await helper.newAccount('$AccountWebAuthnMock', [ + webAuthnSigner.signingKey.publicKey.qx, + webAuthnSigner.signingKey.publicKey.qy, + 'AccountWebAuthn', + '1', + ]); + + const p256Mock = await helper.newAccount('$AccountWebAuthnMock', [ + p256Signer.signingKey.publicKey.qx, + p256Signer.signingKey.publicKey.qy, + 'AccountWebAuthn', + '1', + ]); + + // ERC-4337 Entrypoint domain + const entrypointDomain = await getDomain(entrypoint.v08); + + // domain cannot be fetched using getDomain(mock) before the mock is deployed + const domain = { + name: 'AccountWebAuthn', + version: '1', + chainId: entrypointDomain.chainId, + }; + + // Sign userOp with the active signer + const signUserOp = function (userOp) { + return this.signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + }; + + return { helper, domain, webAuthnMock, p256Mock, target, beneficiary, other, signUserOp }; +} + +describe('AccountWebAuthn', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('WebAuthn Assertions', function () { + beforeEach(async function () { + this.signer = webAuthnSigner; + this.mock = this.webAuthnMock; + this.domain.verifyingContract = this.mock.address; + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe('as regular P256 validator', function () { + beforeEach(async function () { + this.signer = p256Signer; + this.mock = this.p256Mock; + this.domain.verifyingContract = this.mock.address; + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); +}); diff --git a/test/helpers/signers.js b/test/helpers/signers.js index baa8b066627..b71417a3f09 100644 --- a/test/helpers/signers.js +++ b/test/helpers/signers.js @@ -1,31 +1,14 @@ -const { - AbiCoder, - AbstractSigner, - Signature, - TypedDataEncoder, - assert, - assertArgument, - concat, - dataLength, - decodeBase64, - getBytes, - getBytesCopy, - hashMessage, - hexlify, - sha256, - toBeHex, - keccak256, -} = require('ethers'); +const { ethers } = require('ethers'); const { secp256r1 } = require('@noble/curves/p256'); const { generateKeyPairSync, privateEncrypt } = require('crypto'); // Lightweight version of BaseWallet -class NonNativeSigner extends AbstractSigner { +class NonNativeSigner extends ethers.AbstractSigner { #signingKey; constructor(privateKey, provider) { super(provider); - assertArgument( + ethers.assertArgument( privateKey && typeof privateKey.sign === 'function', 'invalid private key', 'privateKey', @@ -54,7 +37,7 @@ class NonNativeSigner extends AbstractSigner { } async signMessage(message /*: string | Uint8Array*/) /*: Promise*/ { - return this.signingKey.sign(hashMessage(message)).serialized; + return this.signingKey.sign(ethers.hashMessage(message)).serialized; } async signTypedData( @@ -63,17 +46,17 @@ class NonNativeSigner extends AbstractSigner { value /*: Record*/, ) /*: Promise*/ { // Populate any ENS names - const populated = await TypedDataEncoder.resolveNames(domain, types, value, async name => { - assert(this.provider != null, 'cannot resolve ENS names without a provider', 'UNSUPPORTED_OPERATION', { + const populated = await ethers.TypedDataEncoder.resolveNames(domain, types, value, async name => { + ethers.assert(this.provider != null, 'cannot resolve ENS names without a provider', 'UNSUPPORTED_OPERATION', { operation: 'resolveName', info: { name }, }); const address = await this.provider.resolveName(name); - assert(address != null, 'unconfigured ENS name', 'UNCONFIGURED_NAME', { value: name }); + ethers.assert(address != null, 'unconfigured ENS name', 'UNCONFIGURED_NAME', { value: name }); return address; }); - return this.signingKey.sign(TypedDataEncoder.hash(populated.domain, types, populated.value)).serialized; + return this.signingKey.sign(ethers.TypedDataEncoder.hash(populated.domain, types, populated.value)).serialized; } } @@ -81,7 +64,7 @@ class P256SigningKey { #privateKey; constructor(privateKey) { - this.#privateKey = getBytes(privateKey); + this.#privateKey = ethers.getBytes(privateKey); } static random() { @@ -89,20 +72,27 @@ class P256SigningKey { } get privateKey() { - return hexlify(this.#privateKey); + return ethers.hexlify(this.#privateKey); } get publicKey() { const publicKeyBytes = secp256r1.getPublicKey(this.#privateKey, false); - return { qx: hexlify(publicKeyBytes.slice(0x01, 0x21)), qy: hexlify(publicKeyBytes.slice(0x21, 0x41)) }; + return { + qx: ethers.hexlify(publicKeyBytes.slice(0x01, 0x21)), + qy: ethers.hexlify(publicKeyBytes.slice(0x21, 0x41)), + }; } - sign(digest /*: BytesLike*/) /*: Signature*/ { - assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + sign(digest /*: BytesLike*/) /*: ethers.Signature*/ { + ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest); - const sig = secp256r1.sign(getBytesCopy(digest), getBytesCopy(this.#privateKey), { lowS: true }); + const sig = secp256r1.sign(ethers.getBytesCopy(digest), ethers.getBytesCopy(this.#privateKey), { lowS: true }); - return Signature.from({ r: toBeHex(sig.r, 32), s: toBeHex(sig.s, 32), v: sig.recovery ? 0x1c : 0x1b }); + return ethers.Signature.from({ + r: ethers.toBeHex(sig.r, 32), + s: ethers.toBeHex(sig.s, 32), + v: sig.recovery ? 0x1c : 0x1b, + }); } } @@ -113,7 +103,7 @@ class RSASigningKey { constructor(keyPair) { const jwk = keyPair.publicKey.export({ format: 'jwk' }); this.#privateKey = keyPair.privateKey; - this.#publicKey = { e: decodeBase64(jwk.e), n: decodeBase64(jwk.n) }; + this.#publicKey = { e: ethers.decodeBase64(jwk.e), n: ethers.decodeBase64(jwk.n) }; } static random(modulusLength = 2048) { @@ -121,28 +111,67 @@ class RSASigningKey { } get privateKey() { - return hexlify(this.#privateKey); + return ethers.hexlify(this.#privateKey); } get publicKey() { - return { e: hexlify(this.#publicKey.e), n: hexlify(this.#publicKey.n) }; + return { e: ethers.hexlify(this.#publicKey.e), n: ethers.hexlify(this.#publicKey.n) }; } - sign(digest /*: BytesLike*/) /*: Signature*/ { - assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + sign(digest /*: BytesLike*/) /*: ethers.Signature*/ { + ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest); // SHA256 OID = 608648016503040201 (9 bytes) | NULL = 0500 (2 bytes) (explicit) | OCTET_STRING length (0x20) = 0420 (2 bytes) return { - serialized: hexlify( - privateEncrypt(this.#privateKey, getBytes(concat(['0x3031300d060960864801650304020105000420', digest]))), + serialized: ethers.hexlify( + privateEncrypt( + this.#privateKey, + ethers.getBytes(ethers.concat(['0x3031300d060960864801650304020105000420', digest])), + ), ), }; } } class RSASHA256SigningKey extends RSASigningKey { - sign(digest /*: BytesLike*/) /*: Signature*/ { - assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); - return super.sign(sha256(getBytes(digest))); + sign(digest /*: BytesLike*/) /*: ethers.Signature*/ { + ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + return super.sign(ethers.sha256(ethers.getBytes(digest))); + } +} + +class WebAuthnSigningKey extends P256SigningKey { + sign(digest /*: BytesLike*/) /*: { serialized: string } */ { + ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + + const clientDataJSON = JSON.stringify({ + type: 'webauthn.get', + challenge: ethers.encodeBase64(digest).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''), + }); + + // Flags 0x05 = AUTH_DATA_FLAGS_UP | AUTH_DATA_FLAGS_UV + const authenticatorData = ethers.solidityPacked( + ['bytes32', 'bytes1', 'bytes4'], + [ethers.ZeroHash, '0x05', '0x00000000'], + ); + + // Regular P256 signature + const { r, s } = super.sign( + ethers.sha256(ethers.concat([authenticatorData, ethers.sha256(ethers.toUtf8Bytes(clientDataJSON))])), + ); + + const serialized = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'bytes32', 'uint256', 'uint256', 'bytes', 'string'], + [ + r, + s, + clientDataJSON.indexOf('"challenge"'), + clientDataJSON.indexOf('"type"'), + authenticatorData, + clientDataJSON, + ], + ); + + return { serialized }; } } @@ -151,7 +180,7 @@ class MultiERC7913SigningKey { #signers; constructor(signers) { - assertArgument( + ethers.assertArgument( Array.isArray(signers) && signers.length > 0, 'signers must be a non-empty array', 'signers', @@ -159,18 +188,20 @@ class MultiERC7913SigningKey { ); // Sorting is done at construction so that it doesn't have to be done in sign() - this.#signers = signers.sort((s1, s2) => keccak256(s1.bytes ?? s1.address) - keccak256(s2.bytes ?? s2.address)); + this.#signers = signers.sort( + (s1, s2) => ethers.keccak256(s1.bytes ?? s1.address) - ethers.keccak256(s2.bytes ?? s2.address), + ); } get signers() { return this.#signers; } - sign(digest /*: BytesLike*/ /*: Signature*/) { - assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + sign(digest /*: BytesLike*/ /*: ethers.Signature*/) { + ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest); return { - serialized: AbiCoder.defaultAbiCoder().encode( + serialized: ethers.AbiCoder.defaultAbiCoder().encode( ['bytes[]', 'bytes[]'], [ this.#signers.map(signer => signer.bytes ?? signer.address), @@ -181,4 +212,11 @@ class MultiERC7913SigningKey { } } -module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey, MultiERC7913SigningKey }; +module.exports = { + NonNativeSigner, + P256SigningKey, + RSASigningKey, + RSASHA256SigningKey, + WebAuthnSigningKey, + MultiERC7913SigningKey, +}; diff --git a/test/utils/cryptography/WebAuthn.t.sol b/test/utils/cryptography/WebAuthn.t.sol new file mode 100644 index 00000000000..c631820c59c --- /dev/null +++ b/test/utils/cryptography/WebAuthn.t.sol @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {P256} from "@openzeppelin/contracts/utils/cryptography/P256.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; +import {WebAuthn} from "@openzeppelin/contracts/utils/cryptography/WebAuthn.sol"; + +contract WebAuthnTest is Test { + /// forge-config: default.fuzz.runs = 512 + function testVerify(bytes memory challenge, uint256 seed) public view { + assertTrue( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP), + _encodeClientDataJSON(challenge), + false + ) + ); + } + + /// forge-config: default.fuzz.runs = 512 + function testVerifyInvalidType(bytes memory challenge, uint256 seed) public view { + assertFalse( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV), + // solhint-disable-next-line quotes + string.concat('{"type":"webauthn.create","challenge":"', Base64.encodeURL(challenge), '"}'), + false + ) + ); + } + + /// forge-config: default.fuzz.runs = 512 + function testVerifyInvalidChallenge(bytes memory challenge, uint256 seed) public view { + assertFalse( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV), + _encodeClientDataJSON(bytes("invalid_challenge")), + false + ) + ); + } + + /// forge-config: default.fuzz.runs = 512 + function testVerifyFlagsUP(bytes memory challenge, uint256 seed) public view { + // UP = false: FAIL + assertFalse( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UV), + _encodeClientDataJSON(challenge), + false + ) + ); + } + + /// forge-config: default.fuzz.runs = 512 + function testVerifyFlagsUV(bytes memory challenge, uint256 seed) public view { + // UV = false, requireUV = false: SUCCESS + assertTrue( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP), + _encodeClientDataJSON(challenge), + false + ) + ); + // UV = false, requireUV = true: FAIL + assertFalse( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP), + _encodeClientDataJSON(challenge), + true + ) + ); + // UV = true, requireUV = true: SUCCESS + assertTrue( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV), + _encodeClientDataJSON(challenge), + true + ) + ); + } + + /// forge-config: default.fuzz.runs = 512 + function testVerifyFlagsBEBS(bytes memory challenge, uint256 seed) public view { + // BS = true, BE = false: FAIL + assertFalse( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData( + WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV | WebAuthn.AUTH_DATA_FLAGS_BS + ), + _encodeClientDataJSON(challenge), + false + ) + ); + // BS = false, BE = true: SUCCESS + assertTrue( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData( + WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV | WebAuthn.AUTH_DATA_FLAGS_BE + ), + _encodeClientDataJSON(challenge), + false + ) + ); + // BS = true, BE = true: SUCCESS + assertTrue( + _runVerify( + seed, + challenge, + _encodeAuthenticatorData( + WebAuthn.AUTH_DATA_FLAGS_UP | + WebAuthn.AUTH_DATA_FLAGS_UV | + WebAuthn.AUTH_DATA_FLAGS_BE | + WebAuthn.AUTH_DATA_FLAGS_BS + ), + _encodeClientDataJSON(challenge), + false + ) + ); + } + + function _runVerify( + uint256 seed, + bytes memory challenge, + bytes memory authenticatorData, + string memory clientDataJSON, + bool requireUV + ) private view returns (bool) { + // Generate private key and get public key + uint256 privateKey = bound(seed, 1, P256.N - 1); + (uint256 x, uint256 y) = vm.publicKeyP256(privateKey); + + // Sign the message + bytes32 messageHash = sha256(abi.encodePacked(authenticatorData, sha256(bytes(clientDataJSON)))); + (bytes32 r, bytes32 s) = vm.signP256(privateKey, messageHash); + + // Verify the signature + return + WebAuthn.verify( + challenge, + WebAuthn.WebAuthnAuth({ + authenticatorData: authenticatorData, + clientDataJSON: clientDataJSON, + challengeIndex: 23, // Position of challenge in clientDataJSON + typeIndex: 1, // Position of type in clientDataJSON + r: r, + s: bytes32(Math.min(uint256(s), P256.N - uint256(s))) + }), + bytes32(x), + bytes32(y), + requireUV + ); + } + + function testTryDecodeAuthValid( + bytes32 r, + bytes32 s, + uint256 challengeIndex, + uint256 typeIndex, + bytes memory authenticatorData, + string memory clientDataJSON + ) public view { + (bool success, WebAuthn.WebAuthnAuth memory auth) = this.tryDecodeAuth( + abi.encode(r, s, challengeIndex, typeIndex, authenticatorData, clientDataJSON) + ); + assertTrue(success); + assertEq(auth.r, r); + assertEq(auth.s, s); + assertEq(auth.challengeIndex, challengeIndex); + assertEq(auth.typeIndex, typeIndex); + assertEq(auth.authenticatorData, authenticatorData); + assertEq(auth.clientDataJSON, clientDataJSON); + } + + function testTryDecodeAuthInvalid() public view { + bytes32 r = keccak256("r"); + bytes32 s = keccak256("s"); + uint256 challengeIndex = 17; + uint256 typeIndex = 1; + + // too short + assertFalse(this.tryDecodeAuthDrop(abi.encodePacked(r, s, challengeIndex, typeIndex))); + + // offset out of bound + assertFalse( + this.tryDecodeAuthDrop(abi.encodePacked(r, s, challengeIndex, typeIndex, uint256(0xc0), uint256(0))) + ); + assertFalse( + this.tryDecodeAuthDrop(abi.encodePacked(r, s, challengeIndex, typeIndex, uint256(0), uint256(0xc0))) + ); + + // minimal valid (bytes and string both length 0, at the same position) + assertTrue( + this.tryDecodeAuthDrop( + abi.encodePacked(r, s, challengeIndex, typeIndex, uint256(0xc0), uint256(0xc0), uint256(0)) + ) + ); + + // length out of bound + assertTrue( + this.tryDecodeAuthDrop( + abi.encodePacked( + r, + s, + challengeIndex, + typeIndex, + uint256(0xc0), + uint256(0xe0), + uint256(0x20), + uint256(0) + ) + ) + ); + assertFalse( + this.tryDecodeAuthDrop( + abi.encodePacked( + r, + s, + challengeIndex, + typeIndex, + uint256(0xc0), + uint256(0xe0), + uint256(0x21), + uint256(0) + ) + ) + ); + assertTrue( + this.tryDecodeAuthDrop( + abi.encodePacked( + r, + s, + challengeIndex, + typeIndex, + uint256(0xc0), + uint256(0xe0), + uint256(0), + uint256(0x00) + ) + ) + ); + assertFalse( + this.tryDecodeAuthDrop( + abi.encodePacked( + r, + s, + challengeIndex, + typeIndex, + uint256(0xc0), + uint256(0xe0), + uint256(0), + uint256(0x01) + ) + ) + ); + } + + function tryDecodeAuth( + bytes calldata encoded + ) public pure returns (bool success, WebAuthn.WebAuthnAuth calldata auth) { + (success, auth) = WebAuthn.tryDecodeAuth(encoded); + } + + function tryDecodeAuthDrop(bytes calldata encoded) public pure returns (bool success) { + (success, ) = WebAuthn.tryDecodeAuth(encoded); + } + + function _encodeAuthenticatorData(bytes1 flags) private pure returns (bytes memory) { + return abi.encodePacked(bytes32(0), flags, bytes4(0)); + } + + function _encodeClientDataJSON(bytes memory challenge) private pure returns (string memory) { + // solhint-disable-next-line quotes + return string.concat('{"type":"webauthn.get","challenge":"', Base64.encodeURL(challenge), '"}'); + } +}