이미지 로딩 중...

타입스크립트로 비트코인 클론하기 15편 ECDSA 디지털 서명 구현하기 - 슬라이드 1/9
A

AI Generated

2025. 11. 10. · 4 Views

타입스크립트로 비트코인 클론하기 15편 ECDSA 디지털 서명 구현하기

블록체인의 핵심 보안 메커니즘인 ECDSA 디지털 서명을 타입스크립트로 직접 구현해봅니다. 타원곡선 암호화부터 서명 생성, 검증까지 비트코인이 거래를 안전하게 보호하는 원리를 코드로 배워보세요.


목차

  1. ECDSA란_무엇인가
  2. 타원곡선_점_연산_구현
  3. secp256k1_곡선_파라미터_설정
  4. 개인키와_공개키_생성
  5. 메시지_해싱과_서명_생성
  6. 서명_검증_구현
  7. DER_인코딩_구현
  8. 트랜잭션_서명_통합

1. ECDSA란_무엇인가

시작하며

여러분이 비트코인 거래를 보낼 때, 어떻게 그 거래가 정말 여러분이 보낸 것이라고 증명할 수 있을까요? 은행 계좌처럼 중앙 기관이 신원을 확인해주는 것도 아닌데 말이죠.

이것이 바로 블록체인이 직면한 가장 중요한 문제입니다. 분산된 네트워크에서 누군가가 거래를 위조하거나 남의 코인을 훔치는 것을 어떻게 막을 수 있을까요?

비트코인은 이 문제를 ECDSA(Elliptic Curve Digital Signature Algorithm)라는 강력한 암호화 기술로 해결합니다. 이것은 여러분만이 만들 수 있는 디지털 서명을 통해 거래의 진위를 증명하는 방법입니다.

개요

간단히 말해서, ECDSA는 타원곡선 수학을 이용한 디지털 서명 알고리즘입니다. 왜 이것이 필요한지 실무 관점에서 살펴볼까요?

블록체인에서는 중앙 서버가 없기 때문에, 거래를 보낸 사람이 정말 코인의 소유자인지 확인할 방법이 필요합니다. 예를 들어, 앨리스가 밥에게 1 BTC를 보낼 때, 네트워크의 모든 노드가 "이 거래는 정말 앨리스가 승인한 것이다"라고 확인할 수 있어야 합니다.

전통적인 RSA 서명도 있지만, ECDSA는 같은 보안 수준을 훨씬 작은 키 크기로 달성합니다. 256비트 ECDSA 키는 3072비트 RSA 키와 동일한 보안을 제공하죠.

ECDSA의 핵심 특징은 세 가지입니다: 1) 개인키로 서명을 생성하고, 2) 공개키로 누구나 검증할 수 있으며, 3) 서명으로부터 개인키를 역산하는 것은 수학적으로 불가능합니다. 이러한 특징들이 블록체인의 보안을 보장하는 근간이 됩니다.

코드 예제

// ECDSA의 기본 구조를 표현하는 인터페이스
interface ECDSASignature {
  r: bigint;  // 서명의 첫 번째 구성요소
  s: bigint;  // 서명의 두 번째 구성요소
}

interface KeyPair {
  privateKey: bigint;  // 개인키 (비밀로 유지)
  publicKey: Point;    // 공개키 (공개 가능)
}

// 타원곡선 상의 점을 표현
interface Point {
  x: bigint;
  y: bigint;
}

설명

이것이 하는 일: ECDSA는 디지털 서명을 생성하고 검증하는 암호학적 시스템입니다. 마치 여러분의 손으로 쓴 서명처럼, 디지털 세계에서 "이 문서는 내가 승인했다"는 것을 증명합니다.

첫 번째로, 키 쌍의 구조를 이해해야 합니다. KeyPair 인터페이스는 개인키(privateKey)와 공개키(publicKey)로 구성됩니다.

개인키는 무작위로 생성된 매우 큰 숫자이고, 공개키는 타원곡선 상의 한 점입니다. 개인키는 절대 공개해서는 안 되며, 이것이 여러분의 비트코인을 보호하는 유일한 열쇠입니다.

그 다음으로, ECDSASignature 구조를 살펴봅시다. 서명은 r과 s라는 두 개의 큰 정수로 구성됩니다.

이 두 값은 개인키와 서명하려는 메시지(거래 데이터)로부터 특정 수학 공식을 통해 계산됩니다. 내부적으로는 타원곡선 상의 점 연산과 모듈러 연산이 복잡하게 얽혀 있습니다.

마지막으로, Point 인터페이스는 타원곡선 상의 점을 나타냅니다. 타원곡선은 y² = x³ + ax + b 형태의 방정식을 만족하는 점들의 집합인데, 비트코인은 특정 파라미터를 가진 secp256k1 곡선을 사용합니다.

공개키와 서명 검증 과정에서 이 점들 간의 덧셈과 곱셈 연산이 핵심 역할을 합니다. 여러분이 이 구조를 사용하면 거래 데이터에 대한 위조 불가능한 서명을 만들 수 있습니다.

누군가 거래 내용을 단 1비트라도 변경하면 서명 검증이 실패하고, 개인키 없이는 유효한 서명을 만들 수 없으며, 서명만으로는 개인키를 알아낼 수 없다는 세 가지 핵심 보안 속성을 제공합니다.

실전 팁

💡 개인키는 절대 하드코딩하거나 코드에 포함하지 마세요. 환경 변수나 암호화된 키스토어를 사용하고, 메모리에서도 사용 후 즉시 제거해야 합니다.

💡 bigint 타입을 사용하는 이유는 JavaScript의 number 타입이 안전하게 표현할 수 있는 범위(2^53-1)를 훨씬 초과하는 큰 숫자를 다루기 때문입니다. 암호화에서는 정확성이 생명입니다.

💡 서명의 r과 s 값은 특정 범위 내에 있어야 합니다. 범위를 벗어난 값은 유효하지 않은 서명이므로 생성 후 항상 검증하세요.

💡 타원곡선 연산은 CPU 집약적이므로, 대량의 서명 검증이 필요한 경우 배치 검증이나 멀티스레딩을 고려하세요.


2. 타원곡선_점_연산_구현

시작하며

여러분이 일반적인 숫자 계산은 익숙하시겠지만, 타원곡선 위의 점들을 더하거나 곱한다는 것은 생소할 겁니다. 하지만 이것이 ECDSA의 핵심입니다.

타원곡선 암호화의 안전성은 이 특별한 점 연산의 수학적 성질에서 나옵니다. 점 덧셈은 정방향으로는 쉽지만, 역방향으로는 거의 불가능하다는 "이산 로그 문제"가 보안의 기반이죠.

이번 섹션에서는 타원곡선 상의 점 덧셈과 스칼라 곱셈을 직접 구현해보겠습니다. 이것이 없으면 공개키 생성도, 서명 생성도 불가능합니다.

개요

간단히 말해서, 타원곡선 점 연산은 특별한 기하학적 규칙에 따라 점들을 더하고 곱하는 연산입니다. 왜 이것이 필요한지 실무 관점에서 보면, ECDSA의 모든 연산이 이 점 연산에 기반합니다.

예를 들어, 공개키 = 개인키 × 생성점(G)이라는 계산을 할 때, 이 곱셈은 일반 숫자 곱셈이 아니라 타원곡선 상의 스칼라 곱셈입니다. 서명 검증에서도 여러 점들을 더하고 곱하는 연산이 필수적이죠.

전통적인 수학에서는 점을 더한다는 개념이 없지만, 타원곡선에서는 기하학적으로 정의됩니다. 두 점 P와 Q를 잇는 직선이 곡선과 만나는 세 번째 점을 x축 대칭시킨 것이 P + Q입니다.

