ビットコイン 暗号技術 Python ウォレット基礎

ビットコインの「秘密鍵」と「アドレス」はどう作る?(Pythonで生成して理解する)

この記事では、ビットコインの秘密鍵(private key)から公開鍵(public key)を導出し、アドレス(P2WPKH / P2PKH)を作る までの流れを、Pythonコードとあわせて丁寧に説明します。

重要:ここで扱う秘密鍵は、資産の「所有権そのもの」です。 記事のサンプルは学習目的であり、実運用の資産用の鍵を安易に出力・共有しないでください。 ターミナル履歴やログに残る点も含め、取り扱いには十分注意してください。

※本記事は一般的な技術解説であり、投資助言・法的助言ではありません。

全体像:秘密鍵 → 公開鍵 → アドレス

ビットコインの鍵生成は、ざっくり言うと次のパイプラインです。

① 秘密鍵
32バイト乱数
ただし範囲は 1 ≤ k < n(secp256k1)
② 公開鍵
楕円曲線演算
k × G(secp256k1)→ (x,y)
③ アドレス
ハッシュ+エンコード
HASH160 → Base58Check / Bech32
「秘密鍵を持つ」=「そのアドレスの資産を動かせる」。
だからこそ、生成・保存・表示には細心の注意が必要です。

Step 1:秘密鍵(secp256k1)を生成する

秘密鍵は、暗号学的に安全な乱数で生成するのが基本です。 このサンプルでは Python の secrets を使い、 32バイトの乱数を作って整数に変換し、secp256k1 の位数 n に対して 1 ≤ k < n を満たすまで繰り返します。

  • 32バイト:ビットコインの秘密鍵は通常 256bit(32 bytes)
  • 範囲チェック:楕円曲線の有効範囲(1〜n-1)に入れる
  • secrets:暗号用途向けの安全な乱数生成
(参考)秘密鍵生成の考え方(擬似コード)
while True:
    k = secure_random_32bytes()
    if 1 <= int(k) < secp256k1_n:
        return k

Step 2:秘密鍵から公開鍵を導出する(圧縮形式)

公開鍵は、楕円曲線 secp256k1 上で k × G(Gは生成点)を計算して得られます。 実装では ecdsa ライブラリを使い、(x,y) を取り出します。

非圧縮公開鍵(65 bytes)

  • 0x04 + x(32) + y(32)
  • 昔からある表現

圧縮公開鍵(33 bytes)

  • 先頭は 0x02(y偶数)or 0x03(y奇数)
  • 続けて x(32)
  • 現在の標準的な形式(推奨)
ポイント P2WPKH(bc1...)は「圧縮公開鍵」が前提

本記事のPythonコードでも、P2WPKH作成時に「圧縮公開鍵でなければエラー」にしています。

Step 3:アドレスを作る(P2WPKH / P2PKH)

アドレスは「公開鍵そのもの」ではなく、公開鍵をハッシュ化・エンコードした表現です。 ここでは代表的な2種類を作ります。

種類 見た目 作り方(要点)
P2WPKH(SegWit v0) bc1... HASH160(圧縮公開鍵) を witness program として Bech32(BIP-0173) でエンコード
P2PKH(Legacy) 1... HASH160(公開鍵) に version byte(mainnet: 0x00)を付け、 Base58Check でエンコード

HASH160 とは?

HASH160(x) = RIPEMD160(SHA256(x))。 ビットコインでは公開鍵・スクリプト等を短く表すのに広く使われます。

Step 4:秘密鍵をWIF形式で表現する

WIF(Wallet Import Format)は秘密鍵を人間が扱いやすい文字列にした形式です。 このコードでは Base58Check を使い、mainnet/testnet と圧縮鍵かどうかで payload を変えています。

