디지털 서명을 통한 트랜잭션

4e5ung·2023년 7월 26일
0

디지털 서명(digital signature)

이더리움에서는 디지털 서명을 세 가지 용도로 사용된다.

  1. 이더리움 계정과 개인키의 소유자가 이더 지출 또는 컨트랙트 이행을 승인했음을 증명한다.
  2. 보낸 사람이 메시지를 보내지 않았음을 부인 할 수 없다.(부인방지(non-repudiation))
  3. 서명된 트랜잭션 데이터는 수정되지 않았다고 수정 할 수 없음을 증명 한다.

디지털 서명 생성 및 트랜잭션 전송

트랜잭션 생성 시 ECDSA 를 통해 메시지를 디지털 서명을 할 수 있으며, 서명 된 트랜잭션을 전송한다.(ECDSA에 대한 설명은 생략)

트랜잭션 발생을 위한 7가지 과정

  1. [nonce, gasPrice, gasLimit, to, value, data, chainID, 0, 0]의 9개 필드를 포함하는 트랜잭션 데이터 구조를 만든다.(EIP-155)
  2. RLP로 인코딩된 트랜잭션 데이터 구조의 시리얼라이즈된 메시지를 생성한다.
  3. 시리얼라이즈된 메시지의 Keccak-256 해시를 계산한다.
  4. EOA의 개인키로 해시에 서명하여 ECDSA 서명을 계산한다.
  5. ECDSA 서명의 계산된 v, r, s 값을 트랜잭션에 추가한다.
  6. 서명이 추가된 트랜잭션 데이터를 RLP 인코딩해 시리얼라이즈된 트랜잭션을 생성한다.
  7. 트랜잭션을 전송한다.

※ EIP-155
'단순 재생 공격 방지(Simple Replay Attack Protection)' 표준은 서명 하기 전에 트랜잭션 데이터 내부에 체인 식별자(chain identifier)를 포함하여 인코딩한다.
서명에 체인 식별자가 포함됨으로써 다른 체인에서 트랜잭션을 수행할 수 없다.
EIP-155 구조는 주요 6개 필드에 chainID, 0, 0 3개 필드를 추가한다.

※ v 변수
ECDSArecover 함수가 서명을 확인하는데 도움이 되는 복구 식별자와 체인ID이다.
v는 27 또는 28 중 하나로 계산되거나, 체인ID * 2 + 35 또는 36(signature->recoveryParam)으로 계산된다.

서명 확인

서명확인은 서명(r, s)과 시리얼라이즈된 트랜잭션, 서명을 만드는데 사용된 개인키에 상응하는 공개키가 있으면 된다.

  1. 트랜잭션 데이터를 갖고온다.
  2. 트랜잭션내에 서명값을 추출한다.(r, s) (v도 추출)
  3. RLP로 인코딩된 트랜잭션 데이터 구조의 시리얼라이즈된 메시지를 생성한다.
  4. 시리얼라이즈된 메시지의 Keccak-256 해시를 계산한다.
  5. ECDSA 서명의 계산된 v, r, s 을 통해 서명 공개키를 복구한다.
  6. 서명 공개키의 이더리움 주소를 얻은 후 개인키에 상응하는 공개키랑 비교한다.

디지털 서명을 통한 트랜잭션 예제

트랜잭션 서명(EIP-155 제외)

// 1. [nonce, gasPrice, gasLimit, to, value, data]의 6개 필드를 포함하는 트랜잭션 데이터 구조 생성한다
const formattedNonce = nonce === 0 ? "0x" : ethers.utils.hexlify(nonce)

const transactionData = {
  nonce: formattedNonce,
  gasPrice: 875000000,
  gasLimit: 21000,
  to: toAccount,
  value: 10,
  data: "0x"
};

