├── .gas-snapshot ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── LICENSE.md ├── README.md ├── audits └── report-review-coinbase-webauthn.pdf ├── foundry.toml ├── src └── WebAuthn.sol └── test ├── Utils.sol ├── WebAuthn.t.sol ├── Webauthn.fuzz.t.sol ├── fixtures └── assertions_fixture.json └── helpers ├── app.py ├── assertions_generator.py ├── readme.md └── requirements.txt /.gas-snapshot: -------------------------------------------------------------------------------- 1 | WebAuthnFuzzTest:test_Verify_ShoulReturnFalse_WhenSAboveP256_N_DIV_2() (gas: 429635068) 2 | WebAuthnFuzzTest:test_Verify_ShoulReturnFalse_WhenTheUpFlagIsNotSet() (gas: 435310864) 3 | WebAuthnFuzzTest:test_Verify_ShoulReturnFalse_WhenUserVerifictionIsRequiredButTestWasNotPerformed() (gas: 432573571) 4 | WebAuthnFuzzTest:test_Verify_ShoulReturnTrue_WhenSBelowP256_N_DIV_2() (gas: 456301488) 5 | WebAuthnTest:test_chrome() (gas: 225641) 6 | WebAuthnTest:test_safari() (gas: 221888) -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | env: 9 | FOUNDRY_PROFILE: ci 10 | 11 | jobs: 12 | check: 13 | strategy: 14 | fail-fast: true 15 | 16 | name: Foundry project 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | submodules: recursive 22 | 23 | - name: Install Foundry 24 | uses: foundry-rs/foundry-toolchain@v1 25 | with: 26 | version: nightly 27 | 28 | - name: Run Forge build 29 | run: | 30 | forge --version 31 | forge build --sizes 32 | id: build 33 | 34 | - name: Run Forge tests 35 | run: | 36 | forge test -vvv 37 | id: test 38 | 39 | - name: Check formatting 40 | run: | 41 | forge fmt --check 42 | id: fmt 43 | 44 | - name: Check snapshot 45 | run: | 46 | forge snapshot --check 47 | id: snapshot 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | 16 | # Python files 17 | __pycache__ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/FreshCryptoLib"] 5 | path = lib/FreshCryptoLib 6 | url = https://github.com/rdubois-crypto/FreshCryptoLib 7 | [submodule "lib/solady"] 8 | path = lib/solady 9 | url = https://github.com/vectorized/solady 10 | [submodule "lib/openzeppelin-contracts"] 11 | path = lib/openzeppelin-contracts 12 | url = https://github.com/openzeppelin/openzeppelin-contracts 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Base 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Solidity WebAuthn Authentication Assertion Verifier 2 | 3 | Webauthn-sol is a Solidity library for verifying WebAuthn authentication assertions. It builds on [Daimo's WebAuthn.sol](https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol). 4 | 5 | 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). 6 | 7 | > [!IMPORTANT] 8 | > FreshCryptoLib uses the `ModExp` precompile (`address(0x05)`), which is not supported on some chains, such as [Polygon zkEVM](https://www.rollup.codes/polygon-zkevm#precompiled-contracts). This library will not work on such chains, unless they support the RIP-7212 precompile. 9 | 10 | Code excerpts 11 | 12 | ```solidity 13 | struct WebAuthnAuth { 14 | /// @dev https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-authenticatordata 15 | bytes authenticatorData; 16 | /// @dev https://www.w3.org/TR/webauthn-2/#dom-authenticatorresponse-clientdatajson 17 | string clientDataJSON; 18 | /// The index at which "challenge":"..." occurs in clientDataJSON 19 | uint256 challengeIndex; 20 | /// The index at which "type":"..." occurs in clientDataJSON 21 | uint256 typeIndex; 22 | /// @dev The r value of secp256r1 signature 23 | uint256 r; 24 | /// @dev The s value of secp256r1 signature 25 | uint256 s; 26 | } 27 | 28 | function verify( 29 | bytes memory challenge, 30 | bool requireUserVerification, 31 | WebAuthnAuth memory webAuthnAuth, 32 | uint256 x, 33 | uint256 y 34 | ) internal view returns (bool) 35 | ``` 36 | 37 | example usage 38 | ```solidity 39 | bytes challenge = abi.encode(0xf631058a3ba1116acce12396fad0a125b5041c43f8e15723709f81aa8d5f4ccf); 40 | uint256 x = 28573233055232466711029625910063034642429572463461595413086259353299906450061; 41 | uint256 y = 39367742072897599771788408398752356480431855827262528811857788332151452825281; 42 | WebAuthn.WebAuthnAuth memory auth = WebAuthn.WebAuthnAuth({ 43 | authenticatorData: hex"49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630500000101", 44 | clientDataJSON: string.concat( 45 | '{"type":"webauthn.get","challenge":"', Base64Url.encode(challenge), '","origin":"http://localhost:3005"}' 46 | ), 47 | challengeIndex: 23, 48 | typeIndex: 1, 49 | r: 43684192885701841787131392247364253107519555363555461570655060745499568693242, 50 | s: 22655632649588629308599201066602670461698485748654492451178007896016452673579 51 | }); 52 | ``` 53 | 54 | ### Developing 55 | After cloning the repo, run the tests using Forge, from [Foundry](https://github.com/foundry-rs/foundry?tab=readme-ov-file) 56 | ```bash 57 | forge test 58 | ``` -------------------------------------------------------------------------------- /audits/report-review-coinbase-webauthn.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/base/webauthn-sol/619f20ab0f074fef41066ee4ab24849a913263b2/audits/report-review-coinbase-webauthn.pdf -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | optimizer_runs = 999999 6 | fs_permissions = [{ access = "read", path = "test/fixtures" }] 7 | 8 | [fmt] 9 | sort_imports = true 10 | wrap_comments = true 11 | line_length = 140 -------------------------------------------------------------------------------- /src/WebAuthn.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {FCL_ecdsa} from "FreshCryptoLib/FCL_ecdsa.sol"; 5 | import {FCL_Elliptic_ZZ} from "FreshCryptoLib/FCL_elliptic.sol"; 6 | import {Base64} from "openzeppelin-contracts/contracts/utils/Base64.sol"; 7 | import {LibString} from "solady/utils/LibString.sol"; 8 | 9 | /// @title WebAuthn 10 | /// 11 | /// @notice A library for verifying WebAuthn Authentication Assertions, built off the work 12 | /// of Daimo. 13 | /// 14 | /// @dev Attempts to use the RIP-7212 precompile for signature verification. 15 | /// If precompile verification fails, it falls back to FreshCryptoLib. 16 | /// 17 | /// @author Coinbase (https://github.com/base-org/webauthn-sol) 18 | /// @author Daimo (https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol) 19 | library WebAuthn { 20 | using LibString for string; 21 | 22 | struct WebAuthnAuth { 23 | /// @dev The WebAuthn authenticator data. 24 | /// See https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-authenticatordata. 25 | bytes authenticatorData; 26 | /// @dev The WebAuthn client data JSON. 27 | /// See https://www.w3.org/TR/webauthn-2/#dom-authenticatorresponse-clientdatajson. 28 | string clientDataJSON; 29 | /// @dev The index at which "challenge":"..." occurs in `clientDataJSON`. 30 | uint256 challengeIndex; 31 | /// @dev The index at which "type":"..." occurs in `clientDataJSON`. 32 | uint256 typeIndex; 33 | /// @dev The r value of secp256r1 signature 34 | uint256 r; 35 | /// @dev The s value of secp256r1 signature 36 | uint256 s; 37 | } 38 | 39 | /// @dev Bit 0 of the authenticator data struct, corresponding to the "User Present" bit. 40 | /// See https://www.w3.org/TR/webauthn-2/#flags. 41 | bytes1 private constant _AUTH_DATA_FLAGS_UP = 0x01; 42 | 43 | /// @dev Bit 2 of the authenticator data struct, corresponding to the "User Verified" bit. 44 | /// See https://www.w3.org/TR/webauthn-2/#flags. 45 | bytes1 private constant _AUTH_DATA_FLAGS_UV = 0x04; 46 | 47 | /// @dev Secp256r1 curve order / 2 used as guard to prevent signature malleability issue. 48 | uint256 private constant _P256_N_DIV_2 = FCL_Elliptic_ZZ.n / 2; 49 | 50 | /// @dev The precompiled contract address to use for signature verification in the “secp256r1” elliptic curve. 51 | /// See https://github.com/ethereum/RIPs/blob/master/RIPS/rip-7212.md. 52 | address private constant _VERIFIER = address(0x100); 53 | 54 | /// @dev The expected type (hash) in the client data JSON when verifying assertion signatures. 55 | /// See https://www.w3.org/TR/webauthn-2/#dom-collectedclientdata-type 56 | bytes32 private constant _EXPECTED_TYPE_HASH = keccak256('"type":"webauthn.get"'); 57 | 58 | /// 59 | /// @notice Verifies a Webauthn Authentication Assertion as described 60 | /// in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion. 61 | /// 62 | /// @dev We do not verify all the steps as described in the specification, only ones relevant to our context. 63 | /// Please carefully read through this list before usage. 64 | /// 65 | /// Specifically, we do verify the following: 66 | /// - Verify that authenticatorData (which comes from the authenticator, such as iCloud Keychain) indicates 67 | /// a well-formed assertion with the user present bit set. If `requireUV` is set, checks that the authenticator 68 | /// enforced user verification. User verification should be required if, and only if, options.userVerification 69 | /// is set to required in the request. 70 | /// - Verifies that the client JSON is of type "webauthn.get", i.e. the client was responding to a request to 71 | /// assert authentication. 72 | /// - Verifies that the client JSON contains the requested challenge. 73 | /// - Verifies that (r, s) constitute a valid signature over both the authenicatorData and client JSON, for public 74 | /// key (x, y). 75 | /// 76 | /// We make some assumptions about the particular use case of this verifier, so we do NOT verify the following: 77 | /// - Does NOT verify that the origin in the `clientDataJSON` matches the Relying Party's origin: tt is considered 78 | /// the authenticator's responsibility to ensure that the user is interacting with the correct RP. This is 79 | /// enforced by most high quality authenticators properly, particularly the iCloud Keychain and Google Password 80 | /// Manager were tested. 81 | /// - Does NOT verify That `topOrigin` in `clientDataJSON` is well-formed: We assume it would never be present, i.e. 82 | /// the credentials are never used in a cross-origin/iframe context. The website/app set up should disallow 83 | /// cross-origin usage of the credentials. This is the default behaviour for created credentials in common settings. 84 | /// - Does NOT verify that the `rpIdHash` in `authenticatorData` is the SHA-256 hash of the RP ID expected by the Relying 85 | /// Party: this means that we rely on the authenticator to properly enforce credentials to be used only by the correct RP. 86 | /// This is generally enforced with features like Apple App Site Association and Google Asset Links. To protect from 87 | /// edge cases in which a previously-linked RP ID is removed from the authorised RP IDs, we recommend that messages 88 | /// signed by the authenticator include some expiry mechanism. 89 | /// - Does NOT verify the credential backup state: this assumes the credential backup state is NOT used as part of Relying 90 | /// Party business logic or policy. 91 | /// - Does NOT verify the values of the client extension outputs: this assumes that the Relying Party does not use client 92 | /// extension outputs. 93 | /// - Does NOT verify the signature counter: signature counters are intended to enable risk scoring for the Relying Party. 94 | /// This assumes risk scoring is not used as part of Relying Party business logic or policy. 95 | /// - Does NOT verify the attestation object: this assumes that response.attestationObject is NOT present in the response, 96 | /// i.e. the RP does not intend to verify an attestation. 97 | /// 98 | /// @param challenge The challenge that was provided by the relying party. 99 | /// @param requireUV A boolean indicating whether user verification is required. 100 | /// @param webAuthnAuth The `WebAuthnAuth` struct. 101 | /// @param x The x coordinate of the public key. 102 | /// @param y The y coordinate of the public key. 103 | /// 104 | /// @return `true` if the authentication assertion passed validation, else `false`. 105 | function verify(bytes memory challenge, bool requireUV, WebAuthnAuth memory webAuthnAuth, uint256 x, uint256 y) 106 | internal 107 | view 108 | returns (bool) 109 | { 110 | if (webAuthnAuth.s > _P256_N_DIV_2) { 111 | // guard against signature malleability 112 | return false; 113 | } 114 | 115 | // 11. Verify that the value of C.type is the string webauthn.get. 116 | // bytes("type":"webauthn.get").length = 21 117 | string memory _type = webAuthnAuth.clientDataJSON.slice(webAuthnAuth.typeIndex, webAuthnAuth.typeIndex + 21); 118 | if (keccak256(bytes(_type)) != _EXPECTED_TYPE_HASH) { 119 | return false; 120 | } 121 | 122 | // 12. Verify that the value of C.challenge equals the base64url encoding of options.challenge. 123 | bytes memory expectedChallenge = bytes(string.concat('"challenge":"', Base64.encodeURL(challenge), '"')); 124 | string memory actualChallenge = 125 | webAuthnAuth.clientDataJSON.slice(webAuthnAuth.challengeIndex, webAuthnAuth.challengeIndex + expectedChallenge.length); 126 | if (keccak256(bytes(actualChallenge)) != keccak256(expectedChallenge)) { 127 | return false; 128 | } 129 | 130 | // Skip 13., 14., 15. 131 | 132 | // 16. Verify that the UP bit of the flags in authData is set. 133 | if (webAuthnAuth.authenticatorData[32] & _AUTH_DATA_FLAGS_UP != _AUTH_DATA_FLAGS_UP) { 134 | return false; 135 | } 136 | 137 | // 17. If user verification is required for this assertion, verify that the User Verified bit of the flags in 138 | // authData is set. 139 | if (requireUV && (webAuthnAuth.authenticatorData[32] & _AUTH_DATA_FLAGS_UV) != _AUTH_DATA_FLAGS_UV) { 140 | return false; 141 | } 142 | 143 | // skip 18. 144 | 145 | // 19. Let hash be the result of computing a hash over the cData using SHA-256. 146 | bytes32 clientDataJSONHash = sha256(bytes(webAuthnAuth.clientDataJSON)); 147 | 148 | // 20. Using credentialPublicKey, verify that sig is a valid signature over the binary concatenation of authData 149 | // and hash. 150 | bytes32 messageHash = sha256(abi.encodePacked(webAuthnAuth.authenticatorData, clientDataJSONHash)); 151 | bytes memory args = abi.encode(messageHash, webAuthnAuth.r, webAuthnAuth.s, x, y); 152 | // try the RIP-7212 precompile address 153 | (bool success, bytes memory ret) = _VERIFIER.staticcall(args); 154 | // staticcall will not revert if address has no code 155 | // check return length 156 | // note that even if precompile exists, ret.length is 0 when verification returns false 157 | // so an invalid signature will be checked twice: once by the precompile and once by FCL. 158 | // Ideally this signature failure is simulated offchain and no one actually pay this gas. 159 | bool valid = ret.length > 0; 160 | if (success && valid) return abi.decode(ret, (uint256)) == 1; 161 | 162 | return FCL_ecdsa.ecdsa_verify(messageHash, webAuthnAuth.r, webAuthnAuth.s, x, y); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /test/Utils.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {FCL_Elliptic_ZZ} from "FreshCryptoLib/FCL_elliptic.sol"; 5 | import {Base64Url} from "FreshCryptoLib/utils/Base64Url.sol"; 6 | 7 | struct WebAuthnInfo { 8 | bytes authenticatorData; 9 | string clientDataJSON; 10 | bytes32 messageHash; 11 | } 12 | 13 | library Utils { 14 | uint256 constant P256_N_DIV_2 = FCL_Elliptic_ZZ.n / 2; 15 | 16 | function getWebAuthnStruct(bytes32 challenge) public pure returns (WebAuthnInfo memory) { 17 | string memory challengeb64url = Base64Url.encode(abi.encode(challenge)); 18 | string memory clientDataJSON = string( 19 | abi.encodePacked( 20 | '{"type":"webauthn.get","challenge":"', challengeb64url, '","origin":"https://sign.coinbase.com","crossOrigin":false}' 21 | ) 22 | ); 23 | 24 | // Authenticator data for Chrome Profile touchID signature 25 | bytes memory authenticatorData = hex"49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630500000000"; 26 | 27 | bytes32 clientDataJSONHash = sha256(bytes(clientDataJSON)); 28 | bytes32 messageHash = sha256(abi.encodePacked(authenticatorData, clientDataJSONHash)); 29 | 30 | return WebAuthnInfo(authenticatorData, clientDataJSON, messageHash); 31 | } 32 | 33 | /// @dev normalizes the s value from a p256r1 signature so that 34 | /// it will pass malleability checks. 35 | function normalizeS(uint256 s) public pure returns (uint256) { 36 | if (s > P256_N_DIV_2) { 37 | return FCL_Elliptic_ZZ.n - s; 38 | } 39 | 40 | return s; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/WebAuthn.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Base64Url} from "FreshCryptoLib/utils/Base64Url.sol"; 5 | import {Test, console2} from "forge-std/Test.sol"; 6 | 7 | import {WebAuthn} from "../src/WebAuthn.sol"; 8 | 9 | contract WebAuthnTest is Test { 10 | bytes challenge = abi.encode(0xf631058a3ba1116acce12396fad0a125b5041c43f8e15723709f81aa8d5f4ccf); 11 | 12 | function test_safari() public { 13 | uint256 x = 28573233055232466711029625910063034642429572463461595413086259353299906450061; 14 | uint256 y = 39367742072897599771788408398752356480431855827262528811857788332151452825281; 15 | WebAuthn.WebAuthnAuth memory auth = WebAuthn.WebAuthnAuth({ 16 | authenticatorData: hex"49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630500000101", 17 | clientDataJSON: string.concat( 18 | '{"type":"webauthn.get","challenge":"', Base64Url.encode(challenge), '","origin":"http://localhost:3005"}' 19 | ), 20 | challengeIndex: 23, 21 | typeIndex: 1, 22 | r: 43684192885701841787131392247364253107519555363555461570655060745499568693242, 23 | s: 22655632649588629308599201066602670461698485748654492451178007896016452673579 24 | }); 25 | assertTrue(WebAuthn.verify(challenge, false, auth, x, y)); 26 | } 27 | 28 | function test_chrome() public { 29 | uint256 x = 28573233055232466711029625910063034642429572463461595413086259353299906450061; 30 | uint256 y = 39367742072897599771788408398752356480431855827262528811857788332151452825281; 31 | WebAuthn.WebAuthnAuth memory auth = WebAuthn.WebAuthnAuth({ 32 | authenticatorData: hex"49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d9763050000010a", 33 | clientDataJSON: string.concat( 34 | '{"type":"webauthn.get","challenge":"', Base64Url.encode(challenge), '","origin":"http://localhost:3005","crossOrigin":false}' 35 | ), 36 | challengeIndex: 23, 37 | typeIndex: 1, 38 | r: 29739767516584490820047863506833955097567272713519339793744591468032609909569, 39 | s: 45947455641742997809691064512762075989493430661170736817032030660832793108102 40 | }); 41 | assertTrue(WebAuthn.verify(challenge, false, auth, x, y)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/Webauthn.fuzz.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {FCL_ecdsa} from "FreshCryptoLib/FCL_ecdsa.sol"; 5 | import {Test, Vm, console, stdJson} from "forge-std/Test.sol"; 6 | 7 | import {WebAuthn} from "../src/WebAuthn.sol"; 8 | 9 | import "./Utils.sol"; 10 | 11 | contract WebAuthnFuzzTest is Test { 12 | using stdJson for string; 13 | 14 | string constant testFile = "/test/fixtures/assertions_fixture.json"; 15 | 16 | /// @dev `WebAuthn.verify` should return `false` when `s` is above P256_N_DIV_2. 17 | function test_Verify_ShoulReturnFalse_WhenSAboveP256_N_DIV_2() public { 18 | string memory rootPath = vm.projectRoot(); 19 | string memory path = string.concat(rootPath, testFile); 20 | string memory json = vm.readFile(path); 21 | uint256 count = abi.decode(json.parseRaw(".count"), (uint256)); 22 | 23 | for (uint256 i; i < count; i++) { 24 | ( 25 | string memory jsonCaseSelector, 26 | bytes memory challenge, 27 | bool uv, 28 | WebAuthn.WebAuthnAuth memory webAuthnAuth, 29 | uint256 x, 30 | uint256 y 31 | ) = _parseJson({json: json, caseIndex: i}); 32 | 33 | console.log("Veryfing", jsonCaseSelector); 34 | 35 | // Only interested in s > P256_N_DIV_2 cases. 36 | if (webAuthnAuth.s <= Utils.P256_N_DIV_2) { 37 | webAuthnAuth.s = FCL_ecdsa.n - webAuthnAuth.s; 38 | } 39 | 40 | bool res = WebAuthn.verify({challenge: challenge, requireUV: uv, webAuthnAuth: webAuthnAuth, x: x, y: y}); 41 | 42 | // Assert the verification failed to guard against signature malleability. 43 | assertEq(res, false, string.concat("Failed on ", jsonCaseSelector)); 44 | 45 | console.log("------------------------------------"); 46 | } 47 | } 48 | 49 | /// @dev `WebAuthn.verify` should return `false` when the `up` flag is not set. 50 | function test_Verify_ShoulReturnFalse_WhenTheUpFlagIsNotSet() public { 51 | string memory rootPath = vm.projectRoot(); 52 | string memory path = string.concat(rootPath, testFile); 53 | string memory json = vm.readFile(path); 54 | uint256 count = abi.decode(json.parseRaw(".count"), (uint256)); 55 | 56 | for (uint256 i; i < count; i++) { 57 | ( 58 | string memory jsonCaseSelector, 59 | bytes memory challenge, 60 | bool uv, 61 | WebAuthn.WebAuthnAuth memory webAuthnAuth, 62 | uint256 x, 63 | uint256 y 64 | ) = _parseJson({json: json, caseIndex: i}); 65 | 66 | console.log("Veryfing", jsonCaseSelector); 67 | 68 | webAuthnAuth.s = Utils.normalizeS(webAuthnAuth.s); 69 | 70 | // Unset the `up` flag. 71 | webAuthnAuth.authenticatorData[32] = webAuthnAuth.authenticatorData[32] & bytes1(0xfe); 72 | 73 | bool res = WebAuthn.verify({challenge: challenge, requireUV: uv, webAuthnAuth: webAuthnAuth, x: x, y: y}); 74 | 75 | // Assert the verification failed because the `up` flag was not set. 76 | assertEq(res, false, string.concat("Failed on ", jsonCaseSelector)); 77 | 78 | console.log("------------------------------------"); 79 | } 80 | } 81 | 82 | /// @dev `WebAuthn.verify` should return `false` when `requireUV` is `true` but the 83 | /// authenticator did not set the `uv` flag. 84 | function test_Verify_ShoulReturnFalse_WhenUserVerifictionIsRequiredButTestWasNotPerformed() public { 85 | string memory rootPath = vm.projectRoot(); 86 | string memory path = string.concat(rootPath, testFile); 87 | string memory json = vm.readFile(path); 88 | uint256 count = abi.decode(json.parseRaw(".count"), (uint256)); 89 | 90 | for (uint256 i; i < count; i++) { 91 | ( 92 | string memory jsonCaseSelector, 93 | bytes memory challenge, 94 | bool uv, 95 | WebAuthn.WebAuthnAuth memory webAuthnAuth, 96 | uint256 x, 97 | uint256 y 98 | ) = _parseJson({json: json, caseIndex: i}); 99 | 100 | console.log("Veryfing", jsonCaseSelector); 101 | 102 | // Only interested in s > P256_N_DIV_2 cases with uv not performed. 103 | if (uv == true) { 104 | continue; 105 | } 106 | 107 | webAuthnAuth.s = Utils.normalizeS(webAuthnAuth.s); 108 | 109 | bool res = WebAuthn.verify({ 110 | challenge: challenge, 111 | requireUV: true, // Set UV to required to ensure false is returned 112 | webAuthnAuth: webAuthnAuth, 113 | x: x, 114 | y: y 115 | }); 116 | 117 | // Assert the verification failed because user verification was required but not performed by the 118 | // authenticator. 119 | assertEq(res, false, string.concat("Failed on ", jsonCaseSelector)); 120 | 121 | console.log("------------------------------------"); 122 | } 123 | } 124 | 125 | /// @dev `WebAuthn.verify` should return `true` when `s` is below `P256_N_DIV_2` and `requireUV` 126 | /// "matches" with the `uv` flag set by the authenticator. 127 | function test_Verify_ShoulReturnTrue_WhenSBelowP256_N_DIV_2() public { 128 | string memory rootPath = vm.projectRoot(); 129 | string memory path = string.concat(rootPath, testFile); 130 | string memory json = vm.readFile(path); 131 | 132 | uint256 count = abi.decode(json.parseRaw(".count"), (uint256)); 133 | 134 | for (uint256 i; i < count; i++) { 135 | ( 136 | string memory jsonCaseSelector, 137 | bytes memory challenge, 138 | bool uv, 139 | WebAuthn.WebAuthnAuth memory webAuthnAuth, 140 | uint256 x, 141 | uint256 y 142 | ) = _parseJson({json: json, caseIndex: i}); 143 | 144 | console.log("Veryfing", jsonCaseSelector); 145 | 146 | webAuthnAuth.s = Utils.normalizeS(webAuthnAuth.s); 147 | 148 | bool res = WebAuthn.verify({challenge: challenge, requireUV: uv, webAuthnAuth: webAuthnAuth, x: x, y: y}); 149 | 150 | // Assert the verification succeeded. 151 | assertEq(res, true, string.concat("Failed on ", jsonCaseSelector)); 152 | console.log("------------------------------------"); 153 | } 154 | } 155 | 156 | /// @dev Helper function to parse a test case fron the given json string. 157 | /// @param json The json string to parse. 158 | /// @param caseIndex The test case index to parse. 159 | function _parseJson(string memory json, uint256 caseIndex) 160 | private 161 | pure 162 | returns ( 163 | string memory jsonCaseSelector, 164 | bytes memory challenge, 165 | bool uv, 166 | WebAuthn.WebAuthnAuth memory webAuthnAuth, 167 | uint256 x, 168 | uint256 y 169 | ) 170 | { 171 | jsonCaseSelector = string.concat(".cases.[", string.concat(vm.toString(caseIndex), "]")); 172 | challenge = abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".challenge")), (bytes)); 173 | uv = abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".uv")), (bool)); 174 | 175 | webAuthnAuth = WebAuthn.WebAuthnAuth({ 176 | authenticatorData: abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".authenticator_data")), (bytes)), 177 | clientDataJSON: abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".client_data_json.json")), (string)), 178 | challengeIndex: abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".client_data_json.challenge_index")), (uint256)), 179 | typeIndex: abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".client_data_json.type_index")), (uint256)), 180 | r: abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".r")), (uint256)), 181 | s: abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".s")), (uint256)) 182 | }); 183 | 184 | x = abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".x")), (uint256)); 185 | y = abi.decode(json.parseRaw(string.concat(jsonCaseSelector, ".y")), (uint256)); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /test/helpers/app.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import Process 2 | import sys 3 | from flask import Flask, render_template_string 4 | 5 | _app = Flask(__name__) 6 | 7 | 8 | # HTML template with JavaScript for WebAuthn 9 | HTML_TEMPLATE = ''' 10 | 11 | 12 | 13 | 14 | WebAuthn Test with Flask 15 | 16 | 17 |

