ECDSA, EIP-712, 서명 검증 리뷰
도입
permit, ERC-3009, x402 결제는 모두 사용자가 직접 transaction을 보내지 않고 서명으로 권한을 위임한다는 공통점이 있다. 이 UX는 gasless checkout을 가능하게 하지만, 잘못 설계하면 사용자가 다른 chain, 다른 contract, 다른 spender, 다른 amount에 서명한 메시지가 재사용될 수 있다.
서명 검증은 컨트랙트 함수 하나가 아니다. Backend가 만드는 payload, frontend가 보여주는 typed data, wallet이 서명하는 domain, contract가 검증하는 nonce와 deadline, smart account 지원 여부가 함께 맞아야 한다.
여기서는 서명 결제의 공격면과 리뷰 항목을 다룬다. 제품 선택 관점의 permit/ERC-3009 비교는 Permit, ERC-3009, 서명 결제를 먼저 기준으로 삼으면 중복처럼 느껴지지 않는다.
학습 목표
- 서명 검증의 replay와 domain 위험을 테스트한다.
- 서명 요청 UX와 컨트랙트 검증 로직을 함께 리뷰한다.
- EOA signature, EIP-1271 smart account signature, Permit2 approval을 구분한다.
개념 설명
핵심 개념
- 서명 검증의 replay와 domain 위험을 테스트한다.
- 서명 요청 UX와 컨트랙트 검증 로직을 함께 리뷰한다.
검증 지점
- 권한 경계가 테스트되는가
- 불변조건이 실패 상태를 잡는가
- domain separator 구성요소를 설명했다.
실습 산출물
- EIP-712 domain 검증표 만들기
- 서명 negative test 8개 작성하기
- 운영 변경이 모니터링되는가
1. 서명 결제는 세 층으로 본다
| 층 | 질문 | 실패하면 |
|---|---|---|
| Message schema | 사용자가 무엇에 서명하는가? | spender/recipient/amount 혼동 |
| Domain separation | 어느 chain과 contract에서만 유효한가? | cross-chain 또는 cross-contract replay |
| Execution logic | nonce, deadline, signer, state를 어떻게 검사하는가? | 중복 결제, 만료 서명 실행, frozen 우회 |
EIP-712는 typed structured data를 hash/sign하는 표준이다. 사용자는 사람이 읽을 수 있는 field를 보고 서명할 수 있고, contract는 domain separator와 struct hash를 이용해 의도한 메시지만 검증한다.
2. Permit, ERC-3009, Permit2는 승인 모델이 다르다
| 방식 | 무엇을 승인하는가 | 주요 검토 항목 |
|---|---|---|
ERC-2612 permit | allowance 생성 | owner, spender, value, nonce, deadline |
ERC-3009 transferWithAuthorization | 특정 transfer 자체 | from, to, value, validAfter, validBefore, nonce |
| Permit2 | Permit2 contract에 대한 승인/서명 flow | Permit2 contract approval, spender, amount limit, expiry |
| x402-style payment | request/response 단위 결제 authorization | recipient, amount, resource, expiry, replay scope |
permit은 결제가 아니다. Permit이 성공해도 이후 transferFrom이 실패할 수 있다. ERC-3009는 transfer 자체를 승인하므로 checkout에 더 직접적이지만 nonce와 valid window 관리가 중요하다. Permit2는 token contract allowance가 아니라 Permit2 contract를 중심으로 권한이 모이므로 사용자 설명과 revoke UX가 필요하다.
3. 직접 ecrecover를 쓸 때의 위험
학습용 MockStablecoin은 EIP-712 digest를 만들고 ecrecover로 signer를 확인할 수 있다. 구조를 배우기에는 좋지만 production code에서는 아래를 직접 처리해야 한다.
| 항목 | 위험 |
|---|---|
s malleability | 같은 의미의 다른 signature가 생길 수 있다. |
v 값 처리 | wallet/encoding 차이를 잘못 처리할 수 있다. |
| zero address | invalid signature가 address(0)로 이어질 수 있다. |
| EIP-1271 | smart contract wallet 서명을 지원하지 못한다. |
| domain separator | chainId, verifyingContract가 틀리면 replay 위험이 생긴다. |
OpenZeppelin의 ECDSA/EIP712/SignatureChecker 같은 검증된 helper를 쓰는 이유가 여기에 있다. 특히 smart account를 지원하려면 EOA의 ECDSA.recover만으로 충분하지 않고 EIP-1271의 isValidSignature 경로를 검토해야 한다.
4. Domain separator 검수표
| Field | 확인할 것 |
|---|---|
name | token/payment product 이름이 의도대로 고정되는가? |
version | upgrade 또는 schema 변경 시 signature compatibility를 어떻게 다루는가? |
chainId | fork나 다른 network에서 재사용되지 않는가? |
verifyingContract | 다른 contract에서 replay되지 않는가? |
| struct type hash | backend/frontend/contract가 같은 schema를 쓰는가? |
| nonce | 목적별 nonce가 독립적인가? |
| deadline/valid window | 오래된 signature가 실행되지 않는가? |
Upgrade가 있으면 domain이 더 민감해진다. Proxy address는 유지되지만 implementation과 version 정책은 바뀔 수 있다. 기존 signature가 upgrade 후에도 유효해야 하는지, 무효화해야 하는지 release note에 남겨야 한다.
5. Negative test 목록
| 테스트 | 기대 결과 |
|---|---|
잘못된 spender로 permit 제출 | 실패 |
잘못된 recipient로 ERC-3009 제출 | 실패 |
| 서명 amount와 실행 amount 불일치 | 실패 |
| 만료된 signature 제출 | 상태 변경 없이 실패 |
| 같은 nonce 재사용 | 두 번째 실행 실패 |
| 다른 chainId/domain으로 만든 signature | 실패 |
| frozen account가 signature flow 사용 | 실패 |
| smart account signer인데 EIP-1271 미지원 | 명시적 unsupported 또는 실패 |
| zero address recover | 실패 |
| frontend/backend schema mismatch | 테스트 fixture 불일치 |
Negative test는 성공 테스트보다 중요하다. 서명 검증은 "맞는 서명이 된다"보다 "틀린 서명이 절대 되지 않는다"를 확인하는 영역이다.
6. 서명 UX 검수표
| 화면 field | 사용자에게 필요한 이유 |
|---|---|
| spender 또는 recipient | 누가 토큰을 가져가거나 받을지 확인 |
| amount와 token | 얼마를 어떤 자산으로 승인하는지 확인 |
| chain/network | 다른 chain replay와 사용자의 착각 방지 |
| verifying contract 또는 product name | 어떤 contract/product에 권한을 주는지 확인 |
| deadline 또는 valid window | 서명이 언제까지 유효한지 확인 |
| nonce/authorization id | support와 replay 조사에 필요 |
| Permit2 contract 사용 여부 | 권한이 token contract가 아닌 Permit2에 모임을 안내 |
서명 UI는 보안 기능이다. 사용자가 이해할 수 없는 typed data는 phishing 방어에 약하다. 개발팀은 frontend fixture와 contract type hash가 같은지 테스트해야 한다.
7. Smart account와 EIP-1271
EIP-1271은 contract account가 isValidSignature로 signature 유효성을 알려주는 표준이다. Safe 같은 smart account는 EOA private key 하나로 서명하지 않을 수 있다. 따라서 다음 결정을 문서화한다.
| 결정 항목 | 선택지 |
|---|---|
| Smart account 지원 여부 | 지원, 미지원, 특정 wallet만 지원 |
| 검증 방식 | OpenZeppelin SignatureChecker, 직접 EIP-1271 call, backend precheck |
| 실패 UX | unsupported wallet, pending wallet approval, invalid signature |
| 테스트 | EOA signature, valid EIP-1271, invalid magic value, state-dependent rejection |
Smart account를 미지원으로 둘 수는 있다. 다만 사용자가 왜 결제가 실패했는지 명확히 알려야 하고, 제품 요구사항에서 지원 범위를 숨기면 안 된다.
코드로 확인하기
앞의 보안 기준을 코드와 테스트로 확인한다. 함수가 어떤 전제를 세우고, 테스트가 어떤 실패 조건을 고정하는지 함께 읽는다.
컨트랙트OpenZeppelin ECDSA + EIP712 사용 — production 후보 코드
직접
ecrecover를 호출하지 않고 OZ가 검증한 헬퍼를 통해 malleability(s값 상한)·zero address 복구를 차단한다.
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";contract PaymentEIP712 is EIP712 { using ECDSA for bytes32; bytes32 private constant PAYMENT_TYPEHASH = keccak256( "Payment(address from,address to,uint256 value,uint256 validBefore,bytes32 nonce)" ); constructor() EIP712("Checkout", "1") {} function _verify( address signer, address from, address to, uint256 value, uint256 validBefore, bytes32 nonce, bytes calldata signature ) internal view { bytes32 structHash = keccak256( abi.encode(PAYMENT_TYPEHASH, from, to, value, validBefore, nonce) ); bytes32 digest = _hashTypedDataV4(structHash); // EOA + EIP-1271 smart account 둘 다 지원 if (!SignatureChecker.isValidSignatureNow(signer, digest, signature)) { revert InvalidSignature(); } } error InvalidSignature();}컨트랙트Negative test — signature 우회 시도 5종
production에 가까운 stablecoin이면 아래 5종 negative test가 모두 PASS 해야 한다.
import {Test} from "forge-std/Test.sol";interface CheckoutAuthorizationTarget { function payWithAuthorization( address signer, address from, address to, uint256 value, uint256 validBefore, bytes32 nonce, bytes calldata signature ) external;}contract PaymentSignatureTest is Test { uint256 private constant SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; CheckoutAuthorizationTarget target; // setUp에서 배포된 checkout contract로 연결 uint256 signerKey = 0xA11CE; address signer; address merchant; uint256 value = 100e6; uint256 validBefore; bytes32 nonce = keccak256("invoice-42"); function setUp() public { signer = vm.addr(signerKey); merchant = makeAddr("merchant"); validBefore = block.timestamp + 30 minutes; // target = CheckoutAuthorizationTarget(address(new CheckoutAuthorizationHarness())); } // 1) malleability — 위·아래 s 값 모두 거부되어야 한다 function test_RejectsHighSValue() public { bytes memory sig = _highSSignatureFromFixture(); vm.expectRevert(); // OZ ECDSA가 InvalidSignatureS 로 거절 target.payWithAuthorization(signer, signer, merchant, value, validBefore, nonce, sig); } // 2) wrong domain — 다른 chainId function test_RejectsCrossChainReplay() public { /* ... */ } // 3) wrong verifyingContract function test_RejectsCrossContractReplay() public { /* ... */ } // 4) expired window function test_RejectsExpiredAuthorization() public { /* ... */ } // 5) reused nonce function test_RejectsReusedNonce() public { /* ... */ } function _highSSignatureFromFixture() internal returns (bytes memory) { bytes32 fixtureDigest = keccak256("fixture:eip712-payment"); (uint8 v, bytes32 r, bytes32 lowS) = vm.sign(signerKey, fixtureDigest); bytes32 highS = bytes32(SECP256K1_N - uint256(lowS)); uint8 flippedV = v == 27 ? 28 : 27; return abi.encodePacked(r, highS, flippedV); }}클라이언트서명 UI — 사용자가 봐야 할 5개 필드
사용자가 서명 다이얼로그에서 spender/recipient/amount/token/chain을 보지 못하면 phishing UX다. wallet의 typed data 렌더링이 type을 그대로 보여주도록 EIP-712 schema를 작성한다.
import { signTypedData } from "@wagmi/core";const payload = { domain: { name: "Checkout", version: "1", chainId: 1, // 사용자에게 표시됨 verifyingContract: CHECKOUT_ADDRESS // 사용자에게 표시됨 }, types: { Payment: [ { name: "from", type: "address" }, // 사용자에게 표시됨 { name: "to", type: "address" }, // ← merchant 주소가 보여야 함 { name: "value", type: "uint256" }, // 사용자에게 표시됨 { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" } ] }, primaryType: "Payment" as const, message: { from, to: merchant, value, validBefore, nonce }};const signature = await signTypedData(config, payload);// dialog 검수: spender/merchant/amount/token/chain 5개 필드가 모두 표시되는지 fixture test 로 보장강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| Domain | signature가 어느 chain/contract에서만 유효한가? | domain 검증표 |
| Nonce/expiry | signature가 한 번만, 정해진 시간 안에 쓰이는가? | negative test 목록 |
| UX | 사용자가 spender/recipient/amount/token/chain을 볼 수 있는가? | 서명 UI 검수표 |
| Account type | EOA와 smart account를 어떻게 구분하는가? | EIP-1271 지원 정책 |
실무 예시
컨트랙트[CLIENT] 상황: checkout이 permit + transferFrom으로 결제를 처리한다. 사용자는 100 USDC를 merchant에게 보내는 줄 알고 서명했지만, typed data 화면에는 spender만 보이고 merchant recipient가 보이지 않는다.
| 리뷰 질문 | 문제 | 수정 방향 |
|---|---|---|
| Permit은 결제인가? | allowance만 만들고 결제는 transferFrom에서 일어난다. | permit과 transfer 실행을 하나의 payment state로 묶는다. |
| Recipient가 보이는가? | 사용자는 spender만 보고 merchant를 모른다. | checkout 화면에 recipient/merchant와 amount를 별도 표시한다. |
| Transfer 실패 상태가 있는가? | permit 성공 후 transfer 실패 가능 | PermitSubmitted, TransferFailed 상태를 둔다. |
| Nonce가 재사용되는가? | replay 위험 | nonce replay test와 dashboard signal을 둔다. |
이 예시는 컨트랙트 검증과 UX 검수가 동시에 필요하다는 점을 보여준다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
ecrecover로 주소가 나오면 검증이 끝났다고 본다. | malleability, zero address, domain, nonce, EIP-1271을 봐야 한다. |
permit 성공을 결제 성공으로 처리한다. | transferFrom까지 성공해야 결제다. |
| smart account도 EOA처럼 recover된다고 생각한다. | EIP-1271 검증 경로가 필요하다. |
| Permit2를 쓰면 allowance 위험이 사라진다고 본다. | Permit2 contract에 권한이 모이고 expiry/amount/revoke UX가 필요하다. |
실습 과제
- 컨트랙트EIP-712 domain 검증표 만들기: name, version, chainId, verifyingContract, salt/extension 사용 여부를 적고 잘못된 chain/contract/domain에서 replay가 실패하는 테스트를 정의한다.
- 컨트랙트서명 negative test 8개 작성하기: wrong spender, wrong recipient, wrong amount, expired signature, reused nonce, wrong chainId, frozen signer, smart account unsupported 상황을 테스트 케이스로 작성한다.
- 클라이언트서명 UX 검수표 작성하기: 사용자 화면에 spender, recipient, amount, token, chain, deadline, nonce/authorization id, Permit2 contract가 어떻게 표시되어야 하는지 체크리스트로 만든다.
완료 기준
- domain separator 구성요소를 설명했다.
- negative test 5개를 작성했다.
- 서명 UI 검수표를 만들었다.
- EIP-1271/Permit2 지원 여부와 제한사항을 문서화했다.
근거 자료
- ECDSA EIP712 서명검증: 02-보안-테스트/07-ECDSA-EIP712-서명검증.md
- EIP-712: Typed Structured Data Hashing and Signing: https://eips.ethereum.org/EIPS/eip-712
- ERC-2612: Permit Extension for EIP-20 Signed Approvals: https://eips.ethereum.org/EIPS/eip-2612
- ERC-3009: Transfer With Authorization: https://eips.ethereum.org/EIPS/eip-3009
- ERC-1271: Standard Signature Validation Method for Contracts: https://eips.ethereum.org/EIPS/eip-1271
- OpenZeppelin Cryptography Utilities: https://docs.openzeppelin.com/contracts/api/utils#Cryptography
- Uniswap Permit2 Approval: https://api-docs.uniswap.org/guides/permit2