만족

[Blockchain] js로 이해하는 블록체인의 트랜잭션 서명 (Signing Transaction) 본문

[Blockchain] js로 이해하는 블록체인의 트랜잭션 서명 (Signing Transaction)

BlockChain/이론 Satisfaction 2021. 9. 15. 03:33

이전 포스트에서 계속되는 내용이다.

 

아래 영상을 참고하여 작성되었다.

https://www.youtube.com/watch?v=kWQ84S13-hw&t=33s&ab_channel=SimplyExplained 

 

이전 코드의 문제점

이전 포스트에서 언급했듯이, 

지갑의 주인이 아닌 사람이 마음대로 트랜잭션을 생성해서 남의 코인을 전부 자기 지갑으로 가져올 수 있다는 문제가 있었다.

 

따라서 fromAddr, 즉 보내는 사람이 자신일 경우만 트랜잭션이 유효하도록 조치를 취해야 한다.

 

Key Pair

여기에서 공개키-개인키 개념을 사용할 것이다.

 

먼저 개인키는 자신만 가지고 있어야 하며(외부에 노출되어서는 안됨)

공개키는 개인키에서 파생된 키로, 외부에 공개되는 데이터이다.

 

어떤 알고리즘 f에 의해 f(개인키)= 공개키 가 결정되고,

공개키와 알고리즘 f를 알 수 있을 때, 개인키를 역으로 구할 수 없어야 한다.

(f의 역함수를 fI라고 했을 때, fl(공개키) 값은 구할 수 없어야 안전하다)

 

블록체인에서 공개키를 "지갑 주소"로 사용하며,

이 지갑 주소가 트랜잭션의 fromAddr, toAddr에 위치한다.

 

개인키는 공개키와 함께 트랜잭션에 서명할 때 사용된다.

 

secp256k1

이 암호화 기법은 미국 표준기술연구소에서 제정한 암호화 기법으로,

타원 곡선을 이용하여 개인키와 공개키를 발생시킨다.

 

어떤 방식으로 생성하는지는 다른 포스트에서 알아보기로 하고,

지금은 그냥 이런 방법으로 공개키-개인키를 생성하는구나 하고 넘어가자.

 

아무튼 이 암호화 기법은 비트코인과 이더리움 등의 블록체인 네트워크에서 채택하여 사용하는 암호화 기법이다.

 

secp256k1을 이용해 개인키-공개키를 만들어 보자.

const EC = require("elliptic").ec;
const ec = new EC("secp256k1");

const key = ec.genKeyPair();
const publicKey = key.getPublic("hex");
const privateKey = key.getPrivate("hex");
console.log("\nKey Pair is generated =====");
console.log("Public Key:", publicKey);
console.log("Private Key:", privateKey);
console.log("===========================");

 

여기서 Public Key가 공개키, Private Key가 개인키이고,

Public Key는 지갑 주소가 된다.

 

트랜잭션에 서명하기

//Transaction은 보내는 지갑주소, 받을 지갑주소, 보낸 코인의 양을 포함하는 객체이다
const Transaction = function (fromAddr, toAddr, amount) {
  this.fromAddr = fromAddr;
  this.toAddr = toAddr;
  this.amount = amount;
};
Transaction.prototype.calcHash = function () {
  return SHA256(this.fromAddr + this.toAddr + this.amount).toString();
};
Transaction.prototype.signTransaction = function (signKey) {
  if (signKey.getPublic("hex") !== this.fromAddr) {
    throw "다른 사람의 지갑 정보를 사용하여 트랜잭션에 사인할 수 없습니다";
  }
  const hashTranscation = this.calcHash();
  this.signiture = signKey.sign(hashTranscation, "base64").toDER("hex");
};

signKey(공개키-개인키 쌍)을 받아 signTransaction에서 트랜잭션에 서명한다.

 

첫 단계에서, 입력된 signKey의 공개키 부분이 트랜잭션의 fromAddr(보내는 사람의 지갑 주소)와 동일하지 않을 경우 오류가 발생한다.

 

유효한 입력이라면 다음 단계로 넘어간다.

 

이후, 트랜잭션의 해시 값(fromAddr, toAddr, amount를 해시)을 구한다.

 

그리고 그 해시값을 개인키로 암호화한 후, DER로 변환해 

트랜잭션의 signiture로 저장한다.

 

정리하자면, 트랜잭션의 시그니쳐(서명)은 

