TIL

블록체인: HD Wallet과 ECC

블록체인에서 지갑은 디지털 자산을 관리하는 소프트웨어이고, 개인키를 소유한 사람이 지갑에 접근할 수 있습니다.HD(Hierarchical Deterministic) Wallet은 트랜잭션이 발생할 때마다 지갑의 주소가 새로 생성되는 지갑입니다. 이름에서 알 수 있듯 지

2025년 11월 19일9min read

1. HD Wallet 🎯

블록체인에서 지갑은 디지털 자산을 관리하는 소프트웨어이고, 개인키를 소유한 사람이 지갑에 접근할 수 있습니다.

HD(Hierarchical Deterministic) Wallet은 트랜잭션이 발생할 때마다 지갑의 주소가 새로 생성되는 지갑입니다. 이름에서 알 수 있듯 지갑의 주소가 계층적으로 결정됩니다.

계층적으로 결정된다는 것이 무슨 말일까요? HD Wallet은 어떤 요구에 의해 등장하게 되었을까요?

1-1. HD Wallet 이론 ✅

초기 암호화폐 지갑의 Loose-Key 방식은 개인키를 N개 미리 생성하여 사용했습니다. 문제는 각 개인키를 모두 백업해야 했고, 하나라도 분실하면 자금을 영구적으로 잃는다는 것이었죠. 이를 해결하기 위해 HD Wallet이 등장했습니다. 하나의 루트 시드에서 체인 코드를 이용해 무한개의 키 쌍을 파생(Derivation) 할 수 있게 되었고, 시드 하나만 보관하면 모든 키를 복원할 수 있게 되었습니다.

HD Wallet의 루트 시드는 256비트 난수를 12~24개의 단어로 변환한 니모닉(BIP-39)입니다. 키 파생 방식은 두 가지로 나뉩니다.

Normal Derivation은 부모 확장 공개키로 자식 공개키를 생성할 수 있어 편리하지만, 자식 키 노출 시 부모 개인키 역추적 위험이 있습니다.

Hardened Derivation은 부모 개인키까지 해시에 포함시켜 이 위험을 차단합니다. 실제 지갑은 BIP-32 표준에 따라 상위 레벨에는 Hardened, 하위 레벨에는 Normal Derivation을 혼합 사용합니다.

1-2. HD Wallet 코드 ✅

py
import hashlib

PBKDF2_ROUNDS = 2048

def mnemonic_to_seed(mnemonic_words: list, passphrase: str = "") -> bytes:
    """
    Convert a mnemonic phrase into a seed.

    :param mnemonic_words: List of mnemonic words.
    :param passphrase: An optional passphrase to enhance security.
    :return: A 64-byte seed.
    """
    mnemonic_str = " ".join(mnemonic_words).strip()
    print(f"mnemonic_str: {mnemonic_str}")
    salt = "mnemonic" + passphrase.strip()
    print(f"salt: {salt}")

    # Generate the seed using PBKDF2-HMAC-SHA512 (password-based key derivation function 2)
    seed = hashlib.pbkdf2_hmac(
        "sha512", mnemonic_str.encode("utf-8"), salt.encode("utf-8"), PBKDF2_ROUNDS
    )
    return seed

# Example usage
mnemonic_list = ['bring', 'boil', 'cattle', 'dawn', 'off', 'buyer', 'weird', 'plug', 'summer', 'federal', 'misery', 'ship']
passphrase = ""
seed = mnemonic_to_seed(mnemonic_list, passphrase)
print(f"Generated seed: {seed.hex()} ({len(seed)} bytes)")

지갑 복구를 위한 마스터 시드(Seed)를 생성하는 코드입니다.

1. hashlib: SHA-256, SHA-512 등의 보안 알고리즘을 파이썬에서 사용할 수 있도록 제공하는 표준 라이브러리입니다.

2. PBKDF2_ROUNDS: PBKDF2(Password-Based Key Derivation Function 2) 함수가 실행될 반복 횟수를 뜻합니다. BIP-39 표준에서는 2048회로 설정할 것을 제안합니다.

3. mnemonic_str: 니모닉을 공백으로 연결하여 만든, 니모닉 구문 전체를 담고 있는 문자열입니다.

