Эх сурвалжийг харах

Migrate WebAuthn library, signer and verifier from community (#5809)

Co-authored-by: ernestognw <ernestognw@gmail.com>
Hadrien Croubois 2 сар өмнө
parent
commit
0f578d247c

+ 5 - 0
.changeset/angry-waves-film.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`WebAuthn`: Add a library for verifying WebAuthn Authentication Assertions.

+ 5 - 0
.changeset/petite-seas-shake.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`SignerWebAuthn`: Add an abstract signer that verifies WebAuthn signatures, with a P256 fallback.

+ 5 - 0
.changeset/tender-dolls-nail.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`ERC7913WebAuthnVerifier`: Add an ERC-7913 verifier that verifies WebAuthn Authentication Assertions for P256 identities.

+ 12 - 0
contracts/mocks/account/AccountMock.sol

@@ -16,6 +16,7 @@ import {AbstractSigner} from "../../utils/cryptography/signers/AbstractSigner.so
 import {SignerECDSA} from "../../utils/cryptography/signers/SignerECDSA.sol";
 import {SignerP256} from "../../utils/cryptography/signers/SignerP256.sol";
 import {SignerRSA} from "../../utils/cryptography/signers/SignerRSA.sol";
+import {SignerWebAuthn} from "../../utils/cryptography/signers/SignerWebAuthn.sol";
 import {SignerERC7702} from "../../utils/cryptography/signers/SignerERC7702.sol";
 import {SignerERC7913} from "../../utils/cryptography/signers/SignerERC7913.sol";
 import {MultiSignerERC7913} from "../../utils/cryptography/signers/MultiSignerERC7913.sol";
@@ -70,6 +71,17 @@ abstract contract AccountRSAMock is Account, SignerRSA, ERC7739, ERC7821, ERC721
     }
 }
 