payload の構造(mainnet例)

  • version: 0x80(testnetは 0xEF
  • 秘密鍵 32 bytes
  • 圧縮鍵なら末尾に 0x01 を追加
  • 最後に checksum(ダブルSHA256の先頭4バイト)
注意 WIFや秘密鍵をブログやSNSに貼るのは絶対にNG

その瞬間に「資産の操作権」を渡すのと同じです。学習目的なら、必ずテスト環境やダミー前提で扱いましょう。

実行方法:コードをダウンロードして動かす

ここまでの生成手順を、ひとつのPythonスクリプトとしてまとめたものを用意しておくと便利です。 このブログでは、次の2つのやり方を提示します。

選択肢A:このページの内容を元に自分で作る

手順を追いながら、コードを分割して理解しやすい形で実装できます。 「Base58Check」「Bech32」「HASH160」の役割が掴みやすいです。

ただし、実装ミスがあるとアドレスが不正になることがあるので、検証は慎重に。

おすすめ 選択肢B:上記のPythonコードをダウンロードしてそのまま使う

すぐ動かして確認したい場合は、スクリプトをそのまま利用できます(依存:ecdsarequests)。

※リンク先は例です。実運用では、このHTMLと同じディレクトリに btc_keypair_generator.py を置く想定です。


動かし方(最小手順)

  1. Python 3 を用意
  2. 依存ライブラリをインストール
  3. スクリプトを実行
# 依存のインストール
pip install ecdsa requests

# 実行
python3 btc_keypair_generator.py

補足:スクリプトは TESTNETCOMPRESSED を切り替えられます。 学習目的なら、まずは TESTNET=True にして試すと安心です。

※ただし、秘密鍵の表示・保存の扱いはテストネットでも慎重に。

コード全文(ダウンロード版と同じ)

下のコードは「秘密鍵生成 → 公開鍵導出 → WIF → アドレス(P2WPKH / P2PKH) → 残高確認」の一式です。 私用に改変する場合も、まずはそのまま動かして出力を確認するのが近道です。

#!/usr/bin/env python3
import secrets
import hashlib

# ---- secp256k1 ----
SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

# ---- Base58Check ----
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"

def base58_encode(b: bytes) -> str:
    """Encode bytes as Base58 (Bitcoin alphabet), preserving leading zeroes as '1'."""
    if not isinstance(b, (bytes, bytearray)):
        raise TypeError("base58_encode expects bytes")

    num = int.from_bytes(b, "big")
    out = []
    while num > 0:
        num, rem = divmod(num, 58)
        out.append(BASE58_ALPHABET[rem])
    out_str = "".join(reversed(out))  # may be ""

    # Leading 0x00 bytes become '1' in Base58
    pad = 0
    for byte in b:
        if byte == 0:
            pad += 1
        else:
            break

    if out_str == "":
        return "1" * pad if pad else "1"
    return "1" * pad + out_str

def base58check_encode(payload: bytes) -> str:
    """Base58Check = Base58(payload || checksum4), checksum4 = SHA256(SHA256(payload))[:4]."""
    if not isinstance(payload, (bytes, bytearray)):
        raise TypeError("base58check_encode expects bytes")
    checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
    return base58_encode(payload + checksum)

# ---- Hash helpers ----
def sha256(b: bytes) -> bytes:
    return hashlib.sha256(b).digest()

def ripemd160(b: bytes) -> bytes:
    h = hashlib.new("ripemd160")
    h.update(b)
    return h.digest()

def hash160(b: bytes) -> bytes:
    return ripemd160(sha256(b))

# ---- Bech32 (BIP-0173) for SegWit v0 ----
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"

def bech32_polymod(values):
    GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
    chk = 1
    for v in values:
        b = (chk >> 25) & 0xFF
        chk = ((chk & 0x1FFFFFF) << 5) ^ v
        for i in range(5):
            chk ^= GEN[i] if ((b >> i) & 1) else 0
    return chk

def bech32_hrp_expand(hrp: str):
    return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]

def bech32_create_checksum(hrp: str, data):
    values = bech32_hrp_expand(hrp) + data
    polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1  # BIP-0173 constant for v0
    return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]

