Alert Source Discuss
🚧 Stagnant Standards Track: ERC

ERC-5131: 用于 ENS 的 SAFE 身份验证

使用 ENS 文本记录来促进更安全、更便捷的签名操作。

Authors Wilkins Chung (@wwhchung) <wilkins@manifold.xyz>, Jalil Wahdatehagh (@jwahdatehagh), Cry (@crydoteth), Sillytuna (@sillytuna), Cyberpnk (@CyberpnkWin)
Created 2022-06-03
Discussion Link https://ethereum-magicians.org/t/eip-5131-ens-subdomain-authentication/9458
Requires EIP-137, EIP-181, EIP-634

摘要

此 EIP 通过以太坊名称服务规范 (EIP-137) 链接一个或多个签名钱包,以证明主钱包的控制权和资产所有权。

动机

在以太坊生态系统中,向第三方应用程序证明资产所有权是很常见的。用户经常签署数据有效负载以在获得执行某些操作的权限之前对自己进行身份验证。然而,这种方法——类似于授予第三方对其主钱包的根访问权限——既不安全也不方便。

例子:

  1. 为了让您在 OpenSea 上编辑您的个人资料,您必须使用您的钱包签署消息。
  2. 为了访问 NFT 门控内容,您必须使用包含 NFT 的钱包签署消息,以证明所有权。
  3. 为了获得参加活动的权限,您必须使用包含所需 NFT 的钱包签署消息,以证明所有权。
  4. 为了领取空投,您必须使用符合条件的钱包与智能合约互动。
  5. 为了证明 NFT 的所有权,您必须使用拥有该 NFT 的钱包签署有效负载。

在以上所有示例中,人们都使用钱包本身与 dApp 或智能合约进行交互,这可能是

  • 不方便(如果它通过硬件钱包或多重签名控制)
  • 不安全(因为上述操作是只读的,但您正在通过具有写入权限的钱包进行签名/交互)

相反,应该能够批准多个钱包代表给定的钱包进行身份验证。

现有方法和解决方案的问题

不幸的是,我们已经看到很多用户意外签署恶意有效负载的案例。结果几乎总是与签名地址相关的资产的重大损失。

除此之外,许多用户将其资产的很大一部分保存在“冷存储”中。随着“冷存储”解决方案安全性的提高,我们通常会看到可访问性降低,因为用户自然会增加访问这些钱包所需的障碍。

一些解决方案提出专用的注册表智能合约来创建此链接,或提出要支持的新协议。从采用的角度来看,这是有问题的,并且没有为此创建任何标准。

提议:使用以太坊名称服务(EIP-137)

与其“重新发明轮子”,不如说此提案旨在结合广泛采用的以太坊名称服务和 ENS 文本记录功能 (EIP-634),从而实现更安全、更便捷的签名和身份验证方式,并通过一个或多个辅助钱包提供对主钱包的“只读”访问权限。

从那里开始,好处是双重的。此 EIP 通过将潜在的恶意签名操作外包到更易于访问的钱包(热钱包)来提高用户的安全性,同时能够保持不经常用于签名操作的钱包的预期安全性假设。

提高 dApp 交互安全性

许多 dApp 要求人们证明对钱包的控制权才能获得访问权限。目前,这意味着您必须使用钱包本身与 dApp 交互。这是一个安全问题,因为恶意 dApp 或钓鱼网站可能会导致钱包的资产因签署恶意有效负载而受到威胁。

但是,如果使用辅助钱包进行这些交互,则可以减轻这种风险。恶意交互将被隔离到辅助钱包中持有的资产,该钱包可以设置为不包含任何有价值的东西。

提高多设备访问安全性

为了在多个设备上使用非硬件钱包,您必须将助记词导入到每个设备。每次在新设备上输入助记词时,钱包被盗用的风险都会增加,因为您正在扩大了解助记词的设备的表面积。

相反,每个设备都可以拥有自己的唯一钱包,该钱包是主钱包的授权辅助钱包。如果设备特定的钱包遭到破坏或丢失,您可以简单地删除身份验证授权。

此外,钱包身份验证可以链接起来,以便辅助钱包本身可以授权一个或多个三级钱包,这些钱包具有辅助地址和根主地址的签名权限。 这可以让团队每个人都有自己的签名者,而主钱包只需撤销根茎的权限即可轻松地使整个树无效。

提高便利性

许多人使用硬件钱包以获得最大的安全性。然而,这通常是不方便的,因为许多人不想一直随身携带硬件钱包。

相反,如果您批准一个非硬件钱包用于身份验证活动(例如移动设备),您将能够使用大多数 dApp,而无需随身携带硬件钱包。

规范

本文档中的关键词“必须”、“不得”、“必需”、“应”、“不应”、“建议”、“不建议”、“可以”和“可选”应按照 RFC 2119 中的描述进行解释。

令:

  • mainAddress 表示我们尝试对其进行身份验证或证明资产所有权的钱包地址。
  • mainENS 表示 mainAddress 的反向查找 ENS 字符串。
  • authAddress 表示我们要用于签名以代替 mainAddress 的地址。
  • authENS 表示 authAddress 的反向查找 ENS 字符串。
  • authKey 表示格式为 [0-9A-Za-z]+ 的字符串。

如果满足以下所有条件,则证明 authAddressmainAddress 的控制以及 authAddressmainAddress 资产的所有权:

  • mainAddress 具有 ENS 解析器记录和设置为 mainENS 的反向记录。
  • authAddress 具有 ENS 解析器记录和设置为 authENS 的反向记录。
  • authENS 具有格式为 <authKey>:<mainAddress> 的 ENS 文本记录 eip5131:vault
  • mainENS 具有 ENS 文本记录 eip5131:<authKey>

