diff --git a/contracts/interfaces/IERC7969.sol b/contracts/interfaces/IERC7969.sol new file mode 100644 index 00000000..23544d86 --- /dev/null +++ b/contracts/interfaces/IERC7969.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title ERC-7969 DKIM Registry Interface. + * + * @dev This interface provides a standard way to register and validate DKIM public key hashes onchain + * Domain owners can register their DKIM public key hashes and third parties can verify their validity + * The interface enables email-based account abstraction and secure account recovery mechanisms. + * + * NOTE: The ERC-165 identifier for this interface is `0xdee3d600`. + */ +interface IDKIMRegistry { + /// @dev Emitted when a new DKIM public key hash is registered for a domain + /// @param domainHash The keccak256 hash of the lowercase domain name + /// @param keyHash The keccak256 hash of the DKIM public key + event KeyHashRegistered(bytes32 domainHash, bytes32 keyHash); + + /// @dev Emitted when a DKIM public key hash is revoked for a domain + /// @param domainHash The keccak256 hash of the domain name + event KeyHashRevoked(bytes32 domainHash); + + /// @dev Checks if a DKIM key hash is valid for a given domain + /// @param domainHash The keccak256 hash of the lowercase domain name + /// @param keyHash The keccak256 hash of the DKIM public key + /// @return True if the key hash is valid for the domain, false otherwise + function isKeyHashValid(bytes32 domainHash, bytes32 keyHash) external view returns (bool); +} diff --git a/contracts/utils/cryptography/DKIMRegistry.sol b/contracts/utils/cryptography/DKIMRegistry.sol new file mode 100644 index 00000000..f171510a --- /dev/null +++ b/contracts/utils/cryptography/DKIMRegistry.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IDKIMRegistry} from "../../interfaces/IERC7969.sol"; + +/** + * @dev Implementation of {IDKIMRegistry} for registering and validating DKIM public key hashes onchain. + * + * This contract provides a standard way to register and validate DKIM public key hashes, enabling + * email-based account abstraction and secure account recovery mechanisms. Domain owners can register + * their DKIM public key hashes and third parties can verify their validity. + * + * The contract stores mappings of domain hashes to DKIM public key hashes, where: + * + * * Domain hash: keccak256 hash of the lowercase domain name + * * Key hash: keccak256 hash of the DKIM public key + * + * Example of usage: + * + * ```solidity + * contract MyDKIMRegistry is DKIMRegistry, Ownable { + * function setKeyHash(bytes32 domainHash, bytes32 keyHash) public onlyOwner { + * _setKeyHash(domainHash, keyHash); + * } + * + * function setKeyHashes(bytes32 domainHash, bytes32[] memory keyHashes) public onlyOwner { + * _setKeyHashes(domainHash, keyHashes); + * } + * + * function revokeKeyHash(bytes32 domainHash, bytes32 keyHash) public onlyOwner { + * _revokeKeyHash(domainHash, keyHash); + * } + * } + * ``` + */ +abstract contract DKIMRegistry is IDKIMRegistry { + /// @dev Mapping from domain hash to key hash to validity status + mapping(bytes32 domainHash => mapping(bytes32 keyHash => bool)) private _keyHashes; + + /// @dev Returns whether a DKIM key hash is valid for a given domain. + function isKeyHashValid(bytes32 domainHash, bytes32 keyHash) public view returns (bool) { + return _keyHashes[domainHash][keyHash]; + } + + /** + * @dev Sets a DKIM key hash as valid for a domain. Internal version without access control. + * + * Emits a {KeyHashRegistered} event. + * + * NOTE: This function does not validate that keyHash is non-zero. Consider adding + * validation in derived contracts if needed. + */ + function _setKeyHash(bytes32 domainHash, bytes32 keyHash) internal { + _keyHashes[domainHash][keyHash] = true; + emit KeyHashRegistered(domainHash, keyHash); + } + + /** + * @dev Sets multiple DKIM key hashes as valid for a domain in a single transaction. + * Internal version without access control. + * + * Emits a {KeyHashRegistered} event for each key hash. + * + * NOTE: This function does not validate that the keyHashes array is non-empty. + * Consider adding validation in derived contracts if needed. + */ + function _setKeyHashes(bytes32 domainHash, bytes32[] memory keyHashes) internal { + for (uint256 i = 0; i < keyHashes.length; ++i) { + _setKeyHash(domainHash, keyHashes[i]); + } + } + + /** + * @dev Revokes a DKIM key hash for a domain, making it invalid. + * Internal version without access control. + * + * Emits a {KeyHashRevoked} event. + */ + function _revokeKeyHash(bytes32 domainHash, bytes32 keyHash) internal { + delete _keyHashes[domainHash][keyHash]; + emit KeyHashRevoked(domainHash); + } +} diff --git a/test/utils/cryptography/DKIMRegistry.test.js b/test/utils/cryptography/DKIMRegistry.test.js new file mode 100644 index 00000000..3d4a7125 --- /dev/null +++ b/test/utils/cryptography/DKIMRegistry.test.js @@ -0,0 +1,244 @@ +const { expect } = require('chai'); +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const DOMAIN_EXAMPLE_COM = ethers.keccak256(ethers.toUtf8Bytes('example.com')); +const DOMAIN_EXAMPLE_ORG = ethers.keccak256(ethers.toUtf8Bytes('example.org')); +const DOMAIN_SUBDOMAIN = ethers.keccak256(ethers.toUtf8Bytes('mail.example.com')); + +const KEY_HASH_1 = ethers.keccak256(ethers.toUtf8Bytes('dkim_public_key_1')); +const KEY_HASH_2 = ethers.keccak256(ethers.toUtf8Bytes('dkim_public_key_2')); +const KEY_HASH_3 = ethers.keccak256(ethers.toUtf8Bytes('dkim_public_key_3')); +const ZERO_HASH = ethers.ZeroHash; + +async function fixture() { + const registry = await ethers.deployContract('$DKIMRegistry'); + + return { registry }; +} + +describe('DKIMRegistry', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('isKeyHashValid', function () { + it('should return false for unregistered key hash', async function () { + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.false; + }); + + it('should return true for registered key hash', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + }); + + it('should return false for different domain with same key hash', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_ORG, KEY_HASH_1)).to.eventually.be.false; + }); + + it('should return false for same domain with different key hash', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.false; + }); + + it('should handle zero hash values', async function () { + await expect(this.registry.isKeyHashValid(ZERO_HASH, ZERO_HASH)).to.eventually.be.false; + + await this.registry.$_setKeyHash(ZERO_HASH, ZERO_HASH); + await expect(this.registry.isKeyHashValid(ZERO_HASH, ZERO_HASH)).to.eventually.be.true; + }); + }); + + describe('_setKeyHash', function () { + it('should set a key hash for a domain', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + }); + + it('should emit KeyHashRegistered event', async function () { + await expect(this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1)) + .to.emit(this.registry, 'KeyHashRegistered') + .withArgs(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + }); + + it('should allow setting multiple key hashes for same domain', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_2); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.true; + }); + + it('should allow setting same key hash for different domains', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_ORG, KEY_HASH_1); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_ORG, KEY_HASH_1)).to.eventually.be.true; + }); + + it('should handle subdomains independently', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await this.registry.$_setKeyHash(DOMAIN_SUBDOMAIN, KEY_HASH_2); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_SUBDOMAIN, KEY_HASH_2)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.false; + await expect(this.registry.isKeyHashValid(DOMAIN_SUBDOMAIN, KEY_HASH_1)).to.eventually.be.false; + }); + + it('should allow setting zero hash', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, ZERO_HASH); + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, ZERO_HASH)).to.eventually.be.true; + }); + + it('should overwrite existing key hash', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); // Set again + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + }); + }); + + describe('_setKeyHashes', function () { + it('should set multiple key hashes for a domain', async function () { + const keyHashes = [KEY_HASH_1, KEY_HASH_2, KEY_HASH_3]; + await this.registry.$_setKeyHashes(DOMAIN_EXAMPLE_COM, keyHashes); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_3)).to.eventually.be.true; + }); + + it('should emit KeyHashRegistered event for each key hash', async function () { + const keyHashes = [KEY_HASH_1, KEY_HASH_2]; + const tx = this.registry.$_setKeyHashes(DOMAIN_EXAMPLE_COM, keyHashes); + + await expect(tx).to.emit(this.registry, 'KeyHashRegistered').withArgs(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await expect(tx).to.emit(this.registry, 'KeyHashRegistered').withArgs(DOMAIN_EXAMPLE_COM, KEY_HASH_2); + }); + + it('should handle single key hash in array', async function () { + const keyHashes = [KEY_HASH_1]; + await this.registry.$_setKeyHashes(DOMAIN_EXAMPLE_COM, keyHashes); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + }); + + it('should handle empty array', async function () { + const keyHashes = []; + await this.registry.$_setKeyHashes(DOMAIN_EXAMPLE_COM, keyHashes); + + // No key hashes should be set + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.false; + }); + + it('should handle duplicate key hashes in array', async function () { + const keyHashes = [KEY_HASH_1, KEY_HASH_1, KEY_HASH_2]; + await this.registry.$_setKeyHashes(DOMAIN_EXAMPLE_COM, keyHashes); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.true; + }); + + it('should handle array with zero hash', async function () { + const keyHashes = [KEY_HASH_1, ZERO_HASH, KEY_HASH_2]; + await this.registry.$_setKeyHashes(DOMAIN_EXAMPLE_COM, keyHashes); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, ZERO_HASH)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.true; + }); + }); + + describe('_revokeKeyHash', function () { + beforeEach(async function () { + // Set up some key hashes to revoke + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_2); + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_ORG, KEY_HASH_1); + }); + + it('should revoke a key hash for a domain', async function () { + await this.registry.$_revokeKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.false; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.true; // Other key should remain + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_ORG, KEY_HASH_1)).to.eventually.be.true; // Same key on different domain should remain + }); + + it('should emit KeyHashRevoked event', async function () { + await expect(this.registry.$_revokeKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1)) + .to.emit(this.registry, 'KeyHashRevoked') + .withArgs(DOMAIN_EXAMPLE_COM); + }); + + it('should handle revoking non-existent key hash', async function () { + await this.registry.$_revokeKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_3); + + // Should not affect existing key hashes + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.true; + }); + + it('should handle revoking from non-existent domain', async function () { + const nonExistentDomain = ethers.keccak256(ethers.toUtf8Bytes('nonexistent.com')); + await this.registry.$_revokeKeyHash(nonExistentDomain, KEY_HASH_1); + + // Should not affect existing key hashes + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + }); + + it('should handle revoking zero hash', async function () { + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, ZERO_HASH); + await this.registry.$_revokeKeyHash(DOMAIN_EXAMPLE_COM, ZERO_HASH); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, ZERO_HASH)).to.eventually.be.false; + }); + + it('should allow re-setting a revoked key hash', async function () { + await this.registry.$_revokeKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.false; + + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + }); + }); + + it('should handle multiple domains with overlapping key hashes', async function () { + // Set same key hash for multiple domains + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + await this.registry.$_setKeyHash(DOMAIN_EXAMPLE_ORG, KEY_HASH_1); + await this.registry.$_setKeyHash(DOMAIN_SUBDOMAIN, KEY_HASH_1); + + // Verify all are valid + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_ORG, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_SUBDOMAIN, KEY_HASH_1)).to.eventually.be.true; + + // Revoke from one domain only + await this.registry.$_revokeKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_1); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.false; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_ORG, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_SUBDOMAIN, KEY_HASH_1)).to.eventually.be.true; + }); + + it('should handle batch operations followed by individual revocations', async function () { + const keyHashes = [KEY_HASH_1, KEY_HASH_2, KEY_HASH_3]; + await this.registry.$_setKeyHashes(DOMAIN_EXAMPLE_COM, keyHashes); + + // Verify all are set + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_3)).to.eventually.be.true; + + // Revoke middle key hash + await this.registry.$_revokeKeyHash(DOMAIN_EXAMPLE_COM, KEY_HASH_2); + + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_1)).to.eventually.be.true; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_2)).to.eventually.be.false; + await expect(this.registry.isKeyHashValid(DOMAIN_EXAMPLE_COM, KEY_HASH_3)).to.eventually.be.true; + }); +});