+abstract contract AccountWebAuthnMock is Account, SignerWebAuthn, ERC7739, ERC7821, ERC721Holder, ERC1155Holder {
+    /// @inheritdoc ERC7821
+    function _erc7821AuthorizedExecutor(
+        address caller,
+        bytes32 mode,
+        bytes calldata executionData
+    ) internal view virtual override returns (bool) {
+        return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData);
+    }
+}
+
 abstract contract AccountERC7702Mock is Account, SignerERC7702, ERC7739, ERC7821, ERC721Holder, ERC1155Holder {
     /// @inheritdoc ERC7821
     function _erc7821AuthorizedExecutor(

+ 7 - 1
contracts/utils/cryptography/README.adoc

@@ -13,12 +13,14 @@ A collection of contracts and libraries that implement various signature validat
  * {MerkleProof}: Functions for verifying https://en.wikipedia.org/wiki/Merkle_tree[Merkle Tree] proofs.
  * {EIP712}: Contract with functions to allow processing signed typed structure data according to https://eips.ethereum.org/EIPS/eip-712[EIP-712].
  * {ERC7739Utils}: Utilities library that implements a defensive rehashing mechanism to prevent replayability of smart contract signatures based on ERC-7739.
+ * {WebAuthn}: Library for verifying WebAuthn Authentication Assertions.
  * {AbstractSigner}: Abstract contract for internal signature validation in smart contracts.
  * {ERC7739}: An abstract contract to validate signatures following the rehashing scheme from {ERC7739Utils}.
  * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms.
  * {SignerERC7702}: Implementation of {AbstractSigner} that validates signatures using the contract's own address as the signer, useful for delegated accounts following EIP-7702.
+ * {SignerWebAuthn}: Implementation of {SignerP256} that supports WebAuthn
  * {SignerERC7913}, {MultiSignerERC7913}, {MultiSignerERC7913Weighted}: Implementations of {AbstractSigner} that validate signatures based on ERC-7913. Including a simple and weighted multisignature scheme.
- * {ERC7913P256Verifier}, {ERC7913RSAVerifier}: Ready to use ERC-7913 signature verifiers for P256 and RSA keys.
+ * {ERC7913P256Verifier}, {ERC7913RSAVerifier}, {ERC7913WebAuthnVerifier}: Ready to use ERC-7913 signature verifiers for P256, RSA keys and WebAuthn.
 
 == Utils
 
@@ -40,6 +42,8 @@ A collection of contracts and libraries that implement various signature validat
 
 {{ERC7739Utils}}
 
+{{WebAuthn}}
+
 == Abstract Signers
 
 {{AbstractSigner}}
@@ -65,3 +69,5 @@ A collection of contracts and libraries that implement various signature validat
 {{ERC7913P256Verifier}}
 
 {{ERC7913RSAVerifier}}
+
+{{ERC7913WebAuthnVerifier}}

+ 1 - 1
contracts/utils/cryptography/SignatureChecker.sol

@@ -6,7 +6,7 @@ pragma solidity ^0.8.24;
 import {ECDSA} from "./ECDSA.sol";
 import {IERC1271} from "../../interfaces/IERC1271.sol";
 import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol";
-import {Bytes} from "../../utils/Bytes.sol";
+import {Bytes} from "../Bytes.sol";
 
 /**
  * @dev Signature verification helper that can be used instead of `ECDSA.recover` to seamlessly support:

+ 260 - 0
contracts/utils/cryptography/WebAuthn.sol

@@ -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);
+    }
+}

+ 50 - 0
contracts/utils/cryptography/signers/SignerWebAuthn.sol

@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.24;
+
+import {SignerP256} from "./SignerP256.sol";
+import {WebAuthn} from "../WebAuthn.sol";
+
+/**
+ * @dev Implementation of {SignerP256} that supports WebAuthn authentication assertions.
+ *
+ * This contract enables signature validation using WebAuthn authentication assertions,
+ * leveraging the P256 public key stored in the contract. It allows for both WebAuthn
+ * and raw P256 signature validation, providing compatibility with both signature types.
+ *
+ * The signature is expected to be an abi-encoded {WebAuthn-WebAuthnAuth} struct.
+ *
+ * Example usage:
+ *
+ * ```solidity
+ * contract MyAccountWebAuthn is Account, SignerWebAuthn, Initializable {
+ *     function initialize(bytes32 qx, bytes32 qy) public initializer {
+ *         _setSigner(qx, qy);
+ *     }
+ * }
+ * ```
+ *
+ * IMPORTANT: Failing to call {_setSigner} either during construction (if used standalone)
+ * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable.
+ */
+abstract contract SignerWebAuthn is SignerP256 {
+    /**
+     * @dev Validates a raw signature using the WebAuthn authentication assertion.
+     *
+     * In case the signature can't be validated, it falls back to the
+     * {SignerP256-_rawSignatureValidation} method for raw P256 signature validation by passing
+     * the raw `r` and `s` values from the signature.
+     */
+    function _rawSignatureValidation(
+        bytes32 hash,
+        bytes calldata signature
+    ) internal view virtual override returns (bool) {
+        (bytes32 qx, bytes32 qy) = signer();
+        (bool decodeSuccess, WebAuthn.WebAuthnAuth calldata auth) = WebAuthn.tryDecodeAuth(signature);
+
+        return
+            decodeSuccess
+                ? WebAuthn.verify(abi.encodePacked(hash), auth, qx, qy)
+                : super._rawSignatureValidation(hash, signature);
+    }
+}

+ 1 - 1
contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sol

@@ -3,7 +3,7 @@
 
 pragma solidity ^0.8.20;
 
-import {P256} from "../../../utils/cryptography/P256.sol";
+import {P256} from "../P256.sol";
 import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol";
 
 /**

+ 1 - 1
contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sol

@@ -3,7 +3,7 @@
 
 pragma solidity ^0.8.20;
 
-import {RSA} from "../../../utils/cryptography/RSA.sol";
+import {RSA} from "../RSA.sol";
 import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol";
 
 /**

+ 32 - 0
contracts/utils/cryptography/verifiers/ERC7913WebAuthnVerifier.sol

@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.24;
+
+import {WebAuthn} from "../WebAuthn.sol";
+import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol";
+
+/**
+ * @dev ERC-7913 signature verifier that supports WebAuthn authentication assertions.
+ *
+ * This verifier enables the validation of WebAuthn signatures using P256 public keys.
+ * The key is expected to be a 64-byte concatenation of the P256 public key coordinates (qx || qy).
+ * The signature is expected to be an abi-encoded {WebAuthn-WebAuthnAuth} struct.
+ *
+ * Uses {WebAuthn-verifyMinimal} for signature verification, which performs the essential
+ * WebAuthn checks: type validation, challenge matching, and cryptographic signature verification.
+ *
+ * NOTE: Wallets that may require default P256 validation may install a P256 verifier separately.
+ */
+contract ERC7913WebAuthnVerifier is IERC7913SignatureVerifier {
+    /// @inheritdoc IERC7913SignatureVerifier
+    function verify(bytes calldata key, bytes32 hash, bytes calldata signature) public view virtual returns (bytes4) {
+        (bool decodeSuccess, WebAuthn.WebAuthnAuth calldata auth) = WebAuthn.tryDecodeAuth(signature);
+
+        return
+            decodeSuccess &&
+                key.length == 0x40 &&
+                WebAuthn.verify(abi.encodePacked(hash), auth, bytes32(key[0x00:0x20]), bytes32(key[0x20:0x40]))
+                ? IERC7913SignatureVerifier.verify.selector
+                : bytes4(0xFFFFFFFF);
+    }
+}

+ 23 - 1
test/account/AccountERC7913.test.js

@@ -3,7 +3,7 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
 
 const { getDomain } = require('../helpers/eip712');
 const { ERC4337Helper } = require('../helpers/erc4337');
-const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../helpers/signers');
+const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey, WebAuthnSigningKey } = require('../helpers/signers');
 const { PackedUserOperation } = require('../helpers/eip712-types');
 
 const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior');
@@ -14,6 +14,7 @@ const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior');
 const signerECDSA = ethers.Wallet.createRandom();
 const signerP256 = new NonNativeSigner(P256SigningKey.random());
 const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random());