핵심 특징은: 1) 점 덧셈은 교환법칙과 결합법칙이 성립하고, 2) 무한원점(O)이 덧셈의 항등원이며, 3) 스칼라 곱셈 k×P는 P를 k번 더한 것과 같습니다. 이러한 수학적 구조가 암호학적 안전성을 보장합니다.

코드 예제

// 타원곡선 상의 점 덧셈 구현
class EllipticCurve {
  constructor(
    public a: bigint,  // 곡선 파라미터 a
    public b: bigint,  // 곡선 파라미터 b
    public p: bigint   // 소수 p (유한체)
  ) {}

  // 모듈러 역원 계산 (확장 유클리드 알고리즘)
  modInverse(a: bigint, m: bigint): bigint {
    let [old_r, r] = [a, m];
    let [old_s, s] = [1n, 0n];

    while (r !== 0n) {
      const quotient = old_r / r;
      [old_r, r] = [r, old_r - quotient * r];
      [old_s, s] = [s, old_s - quotient * s];
    }

    return old_s < 0n ? old_s + m : old_s;
  }

  // 점 덧셈: P + Q
  addPoints(P: Point | null, Q: Point | null): Point | null {
    if (P === null) return Q;  // 무한원점 + Q = Q
    if (Q === null) return P;  // P + 무한원점 = P

    // P = Q인 경우 (점 두 배)
    if (P.x === Q.x && P.y === Q.y) {
      return this.doublePoint(P);
    }

    // P = -Q인 경우 (역원)
    if (P.x === Q.x) {
      return null;  // 무한원점
    }

    // 기울기 계산: s = (y2 - y1) / (x2 - x1) mod p
    const slope = (Q.y - P.y) * this.modInverse(Q.x - P.x, this.p) % this.p;

    // 새로운 점 계산
    const x3 = (slope * slope - P.x - Q.x) % this.p;
    const y3 = (slope * (P.x - x3) - P.y) % this.p;

    return {
      x: x3 < 0n ? x3 + this.p : x3,
      y: y3 < 0n ? y3 + this.p : y3
    };
  }

  // 점 두 배: 2P
  doublePoint(P: Point): Point | null {
    // 기울기 계산: s = (3x² + a) / (2y) mod p
    const numerator = (3n * P.x * P.x + this.a) % this.p;
    const denominator = (2n * P.y) % this.p;
    const slope = (numerator * this.modInverse(denominator, this.p)) % this.p;

    const x3 = (slope * slope - 2n * P.x) % this.p;
    const y3 = (slope * (P.x - x3) - P.y) % this.p;

    return {
      x: x3 < 0n ? x3 + this.p : x3,
      y: y3 < 0n ? y3 + this.p : y3
    };
  }

  // 스칼라 곱셈: k × P (이진 확장법)
  multiplyPoint(k: bigint, P: Point): Point | null {
    let result: Point | null = null;
    let addend: Point | null = P;

    while (k > 0n) {
      if (k & 1n) {  // k의 최하위 비트가 1이면
        result = this.addPoints(result, addend);
      }
      addend = this.addPoints(addend, addend);  // 점을 두 배로
      k >>= 1n;  // k를 오른쪽으로 1비트 시프트
    }

    return result;
  }
}

설명

이것이 하는 일: 타원곡선 위의 점들을 더하고, 정수배하는 연산을 유한체 위에서 구현합니다. 이는 암호학적으로 안전한 일방향 함수를 만드는 핵심입니다.

첫 번째로, addPoints 메서드를 살펴봅시다. 이 함수는 두 점 P와 Q를 받아서 타원곡선 상의 새로운 점을 반환합니다.

무한원점(null)은 점 덧셈의 항등원 역할을 하며, P + O = P입니다. 두 점이 같으면 점 두 배(doublePoint)를 호출하고, x 좌표는 같지만 y 좌표가 반대이면 무한원점을 반환합니다.

일반적인 경우, 두 점을 잇는 직선의 기울기를 계산하고, 이 직선이 곡선과 만나는 세 번째 점의 y 좌표를 반전시킨 것이 결과입니다. 그 다음으로, multiplyPoint 메서드는 이진 확장법(binary expansion)을 사용합니다.

k × P를 계산할 때, k를 이진수로 표현하고 각 비트에 대해 점을 두 배로 만들어가며 필요한 경우만 더합니다. 예를 들어 5 × P = (101)₂ × P = 2²P + 2⁰P 입니다.

이 방법은 O(log k) 시간에 연산을 완료하므로, k가 256비트의 큰 숫자여도 256번의 점 덧셈만으로 계산할 수 있습니다. 마지막으로, modInverse는 모듈러 역원을 계산합니다.

a × b ≡ 1 (mod p)를 만족하는 b를 찾는 것인데, 확장 유클리드 알고리즘을 사용합니다. 점 덧셈에서 나눗셈 대신 역원을 곱하는 이유는 유한체에서는 나눗셈이 정의되지 않기 때문입니다.

모든 연산은 mod p로 이루어지며, 음수 결과는 p를 더해서 양수로 만듭니다. 여러분이 이 코드를 사용하면 공개키 생성(개인키 × G), 서명 생성 및 검증의 모든 연산을 수행할 수 있습니다.

이진 확장법 덕분에 256비트 숫자의 스칼라 곱셈도 1밀리초 이내에 완료되며, 수학적으로 증명된 안전성을 보장합니다.

실전 팁

💡 모든 중간 계산 결과에 mod p를 적용하여 오버플로우를 방지하세요. 특히 곱셈 후에는 항상 모듈러 연산을 해야 합니다.

💡 점 덧셈 시 P = -Q를 확인하지 않으면 0으로 나누는 오류가 발생합니다. 이 엣지 케이스를 반드시 처리하세요.

💡 성능 최적화를 위해 자주 사용되는 점(특히 생성점 G)의 배수들을 미리 계산해두는 윈도우 방법(window method)을 고려하세요.

💡 상수 시간 알고리즘을 구현하지 않으면 타이밍 공격에 취약할 수 있습니다. 프로덕션에서는 검증된 라이브러리 사용을 권장합니다.

💡 타원곡선 점의 유효성 검증을 잊지 마세요. y² = x³ + ax + b (mod p)를 만족하는지 확인해야 합니다.


3. secp256k1_곡선_파라미터_설정

시작하며

타원곡선은 무한히 많지만, 비트코인은 왜 특정 곡선만 사용할까요? 보안성, 효율성, 그리고 수학적 특성을 모두 고려한 선택이 바로 secp256k1입니다.

여러분이 비트코인 지갑을 만들거나 거래에 서명할 때, 모두 이 표준 곡선을 사용합니다. 다른 곡선을 사용하면 네트워크와 호환되지 않죠.

secp256k1은 미국 국립표준기술연구소(NIST)가 아닌 SECG(Standards for Efficient Cryptography Group)가 정의한 곡선으로, NSA의 백도어 의혹이 없어 비트코인 커뮤니티가 선택했습니다.

개요

간단히 말해서, secp256k1은 비트코인이 사용하는 표준 타원곡선의 구체적인 파라미터 집합입니다. 왜 이것이 필요한지 보면, ECDSA를 구현하려면 곡선의 모든 파라미터가 정확히 정의되어야 합니다.

예를 들어, 곡선 방정식의 계수 a와 b, 유한체의 크기 p, 생성점 G의 좌표, 그리고 생성점의 위수 n 등이 필요합니다. 이 값들이 하나라도 다르면 완전히 다른 암호 시스템이 됩니다.

