|
@@ -0,0 +1,260 @@
|
|
|
+// SPDX-License-Identifier: MIT
|
|
|
+
|
|
|
+pragma solidity ^0.8.24;
|
|
|
+
|
|
|
+import {P256} from "./P256.sol";
|
|
|
+import {Base64} from "../Base64.sol";
|
|
|
+import {Bytes} from "../Bytes.sol";
|
|
|
+import {Strings} from "../Strings.sol";
|
|
|
+
|
|
|
+/**
|
|
|
+ * @dev Library for verifying WebAuthn Authentication Assertions.
|
|
|
+ *
|
|
|
+ * WebAuthn enables strong authentication for smart contracts using
|
|
|
+ * https://docs.openzeppelin.com/contracts/5.x/api/utils#P256[P256]
|
|
|
+ * as an alternative to traditional secp256k1 ECDSA signatures. This library verifies
|
|
|
+ * signatures generated during WebAuthn authentication ceremonies as specified in the
|
|
|
+ * https://www.w3.org/TR/webauthn-2/[WebAuthn Level 2 standard].
|
|
|
+ *
|
|
|
+ * For blockchain use cases, the following WebAuthn validations are intentionally omitted:
|
|
|
+ *
|
|
|
+ * * Origin validation: Origin verification in `clientDataJSON` is omitted as blockchain
|
|
|
+ * contexts rely on authenticator and dapp frontend enforcement. Standard authenticators
|
|
|
+ * implement proper origin validation.
|
|
|
+ * * RP ID hash validation: Verification of `rpIdHash` in authenticatorData against expected
|
|
|
+ * RP ID hash is omitted. This is typically handled by platform-level security measures.
|
|
|
+ * Including an expiry timestamp in signed data is recommended for enhanced security.
|
|
|
+ * * Signature counter: Verification of signature counter increments is omitted. While
|
|
|
+ * useful for detecting credential cloning, on-chain operations typically include nonce
|
|
|
+ * protection, making this check redundant.
|
|
|
+ * * Extension outputs: Extension output value verification is omitted as these are not
|
|
|
+ * essential for core authentication security in blockchain applications.
|
|
|
+ * * Attestation: Attestation object verification is omitted as this implementation
|
|
|
+ * focuses on authentication (`webauthn.get`) rather than registration ceremonies.
|
|
|
+ *
|
|
|
+ * Inspired by:
|
|
|
+ *
|
|
|
+ * * https://github.com/daimo-eth/p256-verifier/blob/master/src/WebAuthn.sol[daimo-eth implementation]
|
|
|
+ * * https://github.com/base/webauthn-sol/blob/main/src/WebAuthn.sol[base implementation]
|
|
|
+ */
|
|
|
+library WebAuthn {
|
|
|
+ struct WebAuthnAuth {
|
|
|
+ bytes32 r; /// The r value of secp256r1 signature
|
|
|
+ bytes32 s; /// The s value of secp256r1 signature
|
|
|
+ uint256 challengeIndex; /// The index at which "challenge":"..." occurs in `clientDataJSON`.
|
|
|
+ uint256 typeIndex; /// The index at which "type":"..." occurs in `clientDataJSON`.
|
|
|
+ /// The WebAuthn authenticator data.
|
|
|
+ /// https://www.w3.org/TR/webauthn-2/#dom-authenticatorassertionresponse-authenticatordata
|
|
|
+ bytes authenticatorData;
|
|
|
+ /// The WebAuthn client data JSON.
|
|
|
+ /// https://www.w3.org/TR/webauthn-2/#dom-authenticatorresponse-clientdatajson
|
|
|
+ string clientDataJSON;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// @dev Bit 0 of the authenticator data flags: "User Present" bit.
|
|
|
+ bytes1 internal constant AUTH_DATA_FLAGS_UP = 0x01;
|
|
|
+ /// @dev Bit 2 of the authenticator data flags: "User Verified" bit.
|
|
|
+ bytes1 internal constant AUTH_DATA_FLAGS_UV = 0x04;
|
|
|
+ /// @dev Bit 3 of the authenticator data flags: "Backup Eligibility" bit.
|
|
|
+ bytes1 internal constant AUTH_DATA_FLAGS_BE = 0x08;
|
|
|
+ /// @dev Bit 4 of the authenticator data flags: "Backup State" bit.
|
|
|
+ bytes1 internal constant AUTH_DATA_FLAGS_BS = 0x10;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @dev Performs standard verification of a WebAuthn Authentication Assertion.
|
|
|
+ */
|
|
|
+ function verify(
|
|
|
+ bytes memory challenge,
|
|
|
+ WebAuthnAuth memory auth,
|
|
|
+ bytes32 qx,
|
|
|
+ bytes32 qy
|
|
|
+ ) internal view returns (bool) {
|
|
|
+ return verify(challenge, auth, qx, qy, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @dev Performs verification of a WebAuthn Authentication Assertion. This variants allow the caller to select
|
|
|
+ * whether of not to require the UV flag (step 17).
|
|
|
+ *
|
|
|
+ * Verifies:
|
|
|
+ *
|
|
|
+ * 1. Type is "webauthn.get" (see {_validateExpectedTypeHash})
|
|
|
+ * 2. Challenge matches the expected value (see {_validateChallenge})
|
|
|
+ * 3. Cryptographic signature is valid for the given public key
|
|
|
+ * 4. confirming physical user presence during authentication
|
|
|
+ * 5. (if `requireUV` is true) confirming stronger user authentication (biometrics/PIN)
|
|
|
+ * 6. Backup Eligibility (`BE`) and Backup State (BS) bits relationship is valid
|
|
|
+ */
|
|
|
+ function verify(
|
|
|
+ bytes memory challenge,
|
|
|
+ WebAuthnAuth memory auth,
|
|
|
+ bytes32 qx,
|
|
|
+ bytes32 qy,
|
|
|
+ bool requireUV
|
|
|
+ ) internal view returns (bool) {
|
|
|
+ // Verify authenticator data has sufficient length (37 bytes minimum):
|
|
|
+ // - 32 bytes for rpIdHash
|
|
|
+ // - 1 byte for flags
|
|
|
+ // - 4 bytes for signature counter
|
|
|
+ return
|
|
|
+ auth.authenticatorData.length > 36 &&
|
|
|
+ _validateExpectedTypeHash(auth.clientDataJSON, auth.typeIndex) && // 11
|
|
|
+ _validateChallenge(auth.clientDataJSON, auth.challengeIndex, challenge) && // 12
|
|
|
+ _validateUserPresentBitSet(auth.authenticatorData[32]) && // 16
|
|
|
+ (!requireUV || _validateUserVerifiedBitSet(auth.authenticatorData[32])) && // 17
|
|
|
+ _validateBackupEligibilityAndState(auth.authenticatorData[32]) && // Consistency check
|
|
|
+ // P256.verify handles signature malleability internally
|
|
|
+ P256.verify(
|
|
|
+ sha256(
|
|
|
+ abi.encodePacked(
|
|
|
+ auth.authenticatorData,
|
|
|
+ sha256(bytes(auth.clientDataJSON)) // 19
|
|
|
+ )
|
|
|
+ ),
|
|
|
+ auth.r,
|
|
|
+ auth.s,
|
|
|
+ qx,
|
|
|
+ qy
|
|
|
+ ); // 20
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @dev Validates that the https://www.w3.org/TR/webauthn-2/#type[Type] field in the client data JSON is set to
|
|
|
+ * "webauthn.get".
|
|
|
+ *
|
|
|
+ * Step 11 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion].
|
|
|
+ */
|
|
|
+ function _validateExpectedTypeHash(
|
|
|
+ string memory clientDataJSON,
|
|
|
+ uint256 typeIndex
|
|
|
+ ) private pure returns (bool success) {
|
|
|
+ assembly ("memory-safe") {
|
|
|
+ success := and(
|
|
|
+ // clientDataJson.length >= typeIndex + 21
|
|
|
+ gt(mload(clientDataJSON), add(typeIndex, 20)),
|
|
|
+ eq(
|
|
|
+ // get 32 bytes starting at index typexIndex in clientDataJSON, and keep the leftmost 21 bytes
|
|
|
+ and(mload(add(add(clientDataJSON, 0x20), typeIndex)), shl(88, not(0))),
|
|
|
+ // solhint-disable-next-line quotes
|
|
|
+ '"type":"webauthn.get"'
|
|
|
+ )
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @dev Validates that the challenge in the client data JSON matches the `expectedChallenge`.
|
|
|
+ *
|
|
|
+ * Step 12 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion].
|
|
|
+ */
|
|
|
+ function _validateChallenge(
|
|
|
+ string memory clientDataJSON,
|
|
|
+ uint256 challengeIndex,
|
|
|
+ bytes memory challenge
|
|
|
+ ) private pure returns (bool) {
|
|
|
+ // solhint-disable-next-line quotes
|
|
|
+ string memory expectedChallenge = string.concat('"challenge":"', Base64.encodeURL(challenge), '"');
|
|
|
+ string memory actualChallenge = string(
|
|
|
+ Bytes.slice(bytes(clientDataJSON), challengeIndex, challengeIndex + bytes(expectedChallenge).length)
|
|
|
+ );
|
|
|
+
|
|
|
+ return Strings.equal(actualChallenge, expectedChallenge);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @dev Validates that the https://www.w3.org/TR/webauthn-2/#up[User Present (UP)] bit is set.
|
|
|
+ *
|
|
|
+ * Step 16 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion].
|
|
|
+ *
|
|
|
+ * NOTE: Required by WebAuthn spec but may be skipped for platform authenticators
|
|
|
+ * (Touch ID, Windows Hello) in controlled environments. Enforce for public-facing apps.
|
|
|
+ */
|
|
|
+ function _validateUserPresentBitSet(bytes1 flags) private pure returns (bool) {
|
|
|
+ return (flags & AUTH_DATA_FLAGS_UP) == AUTH_DATA_FLAGS_UP;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @dev Validates that the https://www.w3.org/TR/webauthn-2/#uv[User Verified (UV)] bit is set.
|
|
|
+ *
|
|
|
+ * Step 17 in https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion[verifying an assertion].
|
|
|
+ *
|
|
|
+ * The UV bit indicates whether the user was verified using a stronger identification method
|
|
|
+ * (biometrics, PIN, password). While optional, requiring UV=1 is recommended for:
|
|
|
+ *
|
|
|
+ * * High-value transactions and sensitive operations
|
|
|
+ * * Account recovery and critical settings changes
|
|
|
+ * * Privileged operations
|
|
|
+ *
|
|
|
+ * NOTE: For routine operations or when using hardware authenticators without verification capabilities,
|
|
|
+ * `UV=0` may be acceptable. The choice of whether to require UV represents a security vs. usability
|
|
|
+ * tradeoff - for blockchain applications handling valuable assets, requiring UV is generally safer.
|
|
|
+ */
|
|
|
+ function _validateUserVerifiedBitSet(bytes1 flags) private pure returns (bool) {
|
|
|
+ return (flags & AUTH_DATA_FLAGS_UV) == AUTH_DATA_FLAGS_UV;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @dev Validates the relationship between Backup Eligibility (`BE`) and Backup State (`BS`) bits
|
|
|
+ * according to the WebAuthn specification.
|
|
|
+ *
|
|
|
+ * The function enforces that if a credential is backed up (`BS=1`), it must also be eligible
|
|
|
+ * for backup (`BE=1`). This prevents unauthorized credential backup and ensures compliance
|
|
|
+ * with the WebAuthn spec.
|
|
|
+ *
|
|
|
+ * Returns true in these valid states:
|
|
|
+ *
|
|
|
+ * * `BE=1`, `BS=0`: Credential is eligible but not backed up
|
|
|
+ * * `BE=1`, `BS=1`: Credential is eligible and backed up
|
|
|
+ * * `BE=0`, `BS=0`: Credential is not eligible and not backed up
|
|
|
+ *
|
|
|
+ * Returns false only when `BE=0` and `BS=1`, which is an invalid state indicating
|
|
|
+ * a credential that's backed up but not eligible for backup.
|
|
|
+ *
|
|
|
+ * NOTE: While the WebAuthn spec defines this relationship between `BE` and `BS` bits,
|
|
|
+ * validating it is not explicitly required as part of the core verification procedure.
|
|
|
+ * Some implementations may choose to skip this check for broader authenticator
|
|
|
+ * compatibility or when the application's threat model doesn't consider credential
|
|
|
+ * syncing a major risk.
|
|
|
+ */
|
|
|
+ function _validateBackupEligibilityAndState(bytes1 flags) private pure returns (bool) {
|
|
|
+ return (flags & AUTH_DATA_FLAGS_BE) == AUTH_DATA_FLAGS_BE || (flags & AUTH_DATA_FLAGS_BS) == 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @dev Verifies that calldata bytes (`input`) represents a valid `WebAuthnAuth` object. If encoding is valid,
|
|
|
+ * returns true and the calldata view at the object. Otherwise, returns false and an invalid calldata object.
|
|
|
+ *
|
|
|
+ * NOTE: The returned `auth` object should not be accessed if `success` is false. Trying to access the data may
|
|
|
+ * cause revert/panic.
|
|
|
+ */
|
|
|
+ function tryDecodeAuth(bytes calldata input) internal pure returns (bool success, WebAuthnAuth calldata auth) {
|
|
|
+ assembly ("memory-safe") {
|
|
|
+ auth := input.offset
|
|
|
+ }
|
|
|
+
|
|
|
+ // Minimum length to hold 6 objects (32 bytes each)
|
|
|
+ if (input.length < 0xC0) return (false, auth);
|
|
|
+
|
|
|
+ // Get offset of non-value-type elements relative to the input buffer
|
|
|
+ uint256 authenticatorDataOffset = uint256(bytes32(input[0x80:]));
|
|
|
+ uint256 clientDataJSONOffset = uint256(bytes32(input[0xa0:]));
|
|
|
+
|
|
|
+ // The elements length (at the offset) should be 32 bytes long. We check that this is within the
|
|
|
+ // buffer bounds. Since we know input.length is at least 32, we can subtract with no overflow risk.
|
|
|
+ if (input.length - 0x20 < authenticatorDataOffset || input.length - 0x20 < clientDataJSONOffset)
|
|
|
+ return (false, auth);
|
|
|
+
|
|
|
+ // Get the lengths. offset + 32 is bounded by input.length so it does not overflow.
|
|
|
+ uint256 authenticatorDataLength = uint256(bytes32(input[authenticatorDataOffset:]));
|
|
|
+ uint256 clientDataJSONLength = uint256(bytes32(input[clientDataJSONOffset:]));
|
|
|
+
|
|
|
+ // Check that the input buffer is long enough to store the non-value-type elements
|
|
|
+ // Since we know input.length is at least xxxOffset + 32, we can subtract with no overflow risk.
|
|
|
+ if (
|
|
|
+ input.length - authenticatorDataOffset - 0x20 < authenticatorDataLength ||
|
|
|
+ input.length - clientDataJSONOffset - 0x20 < clientDataJSONLength
|
|
|
+ ) return (false, auth);
|
|
|
+
|
|
|
+ return (true, auth);
|
|
|
+ }
|
|
|
+}
|