+const signerWebAuthn = new NonNativeSigner(WebAuthnSigningKey.random());
 
 // Minimal fixture common to the different signer verifiers
 async function fixture() {
@@ -24,6 +25,7 @@ async function fixture() {
   // ERC-7913 verifiers
   const verifierP256 = await ethers.deployContract('ERC7913P256Verifier');
   const verifierRSA = await ethers.deployContract('ERC7913RSAVerifier');
+  const verifierWebAuthn = await ethers.deployContract('ERC7913WebAuthnVerifier');
 
   // ERC-4337 env
   const helper = new ERC4337Helper();
@@ -47,6 +49,7 @@ async function fixture() {
     helper,
     verifierP256,
     verifierRSA,
+    verifierWebAuthn,
     domain,
     target,
     beneficiary,
@@ -113,4 +116,23 @@ describe('AccountERC7913', function () {
     shouldBehaveLikeERC1271({ erc7739: true });
     shouldBehaveLikeERC7821();
   });
+
+  // Using WebAuthn key with an ERC-7913 verifier
+  describe('WebAuthn key', function () {
+    beforeEach(async function () {
+      this.signer = signerWebAuthn;
+      this.mock = await this.makeMock(
+        ethers.concat([
+          this.verifierWebAuthn.target,
+          this.signer.signingKey.publicKey.qx,
+          this.signer.signingKey.publicKey.qy,
+        ]),
+      );
+    });
+
+    shouldBehaveLikeAccountCore();
+    shouldBehaveLikeAccountHolder();
+    shouldBehaveLikeERC1271({ erc7739: true });
+    shouldBehaveLikeERC7821();
+  });
 });

+ 88 - 0
test/account/AccountWebAuthn.test.js

@@ -0,0 +1,88 @@
+const { ethers, entrypoint } = require('hardhat');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { getDomain } = require('../helpers/eip712');
+const { ERC4337Helper } = require('../helpers/erc4337');
+const { NonNativeSigner, P256SigningKey, WebAuthnSigningKey } = require('../helpers/signers');
+const { PackedUserOperation } = require('../helpers/eip712-types');
+
+const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior');
+const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior');
+const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior');
+
+const webAuthnSigner = new NonNativeSigner(WebAuthnSigningKey.random());
+const p256Signer = new NonNativeSigner(P256SigningKey.random());
+
+async function fixture() {
+  // EOAs and environment
+  const [beneficiary, other] = await ethers.getSigners();
+  const target = await ethers.deployContract('CallReceiverMock');
+
+  // ERC-4337 account
+  const helper = new ERC4337Helper();
+
+  const webAuthnMock = await helper.newAccount('$AccountWebAuthnMock', [
+    webAuthnSigner.signingKey.publicKey.qx,
+    webAuthnSigner.signingKey.publicKey.qy,
+    'AccountWebAuthn',
+    '1',
+  ]);
+
+  const p256Mock = await helper.newAccount('$AccountWebAuthnMock', [
+    p256Signer.signingKey.publicKey.qx,
+    p256Signer.signingKey.publicKey.qy,
+    'AccountWebAuthn',
+    '1',
+  ]);
+
+  // ERC-4337 Entrypoint domain
+  const entrypointDomain = await getDomain(entrypoint.v08);
+
+  // domain cannot be fetched using getDomain(mock) before the mock is deployed
+  const domain = {
+    name: 'AccountWebAuthn',
+    version: '1',
+    chainId: entrypointDomain.chainId,
+  };
+
+  // Sign userOp with the active signer
+  const signUserOp = function (userOp) {
+    return this.signer
+      .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed)
+      .then(signature => Object.assign(userOp, { signature }));
+  };
+
+  return { helper, domain, webAuthnMock, p256Mock, target, beneficiary, other, signUserOp };
+}
+
+describe('AccountWebAuthn', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  describe('WebAuthn Assertions', function () {
+    beforeEach(async function () {
+      this.signer = webAuthnSigner;
+      this.mock = this.webAuthnMock;
+      this.domain.verifyingContract = this.mock.address;
+    });
+
+    shouldBehaveLikeAccountCore();
+    shouldBehaveLikeAccountHolder();
+    shouldBehaveLikeERC1271({ erc7739: true });
+    shouldBehaveLikeERC7821();
+  });
+
+  describe('as regular P256 validator', function () {
+    beforeEach(async function () {
+      this.signer = p256Signer;
+      this.mock = this.p256Mock;
+      this.domain.verifyingContract = this.mock.address;
+    });
+
+    shouldBehaveLikeAccountCore();
+    shouldBehaveLikeAccountHolder();
+    shouldBehaveLikeERC1271({ erc7739: true });
+    shouldBehaveLikeERC7821();
+  });
+});

