이미지 로딩 중...

타입스크립트로 비트코인 클론하기 27편 지갑 생성 및 키 관리 - 슬라이드 1/8
A

AI Generated

2025. 11. 11. · 3 Views

타입스크립트로 비트코인 클론하기 27편 지갑 생성 및 키 관리

비트코인 지갑의 핵심인 키 쌍 생성과 관리 방법을 배웁니다. 타입스크립트로 secp256k1 타원곡선 암호화를 사용하여 안전한 개인키와 공개키를 생성하고, 실제 블록체인 환경에서 사용할 수 있는 지갑 시스템을 구현합니다.


목차

  1. secp256k1 타원곡선 암호화 - 비트코인 암호화의 핵심
  2. 지갑 클래스 설계 - 객체지향으로 키 관리하기
  3. HD 지갑과 니모닉 구문 - 하나의 시드로 무한한 키 생성
  4. 서명과 트랜잭션 검증 - ECDSA 디지털 서명의 이해
  5. 키 저장과 암호화 - 안전한 지갑 파일 관리
  6. 다중 서명 지갑 - 2-of-3 멀티시그 구현
  7. 지갑 복구와 백업 전략 - 자산을 잃지 않는 방법

1. secp256k1 타원곡선 암호화 - 비트코인 암호화의 핵심

시작하며

여러분이 비트코인 지갑을 만들려고 할 때 가장 먼저 고민하게 되는 것이 바로 "어떻게 안전하게 키를 생성할까?"입니다. 단순히 랜덤한 숫자를 만드는 것이 아니라, 수학적으로 증명된 안전성을 가진 키를 만들어야 합니다.

비트코인은 수십억 달러의 가치를 다루는 시스템입니다. 만약 키 생성 알고리즘이 취약하다면, 해커가 여러분의 개인키를 추측할 수 있고, 이는 곧 자산 손실로 이어집니다.

실제로 과거에 취약한 난수 생성기를 사용한 지갑들이 해킹당한 사례가 있습니다. 바로 이럴 때 필요한 것이 secp256k1 타원곡선 암호화입니다.

이는 비트코인이 선택한 표준 암호화 방식으로, NSA가 설계한 다른 곡선들과 달리 백도어 의혹이 없고, 수학적으로 안전성이 입증되었습니다.

개요

간단히 말해서, secp256k1은 타원곡선 위의 점들을 이용한 공개키 암호화 방식입니다. 256비트의 개인키로 공개키를 생성하되, 공개키로부터 개인키를 역산하는 것이 사실상 불가능하도록 설계되었습니다.

이 방식이 필요한 이유는 블록체인의 투명성 때문입니다. 비트코인 네트워크에서는 모든 거래가 공개되므로, 공개키도 모두에게 노출됩니다.

하지만 공개키를 알아도 개인키를 알아낼 수 없어야 하며, 이것이 바로 타원곡선 암호화의 핵심입니다. 기존 RSA 암호화는 2048비트 이상의 키 길이가 필요하지만, secp256k1은 256비트만으로도 동일한 수준의 보안을 제공합니다.

이는 블록체인에서 매우 중요한데, 키 크기가 작을수록 트랜잭션 크기가 작아지고, 네트워크 효율성이 높아집니다. secp256k1의 핵심 특징은 첫째, 결정론적 키 생성이 가능하다는 점, 둘째, 서명과 검증 속도가 빠르다는 점, 셋째, 공개키 복구가 가능하여 서명만으로도 공개키를 역산할 수 있다는 점입니다.

이러한 특징들이 블록체인의 효율성과 보안성을 동시에 높여줍니다.

코드 예제

// elliptic 라이브러리를 사용한 secp256k1 구현
import * as elliptic from 'elliptic';
import * as crypto from 'crypto';

const ec = new elliptic.ec('secp256k1');

// 안전한 개인키 생성 (32바이트 = 256비트)
function generatePrivateKey(): string {
  // crypto.randomBytes는 암호학적으로 안전한 난수 생성
  const privateKey = crypto.randomBytes(32).toString('hex');
  return privateKey;
}

// 개인키로부터 공개키 생성
function getPublicKey(privateKey: string): string {
  const keyPair = ec.keyFromPrivate(privateKey);
  // 압축된 형태의 공개키 반환 (33바이트)
  const publicKey = keyPair.getPublic().encode('hex', true);
  return publicKey;
}

설명

이것이 하는 일: 위 코드는 비트코인에서 사용하는 secp256k1 타원곡선을 이용하여 안전한 개인키-공개키 쌍을 생성합니다. elliptic 라이브러리는 자바스크립트/타입스크립트에서 타원곡선 암호화를 쉽게 구현할 수 있게 해주는 검증된 라이브러리입니다.

첫 번째 단계에서 crypto.randomBytes(32)를 호출하여 32바이트(256비트)의 암호학적으로 안전한 난수를 생성합니다. 이는 Node.js의 crypto 모듈이 운영체제의 엔트로피 풀을 활용하여 예측 불가능한 난수를 만들기 때문에 안전합니다.

Math.random()과 같은 일반 난수 생성기를 절대 사용해서는 안 되는 이유가 여기에 있습니다. 두 번째 단계에서 생성된 개인키를 타원곡선 연산에 적용합니다.

ec.keyFromPrivate()는 개인키를 받아 타원곡선 위의 특정 점으로 변환하고, getPublic()은 타원곡선의 기준점(generator point)에 개인키를 곱하여 공개키 점을 계산합니다. 이 과정은 수학적으로 일방향이어서, 공개키로부터 개인키를 역산하려면 이산대수 문제를 풀어야 하는데, 이는 현재 슈퍼컴퓨터로도 수십억 년이 걸립니다.

세 번째 단계에서 공개키를 압축된 형태로 인코딩합니다. 타원곡선 위의 점은 (x, y) 좌표로 표현되는데, 곡선의 대칭성 덕분에 x 좌표와 y의 홀짝 여부만으로도 점을 복원할 수 있습니다.

따라서 압축된 공개키는 33바이트(1바이트 접두사 + 32바이트 x 좌표)만 필요하며, 비압축 형태(65바이트)보다 훨씬 효율적입니다. 여러분이 이 코드를 사용하면 실제 비트코인 네트워크에서 사용 가능한 수준의 키 쌍을 생성할 수 있습니다.

생성된 공개키는 비트코인 주소로 변환되어 다른 사람들이 여러분에게 송금할 때 사용하고, 개인키는 그 자금을 사용할 수 있는 유일한 증명 수단이 됩니다. 또한 이 방식은 이더리움 등 다른 많은 블록체인에서도 동일하게 사용되므로, 한 번 이해하면 여러 프로젝트에 적용할 수 있습니다.

실전 팁

💡 개인키는 절대로 하드코딩하거나 버전 관리 시스템에 커밋하지 마세요. 환경 변수나 안전한 키 관리 시스템(KMS)을 사용하고, 개발 환경에서도 테스트용 키를 별도로 관리하세요.

💡 crypto.randomBytes() 대신 Math.random()을 사용하는 실수를 조심하세요. Math.random()은 예측 가능한 의사 난수이므로 암호학적으로 안전하지 않습니다. 실제로 이런 실수로 수백만 달러가 도난당한 사례가 있습니다.

💡 공개키는 압축 형태(33바이트)를 기본으로 사용하세요. 비압축 형태(65바이트)도 유효하지만, 트랜잭션 크기가 커져 수수료가 증가합니다. getPublic().encode('hex', true)의 true가 압축 옵션입니다.

💡 elliptic 라이브러리 버전을 주기적으로 업데이트하세요. 암호화 라이브러리는 보안 취약점이 발견되면 즉시 패치되므로, 의존성 관리가 매우 중요합니다. npm audit을 정기적으로 실행하세요.

💡 개인키 생성 시 충분한 엔트로피를 확보했는지 확인하세요. 서버 부팅 직후나 가상 환경에서는 엔트로피가 부족할 수 있으므로, /dev/random 대신 /dev/urandom을 사용하는 crypto 모듈의 동작을 이해하고 있어야 합니다.


2. 지갑 클래스 설계 - 객체지향으로 키 관리하기

시작하며