트랜잭션의 해시를 보내는 사람의 개인키로 암호화한 값이다.

 

트랜잭션 검증

트랜잭션에 존재하는 서명이 유효한 서명인지를 검사한다.

 

Transaction.prototype.isValid = function () {
  //채굴 보상을 수여받는 경우, fromAddr은 null이다
  if (!this.fromAddr) return true;

  if (!this.signiture) {
    throw "서명되지 않은 트랜잭션입니다";
  }

  const publicKey = ec.keyFromPublic(this.fromAddr, "hex");
  return publicKey.verify(this.calcHash(), this.signiture);
};

지갑 주소(공개키)를 기준으로 공개키-개인키 오브젝트를 생성한다.

(단 공개키로부터 개인키를 유추할 수 없으므로, 개인키는 얻어낼 수 없다)

 

이 공개키로 트랜잭션의 해시와 트랜잭션의 서명이 유효한지를 검증한다.

 

이는 서명값을 복호화했을 때, 트랜잭션의 해시와 동일한지를 검사하고

만약 동일하지 않을 경우 그 트랜잭션은 무효한 것으로 판단한다.

 

이제 어떤 트랜잭션이 유/무효한지 알았으므로, 블록체인에 트랜잭션을 펜딩시킬 때 트랜잭션을 검증한다.

Blockchain.prototype.addTransaction = function (transcation) {
  if (!transcation.toAddr || !transcation.fromAddr) {
    throw "보내는 사람 정보와 받는 사람 정보가 모두 존재해야 합니다";
  }
  if (!transcation.isValid()) {
    throw "무효한 트랜잭션입니다";
  }
  this.pendingTransactions.push(transcation);
};

설명은 생략한다.

 

전체 코드

 

blockchain.js

const SHA256 = require("crypto-js/sha256");
const EC = require("elliptic").ec;
const ec = new EC("secp256k1");

//Transaction은 보내는 지갑주소, 받을 지갑주소, 보낸 코인의 양을 포함하는 객체이다
const Transaction = function (fromAddr, toAddr, amount) {
  this.fromAddr = fromAddr;
  this.toAddr = toAddr;
  this.amount = amount;
};
Transaction.prototype.calcHash = function () {
  return SHA256(this.fromAddr + this.toAddr + this.amount).toString();
};
Transaction.prototype.signTransaction = function (signKey) {
  if (signKey.getPublic("hex") !== this.fromAddr) {
    throw "다른 사람의 지갑 정보를 사용하여 트랜잭션에 사인할 수 없습니다";
  }
  const hashTranscation = this.calcHash();
  this.signiture = signKey.sign(hashTranscation, "base64").toDER("hex");
};
Transaction.prototype.isValid = function () {
  //채굴 보상을 수여받는 경우, fromAddr은 null이다
  if (!this.fromAddr) return true;

  if (!this.signiture) {
    throw "서명되지 않은 트랜잭션입니다";
  }

  const publicKey = ec.keyFromPublic(this.fromAddr, "hex");
  return publicKey.verify(this.calcHash(), this.signiture);
};
//Block
//더 이상 index값은 필요하지 않다
//왜냐하면 어차피 블록들은 prevHash<->hash로 각각의 블록 위치가 결정되기 때문이다
const Block = function (timestamp, transactions, prevHash = "") {
  this.timestamp = timestamp;
  this.transactions = transactions;
  this.prevHash = prevHash;
  this.hash = this.calcHash();
  this.nonce = 0;
};
Block.prototype.calcHash = function () {
  //index, prevHash, timestamp, data를 입력으로 해시값을 계산한다
  return SHA256(
    this.prevHash +
      this.timestamp +
      JSON.stringify(this.transactions) +
      this.nonce
  ).toString();
};
//블록 생성
Block.prototype.mining = function (difficulty) {
  const start = new Date();
  //difficulty개의 0으로 시작하는 hash가 발생될 때 까지 해시를 반복한다
  while (
    this.hash.substring(0, difficulty) !== Array(difficulty).fill("0").join("")
  ) {
    this.nonce++;
    this.hash = this.calcHash();
  }
  const end = new Date();

  //조건을 만족했을 때 nonce 값 출력
  //이 때 nonce는 해시를 한 횟수와 동일하다
  console.log("block is mined", this.nonce);
  //걸린 시간 출력
  console.log("ellipsed time is ", end.getTime() - start.getTime(), "ms");
};
Block.prototype.hasValidTransactions = function () {
  return this.transactions.every((tx) => {
    return tx.isValid();
  });
};

