Skip to content

Remove templateId and simplify EmailProof validation in ZKEmailUtils #210

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 10-08-2025

- `ZKEmailUtils`: Add `tryDecodeEmailProof` function for safe calldata decoding with comprehensive bounds checking and validation for `EmailProof` struct.
- `ZKEmailUtils`: Update `isValidZKEmail` to receive `EmailProof` struct directly instead of `EmailAuthMsg` struct.
- `SignerZKEmail`: Remove `templateId` functionality and switch from `EmailAuthMsg` to direct `EmailProof` validation for streamlined signature verification.
- `ERC7913ZKEmailVerifier`: Remove `templateId` from signature validation logic and update `_decodeKey` function to directly decode `EmailProof` struct.

## 09-08-2025

- `ZKEmailUtils`: Simplify library implementation and remove `Verifier.sol` indirection for cleaner integration with a Groth16Verifier.
Expand Down
4 changes: 1 addition & 3 deletions contracts/mocks/account/AccountZKEmailMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,11 @@ contract AccountZKEmailMock is Account, SignerZKEmail, ERC7739, ERC7821, ERC721H
constructor(
bytes32 accountSalt_,
IDKIMRegistry registry_,
IGroth16Verifier groth16Verifier_,
uint256 templateId_
IGroth16Verifier groth16Verifier_
) EIP712("AccountZKEmailMock", "1") {
_setAccountSalt(accountSalt_);
_setDKIMRegistry(registry_);
_setVerifier(groth16Verifier_);
_setTemplateId(templateId_);
}

/// @inheritdoc ERC7821
Expand Down
146 changes: 100 additions & 46 deletions contracts/utils/cryptography/ZKEmailUtils.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 {IGroth16Verifier} from "@zk-email/email-tx-builder/src/interfaces/IGroth16Verifier.sol";
import {EmailAuthMsg, EmailProof} from "@zk-email/email-tx-builder/src/interfaces/IEmailTypes.sol";
import {EmailProof} from "@zk-email/email-tx-builder/src/interfaces/IEmailTypes.sol";
import {CommandUtils} from "@zk-email/email-tx-builder/src/libraries/CommandUtils.sol";

