Skip to content

Add SignerZKEmail and ZKEmailUtils#96

Merged
ernestognw merged 62 commits into
masterfrom
feature/zk-email
Apr 16, 2025
Merged

Add SignerZKEmail and ZKEmailUtils#96
ernestognw merged 62 commits into
masterfrom
feature/zk-email

Conversation

@ernestognw

@ernestognw ernestognw commented Mar 25, 2025

Copy link
Copy Markdown
Member

Context

OpenZeppelin will collaborate with ZKEmail https://github.com/zkemail to provide developers with an easy way to opt-in for this functionality for an account.

This PR includes an adaptation of their EmailSigner to be compatible with our AbstractSigner

@ernestognw
ernestognw requested a review from a team as a code owner March 25, 2025 19:15
@Amxx

Amxx commented Mar 26, 2025

Copy link
Copy Markdown
Collaborator

Is there tooling that we could use to have unit tests ?

@Amxx

Amxx commented Mar 27, 2025

Copy link
Copy Markdown
Collaborator

I noticed that this abstract signer is quite different from the other ones we already have:

  • this abstractSigner requires further inheritance to set things such as the accountSalt(), DKIMRegistry() and verifier(). The other implementation we have storage everything they need (and have setters).
  • this other abstractSigners are invisible from the outside, they only use private storage and internal functions. They don't expose anything publicly. This one exposes public functions.

This is not necessarily an issue, but its a pretty significant difference/inconsistency.

I'd personally remove the publics part of this abstract signer, and chose to selectively expose them in the contracts that inherit from this.

@ernestognw

Copy link
Copy Markdown
Member Author
  • this abstractSigner requires further inheritance to set things such as the accountSalt(), DKIMRegistry() and verifier(). The other implementation we have storage everything they need (and have setters).
  • this other abstractSigners are invisible from the outside, they only use private storage and internal functions. They don't expose anything publicly. This one exposes public functions.

Right, these are good observations. For the first point, I didn't implement those functions because I wasn't sure whether it's better to put them in storage or not (now I think it's fine in storage as long as developers can override, I'll update).

For the second, I think the public functions are equivalent to the public key in each signer. For example, all signers have a public signer() getter, so I would say these public functions are a replace for signer(). Perhaps if this ZKEmailSigner exposes a signer() getter returning the accountSalt, DKIMRegistry and verifier would feel more consistent?

@Amxx

Amxx commented Mar 28, 2025

Copy link
Copy Markdown
Collaborator

For the second, I think the public functions are equivalent to the public key in each signer. For example, all signers have a public signer() getter, so I would say these public functions are a replace for signer().

Right.

I'm wondering if we should create a library that implements

library ZKEmailUtils {
    function verifyEmail(
        bytes32 accountSalt,
        IDKIMRegistry registry,
        IVerifier verifier,
        uint256 commandTemplate,
        EmailAuthMsg memory emailAuthMsg
    ) internal view virtual returns (bool, EmailProofError) { ... }
}

(and maybe an override that takes a bytes calldata and decode it to a emailAuthMsg transparently

That can be used in the abstract signer, but also in a 7913 signature verifier. Basically the idea would be to separate the crypto logic from the "integration" logic.

Comment thread contracts/utils/cryptography/ZKEmailSigner.sol Outdated
Comment on lines +91 to +93
string[] memory signHashTemplate = new string[](2);
signHashTemplate[0] = "signHash";
signHashTemplate[1] = "{uint}";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can move that down so that we don't pay the associated cost if the different checks (that don't use it) fails

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not for gas optimisation but another idea is that these could be stored as constants. Makes this function a bit cleaner.

Originally, we were replicating some earlier logic here where the command templates were dynamic

@ernestognw ernestognw Apr 3, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It makes sense to use constants as part of the library. For the "{uint}" one, I'm using the constant from your CommandUtils library.

Perhaps we add an template input to the isValidZKEmail?

