|
| 1 | +// SPDX-License-Identifier: MIT |
| 2 | + |
| 3 | +pragma solidity ^0.8.24; |
| 4 | + |
| 5 | +import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; |
| 6 | +import {Bytes} from "@openzeppelin/contracts/utils/Bytes.sol"; |
| 7 | +import {P256} from "@openzeppelin/contracts/utils/cryptography/P256.sol"; |
| 8 | +import {Strings} from "@openzeppelin/contracts/utils/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 private constant AUTH_DATA_FLAGS_UP = 0x01; |
| 56 | + /// @dev Bit 2 of the authenticator data flags: "User Verified" bit. |
| 57 | + bytes1 private constant AUTH_DATA_FLAGS_UV = 0x04; |
| 58 | + /// @dev Bit 3 of the authenticator data flags: "Backup Eligibility" bit. |
| 59 | + bytes1 private constant AUTH_DATA_FLAGS_BE = 0x08; |
| 60 | + /// @dev Bit 4 of the authenticator data flags: "Backup State" bit. |
| 61 | + bytes1 private constant AUTH_DATA_FLAGS_BS = 0x10; |
| 62 | + |
| 63 | + /// @dev The expected type string in the client data JSON when verifying assertion signatures. |
| 64 | + /// https://www.w3.org/TR/webauthn-2/#dom-collectedclientdata-type |
| 65 | + // solhint-disable-next-line quotes |
| 66 | + bytes32 private constant EXPECTED_TYPE_HASH = keccak256('"type":"webauthn.get"'); |
| 67 | + |
| 68 | + /** |
| 69 | + * @dev Performs the absolute minimal verification of a WebAuthn Authentication Assertion. |
| 70 | + * This function includes only the essential checks required for basic WebAuthn security: |
| 71 | + * |
| 72 | + * 1. Type is "webauthn.get" (see {validateExpectedTypeHash}) |
| 73 | + * 2. Challenge matches the expected value (see {validateChallenge}) |
| 74 | + * 3. Cryptographic signature is valid for the given public key |
| 75 | + * |
| 76 | + * For most applications, use {verify} or {verifyStrict} instead. |
| 77 | + * |
| 78 | + * NOTE: This function intentionally omits User Presence (UP), User Verification (UV), |
| 79 | + * and Backup State/Eligibility checks. Use this only when broader compatibility with |
| 80 | + * authenticators is required or in constrained environments. |
| 81 | + */ |
| 82 | + function verifyMinimal( |
| 83 | + bytes memory challenge, |
| 84 | + WebAuthnAuth memory auth, |
| 85 | + bytes32 qx, |
| 86 | + bytes32 qy |
| 87 | + ) internal view returns (bool) { |
| 88 | + // Verify authenticator data has sufficient length (37 bytes minimum): |
| 89 | + // - 32 bytes for rpIdHash |
| 90 | + // - 1 byte for flags |
| 91 | + // - 4 bytes for signature counter |
| 92 | + if (auth.authenticatorData.length < 37) return false; |
| 93 | + bytes memory clientDataJSON = bytes(auth.clientDataJSON); |
| 94 | + |
| 95 | + return |
| 96 | + validateExpectedTypeHash(clientDataJSON, auth.typeIndex) && // 11 |
| 97 | + validateChallenge(clientDataJSON, auth.challengeIndex, challenge) && // 12 |
| 98 | + // Handles signature malleability internally |
| 99 | + P256.verify( |
| 100 | + sha256( |
| 101 | + abi.encodePacked( |
| 102 | + auth.authenticatorData, |
| 103 | + sha256(clientDataJSON) // 19 |
| 104 | + ) |
| 105 | + ), |
| 106 | + auth.r, |
| 107 | + auth.s, |
| 108 | + qx, |
| 109 | + qy |
| 110 | + ); // 20 |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * @dev Performs standard verification of a WebAuthn Authentication Assertion. |
| 115 | + * |
| 116 | + * Same as {verifyMinimal}, but also verifies: |
| 117 | + * |
| 118 | + * [start=4] |
| 119 | + * 4. {validateUserPresentBitSet} - confirming physical user presence during authentication |
| 120 | + * |
| 121 | + * This compliance level satisfies the core WebAuthn verification requirements while |
| 122 | + * maintaining broad compatibility with authenticators. For higher security requirements, |
| 123 | + * consider using {verifyStrict}. |
| 124 | + */ |
| 125 | + function verify( |
| 126 | + bytes memory challenge, |
| 127 | + WebAuthnAuth memory auth, |
| 128 | + bytes32 qx, |
| 129 | + bytes32 qy |
| 130 | + ) internal view returns (bool) { |
| 131 | + // 16 && rest |
| 132 | + return validateUserPresentBitSet(auth.authenticatorData[32]) && verifyMinimal(challenge, auth, qx, qy); |
| 133 | + } |
| 134 | + |
| 135 | + /** |
| 136 | + * @dev Performs strict verification of a WebAuthn Authentication Assertion. |
| 137 | + * |
| 138 | + * Same as {verify}, but also also verifies: |
| 139 | + * |
| 140 | + * [start=5] |
| 141 | + * 5. {validateUserVerifiedBitSet} - confirming stronger user authentication (biometrics/PIN) |
| 142 | + * 6. {validateBackupEligibilityAndState}- Backup Eligibility (`BE`) and Backup State (BS) bits |
| 143 | + * relationship is valid |
| 144 | + * |
| 145 | + * This strict verification is recommended for: |
| 146 | + * |
| 147 | + * * High-value transactions |
| 148 | + * * Privileged operations |
| 149 | + * * Account recovery or critical settings changes |
| 150 | + * * Applications where security takes precedence over broad authenticator compatibility |
| 151 | + */ |
| 152 | + function verifyStrict( |
| 153 | + bytes memory challenge, |
| 154 | + WebAuthnAuth memory auth, |
| 155 | + bytes32 qx, |
| 156 | + bytes32 qy |
| 157 | + ) internal view returns (bool) { |
| 158 | + return |
| 159 | + validateUserVerifiedBitSet(auth.authenticatorData[32]) && // 17 |
| 160 | + validateBackupEligibilityAndState(auth.authenticatorData[32]) && // Consistency check |
| 161 | + verify(challenge, auth, qx, qy); |
| 162 | + } |
| 163 | + |
| 164 | + /** |
| 165 | + * @dev Validates that the https://www.w3.org/TR/webauthn-2/#up[User Present (UP)] bit is set. |
| 166 | + * Step 16 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. |
| 167 | + * |
| 168 | + * NOTE: Required by WebAuthn spec but may be skipped for platform authenticators |
| 169 | + * (Touch ID, Windows Hello) in controlled environments. Enforce for public-facing apps. |
| 170 | + */ |
| 171 | + function validateUserPresentBitSet(bytes1 flags) internal pure returns (bool) { |
| 172 | + return (flags & AUTH_DATA_FLAGS_UP) == AUTH_DATA_FLAGS_UP; |
| 173 | + } |
| 174 | + |
| 175 | + /** |
| 176 | + * @dev Validates that the https://www.w3.org/TR/webauthn-2/#uv[User Verified (UV)] bit is set. |
| 177 | + * Step 17 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion]. |
| 178 | + * |
| 179 | + * The UV bit indicates whether the user was verified using a stronger identification method |
| 180 | + * (biometrics, PIN, password). While optional, requiring UV=1 is recommended for: |
| 181 | + * |
| 182 | + * * High-value transactions and sensitive operations |
| 183 | + * * Account recovery and critical settings changes |
| 184 | + * * Privileged operations |
| 185 | + * |
| 186 | + * NOTE: For routine operations or when using hardware authenticators without verification capabilities, |
| 187 | + * `UV=0` may be acceptable. The choice of whether to require UV represents a security vs. usability |
| 188 | + * tradeoff - for blockchain applications handling valuable assets, requiring UV is generally safer. |
| 189 | + */ |
| 190 | + function validateUserVerifiedBitSet(bytes1 flags) internal pure returns (bool) { |
| 191 | + return (flags & AUTH_DATA_FLAGS_UV) == AUTH_DATA_FLAGS_UV; |
| 192 | + } |
| 193 | + |
| 194 | + /** |
| 195 | + * @dev Validates the relationship between Backup Eligibility (`BE`) and Backup State (`BS`) bits |
| 196 | + * according to the WebAuthn specification. |
| 197 | + * |
| 198 | + * The function enforces that if a credential is backed up (`BS=1`), it must also be eligible |
| 199 | + * for backup (`BE=1`). This prevents unauthorized credential backup and ensures compliance |
| 200 | + * with the WebAuthn spec. |
| 201 | + * |
| 202 | + * Returns true in these valid states: |
| 203 | + * |
| 204 | + * * `BE=1`, `BS=0`: Credential is eligible but not backed up |
| 205 | + * * `BE=1`, `BS=1`: Credential is eligible and backed up |
| 206 | + * * `BE=0`, `BS=0`: Credential is not eligible and not backed up |
| 207 | + * |
| 208 | + * Returns false only when `BE=0` and `BS=1`, which is an invalid state indicating |
| 209 | + * a credential that's backed up but not eligible for backup. |
| 210 | + * |
| 211 | + * NOTE: While the WebAuthn spec defines this relationship between `BE` and `BS` bits, |
| 212 | + * validating it is not explicitly required as part of the core verification procedure. |
| 213 | + * Some implementations may choose to skip this check for broader authenticator |
| 214 | + * compatibility or when the application's threat model doesn't consider credential |
| 215 | + * syncing a major risk. |
| 216 | + */ |
| 217 | + function validateBackupEligibilityAndState(bytes1 flags) internal pure returns (bool) { |
| 218 | + return (flags & AUTH_DATA_FLAGS_BE) != 0 || (flags & AUTH_DATA_FLAGS_BS) == 0; |
| 219 | + } |
| 220 | + |
| 221 | + /** |
| 222 | + * @dev Validates that the https://www.w3.org/TR/webauthn-2/#type[Type] field in the client data JSON |
| 223 | + * is set to "webauthn.get". |
| 224 | + */ |
| 225 | + function validateExpectedTypeHash(bytes memory clientDataJSON, uint256 typeIndex) internal pure returns (bool) { |
| 226 | + // 21 = length of '"type":"webauthn.get"' |
| 227 | + bytes memory typeValueBytes = Bytes.slice(clientDataJSON, typeIndex, typeIndex + 21); |
| 228 | + return keccak256(typeValueBytes) == EXPECTED_TYPE_HASH; |
| 229 | + } |
| 230 | + |
| 231 | + /// @dev Validates that the challenge in the client data JSON matches the `expectedChallenge`. |
| 232 | + function validateChallenge( |
| 233 | + bytes memory clientDataJSON, |
| 234 | + uint256 challengeIndex, |
| 235 | + bytes memory expectedChallenge |
| 236 | + ) internal pure returns (bool) { |
| 237 | + bytes memory expectedChallengeBytes = bytes( |
| 238 | + // solhint-disable-next-line quotes |
| 239 | + string.concat('"challenge":"', Base64.encodeURL(expectedChallenge), '"') |
| 240 | + ); |
| 241 | + if (challengeIndex + expectedChallengeBytes.length > clientDataJSON.length) return false; |
| 242 | + bytes memory actualChallengeBytes = Bytes.slice( |
| 243 | + clientDataJSON, |
| 244 | + challengeIndex, |
| 245 | + challengeIndex + expectedChallengeBytes.length |
| 246 | + ); |
| 247 | + |
| 248 | + return Strings.equal(string(actualChallengeBytes), string(expectedChallengeBytes)); |
| 249 | + } |
| 250 | +} |
0 commit comments