Readable Typed Signatures for Smart Accounts
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 verification using specific nested EIP-712 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 using the isValidSignature
function.
A straightforward implementation as shown below, is vulnerable to signature replay attacks.
/// @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, 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. 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
The following dependencies are REQUIRED:
-
EIP-712 Typed structured data hashing and signing.
Provides the relevant typed data hashing logic internally, which is required to construct the final hashes. -
ERC-1271 Standard Signature Validation Method for Contracts.
Provides theisValidSignature(bytes32 hash, bytes calldata signature)
function. -
ERC-5267 Retrieval of EIP-712 domain.
Provides theeip712Domain()
function which is required to compute the final hashes.
This standard defines the behavior of the isValidSignature
function for ERC-1271, 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.
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
}))
)
where ‖
denotes the concatenation operator for bytes.
In Solidity, this can be written as:
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)
)
)
)
);
where typedDataSignTypehash
is:
typedDataSignTypehash =
keccak256(
abi.encodePacked(
"TypedDataSign(",
contentsName, " contents,",
"string name,",
"string version,",
"uint256 chainId,",
"address verifyingContract,",
"bytes32 salt"
")",
contentsType
)
);
If contentsType
is "Mail(address from,address to,string message)"
, then contentsName
will be "Mail"
.
The contentsName
is the substring of contentsType
up to (excluding) the first instance of "("
:
In Solidity, this can be written as:
// `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.
contentsName =
LibString.slice(
contentsType,
0, // Start byte index.
LibString.indexOf(contentsType, "(") // End byte index (exclusive).
);
A copy of the LibString
Solidity library is provided for completeness.
For safety, it is RECOMMENDED to treat the signature as invalid if any of the following is true:
contentsName
is the empty string (i.e.bytes(contentsName).length == 0
).contentsName
starts with any of the following bytesabcdefghijklmnopqrstuvwxyz(
.contentsName
contains any of the following bytes, )\x00
.
TypedDataSign
signature
The signature
passed into isValidSignature
will be changed to:
originalSignature ‖ APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsDescription ‖ uint16(contentsDescription.length)
where:
contents
is the bytes32 struct hash of the original struct.contentsDescription
is either:contentsType
(implicit mode),
wherecontentsType
starts withcontentsName
.contentsType ‖ contentsName
(explicit mode),
wherecontentsType
may not necessarily start withcontentsName
.
In Solidity, this can be written as:
signature =
abi.encodePacked(
bytes(originalSignature),
bytes32(APP_DOMAIN_SEPARATOR),
bytes32(contents),
bytes(contentsDescription),
uint16(contentsDescription.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:
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.
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:
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:
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
.
Support detection
Smart accounts SHOULD return bytes4(0x77390001)
for isValidSignature(0x7739773977397739773977397739773977397739773977397739773977397739, "")
to indicate support for this standard.
The magic number bytes4(0x77390001)
MAY be incremented if this standard gets updated.
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:
// 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 intoisValidSignature
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 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.
The bytes1 fields
bitmap and uint256[] extensions
array in ERC-5267 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.
contentsDescription
with implicit and explicit modes
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.
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
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 implementation is provided for reference.
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, where a minimalist reference implementation is wrongly assumed to be safe for production use.
Security Considerations
Rejecting invalid contentsName
Current major implementations of eth_signTypedData
do not sanitize the names of custom types.
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 contentsName
will block this phishing attack vector.
Copyright
Copyright and related rights waived via CC0.