SettleLab
전체 코스
LESSON 06Security Testing

ECDSA, EIP-712, 서명 검증 리뷰

심화45분근거 7

학습 결과

  • 서명 검증의 replay와 domain 위험을 테스트한다.
  • 서명 요청 UX와 컨트랙트 검증 로직을 함께 리뷰한다.
  • EOA signature, EIP-1271 smart account signature, Permit2 approval을 구분한다.

선행 조건

  • Permit, ERC-3009, 서명 결제

완료 기준

  • domain separator 구성요소를 설명했다.
  • negative test 5개를 작성했다.
  • 서명 UI 검수표를 만들었다.

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을 구분한다.

개념 설명

구조 맵가로 스크롤 · 크게 보기 지원
ECDSA, EIP-712, 서명 검증 리뷰 구조 맵이 시각화는 보안 리뷰와 테스트 캠페인에서 actor, 권한, 데이터 증거가 어느 레이어에서 갈라지는지를 보여주며, 'ECDSA, EIP-712, 서명 검증 리뷰'에서 남겨야 할 설계 증거를 좁힌다.
01

핵심 개념

  • 서명 검증의 replay와 domain 위험을 테스트한다.
  • 서명 요청 UX와 컨트랙트 검증 로직을 함께 리뷰한다.
02

검증 지점

  • 권한 경계가 테스트되는가
  • 불변조건이 실패 상태를 잡는가
  • domain separator 구성요소를 설명했다.
03

실습 산출물

  • EIP-712 domain 검증표 만들기
  • 서명 negative test 8개 작성하기
  • 운영 변경이 모니터링되는가
크게 보기
01

핵심 개념

  • 서명 검증의 replay와 domain 위험을 테스트한다.
  • 서명 요청 UX와 컨트랙트 검증 로직을 함께 리뷰한다.
02

검증 지점

  • 권한 경계가 테스트되는가
  • 불변조건이 실패 상태를 잡는가
  • domain separator 구성요소를 설명했다.
03

실습 산출물

  • EIP-712 domain 검증표 만들기
  • 서명 negative test 8개 작성하기
  • 운영 변경이 모니터링되는가

1. 서명 결제는 세 층으로 본다

표 자료가로 스크롤 · 크게 보기 지원
질문실패하면
Message schema사용자가 무엇에 서명하는가?spender/recipient/amount 혼동
Domain separation어느 chain과 contract에서만 유효한가?cross-chain 또는 cross-contract replay
Execution logicnonce, deadline, signer, state를 어떻게 검사하는가?중복 결제, 만료 서명 실행, frozen 우회
크게 보기
질문실패하면
Message schema사용자가 무엇에 서명하는가?spender/recipient/amount 혼동
Domain separation어느 chain과 contract에서만 유효한가?cross-chain 또는 cross-contract replay
Execution logicnonce, deadline, signer, state를 어떻게 검사하는가?중복 결제, 만료 서명 실행, frozen 우회

EIP-712는 typed structured data를 hash/sign하는 표준이다. 사용자는 사람이 읽을 수 있는 field를 보고 서명할 수 있고, contract는 domain separator와 struct hash를 이용해 의도한 메시지만 검증한다.

2. Permit, ERC-3009, Permit2는 승인 모델이 다르다

표 자료가로 스크롤 · 크게 보기 지원
방식무엇을 승인하는가주요 검토 항목
ERC-2612 permitallowance 생성owner, spender, value, nonce, deadline
ERC-3009 transferWithAuthorization특정 transfer 자체from, to, value, validAfter, validBefore, nonce
Permit2Permit2 contract에 대한 승인/서명 flowPermit2 contract approval, spender, amount limit, expiry
x402-style paymentrequest/response 단위 결제 authorizationrecipient, amount, resource, expiry, replay scope
크게 보기
방식무엇을 승인하는가주요 검토 항목
ERC-2612 permitallowance 생성owner, spender, value, nonce, deadline
ERC-3009 transferWithAuthorization특정 transfer 자체from, to, value, validAfter, validBefore, nonce
Permit2Permit2 contract에 대한 승인/서명 flowPermit2 contract approval, spender, amount limit, expiry
x402-style paymentrequest/response 단위 결제 authorizationrecipient, 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 addressinvalid signature가 address(0)로 이어질 수 있다.
EIP-1271smart contract wallet 서명을 지원하지 못한다.
domain separatorchainId, verifyingContract가 틀리면 replay 위험이 생긴다.
크게 보기
항목위험
s malleability같은 의미의 다른 signature가 생길 수 있다.
v 값 처리wallet/encoding 차이를 잘못 처리할 수 있다.
zero addressinvalid signature가 address(0)로 이어질 수 있다.
EIP-1271smart contract wallet 서명을 지원하지 못한다.
domain separatorchainId, verifyingContract가 틀리면 replay 위험이 생긴다.

OpenZeppelin의 ECDSA/EIP712/SignatureChecker 같은 검증된 helper를 쓰는 이유가 여기에 있다. 특히 smart account를 지원하려면 EOA의 ECDSA.recover만으로 충분하지 않고 EIP-1271의 isValidSignature 경로를 검토해야 한다.

4. Domain separator 검수표

