diff --git a/.gitmodules b/.gitmodules index 3773208d..60f21d9b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -9,3 +9,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std.git +[submodule "lib/email-tx-builder"] + path = lib/email-tx-builder + url = https://github.com/zkemail/email-tx-builder diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..a65b4177 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +lib diff --git a/contracts/mocks/account/AccountZKEmailMock.sol b/contracts/mocks/account/AccountZKEmailMock.sol new file mode 100644 index 00000000..e227184e --- /dev/null +++ b/contracts/mocks/account/AccountZKEmailMock.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Account} from "../../account/Account.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import {ERC7739, EIP712} from "../../utils/cryptography/ERC7739.sol"; +import {ERC7821} from "../../account/extensions/ERC7821.sol"; +import {SignerZKEmail} from "../../utils/cryptography/SignerZKEmail.sol"; +import {IDKIMRegistry} from "@zk-email/contracts/DKIMRegistry.sol"; +import {IVerifier} from "@zk-email/email-tx-builder/interfaces/IVerifier.sol"; + +contract AccountZKEmailMock is Account, SignerZKEmail, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { + constructor( + bytes32 accountSalt_, + IDKIMRegistry registry_, + IVerifier verifier_, + uint256 templateId_ + ) EIP712("AccountZKEmailMock", "1") { + _setAccountSalt(accountSalt_); + _setDKIMRegistry(registry_); + _setVerifier(verifier_); + _setTemplateId(templateId_); + } + + /// @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); + } +} diff --git a/contracts/mocks/docs/account/MyAccountZKEmail.sol b/contracts/mocks/docs/account/MyAccountZKEmail.sol new file mode 100644 index 00000000..75829af7 --- /dev/null +++ b/contracts/mocks/docs/account/MyAccountZKEmail.sol @@ -0,0 +1,40 @@ +// contracts/MyAccountZKEmail.sol +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Account} from "../../../account/Account.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import {ERC7739} from "../../../utils/cryptography/ERC7739.sol"; +import {ERC7821} from "../../../account/extensions/ERC7821.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {SignerZKEmail} from "../../../utils/cryptography/SignerZKEmail.sol"; +import {IDKIMRegistry} from "@zk-email/contracts/DKIMRegistry.sol"; +import {IVerifier} from "@zk-email/email-tx-builder/interfaces/IVerifier.sol"; + +contract MyAccountZKEmail is Account, SignerZKEmail, ERC7739, ERC7821, ERC721Holder, ERC1155Holder, Initializable { + constructor() EIP712("MyAccountZKEmail", "1") {} + + function initialize( + bytes32 accountSalt_, + IDKIMRegistry registry_, + IVerifier verifier_, + uint256 templateId_ + ) public initializer { + _setAccountSalt(accountSalt_); + _setDKIMRegistry(registry_); + _setVerifier(verifier_); + _setTemplateId(templateId_); + } + + /// @dev Allows the entry point as an authorized executor. + function _erc7821AuthorizedExecutor( + address caller, + bytes32 mode, + bytes calldata executionData + ) internal view virtual override returns (bool) { + return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData); + } +} diff --git a/contracts/mocks/import.sol b/contracts/mocks/import.sol index 4c81918e..8551bd8f 100644 --- a/contracts/mocks/import.sol +++ b/contracts/mocks/import.sol @@ -3,4 +3,5 @@ pragma solidity ^0.8.20; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import {ECDSAOwnedDKIMRegistry} from "@zk-email/email-tx-builder/utils/ECDSAOwnedDKIMRegistry.sol"; import {ERC1271WalletMock} from "@openzeppelin/contracts/mocks/ERC1271WalletMock.sol"; diff --git a/contracts/mocks/utils/cryptography/ZKEmailVerifierMock.sol b/contracts/mocks/utils/cryptography/ZKEmailVerifierMock.sol new file mode 100644 index 00000000..8195f310 --- /dev/null +++ b/contracts/mocks/utils/cryptography/ZKEmailVerifierMock.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IVerifier, EmailProof} from "@zk-email/email-tx-builder/interfaces/IVerifier.sol"; + +contract ZKEmailVerifierMock is IVerifier { + function commandBytes() external pure returns (uint256) { + // Same as in https://github.com/zkemail/email-tx-builder/blob/1452943807a5fdc732e1113c34792c76cf7dd031/packages/contracts/src/utils/Verifier.sol#L15 + return 605; + } + + function verifyEmailProof(EmailProof memory proof) external pure returns (bool) { + return proof.proof.length > 0 && bytes1(proof.proof[0]) == 0x01; // boolean true + } +} diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index fd0c7d75..c68dd768 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -12,6 +12,8 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {SignerECDSA}, {SignerERC7913}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. * {ERC7913SignatureVerifierP256}, {ERC7913SignatureVerifierRSA}: Ready to use ERC-7913 signature verifiers for P256 and RSA keys * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. + * {SignerZKEmail}: Implementation of an {AbstractSigner} that enables email-based authentication through zero-knowledge proofs. + * {ZKEmailUtils}: Library for ZKEmail signature validation utilities, enabling email-based authentication through zero-knowledge proofs. * {EnumerableSetExtended} and {EnumerableMapExtended}: Extensions of the `EnumerableSet` and `EnumerableMap` libraries with more types, including non-value types. * {Masks}: Library to handle `bytes32` masks. @@ -21,6 +23,8 @@ Miscellaneous contracts and libraries containing utility functions you can use t {{ERC7739Utils}} +{{ZKEmailUtils}} + === Abstract Signers {{AbstractSigner}} @@ -35,6 +39,8 @@ Miscellaneous contracts and libraries containing utility functions you can use t {{SignerRSA}} +{{SignerZKEmail}} + === ERC-7913 {{ERC7913Utils}} diff --git a/contracts/utils/cryptography/SignerZKEmail.sol b/contracts/utils/cryptography/SignerZKEmail.sol new file mode 100644 index 00000000..62d3814a --- /dev/null +++ b/contracts/utils/cryptography/SignerZKEmail.sol @@ -0,0 +1,153 @@ +// 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 {AbstractSigner} from "./AbstractSigner.sol"; +import {ZKEmailUtils} from "./ZKEmailUtils.sol"; + +/** + * @dev Implementation of {AbstractSigner} using https://docs.zk.email[ZKEmail] signatures. + * + * ZKEmail enables secure authentication and authorization through email messages, leveraging + * DKIM signatures from a {DKIMRegistry} and zero-knowledge proofs enabled by a {verifier} + * contract that ensures email authenticity without revealing sensitive information. The DKIM + * registry is trusted to correctly update DKIM keys, but users can override this behaviour and + * set their own keys. This contract implements the core functionality for validating email-based + * signatures in smart contracts. + * + * Developers must set the following components during contract initialization: + * + * * {accountSalt} - A unique identifier derived from the user's email address and account code. + * * {DKIMRegistry} - An instance of the DKIM registry contract for domain verification. + * * {verifier} - An instance of the Verifier contract for zero-knowledge proof validation. + * * {templateId} - The template ID of the sign hash command, defining the expected format. + * + * Example of usage: + * + * ```solidity + * contract MyAccountZKEmail is Account, SignerZKEmail, Initializable { + * constructor(bytes32 accountSalt, IDKIMRegistry registry, IVerifier verifier, uint256 templateId) { + * // Will revert if the signer is already initialized + * _setAccountSalt(accountSalt); + * _setDKIMRegistry(registry); + * _setVerifier(verifier); + * _setTemplateId(templateId); + * } + * } + * ``` + * + * IMPORTANT: Avoiding to call {_setAccountSalt}, {_setDKIMRegistry}, {_setVerifier} and {_setTemplateId} + * 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 SignerZKEmail is AbstractSigner { + using ZKEmailUtils for EmailAuthMsg; + + bytes32 private _accountSalt; + IDKIMRegistry private _registry; + IVerifier private _verifier; + uint256 private _templateId; + + /// @dev Proof verification error. + error InvalidEmailProof(ZKEmailUtils.EmailProofError err); + + /** + * @dev Unique identifier for owner of this contract defined as a hash of an email address and an account code. + * + * An account code is a random integer in a finite scalar field of https://neuromancer.sk/std/bn/bn254[BN254] curve. + * It is a private randomness to derive a CREATE2 salt of the user's Ethereum address + * from the email address, i.e., userEtherAddr := CREATE2(hash(userEmailAddr, accountCode)). + * + * The account salt is used for: + * + * * Privacy: Enables email address privacy on-chain so long as the randomly generated account code is not revealed + * to an adversary. + * * Security: Provides a unique identifier that cannot be easily guessed or brute-forced, as it's derived + * from both the email address and a random account code. + * * Deterministic Address Generation: Enables the creation of deterministic addresses based on email addresses, + * allowing users to recover their accounts using only their email. + */ + function accountSalt() public view virtual returns (bytes32) { + return _accountSalt; + } + + /// @dev An instance of the DKIM registry contract. + /// See https://docs.zk.email/architecture/dkim-verification[DKIM Verification]. + // solhint-disable-next-line func-name-mixedcase + function DKIMRegistry() public view virtual returns (IDKIMRegistry) { + 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]. + function verifier() public view virtual returns (IVerifier) { + return _verifier; + } + + /// @dev The command template of the sign hash command. + function templateId() public view virtual returns (uint256) { + return _templateId; + } + + /// @dev Set the {accountSalt}. + function _setAccountSalt(bytes32 accountSalt_) internal virtual { + _accountSalt = accountSalt_; + } + + /// @dev Set the {DKIMRegistry} contract address. + function _setDKIMRegistry(IDKIMRegistry registry_) internal virtual { + _registry = registry_; + } + + /// @dev Set the {verifier} contract address. + function _setVerifier(IVerifier verifier_) internal virtual { + _verifier = verifier_; + } + + /// @dev Set the command's {templateId}. + function _setTemplateId(uint256 templateId_) internal virtual { + _templateId = templateId_; + } + + /** + * @dev See {AbstractSigner-_rawSignatureValidation}. Validates a raw signature by: + * + * 1. Decoding the email authentication message from the signature + * 2. Verifying the hash matches the command parameters + * 3. Checking the template ID matches + * 4. Validating the account salt + * 5. Verifying the email proof + */ + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + // Check if the signature is long enough to contain the EmailAuthMsg + // The minimum length is 512 bytes (initial part + pointer offsets) + // - `templateId` is a uint256 (32 bytes). + // - `commandParams` is a dynamic array of bytes32 (32 bytes offset). + // - `skippedCommandPrefixSize` is a uint256 (32 bytes). + // - `proof` is a struct with the following fields (32 bytes offset): + // - `domainName` is a dynamic string (32 bytes offset). + // - `publicKeyHash` is a bytes32 (32 bytes). + // - `timestamp` is a uint256 (32 bytes). + // - `maskedCommand` is a dynamic string (32 bytes offset). + // - `emailNullifier` is a bytes32 (32 bytes). + // - `accountSalt` is a bytes32 (32 bytes). + // - `isCodeExist` is a boolean, so its length is 1 byte padded to 32 bytes. + // - `proof` is a dynamic bytes (32 bytes offset). + // There are 128 bytes for the EmailAuthMsg type and 256 bytes for the proof. + // Considering all dynamic elements are empty (i.e. `commandParams` = [], `domainName` = "", `maskedCommand` = "", `proof` = []), + // then we have 128 bytes for the EmailAuthMsg type, 256 bytes for the proof and 4 * 32 for the length of the dynamic elements. + // So the minimum length is 128 + 256 + 4 * 32 = 512 bytes. + if (signature.length < 512) return false; + EmailAuthMsg memory emailAuthMsg = abi.decode(signature, (EmailAuthMsg)); + return (abi.decode(emailAuthMsg.commandParams[0], (bytes32)) == hash && + emailAuthMsg.templateId == templateId() && + emailAuthMsg.proof.accountSalt == accountSalt() && + emailAuthMsg.isValidZKEmail(DKIMRegistry(), verifier()) == ZKEmailUtils.EmailProofError.NoError); + } +} diff --git a/contracts/utils/cryptography/ZKEmailUtils.sol b/contracts/utils/cryptography/ZKEmailUtils.sol new file mode 100644 index 00000000..9452c3d3 --- /dev/null +++ b/contracts/utils/cryptography/ZKEmailUtils.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Bytes} from "@openzeppelin/contracts/utils/Bytes.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +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 {CommandUtils} from "@zk-email/email-tx-builder/libraries/CommandUtils.sol"; + +/** + * @dev Library for https://docs.zk.email[ZKEmail] signature validation utilities. + * + * ZKEmail is a protocol that enables email-based authentication and authorization for smart contracts + * using zero-knowledge proofs. It allows users to prove ownership of an email address without revealing + * the email content or private keys. + * + * The validation process involves several key components: + * + * * A https://docs.zk.email/architecture/dkim-verification[DKIMRegistry] (DomainKeys Identified Mail) verification + * mechanism to ensure the email was sent from a valid domain. Defined by an `IDKIMRegistry` interface. + * * A https://docs.zk.email/email-tx-builder/architecture/command-templates[command template] validation + * mechanism to ensure the email command matches the expected format and parameters. + * * A https://docs.zk.email/architecture/zk-proofs#how-zk-email-uses-zero-knowledge-proofs[zero-knowledge proof] verification + * mechanism to ensure the email was actually sent and received without revealing its contents. Defined by an `IVerifier` interface. + */ +library ZKEmailUtils { + using CommandUtils for bytes[]; + using Bytes for bytes; + using Strings for string; + + /// @dev Enumeration of possible email proof validation errors. + enum EmailProofError { + NoError, + DKIMPublicKeyHash, // The DKIM public key hash verification fails + MaskedCommandLength, // The masked command length exceeds the maximum + SkippedCommandPrefixSize, // The skipped command prefix size is invalid + MismatchedCommand, // The command does not match the proof command + EmailProof // The email proof verification fails + } + + /// @dev Enumeration of possible string cases used to compare the command with the expected proven command. + enum Case { + CHECKSUM, // Computes a checksum of the command. + LOWERCASE, // Converts the command to hex lowercase. + UPPERCASE, // Converts the command to hex uppercase. + ANY + } + + /// @dev Variant of {isValidZKEmail} that validates the `["signHash", "{uint}"]` command template. + function isValidZKEmail( + EmailAuthMsg memory emailAuthMsg, + IDKIMRegistry dkimregistry, + IVerifier verifier + ) internal view returns (EmailProofError) { + string[] memory signHashTemplate = new string[](2); + signHashTemplate[0] = "signHash"; + signHashTemplate[1] = CommandUtils.UINT_MATCHER; // UINT_MATCHER is always lowercase + return isValidZKEmail(emailAuthMsg, dkimregistry, verifier, signHashTemplate, Case.LOWERCASE); + } + + /** + * @dev Validates a ZKEmail authentication message. + * + * This function takes an email authentication message, a DKIM registry contract, and a verifier contract + * as inputs. It performs several validation checks and returns a tuple containing a boolean success flag + * and an {EmailProofError} if validation failed. Returns {EmailProofError.NoError} if all validations pass, + * or false with a specific {EmailProofError} indicating which validation check failed. + * + * NOTE: Attempts to validate the command for all possible string {Case} values. + */ + function isValidZKEmail( + EmailAuthMsg memory emailAuthMsg, + IDKIMRegistry dkimregistry, + IVerifier verifier, + string[] memory template + ) internal view returns (EmailProofError) { + return isValidZKEmail(emailAuthMsg, dkimregistry, verifier, template, Case.ANY); + } + + /** + * @dev Variant of {isValidZKEmail} that validates a template with a specific string {Case}. + * + * Useful for templates with Ethereum address matchers (i.e. `{ethAddr}`), which are case-sensitive (e.g., `["someCommand", "{address}"]`). + */ + function isValidZKEmail( + EmailAuthMsg memory emailAuthMsg, + IDKIMRegistry dkimregistry, + IVerifier verifier, + string[] memory template, + Case stringCase + ) internal view returns (EmailProofError) { + if (emailAuthMsg.skippedCommandPrefix >= verifier.commandBytes()) { + return EmailProofError.SkippedCommandPrefixSize; + } else if (bytes(emailAuthMsg.proof.maskedCommand).length > verifier.commandBytes()) { + return EmailProofError.MaskedCommandLength; + } else if (!_commandMatch(emailAuthMsg, template, stringCase)) { + return EmailProofError.MismatchedCommand; + } else if ( + !dkimregistry.isDKIMPublicKeyHashValid(emailAuthMsg.proof.domainName, emailAuthMsg.proof.publicKeyHash) + ) { + return EmailProofError.DKIMPublicKeyHash; + } else { + return verifier.verifyEmailProof(emailAuthMsg.proof) ? EmailProofError.NoError : EmailProofError.EmailProof; + } + } + + /// @dev Compares the command in the email authentication message with the expected command. + function _commandMatch( + EmailAuthMsg memory emailAuthMsg, + string[] memory template, + Case stringCase + ) private pure returns (bool) { + bytes[] memory commandParams = emailAuthMsg.commandParams; // Not a memory copy + uint256 skippedCommandPrefix = emailAuthMsg.skippedCommandPrefix; // Not a memory copy + string memory command = string(bytes(emailAuthMsg.proof.maskedCommand).slice(skippedCommandPrefix)); // Not a memory copy + + if (stringCase != Case.ANY) + return commandParams.computeExpectedCommand(template, uint8(stringCase)).equal(command); + return + commandParams.computeExpectedCommand(template, uint8(Case.LOWERCASE)).equal(command) || + commandParams.computeExpectedCommand(template, uint8(Case.UPPERCASE)).equal(command) || + commandParams.computeExpectedCommand(template, uint8(Case.CHECKSUM)).equal(command); + } +} diff --git a/docs/modules/ROOT/pages/account-abstraction.adoc b/docs/modules/ROOT/pages/account-abstraction.adoc index eddfa58c..fe8d2501 100644 --- a/docs/modules/ROOT/pages/account-abstraction.adoc +++ b/docs/modules/ROOT/pages/account-abstraction.adoc @@ -56,6 +56,31 @@ Similarly, some government and corporate public key infrastructures use RSA for include::api:example$account/MyAccountRSA.sol[] ---- +For email-based authentication, the library provides xref:api:utils.adoc#SignerZKEmail[`SignerZKEmail`], which enables secure authentication through email messages using zero-knowledge proofs. This implementation leverages DKIM signatures from a trusted registry and a verifier contract to ensure email authenticity without revealing sensitive information. + +The validation process involves several key components: + +* A https://docs.zk.email/architecture/dkim-verification[DKIMRegistry] (DomainKeys Identified Mail) verification mechanism to ensure the email was sent from a valid domain +* A https://docs.zk.email/email-tx-builder/architecture/command-templates[command template] validation mechanism to ensure the email command matches the expected format and parameters +* A https://docs.zk.email/architecture/zk-proofs#how-zk-email-uses-zero-knowledge-proofs[zero-knowledge proof] verification mechanism to ensure the email was actually sent and received without revealing its contents + +To use this signer, developers must set up several components during initialization: + +* **accountSalt**: A unique identifier derived from the user's email address and account code. This is used for: + * User Identification: Links the email address to a specific Ethereum address securely and deterministically + * Security: Provides a unique identifier that cannot be easily guessed or brute-forced + * Deterministic Address Generation: Enables the creation of deterministic addresses based on email addresses +* **DKIMRegistry**: An instance of the DKIM registry contract for domain verification +* **verifier**: An instance of the Verifier contract for zero-knowledge proof validation +* **templateId**: The command template of the sign hash command, defining the expected format + +[source,solidity] +---- +include::api:example$account/MyAccountZKEmail.sol[] +---- + +WARNING: Leaving any of the required components uninitialized may leave the account unusable since no proper authentication mechanism would be associated with it. + For more advanced use cases where you need to support keys that don't have their own Ethereum address (like hardware devices or non-Ethereum cryptographic curves), you can use xref:api:utils.adoc#SignerERC7913[`SignerERC7913`]. This implementation allows for signature verification using ERC-7913 compatible verifiers. [source,solidity] diff --git a/lib/email-tx-builder b/lib/email-tx-builder new file mode 160000 index 00000000..86c905e8 --- /dev/null +++ b/lib/email-tx-builder @@ -0,0 +1 @@ +Subproject commit 86c905e8a583f4b1ffe960df8b90e46368acabd9 diff --git a/lib/forge-std b/lib/forge-std index 841c3a3e..6abf6698 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 841c3a3e8b7612f76599797b93da5a61899c2aa8 +Subproject commit 6abf66980050ab03a35b52bdab814f55001d6929 diff --git a/package-lock.json b/package-lock.json index 45add159..fdb8ed80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@axelar-network/axelar-gmp-sdk-solidity": "^6.0.6" + "@axelar-network/axelar-gmp-sdk-solidity": "^6.0.6", + "@zk-email/contracts": "^6.3.2" }, "devDependencies": { "@openzeppelin/contracts": "file:lib/@openzeppelin-contracts", @@ -21,7 +22,6 @@ "lib/@openzeppelin-contracts": { "name": "openzeppelin-solidity", "version": "5.3.0", - "dev": true, "license": "MIT", "devDependencies": { "@changesets/changelog-github": "^0.5.0", @@ -1744,6 +1744,14 @@ "resolved": "lib/@openzeppelin-contracts", "link": true }, + "node_modules/@openzeppelin/contracts-upgradeable": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-5.2.0.tgz", + "integrity": "sha512-mZIu9oa4tQTlGiOJHk6D3LdJlqFqF6oNOSn6S6UVJtzfs9UsY9/dhMEbAVTwElxUtJnjpf6yA062+oBp+eOyPg==", + "peerDependencies": { + "@openzeppelin/contracts": "5.2.0" + } + }, "node_modules/@openzeppelin/docs-utils": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@openzeppelin/docs-utils/-/docs-utils-0.1.5.tgz", @@ -2267,6 +2275,27 @@ "@types/node": "*" } }, + "node_modules/@zk-email/contracts": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@zk-email/contracts/-/contracts-6.3.2.tgz", + "integrity": "sha512-+JW0aZMlcrT66lsLqpyxHoGrOT3cMsfBnRinJ8wgXJit4rbZSRYKqVi42dGMyJVvpiJ8O9VQ5hbeYn/fpQKUxw==", + "dependencies": { + "@openzeppelin/contracts": "^5.0.0", + "@openzeppelin/contracts-upgradeable": "^5.0.0", + "dotenv": "^16.3.1" + } + }, + "node_modules/@zk-email/contracts/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/abbrev": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", diff --git a/package.json b/package.json index bdd79611..2b7d3c63 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "prepare-docs": "scripts/prepare-docs.sh", "lint": "npm run lint:js && npm run lint:sol", "lint:fix": "npm run lint:js:fix && npm run lint:sol:fix", - "lint:js": "prettier --log-level warn --ignore-path .gitignore '**/*.{js,ts}' --check && eslint .", - "lint:js:fix": "prettier --log-level warn --ignore-path .gitignore '**/*.{js,ts}' --write && eslint . --fix", - "lint:sol": "prettier --log-level warn --ignore-path .gitignore '{contracts,test}/**/*.sol' --check && solhint '{contracts,test}/**/*.sol'", - "lint:sol:fix": "prettier --log-level warn --ignore-path .gitignore '{contracts,test}/**/*.sol' --write", + "lint:js": "prettier --log-level warn '**/*.{js,ts}' --check && eslint .", + "lint:js:fix": "prettier --log-level warn '**/*.{js,ts}' --write && eslint . --fix", + "lint:sol": "prettier --log-level warn '{contracts,test}/**/*.sol' --check && solhint '{contracts,test}/**/*.sol'", + "lint:sol:fix": "prettier --log-level warn '{contracts,test}/**/*.sol' --write", "coverage": "scripts/checks/coverage.sh", "generate": "scripts/generate/run.js", "test": "hardhat test", @@ -45,7 +45,8 @@ "zeppelin" ], "dependencies": { - "@axelar-network/axelar-gmp-sdk-solidity": "^6.0.6" + "@axelar-network/axelar-gmp-sdk-solidity": "^6.0.6", + "@zk-email/contracts": "^6.3.2" }, "devDependencies": { "@openzeppelin/contracts": "file:lib/@openzeppelin-contracts", diff --git a/remappings.txt b/remappings.txt index 27ffccff..2360945c 100644 --- a/remappings.txt +++ b/remappings.txt @@ -2,3 +2,5 @@ @openzeppelin/contracts-upgradeable/=lib/@openzeppelin-contracts-upgradeable/contracts/ @openzeppelin/community-contracts/=contracts/ @axelar-network/axelar-gmp-sdk-solidity/=node_modules/@axelar-network/axelar-gmp-sdk-solidity/ +@zk-email/email-tx-builder/=lib/email-tx-builder/packages/contracts/src/ +@zk-email/contracts/=node_modules/@zk-email/contracts/ diff --git a/scripts/checks/pragma-consistency.js b/scripts/checks/pragma-consistency.js index cf74cd27..a4e25c55 100755 --- a/scripts/checks/pragma-consistency.js +++ b/scripts/checks/pragma-consistency.js @@ -7,7 +7,7 @@ const { findAll } = require('solidity-ast/utils'); const { _: artifacts } = require('yargs').argv; // files to skip -const skipPatterns = ['contracts-exposed/**', 'contracts/mocks/WithInit.sol']; +const skipPatterns = ['contracts-exposed/**', 'contracts/mocks/WithInit.sol', '@zk-email/**']; for (const artifact of artifacts) { const { output: solcOutput } = require(path.resolve(__dirname, '../..', artifact)); diff --git a/test/account/AccountZKEmail.test.js b/test/account/AccountZKEmail.test.js new file mode 100644 index 00000000..0531ecf0 --- /dev/null +++ b/test/account/AccountZKEmail.test.js @@ -0,0 +1,86 @@ +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { ERC4337Helper } = require('../helpers/erc4337'); + +const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior'); +const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior'); +const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior'); +const { ZKEmailSigningKey, NonNativeSigner } = require('../helpers/signers'); + +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]); + +const SIGN_HASH_COMMAND = 'signHash'; + +async function fixture() { + // EOAs and environment + const [admin, beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMockExtended'); + + // Registry + 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)); + + // Verifier + const verifier = await ethers.deployContract('ZKEmailVerifierMock'); + + // ERC-4337 signer + const signer = new NonNativeSigner( + new ZKEmailSigningKey(domainName, publicKeyHash, emailNullifier, accountSalt, templateId), + ); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const mock = await helper.newAccount('$AccountZKEmailMock', [accountSalt, dkim.target, verifier.target, templateId]); + + const signUserOp = async userOp => { + // Create email auth message for the user operation hash + const hash = await userOp.hash(); + return Object.assign(userOp, { signature: signer.signingKey.sign(hash).serialized }); + }; + + const userOpInvalidSig = async userOp => { + // Create email auth message for the user operation hash + const hash = await userOp.hash(); + const timestamp = Math.floor(Date.now() / 1000); + const command = SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(); + const isCodeExist = true; + const proof = '0x00'; // Mocked in ZKEmailVerifierMock + + // Encode the email auth message as the signature + return ethers.AbiCoder.defaultAbiCoder().encode( + ['tuple(uint256,bytes[],uint256,tuple(string,bytes32,uint256,string,bytes32,bytes32,bool,bytes))'], + [ + [ + templateId, + [hash], + 0, // skippedCommandPrefix + [domainName, publicKeyHash, timestamp, command, emailNullifier, accountSalt, isCodeExist, proof], + ], + ], + ); + }; + + return { helper, mock, dkim, verifier, target, beneficiary, other, signUserOp, userOpInvalidSig, signer }; +} + +describe('AccountZKEmail', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); +}); diff --git a/test/helpers/enums.js b/test/helpers/enums.js new file mode 100644 index 00000000..37d9e576 --- /dev/null +++ b/test/helpers/enums.js @@ -0,0 +1,14 @@ +const enums = require('@openzeppelin/contracts/test/helpers/enums'); + +module.exports = { + ...enums, + EmailProofError: enums.Enum( + 'NoError', + 'DKIMPublicKeyHash', + 'MaskedCommandLength', + 'SkippedCommandPrefixSize', + 'MismatchedCommand', + 'EmailProof', + ), + Case: enums.EnumTyped('CHECKSUM', 'LOWERCASE', 'UPPERCASE', 'ANY'), +}; diff --git a/test/helpers/signers.js b/test/helpers/signers.js index 6cd9b6c3..2e354d47 100644 --- a/test/helpers/signers.js +++ b/test/helpers/signers.js @@ -1,4 +1,5 @@ const { + AbiCoder, AbstractSigner, Signature, TypedDataEncoder, @@ -13,6 +14,7 @@ const { hexlify, sha256, toBeHex, + toBigInt, } = require('ethers'); const { secp256r1 } = require('@noble/curves/p256'); const { generateKeyPairSync, privateEncrypt } = require('crypto'); @@ -156,9 +158,74 @@ class RSASHA256SigningKey extends RSASigningKey { } } +class ZKEmailSigningKey { + #domainName; + #publicKeyHash; + #emailNullifier; + #accountSalt; + #templateId; + + constructor(domainName, publicKeyHash, emailNullifier, accountSalt, templateId) { + this.#domainName = domainName; + this.#publicKeyHash = publicKeyHash; + this.#emailNullifier = emailNullifier; + this.#accountSalt = accountSalt; + this.#templateId = templateId; + this.SIGN_HASH_COMMAND = 'signHash'; + } + + get domainName() { + return this.#domainName; + } + + get publicKeyHash() { + return this.#publicKeyHash; + } + + get emailNullifier() { + return this.#emailNullifier; + } + + get accountSalt() { + return this.#accountSalt; + } + + sign(digest /*: BytesLike*/ /*: Signature*/) { + const timestamp = Math.floor(Date.now() / 1000); + const command = this.SIGN_HASH_COMMAND + ' ' + toBigInt(digest).toString(); + const isCodeExist = true; + const proof = '0x01'; // Mocked in ZKEmailVerifierMock + + // Encode the email auth message as the signature + return { + serialized: AbiCoder.defaultAbiCoder().encode( + ['tuple(uint256,bytes[],uint256,tuple(string,bytes32,uint256,string,bytes32,bytes32,bool,bytes))'], + [ + [ + this.#templateId, + [digest], + 0, // skippedCommandPrefix + [ + this.#domainName, + this.#publicKeyHash, + timestamp, + command, + this.#emailNullifier, + this.#accountSalt, + isCodeExist, + proof, + ], + ], + ], + ), + }; + } +} + module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey, + ZKEmailSigningKey, }; diff --git a/test/utils/cryptography/ZKEmailUtils.t.sol b/test/utils/cryptography/ZKEmailUtils.t.sol new file mode 100644 index 00000000..8888fab5 --- /dev/null +++ b/test/utils/cryptography/ZKEmailUtils.t.sol @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {ZKEmailUtils} from "../../../contracts/utils/cryptography/ZKEmailUtils.sol"; +import {ECDSAOwnedDKIMRegistry} from "@zk-email/email-tx-builder/utils/ECDSAOwnedDKIMRegistry.sol"; +import {Groth16Verifier} from "@zk-email/email-tx-builder/utils/Groth16Verifier.sol"; +import {Verifier, EmailProof} from "@zk-email/email-tx-builder/utils/Verifier.sol"; +import {EmailAuthMsg} from "@zk-email/email-tx-builder/interfaces/IEmailTypes.sol"; +import {IDKIMRegistry} from "@zk-email/contracts/DKIMRegistry.sol"; +import {IVerifier, EmailProof} from "@zk-email/email-tx-builder/interfaces/IVerifier.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {CommandUtils} from "@zk-email/email-tx-builder/libraries/CommandUtils.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract ZKEmailUtilsTest is Test { + using Strings for *; + using ZKEmailUtils for EmailAuthMsg; + + // Base field size + uint256 constant Q = 21888242871839275222246405745257275088696311157297823662689037894645226208583; + + IDKIMRegistry private _dkimRegistry; + IVerifier private _verifier; + bytes32 private _accountSalt; + uint256 private _templateId; + // From https://github.com/zkemail/email-tx-builder/blob/main/packages/contracts/test/helpers/DeploymentHelper.sol#L36-L41 + string private _selector = "1234"; + string private _domainName = "gmail.com"; + bytes32 private _publicKeyHash = 0x0ea9c777dc7110e5a9e89b13f0cfc540e3845ba120b2b6dc24024d61488d4788; + bytes32 private _emailNullifier = 0x00a83fce3d4b1c9ef0f600644c1ecc6c8115b57b1596e0e3295e2c5105fbfd8a; + bytes private _mockProof; + + string private constant SIGN_HASH_COMMAND = "signHash "; + + function setUp() public { + // Deploy DKIM Registry + _dkimRegistry = _createECDSAOwnedDKIMRegistry(); + + // Deploy Verifier + _verifier = _createVerifier(); + + // Generate test data + _accountSalt = keccak256("test@example.com"); + _templateId = 1; + _mockProof = abi.encodePacked(bytes1(0x01)); + } + + function buildEmailAuthMsg( + string memory command, + bytes[] memory params, + uint256 skippedPrefix + ) public view returns (EmailAuthMsg memory emailAuthMsg) { + EmailProof memory emailProof = EmailProof({ + domainName: _domainName, + publicKeyHash: _publicKeyHash, + timestamp: block.timestamp, + maskedCommand: command, + emailNullifier: _emailNullifier, + accountSalt: _accountSalt, + isCodeExist: true, + proof: _mockProof + }); + + emailAuthMsg = EmailAuthMsg({ + templateId: _templateId, + commandParams: params, + skippedCommandPrefix: skippedPrefix, + proof: emailProof + }); + } + + function testIsValidZKEmailSignHash( + bytes32 hash, + uint256 timestamp, + bytes32 emailNullifier, + bytes32 accountSalt, + bool isCodeExist, + bytes memory proof + ) public { + // Build email auth message with fuzzed parameters + bytes[] memory commandParams = new bytes[](1); + commandParams[0] = abi.encode(hash); + + EmailAuthMsg memory emailAuthMsg = buildEmailAuthMsg( + string.concat(SIGN_HASH_COMMAND, uint256(hash).toString()), + commandParams, + 0 + ); + + // Override with fuzzed values + emailAuthMsg.proof.timestamp = timestamp; + emailAuthMsg.proof.emailNullifier = emailNullifier; + emailAuthMsg.proof.accountSalt = accountSalt; + emailAuthMsg.proof.isCodeExist = isCodeExist; + emailAuthMsg.proof.proof = proof; + + _mockVerifyEmailProof(emailAuthMsg.proof); + + // Test validation + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( + emailAuthMsg, + IDKIMRegistry(_dkimRegistry), + IVerifier(_verifier) + ); + + assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.NoError)); + } + + function testIsValidZKEmailWithTemplate( + bytes32 hash, + uint256 timestamp, + bytes32 emailNullifier, + bytes32 accountSalt, + bool isCodeExist, + bytes memory proof, + string memory commandPrefix + ) public { + bytes[] memory commandParams = new bytes[](1); + commandParams[0] = abi.encode(hash); + + EmailAuthMsg memory emailAuthMsg = buildEmailAuthMsg( + string.concat(commandPrefix, " ", uint256(hash).toString()), + commandParams, + 0 + ); + + // Override with fuzzed values + emailAuthMsg.proof.timestamp = timestamp; + emailAuthMsg.proof.emailNullifier = emailNullifier; + emailAuthMsg.proof.accountSalt = accountSalt; + emailAuthMsg.proof.isCodeExist = isCodeExist; + emailAuthMsg.proof.proof = proof; + + string[] memory template = new string[](2); + template[0] = commandPrefix; + template[1] = CommandUtils.UINT_MATCHER; + + _mockVerifyEmailProof(emailAuthMsg.proof); + + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( + emailAuthMsg, + IDKIMRegistry(_dkimRegistry), + IVerifier(_verifier), + template + ); + + assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.NoError)); + } + + function testCommandMatchWithDifferentCases( + address addr, + uint256 timestamp, + bytes32 emailNullifier, + bytes32 accountSalt, + bool isCodeExist, + bytes memory proof, + string memory commandPrefix + ) public { + bytes[] memory commandParams = new bytes[](1); + commandParams[0] = abi.encode(addr); + + // Test with different cases + for (uint256 i = 0; i < uint8(type(ZKEmailUtils.Case).max) - 1; i++) { + EmailAuthMsg memory emailAuthMsg = buildEmailAuthMsg( + string.concat(commandPrefix, " ", CommandUtils.addressToHexString(addr, i)), + commandParams, + 0 + ); + + // Override with fuzzed values + emailAuthMsg.proof.timestamp = timestamp; + emailAuthMsg.proof.emailNullifier = emailNullifier; + emailAuthMsg.proof.accountSalt = accountSalt; + emailAuthMsg.proof.isCodeExist = isCodeExist; + emailAuthMsg.proof.proof = proof; + + _mockVerifyEmailProof(emailAuthMsg.proof); + + string[] memory template = new string[](2); + template[0] = commandPrefix; + template[1] = CommandUtils.ETH_ADDR_MATCHER; + + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( + emailAuthMsg, + IDKIMRegistry(_dkimRegistry), + IVerifier(_verifier), + template, + ZKEmailUtils.Case(i) + ); + assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.NoError)); + } + } + + function testCommandMatchWithAnyCase( + address addr, + uint256 timestamp, + bytes32 emailNullifier, + bytes32 accountSalt, + bool isCodeExist, + bytes memory proof, + string memory commandPrefix + ) public { + bytes[] memory commandParams = new bytes[](1); + commandParams[0] = abi.encode(addr); + + EmailAuthMsg memory emailAuthMsg = buildEmailAuthMsg( + string.concat(commandPrefix, " ", addr.toHexString()), + commandParams, + 0 + ); + + // Override with fuzzed values + emailAuthMsg.proof.timestamp = timestamp; + emailAuthMsg.proof.emailNullifier = emailNullifier; + emailAuthMsg.proof.accountSalt = accountSalt; + emailAuthMsg.proof.isCodeExist = isCodeExist; + emailAuthMsg.proof.proof = proof; + + string[] memory template = new string[](2); + template[0] = commandPrefix; + template[1] = CommandUtils.ETH_ADDR_MATCHER; + + _mockVerifyEmailProof(emailAuthMsg.proof); + + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( + emailAuthMsg, + IDKIMRegistry(_dkimRegistry), + IVerifier(_verifier), + template, + ZKEmailUtils.Case.ANY + ); + + assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.NoError)); + } + + function testInvalidDKIMPublicKeyHash(bytes32 hash, string memory domainName, bytes32 publicKeyHash) public view { + bytes[] memory commandParams = new bytes[](1); + commandParams[0] = abi.encode(hash); + + EmailAuthMsg memory emailAuthMsg = buildEmailAuthMsg( + string.concat(SIGN_HASH_COMMAND, uint256(hash).toString()), + commandParams, + 0 + ); + + emailAuthMsg.proof.domainName = domainName; + emailAuthMsg.proof.publicKeyHash = publicKeyHash; + + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( + emailAuthMsg, + IDKIMRegistry(_dkimRegistry), + IVerifier(_verifier) + ); + + assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.DKIMPublicKeyHash)); + } + + function testInvalidMaskedCommandLength(bytes32 hash, uint256 length) public view { + length = bound(length, 606, 1000); // Assuming commandBytes is 605 + + bytes[] memory commandParams = new bytes[](1); + commandParams[0] = abi.encode(hash); + + EmailAuthMsg memory emailAuthMsg = buildEmailAuthMsg(string(new bytes(length)), commandParams, 0); + + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( + emailAuthMsg, + IDKIMRegistry(_dkimRegistry), + IVerifier(_verifier) + ); + + assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.MaskedCommandLength)); + } + + function testSkippedCommandPrefix(bytes32 hash, uint256 skippedPrefix) public view { + uint256 verifierCommandBytes = _verifier.commandBytes(); + skippedPrefix = bound(skippedPrefix, verifierCommandBytes, verifierCommandBytes + 1000); + + bytes[] memory commandParams = new bytes[](1); + commandParams[0] = abi.encode(hash); + + EmailAuthMsg memory emailAuthMsg = buildEmailAuthMsg( + string.concat(SIGN_HASH_COMMAND, uint256(hash).toString()), + commandParams, + skippedPrefix + ); + + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( + emailAuthMsg, + IDKIMRegistry(_dkimRegistry), + IVerifier(_verifier) + ); + + assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.SkippedCommandPrefixSize)); + } + + function testMismatchedCommand(bytes32 hash, string memory invalidCommand) public view { + bytes[] memory commandParams = new bytes[](1); + commandParams[0] = abi.encode(hash); + + EmailAuthMsg memory emailAuthMsg = buildEmailAuthMsg(invalidCommand, commandParams, 0); + + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( + emailAuthMsg, + IDKIMRegistry(_dkimRegistry), + IVerifier(_verifier) + ); + + assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.MismatchedCommand)); + } + + function testInvalidEmailProof( + bytes32 hash, + uint256[2] memory pA, + uint256[2][2] memory pB, + uint256[2] memory pC + ) public view { + // TODO: Remove these when the Verifier wrapper does not revert. + pA[0] = bound(pA[0], 1, Q - 1); + pA[1] = bound(pA[1], 1, Q - 1); + pB[0][0] = bound(pB[0][0], 1, Q - 1); + pB[0][1] = bound(pB[0][1], 1, Q - 1); + pB[1][0] = bound(pB[1][0], 1, Q - 1); + pB[1][1] = bound(pB[1][1], 1, Q - 1); + pC[0] = bound(pC[0], 1, Q - 1); + pC[1] = bound(pC[1], 1, Q - 1); + + bytes[] memory commandParams = new bytes[](1); + commandParams[0] = abi.encode(hash); + + EmailAuthMsg memory emailAuthMsg = buildEmailAuthMsg( + string.concat(SIGN_HASH_COMMAND, uint256(hash).toString()), + commandParams, + 0 + ); + + emailAuthMsg.proof.proof = abi.encode(pA, pB, pC); + + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( + emailAuthMsg, + IDKIMRegistry(_dkimRegistry), + IVerifier(_verifier) + ); + + assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.EmailProof)); + } + + function _createVerifier() private returns (IVerifier) { + Verifier verifier = new Verifier(); + Groth16Verifier groth16Verifier = new Groth16Verifier(); + verifier.initialize(msg.sender, address(groth16Verifier)); + return verifier; + } + + function _createECDSAOwnedDKIMRegistry() private returns (IDKIMRegistry) { + ECDSAOwnedDKIMRegistry ecdsaDkim = new ECDSAOwnedDKIMRegistry(); + (address alice, uint256 alicePk) = makeAddrAndKey("alice"); + ecdsaDkim.initialize(alice, alice); + string memory prefix = ecdsaDkim.SET_PREFIX(); + string memory message = ecdsaDkim.computeSignedMsg(prefix, _domainName, _publicKeyHash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, MessageHashUtils.toEthSignedMessageHash(bytes(message))); + ecdsaDkim.setDKIMPublicKeyHash(_selector, _domainName, _publicKeyHash, abi.encodePacked(r, s, v)); + return ecdsaDkim; + } + + function _mockVerifyEmailProof(EmailProof memory emailProof) private { + vm.mockCall(address(_verifier), abi.encodeCall(IVerifier.verifyEmailProof, (emailProof)), abi.encode(true)); + } +} diff --git a/test/utils/cryptography/ZKEmailUtils.test.js b/test/utils/cryptography/ZKEmailUtils.test.js new file mode 100644 index 00000000..d5594231 --- /dev/null +++ b/test/utils/cryptography/ZKEmailUtils.test.js @@ -0,0 +1,177 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { EmailProofError, Case } = require('../../helpers/enums'); + +const accountSalt = '0x046582bce36cdd0a8953b9d40b8f20d58302bacf3bcecffeb6741c98a52725e2'; // keccak256("test@example.com") + +// From https://github.com/zkemail/email-tx-builder/blob/main/packages/contracts/test/helpers/DeploymentHelper.sol#L36-L41 +const selector = '12345'; +const domainName = 'gmail.com'; +const publicKeyHash = '0x0ea9c777dc7110e5a9e89b13f0cfc540e3845ba120b2b6dc24024d61488d4788'; +const emailNullifier = '0x00a83fce3d4b1c9ef0f600644c1ecc6c8115b57b1596e0e3295e2c5105fbfd8a'; + +const templateId = ethers.solidityPackedKeccak256(['string', 'uint256'], ['TEST', 0n]); + +const SIGN_HASH_COMMAND = 'signHash'; +const UINT_MATCHER = '{uint}'; +const ETH_ADDR_MATCHER = '{ethAddr}'; + +async function fixture() { + const [admin, other, ...accounts] = await ethers.getSigners(); + + // Registry + 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)); + + // Verifier + const verifier = await ethers.deployContract('ZKEmailVerifierMock'); + + // Mock + const mock = await ethers.deployContract('$ZKEmailUtils'); + + return { admin, other, accounts, dkim, verifier, mock }; +} + +function buildEmailAuthMsg(command, params, skippedPrefix) { + const emailProof = { + domainName, + publicKeyHash, + timestamp: Math.floor(Date.now() / 1000), + maskedCommand: command, + emailNullifier, + accountSalt, + isCodeExist: true, + proof: '0x01', // Mocked in ZKEmailVerifierMock + }; + + return { + templateId, + commandParams: params, + skippedCommandPrefix: skippedPrefix, + proof: emailProof, + }; +} + +describe('ZKEmail', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('should validate ZKEmail sign hash', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const emailAuthMsg = buildEmailAuthMsg(SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(), [hash], 0); + await expect(this.mock.$isValidZKEmail(emailAuthMsg, this.dkim.target, this.verifier.target)).to.eventually.equal( + EmailProofError.NoError, + ); + }); + + it('should validate ZKEmail with template', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const commandPrefix = 'testCommand'; + const emailAuthMsg = buildEmailAuthMsg(commandPrefix + ' ' + ethers.toBigInt(hash).toString(), [hash], 0); + const template = [commandPrefix, UINT_MATCHER]; + const fnSig = + '$isValidZKEmail((uint256,bytes[],uint256,(string,bytes32,uint256,string,bytes32,bytes32,bool,bytes)),address,address,string[])'; + await expect(this.mock[fnSig](emailAuthMsg, this.dkim.target, this.verifier.target, template)).to.eventually.equal( + EmailProofError.NoError, + ); + }); + + it('should validate command with address match with different cases', async function () { + const commandPrefix = 'testCommand'; + const template = [commandPrefix, ETH_ADDR_MATCHER]; + + for (const { caseType, address } of [ + { + caseType: Case.LOWERCASE, + address: this.other.address.toLowerCase(), + }, + { caseType: Case.UPPERCASE, address: this.other.address.toUpperCase().replace('0X', '0x') }, + { caseType: Case.CHECKSUM, address: ethers.getAddress(this.other.address) }, + ]) { + const emailAuthMsg = buildEmailAuthMsg(commandPrefix + ' ' + address, [ethers.zeroPadValue(address, 32)], 0); + await expect( + this.mock.$isValidZKEmail(emailAuthMsg, this.dkim.target, this.verifier.target, template, caseType), + ).to.eventually.equal(EmailProofError.NoError); + } + }); + + it('should validate command with address match with any case', async function () { + const commandPrefix = 'testCommand'; + const template = [commandPrefix, ETH_ADDR_MATCHER]; + + // Test with different cases that should all work with ANY case + const addresses = [ + this.other.address.toLowerCase(), + this.other.address.toUpperCase().replace('0X', '0x'), + ethers.getAddress(this.other.address), + ]; + + for (const address of addresses) { + const emailAuthMsg = buildEmailAuthMsg(commandPrefix + ' ' + address, [ethers.zeroPadValue(address, 32)], 0); + await expect( + this.mock.$isValidZKEmail( + emailAuthMsg, + this.dkim.target, + this.verifier.target, + template, + ethers.Typed.uint8(Case.ANY), + ), + ).to.eventually.equal(EmailProofError.NoError); + } + }); + + it('should detect invalid DKIM public key hash', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const emailAuthMsg = buildEmailAuthMsg(SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(), [hash], 0); + emailAuthMsg.proof.publicKeyHash = ethers.hexlify(ethers.randomBytes(32)); // Use a different public key hash + await expect(this.mock.$isValidZKEmail(emailAuthMsg, this.dkim.target, this.verifier.target)).to.eventually.equal( + EmailProofError.DKIMPublicKeyHash, + ); + }); + + it('should detect invalid masked command length', async function () { + // Create a command that's too long (606 bytes) + const longCommand = 'a'.repeat(606); + const emailAuthMsg = buildEmailAuthMsg(longCommand, [ethers.hexlify(ethers.randomBytes(32))], 0); + await expect(this.mock.$isValidZKEmail(emailAuthMsg, this.dkim.target, this.verifier.target)).to.eventually.equal( + EmailProofError.MaskedCommandLength, + ); + }); + + it('should detect invalid skipped command prefix', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const commandBytes = await this.verifier.commandBytes(); + const emailAuthMsg = buildEmailAuthMsg( + SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(), + [hash], + BigInt(commandBytes) + 1n, // Set skipped prefix to be larger than commandBytes + ); + await expect(this.mock.$isValidZKEmail(emailAuthMsg, this.dkim.target, this.verifier.target)).to.eventually.equal( + EmailProofError.SkippedCommandPrefixSize, + ); + }); + + it('should detect mismatched command', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const emailAuthMsg = buildEmailAuthMsg('invalidCommand ' + ethers.toBigInt(hash).toString(), [hash], 0); + await expect(this.mock.$isValidZKEmail(emailAuthMsg, this.dkim.target, this.verifier.target)).to.eventually.equal( + EmailProofError.MismatchedCommand, + ); + }); + + it('should detect invalid email proof', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const emailAuthMsg = buildEmailAuthMsg(SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(), [hash], 0); + emailAuthMsg.proof.proof = '0x00'; // Use an invalid proof + await expect(this.mock.$isValidZKEmail(emailAuthMsg, this.dkim.target, this.verifier.target)).to.eventually.equal( + EmailProofError.EmailProof, + ); + }); +});