-
Notifications
You must be signed in to change notification settings - Fork 5.4k
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
Add EIP-5131: ENS Subdomain Authentication #5131
Changes from 30 commits
02fdd31
d926f0a
3e3b45c
069848e
03d63ba
31fd495
d779068
a24fa63
e51e8ed
5676cce
3c70189
c0c8ded
713c03f
51a0753
dc3ad8a
1c3abd7
54a20ef
00458df
47558a2
8360e7e
bb9dace
fc02098
248dad3
c1b51af
b59c292
9bd601c
7d59810
ace2c39
8ce77e1
1f7f35c
0b880a1
43f8ba4
bf02f83
c3c8687
a9fe2e6
a7c605a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,294 @@ | ||
--- | ||
eip: 5131 | ||
title: ENS Subdomain Authentication | ||
description: Using ENS subdomains to facilitate safer and more convenient signing operations. | ||
author: Wilkins Chung (@wwhchung) | ||
discussions-to: https://ethereum-magicians.org/t/eip-5131-ens-subdomain-authentication/9458 | ||
status: Draft | ||
type: Standards Track | ||
category: ERC | ||
created: 2022-06-03 | ||
requires: 137 | ||
--- | ||
|
||
## Abstract | ||
This EIP links one or more signing wallets via Ethereum Name Service Specification ([EIP-137](./eip-137.md)) to prove control and asset ownership of a main wallet. | ||
|
||
## Motivation | ||
Proving ownership of an asset to a third party application in the Ethereum ecosystem is common. Users frequently sign payloads of data to authenticate themselves before gaining access to perform some operation. However, this method--akin to giving the third party root access to one's main wallet--is both insecure and inconvenient. | ||
|
||
*** Examples: *** | ||
- In order for you to edit your profile on OpenSea, you must sign a message with your wallet address. | ||
- In order to access NFT gated content, you must sign a message with the wallet containing the NFT. | ||
- In order to claim an airdrop, you must interact with the smart contract with the qualifying wallet address. | ||
- In order to prove ownership of an NFT, you must sign a payload with the address that owns that NFT. | ||
|
||
### Problems with existing methods and solutions | ||
Unfortunately, we've seen many cases where users have accidentally signed a malicious payload. The result is almost always a significant loss of assets associated with the signing address. | ||
|
||
In addition to this, many users keep significant portions of their assets in 'cold storage'. With the increased security from 'cold storage' solutions, we usually see decreased accessibility because users naturally increase the barriers required to access these wallets. | ||
|
||
Some solutions propose dedicated registry smart contracts to create this link, or new protocols to be supported. This is problematic from an adoption standpoint, and there have not been any standards created for them. | ||
|
||
### Proposal: Use the Ethereum Name Service (EIP-137) | ||
Rather than 're-invent the wheel', this proposal aims to use the widely adopted Ethereum Name Service in order to bootstrap a safer and more convenient way to sign and authenticate, and provide 'read only' access to a main wallet. | ||
|
||
From there, the benefits are twofold. This EIP gives users increased security via outsourcing potentially malicious signing operations to wallets that are more accessible (hot wallets), while being able to maintain the intended security assumptions of wallets that are not frequently used for signing operations. | ||
|
||
|
||
## Specification | ||
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119. | ||
|
||
|
||
Let: | ||
- `mainAddress` represent the wallet address we are trying to authenticate or prove asset ownership for. | ||
- `mainENS` represent the reverse lookup ENS string for `mainAddress`. | ||
- `authAddress` represent the address we want to use for signing in lieu of `mainAddress`. | ||
- `authENS` represent the reverse lookup ENS string for `authAddress`. It must be in the format `auth[0-9A-za-z]*.<mainENS>`. | ||
|
||
|
||
### Setting up one or many `authAddress` records | ||
The pre-requisite assumes that the `mainAddress` has an ENS ETH resolver record and reverse record configured. | ||
|
||
1. Using your `mainAddress` wallet create a subdomain record for `mainENS` called `auth[0-9A-Za-z]*`. This becomes the `authENS` | ||
2. Set the ETH resolver record for the subdomain created in step 1 (`authENS`) to the `authAddress`. | ||
3. Using `authAddress`, set the ENS reverse record to `authENS` | ||
|
||
Currently this EIP does not enforce an upper-bound on the number of `authAddress` entries you can include. Users can repeat this process with as many address as they like. | ||
|
||
### Authenticating `mainAddress` via `authAddress` | ||
Control of `mainAddress` and ownership of `mainAddress` assets is proven if any one of associated `authAddress` is the msg.sender or has signed the message. | ||
|
||
Practically, this would work by performing the following operations: | ||
1. Get the reverse ENS record for `authAddress` | ||
2. Parse `auth[0-9A-Za-z]*.<mainENS>` to determine the linked ENS | ||
3. Do a lookup on the linked ENS record to determine the linked `mainAddress` | ||
4. MUST get the reverse ENS record for `mainAddress` and verify that it matches `<mainENS>` | ||
- Otherwise one could set up other ENS nodes (with auths) that point to `mainAddress` and authenticate via those. | ||
|
||
Note that this specification allows for both contract level and client/server side validation of signatures. It is not limited to smart contracts, which is why there is no proposed external interface definition. | ||
wwhchung marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
wwhchung marked this conversation as resolved.
Show resolved
Hide resolved
|
||
## Rationale | ||
The proposed specification allows one to link multiple addresses as 'authentication addresses' to a core main address. This is beneficial from a security standpoint (if the authentication address is compromised, the assets held by the main address is not), and convenience (if the authentication address is a simple MetaMask wallet but the main address is a hardware wallet). | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So I think this section is reading more like motivation than rationale. I think you should consider moving a fair bit of this content above and focus on answering why certain design decisions were chosen over other decisions here. |
||
|
||
### Example 1: Event Access | ||
An event requires attendees to prove ownership of an NFT to gain access. The attendee in question only has their phone available to perform a signing operation upon the presentation of a challenge. The phone is considered the attendee's 'hot wallet' which is linked to `auth.wilkins.eth`. | ||
The NFT is owned by the Ethereum address that corresponds to `wilkins.eth`, which is controlled by the attendee's cold storage device (Ledger, Trezor etc). The attendee does not have this device present at the event. | ||
The attendee is able to prove ownership of the NFT by signing with the 'hot wallet' key. | ||
|
||
This is made possible because the attendee completed the necessary upfront authorization steps with the cold storage device to allow the 'hot wallet' to be used for this operation. | ||
|
||
### Example 2: Multiple Devices | ||
I have multiple devices and wish to sign with any of them for convenience. In this situation: | ||
- My phone has mobile metamask hot wallet, and I can add it as an auth account by setting auth1.wilkins.eth to that wallet (and the corresponding reverse record). | ||
- My tablet has the same and can be set to auth2.wilkins.eth, etc. | ||
|
||
Lost Access to Signing Key/Device: | ||
In the event that a user losses access to the signing key of an `authAddress` they can simply delete the corresponding ENS record. An attractive side effect of this is that there is no need to import/reimport seed phrases. | ||
In the unfortunate event that you lose access to the signing key of the `mainAddress`, users will lose access to that top-level domain and all related subdomains. As always, this private key data should be treated as highly sensitive. | ||
|
||
|
||
## Reference Implementation | ||
|
||
### Client/Server Side | ||
In typescript, the validation function, using ethers.js would be as follows: | ||
``` | ||
export interface LinkedAddress { | ||
ens: string, | ||
address: string, | ||
} | ||
|
||
async function getLinkedAddress(provider: ethers.providers.Provider, address: string): Promise<LinkedAddress | null> { | ||
wwhchung marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const addressENS = await provider.lookupAddress(address); | ||
if (!addressENS) return null; | ||
|
||
const authMatch = addressENS.match(/^(auth[0-9A-Za-z]*)\.(.*)/); | ||
if (!authMatch) return null; | ||
|
||
const linkedENS = authMatch[2]; | ||
const linkedAddress = await provider.resolveName(linkedENS); | ||
|
||
if (!linkedAddress) return null; | ||
|
||
return { | ||
ens: linkedENS, | ||
address: linkedAddress | ||
}; | ||
} | ||
``` | ||
|
||
### Contract side | ||
|
||
#### With a secure server | ||
If your application operates a secure backend server, you could run the client/server code above, then use the result in conjunction with specs like [EIP-1271](./eip-1271.md) : `Standard Signature Validation Method for Contracts` for a cheap and secure way to validate that the the message signer is indeed authenticated for the main address. | ||
|
||
#### Without a secure server (web client only) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have trouble understanding what is meant by
wwhchung marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Provided is a refrence implementation for an internal function to verify that the message sender has an authentication link to the main address. | ||
wwhchung marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
``` | ||
// SPDX-License-Identifier: MIT | ||
|
||
pragma solidity ^0.8.0; | ||
|
||
/// @author: manifold.xyz | ||
|
||
/** | ||
* ENS Registry Interface | ||
*/ | ||
interface ENS { | ||
function resolver(bytes32 node) external view returns (address); | ||
} | ||
|
||
/** | ||
* ENS Resolver Interface | ||
*/ | ||
interface Resolver{ | ||
function addr(bytes32 node) external view returns (address); | ||
function name(bytes32 node) external view returns (string memory); | ||
} | ||
|
||
/** | ||
* Validate a signing address is associtaed with a linked address | ||
*/ | ||
library LinkedAddress { | ||
/** | ||
* Validate that the message sender is an authentication address for mainAddress | ||
* @param ensRegistry Address of ENS registry | ||
* @param authENSLabel The ENS label of the authentication wallet (must be `auth[0-9A-Za-z]*`) | ||
* @param mainAddress The main address we want to authenticate for. | ||
* @param mainENSParts The array of the main address ENS domain parts (e.g. wilkins.eth == ['wilkins', 'eth']). | ||
* This is used vs. the full ENS a a single string name hash computations are gas efficient. | ||
*/ | ||
function validateSender( | ||
address ensRegistry, | ||
bytes calldata authENSLabel, | ||
address mainAddress, | ||
string[] calldata mainENSParts | ||
) internal view returns (bool) { | ||
return validate(ensRegistry, msg.sender, authENSLabel, mainAddress, mainENSParts); | ||
} | ||
|
||
/** | ||
* Validate that the authAddress is an authentication address for mainAddress | ||
* | ||
* @param ensRegistry Address of ENS registry | ||
* @param authAddress The address of the authentication wallet | ||
* @param authENSLabel The ENS label of the authentication wallet (must be `auth[0-9A-Za-z]*`) | ||
* @param mainAddress The main address we want to authenticate for. | ||
* @param mainENSParts The array of the main address ENS domain parts (e.g. wilkins.eth == ['wilkins', 'eth']). | ||
* This is used vs. the full ENS a a single string name hash computations are gas efficient. | ||
*/ | ||
function validate( | ||
address ensRegistry, | ||
address authAddress, | ||
bytes calldata authENSLabel, | ||
address mainAddress, | ||
string[] calldata mainENSParts | ||
) internal view returns (bool) { | ||
// Check if the ENS nodes resolve correctly to the provided addresses | ||
bytes32 mainNameHash = _computeNamehash(mainENSParts); | ||
address mainResolver = ENS(ensRegistry).resolver(mainNameHash); | ||
require(mainResolver != address(0), "Main ENS not registered"); | ||
require(mainAddress == Resolver(mainResolver).addr(mainNameHash), "Main address is wrong"); | ||
|
||
bytes32 mainReverseHash = _computeReverseNamehash(mainAddress); | ||
address mainReverseResolver = ENS(ensRegistry).resolver(mainReverseHash); | ||
require(mainReverseResolver != address(0), "Main ENS reverse lookup not registered"); | ||
|
||
// Verify that the reverse lookup for mainAddress matches the mainENSParts | ||
{ | ||
uint256 len = mainENSParts.length; | ||
bytes memory ensCheckBuffer = bytes(mainENSParts[0]); | ||
unchecked { | ||
for (uint256 idx = 1; idx < len; ++idx) { | ||
ensCheckBuffer = abi.encodePacked(ensCheckBuffer, ".", mainENSParts[idx]); | ||
} | ||
} | ||
require( | ||
keccak256(abi.encodePacked(Resolver(mainReverseResolver).name(mainReverseHash))) == | ||
keccak256(ensCheckBuffer), | ||
"Main ENS mismatch" | ||
); | ||
} | ||
|
||
bytes32 authNameHash = _computeNamehash(mainNameHash, string(authENSLabel)); | ||
address authResolver = ENS(ensRegistry).resolver(authNameHash); | ||
require(authResolver != address(0), "Auth ENS not registered"); | ||
require(authAddress == Resolver(authResolver).addr(authNameHash), "Not authenticated"); | ||
|
||
// Check that the subdomain name has the correct format auth[0-9A-Za-z]*. | ||
bytes4 authPart = bytes4(authENSLabel[:4]); | ||
require(authPart == "auth", "Invalid prefix"); | ||
unchecked { | ||
for (uint256 i = authENSLabel.length; i > 4; i--) { | ||
bytes1 char = authENSLabel[i]; | ||
require( | ||
(char >= 0x30 && char <= 0x39) || | ||
(char >= 0x41 && char <= 0x5A) || | ||
(char >= 0x61 && char <= 0x7A), | ||
"Invalid char" | ||
); | ||
} | ||
} | ||
|
||
return true; | ||
} | ||
|
||
// ********************* | ||
// Helper Functions | ||
// ********************* | ||
|
||
function _computeNamehash(string[] calldata _nameParts) | ||
private | ||
pure | ||
returns (bytes32 namehash) | ||
{ | ||
namehash = 0x0000000000000000000000000000000000000000000000000000000000000000; | ||
unchecked { | ||
for (uint256 i = _nameParts.length; i > 0; --i) { | ||
namehash = _computeNamehash(namehash, _nameParts[i - 1]); | ||
} | ||
} | ||
} | ||
|
||
function _computeNamehash(bytes32 parentNamehash, string calldata name) | ||
private | ||
pure | ||
returns (bytes32 namehash) | ||
{ | ||
namehash = keccak256(abi.encodePacked(parentNamehash, keccak256(bytes(name)))); | ||
} | ||
|
||
// _computeNamehash('addr.reverse') | ||
bytes32 constant ADDR_REVERSE_NODE = | ||
0x91d1777781884d03a6757a803996e38de2a42967fb37eeaca72729271025a9e2; | ||
|
||
function _computeReverseNamehash(address _address) private pure returns (bytes32 namehash) { | ||
namehash = keccak256(abi.encodePacked(ADDR_REVERSE_NODE, sha3HexAddress(_address))); | ||
} | ||
|
||
function sha3HexAddress(address addr) private pure returns (bytes32 ret) { | ||
assembly { | ||
let lookup := 0x3031323334353637383961626364656600000000000000000000000000000000 | ||
let i := 40 | ||
for { | ||
|
||
} gt(i, 0) { | ||
|
||
} { | ||
i := sub(i, 1) | ||
mstore8(i, byte(and(addr, 0xf), lookup)) | ||
addr := div(addr, 0x10) | ||
} | ||
ret := keccak256(0, 40) | ||
} | ||
} | ||
} | ||
``` | ||
|
||
## Security Considerations | ||
The core purpose of this EIP is to enhance security and promote a safer way to authenticate wallet control and asset ownership when the main wallet is not needed and assets held by the main wallet do not need to be moved. Consider it a way to do 'read only' authentication. | ||
|
||
|
||
## Copyright | ||
Copyright and related rights waived via [CC0](../LICENSE.md). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This EIP seems highly useful given these examples. At the moment there is a real pain point of holding assets securely while still being able to prove ownership via a hot wallet especially in mobile settings. I'd be excited to see this proposal go through