def bech32_encode(hrp: str, data) -> str:
    combined = data + bech32_create_checksum(hrp, data)
    return hrp + "1" + "".join(BECH32_CHARSET[d] for d in combined)

def convertbits(data: bytes, frombits: int, tobits: int, pad: bool = True):
    """General power-of-2 base conversion (BIP-0173 style). Returns list of ints or None on error."""
    acc = 0
    bits = 0
    ret = []
    maxv = (1 << tobits) - 1
    for b in data:
        if b < 0 or b >> frombits:
            return None
        acc = (acc << frombits) | b
        bits += frombits
        while bits >= tobits:
            bits -= tobits
            ret.append((acc >> bits) & maxv)
    if pad:
        if bits:
            ret.append((acc << (tobits - bits)) & maxv)
    else:
        if bits >= frombits:
            return None
        if (acc << (tobits - bits)) & maxv:
            return None
    return ret

# ---- Key generation / formats ----
def generate_private_key() -> bytes:
    """Generate a uniformly random 32-byte secp256k1 private key in [1, n-1]."""
    while True:
        k = secrets.token_bytes(32)
        ki = int.from_bytes(k, "big")
        if 1 <= ki < SECP256K1_N:
            return k

def to_wif(privkey32: bytes, compressed: bool = True, testnet: bool = False) -> str:
    """Encode a 32-byte private key as WIF (Base58Check)."""
    if not isinstance(privkey32, (bytes, bytearray)) or len(privkey32) != 32:
        raise ValueError("privkey32 must be 32 bytes")
    version = b"\xEF" if testnet else b"\x80"
    payload = version + privkey32 + (b"\x01" if compressed else b"")
    return base58check_encode(payload)

def pubkey_from_privkey(privkey32: bytes, compressed: bool = True) -> bytes:
    """Derive a secp256k1 public key from a 32-byte private key."""
    if not isinstance(privkey32, (bytes, bytearray)) or len(privkey32) != 32:
        raise ValueError("privkey32 must be 32 bytes")

    # dependency: pip install ecdsa
    from ecdsa import SigningKey, SECP256k1

    sk = SigningKey.from_string(privkey32, curve=SECP256k1)
    vk = sk.get_verifying_key()
    xy = vk.to_string()  # 64 bytes: x(32) || y(32)
    x = xy[:32]
    y = xy[32:]

    if not compressed:
        return b"\x04" + x + y

    # Compressed form: 0x02 if y even, 0x03 if y odd, followed by x
    prefix = b"\x03" if (y[-1] & 1) else b"\x02"
    return prefix + x

def address_p2pkh(pubkey: bytes, testnet: bool = False) -> str:
    """Legacy P2PKH address (Base58Check), starts with '1' (mainnet) or 'm/n' (testnet)."""
    if not isinstance(pubkey, (bytes, bytearray)) or len(pubkey) not in (33, 65):
        raise ValueError("pubkey must be 33 (compressed) or 65 (uncompressed) bytes")
    h160 = hash160(pubkey)
    version = b"\x6F" if testnet else b"\x00"
    return base58check_encode(version + h160)

def address_p2wpkh(pubkey: bytes, testnet: bool = False) -> str:
    """
    Native SegWit v0 P2WPKH (Bech32).
    IMPORTANT: Standard P2WPKH uses HASH160(compressed_pubkey) only.
    """
    if not isinstance(pubkey, (bytes, bytearray)):
        raise TypeError("pubkey must be bytes")

    # Enforce compressed pubkey (33 bytes starting with 0x02 or 0x03)
    if not (len(pubkey) == 33 and pubkey[0] in (2, 3)):
        raise ValueError("P2WPKH requires a compressed public key (33 bytes, prefix 0x02/0x03).")

    h160 = hash160(pubkey)  # 20 bytes
    hrp = "tb" if testnet else "bc"
    prog5 = convertbits(h160, 8, 5, pad=True)
    if prog5 is None:
        raise ValueError("convertbits failed")
    data = [0] + prog5  # witness version 0 + program
    return bech32_encode(hrp, data)

