-
Notifications
You must be signed in to change notification settings - Fork 477
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add ERC: Readable Typed Signatures for Smart Accounts
Merged by EIP-Bot.
- Loading branch information
1 parent
6493b55
commit 21416f5
Showing
5 changed files
with
1,172 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,359 @@ | ||
--- | ||
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) | ||
discussions-to: https://ethereum-magicians.org/t/erc-7739-readable-typed-signatures-for-smart-accounts/20513 | ||
status: Draft | ||
type: Standards Track | ||
category: ERC | ||
created: 2024-05-28 | ||
requires: 191, 712, 1271, 5267 | ||
--- | ||
|
||
## Abstract | ||
|
||
This proposal defines a standard to prevent signature replays across multiple smart accounts when they are owned by a single Externally Owned Account (EOA). This is achieved through a defensive rehashing scheme for [ERC-1271](./eip-1271.md) verification using specific nested [EIP-712](./eip-712.md) typed structures, which preserves the readability of the signed contents during wallet client signature requests. | ||
|
||
## Motivation | ||
|
||
Smart accounts can verify signatures with via [ERC-1271](./eip-1271.md) using the `isValidSignature` function. | ||
|
||
A straightforward implementation as shown below, is vulnerable to signature replay attacks. | ||
|
||
```solidity | ||
/// @dev This implementation is NOT safe. | ||
function isValidSignature( | ||
bytes32 hash, | ||
bytes calldata signature | ||
) external override view returns (bytes4) { | ||
uint8 v = uint8(signature[64]); | ||
(bytes32 r, bytes32 s) = abi.decode(signature, (bytes32, bytes32)); | ||
// Reject malleable signatures. | ||
require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0); | ||
address signer = ecrecover(hash, v, r, s); | ||
// Reject failed recovery. | ||
require(signer != address(0)); | ||
// `owner` is a storage variable containing the smart account's owner. | ||
if (signer == owner) { | ||
return 0x1626ba7e; | ||
} else { | ||
return 0xffffffff; | ||
} | ||
} | ||
``` | ||
|
||
When multiple smart accounts are owned by a single EOA, the same signature can be replayed across the smart accounts if the `hash` does not include the smart account address. | ||
|
||
Unfortunately, this is the case for many popular applications (e.g. Permit2). As such, many smart account implementations perform some form of defensive rehashing. First, the smart account computes a final hash from minimally: (1) the hash, (2) its own address, (3) the chain ID. Then, the smart account verifies the final hash against the signature. Defensive rehashing can be implemented with [EIP-712](./eip-712.md), but a straightforward implementation will make the signed contents opaque. | ||
|
||
This standard provides a defensive rehashing scheme that makes the signed contents visible across all wallet clients that support [EIP-712](./eip-712.md). It is designed for minimal adoption friction. Even if wallet clients or application frontends are not updated, users can still inject client side JavaScript to enable the defensive rehashing. | ||
|
||
## Specification | ||
|
||
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. | ||
|
||
### Overview | ||
|
||
Compliant smart accounts MUST implement the following dependencies: | ||
|
||
- [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. | ||
|
||
- [ERC-1271](./eip-1271.md) Standard Signature Validation Method for Contracts. | ||
Provides the `isValidSignature(bytes32 hash, bytes calldata signature)` function. | ||
|
||
- [ERC-5267](./eip-5267.md) Retrieval of EIP-712 domain. | ||
Provides the `eip712Domain()` function which is required to compute the final hashes. | ||
|
||
This standard defines the behavior of the `isValidSignature` function for [ERC-1271](./eip-1271.md), which comprises of two workflows: (1) the `TypedDataSign` workflow, (2) the `PersonalSign` workflow. | ||
|
||
### `TypedDataSign` workflow | ||
|
||
The `TypedDataSign` workflow handles the case where the `hash` is originally computed with [EIP-712](./eip-712.md). | ||
|
||
#### `TypedDataSign` final hash | ||
|
||
The final hash for the `TypedDataSign` workflow is defined as: | ||
|
||
``` | ||
keccak256(\x19\x01 ‖ APP_DOMAIN_SEPARATOR ‖ | ||
hashStruct(TypedDataSign({ | ||
contents: hashStruct(originalStruct), | ||
name: eip712Domain().name, | ||
version: eip712Domain().version, | ||
chainId: eip712Domain().chainId, | ||
verifyingContract: eip712Domain().verifyingContract, | ||
salt: eip712Domain().salt, | ||
extensions: eip712Domain().extensions | ||
})) | ||
) | ||
``` | ||
|
||
where `‖` denotes the concatenation operator for bytes. | ||
|
||
In Solidity, this can be written as: | ||
|
||
```solidity | ||
finalTypedDataSignHash = | ||
keccak256( | ||
abi.encodePacked( | ||
hex"1901", | ||
// Application specific domain separator. Passed via `signature`. | ||
bytes32(APP_DOMAIN_SEPARATOR), | ||
keccak256( | ||
abi.encode( | ||
// Computed on-the-fly with `contentsType`, which is passed via `signature`. | ||
typedDataSignTypehash, | ||
// This is the `contents` struct hash, which is passed via `signature`. | ||
bytes32(hashStruct(originalStruct)), | ||
// `eip712Domain()` is from ERC-5267. | ||
keccak256(bytes(eip712Domain().name)), | ||
keccak256(bytes(eip712Domain().version)), | ||
uint256(eip712Domain().chainId), | ||
uint256(uint160(eip712Domain().verifyingContract)), | ||
bytes32(eip712Domain().salt), | ||
keccak256(abi.encodePacked(eip712Domain().extensions)) | ||
) | ||
) | ||
) | ||
); | ||
``` | ||
|
||
where `typedDataSignTypehash` is: | ||
|
||
```solidity | ||
typedDataSignTypehash = | ||
keccak256( | ||
abi.encodePacked( | ||
"TypedDataSign(", | ||
contentsTypeName, " contents", | ||
"bytes1 fields,", | ||
"string name,", | ||
"string version,", | ||
"uint256 chainId,", | ||
"address verifyingContract,", | ||
"bytes32 salt,", | ||
"uint256[] extensions", | ||
")", | ||
contentsType | ||
) | ||
); | ||
``` | ||
|
||
If `contentsType` is `"Mail(address from,address to,string message)"`, then `contentsTypeName` will be `"Mail"`. | ||
|
||
The `contentsTypeName` is the substring of `contentsType` up to (excluding) the first instance of `"("`: | ||
|
||
In Solidity, this can be written as: | ||
|
||
```solidity | ||
// `slice(string memory subject, uint256 start, uint256 end)` | ||
// returns a copy of `subject` sliced from `start` to `end` (exclusive). | ||
// `start` and `end` are byte offsets. | ||
// | ||
// `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 = | ||
LibString.slice( | ||
contentsType, | ||
0, // Start byte index. | ||
LibString.indexOf(contentsType, "(") // End byte index (exclusive). | ||
); | ||
``` | ||
|
||
A copy of the `LibString` Solidity library is provided for reference in [`/assets/eip-7739/contracts/utils/LibString.sol`]. | ||
|
||
For safety, smart accounts MUST 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`. | ||
|
||
#### `TypedDataSign` signature | ||
|
||
The `signature` passed into `isValidSignature` will be changed to: | ||
|
||
``` | ||
originalSignature ‖ APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsType ‖ uint16(contentsType.length) | ||
``` | ||
|
||
where `contents` is the bytes32 struct hash of the original struct. | ||
|
||
In Solidity, this can be written as: | ||
|
||
```solidity | ||
signature = | ||
abi.encodePacked( | ||
bytes(originalSignature), | ||
bytes32(APP_DOMAIN_SEPARATOR), | ||
bytes32(contents), | ||
bytes(contentsType), | ||
uint16(contentsType.length) | ||
); | ||
``` | ||
|
||
The appended `APP_DOMAIN_SEPARATOR` and `contents` struct hash will be used to verify if the `hash` passed into `isValidSignature` is indeed correct via: | ||
|
||
```solidity | ||
hash == keccak256( | ||
abi.encodePacked( | ||
hex"1901", | ||
bytes32(APP_DOMAIN_SEPARATOR), | ||
bytes32(contents) | ||
) | ||
) | ||
``` | ||
|
||
If the `hash` does not match the reconstructed hash, then the `hash` and `signature` are invalid under the `TypedDataSign` workflow. | ||
|
||
### `PersonalSign` workflow | ||
|
||
This `PersonalSign` workflow handles the case where the `hash` is originally computed with [EIP-191](./eip-191.md). | ||
|
||
#### `PersonalSign` final hash | ||
|
||
The final hash for the `PersonalSign` workflow is defined as: | ||
|
||
``` | ||
keccak256(\x19\x01 ‖ ACCOUNT_DOMAIN_SEPARATOR ‖ | ||
hashStruct(PersonalSign({ | ||
prefixed: keccak256(bytes(\x19Ethereum Signed Message:\n ‖ | ||
base10(bytes(someString).length) ‖ someString)) | ||
})) | ||
) | ||
``` | ||
|
||
where `‖` denotes the concatenation operator for bytes. | ||
|
||
In Solidity, this can be written as: | ||
|
||
```solidity | ||
finalPersonalSignHash = | ||
keccak256( | ||
abi.encodePacked( | ||
hex"1901", | ||
// Smart account domain separator. | ||
// Can be computed via `eip712Domain()` from ERC-5267. | ||
bytes32(ACCOUNT_DOMAIN_SEPARATOR), | ||
keccak256( | ||
abi.encode( | ||
// `PERSONAL_SIGN_TYPEHASH`. | ||
keccak256("PersonalSign(bytes prefixed)"), | ||
// `hash` is from `isValidSignature(hash, signature)` | ||
hash | ||
) | ||
) | ||
) | ||
); | ||
``` | ||
|
||
Here, `hash` is computed in the application contract and passed into `isValidSignature`. | ||
|
||
The smart account does not need to know how `hash` is computed. For completeness, this is how it can be computed: | ||
|
||
```solidity | ||
hash = | ||
abi.encodePacked( | ||
"\x19Ethereum Signed Message:\n", | ||
// `toString` returns the base10 representation of a uint256. | ||
LibString.toString(someString.length), | ||
// This is the original message to be signed. | ||
someString | ||
); | ||
``` | ||
|
||
#### `PersonalSign` signature | ||
|
||
The `PersonalSign` workflow does not require additional data to be appended to the `signature` passed into `isValidSignature`. | ||
|
||
### `supportsNestedTypedDataSign` function for detection | ||
|
||
To facilitate automatic detection, smart accounts SHOULD implement the following function: | ||
|
||
```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); | ||
} | ||
``` | ||
|
||
### Signature verification workflow deduction | ||
|
||
As the `isValidSignature` signature function signature is unchanged, the implementation MUST be able to deduce the type of workflow required to verify the signature. | ||
|
||
If the signature contains the correct data to reconstruct the `hash`, the `isValidSignature` function MUST perform the `TypedDataSign` workflow. | ||
Otherwise, the `isValidSignature` function MUST perform the `PersonalSign` workflow. | ||
|
||
In Solidity, the check can be written as: | ||
|
||
```solidity | ||
// If this is true, it means that the `signature` contains | ||
// the correct `APP_DOMAIN_SEPARATOR` and `contents`, | ||
// and the `TypedDataSign` workflow MUST be performed. | ||
// Otherwise, the `PersonalSign` workflow MUST be performed. | ||
hash == keccak256( | ||
abi.encodePacked( | ||
hex"1901", | ||
bytes32(APP_DOMAIN_SEPARATOR), | ||
bytes32(contents) | ||
) | ||
) | ||
``` | ||
|
||
### Conditional skipping of defensive rehashing | ||
|
||
Smart accounts MAY skip the defensive rehashing workflows if any of the following is true: | ||
|
||
- `isValidSignature` is called off-chain. | ||
- The `hash` passed into `isValidSignature` has already included the address of the smart account. | ||
|
||
As many developers may not update their applications to support the nested EIP-712 workflow, smart account implementations SHOULD try to accommodate by skipping the defensive rehashing where it is safe to do so. | ||
|
||
## Rationale | ||
|
||
### `TypedDataSign` structure | ||
|
||
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`. | ||
|
||
### `supportsNestedTypedDataSign` for detection | ||
|
||
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. | ||
|
||
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`. | ||
|
||
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. | ||
|
||
## Backwards Compatibility | ||
|
||
No backwards compatibility issues. | ||
|
||
## Reference Implementation | ||
|
||
A production ready and optimized reference implementation is provided at [`/assets/eip-7739/contracts/accounts/ERC1271.sol`]. | ||
|
||
It includes relevant complementary features required for safety, flexibility, developer experience, and user experience. | ||
|
||
The reference implementation is intentionally not minimalistic. This is to avoid repeating the mistake of [ERC-1271](./eip-1271.md), where a minimalist reference implementation is wrongly assumed to be safe for production use. | ||
|
||
## Security Considerations | ||
|
||
### Rejecting invalid `contentsTypeName` | ||
|
||
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. | ||
|
||
Requiring on-chain sanitization of `contentsTypeName` will block this phishing attack vector. | ||
|
||
## Copyright | ||
|
||
Copyright and related rights waived via [CC0](../LICENSE.md). |
Oops, something went wrong.