Not sure if this has been discussed before but the idea is that the contract-based account owner (Alice) can generate a merkle root based on her address book list so when Alice loses her management keys to the account contract, she can have n of m address book peer signatures submitted to set a new management key owned by Alice.
The merkle root is stored in the account contract and no other information about the address book list is required or stored on-chain. Alice selects a subset of trusted peers from her address book, generates the merkle root with the addresses as tree leaves, and sets the root on-chain. The address list used can be encrypted and emailed to her since she’ll need it when it’s time to recover the account.
Let’s say the address book list she used has the addresses of Bob, Charlie, Dave, Eve, and Frank, and requirs 2 of n signatures to set a new management key.
- Alice loses her management keys and asks Eve to help her recover her account.
- Alice gives Eve a newly generated address she controls and Eve takes a hash of the public address and signs the hash with her private key corresponding to the public address that Alice has stored for Eve in Alice’s address book.
- Alice takes Eve’s signature and generates a merkle proof using her trusted peer address book list and Eve’s address as the leaf.
- Alice submits the signature, data, and proof to her contract. The contract verifies that Eve is indeed part of the stored merkle proof and the submission is recorded in a mapping.
- Alice then asks Bob to help her recover the account. Bob signs the hash of the new address and Alice submits the same pieces of data to the contract.
- The contract verifies that Bob is part of the stored merkle proof, and then checks if the threshold has been met.
- If the signatures required threshold has been met, then it verifies that all the signers agreed on the same new management key address.
- If that succeeds, then the new management key is set and Alice can access her contract-based account again.
To demonstrate, here’s a rough proof-of-concept contract in Solidity:
pragma solidity ^0.5.2;
import './ECDSA.sol';
import './MerkleProof.sol';
contract Account {
using ECDSA for *;
using MerkleProof for *;
address public owner;
bytes32 public recoveryRoot;
uint256 public sigsRequired;
uint256 public seq;
mapping (uint256 => address) sigs;
modifier isOwner {
require(msg.sender == owner, "Invalid sender");
_;
}
constructor() public {
owner = msg.sender;
sigsRequired = 1;
}
function setOwner(address newOwner) external isOwner {
owner = newOwner;
}
function setRecoveryRoot(bytes32 root) external isOwner {
recoveryRoot = root;
}
function setSigsRequired(uint256 num) external isOwner {
sigsRequired = num;
}
function recover(bytes32[] memory proof, bytes memory signature, address newOwner) public {
bytes32 hash = keccak256(abi.encodePacked(newOwner));
address recoveryKey = hash.toEthSignedMessageHash().recover(signature);
bytes32 leaf = keccak256(abi.encodePacked(recoveryKey));
require(proof.verify(recoveryRoot, leaf), "Invalid proof");
sigs[seq] = newOwner;
seq++;
if (seq == sigsRequired) {
address proposedOwner;
for (uint8 i = 0; i < seq; i++) {
if (i > 0 && proposedOwner != sigs[i]) {
revert("Invalid new owner");
}
proposedOwner = sigs[i];
}
owner = proposedOwner;
seq = 0;
}
}
}
The test would go as follows (pseudocode):
const addressBook = [Bob, Charlie, Dave, Eve, Frank]
const leaves = addressBook.map(x => keccak256(x)).sort()
const tree = new MerkleTree(leaves, keccak256)
const root = tree.getRoot()
await contract.setRecoveryRoot(root)
await contract.setSigsRequired(2)
const hash = keccak256(AliceNewKey)
const EveSig = web3.eth.sign(hash, Eve)
const EveProof = tree.getProof(keccak256(Eve))
await contract.recover(EveProof, EveSig, AliceNewKey)
const BobSig = web3.eth.sign(hash, Bob)
const BobProof = tree.getProof(keccak256(Bob))
await contract.recover(BobProof, BobSig, AliceNewKey)
assert.equal(await contract.owner.call(), AliceNewKey)
Working example code is on github.
Things to note:
- A benefit of this method is that it doesn’t require pre-approval of your address list beforehand, so there is no awkward UX issues, and recovery is as simple as asking the peers to sign a hash of a public address, which then you or a relayer can submit onchain.
- Address book list is never exposed until it’s time for recovery, then the senders will of course be exposed.
- Verifying merkle proof on-chain is expensive but recovery is something that should happen infrequently so this is fine.
- To make it more secure, a timelock period can be initiated after the threshold is met to allow the owner to cancel the recovery and set a new merkle root in the case the peers collude which prevents them from immediately setting the new management. A recommended number would be to require at least 2 peers and 1 hardware device so in the case the peers collude they still need the hardware device signature, and in the case the hardware device is compromised then the peer signatures are still required. The contract can maintain two merkle roots, one consisting of friends and family and one consisting of hardware device keys, both with their own thresholds.
- The address book can be maintained in decentralized fashion, such as using 3Box’s private storage, making the user’s address book be portable.
- Instead of addreses, using ENS names can also work by resolving the name onchain when checking the recovered signer.
Would like to open up the discussion and hear what you guys think about all this. Thanks!