EIP-712: Typed structured data hashing and signing

frenchkebab·2023년 5월 9일
1

EIP / Open Source

목록 보기
3/9
post-thumbnail

Abstract

: EIP-712는 단순한 바이트스트링이 아닌 형식화된 구조물을 해싱하고 서명하기 위한 표준입니다.

  • 인코딩 함수의 정확성을 위한 이론적인 프레임워크
  • Solidity 구조체와 유사하고 호환 가능한 구조화된 데이터의 명세
  • 이러한 구조체 인스턴스를 위한 안전한 해싱 알고리즘
  • 서명 가능한 메시지 집합에 이러한 인스턴스를 안전하게 포함시키는 방법
  • 도메인 분리를 위한 확장 가능한 메커니즘
  • 새로운 RPC 호출인 eth_signTypedData
  • EVM에서 최적화된 해시 알고리즘 구현

Motivation

EIP-712이 어따가 쓰이는지는 대부분 알 것이라고 생각한다.

사용자가 이런 무작위의 byte string이 아니라

이렇게 readible한 구조화된 데이터에 sign을 하여, 본인이 무엇에 서명을 하는지를 알게끔 하도록 하기 위함이다.

Specification

상당히 복잡해보이지만... chat GPT를 돌려 번역해보면 다음과 같다.

서명 가능한 메시지 집합은 트랜잭션과 바이트스트링 𝕋 ∪ 𝔹⁸ⁿ에서 구조화된 데이터 𝕊로 확장됩니다. 이에 따라 새로운 서명 가능한 메시지 집합은 𝕋 ∪ 𝔹⁸ⁿ ∪ 𝕊입니다.

결국 서명 가능한 message의 집합에 𝕊가 추가되었다는 말인데 이게 뭔소리냐면...

encode(transaction : 𝕋)

RLP_encode(transaction) 의 집합을 의미한다.

즉 일반적인 이더리움 트랜잭션에서 EOA가 sign을 하기 전의 transaction을 말한다.
(raw transaction이 RLP encoding은 되고, sign은 하지 않은 상태)

encode(message : 𝔹⁸ⁿ)

는 이 글에서 단순히 concat이라고 보아도 무방하니 참고하자!

"\x19Ethereum Signed Message:\n" ‖ len(message) ‖ message

을 의미한다. 이전 EIP-191 글 을 참조하면 알 수 있다.

⁸ⁿ의 의미는 8bit의 배수라는 뜻으로 결국 그냥 1byte 단위라는 의미이다.

참고로 여기서 \x19는 16진법 0x19를 의미하며, 이 prefix를 붙이는 이유는 EIP-191 글 에 설명해두었다.

간략하게 설명하면 "이것은 RLP encoding된 트랜잭션이 아니오 !!"라는 의미이다.

encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊)

"\x19\x01" ‖ domainSeparator ‖ hashStruct(message)

²⁵⁶의 의미는 256bit (32byte)이며 이것은 keccak256(domainSeparator)의 결과가 32byte이기 때문이다.

여기서 domainSeperatorhashStruct는 이 표준의 핵심이며 아래에 설명할 예정이다.

참고로 이것은 EIP-191과 호환되며, version prefix는 0x01로 고정된다.
(version prefix에 대해서도 해당 글에 설명되어있다. version 0x01자체가 EIP-712를 의미한다.)

typed structured data 𝕊

결국 우리가 서명을 하고자 하는 message의 핵심 부분이다. ERC20의 경우에는 예를 들어 transferFrom에 대한 정보로 누가, 누구에게, 얼마의 토큰을 전송할지에 대한 정보일 수 있다.

struct Mail {
    address from;
    address to;
    string contents;
}

위와 같이 일반적인 solidity의 struct 구조체이다.

아래는 EIP 문서에 나와있는 definition 부분인데, 참고삼아 정리해 두었으나, 그냥 struct를 사용하기 위한 문법과 일치한다고 볼 수 있다.

정의1

구조체 타입은 이름으로 식별이 가능하며,
0개 이상의 멤버 변수를 포함한다. (당연히)

멤버 변수는 typename을 갖는다. (당연히 변수니까)

정의2 - Atomic type

그냥 solidity의 원자 타입이다. 다음과 같다.

  • bytes1 ~ bytes32
  • uint8 ~ uint256
  • int8 ~ int256
  • bool
  • address

정의3 - Dynamic type

  • bytes
  • string

정의4 - Reference type

  • array (fixed / dynamic)
  • struct (nested struct 포함)

