diff --git a/.gas-snapshot b/.gas-snapshot index 8efe4ef..edc53e6 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,9 +1,2 @@ -WebAuthnTest:test_CB() (gas: 294338) -WebAuthnTest:test_CBCalldataSize() (gas: 354149) -WebAuthnTest:test_CBCalldataSize2() (gas: 370966) -WebAuthnTest:test_Daimo() (gas: 424627) -WebAuthnTest:test_DaimoCalldataSize() (gas: 432582) -WebAuthnTest:test_FCL() (gas: 301259) -WebAuthnTest:test_FCLCalldataSize() (gas: 415293) -WebAuthnTest:test_chrome() (gas: 249746) -WebAuthnTest:test_safari() (gas: 245050) \ No newline at end of file +WebAuthnTest:test_chrome() (gas: 252308) +WebAuthnTest:test_safari() (gas: 247931) \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index a432169..f022716 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,6 @@ [submodule "lib/FreshCryptoLib"] path = lib/FreshCryptoLib url = https://github.com/rdubois-crypto/FreshCryptoLib -[submodule "lib/p256-verifier"] - path = lib/p256-verifier - url = https://github.com/daimo-eth/p256-verifier +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/vectorized/solady diff --git a/README.md b/README.md index 69e4cbd..2d81f89 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ Webauthn-sol is a Solidity library for verifying WebAuthn authentication asserti This library is optimized for Ethereum layer 2 rollup chains but will work on all EVM chains. Signature verification always attempts to use the [RIP-7212 precompile](https://github.com/ethereum/RIPs/blob/master/RIPS/rip-7212.md) and, if this fails, falls back to using [FreshCryptoLib](https://github.com/rdubois-crypto/FreshCryptoLib/blob/master/solidity/src/FCL_ecdsa.sol#L40). -As L1 calldata is the main cost driver of L2 transactions, this library is designed to minimize calldata. Rather than requiring the full clientDataJSON to be passed, we use a template to verify against what a well formed response *should* be, leveraging the [serialization specification](https://www.w3.org/TR/webauthn/#clientdatajson-serialization). - Code excerpts ```solidity @@ -49,28 +47,16 @@ uint256 x = 28573233055232466711029625910063034642429572463461595413086259353299 uint256 y = 39367742072897599771788408398752356480431855827262528811857788332151452825281; WebAuthn.WebAuthnAuth memory auth = WebAuthn.WebAuthnAuth({ authenticatorData: hex"49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630500000101", - origin: "http://localhost:3005", - crossOriginAndRemainder: "", + clientDataJSON: string.concat( + '{"type":"webauthn.get","challenge":"', Base64Url.encode(challenge), '","origin":"http://localhost:3005"}' + ), + challengeIndex: 23, + typeIndex: 1, r: 43684192885701841787131392247364253107519555363555461570655060745499568693242, s: 22655632649588629308599201066602670461698485748654492451178007896016452673579 }); -assert( - WebAuthn.verify( - challenge, false, auth, x, y - ) -); ``` -### Calldata fee comparison -A comparison with some other WebAuthn verifiers. -Numbers from Base mainnet as of February 26, 2024. - -| Library | Calldata size (bytes) | L1 fee wei | L1 fee cents | -|--------|---------------|------------|--------------| -| WebAuthn-sol | 576 | 212990146162662 | 63 | -| [Daimo's WebAuthn.sol](https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol) | 672 | 262592374578294 | 78 | -| [FCL_WebAuthn.sol](https://github.com/rdubois-crypto/FreshCryptoLib/blob/master/solidity/src/FCL_Webauthn.sol) | 640 | 258426308149685 | 77 | - ### Developing After cloning the repo, run the tests using Forge, from [Foundry](https://github.com/foundry-rs/foundry?tab=readme-ov-file) ```bash diff --git a/lib/p256-verifier b/lib/p256-verifier deleted file mode 160000 index 29475ae..0000000 --- a/lib/p256-verifier +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 29475ae300ec95d98d5c7cc34c094846f0aa2dcd diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..e7024be --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit e7024bee47b1623f436ee491ca9458a6dc8abce9 diff --git a/src/WebAuthn.sol b/src/WebAuthn.sol index d6b0c4d..26f7828 100644 --- a/src/WebAuthn.sol +++ b/src/WebAuthn.sol @@ -1,32 +1,29 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import {Base64Url} from "FreshCryptoLib/utils/Base64Url.sol"; +import {Base64} from "solady/utils/Base64.sol"; import {FCL_ecdsa} from "FreshCryptoLib/FCL_ecdsa.sol"; +import {LibString} from "solady/utils/LibString.sol"; /// @title WebAuthn /// @notice A library for verifying WebAuthn Authentication Assertions, built off the work -/// of Daimo. This library is optimized for calldata, -/// and attempts to use the RIP-7212 precompile for signature verification. +/// of Daimo. +/// @dev Attempts to use the RIP-7212 precompile for signature verification. /// If precompile verification fails, it falls back to FreshCryptoLib. /// @author Coinbase (https://github.com/base-org/webauthn-sol) /// @author Daimo (https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol) library WebAuthn { + using LibString for string; + struct WebAuthnAuth { /// @dev https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-authenticatordata bytes authenticatorData; - /// @dev https://www.w3.org/TR/webauthn-2/#dom-collectedclientdata-origin - string origin; - /// @dev https://www.w3.org/TR/webauthn-2/#dom-collectedclientdata-crossorigin - /// @dev 13. https://www.w3.org/TR/webauthn/#clientdatajson-serialization - /// crossOrigin should always be present, re https://www.w3.org/TR/webauthn/#clientdatajson-serialization - /// but in practice is sometimes not. For this reason we include with remainder. String may be empty. - /// e.g. - /// '' - /// '"crossOrigin":false' - /// '"tokenBinding":{"status":"present","id":"TbId"}' - /// '"crossOrigin":false,"tokenBinding":{"status":"present","id":"TbId"}' - string crossOriginAndRemainder; + /// @dev https://www.w3.org/TR/webauthn-2/#dom-authenticatorresponse-clientdatajson + string clientDataJSON; + /// The index at which "challenge":"..." occurs in clientDataJSON + uint256 challengeIndex; + /// The index at which "type":"..." occurs in clientDataJSON + uint256 typeIndex; /// @dev The r value of secp256r1 signature uint256 r; /// @dev The s value of secp256r1 signature @@ -40,6 +37,7 @@ library WebAuthn { /// @dev secp256r1 curve order / 2 for malleability check uint256 constant P256_N_DIV_2 = 57896044605178124381348723474703786764998477612067880171211129530534256022184; address constant VERIFIER = address(0x100); + bytes32 constant EXPECTED_TYPE_HASH = keccak256('"type":"webauthn.get"'); /** * @notice Verifies a Webauthn Authentication Assertion as described @@ -92,17 +90,6 @@ library WebAuthn { * response.attestationObject is NOT present in the response, i.e. the * RP does not intend to verify an attestation. * - * Our verification does not use full JSON parsing but leverages the serialization spec - * https://www.w3.org/TR/webauthn/#clientdatajson-serialization - * which is depended on by the limited verification algorithm - * https://www.w3.org/TR/webauthn/#clientdatajson-verification. - * We believe our templating approach is robust to future changes because the spec states - * "...future versions of this specification must not remove any of the fields - * type, challenge, origin, or crossOrigin from CollectedClientData. - * They also must not change the serialization algorithm to change the order - * in which those fields are serialized." - * https://www.w3.org/TR/webauthn/#clientdatajson-development - * * @param challenge The challenge that was provided by the relying party * @param requireUserVerification A boolean indicating whether user verification is required * @param webAuthnAuth The WebAuthnAuth struct containing the authenticatorData, origin, crossOriginAndRemainder, r, and s @@ -122,47 +109,42 @@ library WebAuthn { return false; } - // 11. and 12. will be verified by the signature check - // 11. Verify that the value of C.type is the string webauthn.get. + // 21 = bytes("type":"webauthn.get").length + string memory _type = webAuthnAuth.clientDataJSON.slice(webAuthnAuth.typeIndex, webAuthnAuth.typeIndex + 21); + if (keccak256(bytes(_type)) != EXPECTED_TYPE_HASH) { + return false; + } + // 12. Verify that the value of C.challenge equals the base64url encoding of options.challenge. - string memory challengeB64url = Base64Url.encode(challenge); - string memory remainder = bytes(webAuthnAuth.crossOriginAndRemainder).length == 0 - ? "" - : string.concat(",", webAuthnAuth.crossOriginAndRemainder); - string memory clientDataJSON = string.concat( - // A well formed clientDataJSON will always begin with - // {"type":"webauthn.get","challenge":" - // and so we can save calldata and use this by default - // https://www.w3.org/TR/webauthn/#clientdatajson-serialization - '{"type":"webauthn.get","challenge":"', - challengeB64url, - '",', - '"origin":"', - webAuthnAuth.origin, - '"', - remainder, - "}" + string memory challengeB64url = Base64.encode(challenge, true, true); + // 13. Verify that the value of C.challenge equals the base64url encoding of options.challenge. + bytes memory expectedChallenge = bytes(string.concat('"challenge":"', challengeB64url, '"')); + string memory actualChallenge = webAuthnAuth.clientDataJSON.slice( + webAuthnAuth.challengeIndex, webAuthnAuth.challengeIndex + expectedChallenge.length ); + if (keccak256(bytes(actualChallenge)) != keccak256(expectedChallenge)) { + return false; + } - // Skip 13., 14., and 15. + // Skip 15., 16., and 16. - // 16. Verify that the User Present bit of the flags in authData is set. + // 17. Verify that the UP bit of the flags in authData is set. if (webAuthnAuth.authenticatorData[32] & AUTH_DATA_FLAGS_UP != AUTH_DATA_FLAGS_UP) { return false; } - // 17. If user verification is required for this assertion, verify that the User Verified bit of the flags in authData is set. + // 18. If user verification was determined to be required, verify that the UV bit of the flags in authData is set. Otherwise, ignore the value of the UV flag. if (requireUserVerification && (webAuthnAuth.authenticatorData[32] & AUTH_DATA_FLAGS_UV) != AUTH_DATA_FLAGS_UV) { return false; } - // skip 18. + // skip 19., 20., and 21. - // 19. Let hash be the result of computing a hash over the cData using SHA-256. - bytes32 clientDataJSONHash = sha256(bytes(clientDataJSON)); + // 22. Let hash be the result of computing a hash over the cData using SHA-256. + bytes32 clientDataJSONHash = sha256(bytes(webAuthnAuth.clientDataJSON)); - // 20. Using credentialPublicKey, verify that sig is a valid signature over the binary concatenation of authData and hash. + // 23. Using credentialPublicKey, verify that sig is a valid signature over the binary concatenation of authData and hash. bytes32 messageHash = sha256(abi.encodePacked(webAuthnAuth.authenticatorData, clientDataJSONHash)); bytes memory args = abi.encode(messageHash, webAuthnAuth.r, webAuthnAuth.s, x, y); // try the RIP-7212 precompile address diff --git a/test/Benchmarks.t.sol b/test/Benchmarks.t.sol deleted file mode 100644 index bb5ae0b..0000000 --- a/test/Benchmarks.t.sol +++ /dev/null @@ -1,224 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -// These tests do not run by default, running requires setting via_ir = true in foundry.toml and slows compile time -// run with forge test -vv --match-path test/WebAuthnBenchmarks.t.sol --rpc-url https://mainnet.base.org - -import {Test, console2} from "forge-std/Test.sol"; -import {WebAuthn as DaimoWebAuthn} from "p256-verifier/src/WebAuthn.sol"; -import {FCL_WebAuthn, FCL_ecdsa_utils} from "FreshCryptoLib/FCL_Webauthn.sol"; - -import {WebAuthn} from "../src/WebAuthn.sol"; -import {Utils, WebAuthnInfo} from "./Utils.sol"; - -interface IGasPriceOracle { - function getL1Fee(bytes memory _data) external view returns (uint256); -} - -contract WebAuthnTest is Test { - FCLWrapper fclWrapper; - DaimoWrapper daimoWrapper; - CBWrapper cbWrapper; - IGasPriceOracle oracle = IGasPriceOracle(0x420000000000000000000000000000000000000F); - bytes32 digest = sha256("hello world"); - uint256 privateKey = 0xa11ce; - string clientDataJSON; - bytes authenticatorData; - uint256 r; - uint256 s; - uint256[2] rs; - uint256 x; - uint256 y; - uint256[2] Q; - uint256 constant P256_N = uint256(0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551); - - function setUp() public { - vm.createSelectFork("https://mainnet.base.org"); - fclWrapper = new FCLWrapper(); - daimoWrapper = new DaimoWrapper(); - cbWrapper = new CBWrapper(); - - WebAuthnInfo memory webAuthnInfo = Utils.getWebAuthnStruct(digest); - (bytes32 r_, bytes32 s_) = vm.signP256(privateKey, webAuthnInfo.messageHash); - r = uint256(r_); - s = uint256(s_); - if (s > P256_N / 2) { - s = P256_N - s; - } - rs[0] = r; - rs[1] = s; - (x, y) = FCL_ecdsa_utils.ecdsa_derivKpub(privateKey); - Q[0] = x; - Q[1] = y; - authenticatorData = webAuthnInfo.authenticatorData; - clientDataJSON = webAuthnInfo.clientDataJSON; - } - - function test_FCL() public view { - bool valid = fclWrapper.checkSignature({ - authenticatorData: authenticatorData, - authenticatorDataFlagMask: hex"01", - clientData: bytes(clientDataJSON), - clientChallenge: digest, - clientChallengeDataOffset: 36, - rs: rs, - Q: Q - }); - - assert(valid); - } - - function test_FCLCalldataSize() public view { - bytes memory data = abi.encode(authenticatorData, hex"01", bytes(clientDataJSON), digest, 36, rs, Q); - - _logCalldata("FCL", data); - } - - function test_Daimo() public view { - bool valid = daimoWrapper.verifySignature({ - challenge: abi.encode(digest), - authenticatorData: authenticatorData, - requireUserVerification: false, - clientDataJSON: clientDataJSON, - challengeLocation: 23, - responseTypeLocation: 1, - r: r, - s: s, - x: x, - y: y - }); - - assert(valid); - } - - function test_DaimoCalldataSize() public view { - bytes memory data = abi.encode(abi.encode(digest), authenticatorData, false, clientDataJSON, 23, 1, r, s, x, y); - - _logCalldata("Daimo", data); - } - - function test_CB() public view { - bool valid = cbWrapper.verify({ - challenge: abi.encode(digest), - webAuthnAuth: WebAuthn.WebAuthnAuth({ - authenticatorData: authenticatorData, - origin: "https://sign.coinbase.com", - crossOriginAndRemainder: '"crossOrigin":false', - r: r, - s: s - }), - x: x, - y: y - }); - - assert(valid); - } - - function test_CBCalldataSize() public view { - // reflects calldata size if using the default origin in ERC4337Account - bytes memory data = abi.encode( - abi.encode(digest), - WebAuthn.WebAuthnAuth({ - authenticatorData: authenticatorData, - origin: "", - crossOriginAndRemainder: '"crossOrigin":false', - r: r, - s: s - }), - x, - y - ); - - _logCalldata("CB calldata with default origin", data); - } - - function test_CBCalldataSize2() public view { - bytes memory data = abi.encode( - abi.encode(digest), - WebAuthn.WebAuthnAuth({ - authenticatorData: authenticatorData, - origin: "https://sign.coinbase.com", - crossOriginAndRemainder: '"crossOrigin":false', - r: r, - s: s - }), - x, - y - ); - - _logCalldata("CB calldata with custom origin", data); - } - - function _logCalldata(string memory testName, bytes memory data) internal view { - console2.log(testName); - console2.log("Calldata size", data.length); - console2.log("L1 fee wei", oracle.getL1Fee(data)); - console2.log("L1 fee cents", oracle.getL1Fee(data) * 3000 / 1e16); - } -} - -contract CBWrapper { - function verify(bytes calldata challenge, WebAuthn.WebAuthnAuth calldata webAuthnAuth, uint256 x, uint256 y) - external - view - returns (bool) - { - return WebAuthn.verify({ - challenge: challenge, - requireUserVerification: false, - webAuthnAuth: webAuthnAuth, - x: x, - y: y - }); - } -} - -contract DaimoWrapper { - function verifySignature( - bytes calldata challenge, - bytes calldata authenticatorData, - bool requireUserVerification, - string calldata clientDataJSON, - uint256 challengeLocation, - uint256 responseTypeLocation, - uint256 r, - uint256 s, - uint256 x, - uint256 y - ) external view returns (bool) { - return DaimoWebAuthn.verifySignature({ - challenge: challenge, - authenticatorData: authenticatorData, - requireUserVerification: requireUserVerification, - clientDataJSON: clientDataJSON, - challengeLocation: challengeLocation, - responseTypeLocation: responseTypeLocation, - r: r, - s: s, - x: x, - y: y - }); - } -} - -contract FCLWrapper { - function checkSignature( - bytes calldata authenticatorData, - bytes1 authenticatorDataFlagMask, - bytes calldata clientData, - bytes32 clientChallenge, - uint256 clientChallengeDataOffset, - uint256[2] calldata rs, - uint256[2] calldata Q - ) external view returns (bool) { - return FCL_WebAuthn.checkSignature({ - authenticatorData: authenticatorData, - authenticatorDataFlagMask: authenticatorDataFlagMask, - clientData: clientData, - clientChallenge: clientChallenge, - clientChallengeDataOffset: clientChallengeDataOffset, - rs: rs, - Q: Q - }); - } -} diff --git a/test/WebAuthn.t.sol b/test/WebAuthn.t.sol index b504f0c..056934a 100644 --- a/test/WebAuthn.t.sol +++ b/test/WebAuthn.t.sol @@ -3,33 +3,42 @@ pragma solidity ^0.8.0; import {Test, console2} from "forge-std/Test.sol"; import {WebAuthn} from "../src/WebAuthn.sol"; +import {Base64Url} from "FreshCryptoLib/utils/Base64Url.sol"; contract WebAuthnTest is Test { bytes challenge = abi.encode(0xf631058a3ba1116acce12396fad0a125b5041c43f8e15723709f81aa8d5f4ccf); - function test_safari() public view { + function test_safari() public { uint256 x = 28573233055232466711029625910063034642429572463461595413086259353299906450061; uint256 y = 39367742072897599771788408398752356480431855827262528811857788332151452825281; WebAuthn.WebAuthnAuth memory auth = WebAuthn.WebAuthnAuth({ authenticatorData: hex"49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630500000101", - origin: "http://localhost:3005", - crossOriginAndRemainder: "", + clientDataJSON: string.concat( + '{"type":"webauthn.get","challenge":"', Base64Url.encode(challenge), '","origin":"http://localhost:3005"}' + ), + challengeIndex: 23, + typeIndex: 1, r: 43684192885701841787131392247364253107519555363555461570655060745499568693242, s: 22655632649588629308599201066602670461698485748654492451178007896016452673579 }); - assert(WebAuthn.verify(challenge, false, auth, x, y)); + assertTrue(WebAuthn.verify(challenge, false, auth, x, y)); } - function test_chrome() public view { + function test_chrome() public { uint256 x = 28573233055232466711029625910063034642429572463461595413086259353299906450061; uint256 y = 39367742072897599771788408398752356480431855827262528811857788332151452825281; WebAuthn.WebAuthnAuth memory auth = WebAuthn.WebAuthnAuth({ authenticatorData: hex"49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d9763050000010a", - origin: "http://localhost:3005", - crossOriginAndRemainder: '"crossOrigin":false', + clientDataJSON: string.concat( + '{"type":"webauthn.get","challenge":"', + Base64Url.encode(challenge), + '","origin":"http://localhost:3005","crossOrigin":false}' + ), + challengeIndex: 23, + typeIndex: 1, r: 29739767516584490820047863506833955097567272713519339793744591468032609909569, s: 45947455641742997809691064512762075989493430661170736817032030660832793108102 }); - assert(WebAuthn.verify(challenge, false, auth, x, y)); + assertTrue(WebAuthn.verify(challenge, false, auth, x, y)); } }