// 2. RLP로 인코딩된 트랜잭션 데이터 구조의 시리얼라이즈된 메시지를 생성한다
const rlpData = [
  transactionData.nonce,
  ethers.utils.hexlify(transactionData.gasPrice),
  ethers.utils.hexlify(transactionData.gasLimit),
  ethers.utils.hexlify(transactionData.to),
  ethers.utils.hexlify(transactionData.value),
  transactionData.data
];
const serializeData = ethers.utils.RLP.encode(rlpData);

// serializeTransaction 함수를 통해서도 가능
const serializeData2 = ethers.utils.serializeTransaction(transactionData);
assert.equal(serializeData, serializeData2)


// 3. 시리얼라이즈된 메시지의 Keccak-256 해시를 계산한다
const digest = ethers.utils.keccak256(serializeData);

// 4. EOA의 개인키로 해시에 서명하여 ECDSA 서명을 계산
const signingKey = new ethers.utils.SigningKey(privateKey);
const signature = signingKey.signDigest(digest);

// 5.  ECDSA 서명의 계산된 v, r, s 값을 트랜잭션에 추가        
const { v, r, s } = ethers.utils.splitSignature(signature);
const signRlpData = [
  ethers.utils.hexlify(transactionData.nonce),
  ethers.utils.hexlify(transactionData.gasPrice),
  ethers.utils.hexlify(transactionData.gasLimit),
  transactionData.to,
  ethers.utils.hexlify(transactionData.value),
  transactionData.data,
  ethers.utils.hexlify(v),
  r,
  s
];

// 6. 서명이 추가된 트랜잭션 데이터를 RLP 인코딩해 시리얼라이즈하여 전송한다.
const signData= ethers.utils.RLP.encode(signRlpData);

// 7. 트랜잭션 전송
const sentTransaction = await ethers.provider.sendTransaction(signData);

트랜잭션 서명(EIP-155 제외) - signTransaction 이용

// 1. [nonce, gasPrice, gasLimit, to, value, data]의 6개 필드를 포함하는 트랜잭션 데이터 구조 생성한다
const formattedNonce = nonce === 0 ? "0x" : ethers.utils.hexlify(nonce)

const transactionData = {
  nonce: formattedNonce,
  gasPrice: 875000000,
  gasLimit: 21000,
  to: toAccount,
  value: 10,
  data: "0x"
};

// 2, 3, 4, 5, 6 = > 트랜잭션 서명
const signedTransaction = await wallet.signTransaction(transactionData);        


// 7. 트랜잭션 전송
const sentTransaction = await ethers.provider.sendTransaction(signedTransaction);

트랜잭션 서명 검증(EIP-155 제외)

 // 1. 트랜잭션 데이터를 갖고온다.
const transaction = await ethers.provider.getTransaction(transactionHash);

// 2. 트랜잭션내에 서명값을 추출한다.(r, s) (v도 추출)
const v = ethers.utils.hexStripZeros(transaction.v);
const r = ethers.utils.hexZeroPad(transaction.r, 32);
const s = ethers.utils.hexZeroPad(transaction.s, 32);

// 3. RLP로 인코딩된 트랜잭션 데이터 구조의 시리얼라이즈된 메시지를 생성한다.
const nonce = transaction.nonce === 0 ? "0x" : ethers.utils.hexlify(transaction.nonce)
const rlpData = [
  nonce,
  ethers.utils.hexlify(transaction.gasPrice),
  ethers.utils.hexlify(transaction.gasLimit),
  ethers.utils.hexlify(transaction.to),
  ethers.utils.hexlify(transaction.value),
  transaction.data
];
const signData = ethers.utils.RLP.encode(rlpData);

// 4. 시리얼라이즈된 메시지의 Keccak-256 해시를 계산한다.
const messageHash = ethers.utils.keccak256(signData);

// 5. ECDSA 서명의 계산된 v, r, s 을 통해 서명 공개키를 복구한다.
const pubKey = ethers.utils.recoverPublicKey(messageHash, { v, r, s });

// 6. 서명 공개키의 이더리움 주소를 얻은 후 개인키에 상응하는 공개키랑 비교한다.
const signerAddress = ethers.utils.computeAddress(pubKey);   