전통적으로 NIST P-256 같은 곡선도 널리 쓰이지만, secp256k1은 특별한 형태(a=0)로 인해 연산이 더 빠르고, Koblitz 곡선의 특성상 효율적인 구현이 가능합니다. 핵심 파라미터는: 1) p는 2²⁵⁶ - 2³² - 977인 소수, 2) 곡선 방정식은 y² = x³ + 7 (a=0, b=7), 3) 생성점 G의 위수 n은 약 2²⁵⁶입니다.

이들은 암호학적으로 안전하도록 신중하게 선택된 값들입니다.

코드 예제

// secp256k1 곡선 파라미터 상수 정의
class Secp256k1 {
  // 유한체의 소수 (2^256 - 2^32 - 977)
  static readonly P = BigInt(
    '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F'
  );

  // 곡선 파라미터 a (secp256k1은 a = 0)
  static readonly A = 0n;

  // 곡선 파라미터 b (secp256k1은 b = 7)
  static readonly B = 7n;

  // 생성점 G의 x 좌표
  static readonly Gx = BigInt(
    '0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798'
  );

  // 생성점 G의 y 좌표
  static readonly Gy = BigInt(
    '0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8'
  );

  // 생성점의 위수 (곡선의 점의 개수)
  static readonly N = BigInt(
    '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'
  );

  // 생성점 G
  static readonly G: Point = {
    x: Secp256k1.Gx,
    y: Secp256k1.Gy
  };

  // 곡선 인스턴스 생성
  static createCurve(): EllipticCurve {
    return new EllipticCurve(Secp256k1.A, Secp256k1.B, Secp256k1.P);
  }

  // 값이 유효한 범위 내에 있는지 확인
  static isValidFieldElement(x: bigint): boolean {
    return x >= 0n && x < Secp256k1.P;
  }

  // 개인키가 유효한지 확인
  static isValidPrivateKey(key: bigint): boolean {
    return key > 0n && key < Secp256k1.N;
  }
}

설명

이것이 하는 일: secp256k1의 모든 표준 파라미터를 정의하고, 이를 사용한 곡선 인스턴스를 생성하며, 입력값의 유효성을 검증하는 유틸리티를 제공합니다. 첫 번째로, 핵심 상수들을 살펴봅시다.

P는 유한체의 크기를 정의하는 소수로, 2²⁵⁶보다 약간 작은 값입니다. 이 특별한 형태(메르센 소수에 가까운) 덕분에 모듈러 연산을 최적화할 수 있습니다.

A = 0과 B = 7은 곡선 방정식 y² = x³ + 7을 정의하는데, a가 0이라는 점이 연산 속도를 크게 향상시킵니다. 일반 형태 y² = x³ + ax + b에서 a 항이 없으니 곱셈 하나가 사라지는 셈이죠.

그 다음으로, 생성점 G와 그 위수 N을 봅시다. Gx와 Gy는 곡선 위의 특정 점인데, 이 점을 반복해서 더하면 N번째에 다시 무한원점으로 돌아옵니다.

N은 약 2²⁵⁶으로, 개인키가 가질 수 있는 값의 범위를 결정합니다. 모든 공개키는 (개인키 × G)로 계산되므로, G는 모든 키 쌍의 기준점이 됩니다.

이 값들은 무작위가 아니라 SECG 표준에 명시된 정확한 값입니다. 마지막으로, 검증 함수들을 보겠습니다.

isValidFieldElement는 값이 0 이상 P 미만인지 확인하여 유한체의 원소로 적합한지 검사합니다. isValidPrivateKey는 개인키가 1 이상 N 미만인지 확인하는데, 0이면 공개키가 무한원점이 되고, N 이상이면 mod N으로 축약되므로 유효하지 않습니다.

createCurve 메서드는 이 파라미터들로 EllipticCurve 인스턴스를 생성하여 실제 연산에 사용할 수 있게 합니다. 여러분이 이 코드를 사용하면 비트코인 네트워크와 완벽하게 호환되는 키 쌍과 서명을 생성할 수 있습니다.

모든 비트코인 지갑과 노드가 동일한 파라미터를 사용하기 때문에, 여러분이 만든 서명은 전 세계 어디서나 검증 가능합니다.

실전 팁

💡 이 상수들은 절대 변경하지 마세요. 단 하나라도 바뀌면 비트코인 네트워크와 호환되지 않습니다.

💡 16진수 리터럴을 사용할 때 '0x' 접두사와 대문자를 정확히 유지하세요. 타이핑 실수가 치명적인 보안 문제를 일으킬 수 있습니다.

💡 P의 특별한 형태(2²⁵⁶ - 2³² - 2⁹ - 2⁸ - 2⁷ - 2⁶ - 2⁴ - 1)를 활용하면 모듈러 감소를 빠르게 수행할 수 있습니다.

💡 생성점 G가 정말 곡선 위에 있는지 테스트 코드로 검증하세요: Gy² ≡ Gx³ + 7 (mod P)를 확인하면 됩니다.

💡 프로덕션 환경에서는 이 상수들을 읽기 전용 메모리 영역에 저장하여 실수로 수정되는 것을 방지하세요.


4. 개인키와_공개키_생성

시작하며

비트코인 지갑의 시작은 개인키 하나로부터 시작됩니다. 이 하나의 숫자가 여러분의 모든 자산을 보호하는 마스터 키입니다.

여러분이 "새 지갑 만들기"를 클릭할 때 내부에서는 무슨 일이 일어날까요? 단순히 무작위 숫자를 뽑는 것이 아니라, 암호학적으로 안전한 난수를 생성하고 이로부터 공개키를 계산합니다.

이 과정이 안전하지 않으면 해커가 여러분의 개인키를 예측할 수 있고, 그것은 곧 자산 손실을 의미합니다. 실제로 약한 난수 생성기를 사용한 지갑들이 해킹당한 사례가 여럿 있습니다.

개요

간단히 말해서, 키 생성은 안전한 무작위 개인키를 만들고, 타원곡선 연산으로 대응하는 공개키를 계산하는 과정입니다. 왜 이것이 필요한지 보면, 비트코인 주소를 만들고 거래에 서명하려면 반드시 키 쌍이 있어야 합니다.

예를 들어, 개인키는 여러분만 아는 비밀 숫자이고, 공개키는 다른 사람들이 여러분에게 코인을 보내거나 서명을 검증할 때 사용합니다. 이 둘은 수학적으로 연결되어 있지만, 공개키로부터 개인키를 역산하는 것은 불가능합니다.

전통적인 비대칭 암호화와 마찬가지로 ECDSA도 키 쌍을 사용하지만, RSA보다 훨씬 작은 키 크기로 같은 보안을 제공합니다. 핵심 프로세스는: 1) 암호학적으로 안전한 난수 생성(1 ~ N-1 범위), 2) 개인키 × G 연산으로 공개키 계산, 3) 공개키를 압축 또는 비압축 형식으로 인코딩합니다.

이 과정의 안전성이 전체 시스템의 보안을 결정합니다.

코드 예제

import * as crypto from 'crypto';

class KeyGenerator {
  private curve: EllipticCurve;

  constructor() {
    this.curve = Secp256k1.createCurve();
  }

  // 암호학적으로 안전한 개인키 생성
  generatePrivateKey(): bigint {
    let privateKey: bigint;

    do {
      // 32바이트(256비트)의 무작위 데이터 생성
      const randomBytes = crypto.randomBytes(32);
      privateKey = BigInt('0x' + randomBytes.toString('hex'));
    } while (!Secp256k1.isValidPrivateKey(privateKey));
    // 1 <= privateKey < N 범위를 만족할 때까지 반복

    return privateKey;
  }

  // 개인키로부터 공개키 생성
  generatePublicKey(privateKey: bigint): Point {
    if (!Secp256k1.isValidPrivateKey(privateKey)) {
      throw new Error('Invalid private key');
    }

    // 공개키 = 개인키 × G (타원곡선 스칼라 곱셈)
    const publicKey = this.curve.multiplyPoint(privateKey, Secp256k1.G);

    if (publicKey === null) {
      throw new Error('Failed to generate public key');
    }

    return publicKey;
  }