4. salt: PBKDF2 과정에서 니모닉에 추가되어 해시 결과를 무작위화하고 무차별 대입 공격을 방지하는 역할을 하는 선택적 암호입니다. 위 코드에서는 salt 값을 설정하지 않았습니다.

5. seed: 니모닉과 솔트를 이용해 2048회의 PBKDF2를 거쳐 최종적으로 파생된 64바이트 길이의 이진 데이터입니다. HD Wallet의 모든 키를 파생하는 루트 키입니다.

py
import hmac

def derive_master_key(seed: bytes) -> tuple[bytes, bytes]:
    """
    Derive the master key and chain code from the seed.

    :param seed: A 64-byte seed.
    :return: A tuple containing the master private key and chain code.
    """
    key = b"Bitcoin seed"
    h = hmac.new(key, seed, hashlib.sha512).digest()
    master_key = h[:32]
    chain_code = h[32:]
    return master_key, chain_code

# Example usage
print(f"Generate master key using seed {seed.hex()} ({len(seed)} bytes)")
master_key, chain_code = derive_master_key(seed)
print(f"Master Key: {master_key.hex()} ({len(master_key)} bytes)")
print(f"Chain Code: {chain_code.hex()} ({len(chain_code)} bytes)")

BIP-32 표준에 따라 마스터 시드로부터, 마스터 개인키와 체인 코드를 파생하는 과정을 구현한 코드입니다.

1. hmac: 암호화 메세지 인증 코드(HMAC)을 구현하는 라이브러리입니다. 위에서는 HMAC-SHA512를 사용하여 키 파생을 안전하게 수행합니다.

2. key: HMAC 연산에 사용되는 마스터키 역할을 하는 고정된 문자열입니다. BIP-32 표준에서 정의한 "Bitcoin seed"를 사용합니다.

3. h: key를 HMAC 키로, seed를 메세지로 사용하여 해시한 64바이트(512비트) 출력값입니다.

4. master_key: h의 앞 32바이트(256비트)를 잘라낸 값으로, HD Wallet 구조의 최상위 마스터 개인키 역할을 합니다.

5. chain_code: h의 뒤 32바이트(256비트)를 잘라낸 값으로, 자식 키를 파생할 때 무작위성을 더해주는 추가적인 보안 매개변수입니다.

py
from ecdsa.util import string_to_number
from ecdsa import SigningKey, VerifyingKey, SECP256k1
import hashlib
import hmac

def derive_child_key(
    parent_key: bytes,
    parent_chain_code: bytes,
    index: int
) -> tuple[bytes, bytes, bytes]:
    """
    Derive a child key from a parent key and chain code.

    :param parent_key: The parent private key (32 bytes).
    :param parent_chain_code: The parent chain code.
    :param index: The child index (use hardened index for added security).
    :return: A tuple containing the child private key, chain code, and public key.
    """
    # Convert index to 4-byte big-endian
    hdi = index.to_bytes(4, "big")
    print(f"Index (4 bytes): {hdi.hex()}")

    if index & 0x80000000:  # Hardened derivation
        # Hardened: Use private key
        data = b"\x00" + parent_key + hdi
    else:  # Normal derivation
        parent_pub_key = derive_public_key_from_private_key(parent_key)
        data = parent_pub_key + hdi
    
    print(f"HMAC input data: {data.hex()}")

    # Perform HMAC-SHA512
    h = hmac.new(parent_chain_code, data, hashlib.sha512).digest()
    child_tweak = string_to_number(h[:32])  # First 32 bytes
    child_chain_code = h[32:]  # Last 32 bytes

    print(f"Child tweak: {h[:32].hex()}, Child chain code: {child_chain_code.hex()}")

    # SECP256k1's curve order
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

    # Compute child private key
    parent_int = string_to_number(parent_key)
    child_private_key = (parent_int + child_tweak) % n
    if child_private_key == 0:
        raise ValueError("Derived child private key is invalid (zero).")

    child_private_key_bytes = child_private_key.to_bytes(32, "big")
    print(f"Derived child private key: {child_private_key_bytes.hex()}")

    # Compute child public key from private key
    child_pub_key = derive_public_key_from_private_key(child_private_key_bytes)

    return child_private_key_bytes, child_chain_code, child_pub_key