/**
Expand Down Expand Up @@ -43,7 +43,6 @@ library ZKEmailUtils {
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
InvalidFieldPoint, // The Groth16 field point is invalid
EmailProof // The email proof verification fails
Expand All @@ -59,33 +58,38 @@ library ZKEmailUtils {

/// @dev Variant of {isValidZKEmail} that validates the `["signHash", "{uint}"]` command template.
function isValidZKEmail(
EmailAuthMsg memory emailAuthMsg,
EmailProof memory emailProof,
IDKIMRegistry dkimregistry,
IGroth16Verifier groth16Verifier
IGroth16Verifier groth16Verifier,
bytes32 hash
) 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, groth16Verifier, signHashTemplate, Case.LOWERCASE);
bytes[] memory signHashParams = new bytes[](1);
signHashParams[0] = abi.encode(hash);
return
isValidZKEmail(emailProof, dkimregistry, groth16Verifier, signHashTemplate, signHashParams, Case.LOWERCASE);
}

/**
* @dev Validates a ZKEmail authentication message.
* @dev Validates a ZKEmail proof against a command template.
*
* 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.
* This function takes an email proof, a DKIM registry contract, and a verifier contract
* as inputs. It performs several validation checks and returns an {EmailProofError} indicating the result.
* Returns {EmailProofError.NoError} if all validations pass, or 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,
EmailProof memory emailProof,
IDKIMRegistry dkimregistry,
IGroth16Verifier groth16Verifier,
string[] memory template
string[] memory template,
bytes[] memory templateParams
) internal view returns (EmailProofError) {
return isValidZKEmail(emailAuthMsg, dkimregistry, groth16Verifier, template, Case.ANY);
return isValidZKEmail(emailProof, dkimregistry, groth16Verifier, template, templateParams, Case.ANY);
}

/**
Expand All @@ -94,66 +98,99 @@ library ZKEmailUtils {
* Useful for templates with Ethereum address matchers (i.e. `{ethAddr}`), which are case-sensitive (e.g., `["someCommand", "{address}"]`).
*/
function isValidZKEmail(
EmailAuthMsg memory emailAuthMsg,
EmailProof memory emailProof,
IDKIMRegistry dkimregistry,
IGroth16Verifier groth16Verifier,
string[] memory template,
bytes[] memory templateParams,
Case stringCase
) internal view returns (EmailProofError) {
if (emailAuthMsg.skippedCommandPrefix >= COMMAND_BYTES) {
return EmailProofError.SkippedCommandPrefixSize;
} else if (bytes(emailAuthMsg.proof.maskedCommand).length > COMMAND_BYTES) {
if (bytes(emailProof.maskedCommand).length > COMMAND_BYTES) {
return EmailProofError.MaskedCommandLength;
} else if (!_commandMatch(emailAuthMsg, template, stringCase)) {
} else if (!_commandMatch(emailProof, template, templateParams, stringCase)) {
return EmailProofError.MismatchedCommand;
} else if (
!dkimregistry.isDKIMPublicKeyHashValid(emailAuthMsg.proof.domainName, emailAuthMsg.proof.publicKeyHash)
) {
} else if (!dkimregistry.isDKIMPublicKeyHashValid(emailProof.domainName, emailProof.publicKeyHash)) {
return EmailProofError.DKIMPublicKeyHash;
}

(uint256[2] memory pA, uint256[2][2] memory pB, uint256[2] memory pC) = abi.decode(
emailAuthMsg.proof.proof,
emailProof.proof,
(uint256[2], uint256[2][2], uint256[2])
);

uint256 q = Q - 1; // upper bound of the field elements
if (
pA[0] > q ||
pA[1] > q ||
pB[0][0] > q ||
pB[0][1] > q ||
pB[1][0] > q ||
pB[1][1] > q ||
pC[0] > q ||
pC[1] > q
) return EmailProofError.InvalidFieldPoint;
if (!_isValidFieldPoint(pA, pB, pC)) {
return EmailProofError.InvalidFieldPoint;
}

return
groth16Verifier.verifyProof(pA, pB, pC, toPubSignals(emailAuthMsg.proof))
groth16Verifier.verifyProof(pA, pB, pC, toPubSignals(emailProof))
? EmailProofError.NoError
: EmailProofError.EmailProof;
}

/// @dev Compares the command in the email authentication message with the expected command.
/**
* @dev Verifies that calldata bytes (`input`) represents a valid `EmailProof` 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 `emailProof` object should not be accessed if `success` is false. Trying to access the data may
* cause revert/panic.
*/
function tryDecodeEmailProof(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious to know what the main benefit of these checks is? Looking at the places where this is used, there is always a call to isValidZKEmail, which will reject a malformed EmailProof either by failing to pass the zk-proof checks (including DKIM checks and command matching) or by reverting somewhere. However, it does help to revert early if EmailProof is not a valid struct. So basically would these be redundant checks for valid EmailProofs? or is there some other attack vector if these checks are not in place?

bytes calldata input
) internal pure returns (bool success, EmailProof calldata emailProof) {
assembly ("memory-safe") {
emailProof := input.offset
}

// Minimum length to hold 8 objects (32 bytes each)
if (input.length < 0x100) return (false, emailProof);

// Get offset of non-value-type elements relative to the input buffer
uint256 domainNameOffset = uint256(bytes32(input[0x00:]));
uint256 maskedCommandOffset = uint256(bytes32(input[0x60:]));
uint256 proofOffset = uint256(bytes32(input[0xe0:]));

// 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 < domainNameOffset ||
input.length - 0x20 < maskedCommandOffset ||
input.length - 0x20 < proofOffset
) return (false, emailProof);

// Get the lengths. offset + 32 is bounded by input.length so it does not overflow.
uint256 domainNameLength = uint256(bytes32(input[domainNameOffset:]));
uint256 maskedCommandLength = uint256(bytes32(input[maskedCommandOffset:]));
uint256 proofLength = uint256(bytes32(input[proofOffset:]));

// 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 - domainNameOffset - 0x20 < domainNameLength ||
input.length - maskedCommandOffset - 0x20 < maskedCommandLength ||
input.length - proofOffset - 0x20 < proofLength
) return (false, emailProof);

return (true, emailProof);
}

/// @dev Compares the command in the email proof with the expected command template.
function _commandMatch(
EmailAuthMsg memory emailAuthMsg,
EmailProof memory proof,
string[] memory template,
bytes[] memory templateParams,
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 templateParams.computeExpectedCommand(template, uint8(stringCase)).equal(proof.maskedCommand);

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);
templateParams.computeExpectedCommand(template, uint8(Case.LOWERCASE)).equal(proof.maskedCommand) ||
templateParams.computeExpectedCommand(template, uint8(Case.UPPERCASE)).equal(proof.maskedCommand) ||
templateParams.computeExpectedCommand(template, uint8(Case.CHECKSUM)).equal(proof.maskedCommand);
}