  // 공개키를 압축 형식으로 인코딩 (33바이트)
  compressPublicKey(publicKey: Point): Buffer {
    const prefix = publicKey.y % 2n === 0n ? 0x02 : 0x03;
    const xBuffer = Buffer.from(publicKey.x.toString(16).padStart(64, '0'), 'hex');

    return Buffer.concat([Buffer.from([prefix]), xBuffer]);
  }

  // 공개키를 비압축 형식으로 인코딩 (65바이트)
  uncompressPublicKey(publicKey: Point): Buffer {
    const xBuffer = Buffer.from(publicKey.x.toString(16).padStart(64, '0'), 'hex');
    const yBuffer = Buffer.from(publicKey.y.toString(16).padStart(64, '0'), 'hex');

    return Buffer.concat([Buffer.from([0x04]), xBuffer, yBuffer]);
  }

  // 완전한 키 쌍 생성
  generateKeyPair(): KeyPair {
    const privateKey = this.generatePrivateKey();
    const publicKey = this.generatePublicKey(privateKey);

    return { privateKey, publicKey };
  }
}

설명

이것이 하는 일: 암호학적으로 안전한 방법으로 ECDSA 키 쌍을 생성하고, 공개키를 다양한 형식으로 직렬화합니다. 첫 번째로, generatePrivateKey 메서드를 봅시다.

Node.js의 crypto.randomBytes를 사용하여 32바이트(256비트)의 무작위 데이터를 생성합니다. 이 함수는 운영체제의 CSPRNG(암호학적으로 안전한 난수 생성기)를 사용하므로 예측 불가능합니다.

생성된 바이트를 16진수 문자열로 변환한 후 bigint로 파싱하는데, 반드시 1 이상 N 미만이어야 하므로 조건을 만족할 때까지 반복합니다. 확률적으로 거의 항상 첫 시도에 성공하지만, 안전성을 위해 검증합니다.

그 다음으로, generatePublicKey는 개인키를 입력받아 공개키를 계산합니다. 핵심은 multiplyPoint(privateKey, G) 호출인데, 이것은 생성점 G를 개인키 번 더한 것과 같습니다.

예를 들어 개인키가 5라면 G + G + G + G + G를 계산하는 것이죠. 하지만 실제로는 이진 확장법으로 훨씬 빠르게 계산됩니다.

이 연산은 일방향이어서, 공개키를 알아도 개인키를 찾는 것은 수학적으로 거의 불가능합니다(이산 로그 문제). 마지막으로, 공개키 인코딩 방식을 살펴봅시다.

비압축 형식은 0x04 + x좌표(32바이트) + y좌표(32바이트) = 65바이트입니다. 압축 형식은 y 좌표의 짝수/홀수 정보만 포함하여 0x02 또는 0x03 + x좌표(32바이트) = 33바이트입니다.

타원곡선 방정식 y² = x³ + 7에서 x를 알면 y는 두 값 중 하나이므로, 짝수인지 홀수인지만 알면 y를 복원할 수 있습니다. 압축 형식은 블록체인 공간을 50% 절약합니다.

여러분이 이 코드를 사용하면 실제 비트코인 지갑과 동일한 방식으로 키를 생성할 수 있습니다. 생성된 공개키는 비트코인 주소로 변환되어 다른 사람들이 여러분에게 코인을 보낼 수 있게 하고, 개인키는 안전하게 보관하여 자산을 보호합니다.

실전 팁

💡 Math.random()이나 Date.now() 같은 약한 난수 생성기를 절대 사용하지 마세요. 반드시 crypto.randomBytes처럼 암호학적으로 안전한 함수를 써야 합니다.

💡 개인키를 파일로 저장할 때는 반드시 암호화하세요. BIP38 같은 표준을 사용하거나, 최소한 AES-256으로 암호화해야 합니다.

💡 HD(계층적 결정론적) 지갑을 구현하려면 BIP32를 참고하세요. 하나의 시드로부터 무한한 키를 파생시킬 수 있습니다.

💡 공개키 압축은 세그윗(SegWit) 이후 표준이 되었습니다. 신규 구현에서는 항상 압축 형식을 사용하세요.

💡 키 생성 후 반드시 테스트 서명/검증을 수행하여 키가 제대로 작동하는지 확인하세요.


5. 메시지_해싱과_서명_생성

시작하며

여러분이 비트코인을 보낼 때, 실제로 서명하는 것은 거래 데이터의 해시값입니다. 원본 데이터를 직접 서명하지 않는 이유가 있을까요?

해시 함수는 임의 길이의 데이터를 고정 길이(256비트)로 압축하면서도, 원본이 조금만 바뀌어도 완전히 다른 해시를 만듭니다. 이것이 서명의 무결성을 보장하는 핵심입니다.

ECDSA 서명 생성은 생각보다 복잡합니다. 무작위 논스(nonce)를 선택하고, 여러 타원곡선 연산을 수행하며, 모듈러 산술로 r과 s 값을 계산해야 합니다.

한 단계라도 잘못되면 개인키가 노출될 수 있습니다.

개요

간단히 말해서, ECDSA 서명 생성은 메시지 해시와 개인키로부터 (r, s) 쌍을 계산하는 과정입니다. 왜 이것이 필요한지 실무 관점에서 보면, 거래의 진위를 증명하는 유일한 방법이 서명이기 때문입니다.

예를 들어, 앨리스가 1 BTC를 밥에게 보내는 거래를 만들면, 네트워크의 모든 노드가 "이 거래는 정말 앨리스가 승인했다"라고 확인할 수 있어야 합니다. 서명이 없으면 누구나 앨리스의 코인을 훔칠 수 있겠죠.

전통적인 RSA 서명은 메시지를 개인키로 "암호화"하는 개념이지만, ECDSA는 타원곡선 연산과 모듈러 산술을 결합한 더 복잡한 프로토콜입니다. 서명 생성의 핵심 단계는: 1) SHA-256으로 메시지 해시 계산, 2) 암호학적으로 안전한 무작위 논스 k 생성, 3) R = k × G 계산하여 r = R.x mod n, 4) s = k⁻¹(z + r × 개인키) mod n 계산입니다.

이 과정에서 논스 재사용이나 약한 난수는 치명적입니다.

코드 예제

import * as crypto from 'crypto';

class ECDSASigner {
  private curve: EllipticCurve;

  constructor() {
    this.curve = Secp256k1.createCurve();
  }

  // SHA-256 해시 계산
  hashMessage(message: Buffer): bigint {
    const hash = crypto.createHash('sha256').update(message).digest();
    return BigInt('0x' + hash.toString('hex'));
  }

  // 안전한 논스 생성 (RFC 6979 간소화 버전)
  generateNonce(messageHash: bigint, privateKey: bigint): bigint {
    let nonce: bigint;

    do {
      // 실제로는 RFC 6979 결정론적 생성을 사용해야 함
      const nonceBytes = crypto.randomBytes(32);
      nonce = BigInt('0x' + nonceBytes.toString('hex'));
    } while (nonce <= 0n || nonce >= Secp256k1.N);

    return nonce;
  }