표 자료가로 스크롤 · 크게 보기 지원
Field확인할 것
nametoken/payment product 이름이 의도대로 고정되는가?
versionupgrade 또는 schema 변경 시 signature compatibility를 어떻게 다루는가?
chainIdfork나 다른 network에서 재사용되지 않는가?
verifyingContract다른 contract에서 replay되지 않는가?
struct type hashbackend/frontend/contract가 같은 schema를 쓰는가?
nonce목적별 nonce가 독립적인가?
deadline/valid window오래된 signature가 실행되지 않는가?
크게 보기
Field확인할 것
nametoken/payment product 이름이 의도대로 고정되는가?
versionupgrade 또는 schema 변경 시 signature compatibility를 어떻게 다루는가?
chainIdfork나 다른 network에서 재사용되지 않는가?
verifyingContract다른 contract에서 replay되지 않는가?
struct type hashbackend/frontend/contract가 같은 schema를 쓰는가?
nonce목적별 nonce가 독립적인가?
deadline/valid window오래된 signature가 실행되지 않는가?

Upgrade가 있으면 domain이 더 민감해진다. Proxy address는 유지되지만 implementation과 version 정책은 바뀔 수 있다. 기존 signature가 upgrade 후에도 유효해야 하는지, 무효화해야 하는지 release note에 남겨야 한다.

5. Negative test 목록

표 자료가로 스크롤 · 크게 보기 지원
테스트기대 결과
잘못된 spenderpermit 제출실패
잘못된 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 불일치
크게 보기
테스트기대 결과
잘못된 spenderpermit 제출실패
잘못된 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 idsupport와 replay 조사에 필요
Permit2 contract 사용 여부권한이 token contract가 아닌 Permit2에 모임을 안내
크게 보기
화면 field사용자에게 필요한 이유
spender 또는 recipient누가 토큰을 가져가거나 받을지 확인
amount와 token얼마를 어떤 자산으로 승인하는지 확인
chain/network다른 chain replay와 사용자의 착각 방지
verifying contract 또는 product name어떤 contract/product에 권한을 주는지 확인
deadline 또는 valid window서명이 언제까지 유효한지 확인
nonce/authorization idsupport와 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
실패 UXunsupported wallet, pending wallet approval, invalid signature
테스트EOA signature, valid EIP-1271, invalid magic value, state-dependent rejection
크게 보기
결정 항목선택지
Smart account 지원 여부지원, 미지원, 특정 wallet만 지원
검증 방식OpenZeppelin SignatureChecker, 직접 EIP-1271 call, backend precheck
실패 UXunsupported 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 복구를 차단한다.

CODE SURFACEsolidity
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 해야 한다.

CODE SURFACEsolidity
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를 작성한다.

CODE SURFACEtypescript
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 로 보장

강의 포인트

표 자료가로 스크롤 · 크게 보기 지원
관점강의 중 확인할 질문학습 후 남길 증거
Domainsignature가 어느 chain/contract에서만 유효한가?domain 검증표
Nonce/expirysignature가 한 번만, 정해진 시간 안에 쓰이는가?negative test 목록
UX사용자가 spender/recipient/amount/token/chain을 볼 수 있는가?서명 UI 검수표
Account typeEOA와 smart account를 어떻게 구분하는가?EIP-1271 지원 정책
크게 보기
관점강의 중 확인할 질문학습 후 남길 증거
Domainsignature가 어느 chain/contract에서만 유효한가?domain 검증표
Nonce/expirysignature가 한 번만, 정해진 시간 안에 쓰이는가?negative test 목록
UX사용자가 spender/recipient/amount/token/chain을 볼 수 있는가?서명 UI 검수표
Account typeEOA와 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을 둔다.
크게 보기
리뷰 질문문제수정 방향
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가 필요하다.
크게 보기
오해실제로 확인할 것
ecrecover로 주소가 나오면 검증이 끝났다고 본다.malleability, zero address, domain, nonce, EIP-1271을 봐야 한다.
permit 성공을 결제 성공으로 처리한다.transferFrom까지 성공해야 결제다.
smart account도 EOA처럼 recover된다고 생각한다.EIP-1271 검증 경로가 필요하다.
Permit2를 쓰면 allowance 위험이 사라진다고 본다.Permit2 contract에 권한이 모이고 expiry/amount/revoke UX가 필요하다.

실습 과제

  1. 컨트랙트EIP-712 domain 검증표 만들기: name, version, chainId, verifyingContract, salt/extension 사용 여부를 적고 잘못된 chain/contract/domain에서 replay가 실패하는 테스트를 정의한다.
  2. 컨트랙트서명 negative test 8개 작성하기: wrong spender, wrong recipient, wrong amount, expired signature, reused nonce, wrong chainId, frozen signer, smart account unsupported 상황을 테스트 케이스로 작성한다.
  3. 클라이언트서명 UX 검수표 작성하기: 사용자 화면에 spender, recipient, amount, token, chain, deadline, nonce/authorization id, Permit2 contract가 어떻게 표시되어야 하는지 체크리스트로 만든다.

완료 기준

  1. domain separator 구성요소를 설명했다.
  2. negative test 5개를 작성했다.
  3. 서명 UI 검수표를 만들었다.
  4. EIP-1271/Permit2 지원 여부와 제한사항을 문서화했다.

근거 자료

Final checkpoint

읽기를 마쳤다면 여기서 기록한다

아래 버튼은 읽기 진도를 저장한다. 체크리스트, 과제, 랩 산출물은 위 Workbook에서 따로 관리한다.

  • domain separator 구성요소를 설명했다.
  • negative test 5개를 작성했다.
  • 서명 UI 검수표를 만들었다.

학습 자료 근거

ECDSA EIP712 서명검증
이 LMS 레슨의 개념, 예시, 과제 구성을 잡는 데 사용한 근거 문서.
내부 참고 문서
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