hashStruct

hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s)) where typeHash = keccak256(encodeType(typeOf(s)))

무섭게 생겼지만 그냥

keccak256(typeHash, encodeData(s))

이다.

여기서 typeHash는 다음과 같다.

typeHash = keccak256(encodeType(typeOf(s)))

encodeTypeencodeData(s)는 아래에서 정의한다.

encodeType

name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")"

(각 member는 type ‖ " " ‖ name로 이루어져 있음)

복잡해 보이지만 별게 아니다. 아래의 예시를 보면 쉽게 이해할 수 있다.

example

struct Mail {
    address from;
    address to;
    string contents;
}

위에서 보았던 Mail struct는 아래와 같이 encode할 수 있다.

Mail(address from,address to,string contents).

typename 사이에만 공백을 두고 나머지는 다 붙여쓴다.

struct 내부에 struct가 들어갈 경우

struct Asset {
    address token;
    uint256 amount;
}

struct Person {
    address wallet;
    string name;
}

struct Transaction {
    Person from;
    Person to;
    Asset tx;
}

를 encoding하면 다음과 같다.

Transaction(Person from,Person to,Asset tx)Asset(address token,uint256 amount)Person(address wallet,string name)

여기서 AssetPerson의 순서는 단순히 알파벳 순이다.

encodeData

enc(value₁) ‖ enc(value₂) ‖ … ‖ enc(valueₙ)

각 value들은 32byte로 통일되어야 한다.

  • bytes, string의 경우에는 keccak256함수로 32byte로 뽑아내고,

  • 배열의 경우에는 keccak256( arr[0] || arr[1] || arr[2]) 처럼 각 원소를 concat하여 해싱한다.

  • struct의 경우에는 recursive하게 encoding된다. (자세한 예시는 나와있지가 않다)

다시 hashStruct 정리하면

hashStruct(s : structured data 𝕊) = keccak256(typeHash ‖ encodeData(s)) 
typeHash = keccak256(encodeType(typeOf(s)))

결국

keccak(
	keccak(encodeType) 
    ‖ enc(value₁) 
    ‖ enc(value₂) 
    ‖ … 
    ‖ enc(valueₙ))
)

이렇게 다 합쳐서 다시 keccak을 돌리게 된다.

예시

pragma solidity ^0.8.0;

contract Example {

	//structured Data 𝕊
	struct Mail {
	    address from;
	    address to;
	    string contents;
	}

	//1. Calculate TypeHash = keccak256(encodeType(typeOf(s)))
	bytes32 public constant MAIL_TYPEHASH = keccak256(
			"Mail(address from,address to,string contents)"
	);

	//2. hashStruct(s) = keccak256(typeHash ‖ encodeData(s))
	function hashStruct(Mail mail) internal pure returns (bytes32) {
			return keccak256(abi.encode(
              		// typeHash
					MAIL_TYPEHASH,
              		
              		// 3. encodeData = enc(value₁) ‖ enc(value₂) ‖ … ‖ enc(valueₙ)
					mail.from,
					mail.to,
					// bytes, string 값에는 keccak256 함수를 적용해 32 바이트 길이로 통일
					keccak256(bytes(mail.contents))
			));
	}
}

출처

여기서 abi.encode를 해주는 이유는 abi.encode가 256bit에 맞추어 정렬을 해주기 때문이다.
(uint8 이나 bool같이 256비트가 안되는 것들은 앞에 0으로 패딩을 붙여준다.)

encodeData = enc(value₁) ‖ enc(value₂) ‖ … ‖ enc(valueₙ)

에서 각 값은 32byte로 통일되어야 한다고 위에서 언급했었다.

domainSeperator

위의 예시는 hashStruct(Main mail)이였다.