  // ECDSA 서명 생성
  sign(message: Buffer, privateKey: bigint): ECDSASignature {
    // 1. 메시지 해시 계산
    const z = this.hashMessage(message);

    let signature: ECDSASignature | null = null;

    // 유효한 서명이 생성될 때까지 반복
    while (signature === null) {
      // 2. 논스 생성
      const k = this.generateNonce(z, privateKey);

      // 3. R = k × G 계산
      const R = this.curve.multiplyPoint(k, Secp256k1.G);
      if (R === null) continue;

      // 4. r = R.x mod n
      const r = R.x % Secp256k1.N;
      if (r === 0n) continue;  // r이 0이면 다시 시도

      // 5. s = k⁻¹(z + r × privateKey) mod n
      const kInv = this.curve.modInverse(k, Secp256k1.N);
      const s = (kInv * (z + r * privateKey)) % Secp256k1.N;
      if (s === 0n) continue;  // s가 0이면 다시 시도

      signature = { r, s };
    }

    // Low-S 정규화 (비트코인 표준)
    return this.normalizeLowS(signature);
  }

  // Low-S 정규화: s > N/2이면 s = N - s로 변환
  private normalizeLowS(signature: ECDSASignature): ECDSASignature {
    const halfN = Secp256k1.N / 2n;

    if (signature.s > halfN) {
      return {
        r: signature.r,
        s: Secp256k1.N - signature.s
      };
    }

    return signature;
  }

  // 서명을 16진수 문자열로 변환
  signatureToHex(signature: ECDSASignature): string {
    const r = signature.r.toString(16).padStart(64, '0');
    const s = signature.s.toString(16).padStart(64, '0');
    return r + s;
  }
}

설명

이것이 하는 일: 메시지를 SHA-256으로 해시하고, 안전한 논스를 생성하며, ECDSA 알고리즘으로 디지털 서명을 만듭니다. 첫 번째로, hashMessage 메서드는 임의 길이의 메시지를 256비트 해시로 압축합니다.

SHA-256은 충돌 저항성(서로 다른 입력이 같은 해시를 만들기 어려움), 역상 저항성(해시로부터 원본을 찾기 어려움), 그리고 눈사태 효과(입력의 작은 변화가 출력을 크게 바꿈)를 가집니다. 비트코인은 실제로 SHA-256을 두 번 적용(Double-SHA256)하지만, 여기서는 간소화했습니다.

해시 결과를 bigint로 변환하여 이후 모듈러 연산에 사용합니다. 그 다음으로, sign 메서드의 핵심 알고리즘을 살펴봅시다.

먼저 무작위 논스 k를 생성하는데, 이것은 서명마다 달라야 합니다. 같은 k를 두 번 사용하면 두 서명으로부터 개인키를 역산할 수 있습니다(소니 PS3 해킹 사건이 대표적).

k × G를 계산하여 점 R을 얻고, 그 x좌표를 mod n한 값이 r입니다. 그 다음 k의 모듈러 역원을 구하고, s = k⁻¹(z + r × privateKey) mod n을 계산합니다.

이 공식은 ECDSA 표준에 정의된 것으로, 수학적으로 증명된 안전성을 가집니다. 마지막으로, normalizeLowS 메서드는 비트코인 특유의 정규화를 수행합니다.

ECDSA에서 (r, s)가 유효한 서명이면 (r, n-s)도 유효한 서명입니다. 이 말레이빌리티(malleability)를 방지하기 위해 비트코인은 s가 항상 n/2 이하가 되도록 강제합니다.

s > n/2이면 s를 n - s로 교체하는 것이죠. 이것이 BIP 62와 BIP 146에 명시된 Low-S 규칙입니다.

여러분이 이 코드를 사용하면 비트코인 거래에 유효한 서명을 생성할 수 있습니다. 생성된 (r, s) 쌍은 64바이트(각 32바이트)로 인코딩되어 거래 데이터에 포함되며, 네트워크의 모든 노드가 이를 검증하여 거래의 진위를 확인합니다.

실전 팁

💡 프로덕션에서는 RFC 6979를 구현하여 결정론적 논스를 생성하세요. 같은 메시지와 개인키에 대해 항상 같은 논스를 만들어 재사용 위험을 원천 차단합니다.

💡 논스 생성에 절대 Math.random()을 사용하지 마세요. 2013년 안드로이드 비트코인 지갑 버그가 이로 인해 발생했습니다.

💡 서명 전에 메시지 해시가 정말 32바이트인지 확인하세요. 길이가 다르면 보안 문제가 생길 수 있습니다.

💡 타이밍 공격을 방지하려면 k의 역원 계산을 상수 시간 알고리즘으로 구현해야 합니다.

💡 서명 생성 후 즉시 자체 검증을 수행하여 구현 오류를 조기에 발견하세요.


6. 서명_검증_구현

시작하며

서명을 만들 수 있다면, 이제 그것이 진짜인지 확인하는 방법도 알아야 합니다. 비트코인 네트워크의 모든 노드는 거래를 받으면 서명을 검증합니다.

여러분이 블록체인 익스플로러에서 거래를 볼 때, 그 거래는 이미 수천 개의 노드가 서명을 검증하고 승인한 것입니다. 단 하나의 노드라도 서명이 유효하지 않다고 판단하면 거래는 거부됩니다.

서명 검증은 서명 생성보다 수학적으로 더 복잡합니다. 타원곡선 상의 여러 점을 계산하고 비교하는 과정을 거쳐야 하죠.

개요

간단히 말해서, ECDSA 서명 검증은 공개키와 서명(r, s)으로 메시지의 진위를 수학적으로 확인하는 과정입니다. 왜 이것이 필요한지 보면, 분산 네트워크에서 신뢰를 확립하는 유일한 방법이 바로 서명 검증이기 때문입니다.

예를 들어, 누군가 "앨리스가 1 BTC를 밥에게 보냈다"는 거래를 네트워크에 브로드캐스트하면, 모든 노드가 앨리스의 공개키로 서명을 검증하여 정말 앨리스가 승인한 거래인지 확인합니다. 검증에 실패하면 그 거래는 무시됩니다.

전통적인 서명 검증은 공개키로 "복호화"하는 개념이지만, ECDSA는 타원곡선 점들의 동일성을 비교하는 방식입니다. 검증의 핵심 단계는: 1) r과 s가 유효 범위 내인지 확인, 2) u1 = z × s⁻¹ mod n과 u2 = r × s⁻¹ mod n 계산, 3) P = u1 × G + u2 × 공개키 계산, 4) P.x mod n이 r과 같은지 확인입니다.

이것이 성립하면 서명이 유효합니다.

코드 예제

class ECDSAVerifier {
  private curve: EllipticCurve;

  constructor() {
    this.curve = Secp256k1.createCurve();
  }

  // SHA-256 해시 계산 (서명 생성과 동일)
  hashMessage(message: Buffer): bigint {
    const hash = crypto.createHash('sha256').update(message).digest();
    return BigInt('0x' + hash.toString('hex'));
  }

  // ECDSA 서명 검증
  verify(message: Buffer, signature: ECDSASignature, publicKey: Point): boolean {
    // 1. r과 s가 유효 범위 내인지 확인
    if (signature.r <= 0n || signature.r >= Secp256k1.N) {
      return false;
    }
    if (signature.s <= 0n || signature.s >= Secp256k1.N) {
      return false;
    }

    // 2. 메시지 해시 계산
    const z = this.hashMessage(message);

    // 3. s의 모듈러 역원 계산
    const sInv = this.curve.modInverse(signature.s, Secp256k1.N);

    // 4. u1 = z × s⁻¹ mod n
    const u1 = (z * sInv) % Secp256k1.N;

    // 5. u2 = r × s⁻¹ mod n
    const u2 = (signature.r * sInv) % Secp256k1.N;

    // 6. P = u1 × G + u2 × 공개키
    const point1 = this.curve.multiplyPoint(u1, Secp256k1.G);
    const point2 = this.curve.multiplyPoint(u2, publicKey);
    const P = this.curve.addPoints(point1, point2);

    // 7. P가 무한원점이면 검증 실패
    if (P === null) {
      return false;
    }

    // 8. P.x mod n이 r과 같은지 확인
    const calculatedR = P.x % Secp256k1.N;
    return calculatedR === signature.r;
  }