if( transaction.from == signerAddress )
  return true

트랜잭션 서명(EIP-155)

// 1. [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]의 9개 필드를 포함하는 트랜잭션 데이터 구조 생성한다
const formattedNonce = nonce === 0 ? "0x" : ethers.utils.hexlify(nonce)

const transactionData = {
  nonce: formattedNonce,
  gasPrice: gasPrise,
  gasLimit: 30000000,
  to: toAccount,
  value: 10,
  data: "0x",
  chainId: chainId
};


// 2. RLP로 인코딩된 트랜잭션 데이터 구조의 시리얼라이즈된 메시지를 생성한다
const rlpData = [
  transactionData.nonce,
  ethers.utils.hexlify(transactionData.gasPrice),
  ethers.utils.hexlify(transactionData.gasLimit),
  ethers.utils.hexlify(transactionData.to),
  ethers.utils.hexlify(transactionData.value),
  transactionData.data,
  ethers.utils.hexlify(transactionData.chainId),
  "0x",
  "0x"
];
const serializeData = ethers.utils.RLP.encode(rlpData);

// serializeTransaction 함수를 통해서도 가능
const serializeData2 = ethers.utils.serializeTransaction(transactionData);
assert.equal(serializeData, serializeData2)


// 3. 시리얼라이즈된 메시지의 Keccak-256 해시를 계산한다
const digest = ethers.utils.keccak256(serializeData);

// 4. EOA의 개인키로 해시에 서명하여 ECDSA 서명을 계산
const signingKey = new ethers.utils.SigningKey(privateKey);
const signature = signingKey.signDigest(digest);


// 5.  ECDSA 서명의 계산된 v, r, s 값을 트랜잭션에 추가        
const { r, s } = ethers.utils.splitSignature(signature);

const signRlpData = [
  ethers.utils.hexlify(transactionData.nonce),
  ethers.utils.hexlify(transactionData.gasPrice),
  ethers.utils.hexlify(transactionData.gasLimit),
  transactionData.to,
  ethers.utils.hexlify(transactionData.value),
  transactionData.data,
  ethers.utils.hexlify(transactionData.chainId*2+(35+signature.recoveryParam)),
  r,
  s
];

// 6. 서명이 추가된 트랜잭션 데이터를 RLP 인코딩해 시리얼라이즈하여 전송한다.
const signData= ethers.utils.RLP.encode(signRlpData);


// 7. 트랜잭션 전송
const sentTransaction = await ethers.provider.sendTransaction(signData);

트랜잭션 서명(EIP-155) - signTransaction 이용

// 1. [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]의 9개 필드를 포함하는 트랜잭션 데이터 구조 생성한다
const formattedNonce = nonce === 0 ? "0x" : ethers.utils.hexlify(nonce)

const transactionData = {
  nonce: formattedNonce,
  gasPrice: gasPrise,
  gasLimit: 30000000,
  to: toAccount,
  value: 10,
  data: "0x",
  chainId: chainId
};

// 2, 3, 4, 5, 6 = > 트랜잭션 서명
const signedTransaction = await wallet.signTransaction(transactionData);      

// 7. 트랜잭션 전송
const sentTransaction = await ethers.provider.sendTransaction(signedTransaction);

트랜잭션 서명 검증(EIP-155)

// 1. 트랜잭션 데이터를 갖고온다.
const transaction = await ethers.provider.getTransaction(transactionHash);

// 2. 트랜잭션내에 서명값을 추출한다.(r, s) (v도 추출)
const v = ethers.utils.hexStripZeros(transaction.v);
const r = ethers.utils.hexZeroPad(transaction.r, 32);
const s = ethers.utils.hexZeroPad(transaction.s, 32);

// joinSignature 함수를 통해서도 가능 
const expandedSig = {
  r: transaction.r,
  s: transaction.s,
  v: transaction.v
};
// recoverPublicKey(messageHash, signature)
const signature = ethers.utils.joinSignature(expandedSig);

