|
| 1 | +// SPDX-License-Identifier: MIT |
| 2 | + |
| 3 | +pragma solidity ^0.8.24; |
| 4 | + |
| 5 | +import {P256} from "./P256.sol"; |
| 6 | +import {Base64} from "../Base64.sol"; |
| 7 | +import {Bytes} from "../Bytes.sol"; |
| 8 | +import {Strings} from "../Strings.sol"; |
| 9 | + |
| 10 | +/** |
| 11 | + * @dev Library for verifying WebAuthn Authentication Assertions. |
| 12 | + * |
| 13 | + * WebAuthn enables strong authentication for smart contracts using |
| 14 | + * https://docs.openzeppelin.com/contracts/5.x/api/utils#P256[P256] |
| 15 | + * as an alternative to traditional secp256k1 ECDSA signatures. This library verifies |
| 16 | + * signatures generated during WebAuthn authentication ceremonies as specified in the |
| 17 | + * https://www.w3.org/TR/webauthn-2/[WebAuthn Level 2 standard]. |
| 18 | + * |
| 19 | + * For blockchain use cases, the following WebAuthn validations are intentionally omitted: |
| 20 | + * |
| 21 | + * * Origin validation: Origin verification in `clientDataJSON` is omitted as blockchain |
| 22 | + * contexts rely on authenticator and dapp frontend enforcement. Standard authenticators |
| 23 | + * implement proper origin validation. |
| 24 | + * * RP ID hash validation: Verification of `rpIdHash` in authenticatorData against expected |
| 25 | + * RP ID hash is omitted. This is typically handled by platform-level security measures. |
| 26 | + * Including an expiry timestamp in signed data is recommended for enhanced security. |
| 27 | + * * Signature counter: Verification of signature counter increments is omitted. While |
| 28 | + * useful for detecting credential cloning, on-chain operations typically include nonce |
| 29 | + * protection, making this check redundant. |
| 30 | + * * Extension outputs: Extension output value verification is omitted as these are not |
| 31 | + * essential for core authentication security in blockchain applications. |
| 32 | + * * Attestation: Attestation object verification is omitted as this implementation |
| 33 | + * focuses on authentication (`webauthn.get`) rather than registration ceremonies. |
| 34 | + * |
| 35 | + * Inspired by: |
| 36 | + * |
| 37 | + * * https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol[daimo-eth implementation] |
| 38 | + * * https://github.com/base/webauthn-sol/blob/main/src/WebAuthn.sol[base implementation] |
| 39 | + */ |
| 40 | +library WebAuthn { |
| 41 | + struct WebAuthnAuth { |
| 42 | + bytes32 r; /// The r value of secp256r1 signature |
| 43 | + bytes32 s; /// The s value of secp256r1 signature |
| 44 | + uint256 challengeIndex; /// The index at which "challenge":"..." occurs in `clientDataJSON`. |
| 45 | + uint256 typeIndex; /// The index at which "type":"..." occurs in `clientDataJSON`. |
| 46 | + /// The WebAuthn authenticator data. |
| 47 | + /// https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-authenticatordata |
| 48 | + bytes authenticatorData; |
| 49 | + /// The WebAuthn client data JSON. |
| 50 | + /// https://www.w3.org/TR/webauthn-2/#dom-authenticatorresponse-clientdatajson |
| 51 | + string clientDataJSON; |
| 52 | + } |
| 53 | + |
| 54 | + /// @dev Bit 0 of the authenticator data flags: "User Present" bit. |
| 55 | + bytes1 internal constant AUTH_DATA_FLAGS_UP = 0x01; |
| 56 | + /// @dev Bit 2 of the authenticator data flags: "User Verified" bit. |
| 57 | + bytes1 internal constant AUTH_DATA_FLAGS_UV = 0x04; |
| 58 | + /// @dev Bit 3 of the authenticator data flags: "Backup Eligibility" bit. |
| 59 | + bytes1 internal constant AUTH_DATA_FLAGS_BE = 0x08; |
| 60 | + /// @dev Bit 4 of the authenticator data flags: "Backup State" bit. |
| 61 | + bytes1 internal constant AUTH_DATA_FLAGS_BS = 0x10; |
| 62 | + |
| 63 | + /** |
| 64 | + * @dev Performs standard verification of a WebAuthn Authentication Assertion. |
| 65 | + */ |
| 66 | + function verify( |
| 67 | + bytes memory challenge, |
| 68 | + WebAuthnAuth memory auth, |
| 69 | + bytes32 qx, |
| 70 | + bytes32 qy |
| 71 | + ) internal view returns (bool) { |
| 72 | + return verify(challenge, auth, qx, qy, true); |
| 73 | + } |
| 74 | + |
| 75 | + /** |
| 76 | + * @dev Performs verification of a WebAuthn Authentication Assertion. This variants allow the caller to select |
| 77 | + * whether of not to require the UV flag (step 17). |
| 78 | + * |
| 79 | + * Verifies: |
| 80 | + * |
| 81 | + * 1. Type is "webauthn.get" (see {_validateExpectedTypeHash}) |
| 82 | + * 2. Challenge matches the expected value (see {_validateChallenge}) |
| 83 | + * 3. Cryptographic signature is valid for the given public key |
| 84 | + * 4. confirming physical user presence during authentication |
| 85 | + * 5. (if `requireUV` is true) confirming stronger user authentication (biometrics/PIN) |
| 86 | + * 6. Backup Eligibility (`BE`) and Backup State (BS) bits relationship is valid |
| 87 | + */ |
| 88 | + function verify( |
| 89 | + bytes memory challenge, |
| 90 | + WebAuthnAuth memory auth, |
| 91 | + bytes32 qx, |
| 92 | + bytes32 qy, |
| 93 | + bool requireUV |
| 94 | + ) internal view returns (bool) { |
| 95 | + // Verify authenticator data has sufficient length (37 bytes minimum): |
| 96 | + // - 32 bytes for rpIdHash |
| 97 | + // - 1 byte for flags |
| 98 | + // - 4 bytes for signature counter |
| 99 | + return |
| 100 | + auth.authenticatorData.length > 36 && |
| 101 | + _validateExpectedTypeHash(auth.clientDataJSON, auth.typeIndex) && // 11 |
| 102 | + _validateChallenge(auth.clientDataJSON, auth.challengeIndex, challenge) && // 12 |
| 103 | + _validateUserPresentBitSet(auth.authenticatorData[32]) && // 16 |
| 104 | + (!requireUV || _validateUserVerifiedBitSet(auth.authenticatorData[32])) && // 17 |
| 105 | + _validateBackupEligibilityAndState(auth.authenticatorData[32]) && // Consistency check |
| 106 | + // P256.verify handles signature malleability internally |
| 107 | + P256.verify( |
| 108 | + sha256( |
| 109 | + abi.encodePacked( |
| 110 | + auth.authenticatorData, |
| 111 | + sha256(bytes(auth.clientDataJSON)) // 19 |
| 112 | + ) |
| 113 | + ), |
| 114 | + auth.r, |
| 115 | + auth.s, |
| 116 | + qx, |
| 117 | + qy |
| 118 | + ); // 20 |
| 119 | + } |
| 120 | + |
| 121 | + /** |
| 122 | + * @dev Validates that the https://www.w3.org/TR/webauthn-2/#type[Type] field in the client data JSON is set to |
| 123 | + * "webauthn.get". |
| 124 | + * |
| 125 | + * Step 11 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. |
| 126 | + */ |
| 127 | + function _validateExpectedTypeHash( |
| 128 | + string memory clientDataJSON, |
| 129 | + uint256 typeIndex |
| 130 | + ) private pure returns (bool success) { |
| 131 | + assembly ("memory-safe") { |
| 132 | + success := and( |
| 133 | + // clientDataJson.length >= typeIndex + 21 |
| 134 | + gt(mload(clientDataJSON), add(typeIndex, 20)), |
| 135 | + eq( |
| 136 | + // get 32 bytes starting at index typexIndex in clientDataJSON, and keep the leftmost 21 bytes |
| 137 | + and(mload(add(add(clientDataJSON, 0x20), typeIndex)), shl(88, not(0))), |
| 138 | + // solhint-disable-next-line quotes |
| 139 | + '"type":"webauthn.get"' |
| 140 | + ) |
| 141 | + ) |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + /** |
| 146 | + * @dev Validates that the challenge in the client data JSON matches the `expectedChallenge`. |
| 147 | + * |
| 148 | + * Step 12 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. |
| 149 | + */ |
| 150 | + function _validateChallenge( |
| 151 | + string memory clientDataJSON, |
| 152 | + uint256 challengeIndex, |
| 153 | + bytes memory challenge |
| 154 | + ) private pure returns (bool) { |
| 155 | + // solhint-disable-next-line quotes |
| 156 | + string memory expectedChallenge = string.concat('"challenge":"', Base64.encodeURL(challenge), '"'); |
| 157 | + string memory actualChallenge = string( |
| 158 | + Bytes.slice(bytes(clientDataJSON), challengeIndex, challengeIndex + bytes(expectedChallenge).length) |
| 159 | + ); |
| 160 | + |
| 161 | + return Strings.equal(actualChallenge, expectedChallenge); |
| 162 | + } |
| 163 | + |
| 164 | + /** |
| 165 | + * @dev Validates that the https://www.w3.org/TR/webauthn-2/#up[User Present (UP)] bit is set. |
| 166 | + * |
| 167 | + * Step 16 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. |
| 168 | + * |
| 169 | + * NOTE: Required by WebAuthn spec but may be skipped for platform authenticators |
| 170 | + * (Touch ID, Windows Hello) in controlled environments. Enforce for public-facing apps. |
| 171 | + */ |
| 172 | + function _validateUserPresentBitSet(bytes1 flags) private pure returns (bool) { |
| 173 | + return (flags & AUTH_DATA_FLAGS_UP) == AUTH_DATA_FLAGS_UP; |
| 174 | + } |
| 175 | + |
| 176 | + /** |
| 177 | + * @dev Validates that the https://www.w3.org/TR/webauthn-2/#uv[User Verified (UV)] bit is set. |
| 178 | + * |
| 179 | + * Step 17 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. |
| 180 | + * |
| 181 | + * The UV bit indicates whether the user was verified using a stronger identification method |
| 182 | + * (biometrics, PIN, password). While optional, requiring UV=1 is recommended for: |
| 183 | + * |
| 184 | + * * High-value transactions and sensitive operations |
| 185 | + * * Account recovery and critical settings changes |
| 186 | + * * Privileged operations |
| 187 | + * |
| 188 | + * NOTE: For routine operations or when using hardware authenticators without verification capabilities, |
| 189 | + * `UV=0` may be acceptable. The choice of whether to require UV represents a security vs. usability |
| 190 | + * tradeoff - for blockchain applications handling valuable assets, requiring UV is generally safer. |
| 191 | + */ |
| 192 | + function _validateUserVerifiedBitSet(bytes1 flags) private pure returns (bool) { |
| 193 | + return (flags & AUTH_DATA_FLAGS_UV) == AUTH_DATA_FLAGS_UV; |
| 194 | + } |
| 195 | + |
| 196 | + /** |
| 197 | + * @dev Validates the relationship between Backup Eligibility (`BE`) and Backup State (`BS`) bits |
| 198 | + * according to the WebAuthn specification. |
| 199 | + * |
| 200 | + * The function enforces that if a credential is backed up (`BS=1`), it must also be eligible |
| 201 | + * for backup (`BE=1`). This prevents unauthorized credential backup and ensures compliance |
| 202 | + * with the WebAuthn spec. |
| 203 | + * |
| 204 | + * Returns true in these valid states: |
| 205 | + * |
| 206 | + * * `BE=1`, `BS=0`: Credential is eligible but not backed up |
| 207 | + * * `BE=1`, `BS=1`: Credential is eligible and backed up |
| 208 | + * * `BE=0`, `BS=0`: Credential is not eligible and not backed up |
| 209 | + * |
| 210 | + * Returns false only when `BE=0` and `BS=1`, which is an invalid state indicating |
| 211 | + * a credential that's backed up but not eligible for backup. |
| 212 | + * |
| 213 | + * NOTE: While the WebAuthn spec defines this relationship between `BE` and `BS` bits, |
| 214 | + * validating it is not explicitly required as part of the core verification procedure. |
| 215 | + * Some implementations may choose to skip this check for broader authenticator |
| 216 | + * compatibility or when the application's threat model doesn't consider credential |
| 217 | + * syncing a major risk. |
| 218 | + */ |
| 219 | + function _validateBackupEligibilityAndState(bytes1 flags) private pure returns (bool) { |
| 220 | + return (flags & AUTH_DATA_FLAGS_BE) == AUTH_DATA_FLAGS_BE || (flags & AUTH_DATA_FLAGS_BS) == 0; |
| 221 | + } |
| 222 | + |
| 223 | + /** |
| 224 | + * @dev Verifies that calldata bytes (`input`) represents a valid `WebAuthnAuth` object. If encoding is valid, |
| 225 | + * returns true and the calldata view at the object. Otherwise, returns false and an invalid calldata object. |
| 226 | + * |
| 227 | + * NOTE: The returned `auth` object should not be accessed if `success` is false. Trying to access the data may |
| 228 | + * cause revert/panic. |
| 229 | + */ |
| 230 | + function tryDecodeAuth(bytes calldata input) internal pure returns (bool success, WebAuthnAuth calldata auth) { |
| 231 | + assembly ("memory-safe") { |
| 232 | + auth := input.offset |
| 233 | + } |
| 234 | + |
| 235 | + // Minimum length to hold 6 objects (32 bytes each) |
| 236 | + if (input.length < 0xC0) return (false, auth); |
| 237 | + |
| 238 | + // Get offset of non-value-type elements relative to the input buffer |
| 239 | + uint256 authenticatorDataOffset = uint256(bytes32(input[0x80:])); |
| 240 | + uint256 clientDataJSONOffset = uint256(bytes32(input[0xa0:])); |
| 241 | + |
| 242 | + // The elements length (at the offset) should be 32 bytes long. We check that this is within the |
| 243 | + // buffer bounds. Since we know input.length is at least 32, we can subtract with no overflow risk. |
| 244 | + if (input.length - 0x20 < authenticatorDataOffset || input.length - 0x20 < clientDataJSONOffset) |
| 245 | + return (false, auth); |
| 246 | + |
| 247 | + // Get the lengths. offset + 32 is bounded by input.length so it does not overflow. |
| 248 | + uint256 authenticatorDataLength = uint256(bytes32(input[authenticatorDataOffset:])); |
| 249 | + uint256 clientDataJSONLength = uint256(bytes32(input[clientDataJSONOffset:])); |
| 250 | + |
| 251 | + // Check that the input buffer is long enough to store the non-value-type elements |
| 252 | + // Since we know input.length is at least xxxOffset + 32, we can subtract with no overflow risk. |
| 253 | + if ( |
| 254 | + input.length - authenticatorDataOffset - 0x20 < authenticatorDataLength || |
| 255 | + input.length - clientDataJSONOffset - 0x20 < clientDataJSONLength |
| 256 | + ) return (false, auth); |
| 257 | + |
| 258 | + return (true, auth); |
| 259 | + } |
| 260 | +} |
0 commit comments