  // 공개키 유효성 검증
  isValidPublicKey(publicKey: Point): boolean {
    // 1. 점이 유한체 범위 내에 있는지 확인
    if (!Secp256k1.isValidFieldElement(publicKey.x)) {
      return false;
    }
    if (!Secp256k1.isValidFieldElement(publicKey.y)) {
      return false;
    }

    // 2. 점이 타원곡선 방정식을 만족하는지 확인
    // y² = x³ + 7 (mod p)
    const left = (publicKey.y * publicKey.y) % Secp256k1.P;
    const right = (publicKey.x * publicKey.x * publicKey.x + Secp256k1.B) % Secp256k1.P;

    if (left !== right) {
      return false;
    }

    // 3. 점이 무한원점이 아닌지 확인 (이미 좌표가 있으므로 OK)
    // 4. n × 공개키 = O (무한원점)인지 확인 (비용이 크므로 생략 가능)

    return true;
  }

  // 16진수 문자열에서 서명 파싱
  signatureFromHex(hex: string): ECDSASignature {
    if (hex.length !== 128) {
      throw new Error('Invalid signature hex length');
    }

    const r = BigInt('0x' + hex.slice(0, 64));
    const s = BigInt('0x' + hex.slice(64, 128));

    return { r, s };
  }
}

설명

이것이 하는 일: 공개키와 서명을 사용하여 메시지가 정말 해당 개인키 소유자가 승인한 것인지 수학적으로 검증합니다. 첫 번째로, 검증 전 사전 체크를 살펴봅시다.

r과 s가 모두 1 이상 n 미만인지 확인합니다. 이 범위를 벗어나면 수학적으로 유효하지 않은 서명입니다.

또한 isValidPublicKey 메서드로 공개키가 정말 타원곡선 위의 점인지 확인해야 합니다. 공개키가 곡선 밖의 점이면 공격자가 임의의 서명을 만들어낼 수 있습니다.

y² = x³ + 7 (mod p)를 만족하는지 계산하여 검증합니다. 그 다음으로, 핵심 검증 알고리즘을 이해해봅시다.

먼저 s의 역원을 구하고, u1 = z × s⁻¹과 u2 = r × s⁻¹를 계산합니다. 이 값들은 타원곡선 스칼라 곱셈의 계수입니다.

u1 × G는 메시지 해시의 기여분이고, u2 × 공개키는 서명의 r 값의 기여분입니다. 이 두 점을 더한 P의 x 좌표를 mod n하면, 서명이 유효할 경우 원래의 r 값과 정확히 일치합니다.

왜 이것이 작동하는지 수학적으로 살펴볼까요? 서명 생성 시 s = k⁻¹(z + r × d)였으므로, k = (z + r × d) × s⁻¹입니다.

여기서 d는 개인키이고, 공개키 Q = d × G입니다. 따라서 k × G = z × s⁻¹ × G + r × s⁻¹ × d × G = u1 × G + u2 × Q입니다.

서명 생성 시 r = (k × G).x였으므로, 검증 시 계산한 P = k × G의 x 좌표도 r과 같아야 합니다. 이 동일성이 성립하면 서명이 유효한 것입니다.

여러분이 이 코드를 사용하면 비트코인 거래의 서명을 독립적으로 검증할 수 있습니다. 비트코인 풀 노드가 하는 일이 바로 이것입니다.

모든 트랜잭션 인풋의 서명을 검증하여, 정말 코인의 소유자가 거래를 승인했는지 확인하는 거죠.

실전 팁

💡 서명 검증은 공개 연산이므로 타이밍 공격에 덜 취약하지만, 그래도 사이드 채널 공격을 고려하여 상수 시간 구현을 권장합니다.

💡 대량의 서명을 검증할 때는 배치 검증(batch verification) 기법을 사용하면 30-40% 성능 향상을 얻을 수 있습니다.

💡 공개키 검증을 건너뛰면 invalid curve attack에 취약해집니다. 반드시 곡선 방정식을 만족하는지 확인하세요.

💡 서명 검증 실패 시 구체적인 실패 이유를 로그에 남기되, 외부에 노출하지 마세요. 공격자에게 힌트를 줄 수 있습니다.

💡 메모리 부족 공격을 방지하려면 한 번에 검증할 수 있는 서명 개수를 제한하세요.


7. DER_인코딩_구현

시작하며

여러분이 지금까지 만든 서명은 (r, s) 두 개의 bigint입니다. 하지만 비트코인 블록체인에 저장하거나 네트워크로 전송하려면 표준 형식으로 인코딩해야 합니다.

DER(Distinguished Encoding Rules)은 ASN.1 데이터 구조를 바이너리로 직렬화하는 표준 방법입니다. 비트코인은 ECDSA 서명을 DER 형식으로 인코딩하여 거래에 포함시킵니다.

DER 인코딩은 복잡해 보이지만, 구조를 이해하면 단순합니다. 태그-길이-값(TLV) 방식으로 데이터를 표현하는 것이죠.

개요

간단히 말해서, DER 인코딩은 ECDSA 서명을 비트코인 네트워크가 이해할 수 있는 바이너리 형식으로 변환하는 과정입니다. 왜 이것이 필요한지 보면, 블록체인은 바이트 단위로 데이터를 저장하므로 bigint를 그대로 쓸 수 없습니다.

예를 들어, r과 s의 길이가 가변적이므로 어디까지가 r이고 어디부터가 s인지 구분할 방법이 필요합니다. DER은 각 값의 길이를 명시적으로 포함하여 이 문제를 해결합니다.

전통적으로 X.509 인증서나 PKCS 표준에서도 DER을 사용하며, 비트코인도 이 검증된 표준을 채택했습니다. DER 서명의 구조는: 0x30 (SEQUENCE 태그) + 전체 길이 + 0x02 (INTEGER 태그) + r 길이 + r 값 + 0x02 + s 길이 + s 값입니다.

비트코인은 추가로 SIGHASH 타입(1바이트)을 뒤에 붙입니다.

코드 예제

class DEREncoder {
  // ECDSA 서명을 DER 형식으로 인코딩
  encodeToDER(signature: ECDSASignature, sighashType: number = 0x01): Buffer {
    // r과 s를 바이트 배열로 변환
    const rBuffer = this.encodeInteger(signature.r);
    const sBuffer = this.encodeInteger(signature.s);

    // SEQUENCE의 전체 길이 계산
    const sequenceLength = rBuffer.length + sBuffer.length;

    // DER 구조 조립
    const derSignature = Buffer.concat([
      Buffer.from([0x30]),                    // SEQUENCE 태그
      Buffer.from([sequenceLength]),          // SEQUENCE 길이
      rBuffer,                                 // r INTEGER
      sBuffer,                                 // s INTEGER
      Buffer.from([sighashType])               // SIGHASH 타입
    ]);

    return derSignature;
  }

  // bigint를 DER INTEGER로 인코딩
  private encodeInteger(value: bigint): Buffer {
    // bigint를 16진수 문자열로 변환
    let hex = value.toString(16);

    // 홀수 길이면 앞에 0 추가 (바이트 단위로 맞추기)
    if (hex.length % 2 !== 0) {
      hex = '0' + hex;
    }

    let buffer = Buffer.from(hex, 'hex');

    // 최상위 비트가 1이면 음수로 해석되므로 0x00 추가
    if (buffer[0] & 0x80) {
      buffer = Buffer.concat([Buffer.from([0x00]), buffer]);
    }

    // INTEGER 태그 + 길이 + 값
    return Buffer.concat([
      Buffer.from([0x02]),           // INTEGER 태그
      Buffer.from([buffer.length]),  // 길이
      buffer                         // 값
    ]);
  }