/**
* @dev Builds the expected public signals array for the Groth16 verifier from the given EmailAuthMsg.
* @dev Builds the expected public signals array for the Groth16 verifier from the given EmailProof.
*
* Packs the domain, public key hash, email nullifier, timestamp, masked command, account salt, and isCodeExist fields
* into a uint256 array in the order expected by the verifier circuit.
Expand Down Expand Up @@ -216,4 +253,21 @@ library ZKEmailUtils {
}
return fields;
}

/// @dev Checks if the field points are valid in the range of [0, Q).
function _isValidFieldPoint(
uint256[2] memory pA,
uint256[2][2] memory pB,
uint256[2] memory pC
) private pure returns (bool) {
return
pA[0] < Q &&
pA[1] < Q &&
pB[0][0] < Q &&
pB[0][1] < Q &&
pB[1][0] < Q &&
pB[1][1] < Q &&
pC[0] < Q &&
pC[1] < Q;
}
}
62 changes: 14 additions & 48 deletions contracts/utils/cryptography/signers/SignerZKEmail.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pragma solidity ^0.8.24;

import {IDKIMRegistry} from "@zk-email/contracts/DKIMRegistry.sol";
import {IGroth16Verifier} from "@zk-email/email-tx-builder/src/interfaces/IGroth16Verifier.sol";
import {EmailAuthMsg} from "@zk-email/email-tx-builder/src/interfaces/IEmailTypes.sol";
import {EmailProof} from "@zk-email/email-tx-builder/src/interfaces/IVerifier.sol";
import {AbstractSigner} from "@openzeppelin/contracts/utils/cryptography/signers/AbstractSigner.sol";
import {ZKEmailUtils} from "../ZKEmailUtils.sol";

Expand All @@ -23,7 +23,6 @@ import {ZKEmailUtils} from "../ZKEmailUtils.sol";
* * {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 Groth16Verifier contract for zero-knowledge proof validation.
* * {templateId} - The template ID of the sign hash command, defining the expected format.
*
* Example of usage:
*
Expand All @@ -32,29 +31,26 @@ import {ZKEmailUtils} from "../ZKEmailUtils.sol";
* function initialize(
* bytes32 accountSalt,
* IDKIMRegistry registry,
* IGroth16Verifier groth16Verifier,
* uint256 templateId
* IGroth16Verifier groth16Verifier
* ) public initializer {
* // Will revert if the signer is already initialized
* _setAccountSalt(accountSalt);
* _setDKIMRegistry(registry);
* _setGroth16Verifier(groth16Verifier);
* _setTemplateId(templateId);
* _setVerifier(groth16Verifier);
* }
* }
* ```
*
* IMPORTANT: Avoiding to call {_setAccountSalt}, {_setDKIMRegistry}, {_setVerifier} and {_setTemplateId}
* IMPORTANT: Failing to call {_setAccountSalt}, {_setDKIMRegistry}, and {_setVerifier}
* 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;
using ZKEmailUtils for EmailProof;

bytes32 private _accountSalt;
IDKIMRegistry private _registry;
IGroth16Verifier private _groth16Verifier;
uint256 private _templateId;

/// @dev Proof verification error.
error InvalidEmailProof(ZKEmailUtils.EmailProofError err);
Expand Down Expand Up @@ -94,11 +90,6 @@ abstract contract SignerZKEmail is AbstractSigner {
return _groth16Verifier;
}

/// @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_;
Expand All @@ -114,47 +105,22 @@ abstract contract SignerZKEmail is AbstractSigner {
_groth16Verifier = 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
* 1. Decoding the email proof from the signature
* 2. Validating the account salt matches
* 3. Verifying the email proof using ZKEmail utilities
*/
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);
(bool decodeSuccess, EmailProof calldata emailProof) = ZKEmailUtils.tryDecodeEmailProof(signature);

return
decodeSuccess &&
emailProof.accountSalt == accountSalt() &&
emailProof.isValidZKEmail(DKIMRegistry(), verifier(), hash) == ZKEmailUtils.EmailProofError.NoError;
}
}
Loading