domainSeparator = hashStruct(eip712Domain)
struct EIP712Domain {
	string name;
   	string version;
  	uint256 chainId;
    address verifyingContract;
    bytes32 salt;

EIP에서는 해당 순서를 지키되 필요없는 것들은 제거하면서 Dapp의 상황에 맞게 사용해야 한다고 한다.

이후의 필드는 알파벳 순서로 정렬하여 위의 struct 하단에 추가하라고 나와있다.

bytes32 public DOMAIN_SEPARATOR;
bytes32 public constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");

constructor (string memory name, string memory version) {
		//hashStruct(s) = keccak256(typeHash ‖ encodeData(s))
		DOMAIN_SEPARATOR = keccak256(
				abi.encode(
						//typeHash(eip712Domain)
						EIP712DOMAIN_TYPEHASH,
						//name
						keccak256(bytes(name)),
						//version
						keccak256(bytes(version)),
						//chaindid
						block.chainid,
						//verifyingContract
						address(this)
				)
		);
}

출처

domainSeperator의 경우 값이 고정되어 있지 않기 때문에, 굳이 struct로 선언하지 않고 이렇게 바로 계산해도 무방하다.

Upgradeable contract의 경우에는 verifyingContract의 주소가 바뀔 수 있으므로 유의하자.

생성 방법은 hashStruct값과 동일하게

keccak(
	ERC721DOMAIN_TYPEHASH,
    value1,
    value2,
    ...,
    valuen
)

의 형식이며, 동일하게 bytes, string 등의 경우 hash한 값을 사용하고 있다.

또한 address와 같이 32bytes 길이가 되지 않는 것들은 abi.encode를 통해 앞에 0으로 패딩을 해주고 있다.

verify 방법

    function verify(Mail memory mail, uint8 v, bytes32 r<, bytes32 s) internal view returns (bool) {
    // Note: we need to use `encodePacked` here instead of `encode`.
        bytes32 hash = keccak256(abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            hashStruct(mail)
        ));
        return ecrecover(hash, v, r, s) == mail.from;
    }

이 예시를 보면 이해할 수 있다.

뭔가 이것 저것 복잡해보이지만, 사실 EIP-191에서 살펴보았던 매커니즘과 동일하다.

사인하고자 하는 message가 있고, signer가 이것에 서명하여 v, r, s값을 뽑아내는 과정인 것이다.

다만 이 message가 바로 이렇게 이루어져 있는 것이다.

keccak256(abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            hashStruct(mail)
        ));

주의할 점은 여기서 실질적으로 바뀔 수 있는 값은 mail하나 뿐이다.

DOMAIN_SEPERATOR는 상수로서 contract에 저장되어 있으며 signer도 이것을 그대로 가져다가 해싱한다.

hashStruct의 경우에도 내부에 typedHash는 이미 정의가 되어 있는 상수이고, mail의 값들에 대한 부분만 바뀐다.

정리하면

sign을 하는 과정

  1. DOMAIN_SEPERATOR 의 hash값
keccak256(
	abi.encode(
    	EIP712DOMAIN_TYPEHASH,
        domain멤버값들
    )
)
  1. hashStruct의 hash값
keccak256(
	abi.encode(
    	typeHash,
        encodeData 값들 // 지금의 경우 mail의 각 값들 (얘만 변하는 값이다)
    )
)

을 다시 keccak256(1번, 2번)한 값이 최종 message이다.

이것에 sign을 해서 v, r, s 값을 뽑아낸다.

verifier에게는 변하는 값인 mail에 대한 정보와 signature(v, r, s)만 넘겨준다.

verify하는 과정

verifier는 이미EIP712DOMAIN_TYPEHASH, domain멤버값들, typeHash에 대해 알고있다.
(당연히 verifier에게 먼저 정의된 것을 signer가 가져갔다.)

따라서 mail의 값을 받아서 따로 새로운 message hash를 만들어 낸다.

여기서 verifier가 검증을 위해 새로 생성한 message를 msg라고 하면,

ecrecover(msg, v, r, s);

를 했을 때 signer의 주소가 return되면 정상적인 verify가 완료된 것이다.

이 글에서는 따로 javascript에서 다루지는 않는다.

마치며

domain seperator를 사용하면 다른 컨트랙트의 동일한 함수에 대한 Replay Attack은 막을 수 있겠지만, 동일한 컨트랙트에 여러번 signature가 사용되는 것은 막지 못할 수도 있다.

Uniswap V2 ERC20Permit 함수처럼 owner의 nonce를 증가시키는 등 각 Dapp의 상황에 맞게 로직을 만들어 Replay Attack을 방지하는 것이 필요하다.

EIP-712가 항상 헷갈렸는데 하루종일 이것에 대해 정리하고 생각하면서 이제 확실하게 이해가 된다.

혹시 이 글을 읽는 당신도 헷갈려서 슥슥 스크롤을 내렸다면 몇 시간이 걸리든 다른 자료와 함께 찾아보길 바란다.

이해를 완전히 하고나니 그렇게 어려운 것이 아니였다 ㅠ.ㅠ

profile
Solidity에 대해 공부하고 있습니다.

0개의 댓글