def derive_public_key_from_private_key(private_key: bytes) -> bytes:
    """
    Derive the public key from the private key.

    :param private_key: The private key (32 bytes).
    :return: The compressed public key (33 bytes).
    """
    sk = SigningKey.from_string(private_key, curve=SECP256k1)
    vk = sk.verifying_key
    x = vk.pubkey.point.x()
    prefix = b"\x02" if vk.pubkey.point.y() % 2 == 0 else b"\x03"
    return prefix + x.to_bytes(32, "big")

BIP-32 표준에 따라 Hardened와 Normal 파생을 구분하여 자식 키를 파생하는 과정을 구현한 코드입니다.

1. derive_child_key: 부모의 확장 개인키와 인덱스를 사용하여 자식의 확장 개인키를 파생하는 BIP-32의 핵심 함수입니다.

2. derive_public_key_from_private_key: 주어진 개인키로부터 SECP256k1 곡선 연산을 통해 해당 개인키에 대응하는 33바이트 압축 공개키를 계산하는 헬퍼 함수입니다.

3. hdi: 인덱스($i$)를 4바이트 크기의 빅 엔디안 형식 바이트 배열로 변환한 값으로, HMAC 입력 데이터의 일부로 사용됩니다.

4. index & 0x80000000: 인덱스의 최상위 비트(MSB)를 확인하여 Hardened Derivation($i'$)을 수행할지 Normal Derivation($i$)을 수행할지 결정하는 조건문입니다.

5. data: HMAC-SHA512 연산에 사용되는 입력 데이터로, Hardened 파생 시 개인키를, Normal 파생 시 공개키를 포함합니다.

6. child_tweak: HMAC 결과(h)의 앞 32바이트를 정수(Integer)로 변환한 값으로, 자식 개인키를 계산할 때 부모 개인키에 더해지는 조정 값입니다.

7. n: 비트코인 등이 사용하는 SECP256k1 타원 곡선의 순서(Order)를 나타내는 상수값으로, 개인키 계산 시 모듈러 연산의 기준이 됩니다.

8. child_private_key: 부모 개인키와 child_tweak을 합한 후 곡선 순서로 나눈 나머지인, 최종 자식 개인키입니다.

py
def derive_from_path(
    master_key: bytes,
    chain_code: bytes,
    path: str
) -> tuple[bytes, bytes, bytes]:
    """
    Derive a key from a given derivation path.

    :param master_key: The master private key.
    :param chain_code: The master chain code.
    :param path: The derivation path (e.g., "m/44'/60'/0'/0/0").
    :return: The derived private key, chain code, and public key.
    """
    levels = path.split("/")[1:]  # Skip "m"
    key, code = master_key, chain_code

    for level in levels:
        if "'" in level:  # Hardened key
            index = 0x80000000 | int(level[:-1])
        else:  # Normal key
            index = int(level)

        print(f"Processing index: {index} (Hardened: {'Yes' if index & 0x80000000 else 'No'})")
        key, code, pub_key = derive_child_key(key, code, index)

        # Log the current state
        print(f"Derived private key: {key.hex()}")
        print(f"Derived chain code: {code.hex()}")
        print(f"Derived public key: {pub_key.hex()}")

    return key, code, pub_key
    
# Example usage
path = "m/44'/60'/0'/0/0" # Compare with Metamask
derived_key, derived_chain_code, derived_pub_key = derive_from_path(master_key, chain_code, path)
print(f"Derived Private Key: {derived_key.hex() if derived_key else None}")
print(f"Derived Chain Code: {derived_chain_code.hex()}")
print(f"Derived Public Key: {derived_pub_key.hex()}")

HD Wallet의 마스터키($m$)에서 BIP-44 경로($m / purpose' / coin\_type' / account' / change / address\_index$)를 따라 최종 주소의 키를 파생하는 코드입니다.

1. derive_from_path: BIP-44 표준 파생 경로 문자열을 해석하여 마스터키에서 최종 주소의 키까지 계층적으로 순차 파생하는 함수입니다.

2. levels: 입력된 경로 문자열(path)에서 최상위 m을 제외하고 /로 구분된 파생 인덱스 문자열 리스트입니다.

3. level: levels 리스트를 순회하며 처리하는 각 계층의 인덱스 문자열입니다.

4. if "'" in level: 현재 level 문자열에 프라임 기호(')가 포함되어 있는지 확인하여 Hardened Derivation 대상인지 판단하는 조건문입니다.

5. index = 0x80000000 | int(level[:-1]): Hardened 인덱스(44')의 프라임 기호를 제거한 후, $0x80000000$을 비트 OR 연산하여 최상위 비트를 1로 설정해 Hardened 파생을 명시합니다.

6. index = int(level): 프라임 기호가 없는 Normal 인덱스(0)를 단순히 정수형으로 변환합니다.

7. key, code, pub_key = derive_child_key(...): 현재 파생된 확장 키(key, code)를 다음 루프의 부모 키로 사용하여, 경로의 다음 단계로 순차적으로 진행합니다.


2. ECC 🎯

Elliptic Curve Cryptography는 타원 곡선 암호입니다. ECC를 통해 공개키 암호화 과정을 살펴보겠습니다.

2-1. 개인키, 공개키 이론 ✅

개인키 d는 랜덤하게 생성된 바이트일 뿐입니다. d는 1 이상 N 미만의 범위를 갖는데요, 여기서 N은 타원 곡선 군의 위수를 의미합니다. 즉, 개인키는 특별한 구조가 없는 단순한 난수입니다.

공개키 Q는 개인키 d에 타원 곡선의 생성점 G를 곱한 결과입니다. 즉, Q = dG이죠. Q는 (x, y)의 형태로 도출됩니다. 공개키라는 것은 타원 곡선 위의 점이라고 이해할 수 있겠습니다.

Q가 15이고 G가 3이라면, d는 5라는 것을 쉽게 알 수 있습니다. 이거 뭐 개인키 오픈하는 거 아니냐?라는 의심이 들 수 있는데요, 타원 곡선 위에서의 곱셈 연산은 일반적인 곱셈 연산이 아니라 Scalar Multiplication이라는 점 덧셈입니다. 역산이 매우 어렵기 때문에 타원 곡선 암호의 보안성을 보장할 수 있습니다.

2-2. 개인키, 공개키 코드 ✅

go
func TestPrivateKey(t *testing.T) {
	privateKey, err := crypto.GenerateKey()
	require.NoError(t, err)

	// Parameters of the secp256k1 curve which is used in Ethereum
	curveParams := privateKey.Params()
	t.Logf("P: %x\n", curveParams.P)
	t.Logf("N: %x\n", curveParams.N)
	t.Logf("B: %x\n", curveParams.B)
	t.Logf("Gx: %x\n", curveParams.Gx)
	t.Logf("Gy: %x\n", curveParams.Gy)
	t.Logf("BitSize: %v\n", curveParams.BitSize)
	t.Logf("Name: %v\n", curveParams.Name)

	// The private key is just a random number
	t.Logf("D: %x\n", privateKey.D)

	// The public key is a point on the curve calculated by (Gx, Gy) * D
	t.Logf("X: %x\n", privateKey.PublicKey.X)
	t.Logf("Y: %x\n", privateKey.PublicKey.Y)

	// Uncomprseed public key = 04 + X + Y
	t.Logf("Uncompressed Public Key: %x\n", crypto.FromECDSAPub(&privateKey.PublicKey))

	// Compressed public key = 02 or 03 + X
	t.Logf("Compressed Public Key: %x\n", crypto.CompressPubkey(&privateKey.PublicKey))

	// Sign a message
	message := []byte("Hello, world!")
	messageHash := crypto.Keccak256Hash(message)
	signature, err := crypto.Sign(messageHash[:], privateKey)
	require.NoError(t, err)
	t.Logf("Signature: %x\n", signature)

	// Verify the signature
	crypto.VerifySignature(crypto.FromECDSAPub(&privateKey.PublicKey), messageHash[:], signature)
}

위 함수는 이더리움에서 사용되는 secp256k1 곡선을 기반으로 키 쌍을 생성하고, 서명과 검증을 수행하는 과정을 보여줍니다.

1. crypto.GenerateKey(): 이더리움의 crypto 패키지를 사용하여 secp256k1 곡선에 유효한 새로운 개인키를 무작위로 생성합니다.

2. curveParams.P: 타원 곡선의 정의 방정식인 $y^2 = x^3 + ax + b \pmod{p}$에서 사용되는 소수 필드의 크기(p)입니다.

3. curveParams.N: 타원 곡선 군의 위수(Order)로, 개인키(d)가 가질 수 있는 최대 범위(1≤d

4. curveParams.Gx, curveParams.Gy: 타원 곡선 군의 생성점(G)의 X 좌표와 Y 좌표를 나타냅니다.

5. privateKey.D: 생성된 개인키(d)의 실제 값으로, 타원 곡선 위수(N) 미만의 무작위 정수입니다.

6. privateKey.PublicKey.X, privateKey.PublicKey.Y: 개인키(d)와 생성점(G)의 스칼라 곱(Q=d⋅G)으로 계산된 공개키(Q)의 X 좌표와 Y 좌표입니다.

7. crypto.FromECDSAPub(...): 공개키 $Q(x, y)$를 접두사 04를 포함하는 비압축 형식(04∥X∥Y)의 바이트로 직렬화합니다.

8. crypto.CompressPubkey(...): 공개키 $Q(x, y)$를 $02$ 또는 $03$ 접두사를 포함하는 압축 형식($[02/03] \| X$)의 바이트로 직렬화합니다.

9. crypto.Keccak256Hash(message): 서명 전에 메시지 원본을 이더리움 표준 해시 함수인 Keccak-256으로 해시하여 메시지 다이제스트를 생성합니다.

10. crypto.Sign(...): 해시된 메시지와 개인키를 사용하여 ECDSA(타원 곡선 디지털 서명 알고리즘) 기반의 서명(r,s,v)을 생성합니다.

11. crypto.VerifySignature(...): 공개키, 메시지 해시, 그리고 서명을 사용하여 해당 서명이 이 개인키에 의해 생성되었는지 검증하는 과정입니다.


3. 정리 🎯

안전하게 관리되는 니모닉(BIP-39)은 PBKDF2-HMAC-SHA512 알고리즘을 통해 64바이트의 마스터 시드(Seed)로 변환되며, 이 시드가 곧 HD Wallet (Hierarchical Deterministic Wallet) 구조의 출발점이 됩니다.

HD Wallet은 초기 Loose-Key 방식의 비효율적인 다중 백업 문제를 해결하고, 트랜잭션마다 새로운 주소를 생성하여 프라이버시를 향상시키기 위해 등장했습니다.

HD Wallet은 BIP-32 표준에 따라 하나의 마스터 시드에서 무한한 키 쌍을 계층적(부모-자식 관계)이고 결정론적(항상 동일하게 재현 가능)으로 파생시킵니다. 키를 파생하는 과정은 HMAC-SHA512를 기반으로 하며, 보안 수준에 따라 두 가지 방식으로 나뉩니다.

Hardened Derivation (강화 파생): 부모 개인키를 사용하여 보안성이 높으며, 경로의 상위 계층(예: 목적, 코인 타입, 계정)에 사용됩니다.

Normal Derivation (일반 파생): 부모 공개키만으로 자식 공개키를 파생시킬 수 있어 편리하며, 경로의 하위 계층(예: 주소 인덱스)에 사용됩니다.

이 모든 키 파생의 근간에는 타원 곡선 암호(ECC, Elliptic Curve Cryptography)가 있습니다. ECC는 개인키($d$)와 생성점($G$)의 스칼라 곱셈 연산($Q = d \cdot G$)을 통해 공개키($Q$)를 생성하며, 이 과정의 역산인 타원 곡선 이산 로그 문제(ECDLP)의 어려움을 이용하여 지갑의 보안성을 최종적으로 보장합니다.

추가적으로 Metamask 지갑을 설치하고 니모닉을 생성하는 등의 실습을 진행해 봤습니다. 단순 UI 조작이기에, 이 부분은 직접 해보시면 좋을 것 같습니다.