WebAuthn.sol 12 KB

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