ERC-6492 배포 전 컨트랙트의 서명 검증 (Signature Validation for Predeploy Contracts)
Final · 서명·인증 · 2023-02-10 작성
아직 배포되지 않은 컨트랙트 지갑이 만든 서명을 검증할 수 있게 합니다. 서명 뒤에 배포 정보를 붙여 보내면, 검증하는 쪽이 필요할 때 컨트랙트를 먼저 배포한 뒤 ERC-1271 검증을 이어 갑니다.
왜 필요한가
계정 추상화(account abstraction) 지갑이 퍼지면서 컨트랙트 지갑은 사용자가 첫 거래를 보낼 때 비로소 컨트랙트를 배포하는 방식을 많이 택합니다. 지갑을 쓰기 전에 배포 단계를 따로 거치는 부담을 덜기 위해서입니다. 그런데 많은 앱은 거래뿐 아니라 로그인에도 서명을 요구합니다. 컨트랙트가 만든 서명은 ERC-1271 에 따라 그 컨트랙트에 직접 물어 검증하는데, 컨트랙트가 아직 배포되지 않았으면 물어볼 대상이 없어 검증할 수 없습니다. 그래서 컨트랙트 지갑은 실제 배포가 이루어지기 전까지 메시지에 서명하는 능력이 제한되어 왔습니다. 배포 전 컨트랙트가 서명을 못 하는 것은 ERC-1271 의 오랜 한계이기도 했습니다.
구현해야 하는 것
function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4 magicValue);: ERC-1271 이 정한 함수입니다. 해시와 서명을 받아 유효하면 매직 값 0x1626ba7e 를 돌려줍니다.error ERC1271Revert(bytes error);: 참조 구현에서 ERC-1271 검증 호출이 되돌려졌을 때 그 오류 데이터를 담아 되돌리는 오류입니다.error ERC6492DeployFailed(bytes error);: 참조 구현에서 팩토리 호출(배포 또는 준비 거래)이 실패했을 때 오류 데이터를 담아 되돌리는 오류입니다.function isValidSigWithSideEffects(address _signer, bytes32 _hash, bytes calldata _signature) external returns (bool): allowSideEffects 를 true 로 두고 검증합니다. 재진입에 안전하지 않지만 일부 경우 가스가 덜 듭니다.function isValidSig(address _signer, bytes32 _hash, bytes calldata _signature) external returns (bool): 재진입에 안전한 검증 함수입니다. 배포 부작용이 있으면 revert 데이터로 결과를 받아 상태 변경을 되돌립니다.constructor (address _signer, bytes32 _hash, bytes memory _signature): ValidateSigOffchain 의 생성자입니다. eth_call 한 번으로 검증을 끝내기 위해 검증기를 만들고 결과 한 바이트를 반환합니다.
심층 설명
감싸기 서명 형식과 magicBytes
배포 전 컨트랙트는 서명을 `concat(abi.encode((create2Factory, factoryCalldata, originalERC1271Signature), (address, bytes, bytes)), magicBytes)` 형태로 감쌉니다. magicBytes 는 `0x6492649264926492649264926492649264926492649264926492649264926492` 로 정해야 하며(MUST), 검증자는 서명이 이 값으로 끝나는지 보고 감싸기 형식을 알아냅니다. 감싸기 형식이 감지되면 검증자는 isValidSignature 를 호출하기 전에 반드시 컨트랙트를 배포해야 합니다(MUST). salt 와 bytecode 대신 factoryCalldata 를 넘기는 이유는 어떤 팩토리 인터페이스와도 맞추기 위해서입니다. ERC-1271 검증은 이미 계정 주소를 알고 있다는 전제이므로 create2Factory, salt, bytecode 로 주소를 다시 계산할 필요가 없습니다. 배포 주소가 항상 예측 가능한 CREATE2 컨트랙트와 함께 쓰는 것이 권장됩니다(RECOMMENDED).
서명자 쪽 세 가지 경우
서명하는 컨트랙트는 보통 컨트랙트 지갑이지만 ERC-1271 을 구현하고 카운터팩추얼로 배포되는 어떤 컨트랙트든 될 수 있습니다. 첫째, 이미 배포되어 있으면 일반 ERC-1271 서명을 만듭니다. 둘째, 아직 배포되지 않았으면 create2Factory 와 factoryCalldata 로 감쌉니다. 셋째, 배포는 되었지만 ERC-1271 검증 준비가 안 된 상태라면 `concat(abi.encode((prepareTo, prepareData, originalERC1271Signature), (address, bytes, bytes)), magicBytes)` 로 감쌉니다. 이때 prepareTo 와 prepareData 에는 컨트랙트를 ERC-1271 검증 가능한 상태로 만드는 거래(예: migrate 나 update 호출)가 들어가야 합니다.
검증자 쪽 필수 순서
전체 검증은 다음 순서를 반드시(MUST) 따릅니다. 1) 서명이 magicBytes 로 끝나면 멀티콜 컨트랙트에 eth_call 을 보내, 먼저 팩토리를 factoryCalldata 로 호출해 컨트랙트가 없을 때 배포하고 이어서 감싸기를 벗긴 서명으로 isValidSignature 를 호출합니다. 2) 주소에 컨트랙트 코드가 있으면 평소처럼 isValidSignature 로 ERC-1271 검증을 합니다. 3) ERC-1271 검증이 실패했고 지갑에 이미 코드가 있어 팩토리 배포 호출을 건너뛰었다면, factoryCalldata 거래를 실행한 뒤 isValidSignature 를 다시 시도합니다. 4) 주소에 컨트랙트 코드가 없으면 ecrecover 검증을 시도합니다.
순서를 이렇게 정한 이유
magicBytes 확인은 일반 ERC-1271 확인보다 먼저 해야 합니다(MUST). 그래야 컨트랙트가 배포된 뒤에도 배포 전에 만든 서명이 유효하게 남습니다. magicBytes 확인은 ecrecover 보다도 먼저 해야 하는데(MUST), 배포 전 컨트랙트 서명임이 분명한 것을 ecrecover 로 검증하려 드는 일을 막기 위해서입니다. ecrecover 는 ERC-1271 검증보다 앞서면 안 됩니다(MUST NOT). 컨트랙트가 쓰는 서명 형식이 우연히 다른 주소의 외부 소유 계정(EOA) 서명으로도 유효할 수 있기 때문입니다. 그 EOA 가 제어하는 지갑 컨트랙트가 그런 예입니다. 지갑에 이미 코드가 있는데 배포 접두가 붙어 있으면, 배포 전에 서명했는지 준비가 덜 된 상태인지 구분할 수 없으므로 두 가능성을 모두 시도합니다. magicBytes 가 `0x92` 로 끝나는 것은 r, s, v 형식의 ecrecover 서명과 충돌하지 않게 하기 위함입니다. `0x92` 는 유효한 v 값이 아닙니다. bytes32 길이로 둔 것도 일반 ERC-1271 서명과의 충돌을 피하려는 것입니다.
참조 구현 UniversalSigValidator
참조 구현은 온체인과 오프체인 모두에서 쓸 수 있는 범용 검증 컨트랙트로, 싱글턴 배포를 전제합니다. 이 표준, ERC-1271, 전통적인 ecrecover 서명을 모두 검증합니다. 최종 다이제스트(_hash)를 검증하므로 EIP-712 도 함께 지원됩니다. isValidSigImpl 은 서명 끝 32바이트가 ERC6492_DETECTION_SUFFIX 와 같은지로 카운터팩추얼 여부를 판단하고 컨트랙트 코드 길이가 0 이거나 tryPrepare 가 참이면 create2Factory.call(factoryCalldata) 를 실행합니다. 실패하면 ERC6492DeployFailed 로 되돌립니다. 이어서 isValidSignature 결과가 ERC1271_SUCCESS(0x1626ba7e) 인지 확인합니다. 실패했거나 호출이 되돌려졌는데 코드가 있으면 tryPrepare 를 참으로 바꿔 한 번 더 시도합니다. ecrecover 단계에서는 서명 길이가 65 바이트여야 하고 v 는 27 또는 28 만 허용합니다.
온체인 검증 두 가지 방식
온체인에서는 두 함수를 쓸 수 있습니다. isValidSig(_signer, _hash, _signature) 는 서명 유효 여부를 bool 로 돌려주며 재진입에 안전합니다. 내부적으로 isValidSigImpl 을 allowSideEffects 를 false 로 호출하고 배포로 상태가 바뀌었을 때는 결과를 한 바이트 revert 데이터로 받아 상태 변경을 되돌립니다. 길이가 1 인 revert 데이터는 결과값으로 해석하고 그 외 오류는 그대로 전달합니다. isValidSigWithSideEffects(_signer, _hash, _signature) 는 같은 결과를 내지만 재진입에 안전하지 않은 대신 일부 경우 가스 효율이 더 좋습니다. 두 함수 모두 내부 호출이 되돌려지면 함께 되돌려질 수 있습니다.
오프체인 검증은 eth_call 한 번
ValidateSigOffchain 은 생성자 안에서 UniversalSigValidator 를 새로 만들고 isValidSigWithSideEffects 를 호출한 뒤 결과를 한 바이트로 반환합니다. 미리 배포된 컨트랙트 없이 eth_call 한 번으로 범용 검증을 끝낼 수 있습니다. ethers 예시는 ValidateSigOffchain 바이트코드 뒤에 signer, hash, signature 를 ['address', 'bytes32', 'bytes'] 로 인코딩해 이어 붙인 데이터를 provider.call 로 보내고 반환값이 '0x01' 이면 유효하다고 봅니다. Ambire 의 signature-validator 같은 라이브러리를 써도 됩니다.
호환성과 일반 컨트랙트 서명에의 활용
이 표준은 ERC-1271 등 기존 서명 검증 작업과 호환됩니다. EOA 서명과 EIP-712 타입 데이터를 포함한 모든 서명 유형을 쉽게 검증할 수 있게 합니다. 감싸기 형식은 배포 전 컨트랙트만이 아니라 모든 컨트랙트 서명에 일반 ERC-1271 대신 써도 됩니다. 그러면 magicBytes 덕분에 블록체인을 조회하지 않고도 컨트랙트 서명임을 바로 알 수 있고 ecrecover 처럼 create2Factory 와 factoryCalldata 만으로 서명에서 주소를 복원할 수 있습니다.
쓸 때 주의할 점
- ERC-1271 과 같은 주의점: ERC-1271 의 보안 고려 사항이 그대로 적용됩니다.
- 배포에 따른 재진입 위험: 컨트랙트 배포에는 STATICCALL 이 아닌 CALL 이 필요해 재진입 문제가 생깁니다. 참조 구현은 부작용이 있으면 검증 함수가 항상 revert 하고 revert 데이터에서 실제 결과를 꺼내는 방식으로 이를 완화합니다. 재진입이 문제가 아닌 경우를 위해 isValidSigWithSideEffects 를 따로 제공합니다. 온체인 검증은 보통 지갑이 이미 배포되어 있다는 전제라서 이 표준은 오프체인 검증에 더 자주 쓰일 가능성이 큽니다.
- 배포 시점의 권한 설정: 컨트랙트가 배포 시점에 올바른 권한으로 설정되어 의미 있는 서명 검증이 가능한지는 이 표준 범위 밖이며 구현에 달려 있습니다. 다만 CREATE2 특성상 서명 안의 바이트코드나 생성자 호출 데이터를 바꾸면 배포 주소가 달라져 검증이 실패하므로, 이를 통한 권한 상승은 불가능합니다.
- 인증 방식이 바뀌는 컨트랙트 계정: 컨트랙트 계정은 인증 방식을 동적으로 바꿀 수 있습니다. 이 표준은 배포 전 서명을 검증할 때도 컨트랙트가 이미 배포되어 있으면 현재 배포된 버전을 호출해 검사하도록 설계되어 이 문제를 완화합니다.
- 재전송 방지와 다른 네트워크: 대부분의 경우 재전송(replay) 방지를 구현해야 합니다. 이 표준은 여기에 한 차원을 더합니다. 인가 키 변경으로 무효가 된 서명이라도, 배포 시점에 유효했고 같은 팩토리 주소와 바이트코드로 다른 네트워크에 배포할 수 있다면 그 네트워크에서는 검증을 통과할 수 있습니다.