+ 86 - 48
test/helpers/signers.js

@@ -1,31 +1,14 @@
-const {
-  AbiCoder,
-  AbstractSigner,
-  Signature,
-  TypedDataEncoder,
-  assert,
-  assertArgument,
-  concat,
-  dataLength,
-  decodeBase64,
-  getBytes,
-  getBytesCopy,
-  hashMessage,
-  hexlify,
-  sha256,
-  toBeHex,
-  keccak256,
-} = require('ethers');
+const { ethers } = require('ethers');
 const { secp256r1 } = require('@noble/curves/p256');
 const { generateKeyPairSync, privateEncrypt } = require('crypto');
 
 // Lightweight version of BaseWallet
-class NonNativeSigner extends AbstractSigner {
+class NonNativeSigner extends ethers.AbstractSigner {
   #signingKey;
 
   constructor(privateKey, provider) {
     super(provider);
-    assertArgument(
+    ethers.assertArgument(
       privateKey && typeof privateKey.sign === 'function',
       'invalid private key',
       'privateKey',
@@ -54,7 +37,7 @@ class NonNativeSigner extends AbstractSigner {
   }
 
   async signMessage(message /*: string | Uint8Array*/) /*: Promise<string>*/ {
-    return this.signingKey.sign(hashMessage(message)).serialized;
+    return this.signingKey.sign(ethers.hashMessage(message)).serialized;
   }
 
   async signTypedData(
@@ -63,17 +46,17 @@ class NonNativeSigner extends AbstractSigner {
     value /*: Record<string, any>*/,
   ) /*: Promise<string>*/ {
     // Populate any ENS names
-    const populated = await TypedDataEncoder.resolveNames(domain, types, value, async name => {
-      assert(this.provider != null, 'cannot resolve ENS names without a provider', 'UNSUPPORTED_OPERATION', {
+    const populated = await ethers.TypedDataEncoder.resolveNames(domain, types, value, async name => {
+      ethers.assert(this.provider != null, 'cannot resolve ENS names without a provider', 'UNSUPPORTED_OPERATION', {
         operation: 'resolveName',
         info: { name },
       });
       const address = await this.provider.resolveName(name);
-      assert(address != null, 'unconfigured ENS name', 'UNCONFIGURED_NAME', { value: name });
+      ethers.assert(address != null, 'unconfigured ENS name', 'UNCONFIGURED_NAME', { value: name });
       return address;
     });
 
-    return this.signingKey.sign(TypedDataEncoder.hash(populated.domain, types, populated.value)).serialized;
+    return this.signingKey.sign(ethers.TypedDataEncoder.hash(populated.domain, types, populated.value)).serialized;
   }
 }
 
@@ -81,7 +64,7 @@ class P256SigningKey {
   #privateKey;
 
   constructor(privateKey) {
-    this.#privateKey = getBytes(privateKey);
+    this.#privateKey = ethers.getBytes(privateKey);
   }
 
   static random() {
@@ -89,20 +72,27 @@ class P256SigningKey {
   }
 
   get privateKey() {
-    return hexlify(this.#privateKey);
+    return ethers.hexlify(this.#privateKey);
   }
 
   get publicKey() {
     const publicKeyBytes = secp256r1.getPublicKey(this.#privateKey, false);
-    return { qx: hexlify(publicKeyBytes.slice(0x01, 0x21)), qy: hexlify(publicKeyBytes.slice(0x21, 0x41)) };
+    return {
+      qx: ethers.hexlify(publicKeyBytes.slice(0x01, 0x21)),
+      qy: ethers.hexlify(publicKeyBytes.slice(0x21, 0x41)),
+    };
   }
 
-  sign(digest /*: BytesLike*/) /*: Signature*/ {
-    assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
+  sign(digest /*: BytesLike*/) /*: ethers.Signature*/ {
+    ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
 
-    const sig = secp256r1.sign(getBytesCopy(digest), getBytesCopy(this.#privateKey), { lowS: true });
+    const sig = secp256r1.sign(ethers.getBytesCopy(digest), ethers.getBytesCopy(this.#privateKey), { lowS: true });
 
-    return Signature.from({ r: toBeHex(sig.r, 32), s: toBeHex(sig.s, 32), v: sig.recovery ? 0x1c : 0x1b });
+    return ethers.Signature.from({
+      r: ethers.toBeHex(sig.r, 32),
+      s: ethers.toBeHex(sig.s, 32),
+      v: sig.recovery ? 0x1c : 0x1b,
+    });
   }
 }
 
@@ -113,7 +103,7 @@ class RSASigningKey {
   constructor(keyPair) {
     const jwk = keyPair.publicKey.export({ format: 'jwk' });
     this.#privateKey = keyPair.privateKey;
-    this.#publicKey = { e: decodeBase64(jwk.e), n: decodeBase64(jwk.n) };
+    this.#publicKey = { e: ethers.decodeBase64(jwk.e), n: ethers.decodeBase64(jwk.n) };
   }
 
   static random(modulusLength = 2048) {
@@ -121,28 +111,67 @@ class RSASigningKey {
   }
 
   get privateKey() {
-    return hexlify(this.#privateKey);
+    return ethers.hexlify(this.#privateKey);
   }
 
   get publicKey() {
-    return { e: hexlify(this.#publicKey.e), n: hexlify(this.#publicKey.n) };
+    return { e: ethers.hexlify(this.#publicKey.e), n: ethers.hexlify(this.#publicKey.n) };
   }
 
-  sign(digest /*: BytesLike*/) /*: Signature*/ {
-    assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
+  sign(digest /*: BytesLike*/) /*: ethers.Signature*/ {
+    ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
     // SHA256 OID = 608648016503040201 (9 bytes) | NULL = 0500 (2 bytes) (explicit) | OCTET_STRING length (0x20) = 0420 (2 bytes)
     return {
-      serialized: hexlify(
-        privateEncrypt(this.#privateKey, getBytes(concat(['0x3031300d060960864801650304020105000420', digest]))),
+      serialized: ethers.hexlify(
+        privateEncrypt(
+          this.#privateKey,
+          ethers.getBytes(ethers.concat(['0x3031300d060960864801650304020105000420', digest])),
+        ),
       ),
     };
   }
 }
 
 class RSASHA256SigningKey extends RSASigningKey {
-  sign(digest /*: BytesLike*/) /*: Signature*/ {
-    assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
-    return super.sign(sha256(getBytes(digest)));
+  sign(digest /*: BytesLike*/) /*: ethers.Signature*/ {
+    ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
+    return super.sign(ethers.sha256(ethers.getBytes(digest)));
+  }
+}
+
+class WebAuthnSigningKey extends P256SigningKey {
+  sign(digest /*: BytesLike*/) /*: { serialized: string } */ {
+    ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
+
+    const clientDataJSON = JSON.stringify({
+      type: 'webauthn.get',
+      challenge: ethers.encodeBase64(digest).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''),
+    });
+
+    // Flags 0x05 = AUTH_DATA_FLAGS_UP | AUTH_DATA_FLAGS_UV
+    const authenticatorData = ethers.solidityPacked(
+      ['bytes32', 'bytes1', 'bytes4'],
+      [ethers.ZeroHash, '0x05', '0x00000000'],
+    );
+
+    // Regular P256 signature
+    const { r, s } = super.sign(
+      ethers.sha256(ethers.concat([authenticatorData, ethers.sha256(ethers.toUtf8Bytes(clientDataJSON))])),
+    );
+
+    const serialized = ethers.AbiCoder.defaultAbiCoder().encode(
+      ['bytes32', 'bytes32', 'uint256', 'uint256', 'bytes', 'string'],
+      [
+        r,
+        s,
+        clientDataJSON.indexOf('"challenge"'),
+        clientDataJSON.indexOf('"type"'),
+        authenticatorData,
+        clientDataJSON,
+      ],
+    );
+
+    return { serialized };
   }
 }
 
@@ -151,7 +180,7 @@ class MultiERC7913SigningKey {
   #signers;
 
   constructor(signers) {
-    assertArgument(
+    ethers.assertArgument(
       Array.isArray(signers) && signers.length > 0,
       'signers must be a non-empty array',
       'signers',
@@ -159,18 +188,20 @@ class MultiERC7913SigningKey {
     );
 
     // Sorting is done at construction so that it doesn't have to be done in sign()
-    this.#signers = signers.sort((s1, s2) => keccak256(s1.bytes ?? s1.address) - keccak256(s2.bytes ?? s2.address));
+    this.#signers = signers.sort(
+      (s1, s2) => ethers.keccak256(s1.bytes ?? s1.address) - ethers.keccak256(s2.bytes ?? s2.address),
+    );
   }
 
   get signers() {
     return this.#signers;
   }
 
-  sign(digest /*: BytesLike*/ /*: Signature*/) {
-    assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
+  sign(digest /*: BytesLike*/ /*: ethers.Signature*/) {
+    ethers.assertArgument(ethers.dataLength(digest) === 32, 'invalid digest length', 'digest', digest);
 
     return {
-      serialized: AbiCoder.defaultAbiCoder().encode(
+      serialized: ethers.AbiCoder.defaultAbiCoder().encode(
         ['bytes[]', 'bytes[]'],
         [
           this.#signers.map(signer => signer.bytes ?? signer.address),
@@ -181,4 +212,11 @@ class MultiERC7913SigningKey {
   }
 }
 
-module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey, MultiERC7913SigningKey };
+module.exports = {
+  NonNativeSigner,
+  P256SigningKey,
+  RSASigningKey,
+  RSASHA256SigningKey,
+  WebAuthnSigningKey,
+  MultiERC7913SigningKey,
+};

+ 297 - 0
test/utils/cryptography/WebAuthn.t.sol

@@ -0,0 +1,297 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {P256} from "@openzeppelin/contracts/utils/cryptography/P256.sol";
+import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
+import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
+import {WebAuthn} from "@openzeppelin/contracts/utils/cryptography/WebAuthn.sol";
+
+contract WebAuthnTest is Test {
+    /// forge-config: default.fuzz.runs = 512
+    function testVerify(bytes memory challenge, uint256 seed) public view {
+        assertTrue(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP),
+                _encodeClientDataJSON(challenge),
+                false
+            )
+        );
+    }
+
+    /// forge-config: default.fuzz.runs = 512
+    function testVerifyInvalidType(bytes memory challenge, uint256 seed) public view {
+        assertFalse(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV),
+                // solhint-disable-next-line quotes
+                string.concat('{"type":"webauthn.create","challenge":"', Base64.encodeURL(challenge), '"}'),
+                false
+            )
+        );
+    }
+
+    /// forge-config: default.fuzz.runs = 512
+    function testVerifyInvalidChallenge(bytes memory challenge, uint256 seed) public view {
+        assertFalse(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV),
+                _encodeClientDataJSON(bytes("invalid_challenge")),
+                false
+            )
+        );
+    }
+
+    /// forge-config: default.fuzz.runs = 512
+    function testVerifyFlagsUP(bytes memory challenge, uint256 seed) public view {
+        // UP = false: FAIL
+        assertFalse(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UV),
+                _encodeClientDataJSON(challenge),
+                false
+            )
+        );
+    }
+
+    /// forge-config: default.fuzz.runs = 512
+    function testVerifyFlagsUV(bytes memory challenge, uint256 seed) public view {
+        // UV = false, requireUV = false: SUCCESS
+        assertTrue(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP),
+                _encodeClientDataJSON(challenge),
+                false
+            )
+        );
+        // UV = false, requireUV = true: FAIL
+        assertFalse(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP),
+                _encodeClientDataJSON(challenge),
+                true
+            )
+        );
+        // UV = true, requireUV = true: SUCCESS
+        assertTrue(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV),
+                _encodeClientDataJSON(challenge),
+                true
+            )
+        );
+    }
+
+    /// forge-config: default.fuzz.runs = 512
+    function testVerifyFlagsBEBS(bytes memory challenge, uint256 seed) public view {
+        // BS = true, BE = false: FAIL
+        assertFalse(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(
+                    WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV | WebAuthn.AUTH_DATA_FLAGS_BS
+                ),
+                _encodeClientDataJSON(challenge),
+                false
+            )
+        );
+        // BS = false, BE = true: SUCCESS
+        assertTrue(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(
+                    WebAuthn.AUTH_DATA_FLAGS_UP | WebAuthn.AUTH_DATA_FLAGS_UV | WebAuthn.AUTH_DATA_FLAGS_BE
+                ),
+                _encodeClientDataJSON(challenge),
+                false
+            )
+        );
+        // BS = true, BE = true: SUCCESS
+        assertTrue(
+            _runVerify(
+                seed,
+                challenge,
+                _encodeAuthenticatorData(
+                    WebAuthn.AUTH_DATA_FLAGS_UP |
+                        WebAuthn.AUTH_DATA_FLAGS_UV |
+                        WebAuthn.AUTH_DATA_FLAGS_BE |
+                        WebAuthn.AUTH_DATA_FLAGS_BS
+                ),
+                _encodeClientDataJSON(challenge),
+                false
+            )
+        );
+    }
+
+    function _runVerify(
+        uint256 seed,
+        bytes memory challenge,
+        bytes memory authenticatorData,
+        string memory clientDataJSON,
+        bool requireUV
+    ) private view returns (bool) {
+        // Generate private key and get public key
+        uint256 privateKey = bound(seed, 1, P256.N - 1);
+        (uint256 x, uint256 y) = vm.publicKeyP256(privateKey);
+
+        // Sign the message
+        bytes32 messageHash = sha256(abi.encodePacked(authenticatorData, sha256(bytes(clientDataJSON))));
+        (bytes32 r, bytes32 s) = vm.signP256(privateKey, messageHash);
+
+        // Verify the signature
+        return
+            WebAuthn.verify(
+                challenge,
+                WebAuthn.WebAuthnAuth({
+                    authenticatorData: authenticatorData,
+                    clientDataJSON: clientDataJSON,
+                    challengeIndex: 23, // Position of challenge in clientDataJSON
+                    typeIndex: 1, // Position of type in clientDataJSON
+                    r: r,
+                    s: bytes32(Math.min(uint256(s), P256.N - uint256(s)))
+                }),
+                bytes32(x),
+                bytes32(y),
+                requireUV
+            );
+    }
+
+    function testTryDecodeAuthValid(
+        bytes32 r,
+        bytes32 s,
+        uint256 challengeIndex,
+        uint256 typeIndex,
+        bytes memory authenticatorData,
+        string memory clientDataJSON
+    ) public view {
+        (bool success, WebAuthn.WebAuthnAuth memory auth) = this.tryDecodeAuth(
+            abi.encode(r, s, challengeIndex, typeIndex, authenticatorData, clientDataJSON)
+        );
+        assertTrue(success);
+        assertEq(auth.r, r);
+        assertEq(auth.s, s);
+        assertEq(auth.challengeIndex, challengeIndex);
+        assertEq(auth.typeIndex, typeIndex);
+        assertEq(auth.authenticatorData, authenticatorData);
+        assertEq(auth.clientDataJSON, clientDataJSON);
+    }
+
+    function testTryDecodeAuthInvalid() public view {
+        bytes32 r = keccak256("r");
+        bytes32 s = keccak256("s");
+        uint256 challengeIndex = 17;
+        uint256 typeIndex = 1;
+
+        // too short
+        assertFalse(this.tryDecodeAuthDrop(abi.encodePacked(r, s, challengeIndex, typeIndex)));
+
+        // offset out of bound
+        assertFalse(
+            this.tryDecodeAuthDrop(abi.encodePacked(r, s, challengeIndex, typeIndex, uint256(0xc0), uint256(0)))
+        );
+        assertFalse(
+            this.tryDecodeAuthDrop(abi.encodePacked(r, s, challengeIndex, typeIndex, uint256(0), uint256(0xc0)))
+        );
+
+        // minimal valid (bytes and string both length 0, at the same position)
+        assertTrue(
+            this.tryDecodeAuthDrop(
+                abi.encodePacked(r, s, challengeIndex, typeIndex, uint256(0xc0), uint256(0xc0), uint256(0))
+            )
+        );
+
+        // length out of bound
+        assertTrue(
+            this.tryDecodeAuthDrop(
+                abi.encodePacked(
+                    r,
+                    s,
+                    challengeIndex,
+                    typeIndex,
+                    uint256(0xc0),
+                    uint256(0xe0),
+                    uint256(0x20),
+                    uint256(0)
+                )
+            )
+        );
+        assertFalse(
+            this.tryDecodeAuthDrop(
+                abi.encodePacked(
+                    r,
+                    s,
+                    challengeIndex,
+                    typeIndex,
+                    uint256(0xc0),
+                    uint256(0xe0),
+                    uint256(0x21),
+                    uint256(0)
+                )
+            )
+        );
+        assertTrue(
+            this.tryDecodeAuthDrop(
+                abi.encodePacked(
+                    r,
+                    s,
+                    challengeIndex,
+                    typeIndex,
+                    uint256(0xc0),
+                    uint256(0xe0),
+                    uint256(0),
+                    uint256(0x00)
+                )
+            )
+        );
+        assertFalse(
+            this.tryDecodeAuthDrop(
+                abi.encodePacked(
+                    r,
+                    s,
+                    challengeIndex,
+                    typeIndex,
+                    uint256(0xc0),
+                    uint256(0xe0),
+                    uint256(0),
+                    uint256(0x01)
+                )
+            )
+        );
+    }
+
+    function tryDecodeAuth(
+        bytes calldata encoded
+    ) public pure returns (bool success, WebAuthn.WebAuthnAuth calldata auth) {
+        (success, auth) = WebAuthn.tryDecodeAuth(encoded);
+    }
+
+    function tryDecodeAuthDrop(bytes calldata encoded) public pure returns (bool success) {
+        (success, ) = WebAuthn.tryDecodeAuth(encoded);
+    }
+
+    function _encodeAuthenticatorData(bytes1 flags) private pure returns (bytes memory) {
+        return abi.encodePacked(bytes32(0), flags, bytes4(0));
+    }
+
+    function _encodeClientDataJSON(bytes memory challenge) private pure returns (string memory) {
+        // solhint-disable-next-line quotes
+        return string.concat('{"type":"webauthn.get","challenge":"', Base64.encodeURL(challenge), '"}');
+    }
+}