function isValidZKEmail(
    EmailAuthMsg memory emailAuthMsg,
    IDKIMRegistry dkimregistry,
    IVerifier verifier,
    string memory template
) internal view returns (bool, EmailProofError) {
    if (!dkimregistry.isDKIMPublicKeyHashValid(emailAuthMsg.proof.domainName, emailAuthMsg.proof.publicKeyHash)) {
        return (false, EmailProofError.DKIMPublicKeyHash);
    } else if (bytes(emailAuthMsg.proof.maskedCommand).length > verifier.commandBytes()) {
        return (false, EmailProofError.MaskedCommandLength);
    } else if (emailAuthMsg.skippedCommandPrefix >= verifier.commandBytes()) {
        return (false, EmailProofError.SkippedCommandPrefixSize);
    } else {
        // Construct an expectedCommand from template and the values of emailAuthMsg.commandParams.
        string memory trimmedMaskedCommand = CommandUtils.removePrefix(
            emailAuthMsg.proof.maskedCommand,
            emailAuthMsg.skippedCommandPrefix
        );
        for (uint256 stringCase = 0; stringCase < 2; stringCase++) {
            if (
                CommandUtils.computeExpectedCommand(emailAuthMsg.commandParams, template, stringCase).equal(
                    trimmedMaskedCommand
                )
            ) {
                if (verifier.verifyEmailProof(emailAuthMsg.proof)) return (true, EmailProofError.NoError);
                else return (false, EmailProofError.EmailProof);
            }
        }
        return (false, EmailProofError.Command);
    }
}

In this way the implementation can construct the template out of constants. One of them being signHash.

@JohnGuilding JohnGuilding left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Implementation looks good in regards to abidance with our logic. Some comments on your discussion:

I noticed that this abstract signer is quite different from the other ones we already have

Agree it makes sense to keep your signers consistent

Perhaps if this ZKEmailSigner exposes a signer() getter returning the accountSalt, DKIMRegistry and verifier would feel more consistent?

Nit: signer() feels like the wrong naming here for returning these values

the public functions are equivalent to the public key in each signer.

Yeah there are equivalencies here to your signers, and then there also ZK Email-specific abstractions.

The “public keys” here would be the public keys stored in the DKIM registry, and also the accountSalt. The account salt is an implementation detail of ZK Email in order to provide onchain privacy. More info here. We use the concept of an accountSalt in the EmailAuth circuits and ZK JWT circuits. Context: The EmailAuth circuits are the circuits we use to authenticate onchain actions with email

The verifier is kinda more analogous to the P256 and RSA libs you have. The commandTemplate is an implementation detail of the specific circuits that this signer would use - the EmailAuth circuits. More detail here.

I'm wondering if we should create a library that implements...

Open to the idea of a library. The thinking would then be that we would still store reference to the registry, verifier, salt etc in the signer @Amxx? Or something different

Comment on lines +91 to +93
string[] memory signHashTemplate = new string[](2);
signHashTemplate[0] = "signHash";
signHashTemplate[1] = "{uint}";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not for gas optimisation but another idea is that these could be stored as constants. Makes this function a bit cleaner.

Originally, we were replicating some earlier logic here where the command templates were dynamic

@JohnGuilding

Copy link
Copy Markdown

@ernestognw merged a fix for the unused import. It's in main now

@Amxx

Amxx commented Apr 2, 2025

Copy link
Copy Markdown
Collaborator

Open to the idea of a library. The thinking would then be that we would still store reference to the registry, verifier, salt etc in the signer @Amxx? Or something different

It would be something like:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {IDKIMRegistry} from "@zk-email/contracts/DKIMRegistry.sol";
import {IVerifier, EmailProof} 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";
import {AbstractSigner} from "./AbstractSigner.sol";

library ZKEmailUtils {
    using Strings for string;

    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
        Command, // The command format is invalid
        EmailProof // The email proof verification fails
    }

    function isValidZKEmail(
        EmailAuthMsg memory emailAuthMsg,
        IDKIMRegistry dkimregistry,
        IVerifier verifier
    ) internal view returns (bool, EmailProofError) {
        if (!dkimregistry.isDKIMPublicKeyHashValid(emailAuthMsg.proof.domainName, emailAuthMsg.proof.publicKeyHash)) {
            return (false, EmailProofError.DKIMPublicKeyHash);
        } else if (bytes(emailAuthMsg.proof.maskedCommand).length > verifier.commandBytes()) {
            return (false, EmailProofError.MaskedCommandLength);
        } else if (emailAuthMsg.skippedCommandPrefix >= verifier.commandBytes()) {
            return (false, EmailProofError.SkippedCommandPrefixSize);
        } else {
            string[] memory signHashTemplate = new string[](2);
            signHashTemplate[0] = "signHash";
            signHashTemplate[1] = "{uint}";
            
            // Construct an expectedCommand from template and the values of emailAuthMsg.commandParams.
            string memory expectedCommand = "";
            string memory trimmedMaskedCommand = CommandUtils.removePrefix(
                emailAuthMsg.proof.maskedCommand,
                emailAuthMsg.skippedCommandPrefix
            );
            for (uint256 stringCase = 0; stringCase < 2; ++stringCase) {
                if (
                    CommandUtils.computeExpectedCommand(
                        emailAuthMsg.commandParams,
                        signHashTemplate,
                        stringCase
                    ).equal(expectedCommand, trimmedMaskedCommand)
                ) {
                    if (verifier.verifyEmailProof(emailAuthMsg.proof)) 
                        return (true, EmailProofError.NoError);
                    else 
                        return (false, EmailProofError.EmailProof);
                }
            }
            return (false, EmailProofError.Command);
        }
    }
}