import requests

SATOSHIS_PER_BTC = 100_000_000

def get_address_balance(address: str, testnet: bool = False, timeout: float = 10.0):
    """
    Fetch balance info for a Bitcoin address using Blockstream's API.

    Returns a dict with:
      - confirmed_sats / confirmed_btc
      - mempool_sats / mempool_btc (net in mempool)
      - total_sats / total_btc (confirmed + mempool)
      - also raw stats for reference
    """
    if not isinstance(address, str) or not address:
        raise ValueError("address must be a non-empty string")

    base = "https://blockstream.info"
    api = f"{base}/testnet/api" if testnet else f"{base}/api"
    url = f"{api}/address/{address}"

    r = requests.get(url, timeout=timeout)
    r.raise_for_status()
    data = r.json()

    cs = data.get("chain_stats", {})
    ms = data.get("mempool_stats", {})

    confirmed = int(cs.get("funded_txo_sum", 0)) - int(cs.get("spent_txo_sum", 0))
    mempool = int(ms.get("funded_txo_sum", 0)) - int(ms.get("spent_txo_sum", 0))
    total = confirmed + mempool

    return {
        "address": address,
        "network": "testnet" if testnet else "mainnet",
        "confirmed_sats": confirmed,
        "confirmed_btc": confirmed / SATOSHIS_PER_BTC,
        "mempool_sats": mempool,
        "mempool_btc": mempool / SATOSHIS_PER_BTC,
        "total_sats": total,
        "total_btc": total / SATOSHIS_PER_BTC,
        "chain_stats": cs,
        "mempool_stats": ms,
    }

if __name__ == "__main__":
    TESTNET = False   # True -> testnet (tb1..., m/n..., WIF usually starts with 'c')
    COMPRESSED = True # Recommended: True (modern standard)

    priv = generate_private_key()
    pub = pubkey_from_privkey(priv, compressed=COMPRESSED)

    # WARNING: Printing private keys exposes them in terminal history/logs.
    print("Private key (hex):", priv.hex())
    print("Public key (hex): ", pub.hex())
    print("WIF:              ", to_wif(priv, compressed=COMPRESSED, testnet=TESTNET))
    print("Address (P2WPKH): ", address_p2wpkh(pub, testnet=TESTNET))  # bc1... / tb1...
    print("Address (P2PKH):  ", address_p2pkh(pub, testnet=TESTNET))   # 1... / m/n...

    addr_wpkh = address_p2wpkh(pub, testnet=TESTNET)
    addr_p2pkh = address_p2pkh(pub, testnet=TESTNET)

    print("Address (P2WPKH): ", addr_wpkh)
    print("Address (P2PKH):  ", addr_p2pkh)

    # Check balances
    try:
        b1 = get_address_balance(addr_wpkh, testnet=TESTNET)
        b2 = get_address_balance(addr_p2pkh, testnet=TESTNET)

        print("\\nBalance (P2WPKH):", b1["total_btc"], "BTC",
              f"(confirmed={b1['confirmed_btc']} BTC, mempool={b1['mempool_btc']} BTC)")
        print("Balance (P2PKH): ", b2["total_btc"], "BTC",
              f"(confirmed={b2['confirmed_btc']} BTC, mempool={b2['mempool_btc']} BTC)")
    except Exception as e:
        print("Balance check failed:", e)

よくある注意点

1) 秘密鍵を表示・保存しない工夫

  • ターミナル履歴・CIログ・スクショに残りやすい
  • 学習目的でも、出力は最小限に
  • 本格運用はハードウェアウォレット等も検討

2) テストネットを活用する

  • TESTNET=True で試す
  • アドレスは tb1... / m/n... になる
  • 学習の安全性が上がる
まとめ 同じ手順で「秘密鍵 → 公開鍵 → アドレス」を作れると、ウォレットの仕組みが一気に理解しやすくなる

実装の中身(HASH160 / Base58Check / Bech32)を追うことで、「なぜその形式になるのか」が腹落ちします。