diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..97022bd8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/webauthn-sol"] + path = lib/webauthn-sol + url = https://github.com/base/webauthn-sol diff --git a/contracts/external/wc-cosigner/MultiKeySigner.sol b/contracts/external/wc-cosigner/MultiKeySigner.sol new file mode 100644 index 00000000..45cc7eaa --- /dev/null +++ b/contracts/external/wc-cosigner/MultiKeySigner.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.25; + +// External Dependencies +import { ECDSA } from "solady/utils/ECDSA.sol"; + +// Local Project Files +import { ERC7579_MODULE_TYPE_STATELESS_VALIDATOR } from "../../DataTypes.sol"; +import { PasskeyHelper, WebAuthnValidatorData } from "./libs/PasskeyHelper.sol"; +import { ISessionValidator } from "contracts/interfaces/ISessionValidator.sol"; +import { SignerEncode, Signer, SignerType } from "./libs/SignerEncode.sol"; + +/// @title MultiKeySigner - Stateless Session Validator for ERC-7579 +/// @author [alphabetically] Filipp Makarov (Biconomy), rplusq (Reown) & zeroknots.eth (Rhinestone) + +/// @notice This contract is an ERC-7579 compliant stateless session validator module, +/// designed for use with the Smart Sessions framework. It enables signature validation +/// for sessions using multiple signer types, such as EOA and WebAuthn Passkeys. +/// The configuration for signers is provided via the `data` parameter in `validateSignatureWithData`, +/// making the validator stateless in its operation for each validation. +/// +/// @dev Implements `ISessionValidator`. This module is intended to be a secure +/// and verifiable component, attestable via an EIP-7484 compliant module registry. +contract MultiKeySigner is ISessionValidator { + using PasskeyHelper for *; + using SignerEncode for *; + + // --- Errors --- + + /// @notice Reverted if the provided signature array's length does not match the signers array's length. + error InvalidSignatureLength(); + /// @notice Reverted if an unsupported signer type is encountered during validation. + error InvalidSignatureType(); + /// @notice Reverted if installation data is provided, as this module does not support it. + error InstallationDataNotSupported(); + /// @notice Reverted if an unknown signer type is encountered during decoding. + error UnknownSignerType(); + + // --- External Functions --- + + /// @notice Hook called by the account when this module is installed. + /// @dev As per ERC-7579, modules can use `data` for initialization. This stateless module does not + /// support initialization data via this function. If `data` is provided, the call will revert. + /// The actual signer configuration is passed dynamically during validation. + /// @param data Arbitrary data passed by the account during installation. Must be empty for this module. + function onInstall(bytes calldata data) external { + if (data.length > 0) { + revert InstallationDataNotSupported(); + } + } + + /// @notice Hook called by the account when this module is uninstalled. + /// @dev As per ERC-7579, modules can use `data` for cleanup or other actions upon uninstallation. + /// This stateless module currently has no specific state to clean up. + function onUninstall(bytes calldata /* data */ ) external { } + + /// @notice Checks if this module conforms to a given module type ID, as per ERC-7579. + /// @dev Returns true if the `id` matches the ERC-7579 Stateless Validator Module type ID. + /// @param id The module type ID to check. + /// @return isType True if this module is of the specified type, false otherwise. + function isModuleType(uint256 id) external pure returns (bool) { + return (id == ERC7579_MODULE_TYPE_STATELESS_VALIDATOR); + } + + /// @notice Checks if the contract supports a given interface ID. + /// @dev Specifically checks for `ISessionValidator` interface support, often used in conjunction + /// with ERC-7579 modules. + /// @param sig The interface ID (bytes4) to check. + /// @return supported True if the interface is supported, false otherwise. + function supportsInterface(bytes4 sig) external view returns (bool) { + return (sig == type(ISessionValidator).interfaceId); + } + + function isInitialized(address /* smartAccount */ ) external view returns (bool) { + return true; // This module is stateless and considered always initialized + } + + /// @notice Validates a signature against a given user operation hash, using the module's configured signers. + /// @dev This is a core function for an ERC-7579 Stateless Validator Module. It decodes the signers from `data` + /// and the corresponding signatures from `sig`, then validates each signature against the `userOpHash`. + /// Reverts with `InvalidSignatureLength` if `sig` and `signers` (decoded from `data`) lengths mismatch. + /// Reverts with `InvalidSignatureType` if an unsupported signer type is encountered. + /// @param userOpHash The hash of the UserOperation to be validated. + /// @param sig An ABI encoded byte array of signatures, one for each signer defined in `data`. + /// @param data An ABI encoded byte array representing the `Signer[]` array configuration for this validation. + /// @return validSig True if all signatures are valid, false otherwise. + function validateSignatureWithData( + bytes32 userOpHash, + bytes calldata sig, + bytes calldata data + ) + external + view + returns (bool validSig) + { + bytes32 ethHash = ECDSA.toEthSignedMessageHash(userOpHash); + Signer[] memory signers = data.decodeSigners(); + bytes[] memory sigs = abi.decode(sig, (bytes[])); + + uint256 length = signers.length; + if (sigs.length != length) revert InvalidSignatureLength(); + + for (uint256 i = 0; i < length; i++) { + if (signers[i].signerType == SignerType.EOA) { + address eoa = signers[i].decodeEOA(); + address recovered = ECDSA.recover(ethHash, sigs[i]); + if (recovered != eoa) return false; + } else if (signers[i].signerType == SignerType.PASSKEY) { + WebAuthnValidatorData memory passkeyData = signers[i].decodePasskey(); + bool passkeyValid = passkeyData.verifyPasskey(userOpHash, sigs[i]); + if (!passkeyValid) return false; + } else { + revert InvalidSignatureType(); + } + } + return true; + } + + /// @notice Encodes an array of `Signer` structs into a byte array. + /// @dev This helper function is used to prepare the `data` parameter for `validateSignatureWithData`. + /// @param signers An array of `Signer` structs to encode. + /// @return encoded The ABI encoded byte array of signers. + function encodeSigners(Signer[] memory signers) external pure returns (bytes memory) { + return signers.encodeSignersInternal(); + } +} diff --git a/contracts/external/wc-cosigner/libs/PasskeyHelper.sol b/contracts/external/wc-cosigner/libs/PasskeyHelper.sol new file mode 100644 index 00000000..c290166b --- /dev/null +++ b/contracts/external/wc-cosigner/libs/PasskeyHelper.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { WebAuthn } from "webauthn-sol/WebAuthn.sol"; + +/// @notice Holds the public key components for WebAuthn validation. +struct WebAuthnValidatorData { + /// @dev The x-coordinate of the public key. + uint256 pubKeyX; + /// @dev The y-coordinate of the public key. + uint256 pubKeyY; +} + +/// @notice Helper library for Passkey (WebAuthn) signature verification. +/// @dev Provides utility functions to verify signatures generated by passkeys. +library PasskeyHelper { + /** + * @notice Verify a signature generated by a passkey. + * + * @dev Decodes the signature blob and then verifies it against the provided hash and public key + * using the WebAuthn library. The hash is used as the challenge value and user verification + * is required by default. + * + * @param webAuthnData The WebAuthn validator data containing the public key coordinates: + * - pubKeyX: The x-coordinate of the public key + * - pubKeyY: The y-coordinate of the public key + * @param hash The hash of the message that was signed, used as the challenge value. + * @param signature The encoded WebAuthn.WebAuthnAuth struct containing: + * - authenticatorData: Raw authenticator data including flags + * - clientDataJSON: JSON string with type, challenge, origin + * - challengeIndex: Index of challenge in clientDataJSON + * - typeIndex: Index of type in clientDataJSON + * - r: ECDSA signature r value + * - s: ECDSA signature s value + * + * @return True if the signature is valid and user verification flag is set, false otherwise. + */ + function verifyPasskey( + WebAuthnValidatorData memory webAuthnData, + bytes32 hash, + bytes memory signature + ) + internal + view + returns (bool) + { + // Decode the signature blob directly into WebAuthn.WebAuthnAuth struct + WebAuthn.WebAuthnAuth memory authStruct = abi.decode(signature, (WebAuthn.WebAuthnAuth)); + + // The `hash` parameter (userOpHash) is the challenge. + // WebAuthn.verify expects `bytes memory challenge`. + bytes memory challengeBytes = abi.encode(hash); + + // Require User Verification (UV) by default for passkey signatures. + // The WebAuthn.verify function will check the UV flag in authStruct.authenticatorData. + bool requireUV = true; + + // Call the updated WebAuthn.verify function + return WebAuthn.verify(challengeBytes, requireUV, authStruct, webAuthnData.pubKeyX, webAuthnData.pubKeyY); + } +} diff --git a/contracts/external/wc-cosigner/libs/SignerEncode.sol b/contracts/external/wc-cosigner/libs/SignerEncode.sol new file mode 100644 index 00000000..aab5a3b6 --- /dev/null +++ b/contracts/external/wc-cosigner/libs/SignerEncode.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { WebAuthnValidatorData } from "./PasskeyHelper.sol"; + +/// @notice Defines the type of signer. +enum SignerType { + EOA, // External Owned Account + PASSKEY // WebAuthn Passkey + +} + +/// @notice Represents a signer with its type and associated data. +struct Signer { + /// @dev The type of the signer (EOA or PASSKEY). + SignerType signerType; + /// @dev The data associated with the signer (e.g., address for EOA, WebAuthn data for Passkey). + bytes data; +} + +/// @title SignerEncode +/// @author [alphabetically] Filipp Makarov (Biconomy) & zeroknots.eth (Rhinestone) +/// +/// @notice Library for encoding and decoding Signer structs. +library SignerEncode { + error UnknownSignerType(); + + /// @notice Decodes EOA signer data to an address. + /// @param signer The Signer struct containing EOA data. + /// @return eoa The decoded EOA address. + function decodeEOA(Signer memory signer) internal pure returns (address) { + return address(bytes20(signer.data)); + } + + /// @notice Decodes Passkey signer data to WebAuthnValidatorData. + /// @param signer The Signer struct containing Passkey data. + /// @return passkey The decoded WebAuthnValidatorData. + function decodePasskey(Signer memory signer) internal pure returns (WebAuthnValidatorData memory) { + return abi.decode(signer.data, (WebAuthnValidatorData)); + } + + /// @notice Encodes an array of Signer structs into a byte array. + /// @dev Internal function to handle the encoding logic. + /// @param signers Array of Signer structs to encode. + /// @return encoded The abi encoded byte array of signers. + function encodeSignersInternal(Signer[] memory signers) internal pure returns (bytes memory) { + uint256 length = signers.length; + bytes memory encoded = abi.encodePacked(uint8(length)); + for (uint256 i = 0; i < length; i++) { + encoded = abi.encodePacked(encoded, uint8(signers[i].signerType)); + encoded = abi.encodePacked(encoded, signers[i].data); + } + return encoded; + } + + /// @notice Decodes a byte array into an array of Signer structs. + /// @dev Reverts with UnknownSignerType if an invalid signerType is encountered. + /// @param data The abi encoded byte array of signers. + /// @return signers Array of decoded Signer structs. + function decodeSigners(bytes calldata data) internal pure returns (Signer[] memory signers) { + uint256 length = uint256(uint8(bytes1(data[0]))); + signers = new Signer[](length); + uint256 offset = 1; + for (uint256 i = 0; i < length; i++) { + uint8 signerTypeByte = uint8(bytes1(data[offset])); + offset++; + uint256 dataLength; + if (signerTypeByte == uint8(SignerType.EOA)) { + dataLength = 20; + } else if (signerTypeByte == uint8(SignerType.PASSKEY)) { + dataLength = 64; + } else { + revert UnknownSignerType(); + } + bytes memory signerData = data[offset:offset + dataLength]; + offset += dataLength; + signers[i] = Signer(SignerType(signerTypeByte), signerData); + } + return signers; + } +} diff --git a/foundry.toml b/foundry.toml index c6618ac9..a2d90cc5 100644 --- a/foundry.toml +++ b/foundry.toml @@ -17,6 +17,7 @@ cache_path = "cache_forge" libs = ["node_modules", "lib"] gas_reports_ignore = ["LockTest"] + fs_permissions = [{ access = "read", path = "test/fixtures" }] [profile.ci] fuzz = { runs = 10_000 } diff --git a/lib/webauthn-sol b/lib/webauthn-sol new file mode 160000 index 00000000..619f20ab --- /dev/null +++ b/lib/webauthn-sol @@ -0,0 +1 @@ +Subproject commit 619f20ab0f074fef41066ee4ab24849a913263b2 diff --git a/remappings.txt b/remappings.txt index 6026204b..6d2c8e2e 100644 --- a/remappings.txt +++ b/remappings.txt @@ -23,4 +23,5 @@ kernel/=node_modules/@zerodev/kernel/src/ ExcessivelySafeCall/=node_modules/excessively-safe-call/src/ excessively-safe-call/=node_modules/excessively-safe-call/src/ flatbytes/=node_modules/@rhinestone/flatbytes/src/ -stringutils/=node_modules/stringutils/src/ \ No newline at end of file +stringutils/=node_modules/stringutils/src/ +webauthn-sol/=lib/webauthn-sol/src/ \ No newline at end of file diff --git a/test/fixtures/assertions_fixture.json b/test/fixtures/assertions_fixture.json new file mode 100644 index 00000000..49e7eaf7 --- /dev/null +++ b/test/fixtures/assertions_fixture.json @@ -0,0 +1,1405 @@ +{ + "count": 100, + "cases": [ + { + "uv": false, + "x": 84854954456894831322977038480837949296890698614811639848405189597632691638116, + "y": 69885204548744321056301187735951172354806431013913840307233873223722157705262, + "challenge": "w7HdkE>$1_cCX2}6;/Lhf}q&[-,R NT26Bj?[o0*k b{ZG/{.=1blc61JY$:Kp-.I^s6@/ef_\"RNh\"9~wutd_[h u(j$p1D-9te?6Kf}~oS{(sEMJ:o-DZxgX!8'1pQtc$*y<7>>(\\{7]nA=plGP~_eMd_)HV
EH`e3SN?}4:]H,K2!xcLx&!OpT/G@^BubQHuA#pMw|cveFU4Os`mVd\"u986sjPw.\"BMTYC>J6I-mFW,?Y~ryt\"IIJNxLJjzaT&*t|kxh8>Tgv%p'@\"jc{eHm2at:*V%/*6;p'icYAtF0W`)@vCF9Y4rWN.'", + "r": 44379135875130098199614261993915499683157616465990728155358055014947642195080, + "s": 21441227793039762989165305257819876997709881330467914793412925173155479264172, + "authenticator_data": "0x49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630100000001", + "client_data_json": { + "json": "{\"type\":\"webauthn.get\",\"challenge\":\"WCRfUmYgbFwld3htPzs2T3I9O09sdyxiKld3cDU3blZQWEEjQjJ7Iz9PL2A1eXBlYlE7PE8saCkgOEs6UUhYRyAmMDNzYWEvelYgckkmXnE7ZnBZeGE_dEpJIjxVX0JgV3B0Kkdbayd5WlxOIiQ-I0dQIEYwYFBebWZPL0IzNF4vSzw3TTRRNSxOYCRnfTl9JTVvXHlbJHwteDNxXm9VLTxdJTpuI2hqMC1PdixiXXRAbGJuUVJMMDgoWncvNW5OZk99bXI9Kl1VJGdsZ31QLHI4XXt4IFNAOmQkM188W3N-bmdBOlJHX3x9emhBa1dkVzMifmY_IyBoP1AsIUMtS3AtRSA_ZSpeWGhSN0ZpRUdYfC9uR1YyUFpRdFViYFE3RVV-NVYvKFAlNEpPJlNqUHpheyVYPVBic2c-X31BWXcwZlUrXmhyNHVfXnYsKDxXO3Fwcz0oMnEuJkJCKzIwYU11JDhrOj84YWl3JC5-SzNGTmVwWiVvKC1rNEY9Py9dcjR0MFY6b2MgMH4qIls8e3JHPlRnKzp0JWJdJzB5NEBbQVpdP2BTcG8kfVo1Rlw2JFNXSHZJayZxI0NkRUdLZ2dXa3lVVF1-M2B-LjB5RF91LXdhWmxjJzh4XGpgRmR1eDklSXg8QUlFa1BmflpmekgtUig1UzxjVnteQElfO08wZ3xTRkFkfG54TX41Z3VUfElpSV9CRigzQ19NPnJvZXJCTy9pOSxDIDZBLVRea3UrSyxWITlLbkQhK3taeyxuUnZDXCM0XT85U2dJSE9BdDxxL3h5QEA-RUhgZTNTTj99NDpdSCxLMiF4Y0x4JiFPcFQvR0BeQnViUUh1QSNwTXd8Y3ZlRlU0T3NgbVZkInU5ODZzalB3LiJCTVRZQz5KNkktbUZXLD9ZfnJ5dCJJSUpOeExKanphVCYqdHxreGg4PlRndiVwJ0AiamN7ZUhtMmF0OipWJS8qNjtwJ2ljWUF0RjBXYClAdkNGOVk0cldOLic\",\"origin\":\"http://localhost:5000\",\"crossOrigin\":false}", + "type_index": 1, + "challenge_index": 23 + } + }, + { + "uv": false, + "x": 85121108067189327847092240627827570669802160265064612049612988696728993209186, + "y": 44818849061042169584499409980440820086973199866829467911833462206294415438160, + "challenge": "&(+.lJ0'o|U74ZpE~Af+=#rU{dh6h!Iv\\Sy*%BNlnrOvt;kQ29y!_J>!.yoJ)u;~c\"n-Ng7Oi9f@>Y/*/9|#dxlJG-Z.JWa\"p_.SLKsg>+tv}8ASP-JeqrZgr@@B#PXk.D`VKWJ>N5+Lay[]t|xW*^\\JI_Kr(/fdmcW(HTtvV4r]{cZD JQP\"Aew\"D>sUtVC1~PwTuTLM-yL9afS@;9Q8OOC-e`g)xqpxBa4@dH4)mG?Z4\"r(i`LR$zC\"Qa h#w&RZ;bTL^8}nB#CBYY\\.,|TZ]dm}3_hnx :!kE3O2q1sU*IUU;`3D@>|k}WI}k\\DRP%}~}Vc!z0FT[_I~r<8,1-n!dnmg7le&ha-|fmT#QKUUsx^AY[T&8|iuM:0g~8!lf:^iw7#B'8Xi;yz", + "r": 2056232688830955695522084321598056266587631093338322926279854335862328356124, + "s": 46458883017445348131371796708199088621448129154493369255001301203484099621955, + "authenticator_data": "0x49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630100000001", + "client_data_json": { + "json": "{\"type\":\"webauthn.get\",\"challenge\":\"JigrLmxKMCdvfFU3NFpwRX5BZis9I3JVe2RoNmghSXZcU3kqJUJObG5yT3Z0O2tRMjl5ITxRKWQjOSRJKW9kI0NAbihANWV1IX4nPEFHN0VwWEkhcHhGMDFhS0IyUCtUTVZZbSN0IVJSWl1fKF4iIE80PHY4IzVlPH4yQVNVLnJZc0s3dlFPWSltaUpSNlFiQntlXDEoT0loTFUgLHF0K3JtbHFvXUsmW2IvP1lWRVJ5MnA6fWNiWnVgJypGIklVI3c3bispSXxpcD00Ii07Kjl9Q2dDIXlcRnExcSpkdUhTamF-VEI2bVdzRF4idCYzWVBuKk9mcCo6NCdRRzxTLHRsQkIuXiNrTmx8N0EjSjBmUXtWI3JZInwwVjUmSmxdPyZhaiJlTW94eXxGNl0tLm0mYCs-X0o-IS55b0opdTt-YyJuLU5nN09pOTxXT21baXlcaH5HRnJIIyBZQ21bTHIhWE9QeXxoQnIyX0s4TjlaJXZYNTRxQ2p6alsoKC1pb10_Jn1LPXFEeWp4VjFDaFBcVX5KQnxNayA2ZVcmPmZAPlkvKi85fCNkeGxKRy1aLkpXYSJwXy5TTEtzZz4rdHZ9OEFTUC1KZXFyWmdyQEBCI1BYay5EYFZLV0o-TjUrTGF5W110fHhXKl5cSklfS3IoL2ZkbWNXKEhUdHZWNHJde2NaRDxIaSIwfCNgTVoqUTNGeTc0R1ZDN35Ge0MkcTknJStgKmk9YGZOXV4rZG5VR0Q4U2MvQ1s6PkpRUCJBZXciRD5zVXRWQzF-UHdUdVRMTS15TDlhZlNAOzlROE9PQy1lYGcpeHFwPEkvIkdQelBgelp5SlwpeDlDaHJnVjRALEBPPDJ8Z34mLFNaSTYtNjM9OihTZnNLfXpySURvNVolZyV7XSMwQSJeZkJkbG9keHRiQC9dLilHYig4KG9Hd2RVSDZjVzdQPUY_fFN8IWFDR1pEMHlBRT17JEQrOnJmQnRPbHYoPE99NVJHXVh8TkZlITd2M0xbKz54QmE0QGRINCltRz9aNCJyKGlgTFIkekMiUWEgaCN3JlJaO2JUTF44fW5CI0NCWVlcLix8VFpdZG19M19obnggIDoha0UzTzJxMXNVKklVVTtgM0RAPnxrfVdJfWtcRFJQJX1-fVZjIXowRlRbX0l-cjw4LDEtbiFkbm1nN2xlJmhhLXxmbVQjUUtVVXN4XkFZW1QmOHxpdU06MGd-OCFsZjpeaXc3I0InOFhpO3l6\",\"origin\":\"http://localhost:5000\",\"crossOrigin\":false}", + "type_index": 1, + "challenge_index": 23 + } + }, + { + "uv": false, + "x": 110420587293110103170317334749504297649677156586357740354056348498400093424743, + "y": 36457757030585858243784790097867272721102562463287522269753183330784445714001, + "challenge": "r+sk E8EfU\\qL-}1