contract ZKEmailSigner is AbstractSigner {
    using ZKEmailUtils for *;

    // Need setters for these
    bytes32 private _accountSalt;
    IDKIMRegistry private _registry;
    IVerifier private _verifier;
    uint256 private _commandTemplate;


    function accountSalt() public view virtual returns (bytes32) { return _accountSalt; }
    function DKIMRegistry() public view virtual returns (IDKIMRegistry) { return _registry; }
    function verifier() public view virtual returns (IVerifier) { return _verifier; }
    function commandTemplate() public view virtual returns (uint256) { return _commandTemplate; }

    function _setAccountSalt(bytes32 accountSalt_) internal virtual { _accountSalt = accountSalt_; }
    function _setDKIMRegistry(IDKIMRegistry registry_) internal virtual { _registry = registry_; }
    function _setVerifier(IVerifier verifier_) internal virtual { _verifier = verifier_; }
    function _setCommandTemplate(uint256 commandTemplate_) internal virtual { _commandTemplate = commandTemplate_; }

    /// @inheritdoc AbstractSigner
    function _rawSignatureValidation(
        bytes32 hash,
        bytes calldata signature
    ) internal view virtual override returns (bool) {
        EmailAuthMsg memory emailAuthMsg = abi.decode(signature, (EmailAuthMsg));
        if (
            emailAuthMsg.commandParams[0] == hash &&
            emailAuthMsg.templateId == commandTemplate() &&
            emailAuthMsg.proof.accountSalt == accountSalt()
        ) {
            (bool verified, ) = emailAuthMsg.isValidZKEmail(DKIMRegistry(), verifier());
            return verified;
        } else {
            return false;
        }
    }
}

@JohnGuilding

JohnGuilding commented Apr 3, 2025

Copy link
Copy Markdown

It would be something like:...

Yeah I'm a fan of the approach outlined.

This signer would still differ from your existing signers, as it would not contain the exact functions _setSigner or signer(). Is this ok for you or would you want to change something else here?

The variables are quite different to what is returned from signer() in the other signers, so it would feel like inconsistent naming to return the 4 zkemail-specific variables from a function called signer().

Comment thread package.json
Comment on lines -20 to -23
"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",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

According to prettier --help, the default value already is [.gitignore, .prettierignore]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we should also change that on the vanila repo

@ernestognw

ernestognw commented Apr 3, 2025

Copy link
Copy Markdown
Member Author