//Blockchain
const Blockchain = function () {
  this.chain = [this.createGenesisBlock()];
  this.difficulty = 2;
  //새로운 block이 mining될 때 까지 트랜잭션들은 이곳에 보관된다.
  //새로운 block이 채굴되면 거래 내역들이 블록에 포함된다.
  this.pendingTransactions = [];
  //채굴에 성공했을 때, 채굴자에게 수여되는 코인의 양
  //채굴자가 이 값을 임의로 바꾸는 것은 가능하지만
  //매우 많은 수의 사용자들이 P2P로 연결되어 있기 때문에 값을 조작할 경우 그 값은 무시될 것이다
  this.miningRewrad = 100;
};
Blockchain.prototype.minePendingTransactions = function (miningRewardAddress) {
  //예를 들어 비트코인에서는 현재 대기중인 모든 트랜잭션을 블록에 포함시키지는 않는다
  //비트코인에서 하나의 블록 사이즈는 1MB를 넘길 수 없으므로, 채굴자가 어떤 트랜잭션을 포함시킬지를 선택한다
  const block = new Block(
    Date.now(),
    this.pendingTransactions,
    this.getLatestBlock().hash
  );
  block.mining(this.difficulty);
  this.chain.push(block);

  this.pendingTransactions = [
    new Transaction(null, miningRewardAddress, this.miningRewrad),
  ];
};
Blockchain.prototype.addTransaction = function (transcation) {
  if (!transcation.toAddr || !transcation.fromAddr) {
    throw "보내는 사람 정보와 받는 사람 정보가 모두 존재해야 합니다";
  }
  if (!transcation.isValid()) {
    throw "무효한 트랜잭션입니다";
  }
  this.pendingTransactions.push(transcation);
};
//어떤 지갑 주소에 대해 잔액을 알고 싶을 떄 이 함수를 사용한다.
//각각의 주소에 대해 잔액을 저장하지 않기 때문에 모든 트랜잭션에 대해 순회하며 잔액을 계산해야 한다
Blockchain.prototype.getBalanceOfAddress = function (addr) {
  let balance = 0;
  this.chain.map((block, idx) => {
    if (idx === 0) {
      //genesis block은 생략한다
      return;
    }
    block.transactions.map((transcation) => {
      if (transcation.toAddr === addr) {
        balance += transcation.amount;
      }
      if (transcation.fromAddr === addr) {
        balance -= transcation.amount;
      }
    });
  });
  return balance;
};
// 더 이상 임의의 데이터를 블록에 추가시키는 동작을 하지 않는다
// Blockchain.prototype.addBlock = function (newBlock) {
//   //새로운 블록이 생성되면 가장 최근 블록의 해시값을 새로운 블록의 prevHash에 복사한다
//   newBlock.prevHash = this.getLatestBlock().hash;
//   newBlock.mining(this.difficulty);

//   //해시 계산이 완료되면 블록체인에 연결시킨다
//   this.chain.push(newBlock);
// };
Blockchain.prototype.createGenesisBlock = function () {
  //번호 0번, 이전 해시 "0", data를 "GenesisBlock"으로 임의로 지정
  return new Block("2021/09/13", "GenesisBlock", "0");
};
Blockchain.prototype.getLatestBlock = function () {
  return this.chain[this.chain.length - 1];
};
Blockchain.prototype.isValid = function () {
  //제네시스 블록은 이전 블록이 없어 검사를 건너뛰기 위해 1부터 시작한다.
  for (let i = 1; i < this.chain.length; i++) {
    const currentBlock = this.chain[i];
    const prevHash = this.chain[i - 1].hash;

    //블록에 포함된 모든 트랜잭션이 유효한지도 검사
    if (!currentBlock.hasValidTransactions()) {
      return false;
    }

    if (currentBlock.prevHash !== prevHash) {
      //현재 블록의 이전 해시값이 일치하지 않음
      return false;
    } else if (currentBlock.calcHash() !== currentBlock.hash) {
      //현재 블록에 저장된 해시값과 다시 계산한 해시값이 일치하지 않음
      return false;
    }
  }
  return true;
};

module.exports = {
  Blockchain,
  Transaction,
};

 

keygen.js

const EC = require("elliptic").ec;
const ec = new EC("secp256k1");

