├── test
├── helpers
│ ├── requirements.txt
│ ├── readme.md
│ ├── app.py
│ └── assertions_generator.py
├── Utils.sol
├── WebAuthn.t.sol
└── Webauthn.fuzz.t.sol
├── audits
└── report-review-coinbase-webauthn.pdf
├── foundry.toml
├── .gitignore
├── .gas-snapshot
├── .gitmodules
├── .github
└── workflows
│ └── test.yml
├── LICENSE.md
├── README.md
└── src
└── WebAuthn.sol
/test/helpers/requirements.txt:
--------------------------------------------------------------------------------
1 | flask
2 | selenium
3 | cryptography
--------------------------------------------------------------------------------
/audits/report-review-coinbase-webauthn.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KarenAuhcy4ka/webauthn-sol/HEAD/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
--------------------------------------------------------------------------------
/.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__
--------------------------------------------------------------------------------
/.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)
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 | ```
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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.
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | ```
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------