// 3. RLP로 인코딩된 트랜잭션 데이터 구조의 시리얼라이즈된 메시지를 생성한다.
const nonce = transaction.nonce === 0 ? "0x" : ethers.utils.hexlify(transaction.nonce)
const rlpData = [
  nonce,
  ethers.utils.hexlify(transaction.gasPrice),
  ethers.utils.hexlify(transaction.gasLimit),
  ethers.utils.hexlify(transaction.to),
  ethers.utils.hexlify(transaction.value),
  transaction.data,
  ethers.utils.hexlify(transaction.chainId),
  "0x",
  "0x"
];
const signData = ethers.utils.RLP.encode(rlpData);

// 4. 시리얼라이즈된 메시지의 Keccak-256 해시를 계산한다.
const messageHash = ethers.utils.keccak256(signData);

// 5. ECDSA 서명의 계산된 v, r, s 을 통해 서명 공개키를 복구한다.
const pubKey = ethers.utils.recoverPublicKey(messageHash, { v, r, s });

// 6. 서명 공개키의 이더리움 주소를 얻은 후 개인키에 상응하는 공개키랑 비교한다.
const signerAddress = ethers.utils.computeAddress(pubKey);   

if( transaction.from == signerAddress )
  return true

디지털 서명을 통한 컨트랙트 예제

컨트랙트 내부에서 서명 검증(컨트랙트)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;


contract MyContract {

    mapping(uint256 => bool) usedNonces;
    uint256 private chainId;
    address private signVerifier;

    uint256 public values;

    constructor(address _signVerifier, uint256 _chainId){
        chainId = _chainId;
        signVerifier = _signVerifier;
    }

    function setValues(uint256 _values, uint256 _nonce, bytes memory _sig) external { 
        require(!usedNonces[_nonce], "Signature Has Already Been Used");
        usedNonces[_nonce] = true;

        bytes32 message = prefixed(keccak256(abi.encodePacked(msg.sender, _values, _nonce, chainId, this)));
        require(recoverSigner(message, _sig) == signVerifier, "Invalid Signature");
        values = _values;
    }

    function recoverSigner(bytes32 message, bytes memory sig)
        public
        pure
        returns (address)
        {
        uint8 v;
        bytes32 r;
        bytes32 s;
        (v, r, s) = splitSignature(sig);
        return ecrecover(message, v, r, s);
    }

    function splitSignature(bytes memory sig)
        public
        pure
        returns (uint8, bytes32, bytes32)
        {
        require(sig.length == 65);

        bytes32 r;
        bytes32 s;
        uint8 v;

        assembly {
            // first 32 bytes, after the length prefix
            r := mload(add(sig, 32))
            // second 32 bytes
            s := mload(add(sig, 64))
            // final byte (first byte of the next 32 bytes)
            v := byte(0, mload(add(sig, 96)))
        }
    
        return (v, r, s);
    }

    function prefixed(bytes32 hash) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
    }
}

컨트랙트 내부에서 서명 검증(컨트랙트 호출)

const MessageData = {
  account : accounts[0].address,
  data : 10,
  nonce : await accounts[0].getTransactionCount(),
  chainId : chainId,
  contract : myContract.address
}

const encodedData = ethers.utils.keccak256(ethers.utils.solidityPack(
  ["address", "uint256", "uint256", "uint256", "address"],
  [MessageData.account, MessageData.data, MessageData.nonce, MessageData.chainId, MessageData.contract]
));

const wallet = new ethers.Wallet(privateKey);
const signature = await wallet.signMessage(ethers.utils.arrayify(encodedData));

await myContract.setValues(
  MessageData.data, 
  MessageData.nonce,
  signature
)

Ref

https://devkly.com/blockchain/raw-transaction/
https://programtheblockchain.com/posts/2018/02/17/signing-and-verifying-messages-in-ethereum/
<마스터링 이더리움>

git repository

https://github.com/4e5ung/solidity-study/tree/main/ecdsa_transaction

0개의 댓글