diff --git a/ERCS/erc-7739.md b/ERCS/erc-7739.md index f4d4c86710..edc581a536 100644 --- a/ERCS/erc-7739.md +++ b/ERCS/erc-7739.md @@ -2,7 +2,7 @@ eip: 7739 title: Readable Typed Signatures for Smart Accounts description: A defensive rehashing scheme which prevents signature replays across smart accounts and preserves the readability of the signed contents -author: vectorized (@vectorized), Sihoon Lee (@push0ebp), Francisco Giordano (@frangio), Im Juno (@junomonster), howydev (@howydev), 0xcuriousapple (@0xcuriousapple) +author: vectorized (@vectorized), Sihoon Lee (@push0ebp), Francisco Giordano (@frangio), Hadrien Croubois (@Amxx), Ernesto García (@ernestognw), Im Juno (@junomonster), howydev (@howydev), Atarpara (@Atarpara), 0xcuriousapple (@0xcuriousapple) discussions-to: https://ethereum-magicians.org/t/erc-7739-readable-typed-signatures-for-smart-accounts/20513 status: Draft type: Standards Track @@ -55,7 +55,7 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ### Overview -Compliant smart accounts MUST implement the following dependencies: +The following dependencies are REQUIRED: - [EIP-712](./eip-712.md) Typed structured data hashing and signing. Provides the relevant typed data hashing logic internally, which is required to construct the final hashes. @@ -84,8 +84,7 @@ keccak256(\x19\x01 ‖ APP_DOMAIN_SEPARATOR ‖ version: eip712Domain().version, chainId: eip712Domain().chainId, verifyingContract: eip712Domain().verifyingContract, - salt: eip712Domain().salt, - extensions: eip712Domain().extensions + salt: eip712Domain().salt })) ) ``` @@ -112,8 +111,7 @@ finalTypedDataSignHash = keccak256(bytes(eip712Domain().version)), uint256(eip712Domain().chainId), uint256(uint160(eip712Domain().verifyingContract)), - bytes32(eip712Domain().salt), - keccak256(abi.encodePacked(eip712Domain().extensions)) + bytes32(eip712Domain().salt) ) ) ) @@ -127,23 +125,21 @@ typedDataSignTypehash = keccak256( abi.encodePacked( "TypedDataSign(", - contentsTypeName, " contents", - "bytes1 fields,", + contentsName, " contents,", "string name,", "string version,", "uint256 chainId,", "address verifyingContract,", - "bytes32 salt,", - "uint256[] extensions", + "bytes32 salt" ")", contentsType ) ); ``` -If `contentsType` is `"Mail(address from,address to,string message)"`, then `contentsTypeName` will be `"Mail"`. +If `contentsType` is `"Mail(address from,address to,string message)"`, then `contentsName` will be `"Mail"`. -The `contentsTypeName` is the substring of `contentsType` up to (excluding) the first instance of `"("`: +The `contentsName` is the substring of `contentsType` up to (excluding) the first instance of `"("`: In Solidity, this can be written as: @@ -155,7 +151,7 @@ In Solidity, this can be written as: // `indexOf(string memory subject, string memory search)` // Returns the byte index of the first location of `search` in `subject`, // searching from left to right. Returns `2**256 - 1` if `search` is not found. -contentsTypeName = +contentsName = LibString.slice( contentsType, 0, // Start byte index. @@ -163,23 +159,30 @@ contentsTypeName = ); ``` -A copy of the `LibString` Solidity library is provided for reference in [`/assets/eip-7739/contracts/utils/LibString.sol`]. +[A copy of the `LibString` Solidity library is provided for completeness](../assets/eip-7739/contracts/utils/LibString.sol). -For safety, smart accounts MUST treat the signature as invalid if any of the following is true: +For safety, it is RECOMMENDED to treat the signature as invalid if any of the following is true: -- `contentsTypeName` is the empty string (i.e. `bytes(contentsTypeName).length == 0`). -- `contentsTypeName` starts with any of the following bytes `abcdefghijklmnopqrstuvwxyz(`. -- `contentsTypeName` contains any of the following bytes `, )\x00`. +- `contentsName` is the empty string (i.e. `bytes(contentsName).length == 0`). +- `contentsName` starts with any of the following bytes `abcdefghijklmnopqrstuvwxyz(`. +- `contentsName` contains any of the following bytes `, )\x00`. #### `TypedDataSign` signature The `signature` passed into `isValidSignature` will be changed to: ``` -originalSignature ‖ APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsType ‖ uint16(contentsType.length) +originalSignature ‖ APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsDescription ‖ uint16(contentsDescription.length) ``` -where `contents` is the bytes32 struct hash of the original struct. +where: + +- `contents` is the bytes32 struct hash of the original struct. +- `contentsDescription` is either: + - `contentsType` (implicit mode), + where `contentsType` starts with `contentsName`. + - `contentsType ‖ contentsName` (explicit mode), + where `contentsType` may not necessarily start with `contentsName`. In Solidity, this can be written as: @@ -189,8 +192,8 @@ signature = bytes(originalSignature), bytes32(APP_DOMAIN_SEPARATOR), bytes32(contents), - bytes(contentsType), - uint16(contentsType.length) + bytes(contentsDescription), + uint16(contentsDescription.length) ); ``` @@ -268,21 +271,11 @@ hash = The `PersonalSign` workflow does not require additional data to be appended to the `signature` passed into `isValidSignature`. -### `supportsNestedTypedDataSign` function for detection +### Support detection -To facilitate automatic detection, smart accounts SHOULD implement the following function: +Smart accounts SHOULD return `bytes4(0x77390001)` for `isValidSignature(0x7739773977397739773977397739773977397739773977397739773977397739, "")` to indicate support for this standard. -```solidity -/// @dev For automatic detection that the smart account supports the nested EIP-712 workflow. -/// By default, it returns `bytes32(bytes4(keccak256("supportsNestedTypedDataSign()")))`, -/// denoting support for the default behavior, as implemented in -/// `_erc1271IsValidSignatureViaNestedEIP712`, which is called in `isValidSignature`. -/// Future extensions should return a different non-zero `result` to denote different behavior. -/// This method intentionally returns bytes32 to allow freedom for future extensions. -function supportsNestedTypedDataSign() public view virtual returns (bytes32 result) { - result = bytes4(0xd620c85a); -} -``` +The magic number `bytes4(0x77390001)` MAY be incremented if this standard gets updated. ### Signature verification workflow deduction @@ -322,23 +315,31 @@ As many developers may not update their applications to support the nested EIP-7 The `typedDataSignTypehash` must be constructed on-the-fly on-chain. This is to enforce that the signed contents will be visible in the signature request, by requiring that `contents` be a user defined type. -The structure is intentionally made flat with the fields of `eip712Domain` to make implementation feasible. Otherwise, smart accounts must implement on-chain lexographical sorting of strings for the struct type names when constructing `typedDataSignTypehash`. +The fields of `eip712Domain` are flattened into the `TypedDataSign` structure instead of being included as a field of type `EIP712Domain` in order to to avoid a conflict with the domain type of the verifying contract in case it's different. -### `supportsNestedTypedDataSign` for detection +The `bytes1 fields` bitmap and `uint256[] extensions` array in [ERC-5267](./eip-5267.md) have been omitted. Differentiating between an absent field versus a zero field (e.g. `bytes32(0)`) offers no additional security benefits for on-chain defensive rehashing. The `extensions` parameter is a list of EIP numbers used for off-chain signaling. -Without this function, this standard will not change the interface of the smart account, as it defines the behavior of `isValidSignature` without adding any new functions. As such, [ERC-165](./eip-165.md) cannot be used. +### `contentsDescription` with implicit and explicit modes -For future extendability, `supportsNestedTypedDataSign` is defined to return a bytes32 as the first word of its returned data. For bytecode compactness and to leave space for bit packing, only the leftmost 4 bytes are set to the function selector of `supportsNestedTypedDataSign`. +When the `contents` structure contains nested types, EIP-712 lexicographical sorting can result in the `contentsName` not being positioned exactly at the start of the `contentsType`. As such, we need the explicit mode. -The `supportsNestedTypedDataSign` function may be extended to return multiple values (e.g. `bytes32 result, bytes memory data`), as long as the first word of the returned data is a bytes32 identifier. This will not change the function selector. +### Support detection with `isValidSignature` + +For easier implementation in modular smart accounts, we have decided to utilize the `isValidSignature` method to return a magic number instead of defining new functions. + +### Rejecting `contentsName` beginning with any lowercase 7-bit ASCII character + +This recommendation is to keep the standard language agnostic and future-proof. Atomic types such as `uint256` may be named differently in other languages (e.g. `u256`). ## Backwards Compatibility -No backwards compatibility issues. +### Detection of previous draft + +In an earlier draft, we have designated a `supportsNestedTypedDataSign()` function for support detection, which returns `bytes4(0xd620c85a)`. ## Reference Implementation -A production ready and optimized reference implementation is provided at [`/assets/eip-7739/contracts/accounts/ERC1271.sol`]. +[A production ready and optimized implementation is provided for reference](../assets/eip-7739/contracts/accounts/ERC1271.sol). It includes relevant complementary features required for safety, flexibility, developer experience, and user experience. @@ -346,13 +347,13 @@ The reference implementation is intentionally not minimalistic. This is to avoid ## Security Considerations -### Rejecting invalid `contentsTypeName` +### Rejecting invalid `contentsName` Current major implementations of `eth_signTypedData` do not sanitize the names of custom types. -A phishing website can craft a `contentsTypeName` with control characters to break out of the `PersonalSign` type encoding, resulting in the wallet client asking the user to sign an opaque hash. +A phishing website can craft a `contentsName` with control characters to break out of the `PersonalSign` type encoding, resulting in the wallet client asking the user to sign an opaque hash. -Requiring on-chain sanitization of `contentsTypeName` will block this phishing attack vector. +Requiring on-chain sanitization of `contentsName` will block this phishing attack vector. ## Copyright diff --git a/assets/erc-7739/contracts/accounts/ERC1271.sol b/assets/erc-7739/contracts/accounts/ERC1271.sol index c28e00ca10..1e20abfa30 100644 --- a/assets/erc-7739/contracts/accounts/ERC1271.sol +++ b/assets/erc-7739/contracts/accounts/ERC1271.sol @@ -27,6 +27,16 @@ abstract contract ERC1271 is EIP712 { virtual returns (bytes4 result) { + // For automatic detection that the smart account supports the nested EIP-712 workflow, + // See: https://eips.ethereum.org/EIPS/eip-7739. + // If `hash` is `0x7739...7739`, returns `bytes4(0x77390001)`. + // The returned number MAY be increased in future ERC7739 versions. + unchecked { + if (signature.length == uint256(0)) { + // Forces the compiler to optimize for smaller bytecode size. + if (uint256(hash) == ~signature.length / 0xffff * 0x7739) return 0x77390001; + } + } bool success = _erc1271IsValidSignature(hash, _erc1271UnwrapSignature(signature)); /// @solidity memory-safe-assembly assembly { @@ -36,16 +46,6 @@ abstract contract ERC1271 is EIP712 { } } - /// @dev For automatic detection that the smart account supports the nested EIP-712 workflow. - /// By default, it returns `bytes32(bytes4(keccak256("supportsNestedTypedDataSign()")))`, - /// denoting support for the default behavior, as implemented in - /// `_erc1271IsValidSignatureViaNestedEIP712`, which is called in `isValidSignature`. - /// Future extensions should return a different non-zero `result` to denote different behavior. - /// This method intentionally returns bytes32 to allow freedom for future extensions. - function supportsNestedTypedDataSign() public view virtual returns (bytes32 result) { - result = bytes4(0xd620c85a); - } - /// @dev Returns the ERC1271 signer. /// Override to return the signer `isValidSignature` checks against. function _erc1271Signer() internal view virtual returns (address); @@ -146,17 +146,22 @@ abstract contract ERC1271 is EIP712 { /// version: keccak256(bytes(eip712Domain().version)), /// chainId: eip712Domain().chainId, /// verifyingContract: eip712Domain().verifyingContract, - /// salt: eip712Domain().salt, - /// extensions: keccak256(abi.encodePacked(eip712Domain().extensions)) + /// salt: eip712Domain().salt /// })) /// ) /// ``` /// where `‖` denotes the concatenation operator for bytes. /// The order of the fields is important: `contents` comes before `name`. /// - /// The signature will be `r ‖ s ‖ v ‖ - /// APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsType ‖ uint16(contentsType.length)`, - /// where `contents` is the bytes32 struct hash of the original struct. + /// The signature will be `r ‖ s ‖ v ‖ APP_DOMAIN_SEPARATOR ‖ + /// contents ‖ contentsDescription ‖ uint16(contentsDescription.length)`, + /// where: + /// - `contents` is the bytes32 struct hash of the original struct. + /// - `contentsDescription` can be either: + /// a) `contentsType` (implicit mode) + /// where `contentsType` starts with `contentsName`. + /// b) `contentsType ‖ contentsName` (explicit mode) + /// where `contentsType` may not necessarily start with `contentsName`. /// /// The `APP_DOMAIN_SEPARATOR` and `contents` will be used to verify if `hash` is indeed correct. /// __________________________________________________________________________________________ @@ -194,11 +199,33 @@ abstract contract ERC1271 is EIP712 { virtual returns (bool result) { - bytes32 t = _typedDataSignFields(); + uint256 t = uint256(uint160(address(this))); + // Forces the compiler to pop the variables after the scope, avoiding stack-too-deep. + if (t != uint256(0)) { + ( + , + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + ) = eip712Domain(); + /// @solidity memory-safe-assembly + assembly { + t := mload(0x40) // Grab the free memory pointer. + // Skip 2 words for the `typedDataSignTypehash` and `contents` struct hash. + mstore(add(t, 0x40), keccak256(add(name, 0x20), mload(name))) + mstore(add(t, 0x60), keccak256(add(version, 0x20), mload(version))) + mstore(add(t, 0x80), chainId) + mstore(add(t, 0xa0), shr(96, shl(96, verifyingContract))) + mstore(add(t, 0xc0), salt) + mstore(0x40, add(t, 0xe0)) // Allocate the memory. + } + } /// @solidity memory-safe-assembly assembly { let m := mload(0x40) // Cache the free memory pointer. - // `c` is `contentsType.length`, which is stored in the last 2 bytes of the signature. + // `c` is `contentsDescription.length`, which is stored in the last 2 bytes of the signature. let c := shr(240, calldataload(add(signature.offset, sub(signature.length, 2)))) for {} 1 {} { let l := add(0x42, c) // Total length of appended data (32 + 32 + c + 2). @@ -207,7 +234,7 @@ abstract contract ERC1271 is EIP712 { calldatacopy(0x20, o, 0x40) // Copy the `APP_DOMAIN_SEPARATOR` and `contents` struct hash. // Use the `PersonalSign` workflow if the reconstructed hash doesn't match, // or if the appended data is invalid, i.e. - // `appendedData.length > signature.length || contentsType.length == 0`. + // `appendedData.length > signature.length || contentsDescription.length == 0`. if or(xor(keccak256(0x1e, 0x42), hash), or(lt(signature.length, l), iszero(c))) { t := 0 // Set `t` to 0, denoting that we need to `hash = _hashTypedData(hash)`. mstore(t, _PERSONAL_SIGN_TYPEHASH) @@ -216,28 +243,38 @@ abstract contract ERC1271 is EIP712 { break } // Else, use the `TypedDataSign` workflow. - // `TypedDataSign({ContentsName} contents,bytes1 fields,...){ContentsType}`. + // `TypedDataSign({ContentsName} contents,string name,...){ContentsType}`. mstore(m, "TypedDataSign(") // Store the start of `TypedDataSign`'s type encoding. let p := add(m, 0x0e) // Advance 14 bytes to skip "TypedDataSign(". - calldatacopy(p, add(o, 0x40), c) // Copy `contentsType` to extract `contentsName`. + calldatacopy(p, add(o, 0x40), c) // Copy `contentsName`, optimistically. + mstore(add(p, c), 40) // Store a '(' after the end. + if iszero(eq(byte(0, mload(sub(add(p, c), 1))), 41)) { + let e := 0 // Length of `contentsName` in explicit mode. + for { let q := sub(add(p, c), 1) } 1 {} { + e := add(e, 1) // Scan backwards until we encounter a ')'. + if iszero(gt(lt(e, c), eq(byte(0, mload(sub(q, e))), 41))) { break } + } + c := sub(c, e) // Truncate `contentsDescription` to `contentsType`. + calldatacopy(p, add(add(o, 0x40), c), e) // Copy `contentsName`. + mstore8(add(p, e), 40) // Store a '(' exactly right after the end. + } // `d & 1 == 1` means that `contentsName` is invalid. let d := shr(byte(0, mload(p)), 0x7fffffe000000000000010000000000) // Starts with `[a-z(]`. - // Store the end sentinel '(', and advance `p` until we encounter a '(' byte. - for { mstore(add(p, c), 40) } iszero(eq(byte(0, mload(p)), 40)) { p := add(p, 1) } { + // Advance `p` until we encounter '('. + for {} iszero(eq(byte(0, mload(p)), 40)) { p := add(p, 1) } { d := or(shr(byte(0, mload(p)), 0x120100000001), d) // Has a byte in ", )\x00". } - mstore(p, " contents,bytes1 fields,string n") // Store the rest of the encoding. - mstore(add(p, 0x20), "ame,string version,uint256 chain") - mstore(add(p, 0x40), "Id,address verifyingContract,byt") - mstore(add(p, 0x60), "es32 salt,uint256[] extensions)") - p := add(p, 0x7f) + mstore(p, " contents,string name,string") // Store the rest of the encoding. + mstore(add(p, 0x1c), " version,uint256 chainId,address") + mstore(add(p, 0x3c), " verifyingContract,bytes32 salt)") + p := add(p, 0x5c) calldatacopy(p, add(o, 0x40), c) // Copy `contentsType`. // Fill in the missing fields of the `TypedDataSign`. calldatacopy(t, o, 0x40) // Copy the `contents` struct hash to `add(t, 0x20)`. mstore(t, keccak256(m, sub(add(p, c), m))) // Store `typedDataSignTypehash`. // The "\x19\x01" prefix is already at 0x00. // `APP_DOMAIN_SEPARATOR` is already at 0x20. - mstore(0x40, keccak256(t, 0x120)) // `hashStruct(typedDataSign)`. + mstore(0x40, keccak256(t, 0xe0)) // `hashStruct(typedDataSign)`. // Compute the final hash, corrupted if `contentsName` is invalid. hash := keccak256(0x1e, add(0x42, and(1, d))) signature.length := sub(signature.length, l) // Truncate the signature. @@ -249,32 +286,6 @@ abstract contract ERC1271 is EIP712 { result = _erc1271IsValidSignatureNowCalldata(hash, signature); } - /// @dev For use in `_erc1271IsValidSignatureViaNestedEIP712`, - function _typedDataSignFields() private view returns (bytes32 m) { - ( - bytes1 fields, - string memory name, - string memory version, - uint256 chainId, - address verifyingContract, - bytes32 salt, - uint256[] memory extensions - ) = eip712Domain(); - /// @solidity memory-safe-assembly - assembly { - m := mload(0x40) // Grab the free memory pointer. - mstore(0x40, add(m, 0x120)) // Allocate the memory. - // Skip 2 words for the `typedDataSignTypehash` and `contents` struct hash. - mstore(add(m, 0x40), shl(248, byte(0, fields))) - mstore(add(m, 0x60), keccak256(add(name, 0x20), mload(name))) - mstore(add(m, 0x80), keccak256(add(version, 0x20), mload(version))) - mstore(add(m, 0xa0), chainId) - mstore(add(m, 0xc0), shr(96, shl(96, verifyingContract))) - mstore(add(m, 0xe0), salt) - mstore(add(m, 0x100), keccak256(add(extensions, 0x20), shl(5, mload(extensions)))) - } - } - /// @dev Performs the signature validation without nested EIP-712 to allow for easy sign ins. /// This function must always return false or revert if called on-chain. function _erc1271IsValidSignatureViaRPC(bytes32 hash, bytes calldata signature)