在单个 ENS 域上设置一个或多个 authAddress 记录

mainAddress 必须配置 ENS 解析器记录和反向记录。 为了自动发现链接的帐户,authAddress 应该配置 ENS 解析器记录和反向记录。

  1. 选择一个未使用的 <authKey>。这可以是格式为 [0-0A-Za-z]+ 的任何字符串。
  2. mainENS 上设置 TEXT 记录 eip5131:<authKey>,并将值设置为所需的 authAddress
  3. authENS 上设置 TEXT 记录 eip5131:vault,并将值设置为 <authKey>:mainAddress

目前,此 EIP 不强制您可以在其中包含的 authAddress 条目数量的上限。 用户可以根据需要多次重复此过程。

通过 authAddress 验证 mainAddress

如果任何关联的 authAddressmsg.sender 或已签署消息,则证明 mainAddress 的控制权和 mainAddress 资产的所有权。

实际上,这将通过执行以下操作来工作:

  1. 获取 authENS 的解析器
  2. 获取 authENSeip5131:vault TEXT 记录
  3. 解析 <authKey>:<mainAddress> 以确定 authKeymainAddress
  4. 必须获取 mainAddress 的反向 ENS 记录,并验证它是否与 <mainENS> 匹配。
    • 否则,可以设置指向 mainAddress 的其他 ENS 节点(带有验证),并通过这些节点进行身份验证。
  5. 获取 mainENSeip5131:<authKey> TEXT 记录,并确保它与 authAddress 匹配。

请注意,此规范允许对签名进行合约级别和客户端/服务器端验证。 它不限于智能合约,这就是为什么没有提议的外部接口定义的原因。

撤销 authAddress

要撤销 authAddress 的权限,请删除 mainENSeip5131:<authKey> TEXT 记录或更新它以指向新的 authAddress

理由

EIP-137 的使用

拟议的规范利用 EIP-137,而不是引入另一种注册表范例。 这样做的原因是由于 EIP-137 和 ENS 已被广泛采用。

但是,EIP-137 的缺点是任何链接的 authAddress 必须包含一些 ETH,以便设置 authENS 反向记录以及 eip5131:vault TEXT 记录。 这可以通过单独的反向查找注册表来解决,该注册表使 mainAddress 能够使用 authAddress 签名的消息来设置反向记录和 TEXT 记录。

随着 L2 和 ENS Layer 2 功能的出现,即使域在不同的链上管理,也可以对链接地址进行链下验证。

一对多身份验证关系

此提出的规范允许一对多 (authAddress) 身份验证关系。 也就是说,一个 mainAddress 可以授权多个 authAddress 进行身份验证,但 authAddress 只能验证它自己或单个 mainAddress

这种设计选择的原因是为了简化通过客户端和智能合约代码进行身份验证。 您可以确定 authAddress 为哪个 mainAddress 签名,而无需任何额外的用户输入。

此外,您可以设计 UX,而无需任何用户交互来“选择”交互地址,方法是显示 authAddressmainAddress 拥有的资产,并根据用户尝试使用哪个资产进行身份验证来使用相应的地址。

参考实现

客户端/服务器端

在 typescript 中,使用 ethers.js 的验证函数如下:

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
  };
}

合约端

带有后端

如果您的应用程序运行安全的后端服务器,您可以运行上面的客户端/服务器代码,然后结合 EIP-1271 等规范使用结果:Standard Signature Validation Method for Contracts 这是一种廉价且安全的方式来验证消息签名者确实已通过主地址进行身份验证。

没有后端 (仅 JavaScript)

提供了一个参考实现,用于验证消息发送者是否具有指向主地址的身份验证链接的内部函数。

// 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
     * 验证消息发送者是 mainAddress 的身份验证地址
     * @param ensRegistry    Address of ENS registry ENS 注册表的地址
     * @param mainAddress     The main address we want to authenticate for. 我们要验证的主地址
     * @param mainENSNodeHash The main ENS Node Hash 主 ENS 节点哈希
     * @param authKey         The TEXT record of the authKey we are using for validation 我们用于验证的 authKey 的 TEXT 记录
     * @param authENSNodeHash The auth ENS Node Hash 身份验证 ENS 节点哈希
     */
    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
     * 验证 authAddress 是 mainAddress 的身份验证地址
     * @param ensRegistry     Address of ENS registry ENS 注册表的地址
     * @param mainAddress     The main address we want to authenticate for. 我们要验证的主地址
     * @param mainENSNodeHash The main ENS Node Hash 主 ENS 节点哈希
     * @param authAddress     The address of the authentication wallet 身份验证钱包的地址
     * @param authENSNodeHash The auth ENS Node Hash 身份验证 ENS 节点哈希
     */
    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
        // 检查 ENS 节点是否正确解析为提供的地址
        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
        // 验证 authKey TEXT 记录是否由 mainENS 设置为 authAddress
        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
        // 检查 ENS 节点是否正确解析为提供的地址
        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
        // 验证 TEXT 记录是否由 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);
    }
}

安全考虑

此 EIP 的核心目的是提高安全性,并在不需要主钱包并且不需要移动主钱包持有的资产时,促进更安全的方式来验证钱包控制权和资产所有权。 可以将其视为进行“只读”身份验证的一种方式。

版权

版权及相关权利通过 CC0 放弃。

Citation

Please cite this document as:

Wilkins Chung (@wwhchung) <wilkins@manifold.xyz>, Jalil Wahdatehagh (@jwahdatehagh), Cry (@crydoteth), Sillytuna (@sillytuna), Cyberpnk (@CyberpnkWin), "ERC-5131: 用于 ENS 的 SAFE 身份验证 [DRAFT]," Ethereum Improvement Proposals, no. 5131, June 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5131.