여러분이 비트코인 애플리케이션을 만들 때 키 생성 함수만으로는 부족합니다. 실제로는 키를 생성하고, 저장하고, 불러오고, 서명하는 등 여러 기능이 필요한데, 이것들을 함수들로만 관리하면 코드가 금방 복잡해집니다.

예를 들어, 사용자가 여러 개의 지갑을 관리해야 한다면 어떻게 할까요? 각 지갑마다 개인키, 공개키, 주소를 따로 변수로 관리하면 실수하기 쉽고, 관련된 기능들이 흩어져서 유지보수가 어려워집니다.

바로 이럴 때 필요한 것이 객체지향 설계입니다. 지갑의 데이터와 기능을 하나의 클래스로 캡슐화하면, 코드의 재사용성과 안정성이 크게 향상됩니다.

개요

간단히 말해서, Wallet 클래스는 비트코인 지갑의 모든 기능을 하나로 묶은 청사진입니다. 개인키, 공개키, 주소를 속성으로 가지고, 키 생성, 서명, 검증 등의 메서드를 제공합니다.

이 클래스가 필요한 이유는 코드의 응집도를 높이고 결합도를 낮추기 위함입니다. 지갑과 관련된 모든 로직이 한 곳에 모여 있으면, 버그를 찾기 쉽고, 새로운 기능을 추가할 때도 다른 코드에 영향을 주지 않습니다.

예를 들어, 멀티시그 지갑 기능을 추가한다면 Wallet 클래스를 상속받아 확장할 수 있습니다. 기존에는 개인키를 문자열 변수로 관리하고, 관련 함수들을 별도로 만들었다면, 이제는 wallet.sign(), wallet.verify() 같은 직관적인 메서드로 사용할 수 있습니다.

이는 코드 가독성을 크게 향상시킵니다. Wallet 클래스의 핵심 특징은 첫째, 불변성 보장(한 번 생성된 키는 변경 불가), 둘째, 타입 안전성(타입스크립트의 타입 시스템 활용), 셋째, 캡슐화(개인키는 private, 공개키는 public)입니다.

이러한 특징들이 안전한 지갑 시스템의 기반이 됩니다.

코드 예제

import * as elliptic from 'elliptic';
import * as crypto from 'crypto';

const ec = new elliptic.ec('secp256k1');

export class Wallet {
  // 개인키는 외부에서 접근 불가 (캡슐화)
  private readonly privateKey: string;
  public readonly publicKey: string;
  public readonly address: string;

  constructor(privateKey?: string) {
    // 개인키가 주어지면 사용, 없으면 새로 생성
    this.privateKey = privateKey || crypto.randomBytes(32).toString('hex');
    const keyPair = ec.keyFromPrivate(this.privateKey);
    this.publicKey = keyPair.getPublic().encode('hex', true);
    // 실제로는 공개키 해싱 후 Base58Check 인코딩 필요
    this.address = this.generateAddress();
  }

  private generateAddress(): string {
    // 간단한 예시: 실제로는 RIPEMD160(SHA256(publicKey))
    return crypto.createHash('sha256').update(this.publicKey).digest('hex').slice(0, 40);
  }

  // 메시지 서명
  public sign(message: string): string {
    const keyPair = ec.keyFromPrivate(this.privateKey);
    const msgHash = crypto.createHash('sha256').update(message).digest();
    const signature = keyPair.sign(msgHash);
    return signature.toDER('hex');
  }

  // 서명 검증
  public static verify(message: string, signature: string, publicKey: string): boolean {
    const msgHash = crypto.createHash('sha256').update(message).digest();
    const key = ec.keyFromPublic(publicKey, 'hex');
    return key.verify(msgHash, signature);
  }
}

설명

이것이 하는 일: Wallet 클래스는 비트코인 지갑의 핵심 기능을 객체지향 방식으로 구현합니다. 타입스크립트의 접근 제어자를 활용하여 개인키는 외부에서 절대 접근할 수 없도록 하고, 공개키와 주소는 읽기 전용으로 공개합니다.

생성자에서는 선택적으로 개인키를 받을 수 있도록 설계했습니다. 새 지갑을 만들 때는 new Wallet()으로 호출하면 자동으로 새 개인키가 생성되고, 기존 지갑을 복원할 때는 new Wallet(existingPrivateKey)로 호출합니다.

이 패턴은 지갑 복구 기능을 구현할 때 매우 유용합니다. 개인키로부터 keyPair 객체를 만들고, 이를 통해 공개키를 생성한 후, 다시 공개키로부터 주소를 파생시킵니다.

sign 메서드는 인스턴스 메서드로, 해당 지갑의 개인키로 메시지를 서명합니다. 먼저 메시지를 SHA256으로 해싱하는 이유는 secp256k1이 고정된 길이의 입력을 요구하기 때문입니다.

keyPair.sign()은 ECDSA(Elliptic Curve Digital Signature Algorithm) 서명을 생성하며, DER 포맷으로 인코딩하여 반환합니다. DER은 X.690 국제 표준으로, 비트코인 트랜잭션에서 서명을 저장하는 표준 포맷입니다.

verify 메서드는 정적 메서드로 설계했습니다. 서명 검증은 개인키가 필요 없고 공개키만 있으면 되므로, 특정 지갑 인스턴스에 종속될 필요가 없기 때문입니다.

이를 통해 다른 사용자의 서명을 검증할 수 있습니다. 메시지를 동일한 방식으로 해싱한 후, 공개키로부터 키 객체를 만들어 서명이 유효한지 검증합니다.

이 과정은 수학적으로 서명이 해당 공개키와 쌍을 이루는 개인키로 생성되었는지 확인합니다. 여러분이 이 클래스를 사용하면 지갑 생성은 const myWallet = new Wallet() 한 줄이면 끝나고, 서명은 myWallet.sign("거래내용")으로 간단히 할 수 있습니다.

또한 타입스크립트의 타입 체킹 덕분에 컴파일 시점에 많은 오류를 잡을 수 있습니다. readonly 키워드는 실수로 키를 변경하는 것을 방지하며, private 키워드는 개인키 유출을 막는 첫 번째 방어선이 됩니다.

실전 팁

💡 개인키를 JSON.stringify()로 직렬화할 때 조심하세요. private 필드도 포함될 수 있으므로, toJSON() 메서드를 오버라이드하여 공개키와 주소만 포함하도록 하세요. 또는 별도의 export() 메서드를 만드는 것이 더 안전합니다.

💡 Wallet 인스턴스를 메모리에 오래 보관하지 마세요. 사용 후에는 개인키를 메모리에서 지우는 것이 좋습니다. 하지만 자바스크립트는 가비지 컬렉션 언어라 완전한 메모리 삭제가 어려우므로, 중요한 애플리케이션에서는 하드웨어 지갑을 고려하세요.

💡 generateAddress()는 단순화된 예시입니다. 실제 비트코인 주소 생성은 SHA256 -> RIPEMD160 -> Base58Check 인코딩 과정을 거치므로, 프로덕션에서는 bitcoinjs-lib 같은 검증된 라이브러리를 사용하세요.

💡 테스트 코드를 작성할 때는 고정된 개인키를 생성자에 전달하여 테스트의 재현성을 보장하세요. new Wallet('0001020304...')처럼 사용하되, 이 키는 절대 실제 네트워크에서 사용하지 마세요.

💡 클래스를 확장할 때는 상속보다 컴포지션을 고려하세요. 예를 들어, HD Wallet(계층적 결정론적 지갑)을 만들 때 Wallet을 상속받기보다는, HDWallet 클래스가 Wallet 인스턴스들을 관리하는 방식이 더 유연합니다.


3. HD 지갑과 니모닉 구문 - 하나의 시드로 무한한 키 생성

시작하며

여러분이 거래소에서 지갑을 만들 때 "12개 또는 24개의 단어를 안전하게 보관하세요"라는 메시지를 본 적이 있을 것입니다. 이 단어들이 바로 니모닉 구문인데, 단순히 개인키를 외우기 쉽게 만든 것이 아닙니다.

일반적인 지갑은 하나의 개인키만 가지므로, 여러 주소를 사용하려면 여러 개의 지갑을 만들고 각각의 개인키를 백업해야 합니다. 10개의 주소를 사용한다면 10개의 개인키를 관리해야 하는데, 이는 매우 번거롭고 위험합니다.

