diff --git a/src/simpleRecoveryPrototype/EOA712Verifier.sol b/src/simpleRecoveryPrototype/EOA712Verifier.sol new file mode 100644 index 0000000..47b594f --- /dev/null +++ b/src/simpleRecoveryPrototype/EOA712Verifier.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; + + +/** + * @title EOA712Verifier + * @notice A contract that verifies EIP-712 signatures from EOA guardians using OpenZeppelin’s ECDSA and SignatureChecker utilities. + */ +contract SimpleRecoveryVerifier { + using ECDSA for bytes32; + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* ERRORS & EVENTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + error InvalidSignature(address recoveredSigner, bytes signature); + + event GuardianVerified( + address indexed signer, + address indexed recoveredAccount, + uint256 templateIdx, + bytes32 commandParamsHash + ); + + mapping(address => mapping(address => uint256)) private nonces; + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* EIP-712 DOMAIN PARAMETERS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + string private constant NAME = "EOAGuardianVerifier"; + string private constant VERSION = "1.0.0"; + + bytes32 private constant _EIP712_DOMAIN_TYPEHASH = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + + bytes32 private constant _GUARDIAN_TYPEHASH = keccak256( + "GuardianAcceptance(address recoveredAccount,uint256 templateIdx,bytes32 commandParamsHash, uint256 nonce)" + ); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* PUBLIC FUNCTION: VERIFY EOA GUARDIAN SIGNATURE */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Verifies an EOA-based guardian’s EIP-712 signature. + * @param recoveredAccount The account that is being protected. + * @param templateIdx Numeric index indicating the acceptance template. + * @param commandParams Variable-length acceptance parameters (encoded bytes). + * @param signature The ECDSA signature from the guardian. + * @return signer The address recovered from the signature (reverts if invalid). + */ + function verifyEOAGuardian( + address recoveredAccount, + uint256 templateIdx, + bytes[] memory commandParams, + bytes memory signature + ) public virtual returns (address signer) { + // Encode command parameters and hash them + bytes memory encodedParams = abi.encode(commandParams); + bytes32 commandParamsHash = keccak256(encodedParams); + + // Create the struct hash for GuardianAcceptance + bytes32 structHash = keccak256( + abi.encode( + _GUARDIAN_TYPEHASH, + recoveredAccount, + templateIdx, + commandParamsHash, + nonces[msg.sender][recoveredAccount]++ + ) + ); + + // Create the final EIP-712 digest + bytes32 digest = toTypedDataHash(_domainSeparatorV4(), structHash); + + // Recover the signer from the digest and signature + address recoveredSigner = ECDSA.recover(digest, signature); + + // Check the validity of the signature + bool isValid = SignatureChecker.isValidSignatureNow( + recoveredSigner, + digest, + signature + ); + + if (!isValid) { + revert InvalidSignature(recoveredSigner, signature); + } + + signer = recoveredSigner; + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* INTERNAL FUNCTION: DOMAIN SEPARATOR */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @dev Calculates the EIP-712 domain separator for the current chain. + */ + function _domainSeparatorV4() internal view returns (bytes32) { + return keccak256( + abi.encode( + _EIP712_DOMAIN_TYPEHASH, + keccak256(bytes(NAME)), + keccak256(bytes(VERSION)), + block.chainid, + address(this) + ) + ); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* INTERNAL FUNCTION: CREATE TYPED DATA HASH */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @dev Combines the domain separator and struct hash into a single typed data hash. + */ + function toTypedDataHash(bytes32 domainSeparator, bytes32 structHash) + internal + pure + returns (bytes32) + { + return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* PUBLIC FUNCTION: GET DOMAIN SEPARATOR */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Returns the current domain separator. + * @return The EIP-712 domain separator. + */ + function getDomainSeparator() public view returns (bytes32) { + return _domainSeparatorV4(); + } + + function getNonce(address account, address guardian) public view returns (uint256) { + return nonces[account][guardian]; +} + +} diff --git a/src/simpleRecoveryPrototype/SimpleGuardianManager.sol b/src/simpleRecoveryPrototype/SimpleGuardianManager.sol new file mode 100644 index 0000000..0b15869 --- /dev/null +++ b/src/simpleRecoveryPrototype/SimpleGuardianManager.sol @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { EnumerableGuardianMap, GuardianStorage, GuardianStatus } from "../libraries/EnumerableGuardianMap.sol"; +import { ISimpleRecoveryModuleManager } from "./interfaces/ISimpleRecoveryModuleManager.sol"; +import { ISimpleGuardianManager } from "./interfaces/ISimpleGuardianManager.sol"; + +/** + * @title GuardianManager + * @notice A contract to manage guardians + */ +abstract contract SimpleGuardianManager is ISimpleGuardianManager { + using EnumerableGuardianMap for EnumerableGuardianMap.AddressToGuardianMap; + + /** + * @notice Account to guardian configuration + */ + mapping(address account => SimpleGuardianManager.GuardianConfig guardianConfig) + internal guardianConfigs; + + /** + * @notice Account address to guardian storage map + */ + mapping(address account => EnumerableGuardianMap.AddressToGuardianMap guardian) + internal guardiansStorage; + + /** + * @notice Account to guardian address to guardian type + */ + mapping(address => mapping(address => GuardianType)) public guardianTypes; + + /** + * @notice Modifier to check recovery status. Reverts if recovery is in process for the account. + */ + modifier onlyWhenNotRecovering() { + (, , uint256 currentWeight, ) = ISimpleRecoveryModuleManager(address(this)) + .getRecoveryRequest(msg.sender); + if (currentWeight > 0) { + revert RecoveryInProcess(); + } + _; + } + + /** + * @notice Modifier to check if the kill switch has been enabled + */ + modifier onlyWhenActive() { + bool killSwitchEnabled = ISimpleRecoveryModuleManager(address(this)).killSwitchEnabled(); + if (killSwitchEnabled) { + revert KillSwitchEnabled(); + } + _; + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* GUARDIAN LOGIC */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Retrieves the guardian configuration for a given account. + * @param account The address of the account. + * @return GuardianConfig The guardian configuration for the specified account. + */ + function getGuardianConfig(address account) public view returns (GuardianConfig memory) { + return guardianConfigs[account]; + } + + /** + * @notice Retrieves the guardian storage details for a given guardian and account. + * @param account The address of the account associated with the guardian. + * @param guardian The address of the guardian. + * @return GuardianStorage The guardian storage details. + */ + function getGuardian(address account, address guardian) public view returns (GuardianStorage memory) { + return guardiansStorage[account].get(guardian); + } + + function getGuardianType(address account, address guardian) public view returns (GuardianType) { + return guardianTypes[account][guardian]; + } + + /** + * @notice Sets up guardians for an account with specified weights and threshold. + * @param account The account address. + * @param guardians An array of guardian addresses. + * @param weights An array of weights corresponding to each guardian. + * @param guardiantypes An array of guardian types. + * @param threshold The threshold weight required for recovery. + */ + function setupGuardians( + address account, + address[] memory guardians, + uint256[] memory weights, + GuardianType[] memory guardiantypes, + uint256 threshold + ) internal returns (uint256, uint256) { + uint256 guardianCount = guardians.length; + + if (guardianCount != weights.length) { + revert IncorrectNumberOfWeights(guardianCount, weights.length); + } + + if (guardianCount != guardiantypes.length) { + revert IncorrectNumberOfGuardianTypes(guardianCount, guardiantypes.length); + } + + if (threshold == 0) { + revert ThresholdCannotBeZero(); + } + + for (uint256 i = 0; i < guardianCount; i++) { + _addGuardian(account, guardians[i], weights[i], guardiantypes[i]); + } + + uint256 totalWeight = guardianConfigs[account].totalWeight; + if (threshold > totalWeight) { + revert ThresholdExceedsTotalWeight(threshold, totalWeight); + } + + guardianConfigs[account].threshold = threshold; + return (guardianCount, totalWeight); + } + + /** + * @notice Adds a guardian for the caller's account. + * @param guardian The address of the guardian. + * @param weight The weight assigned to the guardian. + * @param guardianType The type of the guardian. + */ + function addGuardian( + address guardian, + uint256 weight, + GuardianType guardianType + ) public onlyWhenNotRecovering { + if (guardianConfigs[msg.sender].threshold == 0) { + revert SetupNotCalled(); + } + + _addGuardian(msg.sender, guardian, weight, guardianType); + } + + /** + * @notice Internal function to add a guardian with specified weight. + * @param account The account address. + * @param guardian The guardian address. + * @param weight The weight assigned to the guardian. + * @param guardianType The type of the guardian. + */ + function _addGuardian(address account, address guardian, uint256 weight, GuardianType guardianType) internal { + if (guardian == address(0) || guardian == account) { + revert InvalidGuardianAddress(guardian); + } + + if (weight == 0) { + revert InvalidGuardianWeight(); + } + + bool success = guardiansStorage[account].set({ + key: guardian, + value: GuardianStorage(GuardianStatus.REQUESTED, weight) + }); + + if (!success) { + revert AddressAlreadyGuardian(); + } + + guardianTypes[account][guardian] = guardianType; + + guardianConfigs[account].guardianCount++; + guardianConfigs[account].totalWeight += weight; + + emit AddedGuardian(account, guardian, weight, guardianType); + } + + /** + * @notice Removes a guardian for the caller's account. + * @param guardian The address of the guardian to be removed. + */ + function removeGuardian(address guardian) external onlyWhenNotRecovering { + GuardianConfig memory guardianConfig = guardianConfigs[msg.sender]; + GuardianStorage memory guardianStorage = guardiansStorage[msg.sender].get(guardian); + + bool success = guardiansStorage[msg.sender].remove(guardian); + if (!success) { + revert AddressNotGuardianForAccount(); + } + + uint256 newTotalWeight = guardianConfig.totalWeight - guardianStorage.weight; + if (newTotalWeight < guardianConfig.threshold) { + revert ThresholdExceedsTotalWeight(newTotalWeight, guardianConfig.threshold); + } + + guardianConfigs[msg.sender].guardianCount--; + guardianConfigs[msg.sender].totalWeight -= guardianStorage.weight; + if (guardianStorage.status == GuardianStatus.ACCEPTED) { + guardianConfigs[msg.sender].acceptedWeight -= guardianStorage.weight; + } + + emit RemovedGuardian(msg.sender, guardian, guardianStorage.weight); + } + + /** + * @notice Changes the threshold for guardian approvals for the caller's account. + * @param threshold The new threshold for guardian approvals. + */ + function changeThreshold(uint256 threshold) external onlyWhenNotRecovering { + if (guardianConfigs[msg.sender].threshold == 0) { + revert SetupNotCalled(); + } + + if (threshold > guardianConfigs[msg.sender].totalWeight) { + revert ThresholdExceedsTotalWeight(threshold, guardianConfigs[msg.sender].totalWeight); + } + + if (threshold == 0) { + revert ThresholdCannotBeZero(); + } + + guardianConfigs[msg.sender].threshold = threshold; + emit ChangedThreshold(msg.sender, threshold); + } + + /** + * @notice Updates the status for a guardian. + * @param account The account address. + * @param guardian The guardian address. + * @param newStatus The new status for the guardian. + */ + function updateGuardianStatus( + address account, + address guardian, + GuardianStatus newStatus + ) internal { + GuardianStorage memory guardianStorage = guardiansStorage[account].get(guardian); + if (newStatus == guardianStorage.status) { + revert StatusCannotBeTheSame(newStatus); + } + + guardiansStorage[account].set({ + key: guardian, + value: GuardianStorage(newStatus, guardianStorage.weight) + }); + emit GuardianStatusUpdated(account, guardian, newStatus); + } +} diff --git a/src/simpleRecoveryPrototype/SimpleRecoveryManager.sol b/src/simpleRecoveryPrototype/SimpleRecoveryManager.sol new file mode 100644 index 0000000..ccd4dc0 --- /dev/null +++ b/src/simpleRecoveryPrototype/SimpleRecoveryManager.sol @@ -0,0 +1,666 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { EmailAccountRecovery } from + "@zk-email/ether-email-auth-contracts/src/EmailAccountRecovery.sol"; +import { SimpleRecoveryVerifier } from + "./EOA712Verifier.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import { IEmailRecoveryCommandHandler } from "../interfaces/IEmailRecoveryCommandHandler.sol"; +import "@zk-email/ether-email-auth-contracts/src/EmailAuth.sol"; +import { SimpleGuardianManager } from "./SimpleGuardianManager.sol"; +import { GuardianStorage, GuardianStatus } from "../libraries/EnumerableGuardianMap.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@zk-email/ether-email-auth-contracts/src/libraries/StringUtils.sol"; +import { ISimpleRecoveryModuleManager } from "./interfaces/ISimpleRecoveryModuleManager.sol"; + +/** + * @title SimpleRecoveryModuleManager + * @notice A simplified recovery module for ERC7579 accounts supporting multiple verification methods + */ +abstract contract SimpleRecoveryModuleManager is SimpleRecoveryVerifier, EmailAccountRecovery, Ownable, SimpleGuardianManager, ISimpleRecoveryModuleManager { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONSTANTS & STORAGE */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + using EnumerableSet for EnumerableSet.AddressSet; + uint256 public constant MINIMUM_RECOVERY_WINDOW = 2 days; + uint256 public constant CANCEL_EXPIRED_RECOVERY_COOLDOWN = 1 days; + + uint256 public immutable minimumDelay; + + address public immutable commandHandler; + // uint public constant CANCEL_EXPIRED_RECOVERY_COOLDOWN = 1 days; + + bool public killSwitchEnabled; + mapping(address account => RecoveryConfig recoveryConfig) internal recoveryConfigs; + mapping(address account => RecoveryRequest recoveryRequest) internal recoveryRequests; + mapping(address account => PreviousRecoveryRequest previousRecoveryRequest) internal + previousRecoveryRequests; + + constructor( + address _verifier, + address _dkimRegistry, + address _emailAuthImpl, + address _commandHandler, + uint256 _minimumDelay, + address _killSwitchAuthorizer + ) + Ownable(_killSwitchAuthorizer) + { + if (_verifier == address(0)) { + revert InvalidVerifier(); + } + if (_emailAuthImpl == address(0)) { + revert InvalidEmailAuthImpl(); + } + if (_dkimRegistry == address(0)) { + revert InvalidDKIMRegistry(); + } + if (_commandHandler == address(0)) { + revert InvalidCommandHandler(); + } + if (_killSwitchAuthorizer == address(0)) { + revert InvalidKillSwitchAuthorizer(); + } + verifierAddr = _verifier; + dkimAddr = _dkimRegistry; + emailAuthImplementationAddr = _emailAuthImpl; + commandHandler = _commandHandler; + minimumDelay = _minimumDelay; + } + + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* FUNCTIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Retrieves the recovery configuration for a given account + * @param account The address of the account for which the recovery configuration is being + * retrieved + * @return RecoveryConfig The recovery configuration for the specified account + */ + function getRecoveryConfig(address account) external view returns (RecoveryConfig memory) { + return recoveryConfigs[account]; + } + + /** + * @notice Retrieves the recovery request details for a given account + * @dev Does not return guardianVoted as that is part of a nested mapping + * @param account The address of the account for which the recovery request details are being + * retrieved + * @return executeAfter The timestamp from which the recovery request can be executed + * @return executeBefore The timestamp from which the recovery request becomes invalid + * @return currentWeight Total weight of all guardian approvals for the recovery request + * @return recoveryDataHash The keccak256 hash of the recovery data used to execute the recovery + * attempt + */ + function getRecoveryRequest(address account) external view returns ( + uint256 executeAfter, + uint256 executeBefore, + uint256 currentWeight, + bytes32 recoveryDataHash + ) { + return ( + recoveryRequests[account].executeAfter, + recoveryRequests[account].executeBefore, + recoveryRequests[account].currentWeight, + recoveryRequests[account].recoveryDataHash + ); + } + + /** + * @notice Retrieves the previous recovery request details for a given account + * @dev the previous recovery request is stored as this helps prevent guardians threatening the + * liveness of recovery attempts by submitting malicious recovery hashes before honest guardians + * correctly submit theirs. See `processRecovery` and `cancelExpiredRecovery` for more details + * @param account The address of the account for which the previous recovery request details are + * being retrieved + * @return PreviousRecoveryRequest The previous recovery request for the specified account + */ + function getPreviousRecoveryRequest(address account) + external + view + returns (PreviousRecoveryRequest memory) + { + return previousRecoveryRequests[account]; + } + + /** + * @notice Returns whether a guardian has voted on the current recovery request for a given + * account + * @param account The address of the account for which the recovery request is being checked + * @param guardian The address of the guardian to check voted status + * @return bool The boolean value indicating whether the guardian has voted on the recovery + * request + */ + function hasGuardianVoted(address account, address guardian) public view returns (bool) { + return recoveryRequests[account].guardianVoted.contains(guardian); + } + + /** + * @notice Checks if the recovery is activated for a given account + * @param account The address of the account for which the activation status is being checked + * @return bool True if the recovery request is activated, false otherwise + */ + function isActivated(address account) public view override returns (bool) { + return guardianConfigs[account].threshold > 0; + } + + /** + * @notice Returns a two-dimensional array of strings representing the command templates for an + * acceptance by a new guardian. + * @dev This is retrieved from the associated command handler. Developers can write their own + * command handlers, this is useful for account implementations which require different data in + * the command or if the email should be in a language that is not English. + * @return string[][] A two-dimensional array of strings, where each inner array represents a + * set of fixed strings and matchers for a command template. + */ + function acceptanceCommandTemplates() public view override returns (string[][] memory) { + return IEmailRecoveryCommandHandler(commandHandler).acceptanceCommandTemplates(); + } + + /** + * @notice Returns a two-dimensional array of strings representing the command templates for + * email recovery. + * @dev This is retrieved from the associated command handler. Developers can write their own + * command handlers, this is useful for account implementations which require different data in + * the command or if the email should be in a language that is not English. + * @return string[][] A two-dimensional array of strings, where each inner array represents a + * set of fixed strings and matchers for a command template. + */ + function recoveryCommandTemplates() public view override returns (string[][] memory) { + return IEmailRecoveryCommandHandler(commandHandler).recoveryCommandTemplates(); + } + + /** + * @notice Extracts the account address to be recovered from the command parameters of an + * acceptance email. + * @dev This is retrieved from the associated command handler. + * @param commandParams The command parameters of the acceptance email. + * @param templateIdx The index of the acceptance command template. + */ + function extractRecoveredAccountFromAcceptanceCommand( + bytes[] memory commandParams, + uint256 templateIdx + ) + public + view + override + returns (address) + { + return IEmailRecoveryCommandHandler(commandHandler) + .extractRecoveredAccountFromAcceptanceCommand(commandParams, templateIdx); + } + + /** + * @notice Extracts the account address to be recovered from the command parameters of a + * recovery email. + * @dev This is retrieved from the associated command handler. + * @param commandParams The command parameters of the recovery email. + * @param templateIdx The index of the recovery command template. + */ + function extractRecoveredAccountFromRecoveryCommand( + bytes[] memory commandParams, + uint256 templateIdx + ) + public + view + override + returns (address) + { + return IEmailRecoveryCommandHandler(commandHandler) + .extractRecoveredAccountFromRecoveryCommand(commandParams, templateIdx); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONFIGURE RECOVERY */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Configures recovery for the caller's account. This is the first core function + * that must be called during the end-to-end recovery flow + * @dev Can only be called once for configuration. Sets up the guardians, and validates config + * parameters, ensuring that no recovery is in process. It is possible to update configuration + * at a later stage if neccessary + * @param guardians An array of guardian addresses + * @param weights An array of weights corresponding to each guardian + * @param threshold The threshold weight required for recovery + * @param delay The delay period before recovery can be executed + * @param expiry The expiry time after which the recovery attempt is invalid + */ + function configureRecovery( + address[] memory guardians, + uint256[] memory weights, + GuardianType[] memory guardianTypes, + uint256 threshold, + uint256 delay, + uint256 expiry + ) internal onlyWhenActive { + address account = msg.sender; + + if (guardianConfigs[account].threshold > 0) { + revert SetupAlreadyCalled(); + } + (uint256 guardianCount, uint256 totalWeight) = setupGuardians(account, guardians, weights, guardianTypes, threshold); + RecoveryConfig memory recoveryConfig = RecoveryConfig(delay, expiry); + + if (guardianConfigs[account].threshold == 0) { + revert AccountNotConfigured(); + } + if (recoveryConfig.delay < minimumDelay) { + revert DelayLessThanMinimumDelay(recoveryConfig.delay, minimumDelay); + } + if (recoveryConfig.expiry < recoveryConfig.delay + MINIMUM_RECOVERY_WINDOW) { + revert RecoveryWindowTooShort(recoveryConfig.expiry - recoveryConfig.delay); + } + if (recoveryConfig.delay > recoveryConfig.expiry) { + revert DelayMoreThanExpiry(recoveryConfig.delay, recoveryConfig.expiry); + } + recoveryConfigs[account] = recoveryConfig; + emit RecoveryConfigured(account, guardianCount, totalWeight, threshold); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* HANDLE ACCEPTANCE */ +/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´`*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/** +* @notice Handles the acceptance of an EOA (Externally Owned Account) guardian. + * + * @dev This function: + * - Extracts the account associated with the acceptance command. + * - Verifies the EOA guardian signature using `verifyEOAGuardian()`. + * - Calls `acceptGuardian()` to complete the guardian acceptance process. + * + * @param templateIdx The index of the acceptance command template. + * @param commandParams The command parameters used for verification. + * @param signature The EOA guardian's signature for verification. + */ +function handleEOAAcceptance( + uint templateIdx, + bytes[] memory commandParams, + bytes memory signature +) external { + address recoveredAccount = extractRecoveredAccountFromAcceptanceCommand( + commandParams, + templateIdx + ); + require(recoveredAccount != address(0), "invalid account"); + // Verify EOA guardian + address guardian = verifyEOAGuardian( + recoveredAccount, + templateIdx, + commandParams, + signature + ); + + acceptGuardian(guardian, templateIdx, commandParams, bytes32(0)); +} + +/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* HANDLE EMAIL AUTH GUARDIAN ACCEPTANCE */ +/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ +/** + * @notice Handles the acceptance of an email-based guardian. + * + * @dev This function: + * - Extracts the account associated with the acceptance command. + * - Validates the email authentication message, ensuring `accountSalt` is present. + * - Deploys a new `EmailAuth` proxy contract if it doesn't already exist. + * - Initializes the deployed `EmailAuth` contract with necessary templates. + * - Verifies that the existing `EmailAuth` proxy controller matches the expected contract. + * - Calls `authEmail()` to authenticate the email-based guardian. + * - Calls `acceptGuardian()` to complete the guardian acceptance process. + * + * @param emailAuthMsg The email authentication message containing the command and proof. + * @param templateIdx The index of the acceptance command template. + */ +function handleEmailAuthAcceptance( + EmailAuthMsg memory emailAuthMsg, + uint templateIdx +) external { + address recoveredAccount = extractRecoveredAccountFromAcceptanceCommand( + emailAuthMsg.commandParams, + templateIdx + ); + + require( + emailAuthMsg.proof.accountSalt != bytes32(0), + "Invalid email auth: missing accountSalt for email guardian" + ); + + address guardian = computeEmailAuthAddress( + recoveredAccount, + emailAuthMsg.proof.accountSalt + ); + uint templateId = computeAcceptanceTemplateId(templateIdx); + require(templateId == emailAuthMsg.templateId, "invalid template id"); + require(emailAuthMsg.proof.isCodeExist == true, "isCodeExist is false"); + + + EmailAuth guardianEmailAuth; + if (guardian.code.length == 0) { + // Deploy a new proxy if it doesn't exist + address proxyAddress = deployEmailAuthProxy( + recoveredAccount, + emailAuthMsg.proof.accountSalt + ); + guardianEmailAuth = EmailAuth(proxyAddress); + guardianEmailAuth.initDKIMRegistry(dkim()); + guardianEmailAuth.initVerifier(verifier()); + + // Add acceptance and recovery templates + for (uint idx = 0; idx < acceptanceCommandTemplates().length; idx++) { + guardianEmailAuth.insertCommandTemplate( + computeAcceptanceTemplateId(idx), + acceptanceCommandTemplates()[idx] + ); + } + for (uint idx = 0; idx < recoveryCommandTemplates().length; idx++) { + guardianEmailAuth.insertCommandTemplate( + computeRecoveryTemplateId(idx), + recoveryCommandTemplates()[idx] + ); + } + } else { + // If proxy already exists, verify the controller + guardianEmailAuth = EmailAuth(payable(address(guardian))); + require( + guardianEmailAuth.controller() == address(this), + "Invalid controller" + ); + } + + // Perform email-based authentication + guardianEmailAuth.authEmail(emailAuthMsg); + acceptGuardian( + guardian, + templateIdx, + emailAuthMsg.commandParams, + emailAuthMsg.proof.emailNullifier + ); +} +/** + * @notice Accepts a guardian for the specified account. This is the second core function + * that must be called during the end-to-end recovery flow + * @dev Called once per guardian added. Although this adds an extra step to recovery, this + * acceptance flow is an important security feature to ensure that no typos are made when adding + * a guardian, and that the guardian is in control of the specified email address. Called as + * part of handleAcceptance in EmailAccountRecovery + * @param guardian The address of the guardian to be accepted + * @param templateIdx The index of the template used for acceptance + * @param commandParams An array of bytes containing the command parameters + * @param {nullifier} Unused parameter. The nullifier acts as a unique identifier for an email, + * but it is not required in this implementation + */ +function acceptGuardian( + address guardian, + uint256 templateIdx, + bytes[] memory commandParams, + bytes32 /* nullifier */ +) + internal + override + onlyWhenActive +{ + address account = IEmailRecoveryCommandHandler(commandHandler).validateAcceptanceCommand( + templateIdx, + commandParams + ); + GuardianStorage memory guardianStorage = getGuardian(account, guardian); + updateGuardianStatus(account, guardian, GuardianStatus.ACCEPTED); + guardianConfigs[account].acceptedWeight += guardianStorage.weight; + + emit GuardianAccepted(account, guardian); +} +/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* HANDLE RECOVERY */ +/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + +/** + * @notice Processes a recovery request for a given account. This is the third core function + * that must be called during the end-to-end recovery flow + * @dev Called once per guardian until the threshold is reached + * @param guardian The address of the guardian initiating/voting on the recovery request + * @param templateIdx The index of the template used for the recovery request + * @param commandParams An array of bytes containing the command parameters + * @param {nullifier} Unused parameter. The nullifier acts as a unique identifier for an email, + * but it is not required in this implementation + */ +function processRecovery( + address guardian, + uint256 templateIdx, + bytes[] memory commandParams, + bytes32 +) internal override onlyWhenActive { + address account = IEmailRecoveryCommandHandler(commandHandler).validateRecoveryCommand( + templateIdx, + commandParams + ); + GuardianType guardianType = getGuardianType(account, guardian); + if(guardianType == GuardianType.EmailVerified) { + // Check if the guardian is deployed + require(address(guardian).code.length > 0, "guardian is not deployed"); + + } + if (!isActivated(account)) { + revert RecoveryIsNotActivated(); + } + + GuardianConfig memory guardianConfig = guardianConfigs[account]; + if (guardianConfig.threshold > guardianConfig.acceptedWeight) { + revert ThresholdExceedsAcceptedWeight(guardianConfig.threshold, guardianConfig.acceptedWeight); + } + + // This check ensures GuardianStatus is correct and also implicitly that the + // account in the email is a valid account + GuardianStorage memory guardianStorage = getGuardian(account, guardian); + if (guardianStorage.status != GuardianStatus.ACCEPTED) { + revert InvalidGuardianStatus(); + } + + RecoveryRequest storage recoveryRequest = recoveryRequests[account]; + bytes32 recoveryDataHash = StringUtils.hexToBytes32(abi.decode(commandParams[1], (string))); + + if (hasGuardianVoted(account, guardian)) { + revert GuardianAlreadyVoted(); + } + + // A malicious guardian can submit an invalid recovery hash that the + // other guardians do not agree with, and also re-submit the same invalid hash once + // the expired recovery attempt has been cancelled, thereby threatening the + // liveness of the recovery attempt. Adding a cooldown period in this scenario gives other + // guardians time to react before the malicious guardian adds another recovery hash + uint256 guardianCount = guardianConfigs[account].guardianCount; + bool cooldownNotExpired = + previousRecoveryRequests[account].cancelRecoveryCooldown > block.timestamp; + if ( + previousRecoveryRequests[account].previousGuardianInitiated == guardian + && cooldownNotExpired && guardianCount > 1 + ) { + revert GuardianMustWaitForCooldown(guardian); + } + + if (recoveryRequest.recoveryDataHash == bytes32(0)) { + recoveryRequest.recoveryDataHash = recoveryDataHash; + previousRecoveryRequests[account].previousGuardianInitiated = guardian; + uint256 executeBefore = block.timestamp + recoveryConfigs[account].expiry; + recoveryRequest.executeBefore = executeBefore; + emit RecoveryRequestStarted(account, guardian, executeBefore, recoveryDataHash); + } + + if (recoveryRequest.recoveryDataHash != recoveryDataHash) { + revert InvalidRecoveryDataHash(); + } + + recoveryRequest.currentWeight += guardianStorage.weight; + recoveryRequest.guardianVoted.add(guardian); + emit GuardianVoted(account, guardian); + + if (recoveryRequest.currentWeight >= guardianConfig.threshold) { + uint256 executeAfter = block.timestamp + recoveryConfigs[account].delay; + recoveryRequest.executeAfter = executeAfter; + emit RecoveryRequestComplete(account, guardian, executeAfter, recoveryRequest.executeBefore, recoveryDataHash); + } +} + +/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ +/* COMPLETE RECOVERY */ +/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + +/** + * @notice Completes the recovery process for a given account. This is the forth and final + * core function that must be called during the end-to-end recovery flow. Can be called by + * anyone. + * @dev Validates the recovery request by checking the total weight, that the delay has passed, + * and the request has not expired. Calls the virtual `recover()` function which triggers + * recovery. This function deletes the recovery request but recovery config state is maintained + * so future recovery requests can be made without having to reconfigure everything + * @param account The address of the account for which the recovery is being completed + * @param recoveryData The data that is passed to recover the validator or account. + * recoveryData = abi.encode(validatorOrAccount, recoveryFunctionCalldata). Although, it is + * possible to design an account/module using this manager without encoding the validator or + * account, depending on how the `handler.parseRecoveryDataHash()` and `recover()` functions + * are implemented + */ +function completeRecovery( + address account, + bytes calldata recoveryData +) external override onlyWhenActive { + if (account == address(0)) { + revert InvalidAccountAddress(); + } + + RecoveryRequest storage recoveryRequest = recoveryRequests[account]; + if (guardianConfigs[account].threshold == 0) { + revert NoRecoveryConfigured(); + } + if (recoveryRequest.currentWeight < guardianConfigs[account].threshold) { + revert NotEnoughApprovals(); + } + if (block.timestamp < recoveryRequest.executeAfter) { + revert DelayNotPassed(block.timestamp, recoveryRequest.executeAfter); + } + if (block.timestamp >= recoveryRequest.executeBefore) { + revert RecoveryRequestExpired(); + } + + recover(account, recoveryData); + emit RecoveryCompleted(account); + exitandclearRecovery(account); +} +/** + * @notice Called during completeRecovery to finalize recovery. Contains implementation-specific + * logic to recover an account + * @dev this is the only function that must be implemented by consuming contracts to use the + * email recovery manager. This does not encompass other important logic such as module + * installation, that logic is specific to each implementation and must be implemeted separately + * @param account The address of the account for which the recovery is being completed + * @param recoveryData The data that is passed to recover the validator or account. + * recoveryData = abi.encode(validatorOrAccount, recoveryFunctionCalldata). Although, it is + * possible to design an account/module using this manager without encoding the validator or + * account, depending on how the `handler.parseRecoveryDataHash()` and `recover()` functions + * are implemented + */ +function recover( + address account, + bytes calldata recoveryData +) internal virtual; + +/** + * @notice Removes all state related to msg.sender. + * @dev A feature specifically important for smart account modules - in order to prevent + * unexpected behaviour when reinstalling account modules, the contract state should be + * deinitialized. This should include removing state accociated with an account. + */ +function deInitRecoveryModule() internal onlyWhenNotRecovering { + address account = msg.sender; + deInitRecoveryModule(account); +} + +/** + * @notice Removes all state related to an account. + * @dev Although this function is internal, it should be used carefully as it can be called by + * anyone. A feature specifically important for smart account modules - in order to prevent + * unexpected behaviour when reinstalling account modules, the contract state should be + * deinitialized. This should include removing state accociated with an account + * @param account The address of the account for which recovery is being deinitialized + */ +function deInitRecoveryModule( + address account +) internal onlyWhenNotRecovering { + delete recoveryConfigs[account]; + exitandclearRecovery(account); + delete guardianConfigs[account]; + delete previousRecoveryRequests[account]; + + emit RecoveryDeInitialized(account); +} + +/** + * @notice Cancels the recovery request for a given account if it is expired. + * @dev Deletes the current recovery request associated with the given account if the recovery + * request has expired. + * @param account The address of the account for which the recovery is being cancelled + */ +function cancelExpiredRecovery(address account) external onlyWhenActive { + if (recoveryRequests[account].currentWeight == 0) { + revert NoRecoveryInProcess(); + } + if (recoveryRequests[account].executeBefore > block.timestamp) { + revert RecoveryHasNotExpired( + account, + block.timestamp, + recoveryRequests[account].executeBefore + ); + } + previousRecoveryRequests[account].cancelRecoveryCooldown = + block.timestamp + CANCEL_EXPIRED_RECOVERY_COOLDOWN; + exitandclearRecovery(account); + emit RecoveryCancelled(account); +} + +/** + * @notice Exits and clears the ongoing recovery process for a specified account. + * + * This function cancels the recovery process if the sender has initiated one. + * It removes all guardian votes associated with the recovery request and deletes the request. + * + * @dev Emits a `RecoveryCancelled` event upon successful cancellation. + * Reverts if no recovery process is currently active for the caller. + * + * @param account The address of the account for which the recovery process is being cleared. + */ +function exitandclearRecovery(address account) public onlyWhenActive { + RecoveryRequest storage recoveryRequest = recoveryRequests[account]; + address[] memory guardiansVoted = recoveryRequest.guardianVoted.values(); + uint256 voteCount = guardiansVoted.length; + + for (uint256 i = 0; i < voteCount; i++) { + recoveryRequest.guardianVoted.remove(guardiansVoted[i]); + } + + delete recoveryRequests[account]; + } + +/** + * @notice Toggles the kill switch on the manager + * @dev Can only be called by the kill switch authorizer + */ +function toggleKillSwitch() external onlyOwner { + killSwitchEnabled = !killSwitchEnabled; +} +/** + * @notice Cancels the recovery request for the caller's account + * @dev Deletes the current recovery request associated with the caller's account + */ + function cancelRecovery() external onlyWhenActive { + if (recoveryRequests[msg.sender].currentWeight == 0) { + revert NoRecoveryInProcess(); + } + exitandclearRecovery(msg.sender); + emit RecoveryCancelled(msg.sender); + } +} diff --git a/src/simpleRecoveryPrototype/interfaces/ISimpleGuardianManager.sol b/src/simpleRecoveryPrototype/interfaces/ISimpleGuardianManager.sol new file mode 100644 index 0000000..6483b18 --- /dev/null +++ b/src/simpleRecoveryPrototype/interfaces/ISimpleGuardianManager.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { GuardianStorage, GuardianStatus } from "../../libraries/EnumerableGuardianMap.sol"; + +interface ISimpleGuardianManager { + /** + * A struct representing the values required for guardian configuration + * Config should be maintained over subsequent recovery attempts unless explicitly modified + */ + struct GuardianConfig { + uint256 guardianCount; // total count for all guardians + uint256 totalWeight; // combined weight for all guardians. Important for checking that + // thresholds are valid. + uint256 acceptedWeight; // combined weight for all accepted guardians. This is separated + // from totalWeight as it is important to prevent recovery starting without enough + // accepted guardians to meet the threshold. Storing this in a variable avoids the need + // to loop over accepted guardians whenever checking if a recovery attempt can be + // started without being broken + uint256 threshold; // the threshold required to successfully process a recovery attempt + } + /** + * @dev Enum representing the type of guardian: + * - EOA: Externally Owned Account (regular wallet address). + * - EmailVerified: Guardian verified through an email verification process. + */ + enum GuardianType { + EOA, + EmailVerified + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* EVENTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + event AddedGuardian( + address indexed account, + address indexed guardian, + uint256 weight, + GuardianType guardianType + ); + + event GuardianStatusUpdated( + address indexed account, + address indexed guardian, + GuardianStatus newStatus + ); + + event RemovedGuardian( + address indexed account, + address indexed guardian, + uint256 weight + ); + + event ChangedThreshold(address indexed account, uint256 threshold); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* ERRORS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + error RecoveryInProcess(); + error KillSwitchEnabled(); + error IncorrectNumberOfWeights(uint256 guardianCount, uint256 weightCount); + error ThresholdCannotBeZero(); + error InvalidGuardianAddress(address guardian); + error InvalidGuardianWeight(); + error AddressAlreadyGuardian(); + error ThresholdExceedsTotalWeight(uint256 threshold, uint256 totalWeight); + error StatusCannotBeTheSame(GuardianStatus newStatus); + error SetupNotCalled(); + error AddressNotGuardianForAccount(); + error InvalidCommandParams(uint256 paramsLength, uint256 expectedParamsLength); + error ThresholdExceedsAcceptedWeight(uint256 threshold, uint256 acceptedWeight); + error IncorrectNumberOfGuardianTypes(uint256 guardianCount, uint256 guardianTypes); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* FUNCTIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + function getGuardianConfig(address account) external view returns (GuardianConfig memory); + + function getGuardian( + address account, + address guardian + ) external view returns (GuardianStorage memory); + + function getGuardianType(address account, address guardian) external view returns (GuardianType) ; + + function addGuardian( + address guardian, + uint256 weight, + GuardianType guardianType + ) external; + + function removeGuardian(address guardian) external; + + function changeThreshold(uint256 threshold) external; +} diff --git a/src/simpleRecoveryPrototype/interfaces/ISimpleRecoveryModuleManager.sol b/src/simpleRecoveryPrototype/interfaces/ISimpleRecoveryModuleManager.sol new file mode 100644 index 0000000..f626357 --- /dev/null +++ b/src/simpleRecoveryPrototype/interfaces/ISimpleRecoveryModuleManager.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +interface ISimpleRecoveryModuleManager { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* TYPE DECLARATIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * A struct representing the values required for recovery configuration. + * Config should be maintained over subsequent recovery attempts unless explicitly modified. + */ + struct RecoveryConfig { + uint256 delay; // the time from when the threshold for a recovery request has passed (when + // the attempt is successful), until the recovery request can be executed. The delay can + // be used to give the account owner time to react in case a malicious recovery + // attempt is started by a guardian. + uint256 expiry; // the time from when a recovery request is started until the recovery + // request becomes invalid. The recovery expiry encourages the timely execution of + // successful recovery attempts and reduces the risk of unauthorized access through + // stale or outdated requests. After the recovery expiry has passed, anyone can cancel + // the recovery request. + } + + struct PreviousRecoveryRequest { + address previousGuardianInitiated; // the address of the guardian who initiated the previous + // recovery request. Used to prevent a malicious guardian threatening the liveness of + // the recovery attempt. For example, a guardian could initiate a recovery request with + // a recovery data hash for calldata that recovers the account to their own + // private key. Recording the previous guardian to initiate the request can be used + // in combination with a cooldown to stop the guardian blocking recovery with an + // invalid hash which is replaced by another invalid recovery hash after the request + // is cancelled + uint256 cancelRecoveryCooldown; // Used in conjunction with previousGuardianInitiated to + // stop a guardian blocking subsequent recovery requests with an invalid hash each time. + // Other guardians can react in time before the cooldown expires to start a valid + // recovery request with a valid hash + } + + + /** + * A struct representing the values required for a recovery request. + * The request state should be maintained over a single recovery attempt unless + * explicitly modified. It should be deleted after a recovery attempt has been processed. + */ + struct RecoveryRequest { + uint256 executeAfter; // the timestamp from which the recovery request can be executed. + uint256 executeBefore; // the timestamp from which the recovery request becomes invalid. + uint256 currentWeight; // total weight of all guardian approvals for the recovery request. + bytes32 recoveryDataHash; // the keccak256 hash of the recovery data used to execute the + // recovery attempt. + EnumerableSet.AddressSet guardianVoted; // the set of guardians who have voted for the + // recovery request. Must be looped through manually to delete each value. + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* EVENTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + event RecoveryConfigured( + address indexed account, + uint256 guardianCount, + uint256 totalWeight, + uint256 threshold + ); + + event GuardianAccepted(address indexed account, address indexed guardian); + event RecoveryRequestStarted( + address indexed account, + address indexed guardian, + uint256 executeBefore, + bytes32 recoveryDataHash + ); + + event GuardianVoted(address indexed account, address indexed guardian); + event RecoveryRequestComplete( + address indexed account, + address indexed guardian, + uint256 executeAfter, + uint256 executeBefore, + bytes32 recoveryDataHash + ); + + event RecoveryCompleted(address indexed account); + event RecoveryCancelled(address indexed account); + event RecoveryDeInitialized(address indexed account); + event RecoveryExecuted(address indexed account, address indexed validator); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* ERRORS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + error InvalidVerifier(); + error InvalidEmailAuthImpl(); + error InvalidDKIMRegistry(); + error SetupAlreadyCalled(); + error InvalidCommandHandler(); + error InvalidKillSwitchAuthorizer(); + error DelayLessThanMinimumDelay(uint256 delay, uint256 minimumDelay); + error DelayMoreThanExpiry(uint256 delay, uint256 expiry); + error RecoveryWindowTooShort(uint256 recoveryWindow); + error NoRecoveryInProcess(); + error RecoveryIsNotActivated(); + error InvalidTemplateIndex(uint256 templateIdx, uint256 expectedTemplateIdx); + error InvalidCommadparams(uint256 paramsLength, uint256 expectedParamsLength); + error InvalidGuardianStatus(); + error GuardianAlreadyVoted(); + error InvalidRecoveryDataHash(); + error InvalidAccountAddress(); + error NoRecoveryConfigured(); + error NotEnoughApprovals(); + error RecoveryRequestExpired(); + error InvalidSelector(); + error AccountNotConfigured(); + error DelayNotPassed(uint256 blockTimestamp, uint256 executeAfter); + error RecoveryHasNotExpired(address account, uint256 blockTimestamp, uint256 executeBefore); + error GuardianMustWaitForCooldown(address guardian); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* FUNCTIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + function killSwitchEnabled() external returns (bool); + + function getRecoveryConfig(address account) external view returns (RecoveryConfig memory); + + function getRecoveryRequest( + address account + ) + external + view + returns ( + uint256 executeAfter, + uint256 executeBefore, + uint256 currentWeight, + bytes32 recoveryDataHash + ); + + function hasGuardianVoted(address account, address guardian) external view returns (bool); + + function exitandclearRecovery(address account) external; +} diff --git a/src/simpleRecoveryPrototype/modules/SimpleRecoveryModule.sol b/src/simpleRecoveryPrototype/modules/SimpleRecoveryModule.sol new file mode 100644 index 0000000..3495efa --- /dev/null +++ b/src/simpleRecoveryPrototype/modules/SimpleRecoveryModule.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { ERC7579ExecutorBase } from "@rhinestone/modulekit/src/Modules.sol"; +import { IERC7579Account } from "erc7579/interfaces/IERC7579Account.sol"; +import { ISimpleRecoveryModuleManager } from "../interfaces/ISimpleRecoveryModuleManager.sol"; +import { SimpleRecoveryModuleManager } from "../SimpleRecoveryManager.sol"; +import { ISimpleGuardianManager } from "../interfaces/ISimpleGuardianManager.sol"; + +contract SimpleRecoveryModule is SimpleRecoveryModuleManager, ERC7579ExecutorBase { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONSTANTS & STORAGE */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + // Validator address for executing recovery requests + address public immutable validator; + + // Function selector that should match the recovery function call + bytes4 public immutable selector; + + // Errors + error InvalidOnInstallData(); + error InvalidValidator(address validator); + + /** + * @notice Constructor to initialize the recovery module with required parameters + * @param verifier Address responsible for verification + * @param dkimRegistry Address of the DKIM registry + * @param emailAuthImpl Implementation of email-based authentication + * @param commandHandler Address responsible for handling commands + * @param minimumDelay Minimum delay before executing recovery + * @param killSwitchAuthorizer Address that can trigger the kill switch + * @param _validator Address to which recovery transactions are executed + * @param _selector Function selector for validation + */ + constructor( + address verifier, + address dkimRegistry, + address emailAuthImpl, + address commandHandler, + uint256 minimumDelay, + address killSwitchAuthorizer, + address _validator, + bytes4 _selector + ) + SimpleRecoveryModuleManager( + verifier, + dkimRegistry, + emailAuthImpl, + commandHandler, + minimumDelay, + killSwitchAuthorizer + ) + { + validator = _validator; + selector = _selector; + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONFIG */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Initializes the module with the threshold, guardians, and other configuration + * @dev Cannot be installed during account deployment due to validation rules. Install after setup. + * @param data Encoded data for recovery configuration + */ + function onInstall(bytes calldata data) external { + if (data.length == 0) revert InvalidOnInstallData(); + + ( + bytes memory isInstalledContext, + address[] memory guardians, + uint256[] memory weights, + ISimpleGuardianManager.GuardianType[] memory guardianTypes, + uint256 threshold, + uint256 delay, + uint256 expiry + ) = abi.decode(data, (bytes, address[], uint256[], ISimpleGuardianManager.GuardianType[], uint256, uint256, uint256)); + + configureRecovery(guardians, weights, guardianTypes, threshold, delay, expiry); + } + + /** + * @notice De-initializes the recovery module and clears stored configuration + */ + function onUninstall(bytes calldata /* data */) external { + deInitRecoveryModule(); + } + + /** + * @notice Checks if the recovery module is initialized for a specific account + * @param account The account address to check + * @return bool True if the module is initialized, false otherwise + */ + function isInitialized(address account) external view returns (bool) { + return getGuardianConfig(account).threshold != 0; + } + + /** + * @notice Returns the type of the module + * @param typeID Type ID of the module + * @return bool True if the type is an executor module, false otherwise + */ + function isModuleType(uint256 typeID) external pure returns (bool) { + return typeID == TYPE_EXECUTOR; + } + + /** + * @notice Checks if a recovery request can be started based on guardian acceptance + * @param account The account to check + * @return bool True if the recovery request can be started, false otherwise + */ + function canStartRecoveryRequest(address account) external view returns (bool) { + GuardianConfig memory guardianConfig = getGuardianConfig(account); + return guardianConfig.threshold > 0 && guardianConfig.acceptedWeight >= guardianConfig.threshold; + } + + /** + * @notice Processes the recovery request + * @param account The account to recover + * @param recoveryData Encoded recovery data + */ + function recover(address account, bytes calldata recoveryData) internal virtual override { + (, bytes memory recoveryCalldata) = abi.decode(recoveryData, (address, bytes)); + + // Extract function selector from the calldata + bytes4 calldataSelector; + assembly { + calldataSelector := mload(add(recoveryCalldata, 32)) + } + + // Validate that the selector matches the expected recovery selector + if (calldataSelector != selector) { + revert InvalidSelector(); + } + + // Execute the recovery transaction + _execute({ + account: account, + to: validator, + value: 0, + data: recoveryCalldata + }); + + emit RecoveryExecuted(account, account); + } + + /** + * @notice Helper function for testing recovery processing + * @param guardian Address of the guardian initiating the request + * @param templateIdx Index of the recovery template + * @param commandParams Parameters passed to the recovery command + */ + function testProcessRecovery( + address guardian, + uint256 templateIdx, + bytes[] memory commandParams + ) external { + processRecovery(guardian, templateIdx, commandParams, ""); + } +} diff --git a/src/simpleRecoveryPrototype/test/FullRecoveryTest.t.sol b/src/simpleRecoveryPrototype/test/FullRecoveryTest.t.sol new file mode 100644 index 0000000..5040245 --- /dev/null +++ b/src/simpleRecoveryPrototype/test/FullRecoveryTest.t.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {ModuleKitHelpers} from "modulekit/ModuleKit.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {BaseTest} from "./SimpleRecoveryBaseTest.t.sol"; +import {EmailAuthMsg, EmailProof} from "@zk-email/ether-email-auth-contracts/src/EmailAuth.sol"; +import {MODULE_TYPE_EXECUTOR, MODULE_TYPE_VALIDATOR} from "modulekit/accounts/common/interfaces/IERC7579Module.sol"; + +// @notice Test contract for recovery functionality that extends BaseTest +// @dev Tests the full recovery process including EOA and email-based guardian acceptance +contract RecoveryTest is BaseTest { + using Strings for uint256; + string private constant NAME = "EOAGuardianVerifier"; + string private constant VERSION = "1.0.0"; + + /// @dev EIP-712 Domain typehash: + /// keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") + bytes32 private constant EIP712_DOMAIN_TYPEHASH = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + + /// @dev Sample typed struct typehash: + /// keccak256("GuardianAcceptance(address recoveredAccount,uint256 templateIdx,bytes32 commandParamsHash)") + bytes32 private constant GUARDIAN_TYPEHASH = keccak256( + "GuardianAcceptance(address recoveredAccount,uint256 templateIdx,bytes32 commandParamsHash, uint256 nonce)" + ); + + string public recoveryDataHashString; + bytes[] public commandParams; + + // @notice Sets up the test environment by installing necessary modules + // @dev Installs validator and executor modules with required configurations + function setUp() public override { + super.setUp(); + + bytes memory isInstalledContext = ""; + bytes memory data = abi.encode( + isInstalledContext, + guardians, + weights, + guardianTypes, + threshold, + delay, + expiry + ); + + vm.prank(owner1); + + ModuleKitHelpers.installModule({ + instance: instance2, + moduleTypeId: MODULE_TYPE_VALIDATOR, + module: accountAddress1, + data: abi.encode(owner1) + }); + ModuleKitHelpers.installModule({ + instance: instance2, + moduleTypeId: MODULE_TYPE_EXECUTOR, + module: address(recoveryModule), + data: data + }); + } + + // @notice Tests the complete recovery process + // @dev Includes EOA guardian acceptance, email guardian acceptance, and recovery completion + // The function tests: + // 1. EOA guardian acceptance with signature verification + // 2. Email guardian acceptance with proof verification + // 3. Recovery process initiation + // 4. Recovery completion and ownership transfer + function testFullRecoveryProcess() public { + uint256 templateId = uint256(keccak256(abi.encode(1, "ACCEPTANCE", 0))); + bytes32 nullifier = bytes32(uint256(1)); + + bytes[] memory acceptanceParams = new bytes[](1); + acceptanceParams[0] = abi.encode(owner1); + + { + uint256 privateKey1 = uint256( + keccak256(abi.encodePacked("eoaGuardian1")) + ); + bytes memory encodedParams = abi.encode(acceptanceParams); + + bytes32 commandParamsHash = keccak256(encodedParams); + + uint256 templateIdx = 0; + + bytes32 structHash = keccak256( + abi.encode( + GUARDIAN_TYPEHASH, + owner1, + templateIdx, + commandParamsHash, + recoveryModule.getNonce(eoaGuardian1, owner1) + ) + ); + + bytes32 domainSeparator = recoveryModule.getDomainSeparator(); + + bytes32 digest = keccak256( + abi.encodePacked("\x19\x01", domainSeparator, structHash) + ); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + privateKey1, + digest); + + bytes memory signature = abi.encodePacked(r, s, v); + + vm.prank(eoaGuardian1); + recoveryModule.handleEOAAcceptance(templateIdx,acceptanceParams, signature); + } + + { + string memory exactCommand = string( + abi.encodePacked( + "Accept guardian request for ", + Strings.toHexString(uint160(owner1)) + ) + ); + EmailProof memory proof1 = generateMockEmailProof( + exactCommand, + + nullifier, + accountSalts[1] + ); + + EmailAuthMsg memory emailAuthMsg1 = EmailAuthMsg({ + templateId: templateId, + commandParams: acceptanceParams, + skippedCommandPrefix: 0, + proof: proof1 + }); + vm.prank(emailGuardian1); + recoveryModule.handleEmailAuthAcceptance(emailAuthMsg1, 0); + } + + vm.prank(eoaGuardian1); + + commandParams = new bytes[](2); + commandParams[0] = abi.encode(owner1); + recoveryDataHashString = uint256(recoveryDataHash).toHexString(32); + commandParams[1] = abi.encode(recoveryDataHashString); + + recoveryModule.testProcessRecovery(eoaGuardian1, 0, commandParams); + vm.prank(emailGuardian1); + recoveryModule.testProcessRecovery(emailGuardian1, 0, commandParams); + + (uint256 executeAfter, , , ) = recoveryModule.getRecoveryRequest( + owner1 + ); + + vm.warp(executeAfter + 1); + + recoveryCallData = abi.encodeWithSelector( + RECOVERY_SELECTOR, + newOwner + ); + bytes memory recoveryData1 = abi.encode( + accountAddress1, + recoveryCallData + ); + + vm.prank(address(0x123)); + recoveryModule.completeRecovery(owner1, recoveryData1); + + address newOwnerAccount = validator.owners(owner1); + assertEq( + newOwnerAccount, + newOwner, + "Recovery did not set the new owner correctly" + ); + + emit log("Recovery completed successfully"); + } +} \ No newline at end of file diff --git a/src/simpleRecoveryPrototype/test/SimpleRecoveryBaseTest.t.sol b/src/simpleRecoveryPrototype/test/SimpleRecoveryBaseTest.t.sol new file mode 100644 index 0000000..fbd5827 --- /dev/null +++ b/src/simpleRecoveryPrototype/test/SimpleRecoveryBaseTest.t.sol @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { Test } from "forge-std/Test.sol"; +import { RhinestoneModuleKit, AccountInstance } from "modulekit/ModuleKit.sol"; +import { SimpleRecoveryModule } from "../modules/SimpleRecoveryModule.sol"; +import { EmailRecoveryCommandHandler } from "../../handlers/EmailRecoveryCommandHandler.sol"; +import "../interfaces/ISimpleGuardianManager.sol"; +import { + EmailAuth, + EmailAuthMsg, + EmailProof +} from "@zk-email/ether-email-auth-contracts/src/EmailAuth.sol"; +import { CommandUtils } from "@zk-email/ether-email-auth-contracts/src/libraries/CommandUtils.sol"; +import { UserOverrideableDKIMRegistry } from "@zk-email/contracts/UserOverrideableDKIMRegistry.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { MockGroth16Verifier } from "src/test/MockGroth16Verifier.sol"; +import { OwnableValidator } from "src/test/OwnableValidator.sol"; + +abstract contract BaseTest is RhinestoneModuleKit, Test { + using Strings for uint256; + + // Core contracts + address public zkEmailDeployer; + SimpleRecoveryModule public recoveryModule; + UserOverrideableDKIMRegistry public dkimRegistry; + MockGroth16Verifier public verifier; + EmailAuth public emailAuthImpl; + + OwnableValidator public validator; + address public accountAddress1; + + address public eoaGuardian1; + address public eoaGuardian2; + + address public emailGuardian1; + + address public owner1; + address public newOwner; + + AccountInstance public instance1; + AccountInstance public instance2; + + address public accountAddress2; + + address public killSwitchAuthorizer; + + // public Account salts + bytes32 public accountSalt1; + bytes32 public accountSalt2; + bytes32 public accountSalt3; + + // Configuration + bytes32[] public accountSalts; + address[] public guardians; + uint256[] public weights; + ISimpleGuardianManager.GuardianType[] public guardianTypes; + + uint256 public threshold; + uint256 public delay; + uint256 public expiry; + uint256 public totalWeight; + + // Email verification constants + string public constant DOMAIN_NAME = "gmail.com"; + bytes32 public constant PUBLIC_KEY_HASH = 0x0ea9c777dc7110e5a9e89b13f0cfc540e3845ba120b2b6dc24024d61488d4788; + uint256 public constant MINIMUM_DELAY = 12 hours; + bytes4 public constant RECOVERY_SELECTOR = bytes4(keccak256(bytes("changeOwner(address)"))); + + EmailRecoveryCommandHandler public commandHandler; + address public simpleRecoveryaddress; + + bytes public recoveryData; + bytes32 public recoveryDataHash; + + bytes public recoveryCallData; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* TEST SETUP FUNCTION */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + /** + * @notice Initializes the testing environment by setting up the account instances, + * deploying contracts, and configuring the recovery module with mock data. + * + * @dev This function: + * - Creates mock accounts (owner1 and newOwner) and funds them. + * - Deploys core contracts such as `OwnableValidator`, `EmailAuth`, `DKIMRegistry`, + * `EmailRecoveryCommandHandler`, and `SimpleRecoveryModule`. + * - Configures the `recoveryModule` with mixed guardians (EOA and EmailVerified). + * - Prepares the initial recovery data for tests. + */ + function setUp() public virtual { + init(); + + newOwner = vm.createWallet("newOwner").addr; + + // Deploy and fund the accounts + instance1 = makeAccountInstance("account1"); + instance2 = makeAccountInstance("account2"); + + validator = new OwnableValidator(); + accountAddress1 = address(validator); + owner1 = instance2.account; + + vm.deal(address(instance2.account), 10 ether); + + accountSalt1 = keccak256(abi.encode("account salt 1")); + accountSalt2 = keccak256(abi.encode("account salt 2")); + accountSalt3 = keccak256(abi.encode("account salt 3")); + + zkEmailDeployer = vm.addr(1); + killSwitchAuthorizer = vm.addr(2); + + vm.startPrank(zkEmailDeployer); + + uint256 setTimeDelay = 0; + UserOverrideableDKIMRegistry overrideableDkimImpl = new UserOverrideableDKIMRegistry(); + ERC1967Proxy dkimProxy = new ERC1967Proxy( + address(overrideableDkimImpl), + abi.encodeCall( + overrideableDkimImpl.initialize, (zkEmailDeployer, zkEmailDeployer, setTimeDelay) + ) + ); + dkimRegistry = UserOverrideableDKIMRegistry(address(dkimProxy)); + + dkimRegistry.setDKIMPublicKeyHash(DOMAIN_NAME, PUBLIC_KEY_HASH, zkEmailDeployer, new bytes(0)); + + verifier = new MockGroth16Verifier(); + emailAuthImpl = new EmailAuth(); + vm.stopPrank(); + + eoaGuardian1 = makeAddr("eoaGuardian1"); + eoaGuardian2 = makeAddr("eoaGuardian2"); + + commandHandler = new EmailRecoveryCommandHandler(); + + accountSalts = new bytes32[](3); + accountSalts[0] = keccak256(abi.encode("salt1")); + accountSalts[1] = keccak256(abi.encode("salt2")); + accountSalts[2] = keccak256(abi.encode("salt3")); + + emailAuthImpl = new EmailAuth(); + + // Prepare recovery data + recoveryData = abi.encode( + address(owner1), + abi.encodeWithSelector(RECOVERY_SELECTOR, accountAddress1) + ); + recoveryDataHash = keccak256(recoveryData); + + // Deploy recovery module + recoveryModule = new SimpleRecoveryModule( + address(verifier), + address(dkimRegistry), + address(emailAuthImpl), // emailAuthImpl + address(commandHandler), // commandHandler + MINIMUM_DELAY, + killSwitchAuthorizer, + address(accountAddress1), // Initial validator + RECOVERY_SELECTOR + ); + + // Compute email guardian addresses + emailGuardian1 = recoveryModule.computeEmailAuthAddress( + address(owner1), + accountSalts[1] + ); + + // Setup mixed guardians (2 EOA, 1 Email) + guardians = new address[](3); + weights = new uint256[](3); + guardianTypes = new ISimpleGuardianManager.GuardianType[](3); + + // EOA Guardian 1 + guardians[0] = eoaGuardian1; + weights[0] = 1; + guardianTypes[0] = ISimpleGuardianManager.GuardianType.EOA; + + // EOA Guardian 2 + guardians[1] = eoaGuardian2; + weights[1] = 1; + guardianTypes[1] = ISimpleGuardianManager.GuardianType.EOA; + + // Email Guardian + guardians[2] = emailGuardian1; + weights[2] = 1; + guardianTypes[2] = ISimpleGuardianManager.GuardianType.EmailVerified; + + // Set recovery configuration + threshold = 2; + delay = 1 days; + expiry = 7 days; + // simpleRecoveryaddress = address(recoveryModule); + } + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* GENERATE MOCK EMAIL PROOF */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + /** + * @notice Generates a mock `EmailProof` for testing email verification logic. + * + * @dev This function creates a proof with pre-defined data for: + * - Domain name (`gmail.com`) and a public key hash (`PUBLIC_KEY_HASH`). + * - Command string (`maskedCommand`) for validation. + * - Nullifier to prevent replay attacks. + * - Account salt for generating unique proofs. + * + * @param command The command string used to create the masked proof. + * @param nullifier The nullifier that makes each proof unique and prevents reuse. + * @param accountSalt The account-specific salt used in the verification process. + * @return emailProof A populated `EmailProof` struct. + */ + + function generateMockEmailProof( + string memory command, + bytes32 nullifier, + bytes32 accountSalt + ) + public + view + returns (EmailProof memory) + { + EmailProof memory emailProof; + emailProof.domainName = "gmail.com"; + emailProof.publicKeyHash = PUBLIC_KEY_HASH; + emailProof.timestamp = block.timestamp; + emailProof.maskedCommand = command; + emailProof.emailNullifier = nullifier; + emailProof.accountSalt = accountSalt; + emailProof.isCodeExist = true; + emailProof.proof = bytes("0"); + + return emailProof; + } +} \ No newline at end of file