  // DER 형식에서 ECDSA 서명 디코딩
  decodeFromDER(derBuffer: Buffer): { signature: ECDSASignature; sighashType: number } {
    let offset = 0;

    // SEQUENCE 태그 확인
    if (derBuffer[offset++] !== 0x30) {
      throw new Error('Invalid DER: missing SEQUENCE tag');
    }

    // SEQUENCE 길이 읽기
    const sequenceLength = derBuffer[offset++];

    // r 값 디코딩
    const r = this.decodeInteger(derBuffer, offset);
    offset += r.bytesRead;

    // s 값 디코딩
    const s = this.decodeInteger(derBuffer, offset);
    offset += s.bytesRead;

    // SIGHASH 타입 읽기
    const sighashType = derBuffer[offset];

    return {
      signature: { r: r.value, s: s.value },
      sighashType
    };
  }

  // DER INTEGER 디코딩
  private decodeInteger(buffer: Buffer, offset: number): { value: bigint; bytesRead: number } {
    // INTEGER 태그 확인
    if (buffer[offset++] !== 0x02) {
      throw new Error('Invalid DER: missing INTEGER tag');
    }

    // INTEGER 길이 읽기
    const length = buffer[offset++];

    // 값 추출
    const valueBuffer = buffer.slice(offset, offset + length);
    const value = BigInt('0x' + valueBuffer.toString('hex'));

    return {
      value,
      bytesRead: 2 + length  // 태그(1) + 길이(1) + 값(length)
    };
  }

  // DER 서명을 16진수 문자열로 변환
  derToHex(derBuffer: Buffer): string {
    return derBuffer.toString('hex');
  }

  // 16진수 문자열에서 DER 서명 파싱
  hexToDER(hex: string): Buffer {
    return Buffer.from(hex, 'hex');
  }
}

설명

이것이 하는 일: ECDSA 서명을 ASN.1 DER 표준에 따라 바이너리로 인코딩하고, 반대로 바이너리에서 서명을 복원합니다. 첫 번째로, encodeToDER 메서드의 구조를 살펴봅시다.

먼저 r과 s를 각각 DER INTEGER로 인코딩합니다. encodeInteger는 bigint를 16진수 문자열로 변환한 후 바이트 배열로 만드는데, 두 가지 중요한 처리를 합니다.

홀수 길이의 16진수는 앞에 0을 붙여 바이트 경계에 맞추고, 최상위 비트가 1이면 0x00을 앞에 추가합니다. 왜냐하면 DER에서 INTEGER는 부호 있는 정수이므로, 최상위 비트가 1이면 음수로 해석되기 때문입니다.

그 다음 0x02(INTEGER 태그), 길이, 값을 순서대로 조립합니다. 그 다음으로, 전체 SEQUENCE 구조를 만듭니다.

0x30(SEQUENCE 태그)으로 시작하고, r과 s를 합친 전체 길이를 기록한 후, r INTEGER와 s INTEGER를 연결합니다. 비트코인은 마지막에 SIGHASH 타입을 추가하는데, 보통 0x01(SIGHASH_ALL)입니다.

이것은 거래의 어떤 부분을 서명하는지 나타냅니다. SIGHASH_ALL은 전체 거래를 서명한다는 의미죠.

디코딩 과정은 인코딩의 역순입니다. decodeFromDER은 오프셋을 유지하며 버퍼를 순회합니다.

먼저 0x30 태그를 확인하고, SEQUENCE 길이를 읽습니다. 그 다음 decodeInteger를 두 번 호출하여 r과 s를 추출하는데, 각 호출 후 읽은 바이트 수만큼 오프셋을 증가시킵니다.

decodeInteger는 0x02 태그를 확인하고, 길이를 읽은 후, 해당 길이만큼의 바이트를 16진수 문자열로 변환하여 bigint로 파싱합니다. 마지막으로 SIGHASH 타입을 읽어 반환합니다.

여러분이 이 코드를 사용하면 비트코인 거래에 포함할 수 있는 표준 형식의 서명을 만들 수 있습니다. 블록체인 익스플로러에서 보는 서명이 바로 이 DER 형식입니다.

인코딩된 서명의 길이는 보통 70-72바이트입니다.

실전 팁

💡 DER 길이 바이트는 127 이하일 때 단일 바이트이지만, 128 이상이면 다중 바이트 인코딩이 필요합니다. 서명은 항상 127 이하이므로 단일 바이트로 충분합니다.

💡 비트코인 코어는 엄격한 DER 검증을 수행합니다. r이나 s가 0으로 시작하는데 두 번째 바이트가 0x80 미만이면 invalid로 처리합니다.

💡 세그윗(SegWit)은 DER 대신 더 간단한 64바이트 고정 형식도 지원하지만, 레거시 거래에서는 여전히 DER이 필수입니다.

💡 SIGHASH 타입은 0x01(ALL), 0x02(NONE), 0x03(SINGLE) 등이 있으며, 0x80과 OR하면 ANYONECANPAY가 됩니다.

💡 DER 파싱 시 버퍼 오버플로우를 방지하려면 모든 길이 필드를 검증하고, 버퍼 범위를 벗어나지 않는지 확인하세요.


8. 트랜잭션_서명_통합

시작하며

이제 모든 조각을 모을 시간입니다. 실제 비트코인 거래를 만들고 서명하는 전체 플로우를 구현해봅시다.

여러분이 지갑 앱에서 "전송" 버튼을 누를 때, 내부에서는 트랜잭션 구조를 만들고, 서명 대상 데이터를 추출하고, 각 인풋에 대해 서명을 생성한 후, DER 인코딩하여 거래에 포함시키는 복잡한 과정이 일어납니다. 비트코인 트랜잭션은 여러 인풋과 아웃풋으로 구성되며, 각 인풋마다 별도의 서명이 필요합니다.

UTXO(미사용 트랜잭션 출력) 모델을 이해해야 합니다.

개요

간단히 말해서, 트랜잭션 서명은 거래 데이터를 직렬화하고, 각 인풋에 대해 ECDSA 서명을 생성하여 ScriptSig에 포함시키는 과정입니다. 왜 이것이 필요한지 보면, 블록체인에 기록되려면 모든 거래가 유효한 서명을 포함해야 하기 때문입니다.

예를 들어, 앨리스가 이전에 받은 0.5 BTC 두 개를 사용해 밥에게 0.8 BTC를 보내고 0.2 BTC를 거스름돈으로 받으려면, 두 인풋 각각에 대해 앨리스의 개인키로 서명해야 합니다. 서명 없이는 채굴자들이 거래를 블록에 포함시키지 않습니다.

전통적인 계정 기반 시스템과 달리, 비트코인은 UTXO 모델을 사용합니다. 각 거래는 이전 거래의 출력을 참조하며, 그 출력을 "소비"할 권한을 서명으로 증명합니다.

트랜잭션 서명의 핵심 단계는: 1) 거래 데이터 구조화(인풋, 아웃풋), 2) 각 인풋에 대해 서명 해시(sighash) 계산, 3) ECDSA 서명 생성, 4) DER 인코딩 및 공개키와 함께 ScriptSig 구성, 5) 완성된 거래를 네트워크에 브로드캐스트입니다.

코드 예제

import * as crypto from 'crypto';

// 트랜잭션 인풋 구조
interface TxInput {
  prevTxHash: Buffer;    // 이전 거래 해시
  outputIndex: number;   // 이전 거래의 출력 인덱스
  scriptSig: Buffer;     // 서명 스크립트 (서명 후 채워짐)
  sequence: number;      // 시퀀스 번호
}

// 트랜잭션 아웃풋 구조
interface TxOutput {
  amount: bigint;        // 사토시 단위 금액
  scriptPubKey: Buffer;  // 공개키 스크립트
}