바로 이럴 때 필요한 것이 HD(Hierarchical Deterministic) 지갑입니다. 하나의 시드(니모닉 구문)로부터 수학적으로 무한한 개인키를 파생시킬 수 있어, 단 하나의 백업만으로 모든 키를 복구할 수 있습니다.

개요

간단히 말해서, HD 지갑은 BIP32, BIP39, BIP44 표준을 따라 하나의 마스터 시드로부터 계층적으로 키를 생성하는 시스템입니다. 니모닉 구문은 이 시드를 사람이 기억하기 쉬운 12~24개의 단어로 인코딩한 것입니다.

이 방식이 필요한 이유는 프라이버시와 보안 때문입니다. 비트코인에서는 주소 재사용을 피하는 것이 권장되는데, 거래마다 새로운 주소를 사용하면 거래 추적이 어려워집니다.

HD 지갑을 사용하면 매번 새 주소를 생성하면서도 백업은 한 번만 하면 됩니다. 예를 들어, 온라인 쇼핑몰을 운영한다면 고객마다 고유한 입금 주소를 제공하면서도 단일 시드로 모든 키를 관리할 수 있습니다.

기존 방식에서는 새 주소가 필요할 때마다 새 개인키를 생성하고 백업해야 했다면, HD 지갑에서는 파생 경로(derivation path)만 기록하면 됩니다. 예를 들어 m/44'/0'/0'/0/5 같은 경로만 알면 언제든 동일한 키를 재생성할 수 있습니다.

HD 지갑의 핵심 특징은 첫째, 결정론적 생성(같은 시드면 항상 같은 키 생성), 둘째, 계층 구조(조직/부서/사용자별 키 분리 가능), 셋째, 확장 키(xpub/xpriv로 특정 레벨의 키만 공유 가능)입니다. 이러한 특징들이 기업용 지갑 솔루션의 기반이 됩니다.

코드 예제

import * as bip39 from 'bip39';
import * as bip32 from 'bip32';
import * as bitcoin from 'bitcoinjs-lib';

export class HDWallet {
  private readonly seed: Buffer;
  private readonly root: bip32.BIP32Interface;

  // 새 HD 지갑 생성 또는 니모닉으로 복구
  constructor(mnemonic?: string) {
    if (mnemonic) {
      // 기존 니모닉으로 복구
      if (!bip39.validateMnemonic(mnemonic)) {
        throw new Error('Invalid mnemonic');
      }
      this.seed = bip39.mnemonicToSeedSync(mnemonic);
    } else {
      // 새 니모닉 생성 (128비트 = 12단어)
      const newMnemonic = bip39.generateMnemonic(128);
      this.seed = bip39.mnemonicToSeedSync(newMnemonic);
      console.log('백업하세요:', newMnemonic);
    }

    // BIP32 마스터 키 생성
    this.root = bip32.fromSeed(this.seed);
  }

  // BIP44 경로로 자식 키 파생: m/44'/0'/0'/0/index
  public deriveKey(index: number): { address: string; privateKey: string } {
    // 44'는 BIP44, 0'은 비트코인, 0'은 첫 번째 계정
    const path = `m/44'/0'/0'/0/${index}`;
    const child = this.root.derivePath(path);

    if (!child.privateKey) {
      throw new Error('키 파생 실패');
    }

    // P2PKH 주소 생성
    const { address } = bitcoin.payments.p2pkh({
      pubkey: child.publicKey,
      network: bitcoin.networks.bitcoin
    });

    return {
      address: address!,
      privateKey: child.privateKey.toString('hex')
    };
  }

  // 확장 공개키 내보내기 (읽기 전용 지갑용)
  public getXPub(): string {
    const account = this.root.derivePath("m/44'/0'/0'");
    return account.neutered().toBase58();
  }
}

설명

이것이 하는 일: HDWallet 클래스는 BIP39(니모닉), BIP32(계층적 키 파생), BIP44(다중 계정)를 결합하여 엔터프라이즈급 지갑 시스템을 구현합니다. 이는 Ledger, Trezor 같은 하드웨어 지갑과 동일한 표준을 사용합니다.

생성자는 두 가지 모드를 지원합니다. 니모닉이 없으면 bip39.generateMnemonic()으로 128비트 엔트로피를 생성하여 12개 단어로 인코딩합니다.

256비트를 사용하면 24개 단어가 되며, 보안은 더 강력하지만 사용자가 기억하기 어렵습니다. 니모닉이 제공되면 먼저 유효성을 검증한 후(체크섬 확인), mnemonicToSeedSync()로 512비트 시드를 생성합니다.

이 과정에서 PBKDF2 키 파생 함수가 사용되어 2048번의 반복 해싱이 일어나므로, 브루트포스 공격에 강합니다. bip32.fromSeed()는 시드로부터 마스터 개인키와 체인 코드를 생성합니다.

체인 코드는 자식 키를 파생할 때 사용되는 256비트 엔트로피로, 개인키만으로는 자식 키를 생성할 수 없게 만듭니다. 이는 확장 공개키(xpub)를 공유해도 개인키가 유출되지 않는 핵심 메커니즘입니다.

