SAFE Authentication For ENS
Abstract
This EIP links one or more signing wallets via Ethereum Name Service Specification (EIP-137) 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.
- In order to access NFT gated content, you must sign a message with the wallet containing the NFT in order to prove ownership.
- In order to gain access to an event, you must sign a message with the wallet containing a required NFT in order to prove ownership.
- In order to claim an airdrop, you must interact with the smart contract with the qualifying wallet.
- In order to prove ownership of an NFT, you must sign a payload with the wallet that owns that NFT.
In all the above examples, one interacts with the dApp or smart contract using the wallet itself, which may be
- inconvenient (if it is controlled via a hardware wallet or a multi-sig)
- insecure (since the above operations are read-only, but you are signing/interacting via a wallet that has write access)
Instead, one should be able to approve multiple wallets to authenticate on behalf of a given wallet.
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 conjunction with the ENS Text Records feature (EIP-634) in order to achieve a safer and more convenient way to sign and authenticate, and provide ‘read only’ access to a main wallet via one or more secondary wallets.
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.
Improving dApp Interaction Security
Many dApps requires one to prove control of a wallet to gain access. At the moment, this means that you must interact with the dApp using the wallet itself. This is a security issue, as malicious dApps or phishing sites can lead to the assets of the wallet being compromised by having them sign malicious payloads.
However, this risk would be mitigated if one were to use a secondary wallet for these interactions. Malicious interactions would be isolated to the assets held in the secondary wallet, which can be set up to contain little to nothing of value.
Improving Multiple Device Access Security
In order for a non-hardware wallet to be used on multiple devices, you must import the seed phrase to each device. Each time a seed phrase is entered on a new device, the risk of the wallet being compromised increases as you are increasing the surface area of devices that have knowledge of the seed phrase.
Instead, each device can have its own unique wallet that is an authorized secondary wallet of the main wallet. If a device specific wallet was ever compromised or lost, you could simply remove the authorization to authenticate.
Further, wallet authentication can be chained so that a secondary wallet could itself authorize one or many tertiary wallets, which then have signing rights for both the secondary address as well as the root main address. This, can allow teams to each have their own signer while the main wallet can easily invalidate an entire tree, just by revoking rights from the root stem.
Improving Convenience
Many invididuals use hardware wallets for maximum security. However, this is often inconvenient, since many do not want to carry their hardware wallet with them at all times.
Instead, if you approve a non-hardware wallet for authentication activities (such as a mobile device), you would be able to use most dApps without the need to have your hardware wallet on hand.
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 formainAddress
.authAddress
represent the address we want to use for signing in lieu ofmainAddress
.authENS
represent the reverse lookup ENS string forauthAddress
.authKey
represents a string in the format[0-9A-Za-z]+
.
Control of mainAddress
and ownership of mainAddress
assets by authAddress
is proven if all the following conditions are met:
mainAddress
has an ENS resolver record and a reverse record set tomainENS
.authAddress
has an ENS resolver record and a reverse record set toauthENS
.authENS
has an ENS TEXT recordeip5131:vault
in the format<authKey>:<mainAddress>
.mainENS
has an ENS TEXT recordeip5131:<authKey>
.
Setting up one or many authAddress
records on a single ENS domain
The mainAddress
MUST have an ENS resolver record and reverse record configured.
In order to automatically discover the linked account, the authAddress
SHOULD have an ENS resolver record and reverse record configured.
- Choose an unused
<authKey>
. This can be any string in the format[0-0A-Za-z]+
. - Set a TEXT record
eip5131:<authKey>
onmainENS
, with the value set to the desiredauthAddress
. - Set a TEXT record
eip5131:vault
onauthENS
, with the value set to the<authKey>:mainAddress
.
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 associated authAddress
is the msg.sender
or has signed the message.
Practically, this would work by performing the following operations:
- Get the resolver for
authENS
- Get the
eip5131:vault
TEXT record ofauthENS
- Parse
<authKey>:<mainAddress>
to determine theauthKey
andmainAddress
. - 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.
- Otherwise one could set up other ENS nodes (with auths) that point to
- Get the
eip5131:<authKey>
TEXT record ofmainENS
and ensure it matchesauthAddress
.
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.
Revocation of authAddress
To revoke permission of authAddress
, delete the eip5131:<authKey>
TEXT record of mainENS
or update it to point to a new authAddress
.
Rationale
Usage of EIP-137
The proposed specification makes use of EIP-137 rather than introduce another registry paradigm. The reason for this is due to the existing wide adoption of EIP-137 and ENS.
However, the drawback to EIP-137 is that any linked authAddress
must contain some ETH in order to set the authENS
reverse record as well as the eip5131:vault
TEXT record. This can be solved by a separate reverse lookup registry that enables mainAddress
to set the reverse record and TEXT record with a message signed by authAddress
.
With the advent of L2s and ENS Layer 2 functionalities, off chain verification of linked addresses is possible even with domains managed across different chains.
One-to-Many Authentication Relationship
This proposed specification allows for a one (mainAddress
) to many (authAddress
) authentication relationship. i.e. one mainAddress
can authorize many authAddress
to authenticate, but an authAddress
can only authenticate itself or a single mainAddress
.
The reason for this design choice is to allow for simplicity of authentication via client and smart contract code. You can determine which mainAddress
the authAddress
is signing for without any additional user input.
Further, you can design UX without any user interaction necessary to ‘pick’ the interacting address by display assets owned by authAddress
and mainAddress
and use the appropriate address dependent on the asset the user is attempting to authenticate with.
Reference Implementation
Client/Server Side
In typescript, the validation function, using ethers.js would be as follows:
export interface LinkedAddress {
ens: string,
address: string,
}
export async function getLinkedAddress(
provider: ethers.providers.EnsProvider, address: string
): Promise<LinkedAddress | null> {
const addressENS = await provider.lookupAddress(address);
if (!addressENS) return null;
const vaultInfo = await (await provider.getResolver(addressENS))?.getText('eip5131:vault');
if (!vaultInfo) return null;
const vaultInfoArray = vaultInfo.split(':');
if (vaultInfoArray.length !== 2) {
throw new Error('EIP5131: Authkey and vault address not configured correctly.');
}
const [ authKey, vaultAddress ] = vaultInfoArray;
const vaultENS = await provider.lookupAddress(vaultAddress);
if (!vaultENS) {
throw new Error(`EIP5131: No ENS domain with reverse record set for vault.`);
};
const expectedSigningAddress = await (
await provider.getResolver(vaultENS)
)?.getText(`eip5131:${authKey}`);
if (expectedSigningAddress?.toLowerCase() !== address.toLowerCase()) {
throw new Error(`EIP5131: Authentication mismatch.`);
};
return {
ens: vaultENS,
address: vaultAddress
};
}
Contract side
With a backend
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 : Standard Signature Validation Method for Contracts
for a cheap and secure way to validate that the message signer is indeed authenticated for the main address.
Without a backend (JavaScript only)
Provided is a reference implementation for an internal function to verify that the message sender has an authentication link to the main address.
// 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);
function text(bytes32 node, string calldata key) 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 mainAddress The main address we want to authenticate for.
* @param mainENSNodeHash The main ENS Node Hash
* @param authKey The TEXT record of the authKey we are using for validation
* @param authENSNodeHash The auth ENS Node Hash
*/
function validateSender(
address ensRegistry,
address mainAddress,
bytes32 mainENSNodeHash,
string calldata authKey,
bytes32 authENSNodeHash
) internal view returns (bool) {
return validate(ensRegistry, mainAddress, mainENSNodeHash, authKey, msg.sender, authENSNodeHash);
}
/**
* Validate that the authAddress is an authentication address for mainAddress
*
* @param ensRegistry Address of ENS registry
* @param mainAddress The main address we want to authenticate for.
* @param mainENSNodeHash The main ENS Node Hash
* @param authAddress The address of the authentication wallet
* @param authENSNodeHash The auth ENS Node Hash
*/
function validate(
address ensRegistry,
address mainAddress,
bytes32 mainENSNodeHash,
string calldata authKey,
address authAddress,
bytes32 authENSNodeHash
) internal view returns (bool) {
_verifyMainENS(ensRegistry, mainAddress, mainENSNodeHash, authKey, authAddress);
_verifyAuthENS(ensRegistry, mainAddress, authKey, authAddress, authENSNodeHash);
return true;
}
// *********************
// Helper Functions
// *********************
function _verifyMainENS(
address ensRegistry,
address mainAddress,
bytes32 mainENSNodeHash,
string calldata authKey,
address authAddress
) private view {
// Check if the ENS nodes resolve correctly to the provided addresses
address mainResolver = ENS(ensRegistry).resolver(mainENSNodeHash);
require(mainResolver != address(0), "Main ENS not registered");
require(mainAddress == Resolver(mainResolver).addr(mainENSNodeHash), "Main address is wrong");
// Verify the authKey TEXT record is set to authAddress by mainENS
string memory authText = Resolver(mainResolver).text(mainENSNodeHash, string(abi.encodePacked("eip5131:", authKey)));
require(
keccak256(bytes(authText)) == keccak256(bytes(_addressToString(authAddress))),
"Invalid auth address"
);
}
function _verifyAuthENS(
address ensRegistry,
address mainAddress,
string memory authKey,
address authAddress,
bytes32 authENSNodeHash
) private view {
// Check if the ENS nodes resolve correctly to the provided addresses
address authResolver = ENS(ensRegistry).resolver(authENSNodeHash);
require(authResolver != address(0), "Auth ENS not registered");
require(authAddress == Resolver(authResolver).addr(authENSNodeHash), "Auth address is wrong");
// Verify the TEXT record is appropriately set by authENS
string memory vaultText = Resolver(authResolver).text(authENSNodeHash, "eip5131:vault");
require(
keccak256(abi.encodePacked(authKey, ":", _addressToString(mainAddress))) ==
keccak256(bytes(vaultText)),
"Invalid auth text record"
);
}
bytes16 private constant _HEX_SYMBOLS = "0123456789abcdef";
function sha3HexAddress(address addr) private pure returns (bytes32 ret) {
uint256 value = uint256(uint160(addr));
bytes memory buffer = new bytes(40);
for (uint256 i = 39; i > 1; --i) {
buffer[i] = _HEX_SYMBOLS[value & 0xf];
value >>= 4;
}
return keccak256(buffer);
}
function _addressToString(address addr) private pure returns (string memory ptr) {
// solhint-disable-next-line no-inline-assembly
assembly {
ptr := mload(0x40)
// Adjust mem ptr and keep 32 byte aligned
// 32 bytes to store string length; address is 42 bytes long
mstore(0x40, add(ptr, 96))
// Store (string length, '0', 'x') (42, 48, 120)
// Single write by offsetting across 32 byte boundary
ptr := add(ptr, 2)
mstore(ptr, 0x2a3078)
// Write string backwards
for {
// end is at 'x', ptr is at lsb char
let end := add(ptr, 31)
ptr := add(ptr, 71)
} gt(ptr, end) {
ptr := sub(ptr, 1)
addr := shr(4, addr)
} {
let v := and(addr, 0xf)
// if > 9, use ascii 'a-f' (no conditional required)
v := add(v, mul(gt(v, 9), 39))
// Add ascii for '0'
v := add(v, 48)
mstore8(ptr, v)
}
// return ptr to point to length (32 + 2 for '0x' - 1)
ptr := sub(ptr, 33)
}
return string(ptr);
}
}
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.