module.exports = {
  generateKey: () => {
    const key = ec.genKeyPair();
    const publicKey = key.getPublic("hex");
    const privateKey = key.getPrivate("hex");
    console.log("\nKey Pair is generated =====");
    console.log("Public Key:", publicKey);
    console.log("Private Key:", privateKey);
    console.log("===========================");

    return [publicKey, privateKey, key];
  },
};

test.js

const { Blockchain, Transaction } = require("./blockchain");
const { generateKey } = require("./keygen");

const myKeyBundle = generateKey();
const [myPublicKey, myPrivateKey, myKeyPair] = myKeyBundle;

//test
const testCoin = new Blockchain();

//myPublicKey-> Jane에게 10코인만큼 전송
const tx1 = new Transaction(myPublicKey, "Jane", 10);
//해당 트랜잭션에 소유자의 키페어로 사인한다
tx1.signTransaction(myKeyPair);
//사인된 트랜잭션을 블록체인에 제출
testCoin.addTransaction(tx1);
console.log("\nsend 10 coin from myPublicKey to Jane");

console.log(
  `\n(before mining)balance of Jane is `,
  testCoin.getBalanceOfAddress("Jane")
);

//채굴 시작
console.log("\nstarting mining...");
testCoin.minePendingTransactions("Jane");

console.log(
  `\n(after mining)balance of Jane is `,
  testCoin.getBalanceOfAddress("Jane")
);

console.log("\nchain is valid?", testCoin.isValid());
console.log("\nmanipulate transaction!!");
testCoin.chain[1].transactions[0].amount = 1000;
console.log("chain is valid?", testCoin.isValid());

 

test.js를 실행시키면 아래와 같은 결과를 얻는다.

 

공개키-개인키 쌍을 생성하고, 이 쌍을 내 지갑으로 사용할 것이다.

 

내가 Jane에게 10 코인을 보내는 경우,

내 지갑의 소유주는 내가 맞으므로 트랜잭션이 정상적으로 처리되고

채굴이 완료된 후 Jane에게 성공적으로 10코인이 보내진다.

 

이 때는 블록 내의 트랜잭션이 훼손되지 않았으므로, 블록체인은 유효하다.

 

console.log("\nmanipulate transaction!!");
testCoin.chain[1].transactions[0].amount = 1000;

그런데, 임의로 내가 그 트랜잭션의 amount를 1000으로 바꾸면 어떻게 될까?

 

그렇게 되면 transaction의 해시가 변조되므로,

transaction의 서명 또한 verified 하지 않게 된다.

 

따라서 해당 블록체인의 유효성 검사에 실패한다.

 

//Jane-> myPublicKey에게 10코인만큼 전송
const tx1 = new Transaction("Jane", myPublicKey, 10);
//해당 트랜잭션에 소유자가 아닌 사람의 키페어로 사인한다
tx1.signTransaction(myKeyPair);

만약 fromAddr(보내는 사람의 지갑 주소; 공개키)가 내가 아닌 트랜잭션에

내가 서명한다면 어떻게 될까?

 

Transaction.prototype.signTransaction = function (signKey) {
  if (signKey.getPublic("hex") !== this.fromAddr) {
    throw "다른 사람의 지갑 정보를 사용하여 트랜잭션에 사인할 수 없습니다";
  }
  const hashTranscation = this.calcHash();
  this.signiture = signKey.sign(hashTranscation, "base64").toDER("hex");
};

이 때는 if(signKey ... 에서 보내는 사람의 주소(지갑; 공개키)가 서명자의 주소와 일치하지 않으므로 서명할 수 없다.

 

따라서 이제 fromAddr의 소유주가 아닌 사람,

즉 fromAddr의 개인키를 가지고 있지 않은 사람은 fromAddr에서 코인을 송금하는 행위가 불가능해졌다.

(트랜잭션에 서명할 수 없기 때문이다)

 

여기까지가 시리즈 끝이다.

 

이 블록체인은 완벽한가?

 

그렇지 않다.

 

DDos 공격을 견뎌내거나, 잔액이 0인데도 코인을 송금할 수 있는 등 여전히 많은 취약점이 남아있다.

 

그러나 기본 핵심 개념을 익히기에는 충분할 것이다.

 

만약, 이 블록체인을 웹 프론트엔드에서 테스트해보고 싶다면, 아래 링크를 참조하면 된다.

 

https://www.youtube.com/watch?v=AQV0WNpE_3g&ab_channel=ACMSIGGRAPH 



Comments