Skip to content

Add ERC-7969 compliant DKIMRegistry #184

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
28 changes: 28 additions & 0 deletions contracts/interfaces/IERC7969.sol
Original file line number Diff line number Diff line change
@@ -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);
}
83 changes: 83 additions & 0 deletions contracts/utils/cryptography/DKIMRegistry.sol
Original file line number Diff line number Diff line change
@@ -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 {
Copy link

Choose a reason for hiding this comment

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

Suggested change
abstract contract DKIMRegistry is IDKIMRegistry {
contract DKIMRegistry is IDKIMRegistry {

Copy link
Member Author

@ernestognw ernestognw Jul 31, 2025

Choose a reason for hiding this comment

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

Hi @0xknon, we generally mark contracts as abstract to make it clear that it's not our intention for people deploy them directly. Note it's missing the public setKeyHash, setKeyHashes and revokeKeyHash functions that require access control.

Alternatively, we can define this functions within the contract but add a modifier with an internal virtual _checkKeyHashUpdate:

abstract contract MyDKIMRegistry is DKIMRegistry, Ownable {
    modifier onlyKeyHashUpdater() {
        _checkKeyHashUpdater();
        _;
    }

    function setKeyHash(bytes32 domainHash, bytes32 keyHash) public onlyKeyHashUpdater {
        _setKeyHash(domainHash, keyHash);
    }

    function setKeyHashes(bytes32 domainHash, bytes32[] memory keyHashes) public onlyKeyHashUpdater {
        _setKeyHashes(domainHash, keyHashes);
    }

    function revokeKeyHash(bytes32 domainHash, bytes32 keyHash) public onlyKeyHashUpdater {
        _revokeKeyHash(domainHash, keyHash);
    }

   ...

    function _checkKeyHashUpdater() internal virtual view;
}

It would be still abstract, but maybe closer to a straightforward deployment. wdyt?

/// @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);
}
}
244 changes: 244 additions & 0 deletions test/utils/cryptography/DKIMRegistry.test.js
Original file line number Diff line number Diff line change
@@ -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;
});
});