diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 817f3da2..875b812f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,5 +18,6 @@ ## ZKEmail /contracts/utils/cryptography/ZKEmailUtils.sol @zkfriendly @benceharomi +/contracts/utils/cryptography/ZKJWTUtils.sol @zkfriendly @benceharomi /contracts/utils/cryptography/signers/SignerZKEmail.sol @zkfriendly @benceharomi /contracts/utils/cryptography/verifiers/ERC7913ZKEmailVerifier.sol @zkfriendly @benceharomi diff --git a/.gitmodules b/.gitmodules index fc3a658b..ad957057 100644 --- a/.gitmodules +++ b/.gitmodules @@ -20,3 +20,6 @@ path = lib/zk-email-verify branch = v6.3.2 url = https://github.com/zkemail/zk-email-verify +[submodule "lib/zk-jwt"] + path = lib/zk-jwt + url = https://github.com/zkemail/zk-jwt diff --git a/contracts/mocks/import.sol b/contracts/mocks/import.sol index 7c5047ed..735b905d 100644 --- a/contracts/mocks/import.sol +++ b/contracts/mocks/import.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.20; import {ECDSAOwnedDKIMRegistry} from "@zk-email/email-tx-builder/src/utils/ECDSAOwnedDKIMRegistry.sol"; +import {JwtRegistry} from "@zk-email/zk-jwt/src/utils/JwtRegistry.sol"; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; diff --git a/contracts/mocks/utils/cryptography/ZKJWTVerifierMock.sol b/contracts/mocks/utils/cryptography/ZKJWTVerifierMock.sol new file mode 100644 index 00000000..5f865cbe --- /dev/null +++ b/contracts/mocks/utils/cryptography/ZKJWTVerifierMock.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IVerifier, EmailProof} from "@zk-email/zk-jwt/src/interfaces/IVerifier.sol"; + +contract ZKJWTVerifierMock is IVerifier { + function getCommandBytes() external pure returns (uint256) { + // Same as in https://github.com/zkemail/zk-jwt/blob/27436a2f23e78e89cf624f649ec1d125f13772dd/packages/contracts/src/utils/JwtVerifier.sol#L20 + 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/cryptography/ZKJWTUtils.sol b/contracts/utils/cryptography/ZKJWTUtils.sol new file mode 100644 index 00000000..020ab550 --- /dev/null +++ b/contracts/utils/cryptography/ZKJWTUtils.sol @@ -0,0 +1,142 @@ +// 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, EmailProof} from "@zk-email/zk-jwt/src/interfaces/IVerifier.sol"; +import {CommandUtils} from "@zk-email/email-tx-builder/src/libraries/CommandUtils.sol"; + +/** + * @dev Library for https://docs.zk.email[ZK JWT] validation utilities. + * + * ZK JWT is a protocol that enables JWT-based authentication and authorization for smart contracts + * using zero-knowledge proofs. It allows users to prove ownership of a JWT token without revealing + * the token content or private keys. See https://datatracker.ietf.org/doc/html/rfc7519[RFC-7519] for + * details on the JWT verification process. + * + * The validation process involves several key components: + * + * * A https://docs.zk.email/jwt-tx-builder/architecture[JWT Registry] verification mechanism to ensure + * the JWT was issued from a valid issuer with valid public key. The registry validates the `kid|iss|azp` format + * used in JWT verification (i.e. key id, issuer, and authorized party). + * * A https://docs.zk.email/email-tx-builder/architecture/command-templates[command template] validation + * mechanism to ensure the JWT command matches the expected format and parameters. + * * A https://docs.zk.email/jwt-tx-builder/architecture[zero-knowledge proof] verification mechanism to ensure + * the JWT was actually issued and received without revealing its contents through RSA signature validation + * and selective claim disclosure. + * + * NOTE: This library adapts the email authentication infrastructure for JWT verification, + * reusing the EmailProof structure which contains JWT-specific data encoded in the domainName field + * as "kid|iss|azp" format. + */ +library ZKJWTUtils { + using CommandUtils for bytes[]; + using Bytes for bytes; + using Strings for string; + + /// @dev Enumeration of possible JWT proof validation errors. + /// See https://docs.zk.email/jwt-tx-builder/architecture[ZK JWT Architecture] for validation flow details. + enum JWTProofError { + NoError, + JWTPublicKeyHash, // The JWT public key hash verification fails + MaskedCommandLength, // The masked command length exceeds the maximum allowed by the circuit + MismatchedCommand, // The command does not match the proof command + JWTProof // The JWT 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 Validates a ZK JWT proof with default "signHash" command template. + function isValidZKJWT( + EmailProof memory jwtProof, + IDKIMRegistry jwtRegistry, + IVerifier verifier, + bytes32 hash + ) internal view returns (JWTProofError) { + string[] memory signHashTemplate = new string[](1); + signHashTemplate[0] = CommandUtils.UINT_MATCHER; + bytes[] memory signHashParams = new bytes[](1); + signHashParams[0] = abi.encode(hash); + return isValidZKJWT(jwtProof, jwtRegistry, verifier, signHashTemplate, signHashParams, Case.LOWERCASE); // UINT_MATCHER is always lowercase + } + + /** + * @dev Validates a ZK JWT proof against a command template. + * + * This function takes a JWT proof, a JWT registry contract, and a verifier contract + * as inputs. It performs several validation checks and returns a {JWTProofError} indicating the result. + * Returns {JWTProofError-NoError} if all validations pass, or a specific {JWTProofError} indicating + * which validation check failed. + * + * NOTE: Attempts to validate the command for all possible string {Case} values. + */ + function isValidZKJWT( + EmailProof memory jwtProof, + IDKIMRegistry jwtRegistry, + IVerifier verifier, + string[] memory template, + bytes[] memory templateParams + ) internal view returns (JWTProofError) { + return isValidZKJWT(jwtProof, jwtRegistry, verifier, template, templateParams, Case.ANY); + } + + /** + * @dev Validates a ZK JWT proof against 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 isValidZKJWT( + EmailProof memory jwtProof, + IDKIMRegistry jwtRegistry, + IVerifier verifier, + string[] memory template, + bytes[] memory templateParams, + Case stringCase + ) internal view returns (JWTProofError) { + if (bytes(jwtProof.maskedCommand).length > verifier.getCommandBytes()) { + return JWTProofError.MaskedCommandLength; + } else if (!_commandMatch(jwtProof, template, templateParams, stringCase)) { + return JWTProofError.MismatchedCommand; + } else if (!jwtRegistry.isDKIMPublicKeyHashValid(jwtProof.domainName, jwtProof.publicKeyHash)) { + // Validate JWT public key and authorized party through registry + // The domainName contains "kid|iss|azp" format for JWT validation + return JWTProofError.JWTPublicKeyHash; + } + + // TODO: Can we remove the try catch? Or add it to ZKEmailUtils.sol? + // Verify the zero-knowledge proof of JWT signature + try verifier.verifyEmailProof(jwtProof) returns (bool isValid) { + return isValid ? JWTProofError.NoError : JWTProofError.JWTProof; + } catch { + return JWTProofError.JWTProof; + } + } + + /// @dev Compares the command in the JWT proof with the expected command template. + function _commandMatch( + EmailProof memory jwtProof, + string[] memory template, + bytes[] memory templateParams, + Case stringCase + ) private pure returns (bool) { + // Convert template to expected command format + uint256 commandPrefixLength = bytes(jwtProof.maskedCommand).indexOf(bytes1(" ")); + string memory command = string(bytes(jwtProof.maskedCommand).slice(commandPrefixLength + 1)); + + if (stringCase != Case.ANY) + return templateParams.computeExpectedCommand(template, uint8(stringCase)).equal(command); + return + templateParams.computeExpectedCommand(template, uint8(Case.LOWERCASE)).equal(command) || + templateParams.computeExpectedCommand(template, uint8(Case.UPPERCASE)).equal(command) || + templateParams.computeExpectedCommand(template, uint8(Case.CHECKSUM)).equal(command); + } +} diff --git a/lib/zk-jwt b/lib/zk-jwt new file mode 160000 index 00000000..684f08c4 --- /dev/null +++ b/lib/zk-jwt @@ -0,0 +1 @@ +Subproject commit 684f08c46e448e6cc23412545fae0a33b4a4296c diff --git a/package-lock.json b/package-lock.json index 6ed56c1b..725c27d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9878,7 +9878,6 @@ "resolved": "https://registry.npmjs.org/solhint/-/solhint-6.0.0.tgz", "integrity": "sha512-PQGfwFqfeYdebi2tEG1fhVfMjqSzbW3Noz+LYf8UusKe5nkikCghdgEjYQPcGfFZj4snlVyJQt//AaxkubOtVQ==", "dev": true, - "license": "MIT", "dependencies": { "@solidity-parser/parser": "^0.20.0", "ajv": "^6.12.6", diff --git a/remappings.txt b/remappings.txt index 18badd3f..2c0e4337 100644 --- a/remappings.txt +++ b/remappings.txt @@ -3,4 +3,5 @@ @openzeppelin/community-contracts/=contracts/ @axelar-network/axelar-gmp-sdk-solidity/=lib/axelar-gmp-sdk-solidity/ @zk-email/email-tx-builder/=lib/email-tx-builder/packages/contracts/ +@zk-email/zk-jwt/=lib/zk-jwt/packages/contracts/ @zk-email/contracts/=lib/zk-email-verify/packages/contracts/ diff --git a/test/helpers/enums.js b/test/helpers/enums.js index 9f02ca8c..ee2cb6c2 100644 --- a/test/helpers/enums.js +++ b/test/helpers/enums.js @@ -11,6 +11,7 @@ module.exports = { 'InvalidFieldPoint', 'EmailProof', ), + JWTProofError: enums.Enum('NoError', 'JWTPublicKeyHash', 'MaskedCommandLength', 'MismatchedCommand', 'JWTProof'), Case: enums.EnumTyped('CHECKSUM', 'LOWERCASE', 'UPPERCASE', 'ANY'), OperationState: enums.Enum('Unknown', 'Scheduled', 'Ready', 'Expired', 'Executed', 'Canceled'), }; diff --git a/test/utils/cryptography/ZKEmailUtils.t.sol b/test/utils/cryptography/ZKEmailUtils.t.sol index 9d2b0a2f..47cea502 100644 --- a/test/utils/cryptography/ZKEmailUtils.t.sol +++ b/test/utils/cryptography/ZKEmailUtils.t.sol @@ -133,11 +133,7 @@ contract ZKEmailUtilsTest is Test { _mockVerifyEmailProof(); // Test validation - ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( - emailAuthMsg, - IDKIMRegistry(_dkimRegistry), - _verifier - ); + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail(emailAuthMsg, _dkimRegistry, _verifier); assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.NoError)); } @@ -180,7 +176,7 @@ contract ZKEmailUtilsTest is Test { ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( emailAuthMsg, - IDKIMRegistry(_dkimRegistry), + _dkimRegistry, _verifier, template ); @@ -206,7 +202,7 @@ contract ZKEmailUtilsTest is Test { commandParams[0] = abi.encode(addr); // Test with different cases - for (uint256 i = 0; i < uint8(type(ZKEmailUtils.Case).max) - 1; i++) { + for (uint256 i = 0; i < uint8(type(ZKEmailUtils.Case).max); i++) { EmailAuthMsg memory emailAuthMsg = _buildEmailAuthMsgMock( string.concat(commandPrefix, " ", CommandUtils.addressToHexString(addr, i)), commandParams, @@ -228,7 +224,7 @@ contract ZKEmailUtilsTest is Test { ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( emailAuthMsg, - IDKIMRegistry(_dkimRegistry), + _dkimRegistry, _verifier, template, ZKEmailUtils.Case(i) @@ -297,11 +293,7 @@ contract ZKEmailUtilsTest is Test { emailAuthMsg.proof.domainName = domainName; emailAuthMsg.proof.publicKeyHash = publicKeyHash; - ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( - emailAuthMsg, - IDKIMRegistry(_dkimRegistry), - _verifier - ); + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail(emailAuthMsg, _dkimRegistry, _verifier); assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.DKIMPublicKeyHash)); } @@ -314,11 +306,7 @@ contract ZKEmailUtilsTest is Test { EmailAuthMsg memory emailAuthMsg = _buildEmailAuthMsgMock(string(new bytes(length)), commandParams, 0); - ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( - emailAuthMsg, - IDKIMRegistry(_dkimRegistry), - _verifier - ); + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail(emailAuthMsg, _dkimRegistry, _verifier); assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.MaskedCommandLength)); } @@ -336,11 +324,7 @@ contract ZKEmailUtilsTest is Test { skippedPrefix ); - ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( - emailAuthMsg, - IDKIMRegistry(_dkimRegistry), - _verifier - ); + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail(emailAuthMsg, _dkimRegistry, _verifier); assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.SkippedCommandPrefixSize)); } @@ -351,11 +335,7 @@ contract ZKEmailUtilsTest is Test { EmailAuthMsg memory emailAuthMsg = _buildEmailAuthMsgMock(invalidCommand, commandParams, 0); - ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( - emailAuthMsg, - IDKIMRegistry(_dkimRegistry), - _verifier - ); + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail(emailAuthMsg, _dkimRegistry, _verifier); assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.MismatchedCommand)); } @@ -379,11 +359,7 @@ contract ZKEmailUtilsTest is Test { emailAuthMsg.proof.proof = abi.encode(pA, pB, pC); - ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail( - emailAuthMsg, - IDKIMRegistry(_dkimRegistry), - _verifier - ); + ZKEmailUtils.EmailProofError err = ZKEmailUtils.isValidZKEmail(emailAuthMsg, _dkimRegistry, _verifier); assertEq(uint256(err), uint256(ZKEmailUtils.EmailProofError.EmailProof)); } diff --git a/test/utils/cryptography/ZKJWTUtils.t.sol b/test/utils/cryptography/ZKJWTUtils.t.sol new file mode 100644 index 00000000..841ca288 --- /dev/null +++ b/test/utils/cryptography/ZKJWTUtils.t.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {ZKJWTUtils} from "../../../contracts/utils/cryptography/ZKJWTUtils.sol"; +import {JwtRegistry} from "@zk-email/zk-jwt/src/utils/JwtRegistry.sol"; +import {JwtGroth16Verifier} from "@zk-email/zk-jwt/src/utils/JwtGroth16Verifier.sol"; +import {JwtVerifier} from "@zk-email/zk-jwt/src/utils/JwtVerifier.sol"; +import {IVerifier, EmailProof} from "@zk-email/zk-jwt/src/interfaces/IVerifier.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {CommandUtils} from "@zk-email/email-tx-builder/src/libraries/CommandUtils.sol"; + +contract ZKJWTUtilsTest is Test { + using Strings for *; + + // Base field size + uint256 constant Q = 21888242871839275222246405745257275088696311157297823662689037894645226208583; + + JwtRegistry private _jwtRegistry; + IVerifier private _verifier; + bytes32 private _accountSalt; + + string private _kid = "123456"; + string private _iss = "https://example.com"; + string private _azp = "client-id-123456"; + string private _domainName = "123456|https://example.com|client-id-123456"; // kid|iss|azp format + bytes32 private _publicKeyHash = 0x0ea9c777dc7110e5a9e89b13f0cfc540e3845ba120b2b6dc24024d61488d4788; + bytes32 private _emailNullifier = 0x00a83fce3d4b1c9ef0f600644c1ecc6c8115b57b1596e0e3295e2c5105fbfd8a; + bytes private _mockProof; + + string private constant SIGN_HASH_COMMAND = "signHash "; + + function setUp() public { + // Deploy JWT Registry + _jwtRegistry = _createJwtRegistry(); + + // Deploy Verifier + _verifier = _createVerifier(); + + // Generate test data + _accountSalt = keccak256("test@example.com"); + _mockProof = abi.encodePacked(bytes1(0x01)); + } + + function testIsValidZKJWTSignHash( + bytes32 hash, + uint256 timestamp, + bytes32 emailNullifier, + bytes32 accountSalt, + bool isCodeExist, + bytes memory proof + ) public { + // Build JWT proof with fuzzed parameters + EmailProof memory jwtProof = _buildJWTProofMock(string.concat(SIGN_HASH_COMMAND, uint256(hash).toString())); + + // Override with fuzzed values + jwtProof.timestamp = timestamp; + jwtProof.emailNullifier = emailNullifier; + jwtProof.accountSalt = accountSalt; + jwtProof.isCodeExist = isCodeExist; + jwtProof.proof = proof; + + _mockVerifyEmailProof(jwtProof); + + // Test default signHash validation + ZKJWTUtils.JWTProofError err = ZKJWTUtils.isValidZKJWT(jwtProof, _jwtRegistry, _verifier, hash); + + assertEq(uint256(err), uint256(ZKJWTUtils.JWTProofError.NoError)); + } + + function testIsValidZKJWTWithTemplate( + bytes32 hash, + uint256 timestamp, + bytes32 emailNullifier, + bytes32 accountSalt, + bool isCodeExist, + bytes memory proof + ) public { + // Use a simple, predictable command prefix + string memory commandPrefix = "testCmd"; + + string[] memory template = new string[](1); + template[0] = CommandUtils.UINT_MATCHER; + + bytes[] memory templateParams = new bytes[](1); + templateParams[0] = abi.encode(hash); + + EmailProof memory jwtProof = _buildJWTProofMock(string.concat(commandPrefix, " ", uint256(hash).toString())); + + // Override with fuzzed values + jwtProof.timestamp = timestamp; + jwtProof.emailNullifier = emailNullifier; + jwtProof.accountSalt = accountSalt; + jwtProof.isCodeExist = isCodeExist; + jwtProof.proof = proof; + + _mockVerifyEmailProof(jwtProof); + + ZKJWTUtils.JWTProofError err = ZKJWTUtils.isValidZKJWT( + jwtProof, + _jwtRegistry, + _verifier, + template, + templateParams + ); + + assertEq(uint256(err), uint256(ZKJWTUtils.JWTProofError.NoError)); + } + + function testCommandMatchWithDifferentCases( + address addr, + uint256 timestamp, + bytes32 emailNullifier, + bytes32 accountSalt, + bool isCodeExist, + bytes memory proof + ) public { + string memory commandPrefix = "authorize"; + + string[] memory template = new string[](1); + template[0] = CommandUtils.ETH_ADDR_MATCHER; + + bytes[] memory templateParams = new bytes[](1); + templateParams[0] = abi.encode(addr); + + // Test with different cases + for (uint256 i = 0; i < uint8(type(ZKJWTUtils.Case).max); i++) { + EmailProof memory jwtProof = _buildJWTProofMock( + string.concat(commandPrefix, " ", CommandUtils.addressToHexString(addr, i)) + ); + + // Override with fuzzed values + jwtProof.timestamp = timestamp; + jwtProof.emailNullifier = emailNullifier; + jwtProof.accountSalt = accountSalt; + jwtProof.isCodeExist = isCodeExist; + jwtProof.proof = proof; + + _mockVerifyEmailProof(jwtProof); + + ZKJWTUtils.JWTProofError err = ZKJWTUtils.isValidZKJWT( + jwtProof, + _jwtRegistry, + _verifier, + template, + templateParams, + ZKJWTUtils.Case(i) + ); + assertEq(uint256(err), uint256(ZKJWTUtils.JWTProofError.NoError)); + } + } + + function testInvalidJWTPublicKeyHash(bytes32 hash, bytes32 publicKeyHash) public view { + // Ensure we use a different public key hash than the registered one + vm.assume(publicKeyHash != _publicKeyHash); + + EmailProof memory jwtProof = _buildJWTProofMock(string.concat(SIGN_HASH_COMMAND, uint256(hash).toString())); + jwtProof.publicKeyHash = publicKeyHash; + + ZKJWTUtils.JWTProofError err = ZKJWTUtils.isValidZKJWT(jwtProof, _jwtRegistry, _verifier, hash); + + assertEq(uint256(err), uint256(ZKJWTUtils.JWTProofError.JWTPublicKeyHash)); + } + + function testInvalidMaskedCommandLength(bytes32 hash, uint256 length) public view { + length = bound(length, 606, 1000); // Assuming commandBytes is 605 + + EmailProof memory jwtProof = _buildJWTProofMock(string(new bytes(length))); + + ZKJWTUtils.JWTProofError err = ZKJWTUtils.isValidZKJWT(jwtProof, _jwtRegistry, _verifier, hash); + + assertEq(uint256(err), uint256(ZKJWTUtils.JWTProofError.MaskedCommandLength)); + } + + function testMismatchedCommand(bytes32 hash) public view { + string memory invalidCommand = string(abi.encodePacked("invalidJWTCommand ", hash)); + + EmailProof memory jwtProof = _buildJWTProofMock(invalidCommand); + + ZKJWTUtils.JWTProofError err = ZKJWTUtils.isValidZKJWT(jwtProof, _jwtRegistry, _verifier, hash); + + assertEq(uint256(err), uint256(ZKJWTUtils.JWTProofError.MismatchedCommand)); + } + + function testMismatchedCommandWithTemplate(bytes32 hash) public view { + string[] memory template = new string[](1); + template[0] = CommandUtils.UINT_MATCHER; + + bytes[] memory templateParams = new bytes[](1); + templateParams[0] = abi.encode(uint256(123456)); // Different value than what's in command + + EmailProof memory jwtProof = _buildJWTProofMock(string(abi.encodePacked("testCmd", " ", hash))); // Different from templateParams + + ZKJWTUtils.JWTProofError err = ZKJWTUtils.isValidZKJWT( + jwtProof, + _jwtRegistry, + _verifier, + template, + templateParams + ); + + assertEq(uint256(err), uint256(ZKJWTUtils.JWTProofError.MismatchedCommand)); + } + + function testInvalidJWTProof( + bytes32 hash, + uint256[2] memory pA, + uint256[2][2] memory pB, + uint256[2] memory pC + ) public view { + 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); + + EmailProof memory jwtProof = _buildJWTProofMock(string.concat(SIGN_HASH_COMMAND, uint256(hash).toString())); + + jwtProof.proof = abi.encode(pA, pB, pC); + + ZKJWTUtils.JWTProofError err = ZKJWTUtils.isValidZKJWT(jwtProof, _jwtRegistry, _verifier, hash); + + assertEq(uint256(err), uint256(ZKJWTUtils.JWTProofError.JWTProof)); + } + + function testComplexJWTCommand( + uint256 amount, + address recipient, + uint256 timestamp, + bytes32 emailNullifier, + bytes32 accountSalt, + bool isCodeExist, + bytes memory proof + ) public { + string[] memory template = new string[](4); + template[0] = CommandUtils.UINT_MATCHER; + template[1] = "ETH"; + template[2] = "to"; + template[3] = CommandUtils.ETH_ADDR_MATCHER; + + bytes[] memory templateParams = new bytes[](2); + templateParams[0] = abi.encode(amount); + templateParams[1] = abi.encode(recipient); + + EmailProof memory jwtProof = _buildJWTProofMock( + string.concat("Send ", amount.toString(), " ETH to ", recipient.toHexString()) + ); + + // Override with fuzzed values + jwtProof.timestamp = timestamp; + jwtProof.emailNullifier = emailNullifier; + jwtProof.accountSalt = accountSalt; + jwtProof.isCodeExist = isCodeExist; + jwtProof.proof = proof; + + _mockVerifyEmailProof(jwtProof); + + ZKJWTUtils.JWTProofError err = ZKJWTUtils.isValidZKJWT( + jwtProof, + _jwtRegistry, + _verifier, + template, + templateParams + ); + + assertEq(uint256(err), uint256(ZKJWTUtils.JWTProofError.NoError)); + } + + function _createVerifier() private returns (IVerifier) { + JwtVerifier verifier = new JwtVerifier(); + JwtGroth16Verifier groth16Verifier = new JwtGroth16Verifier(); + verifier.initialize(msg.sender, address(groth16Verifier)); + return verifier; + } + + function _createJwtRegistry() private returns (JwtRegistry) { + JwtRegistry jwtRegistry = new JwtRegistry(address(this)); + jwtRegistry.setJwtPublicKey(_domainName, _publicKeyHash); + return jwtRegistry; + } + + function _mockVerifyEmailProof(EmailProof memory jwtProof) private { + vm.mockCall(address(_verifier), abi.encodeCall(IVerifier.verifyEmailProof, (jwtProof)), abi.encode(true)); + } + + function _buildJWTProofMock(string memory command) private view returns (EmailProof memory jwtProof) { + jwtProof = EmailProof({ + domainName: _domainName, + publicKeyHash: _publicKeyHash, + timestamp: block.timestamp, + maskedCommand: command, + emailNullifier: _emailNullifier, + accountSalt: _accountSalt, + isCodeExist: true, + proof: _mockProof + }); + } +} diff --git a/test/utils/cryptography/ZKJWTUtils.test.js b/test/utils/cryptography/ZKJWTUtils.test.js new file mode 100644 index 00000000..6e9bb2ad --- /dev/null +++ b/test/utils/cryptography/ZKJWTUtils.test.js @@ -0,0 +1,310 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { JWTProofError, Case } = require('../../helpers/enums'); + +const accountSalt = '0x046582bce36cdd0a8953b9d40b8f20d58302bacf3bcecffeb6741c98a52725e2'; // keccak256("test@example.com") + +// JWT-specific test data - using kid|iss|azp format (from actual zk-jwt tests) +const kid = '12345'; +const iss = 'https://example.com'; +const azp = 'client-id-12345'; +const domainName = `${kid}|${iss}|${azp}`; // JWT format: kid|iss|azp +const publicKeyHash = '0x0ea9c777dc7110e5a9e89b13f0cfc540e3845ba120b2b6dc24024d61488d4788'; +const emailNullifier = '0x00a83fce3d4b1c9ef0f600644c1ecc6c8115b57b1596e0e3295e2c5105fbfd8a'; + +const SIGN_HASH_COMMAND = 'signHash'; +const UINT_MATCHER = '{uint}'; +const ETH_ADDR_MATCHER = '{ethAddr}'; + +async function fixture() { + const [admin, other, ...accounts] = await ethers.getSigners(); + + // JWT Registry (following actual zk-jwt pattern from JwtRegistryBase.t.sol) + const jwtRegistry = await ethers.deployContract('JwtRegistry', [admin.address]); + + // Set up the JWT public key following the actual test pattern + await jwtRegistry + .connect(admin) + .setJwtPublicKey(domainName, publicKeyHash) + .then(() => jwtRegistry.isDKIMPublicKeyHashValid(domainName, publicKeyHash)) + .then(() => jwtRegistry.isJwtPublicKeyValid(domainName, publicKeyHash)); + + // JWT Verifier + const verifier = await ethers.deployContract('$ZKJWTVerifierMock'); + + // ZKJWTUtils mock contract + const mock = await ethers.deployContract('$ZKJWTUtils'); + + return { admin, other, accounts, jwtRegistry, verifier, mock }; +} + +function buildJWTProof(command) { + return { + domainName, // kid|iss|azp format (from actual zk-jwt tests) + publicKeyHash, + timestamp: Math.floor(Date.now() / 1000), + maskedCommand: command, + emailNullifier, + accountSalt, + isCodeExist: true, + proof: '0x01', // Mocked in ZKJWTVerifierMock + }; +} + +describe('ZKJWTUtils', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('should validate ZKJWT sign hash', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const command = SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(); + const jwtProof = buildJWTProof(command); + + // Use the default function that handles signHash template internally + const fnSig = '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,bytes32)'; + await expect(this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, hash)).to.eventually.equal( + JWTProofError.NoError, + ); + }); + + it('should validate ZKJWT with template', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const commandPrefix = 'jwtCommand'; + const command = commandPrefix + ' ' + ethers.toBigInt(hash).toString(); + const jwtProof = buildJWTProof(command); + const template = [UINT_MATCHER]; + const templateParams = [ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [ethers.toBigInt(hash)])]; + + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[])'; + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams), + ).to.eventually.equal(JWTProofError.NoError); + }); + + it('should validate complex JWT commands with multiple parameters', async function () { + const amount = ethers.parseEther('1.5'); + const recipient = this.other.address; + const command = `transfer ${amount.toString()} ${recipient}`; + const jwtProof = buildJWTProof(command); + const template = [UINT_MATCHER, ETH_ADDR_MATCHER]; + const templateParams = [ + ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [amount]), + ethers.AbiCoder.defaultAbiCoder().encode(['address'], [recipient]), + ]; + + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[])'; + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams), + ).to.eventually.equal(JWTProofError.NoError); + }); + + it('should validate JWT maskedCommand from real proof structure', async function () { + // Based on actual JWT verifier test: "Send 0.12 ETH to 0x1234" + const amount = ethers.parseEther('0.12'); + const recipient = '0x1234000000000000000000000000000000000000'; + const command = `Send ${amount.toString()} ETH to ${recipient}`; + const jwtProof = buildJWTProof(command); + const template = [UINT_MATCHER, 'ETH', 'to', ETH_ADDR_MATCHER]; + const templateParams = [ + ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [amount]), + ethers.AbiCoder.defaultAbiCoder().encode(['address'], [recipient]), + ]; + + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[])'; + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams), + ).to.eventually.equal(JWTProofError.NoError); + }); + + it('should validate command with address match in different cases', async function () { + const commandPrefix = 'authorize'; + const template = [ETH_ADDR_MATCHER]; + + const testCases = [ + { + 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), + }, + ]; + + for (const { caseType, address } of testCases) { + const command = commandPrefix + ' ' + address; + const jwtProof = buildJWTProof(command); + const templateParams = [ethers.AbiCoder.defaultAbiCoder().encode(['address'], [address])]; + + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[],uint8)'; + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams, caseType), + ).to.eventually.equal(JWTProofError.NoError); + } + }); + + it('should validate command with address match using any case', async function () { + const commandPrefix = 'grant'; + const template = [ETH_ADDR_MATCHER]; // Only parameter matchers + + // 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 command = commandPrefix + ' ' + address; + const jwtProof = buildJWTProof(command); + const templateParams = [ethers.AbiCoder.defaultAbiCoder().encode(['address'], [address])]; + + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[],uint8)'; + await expect( + this.mock[fnSig]( + jwtProof, + this.jwtRegistry.target, + this.verifier.target, + template, + templateParams, + ethers.Typed.uint8(Case.ANY), + ), + ).to.eventually.equal(JWTProofError.NoError); + } + }); + + it('should detect invalid JWT public key hash', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const command = SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(); + const jwtProof = buildJWTProof(command); + jwtProof.publicKeyHash = ethers.hexlify(ethers.randomBytes(32)); // Invalid public key hash + + const template = [UINT_MATCHER]; + const templateParams = [ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [ethers.toBigInt(hash)])]; + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[])'; + + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams), + ).to.eventually.equal(JWTProofError.JWTPublicKeyHash); + }); + + it('should detect unregistered domain format', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const command = SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(); + const jwtProof = buildJWTProof(command); + // Use a domain that hasn't been registered + jwtProof.domainName = 'unregistered-kid|https://unregistered.com|unregistered-client'; + + const template = [UINT_MATCHER]; + const templateParams = [ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [ethers.toBigInt(hash)])]; + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[])'; + + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams), + ).to.eventually.equal(JWTProofError.JWTPublicKeyHash); + }); + + it('should detect invalid masked command length', async function () { + // Create a command that's too long (exceeds circuit limits - 605 bytes max) + const longCommand = 'a'.repeat(606); + const jwtProof = buildJWTProof(longCommand); + + const template = [UINT_MATCHER]; + const templateParams = [ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [0])]; + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[])'; + + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams), + ).to.eventually.equal(JWTProofError.MaskedCommandLength); + }); + + it('should detect mismatched command template', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const command = 'invalidJWTCommand ' + ethers.toBigInt(hash).toString(); + const jwtProof = buildJWTProof(command); + const template = ['{string}']; + const templateParams = [ethers.AbiCoder.defaultAbiCoder().encode(['string'], ['differentValue'])]; + + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[])'; + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams), + ).to.eventually.equal(JWTProofError.MismatchedCommand); + }); + + it('should detect invalid JWT proof', async function () { + const hash = ethers.hexlify(ethers.randomBytes(32)); + const command = SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(); + const jwtProof = buildJWTProof(command); + jwtProof.proof = '0x00'; // Invalid proof that will fail verification + + const fnSig = '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,bytes32)'; + await expect(this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, hash)).to.eventually.equal( + JWTProofError.JWTProof, + ); + }); + + it('should validate proper kid|iss|azp domain format', async function () { + const domain = 'auth0-key|https://your-domain.auth0.com/|your-auth0-client-id'; + await this.jwtRegistry.connect(this.admin).setJwtPublicKey(domain, publicKeyHash); + + const hash = ethers.hexlify(ethers.randomBytes(32)); + const command = SIGN_HASH_COMMAND + ' ' + ethers.toBigInt(hash).toString(); + + const template = [UINT_MATCHER]; + const templateParams = [ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [ethers.toBigInt(hash)])]; + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[])'; + + const jwtProof = buildJWTProof(command); + jwtProof.domainName = domain; + + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams), + ).to.eventually.equal(JWTProofError.NoError); + }); + + it('should handle JWT with real Google OAuth format', async function () { + await this.jwtRegistry + .connect(this.admin) + .setJwtPublicKey( + 'google-key-id-123|https://accounts.google.com|1234567890.apps.googleusercontent.com', + publicKeyHash, + ); + + // Based on actual Google JWT structure + const googleKid = 'google-key-id-123'; + const googleIss = 'https://accounts.google.com'; + const googleAzp = '1234567890.apps.googleusercontent.com'; + const googleDomain = `${googleKid}|${googleIss}|${googleAzp}`; + + const nonce = ethers.hexlify(ethers.randomBytes(16)); + const nonceValue = ethers.toBigInt(nonce); + const command = `grant ${nonceValue.toString()}`; + const jwtProof = buildJWTProof(command); + jwtProof.domainName = googleDomain; + + const template = [UINT_MATCHER]; + const templateParams = [ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [nonceValue])]; + const fnSig = + '$isValidZKJWT((string,bytes32,uint256,string,bytes32,bytes32,bool,bytes),address,address,string[],bytes[])'; + + await expect( + this.mock[fnSig](jwtProof, this.jwtRegistry.target, this.verifier.target, template, templateParams), + ).to.eventually.equal(JWTProofError.NoError); + }); +});