deriveKey() 메서드는 BIP44 경로를 사용합니다. m은 마스터, 44'는 BIP44 표준, 0'은 비트코인(이더리움은 60'), 0'은 첫 번째 계정, 0은 외부 체인(1은 잔액 반환용), 마지막 숫자가 주소 인덱스입니다.

작은따옴표(')는 강화된 파생(hardened derivation)을 의미하며, 이는 부모 공개키로부터 자식 개인키를 역산할 수 없게 만듭니다. derivePath()는 이 경로를 따라 수학적으로 자식 키를 계산하며, 동일한 경로는 항상 동일한 키를 생성합니다.

getXPub() 메서드는 계정 레벨의 확장 공개키를 반환합니다. neutered()는 개인키 부분을 제거하여 공개키만 남기며, 이 xpub을 사용하면 주소 생성은 가능하지만 서명은 불가능합니다.

이는 온라인 서버에서 입금 주소를 생성할 때 유용합니다. 개인키는 오프라인 콜드 스토리지에만 보관하고, 서버에는 xpub만 저장하면 해킹당해도 자금은 안전합니다.

여러분이 이 코드를 사용하면 거래소나 결제 시스템을 구축할 때 각 사용자에게 고유한 입금 주소를 제공할 수 있습니다. hdWallet.deriveKey(userId)처럼 사용자 ID를 인덱스로 사용하면, 동일한 시드로 수백만 개의 주소를 관리하면서도 백업은 12개 단어만 보관하면 됩니다.

또한 BIP44 표준을 따르므로 Metamask, Trust Wallet 등 다른 지갑과 상호 운용이 가능합니다.

실전 팁

💡 니모닉을 생성할 때 반드시 사용자에게 종이에 적어 안전한 곳에 보관하라고 안내하세요. 스크린샷이나 클라우드 저장은 해킹 위험이 있으므로, 오프라인 백업이 최선입니다. 일부는 금속 판에 새기기도 합니다.

💡 validateMnemonic()은 체크섬만 검증하므로, 니모닉이 BIP39 단어 목록에 있는 단어인지도 확인하세요. 오타가 있으면 완전히 다른 지갑이 생성됩니다. bip39.wordlists를 활용하여 자동 완성 기능을 제공하면 좋습니다.

💡 파생 경로의 강화 여부(')를 이해하세요. m/44'/0'/0'/0/5에서 처음 세 레벨은 강화되어 있어 xpub를 공유해도 안전하지만, 마지막 두 레벨은 비강화되어 연속된 주소 생성이 가능합니다. 잔액 확인 서비스를 만들 때 이를 활용하세요.

💡 테스트넷에서는 bitcoin.networks.testnet을 사용하세요. 메인넷 주소를 실수로 테스트넷에 사용하면 자금이 영원히 손실될 수 있습니다. 환경 변수로 네트워크를 관리하는 것이 좋습니다.

💡 니모닉에 패스프레이즈(25번째 단어)를 추가하는 기능도 고려하세요. mnemonicToSeedSync(mnemonic, passphrase)로 구현할 수 있으며, 동일 니모닉이라도 패스프레이즈가 다르면 완전히 다른 지갑이 생성되어 보안이 강화됩니다.


4. 서명과 트랜잭션 검증 - ECDSA 디지털 서명의 이해

시작하며

여러분이 블록체인에서 거래를 보낼 때 "어떻게 이 거래가 정말 내가 보낸 것이라고 증명할까?"라는 질문에 직면합니다. 비트코인은 중앙 기관이 없으므로, 신분증이나 패스워드로 신원을 증명할 수 없습니다.

만약 서명 없이 거래를 보낸다면, 누구든지 여러분의 공개키(주소)를 보고 여러분 명의로 거래를 위조할 수 있습니다. 실제로 초기 디지털 화폐 시도들이 이중 지불 문제와 함께 서명 위조 문제로 실패했습니다.

바로 이럴 때 필요한 것이 ECDSA(Elliptic Curve Digital Signature Algorithm) 디지털 서명입니다. 이는 수학적으로 "나만이 이 거래를 승인했다"를 증명하면서도, 개인키를 노출하지 않습니다.

개요

간단히 말해서, ECDSA 서명은 메시지(거래 내용)와 개인키를 입력으로 받아, 누구나 공개키로 검증할 수 있는 서명을 생성하는 알고리즘입니다. 서명은 (r, s) 두 개의 숫자로 구성됩니다.

이 서명이 필요한 이유는 부인 방지(non-repudiation)와 무결성 검증입니다. 한 번 서명된 거래는 서명자가 "내가 보낸 게 아니다"라고 부인할 수 없으며, 거래 내용이 조금이라도 변경되면 서명이 무효화됩니다.

예를 들어, 송금액을 0.1 BTC에서 1 BTC로 변조하면 서명 검증이 실패하므로, 네트워크가 거래를 거부합니다. 기존 RSA 서명은 키 크기가 크고 연산이 느렸지만, ECDSA는 256비트 키로 3072비트 RSA와 동일한 보안을 제공하며, 서명 생성과 검증이 훨씬 빠릅니다.

이는 블록에 수천 개의 거래가 들어가는 블록체인에서 매우 중요합니다. ECDSA의 핵심 특징은 첫째, 일회용 난수(nonce) 사용으로 동일한 메시지도 매번 다른 서명 생성, 둘째, 서명으로부터 공개키 복구 가능(비트코인에서 공간 절약), 셋째, 결정론적 nonce 생성(RFC 6979)으로 취약한 난수 문제 해결입니다.

특히 결정론적 nonce는 PlayStation 3 해킹 사건(같은 nonce 재사용으로 개인키 유출) 같은 재앙을 방지합니다.

코드 예제

import * as elliptic from 'elliptic';
import * as crypto from 'crypto';

const ec = new elliptic.ec('secp256k1');

export class TransactionSigner {
  // 거래 데이터 서명
  public static signTransaction(
    txData: { from: string; to: string; amount: number; nonce: number },
    privateKey: string
  ): { signature: string; recovery: number } {
    // 거래를 정규화된 문자열로 직렬화
    const txString = JSON.stringify(txData, Object.keys(txData).sort());

    // SHA256으로 해싱 (고정 길이 필요)
    const txHash = crypto.createHash('sha256').update(txString).digest();

    // ECDSA 서명 (결정론적 nonce 사용)
    const keyPair = ec.keyFromPrivate(privateKey);
    const signature = keyPair.sign(txHash);

    // DER 인코딩된 서명과 복구 ID 반환
    return {
      signature: signature.toDER('hex'),
      recovery: signature.recoveryParam || 0
    };
  }

  // 서명 검증 (공개키 필요)
  public static verifySignature(
    txData: { from: string; to: string; amount: number; nonce: number },
    signature: string,
    publicKey: string
  ): boolean {
    const txString = JSON.stringify(txData, Object.keys(txData).sort());
    const txHash = crypto.createHash('sha256').update(txString).digest();

    const key = ec.keyFromPublic(publicKey, 'hex');
    return key.verify(txHash, signature);
  }

  // 서명으로부터 공개키 복구
  public static recoverPublicKey(
    txData: { from: string; to: string; amount: number; nonce: number },
    signature: string,
    recovery: number
  ): string {
    const txString = JSON.stringify(txData, Object.keys(txData).sort());
    const txHash = crypto.createHash('sha256').update(txString).digest();

    const pubKey = ec.recoverPubKey(
      txHash,
      signature,
      recovery
    );

    return pubKey.encode('hex', true);
  }
}

설명

이것이 하는 일: TransactionSigner 클래스는 비트코인 거래의 서명 생성, 검증, 공개키 복구 기능을 제공합니다. 이는 블록체인 네트워크에서 거래의 진위를 판별하는 핵심 메커니즘입니다.

signTransaction() 메서드는 먼저 거래 객체를 정규화합니다. JSON.stringify()의 두 번째 인자로 정렬된 키 배열을 전달하는 이유는, 자바스크립트 객체의 속성 순서가 보장되지 않기 때문입니다.

{amount: 10, to: "abc"}와 {to: "abc", amount: 10}은 다른 문자열이 되므로, 다른 해시가 생성됩니다. 키를 정렬하면 동일한 거래는 항상 동일한 문자열이 되어, 서명 검증이 일관되게 작동합니다.

거래 문자열을 SHA256으로 해싱하는 이유는 두 가지입니다. 첫째, ECDSA는 고정 길이(32바이트) 입력을 요구합니다.

둘째, 해시 함수의 충돌 저항성 덕분에 다른 거래는 사실상 항상 다른 해시를 가지므로, 서명 위조가 불가능합니다. keyPair.sign()은 RFC 6979 표준을 따라 결정론적으로 nonce를 생성하므로, 동일한 거래를 여러 번 서명해도 같은 서명이 나옵니다.

이는 테스트와 재현성에 유리하며, 취약한 난수 생성기 문제를 피할 수 있습니다. verifySignature() 메서드는 서명자의 공개키를 사용하여 서명을 검증합니다.

거래 데이터를 동일한 방식으로 해싱한 후, ec.verify()는 수학적으로 "(r, s) 서명이 이 해시와 공개키에 대해 유효한가?"를 확인합니다. 내부적으로는 타원곡선 위의 점 연산을 수행하는데, s의 역원을 구하고, 기준점과 공개키를 각각 스칼라 곱한 후 더해서 r과 비교합니다.

이 과정은 개인키 없이 공개키만으로 가능하므로, 누구나 서명을 검증할 수 있습니다. recoverPublicKey() 메서드는 비트코인의 독특한 최적화 기법입니다.

ECDSA 서명의 수학적 특성상 서명과 메시지로부터 공개키를 역산할 수 있습니다(최대 4개 후보 중 recovery 파라미터로 선택). 이를 활용하면 트랜잭션에 공개키를 포함할 필요가 없어 33바이트를 절약할 수 있습니다.

대신 1바이트 recovery ID만 추가하면 됩니다. 이더리움은 이 방식을 적극 활용하여 from 필드 없이도 서명자를 확인합니다.

여러분이 이 코드를 사용하면 안전한 거래 시스템을 구축할 수 있습니다. 거래를 생성하는 클라이언트는 개인키로 서명하고, 네트워크의 모든 노드는 공개키로 검증합니다.

하나의 노드라도 검증에 실패하면 거래가 거부되므로, 위조된 거래는 네트워크에 전파되지 않습니다. 또한 서명에는 타임스탬프나 nonce를 포함시켜 재생 공격(replay attack)을 방지할 수 있습니다.

실전 팁

💡 거래 데이터에 반드시 nonce를 포함하세요. nonce가 없으면 동일한 거래를 여러 번 서명하여 이중 지불이 가능합니다. 이더리움은 계정별 nonce를 사용하고, 비트코인은 UTXO를 참조하여 재생 공격을 방지합니다.

💡 서명 검증 실패 시 구체적인 오류 메시지를 로깅하지 마세요. "Invalid signature"만 반환하고, 상세 정보는 서버 로그에만 남기세요. 공격자가 왜 실패했는지 알면 서명 위조 시도를 반복할 수 있습니다.

💡 DER 인코딩된 서명은 가변 길이이므로(70-72바이트), 고정 길이가 필요하면 compact 형식(64바이트)을 사용하세요. signature.toCompact()로 변환할 수 있으며, 이더리움은 이 형식을 사용합니다.

💡 공개키 복구를 사용할 때는 복구된 공개키가 from 주소와 일치하는지 확인하세요. recovery 파라미터가 잘못되면 엉뚱한 공개키가 복구되므로, 주소 검증이 필수입니다.

💡 서명 전에 거래 데이터를 동결(Object.freeze)하세요. 서명 후 데이터가 변경되면 서명이 무효화되므로, 불변성을 보장하는 것이 중요합니다. 타입스크립트의 Readonly 타입도 활용하세요.


5. 키 저장과 암호화 - 안전한 지갑 파일 관리

시작하며

여러분이 지갑을 만들었다면 다음 문제는 "개인키를 어디에, 어떻게 저장할까?"입니다. 메모리에만 보관하면 애플리케이션을 종료하면 사라지고, 평문으로 파일에 저장하면 누구든 읽을 수 있어 위험합니다.

실제로 2014년 Mt. Gox 사건에서는 핫 월렛의 개인키가 제대로 암호화되지 않아 85만 비트코인(당시 4억 5천만 달러)이 도난당했습니다.

개인키 관리의 실패는 곧 자산 손실로 이어집니다. 바로 이럴 때 필요한 것이 키 암호화 저장 방식입니다.

사용자의 비밀번호로 개인키를 암호화하여 저장하면, 파일이 유출되어도 비밀번호 없이는 복호화할 수 없습니다.

개요

간단히 말해서, 키스토어(keystore) 파일은 개인키를 AES 같은 대칭 암호로 암호화하고, 비밀번호로부터 KDF(Key Derivation Function)로 암호화 키를 파생시켜 저장하는 표준 포맷입니다. 이 방식이 필요한 이유는 다층 보안입니다.

첫째, 파일 시스템이 뚫려도 암호화된 키만 노출됩니다. 둘째, 비밀번호가 약해도 KDF의 반복 연산이 브루트포스를 어렵게 만듭니다.

셋째, salt를 사용하여 동일한 비밀번호라도 다른 암호화 키가 생성됩니다. 예를 들어, 클라우드에 백업할 때도 암호화된 키스토어 파일이라면 상대적으로 안전합니다.

기존에는 개인키를 Base64 인코딩 정도만 하고 저장했다면, 현대적인 키스토어는 AES-128-CTR 같은 강력한 암호화와 scrypt/PBKDF2 같은 KDF를 조합합니다. Ethereum의 Web3 Secret Storage Definition이 사실상 표준입니다.

키스토어의 핵심 특징은 첫째, KDF로 브루트포스 공격 비용 증가(scrypt는 메모리도 많이 사용), 둘째, MAC(Message Authentication Code)으로 파일 변조 감지, 셋째, 버전 관리로 향후 더 강력한 암호화로 업그레이드 가능입니다. 이러한 특징들이 장기간 키를 안전하게 보관할 수 있게 해줍니다.

코드 예제

import * as crypto from 'crypto';
import * as fs from 'fs';

interface Keystore {
  version: number;
  id: string;
  crypto: {
    cipher: string;
    ciphertext: string;
    cipherparams: { iv: string };
    kdf: string;
    kdfparams: {
      salt: string;
      n: number;
      r: number;
      p: number;
      dklen: number;
    };
    mac: string;
  };
}

export class KeystoreManager {
  // 개인키를 암호화하여 키스토어 파일 생성
  public static encryptPrivateKey(privateKey: string, password: string): Keystore {
    const salt = crypto.randomBytes(32);
    const iv = crypto.randomBytes(16);

    // scrypt로 비밀번호로부터 암호화 키 파생 (N=8192는 중간 강도)
    const derivedKey = crypto.scryptSync(password, salt, 32, { N: 8192, r: 8, p: 1 });

    // AES-128-CTR로 개인키 암호화
    const cipher = crypto.createCipheriv('aes-128-ctr', derivedKey.slice(0, 16), iv);
    const ciphertext = Buffer.concat([cipher.update(privateKey, 'hex'), cipher.final()]);

    // MAC 생성으로 변조 감지 가능하게 함
    const mac = crypto.createHash('sha256')
      .update(Buffer.concat([derivedKey.slice(16, 32), ciphertext]))
      .digest();

    return {
      version: 1,
      id: crypto.randomUUID(),
      crypto: {
        cipher: 'aes-128-ctr',
        ciphertext: ciphertext.toString('hex'),
        cipherparams: { iv: iv.toString('hex') },
        kdf: 'scrypt',
        kdfparams: { salt: salt.toString('hex'), n: 8192, r: 8, p: 1, dklen: 32 },
        mac: mac.toString('hex')
      }
    };
  }

  // 키스토어 파일 복호화
  public static decryptKeystore(keystore: Keystore, password: string): string {
    const { crypto: cryptoData } = keystore;
    const salt = Buffer.from(cryptoData.kdfparams.salt, 'hex');

    // 동일한 파라미터로 키 재파생
    const derivedKey = crypto.scryptSync(password, salt, cryptoData.kdfparams.dklen, {
      N: cryptoData.kdfparams.n,
      r: cryptoData.kdfparams.r,
      p: cryptoData.kdfparams.p
    });

    // MAC 검증 (비밀번호 오류 또는 파일 변조 감지)
    const ciphertext = Buffer.from(cryptoData.ciphertext, 'hex');
    const mac = crypto.createHash('sha256')
      .update(Buffer.concat([derivedKey.slice(16, 32), ciphertext]))
      .digest();

    if (mac.toString('hex') !== cryptoData.mac) {
      throw new Error('잘못된 비밀번호 또는 파일 손상');
    }

    // 복호화
    const iv = Buffer.from(cryptoData.cipherparams.iv, 'hex');
    const decipher = crypto.createDecipheriv('aes-128-ctr', derivedKey.slice(0, 16), iv);
    const privateKey = Buffer.concat([decipher.update(ciphertext), decipher.final()]);

    return privateKey.toString('hex');
  }

  // 파일로 저장
  public static saveKeystore(keystore: Keystore, path: string): void {
    fs.writeFileSync(path, JSON.stringify(keystore, null, 2), 'utf8');
  }

  // 파일에서 로드
  public static loadKeystore(path: string): Keystore {
    const data = fs.readFileSync(path, 'utf8');
    return JSON.parse(data) as Keystore;
  }
}

설명

이것이 하는 일: KeystoreManager 클래스는 이더리움 Web3 Secret Storage 표준을 따라 개인키를 안전하게 파일로 저장하고 불러오는 기능을 제공합니다. 이는 MetaMask, MyEtherWallet 등이 사용하는 동일한 방식입니다.

encryptPrivateKey() 메서드는 여러 단계의 보안 레이어를 구축합니다. 첫째, 32바이트 salt와 16바이트 IV(Initialization Vector)를 생성합니다.

salt는 동일한 비밀번호라도 다른 암호화 키가 생성되도록 하여, 레인보우 테이블 공격을 방지합니다. IV는 동일한 평문을 여러 번 암호화해도 다른 암호문이 나오게 하여, 패턴 분석을 막습니다.

scryptSync()는 비밀번호와 salt로부터 32바이트 파생 키를 생성하는데, N=8192는 2^13번의 반복을 의미합니다. 이는 한 번의 키 파생에 약 100ms가 걸리게 하여, 초당 10개 비밀번호만 테스트할 수 있게 만듭니다.

N을 높일수록 보안은 강화되지만 사용자 경험이 나빠지므로, 균형이 중요합니다. 이더리움은 N=262144를 권장하지만, 모바일 환경에서는 낮은 값을 사용합니다.

r=8, p=1은 메모리 사용량과 병렬화 정도를 제어합니다. AES-128-CTR 모드로 암호화를 수행합니다.

CTR(Counter) 모드는 블록 암호를 스트림 암호처럼 사용하게 해주며, 병렬 처리가 가능하고 패딩이 필요 없어 효율적입니다. 파생 키의 앞 16바이트를 암호화 키로 사용하고, createCipheriv()로 암호화 객체를 만든 후, update()와 final()로 실제 암호화를 수행합니다.

개인키가 hex 문자열이므로 'hex' 인코딩을 지정합니다. MAC 생성은 매우 중요합니다.

파생 키의 뒤 16바이트와 암호문을 연결하여 SHA256 해싱하면, 비밀번호가 틀리거나 파일이 변조되면 MAC이 일치하지 않습니다. 이를 통해 복호화 실패 원인이 잘못된 비밀번호인지, 파일 손상인지 구분할 수 있습니다.

MAC을 검증하지 않으면 공격자가 암호문을 조작하여 부분적으로 복호화된 데이터를 얻을 수 있습니다. decryptKeystore() 메서드는 역순으로 진행합니다.

키스토어에 저장된 KDF 파라미터로 동일한 파생 키를 생성하고, MAC을 먼저 검증합니다. MAC이 일치하면 올바른 비밀번호이므로, 복호화를 진행합니다.

이 순서가 중요한데, 복호화부터 시도하면 타이밍 공격에 취약할 수 있습니다. 여러분이 이 코드를 사용하면 지갑 애플리케이션에서 "비밀번호로 보호된 지갑" 기능을 구현할 수 있습니다.

사용자는 강력한 비밀번호만 기억하면 되고, 키스토어 파일은 클라우드에 백업해도 상대적으로 안전합니다. 다만 비밀번호를 잊으면 영원히 복구 불가능하므로, HD 지갑의 니모닉과 병행하는 것이 좋습니다.

니모닉은 장기 백업용, 키스토어는 일상적 사용용으로 구분하세요.

실전 팁

💡 프로덕션 환경에서는 N=262144 이상을 사용하세요. 8192는 테스트용이며, 실제 환경에서는 최소 100ms 이상 걸리도록 설정해야 브루트포스 공격을 효과적으로 막을 수 있습니다. 사용자 기기 성능을 고려하여 조절하세요.

💡 키스토어 파일 권한을 600(소유자만 읽기/쓰기)으로 설정하세요. fs.writeFileSync(path, data, { mode: 0o600 })으로 생성 시 권한을 지정할 수 있습니다. 다른 사용자가 읽을 수 있으면 브루트포스 공격 대상이 됩니다.

💡 비밀번호 강도를 검증하세요. zxcvbn 같은 라이브러리로 최소 엔트로피를 요구하고, 일반적인 비밀번호(password123 등)를 거부하세요. 강력한 암호화도 약한 비밀번호 앞에서는 무력합니다.

💡 키스토어에 메타데이터를 추가하세요. 지갑 이름, 생성 날짜, 주소 등을 함께 저장하면 사용자가 여러 지갑을 관리할 때 편리합니다. 이 메타데이터는 암호화하지 않아도 개인키 유출 위험은 없습니다.

💡 정기적으로 키스토어를 재암호화하세요. 암호화 표준이 발전하므로, 1년에 한 번씩 최신 파라미터로 재암호화하는 것이 좋습니다. 버전 필드를 활용하여 마이그레이션 경로를 제공하세요.


6. 다중 서명 지갑 - 2-of-3 멀티시그 구현

시작하며

여러분이 회사 자금을 관리할 때 한 사람이 모든 권한을 가지는 것은 위험합니다. 그 사람이 악의적이거나, 개인키를 분실하거나, 사고를 당하면 자금에 접근할 수 없게 됩니다.

전통적인 은행은 고액 거래 시 두 명 이상의 승인을 요구하는 이중 통제(dual control) 시스템을 사용합니다. 비트코인에서도 동일한 보안 메커니즘이 필요한데, 이것이 바로 다중 서명(multisig)입니다.

바로 이럴 때 필요한 것이 M-of-N 멀티시그 지갑입니다. N명 중 최소 M명이 서명해야 거래가 유효하므로, 단일 장애점(single point of failure)을 제거할 수 있습니다.

개요

간단히 말해서, 다중 서명 지갑은 여러 개의 개인키 중 정해진 개수 이상의 서명이 있어야 자금을 이동할 수 있는 스마트 계약 또는 스크립트 기반 지갑입니다. 예를 들어 2-of-3는 3개 키 중 2개 서명이 필요합니다.

이 방식이 필요한 이유는 보안과 복구 가능성의 균형입니다. 1-of-1(일반 지갑)은 편리하지만 키 분실 시 복구 불가능하고, 3-of-3는 안전하지만 한 명이 키를 잃으면 자금이 영원히 잠깁니다.

2-of-3는 둘 사이의 스위트 스팟으로, 한 키를 잃어도 나머지 두 키로 자금을 옮길 수 있으며, 한 키가 탈취되어도 자금은 안전합니다. 예를 들어, 거래소는 핫 월렛(온라인), 콜드 스토리지(오프라인), 백업 키(금고)로 2-of-3를 구성합니다.

기존 단일 서명 방식에서는 개인키 하나로 모든 것이 결정되었다면, 멀티시그에서는 CEO, CFO, 보안 담당자가 각각 키를 가지고 두 명 이상이 동의해야 거래가 실행됩니다. 이는 내부 부정도 방지합니다.

멀티시그의 핵심 특징은 첫째, 탈중앙화된 신뢰(어느 한 사람도 단독으로 결정 불가), 둘째, 복구 가능성(일부 키 분실 허용), 셋째, 감사 추적(누가 서명했는지 블록체인에 기록)입니다. 이러한 특징들이 기업 자산 관리와 DAO(탈중앙화 자율조직) 운영의 기반이 됩니다.

코드 예제

import * as elliptic from 'elliptic';
import * as crypto from 'crypto';

const ec = new elliptic.ec('secp256k1');

interface MultiSigConfig {
  requiredSignatures: number;
  publicKeys: string[];
}

export class MultiSigWallet {
  private config: MultiSigConfig;

  constructor(requiredSigs: number, publicKeys: string[]) {
    if (requiredSigs > publicKeys.length) {
      throw new Error('필요한 서명 수가 총 키 개수보다 많음');
    }
    if (requiredSigs < 1) {
      throw new Error('최소 1개 서명 필요');
    }

    this.config = {
      requiredSignatures: requiredSigs,
      publicKeys: publicKeys
    };
  }

  // 거래 제안 생성
  public createTransaction(txData: { to: string; amount: number; nonce: number }): string {
    const txString = JSON.stringify(txData, Object.keys(txData).sort());
    return crypto.createHash('sha256').update(txString).digest('hex');
  }

  // 개별 서명 추가
  public signTransaction(txHash: string, privateKey: string): { signature: string; publicKey: string } {
    const keyPair = ec.keyFromPrivate(privateKey);
    const signature = keyPair.sign(txHash);
    const publicKey = keyPair.getPublic().encode('hex', true);

    // 이 공개키가 멀티시그 참여자인지 확인
    if (!this.config.publicKeys.includes(publicKey)) {
      throw new Error('권한 없는 서명자');
    }

    return {
      signature: signature.toDER('hex'),
      publicKey: publicKey
    };
  }

  // 다중 서명 검증
  public verifyMultiSig(
    txHash: string,
    signatures: Array<{ signature: string; publicKey: string }>
  ): boolean {
    // 중복 서명 제거 (같은 사람이 두 번 서명 방지)
    const uniqueSigs = signatures.filter((sig, index, self) =>
      index === self.findIndex(s => s.publicKey === sig.publicKey)
    );

    // 필요한 서명 수 확인
    if (uniqueSigs.length < this.config.requiredSignatures) {
      return false;
    }

    // 각 서명 개별 검증
    for (const sig of uniqueSigs) {
      if (!this.config.publicKeys.includes(sig.publicKey)) {
        return false; // 권한 없는 서명자
      }

      const key = ec.keyFromPublic(sig.publicKey, 'hex');
      if (!key.verify(txHash, sig.signature)) {
        return false; // 잘못된 서명
      }
    }

    return true;
  }

  // 멀티시그 주소 생성 (단순화된 버전)
  public getAddress(): string {
    const sorted = [...this.config.publicKeys].sort();
    const combined = sorted.join('') + this.config.requiredSignatures;
    return crypto.createHash('sha256').update(combined).digest('hex').slice(0, 40);
  }
}

설명

이것이 하는 일: MultiSigWallet 클래스는 비트코인의 P2SH(Pay-to-Script-Hash) 또는 이더리움의 멀티시그 컨트랙트와 유사한 기능을 타입스크립트로 구현합니다. 여러 당사자가 협력하여 자금을 관리하는 메커니즘을 제공합니다.

생성자는 필요한 서명 수(M)와 참여자들의 공개키 목록(N개)을 받아 유효성을 검증합니다. M > N이면 절대 거래가 실행될 수 없고, M < 1이면 누구든 자금을 가져갈 수 있으므로 거부합니다.

실무에서는 2-of-3, 3-of-5 같은 조합이 많이 사용됩니다. 공개키 목록은 지갑 생성 시 모든 참여자가 각자의 공개키를 제공하여 구성하며, 한번 정해지면 변경할 수 없습니다(불변성).

createTransaction() 메서드는 거래 내용을 해싱하여 서명 대상을 만듭니다. 멀티시그에서는 각 참여자가 비동기적으로 서명하므로, 먼저 거래 제안을 생성하고 이를 다른 참여자들에게 전달합니다.

실제 시스템에서는 이 제안을 데이터베이스나 IPFS에 저장하고, 이메일이나 슬랙으로 알림을 보냅니다. signTransaction() 메서드는 각 참여자가 자신의 개인키로 호출합니다.

먼저 서명을 생성한 후, 공개키가 멀티시그 구성에 포함되어 있는지 확인합니다. 이는 권한 없는 사람의 서명을 초기에 거부하여, 불필요한 데이터 전송을 막습니다.

서명과 공개키를 함께 반환하는 이유는, 수집자가 어느 참여자의 서명인지 식별할 수 있어야 하기 때문입니다. verifyMultiSig() 메서드는 수집된 모든 서명을 검증합니다.

먼저 중복을 제거하는데, 이는 한 사람이 여러 번 서명하여 요구 수를 충족시키는 것을 방지합니다. findIndex로 공개키 기준 중복을 찾아 첫 번째만 남깁니다.

그 다음 고유 서명 수가 요구 수 이상인지 확인하고, 각 서명을 개별적으로 검증합니다. 하나라도 유효하지 않으면 전체가 거부됩니다.

이 과정은 원자적(atomic)이어야 하며, 부분적으로 유효한 거래는 허용되지 않습니다. getAddress() 메서드는 멀티시그 지갑의 고유 주소를 생성합니다.

공개키들을 정렬하는 이유는 순서가 달라도 동일한 멀티시그라면 같은 주소가 나와야 하기 때문입니다. 요구 서명 수도 포함하여 2-of-3과 3-of-3이 다른 주소를 가지도록 합니다.

실제 비트코인에서는 이 주소로 송금된 자금은 redeem script(공개키 목록과 OP_CHECKMULTISIG 포함)를 제시해야만 사용할 수 있습니다. 여러분이 이 코드를 사용하면 DAO의 재무 관리, 공동 창업자 간 자금 관리, 가족 신탁 등을 구현할 수 있습니다.

예를 들어, 스타트업은 CEO, CTO, 투자자가 각각 키를 가지고 2-of-3로 구성하면, 투자자의 동의 없이 자금을 임의로 사용할 수 없습니다. 또는 개인은 노트북, 스마트폰, 하드웨어 월렛에 각각 키를 보관하여 2-of-3로 구성하면, 하나를 분실해도 복구 가능하고, 하나가 해킹당해도 자금은 안전합니다.

실전 팁

💡 서명 수집 시 타임아웃을 구현하세요. 무기한 대기하면 한 참여자의 무응답으로 시스템이 멈출 수 있으므로, 24~48시간 타임아웃을 두고 만료된 제안은 자동 취소하세요.

💡 공개키 순서를 정렬하여 저장하세요. 비트코인 P2SH는 공개키 순서가 중요하므로, 항상 정렬된 순서로 관리하면 다른 지갑과의 호환성이 높아집니다. BIP67 표준을 참고하세요.

💡 멀티시그 구성을 블록체인에 기록하는 대신, 각 참여자가 로컬에 보관하게 하세요. 블록체인에 모든 공개키를 올리면 프라이버시가 침해되고 수수료도 증가합니다. 대신 redeem script 해시만 공개하세요.

💡 참여자 변경 메커니즘을 고려하세요. 직원이 퇴사하면 키를 교체해야 하는데, 멀티시그는 불변이므로 새 멀티시그로 자금을 이동해야 합니다. 이를 자동화하는 스크립트를 준비하세요.

💡 Gnosis Safe, Argent 같은 검증된 멀티시그 솔루션 사용을 고려하세요. 처음부터 구현하면 버그 위험이 크므로, 학습 목적이 아니라면 프로덕션에서는 검증된 라이브러리나 서비스를 사용하는 것이 안전합니다.


7. 지갑 복구와 백업 전략 - 자산을 잃지 않는 방법

시작하며

여러분이 수년간 모은 비트코인을 지갑에 보관하고 있는데, 어느 날 컴퓨터가 고장났다고 상상해보세요. 백업이 없다면 그 자산은 영원히 사라집니다.

블록체인에는 "비밀번호 찾기" 버튼이 없습니다. 실제로 총 발행된 비트코인 2,100만 개 중 약 20%(400만 개)가 영구 손실된 것으로 추정됩니다.

대부분은 개인키 분실이나 하드 드라이브 파손 때문입니다. 2013년에는 한 영국인이 7,500 BTC가 든 하드 드라이브를 실수로 버렸고, 아직도 쓰레기 매립지를 수색 중입니다.

바로 이럴 때 필요한 것이 체계적인 백업과 복구 전략입니다. 단순히 파일을 복사하는 것이 아니라, 3-2-1 룰을 따르고, 테스트 가능한 복구 프로세스를 구축해야 합니다.

개요

간단히 말해서, 지갑 백업 전략은 개인키 또는 니모닉을 여러 형태와 위치에 중복 저장하고, 정기적으로 복구 테스트를 수행하여 자산 손실을 방지하는 프로세스입니다. 이 전략이 필요한 이유는 디지털 데이터의 취약성 때문입니다.

하드 드라이브는 평균 4년 수명이고, USB는 분실하기 쉬우며, 클라우드는 해킹 위험이 있습니다. 단일 백업은 백업이 아니며, 복구 테스트를 하지 않은 백업은 없는 것과 같습니다.

예를 들어, 니모닉을 종이에 적었는데 물에 젖어 읽을 수 없게 되거나, 암호화된 백업의 비밀번호를 잊어버리는 경우가 실제로 빈번합니다. 3-2-1 룰은 3개 복사본, 2개 다른 매체, 1개 오프사이트 보관을 의미합니다.

예를 들어, 니모닉을 금속판(방화/방수)에 새기고 집 금고에 보관, 키스토어 파일을 암호화된 USB에 담아 은행 대여금고에 보관, 비밀번호 관리자(1Password, Bitwarden)에 암호화하여 클라우드 백업, 이렇게 세 곳에 보관하는 것입니다. 백업 전략의 핵심 특징은 첫째, 중복성(단일 장애점 제거), 둘째, 다양성(화재, 침수, 도난 등 다양한 위험 대응), 셋째, 검증 가능성(정기적 복구 테스트)입니다.

이러한 특징들이 장기간 자산을 안전하게 보호합니다.

코드 예제

import * as bip39 from 'bip39';
import * as crypto from 'crypto';
import * as fs from 'fs';

export class WalletRecovery {
  // 니모닉을 Shamir's Secret Sharing으로 분할 (간단한 버전)
  public static splitMnemonic(mnemonic: string, threshold: number, shares: number): string[] {
    // 실제로는 secrets.js 라이브러리 사용 권장
    // 여기서는 개념 설명을 위한 단순 버전
    if (threshold > shares) {
      throw new Error('임계값이 총 조각 수보다 큼');
    }

    const seed = bip39.mnemonicToEntropy(mnemonic);
    const parts: string[] = [];

    // 간단한 XOR 기반 분할 (프로덕션에서는 Shamir's 사용)
    for (let i = 0; i < shares - 1; i++) {
      parts.push(crypto.randomBytes(seed.length / 2).toString('hex'));
    }

    // 마지막 조각은 다른 조각들의 XOR
    let lastPart = Buffer.from(seed, 'hex');
    for (const part of parts) {
      const buf = Buffer.from(part, 'hex');
      lastPart = Buffer.from(lastPart.map((b, i) => b ^ buf[i]));
    }
    parts.push(lastPart.toString('hex'));

    return parts.map((part, i) => `${i + 1}-of-${shares}: ${part}`);
  }

  // 백업 검증 (dry-run 복구)
  public static verifyBackup(mnemonic: string, expectedAddress: string): boolean {
    try {
      // 니모닉 유효성 검사
      if (!bip39.validateMnemonic(mnemonic)) {
        console.error('유효하지 않은 니모닉');
        return false;
      }

      // 시드 생성 및 첫 번째 주소 파생
      const seed = bip39.mnemonicToSeedSync(mnemonic);
      const firstAddress = this.deriveFirstAddress(seed);

      // 예상 주소와 일치 확인
      if (firstAddress !== expectedAddress) {
        console.error('주소 불일치: 잘못된 니모닉 또는 파생 경로');
        return false;
      }

      console.log('✅ 백업 검증 성공');
      return true;
    } catch (error) {
      console.error('백업 검증 실패:', error);
      return false;
    }
  }

  private static deriveFirstAddress(seed: Buffer): string {
    // BIP32로 m/44'/0'/0'/0/0 주소 파생
    // 실제 구현 생략 (앞선 HDWallet 예제 참조)
    return crypto.createHash('sha256').update(seed).digest('hex').slice(0, 40);
  }

  // 백업 메타데이터 생성
  public static createBackupMetadata(walletAddress: string) {
    return {
      version: '1.0',
      walletAddress: walletAddress,
      createdAt: new Date().toISOString(),
      backupType: 'mnemonic',
      instructions: [
        '1. 니모닉 12단어를 순서대로 보관하세요',
        '2. 절대 디지털 형태로 저장하지 마세요 (사진, 문서 파일 등)',
        '3. 최소 2곳 이상의 안전한 장소에 보관하세요',
        '4. 정기적으로 복구 테스트를 수행하세요',
        '5. 상속 계획을 수립하세요'
      ],
      recoveryTest: '매 6개월마다 테스트 지갑으로 복구 시도'
    };
  }

  // 복구 훈련 (테스트넷에서)
  public static async performRecoveryDrill(mnemonic: string): Promise<void> {
    console.log('🔄 복구 훈련 시작...');

    // 1단계: 니모닉 검증
    console.log('1/4 니모닉 유효성 검사...');
    if (!bip39.validateMnemonic(mnemonic)) {
      throw new Error('유효하지 않은 니모닉');
    }

    // 2단계: 시드 생성
    console.log('2/4 시드 생성...');
    const seed = bip39.mnemonicToSeedSync(mnemonic);

    // 3단계: 키 파생
    console.log('3/4 첫 10개 주소 파생...');
    for (let i = 0; i < 10; i++) {
      const address = this.deriveFirstAddress(seed); // 실제로는 인덱스 사용
      console.log(`  주소 ${i}: ${address}`);
    }

    // 4단계: 잔액 확인 (실제로는 API 호출)
    console.log('4/4 잔액 확인...');
    console.log('✅ 복구 훈련 완료 - 백업이 정상 작동합니다');
  }
}

설명

이것이 하는 일: WalletRecovery 클래스는 지갑 백업의 생성, 검증, 복구 훈련을 체계적으로 수행하는 도구를 제공합니다. 실제 자산 손실을 방지하기 위한 프로세스 중심 설계입니다.

splitMnemonic() 메서드는 Shamir's Secret Sharing 개념을 단순화하여 구현했습니다. 실제 Shamir's는 다항식 보간을 사용하지만, 여기서는 XOR 기반으로 설명합니다.

니모닉을 엔트로피로 변환한 후, N-1개의 랜덤 조각을 생성하고, 마지막 조각은 모든 조각의 XOR 결과로 만듭니다. 이렇게 하면 모든 조각을 XOR하면 원본이 복원됩니다.

프로덕션에서는 secrets.js-grempe 같은 검증된 라이브러리를 사용하여 K-of-N 임계값(예: 2-of-3이면 3조각 중 2개만 있어도 복원)을 구현하세요. 이 방식의 장점은 분산 보관입니다.

니모닉 전체를 한 곳에 보관하면 도난 시 즉시 자산 손실이지만, 3조각으로 나누어 가족, 변호사, 은행에 각각 보관하면 한 곳이 유출되어도 안전합니다. 동시에 본인이 사망하거나 무능력 상태가 되어도, 가족과 변호사가 협력하여 2조각을 모으면 자산에 접근할 수 있어 상속 문제도 해결됩니다.

verifyBackup() 메서드는 복구 가능성을 검증하는 핵심 기능입니다. 니모닉의 체크섬을 확인한 후, 실제로 시드를 생성하고 첫 번째 주소를 파생시킵니다.

이 주소가 지갑의 실제 주소와 일치하면 백업이 올바른 것입니다. 일치하지 않으면 니모닉을 잘못 적었거나, 파생 경로가 다르거나, 패스프레이즈를 사용했는데 기록하지 않은 경우입니다.

이 검증은 백업 직후와 정기적(6개월마다)으로 수행해야 합니다. createBackupMetadata()는 백업과 함께 저장할 지침서를 생성합니다.

기술에 익숙하지 않은 가족 구성원도 이해할 수 있도록 명확한 단계별 지침을 제공합니다. "절대 디지털 형태로 저장하지 마세요"는 매우 중요한데, 스크린샷이나 메모 앱에 저장하면 클라우드 동기화로 유출될 수 있습니다.

종이나 금속판 같은 물리적 매체만 사용해야 합니다. performRecoveryDrill() 메서드는 정기적 복구 훈련을 자동화합니다.

실제 지갑을 건드리지 않고, 테스트넷이나 별도의 환경에서 니모닉으로 지갑을 복원하는 전체 과정을 시뮬레이션합니다. 이는 재난 복구 계획에서 필수적인데, 실제 재난 시에 처음 복구를 시도하면 실수할 확률이 높습니다.

6개월마다 한 번씩 이 훈련을 수행하면, 절차에 익숙해지고 백업의 유효성도 확인할 수 있습니다. 여러분이 이 코드를 사용하면 전문적인 자산 관리 체계를 구축할 수 있습니다.

개인 사용자라면 분기마다 백업 검증을 자동으로 수행하도록 cron 작업을 설정하고, 기업이라면 백업 정책을 문서화하고 여러 직원에게 역할을 분담할 수 있습니다. 또한 상속 계획도 중요한데, 유언장에 "내 변호사와 배우자가 협력하면 비트코인 지갑 조각 2개를 모을 수 있다"고 명시하면 사후에도 자산이 가족에게 전달됩니다.

실전 팁

💡 Cryptosteel, Billfodl 같은 금속 백업 장치를 사용하세요. 종이는 화재(섭씨 232도에서 발화), 침수, 시간 경과로 훼손되지만, 스테인리스 금속판은 섭씨 1400도까지 견딥니다. 각인 도구로 직접 새기면 더 안전합니다.

💡 백업 장소를 지리적으로 분산하세요. 같은 도시의 집과 사무실에 보관하면 지진이나 전쟁 같은 광역 재난에 취약합니다. 다른 국가의 은행 금고에 하나를 보관하는 것도 고려하세요.

💡 "미끼" 지갑을 만드세요. 소액을 담은 지갑을 눈에 띄는 곳에 보관하고, 진짜 지갑은 숨기면 강도가 미끼만 가져가고 만족할 수 있습니다. BIP39 패스프레이즈로 구현 가능합니다(같은 니모닉, 다른 패스프레이즈 = 다른 지갑).

💡 백업 검증을 자동화하되, 결과는 수동으로 확인하세요. 스크립트가 "백업 OK"라고 해도, 사람이 직접 복구된 주소를 보고 확인해야 안심할 수 있습니다. 자동화는 편의성이지 책임을 대체하지 않습니다.

💡 시간 잠금 지갑을 고려하세요. "2025년 1월 1일 이전에는 인출 불가" 같은 조건을 스마트 컨트랙트로 설정하면, 해킹당해도 시간을 벌어 대응할 수 있습니다. 비트코인의 OP_CHECKLOCKTIMEVERIFY를 활용하세요.


#TypeScript#Bitcoin#Wallet#Cryptography#secp256k1#typescript

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.