WebAuthn Test Page

18 | 19 | 20 | 21 | 73 | 74 | 75 | ''' 76 | 77 | 78 | @_app.route('/') 79 | def index(): 80 | return render_template_string(HTML_TEMPLATE) 81 | 82 | 83 | def _start(): 84 | _app.run(host="127.0.0.1", port=5000) 85 | 86 | 87 | server = Process(target=_start) 88 | 89 | 90 | def listen(): 91 | server.start() 92 | 93 | 94 | def shutdown(): 95 | server.terminate() 96 | server.join() 97 | 98 | 99 | if __name__ == "__main__": 100 | listen() 101 | -------------------------------------------------------------------------------- /test/helpers/assertions_generator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import base64 4 | from base64 import urlsafe_b64decode 5 | import time 6 | import random 7 | import argparse 8 | 9 | from selenium import webdriver 10 | from selenium.webdriver.remote.webdriver import WebDriver 11 | from selenium.webdriver.common.by import By 12 | from selenium.webdriver.chrome.options import Options as ChromeOptions 13 | from selenium.webdriver.chrome.service import Service as ChromeService 14 | from selenium.webdriver.common.virtual_authenticator import ( 15 | Credential, 16 | VirtualAuthenticatorOptions, 17 | ) 18 | 19 | from cryptography.hazmat.backends import default_backend 20 | from cryptography.hazmat.primitives import serialization 21 | from cryptography.hazmat.primitives import hashes 22 | from cryptography.hazmat.primitives.asymmetric import utils 23 | from cryptography.hazmat.primitives.asymmetric import ec 24 | 25 | import app 26 | 27 | 28 | def _build_chrome_webdriver(): 29 | # Set up Chrome options 30 | options = ChromeOptions() 31 | options.add_argument("--headless") # Ensure GUI is off 32 | options.add_argument("--no-sandbox") # Bypass OS security model 33 | options.add_argument( 34 | "--disable-dev-shm-usage" 35 | ) # Overcome limited resource problems 36 | options.add_argument("--enable-logging") 37 | options.add_argument("--v=1") 38 | options.set_capability("goog:loggingPrefs", {'browser': 'ALL'}) 39 | 40 | service = ChromeService(executable_path="/usr/bin/chromedriver") 41 | 42 | return webdriver.Chrome( 43 | options=options, service=service) 44 | 45 | 46 | def _generate_private_key(): 47 | private_key = ec.generate_private_key(ec.SECP256R1()) 48 | 49 | pem_private_key = private_key.private_bytes( 50 | encoding=serialization.Encoding.PEM, 51 | format=serialization.PrivateFormat.PKCS8, 52 | # or use BestAvailableEncryption for encryption 53 | encryption_algorithm=serialization.NoEncryption(), 54 | ) 55 | 56 | public_key = private_key.public_key() 57 | x = public_key.public_numbers().x 58 | y = public_key.public_numbers().y 59 | 60 | private_key_as_b64 = "\n".join(pem_private_key.decode().split("\n")[1:-2]) 61 | 62 | return (x, y, private_key_as_b64) 63 | 64 | 65 | def _add_virtual_authenticator(driver: WebDriver): 66 | options = VirtualAuthenticatorOptions() 67 | options.transport = VirtualAuthenticatorOptions.Transport.USB 68 | options.protocol = VirtualAuthenticatorOptions.Protocol.CTAP2 69 | 70 | uv = random.choice([True, False]) 71 | options.is_user_verified = uv 72 | options.is_user_consenting = True 73 | options.has_user_verification = uv 74 | options.has_resident_key = random.choice([True, False]) 75 | 76 | driver.add_virtual_authenticator(options) 77 | 78 | return uv 79 | 80 | 81 | def _remove_virtual_authenticator(driver: WebDriver): 82 | driver.remove_virtual_authenticator() 83 | 84 | 85 | def _create_credential(driver: WebDriver, private_key_as_b64: str): 86 | credential_id = bytearray(b"coinbase") 87 | rp_id = "localhost" 88 | private_key = urlsafe_b64decode(private_key_as_b64) 89 | sign_count = 0 90 | 91 | credential = Credential.create_non_resident_credential( 92 | credential_id, rp_id, private_key, sign_count 93 | ) 94 | 95 | driver.add_credential(credential) 96 | 97 | return rp_id 98 | 99 | 100 | def _trigger_assertion(driver: WebDriver, rp_id: str) -> tuple[str, str, str, str]: 101 | def _generate_random_string(length): 102 | # ASCII characters from space (32) to tilde (126) 103 | ascii_characters = ''.join(chr(i) for i in range(32, 127)) 104 | # Generate random string 105 | random_string = ''.join(random.choice(ascii_characters) 106 | for _ in range(length)) 107 | return random_string 108 | 109 | challenge = _generate_random_string(random.randint(1, 1000)) 110 | 111 | js_code = f"setRpId('{rp_id}')" 112 | driver.execute_script(js_code) 113 | 114 | input_element = driver.find_element(By.ID, "challengeInput") 115 | input_element.clear() 116 | input_element.send_keys(challenge) 117 | 118 | button = driver.find_element(By.ID, "authButton") 119 | button.click() 120 | driver.implicitly_wait(0.5) 121 | 122 | js_code = "return arrayBufferToBase64Sync(globalAssertion.response.authenticatorData);" 123 | authenticator_data_as_b64 = driver.execute_script(js_code) 124 | authenticator_data = f"0x{base64.b64decode(authenticator_data_as_b64).hex()}" 125 | 126 | js_code = "return arrayBufferToBase64Sync(globalAssertion.response.signature);" 127 | signature_as_b64 = driver.execute_script(js_code) 128 | signature = base64.b64decode(signature_as_b64) 129 | 130 | js_code = "return getClientDataJson();" 131 | client_data_json = driver.execute_script( 132 | js_code) 133 | 134 | return challenge, authenticator_data, signature, client_data_json 135 | 136 | 137 | def _generate(size: int): 138 | app.listen() 139 | time.sleep(2) 140 | 141 | # TODO: Integrate other drivers (firefox, safari etc.) 142 | driver = _build_chrome_webdriver() 143 | driver.get("http://localhost:5000") 144 | driver.implicitly_wait(0.5) 145 | 146 | results = [] 147 | while len(results) < size: 148 | uv = _add_virtual_authenticator(driver) 149 | 150 | x, y, private_key_as_b64 = _generate_private_key() 151 | 152 | rp_id = _create_credential(driver, private_key_as_b64) 153 | challenge, authenticator_data, signature, client_data_json = _trigger_assertion( 154 | driver, rp_id) 155 | 156 | _remove_virtual_authenticator(driver) 157 | 158 | r, s = utils.decode_dss_signature(signature) 159 | 160 | # NOTE: Intentionally keep those in the test cases to ensure the smart contract is correctly protected against signature malleability 161 | # if s > P256_N_DIV_2: 162 | # print('Skipping because s too big') 163 | # continue 164 | 165 | result = {} 166 | result["uv"] = uv 167 | result["x"] = x 168 | result["y"] = y 169 | result["challenge"] = challenge 170 | result["r"] = r 171 | result["s"] = s 172 | result["authenticator_data"] = authenticator_data 173 | result["client_data_json"] = { 174 | "json": client_data_json, 175 | "type_index": client_data_json.find("\"type\":"), 176 | "challenge_index": client_data_json.find("\"challenge\":") 177 | } 178 | results.append(result) 179 | 180 | driver.quit() 181 | app.shutdown() 182 | 183 | dir_name = os.path.dirname(os.path.realpath(__file__)) 184 | obj = {"count": len(results), "cases": results} 185 | with open(f"{dir_name}/../fixtures/assertions_fixture.json", "w") as json_file: 186 | json_str = json.dumps(obj, indent=4) 187 | json_file.write(json_str) 188 | 189 | 190 | def _parse_args(): 191 | parser = argparse.ArgumentParser() 192 | parser.add_argument( 193 | "count", help="Number of assertions to generate", type=int, ) 194 | return parser.parse_args() 195 | 196 | 197 | def main(args): 198 | count = args.count 199 | _generate(count) 200 | 201 | 202 | if __name__ == "__main__": 203 | args = _parse_args() 204 | main(args) 205 | -------------------------------------------------------------------------------- /test/helpers/readme.md: -------------------------------------------------------------------------------- 1 | # WebAuthn Assertions Generator 2 | 3 | The `assertions_generator.py` script is a utility script that dynamically generates WebAuthn assertions and store them in a json file (used for fuzz tests). 4 | 5 | When executing `assertions_generator.py` a [Flask](https://flask.palletsprojects.com/en/3.0.x/) app (see `app.py`) is launched and interacted with through a headless **Chrome** browser using [Selenium](https://selenium-python.readthedocs.io/) to generate valid WebAuthn assertions. 6 | 7 | ## Installation 8 | 9 | Use the package manager [pip](https://pip.pypa.io/en/stable/) to install the requirements: 10 | 11 | ```bash 12 | pip install -r ./requirements.txt 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```bash 18 | $ python3 ./assertions_generator.py --help 19 | 20 | usage: assertions_generator.py [-h] count 21 | 22 | positional arguments: 23 | count Number of assertions to generate 24 | 25 | optional arguments: 26 | -h, --help show this help message and exit 27 | ``` -------------------------------------------------------------------------------- /test/helpers/requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | selenium 3 | cryptography --------------------------------------------------------------------------------