(and maybe an override that takes a bytes calldata and decode it to a emailAuthMsg transparently

The issue with this is that a library can't have virtual functions. So there's no way to override it.

That can be used in the abstract signer, but also in a 7913 signature verifier. Basically the idea would be to separate the crypto logic from the "integration" logic.

Yes, I agree that a library would be the best way to abstract the logic. I updated the PR accordingly.

The “public keys” here would be the public keys stored in the DKIM registry, and also the accountSalt. The account salt is an implementation detail of ZK Email in order to provide onchain privacy. More info here.

Added this as context to the current accountSalt() function NatSpec. I think it's important so that developers know how to override it if they want.

The verifier is kinda more analogous to the P256 and RSA libs you have. The commandTemplate is an implementation detail of the specific circuits that this signer would use - the EmailAuth circuits. More detail here.

Right. This is way @Amxx brought ERC-7913 (WIP) to the conversation. We think there's a broader use case for deployed key verifiers, so for example, they can be used in ERC-7579 modules or perhaps make a generic 7913 base contract that inherits AbstractSigner too (see #98)

I guess @Amxx point is that the ZKEmailUtils.isValidZKEmail may receive an IERC7913 instance instead of an IVerifier one, as in

function isValidZKEmail(
    EmailAuthMsg memory emailAuthMsg,
    IDKIMRegistry dkimregistry,
    IERC7913 verifier
) internal view virtual returns (bool, EmailProofError)

This signer would still differ from your existing signers, as it would not contain the exact functions _setSigner or signer(). Is this ok for you or would you want to change something else here?

This is a good point. I wouldn't mind that this signer doesn't expose the signer function since the function is not specified by any standard, so the AbstractSigner is mostly an internal interface that's enforced by the compiler (i.e. it requires an implementation to compile).

You seem convinced that we should keep the signers consistency in this regard, curious to hear your thoughts.

@Amxx

Amxx commented Apr 3, 2025

Copy link
Copy Markdown
Collaborator

I guess @Amxx point is that the ZKEmailUtils.isValidZKEmail may receive an IERC7913 instance instead of an IVerifier one

That was not what I had in mind

Comment thread contracts/utils/cryptography/ZKEmailSigner.sol Outdated
@Amxx

Amxx commented Apr 3, 2025

Copy link
Copy Markdown
Collaborator

Is there tooling that we could use to have unit tests ?

Any news on that part. Code looks quite good.

@Amxx Amxx closed this Apr 3, 2025
@Amxx Amxx reopened this Apr 3, 2025
@JohnGuilding

Copy link
Copy Markdown

Any news on that part. Code looks quite good.

You can reference these for unit testing. Although they are forge tests not hardhat tests

Comment thread contracts/utils/cryptography/ZKEmailSigner.sol Outdated

@benceharomi benceharomi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@ernestognw the code looks great! added some comments to the unused imports I found

Comment thread contracts/utils/cryptography/SignerZKEmail.sol Outdated
Comment thread contracts/utils/cryptography/ZKEmailUtils.sol Outdated
Comment thread contracts/utils/cryptography/ZKEmailUtils.sol Outdated
} else if (!_commandMatch(emailAuthMsg, template, stringCase)) {
return EmailProofError.MismatchedCommand;
} else {
return verifier.verifyEmailProof(emailAuthMsg.proof) ? EmailProofError.NoError : EmailProofError.EmailProof;

@benceharomi benceharomi Apr 10, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@JohnGuilding do we care about the order of checks here? If we don't wouldn't it be cheaper to go in this order?

// 1. SkippedCommandPrefixSize
// 2. MaskedCommandLength
// 3. MismatchedCommand
// 4. DKIMPublicKeyHash

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't think the order matters here and changing it doesn't effect the readability imo. And yeah it would be cheaper to fail fast with the less expensive checks. If this was our code, would make this change. Not sure what the OZ view is on optimisations like this? @ernestognw

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think we can make this change. Generally, we don't worry about the cost of the failure case, especially because we don't know what branch is most likely to fail. For example, if the error distribution is 80% DKIMPublicKeyHash, we should prioritize that one first.

I'm updating the code since I don't think the order is a big issue.

@ernestognw

Copy link
Copy Markdown
Member Author

@ernestognw the code looks great! added some comments to the unused imports I found

Thanks for your feedback @benceharomi, I just updated removing the unused imports 😄

@ernestognw

ernestognw commented Apr 11, 2025

Copy link
Copy Markdown
Member Author

All good on our side, gentlemen. The only thing we may be missing is just a real email proof that we can use for testing, currently everything is mocked.

Captura de pantalla 2025-04-10 a la(s) 10 08 39 p m

Also, I noticed it may not be possible not ensure the signer doesn't revert as we want for the ERC-4337 validation phase. The issue is that we're always doing an abi.decode() over the signature in _rawValidateSignature, so reverting is as easy as just passing a wrong value. I'm adding a minimum length check that helps some tests pass but the only alternative might be directly calldata decoding, which is not an easy task and may require heavy use of assembly.

@ernestognw

Copy link
Copy Markdown
Member Author

Merging, thanks everyone for their feedback ❤️

@ernestognw
ernestognw merged commit e46e538 into master Apr 16, 2025
@ernestognw ernestognw mentioned this pull request Jul 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants