diff --git a/contracts/utils/cryptography/ERC7913SignatureVerifierZKEmail.sol b/contracts/utils/cryptography/ERC7913SignatureVerifierZKEmail.sol new file mode 100644 index 00000000..1576b2d3 --- /dev/null +++ b/contracts/utils/cryptography/ERC7913SignatureVerifierZKEmail.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {IDKIMRegistry} from "@zk-email/contracts/DKIMRegistry.sol"; +import {IVerifier} from "@zk-email/email-tx-builder/interfaces/IVerifier.sol"; +import {EmailAuthMsg} from "@zk-email/email-tx-builder/interfaces/IEmailTypes.sol"; +import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol"; +import {ZKEmailUtils} from "./ZKEmailUtils.sol"; + +/** + * @dev ERC-7913 signature verifier that supports ZKEmail accounts. + * + * This contract verifies signatures produced through ZKEmail's zero-knowledge + * proofs which allows users to authenticate using their email addresses. + * + * The key decoding logic is customizable: users may override the {_decodeKey} function + * to enforce restrictions or validation on the decoded values (e.g., requiring a specific + * verifier, templateId, or registry). To remain compliant with ERC-7913's statelessness, + * it is recommended to enforce such restrictions using immutable variables only. + * + * Example of overriding _decodeKey to enforce a specific verifier, registry, (or templateId): + * + * ```solidity + * function _decodeKey(bytes calldata key) internal view override returns ( + * IDKIMRegistry registry, + * bytes32 accountSalt, + * IVerifier verifier, + * uint256 templateId + * ) { + * (registry, accountSalt, verifier, templateId) = super._decodeKey(key); + * require(verifier == _verifier, "Invalid verifier"); + * require(registry == _registry, "Invalid registry"); + * return (registry, accountSalt, verifier, templateId); + * } + * ``` + */ +abstract contract ERC7913SignatureVerifierZKEmail is IERC7913SignatureVerifier { + using ZKEmailUtils for EmailAuthMsg; + + /** + * @dev Verifies a zero-knowledge proof of an email signature validated by a {DKIMRegistry} contract. + * + * The key format is ABI-encoded (IDKIMRegistry, bytes32, IVerifier, uint256) where: + * + * * IDKIMRegistry: The registry contract that validates DKIM public key hashes + * * bytes32: The account salt that uniquely identifies the user's email address + * * IVerifier: The verifier contract instance for ZK proof verification. + * * uint256: The template ID for the command + * + * See {_decodeKey} for the key encoding format. + * + * The signature is an ABI-encoded {ZKEmailUtils-EmailAuthMsg} struct containing + * the command parameters, template ID, and proof details. + * + * Signature encoding: + * + * ```solidity + * bytes memory signature = abi.encode(EmailAuthMsg({ + * templateId: 1, + * commandParams: [hash], + * proof: { + * domainName: "example.com", // The domain name of the email sender + * publicKeyHash: bytes32(0x...), // Hash of the DKIM public key used to sign the email + * timestamp: block.timestamp, // When the email was sent + * maskedCommand: "Sign hash", // The command being executed, with sensitive data masked + * emailNullifier: bytes32(0x...), // Unique identifier for the email to prevent replay attacks + * accountSalt: bytes32(0x...), // Unique identifier derived from email and account code + * isCodeExist: true, // Whether the account code exists in the proof + * proof: bytes(0x...) // The zero-knowledge proof verifying the email's authenticity + * } + * })); + * ``` + */ + function verify( + bytes calldata key, + bytes32 hash, + bytes calldata signature + ) public view virtual override returns (bytes4) { + (IDKIMRegistry registry_, bytes32 accountSalt_, IVerifier verifier_, uint256 templateId_) = abi.decode( + key, + (IDKIMRegistry, bytes32, IVerifier, uint256) + ); + EmailAuthMsg memory emailAuthMsg = abi.decode(signature, (EmailAuthMsg)); + + return + (abi.decode(emailAuthMsg.commandParams[0], (bytes32)) == hash && + emailAuthMsg.templateId == templateId_ && + emailAuthMsg.proof.accountSalt == accountSalt_ && + emailAuthMsg.isValidZKEmail(registry_, verifier_) == ZKEmailUtils.EmailProofError.NoError) + ? IERC7913SignatureVerifier.verify.selector + : bytes4(0xffffffff); + } + + /** + * @dev Decodes the key into its components. + * + * ```solidity + * bytes memory key = abi.encode(registry, accountSalt, verifier, templateId); + * ``` + */ + function _decodeKey( + bytes calldata key + ) + internal + view + virtual + returns (IDKIMRegistry registry, bytes32 accountSalt, IVerifier verifier, uint256 templateId) + { + return abi.decode(key, (IDKIMRegistry, bytes32, IVerifier, uint256)); + } +} diff --git a/contracts/utils/cryptography/SignerZKEmail.sol b/contracts/utils/cryptography/SignerZKEmail.sol index 62d3814a..feb4348f 100644 --- a/contracts/utils/cryptography/SignerZKEmail.sol +++ b/contracts/utils/cryptography/SignerZKEmail.sol @@ -81,8 +81,10 @@ abstract contract SignerZKEmail is AbstractSigner { return _registry; } - /// @dev An instance of the Verifier contract. - /// See https://docs.zk.email/architecture/zk-proofs#how-zk-email-uses-zero-knowledge-proofs[ZK Proofs]. + /** + * @dev An instance of the Verifier contract. + * See https://docs.zk.email/architecture/zk-proofs#how-zk-email-uses-zero-knowledge-proofs[ZK Proofs]. + */ function verifier() public view virtual returns (IVerifier) { return _verifier; } diff --git a/test/account/AccountERC7913.test.js b/test/account/AccountERC7913.test.js index 72004254..6f245abc 100644 --- a/test/account/AccountERC7913.test.js +++ b/test/account/AccountERC7913.test.js @@ -3,7 +3,7 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { getDomain } = require('@openzeppelin/contracts/test/helpers/eip712'); const { ERC4337Helper } = require('../helpers/erc4337'); -const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../helpers/signers'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey, ZKEmailSigningKey } = require('../helpers/signers'); const { PackedUserOperation } = require('../helpers/eip712-types'); const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior'); @@ -15,15 +15,36 @@ const signerECDSA = ethers.Wallet.createRandom(); const signerP256 = new NonNativeSigner(P256SigningKey.random()); const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); +// Constants for ZKEmail +const accountSalt = '0x046582bce36cdd0a8953b9d40b8f20d58302bacf3bcecffeb6741c98a52725e2'; // keccak256("test@example.com") +const selector = '12345'; +const domainName = 'gmail.com'; +const publicKeyHash = '0x0ea9c777dc7110e5a9e89b13f0cfc540e3845ba120b2b6dc24024d61488d4788'; +const emailNullifier = '0x00a83fce3d4b1c9ef0f600644c1ecc6c8115b57b1596e0e3295e2c5105fbfd8a'; +const templateId = ethers.solidityPackedKeccak256(['string', 'uint256'], ['TEST', 0n]); + // Minimal fixture common to the different signer verifiers async function fixture() { // EOAs and environment - const [beneficiary, other] = await ethers.getSigners(); + const [admin, beneficiary, other] = await ethers.getSigners(); const target = await ethers.deployContract('CallReceiverMockExtended'); + // DKIM Registry for ZKEmail + const dkim = await ethers.deployContract('ECDSAOwnedDKIMRegistry'); + await dkim.initialize(admin, admin); + await dkim + .SET_PREFIX() + .then(prefix => dkim.computeSignedMsg(prefix, domainName, publicKeyHash)) + .then(message => admin.signMessage(message)) + .then(signature => dkim.setDKIMPublicKeyHash(selector, domainName, publicKeyHash, signature)); + + // ZKEmail Verifier + const zkEmailVerifier = await ethers.deployContract('ZKEmailVerifierMock'); + // ERC-7913 verifiers const verifierP256 = await ethers.deployContract('ERC7913SignatureVerifierP256'); const verifierRSA = await ethers.deployContract('ERC7913SignatureVerifierRSA'); + const verifierZKEmail = await ethers.deployContract('$ERC7913SignatureVerifierZKEmail'); // ERC-4337 env const helper = new ERC4337Helper(); @@ -43,7 +64,20 @@ async function fixture() { .then(signature => Object.assign(userOp, { signature })); }; - return { helper, verifierP256, verifierRSA, domain, target, beneficiary, other, makeMock, signUserOp }; + return { + helper, + verifierP256, + verifierRSA, + verifierZKEmail, + dkim, + zkEmailVerifier, + domain, + target, + beneficiary, + other, + makeMock, + signUserOp, + }; } describe('AccountERC7913', function () { @@ -103,4 +137,36 @@ describe('AccountERC7913', function () { shouldBehaveLikeERC1271({ erc7739: true }); shouldBehaveLikeERC7821(); }); + + // Using ZKEmail with an ERC-7913 verifier + describe('ZKEmail', function () { + beforeEach(async function () { + // Create ZKEmail signer + this.signer = new NonNativeSigner( + new ZKEmailSigningKey(domainName, publicKeyHash, emailNullifier, accountSalt, templateId), + ); + + // Create account with ZKEmail verifier + this.mock = await this.makeMock( + ethers.concat([ + this.verifierZKEmail.target, + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'bytes32', 'address', 'uint256'], + [this.dkim.target, accountSalt, this.zkEmailVerifier.target, templateId], + ), + ]), + ); + + // Override the signUserOp function to use the ZKEmail signer + this.signUserOp = async userOp => { + const hash = await userOp.hash(); + return Object.assign(userOp, { signature: this.signer.signingKey.sign(hash).serialized }); + }; + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); });