// 비트코인 트랜잭션
interface Transaction {
  version: number;
  inputs: TxInput[];
  outputs: TxOutput[];
  locktime: number;
}

class TransactionSigner {
  private signer: ECDSASigner;
  private encoder: DEREncoder;

  constructor() {
    this.signer = new ECDSASigner();
    this.encoder = new DEREncoder();
  }

  // 트랜잭션 직렬화 (서명 해시 계산용)
  serializeForSigning(
    tx: Transaction,
    inputIndex: number,
    prevScriptPubKey: Buffer,
    sighashType: number = 0x01
  ): Buffer {
    const buffers: Buffer[] = [];

    // 1. 버전 (4바이트, 리틀엔디안)
    const version = Buffer.alloc(4);
    version.writeUInt32LE(tx.version);
    buffers.push(version);

    // 2. 인풋 개수 (varint)
    buffers.push(this.encodeVarInt(tx.inputs.length));

    // 3. 인풋들 직렬화
    tx.inputs.forEach((input, index) => {
      buffers.push(input.prevTxHash);

      const outputIdx = Buffer.alloc(4);
      outputIdx.writeUInt32LE(input.outputIndex);
      buffers.push(outputIdx);

      // 서명 중인 인풋만 prevScriptPubKey 포함, 나머지는 빈 스크립트
      if (index === inputIndex) {
        buffers.push(this.encodeVarInt(prevScriptPubKey.length));
        buffers.push(prevScriptPubKey);
      } else {
        buffers.push(Buffer.from([0x00]));
      }

      const seq = Buffer.alloc(4);
      seq.writeUInt32LE(input.sequence);
      buffers.push(seq);
    });

    // 4. 아웃풋 개수
    buffers.push(this.encodeVarInt(tx.outputs.length));

    // 5. 아웃풋들 직렬화
    tx.outputs.forEach(output => {
      const amount = Buffer.alloc(8);
      amount.writeBigInt64LE(output.amount);
      buffers.push(amount);

      buffers.push(this.encodeVarInt(output.scriptPubKey.length));
      buffers.push(output.scriptPubKey);
    });

    // 6. Locktime
    const locktime = Buffer.alloc(4);
    locktime.writeUInt32LE(tx.locktime);
    buffers.push(locktime);

    // 7. SIGHASH 타입
    const sighash = Buffer.alloc(4);
    sighash.writeUInt32LE(sighashType);
    buffers.push(sighash);

    return Buffer.concat(buffers);
  }

  // 트랜잭션에 서명
  signTransaction(
    tx: Transaction,
    inputIndex: number,
    privateKey: bigint,
    prevScriptPubKey: Buffer
  ): Buffer {
    // 1. 서명 해시 계산
    const serialized = this.serializeForSigning(tx, inputIndex, prevScriptPubKey);
    const hash = crypto.createHash('sha256')
      .update(crypto.createHash('sha256').update(serialized).digest())
      .digest();  // Double-SHA256

    // 2. ECDSA 서명 생성
    const signature = this.signer.sign(hash, privateKey);

    // 3. DER 인코딩 (SIGHASH_ALL 포함)
    const derSignature = this.encoder.encodeToDER(signature, 0x01);

    return derSignature;
  }

  // P2PKH ScriptSig 생성 (서명 + 공개키)
  createScriptSig(signature: Buffer, publicKey: Point): Buffer {
    // 공개키를 압축 형식으로 인코딩
    const keyGen = new KeyGenerator();
    const pubKeyBuffer = keyGen.compressPublicKey(publicKey);

    // ScriptSig: <sig> <pubkey>
    return Buffer.concat([
      this.encodeVarInt(signature.length),
      signature,
      this.encodeVarInt(pubKeyBuffer.length),
      pubKeyBuffer
    ]);
  }

  // 가변 길이 정수 인코딩
  private encodeVarInt(n: number): Buffer {
    if (n < 0xfd) {
      return Buffer.from([n]);
    } else if (n <= 0xffff) {
      const buf = Buffer.alloc(3);
      buf[0] = 0xfd;
      buf.writeUInt16LE(n, 1);
      return buf;
    } else {
      const buf = Buffer.alloc(5);
      buf[0] = 0xfe;
      buf.writeUInt32LE(n, 1);
      return buf;
    }
  }
}

설명

이것이 하는 일: 비트코인 거래 데이터를 표준 형식으로 직렬화하고, 각 인풋에 대해 서명을 생성하며, ScriptSig를 구성하여 거래를 완성합니다. 첫 번째로, serializeForSigning 메서드의 복잡한 로직을 이해해봅시다.

비트코인 거래는 바이너리 형식으로 직렬화되는데, 버전(4바이트), 인풋 개수(varint), 각 인풋(이전 거래 해시 32바이트 + 출력 인덱스 4바이트 + 스크립트 + 시퀀스 4바이트), 아웃풋 개수, 각 아웃풋(금액 8바이트 + 스크립트), locktime(4바이트), SIGHASH 타입(4바이트)으로 구성됩니다. 중요한 점은 서명 중인 인풋에만 이전 출력의 scriptPubKey를 포함하고, 나머지 인풋은 빈 스크립트로 채운다는 것입니다.

이것이 SIGHASH_ALL의 동작입니다. 그 다음으로, signTransaction 메서드는 직렬화된 거래에 Double-SHA256을 적용합니다.

SHA-256을 두 번 적용하는 이유는 길이 확장 공격(length extension attack)을 방지하기 위함입니다. 이 해시값이 실제로 ECDSA 서명의 대상이 됩니다.

서명 생성 후 DER 인코딩하여 반환하는데, SIGHASH_ALL(0x01)을 뒤에 붙입니다. createScriptSig는 P2PKH(Pay-to-Public-Key-Hash) 주소의 표준 ScriptSig를 만듭니다.

형식은 <서명 길이> <서명> <공개키 길이> <공개키>입니다. 공개키는 압축 형식(33바이트)을 사용하여 블록체인 공간을 절약합니다.

거래가 검증될 때, 스크립트 인터프리터는 이 서명과 공개키를 스택에 푸시하고, 이전 출력의 scriptPubKey와 결합하여 실행합니다. scriptPubKey는 OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG 형태인데, 마지막 OP_CHECKSIG가 실제 서명 검증을 수행합니다.

여러분이 이 코드를 사용하면 완전히 동작하는 비트코인 거래를 만들 수 있습니다. 예를 들어, 두 개의 인풋으로부터 0.01 BTC를 받아 0.005 BTC를 보내고 0.005 BTC를 거스름돈으로 받는 거래를 만들고, 각 인풋에 서명한 후, 네트워크에 브로드캐스트하면 블록체인에 기록됩니다.

실전 팁

💡 거래에 서명하기 전에 반드시 모든 아웃풋의 금액 합계가 인풋보다 작은지 확인하세요. 차액은 채굴자 수수료가 됩니다.

💡 서명 순서가 중요합니다. 각 인풋은 독립적으로 서명되므로, 한 번에 하나씩 처리하고 올바른 prevScriptPubKey를 사용해야 합니다.

💡 SegWit 거래는 서명 해시 계산 방식이 다릅니다(BIP 143). 레거시와 SegWit을 구분하여 처리하세요.

💡 실제 프로덕션에서는 거래 크기를 미리 계산하여 적절한 수수료를 책정하세요. 바이트당 사토시(sat/vB) 단위로 계산합니다.

💡 거래 브로드캐스트 전에 testnet에서 충분히 테스트하세요. mainnet에서 실수하면 자산을 잃을 수 있습니다.


#TypeScript#ECDSA#디지털서명#암호화#블록체인#typescript

댓글 (0)

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