比特币密钥生成规则及 Go 实现

通常可以通过比特币客户端 (比如 bitcoin-core) 生成地址。这篇文章总结我对比特币密钥、地址生成规则的理解和通过研究分析 Golang 实现的比特币节点软件比特币地址、密钥部分,同时也希望帮到喜欢对此感兴趣的开发者。

密钥类型

存在三种密钥,并且都是使用 Base58Check 编码成 ASCII 码呈现:

  • 私钥 (private key)
  • 公钥 (public key)
  • 公钥的哈希地址 (hash of public key)

通过私钥能够推导出公钥和公钥哈希,并且交易需要用私钥签名证明私钥持有者拥有比特币资产,保护好自己的私钥就是保护我们的数字资产;公钥哈希就是大家看到的比特币地址,大部分比特币地址由公钥通过 base58Check 编码而来,把公钥地址从 512-Bit 哈希到 160-Bit ,但并不是所有的比特币地址都是公钥推导出来,也有可能是通过脚本建立在比特币网络中的虚拟币(比如彩色币)的脚本标识。从私钥推导出公钥、再从公钥推导出公钥哈希都是单向的,也就是采用不可逆算法。bitcoin_pri_to_address_flow.png

密钥格式

私钥和公钥有可以被编码成多种类型格式,无一例外的作用就是为了方便识别及钱包操作方便。

原生的密钥 (公钥和私钥是随机的字节序列,比特币使用椭圆曲线来产生私钥) 是一个 256 比特的二进制码,也就是 32 字节码,在不同的场景可由此推导出不同格式的私钥。十六进制和原生的在编码中使用,普通用户通常都接触不到,WIF (Wallet import format) 用于钱包之间导入导出私钥,由 base58Check 编码而来。不同格式之间可以相互转换。比特币中私钥格式如下表 1:

TypePrefixDescriptionPrivate key example
RawNone32 bytes
HexNone64 hexadecimal digits1e99423a4ed27608a15a2616a2b0e9e52ced330ac530edcc32c8ffc6a526aedd
WIF5Base58Check encoding: Base58 with version prefix of 128- and 32-bit checksum5J3mBbAH58CpQ3Y5RNJpUKPE62SQ5tfcvU2JpbnkeyhfsYB1Jcn
WIF-compressedK or LAs above, with added suffix 0x01 before encodingKxFC1jmwwCoACiCAWZ3eXa96mBM6tb3TYzGmf6YwgdGWZgawvrtJ

WIF-compressed 格式的私钥以字母 K 开头,用以表明被编码的私钥有一个后缀“01”,只能用于推导生成被压缩格式的公钥 (compressed public keys)

原生公钥是椭圆曲线算法中的一个点,前缀(04) + x, y 坐标值 (x,y 为 32 字节数)组成。因为在交易中包含了公钥,为了优化交易的数据结构、压缩硬盘储存区块链数据,且根据椭圆曲线算法公式根据 x 就能推导出 y 轴的值,所以引进了 压缩公钥 (Compressed public keys),只包含前缀(02 或 03) + x 。以此压缩公钥相比原生公钥减少了一半的存储,每天面临比特币网络成千上万币交易时,构建交易的数据结构时极大地优化了存储。在这里 y 如果为偶数前缀为 02, 为奇数前缀为 03 。想了解具体的数学计算,可以看下文末段引用 1 ,看不懂也没关系。

即使只使用了 x 坐标值生成的32字节 (256 比特)的压缩公钥还比较长,在转换成比特币地址的过程中,首先把压缩公钥通过 SHA-256RIPEMD160 哈希算法转成 160 比特数字,然后再通过 Base58Check 编码最终得到比特币地址。

Base58Check 编码

Base58Check 将密钥从字节码转化成 ASCII 码,通俗地说就是将机器码密格式的钥格转为人类可读的字符串。文末引用 2 说明计算机编码基本概念,熟悉的读者请忽略。

比特币公私钥和地址各种格式前缀和 Base58Check 编码如下表 2:

种类版本前缀 (hex)Base58 格式
Bitcoin Address0x001
Pay-to-Script-Hash Address0x053
Bitcoin Testnet Address0x6Fm or n
Private Key WIF十六进制为 0x80 十进制时为 1285 or K or L
BIP38 Encrypted Private Key0x01426P
BIP32 Extended Public Key0x0488B21Expub

Base58Check 编码是可逆编码,也就是从字节码可编码成字符串,从字符串可解码成字节码。

base58check.png

使用表2 对应密钥的版本前缀加上必要格式的密钥字节码,两次哈希之后获取前四个字节作为校验和。然后再以版本号、密钥数据、校验和做 base58 编码。

密钥流程图

那么从私钥到比特币地址的生成流程如下图:bitcoin_key.png

密钥和地址实现

比特币被私钥锁定在比特币地址中,私钥是一个长度为 256-bit 的随机数。有各种方式生成比特币私钥,本质上就是在 1 到 2 的 256 次方之间选一个数字。比特币客户端软件使用 Secp256k1 ECDSA 标准生成椭圆曲线,使用椭圆生成一个私钥,然后再从私钥中生成对应的公钥。

以下步骤参考 Technical background of version 1 Bitcoin addresses ,完整实现的源码在文章末尾可找到。

关于椭圆曲线加密不再深入研究,涉及到数学知识说实话我没看懂。结合比特币 Go 实现的源码分析。

首先看 Golang ecdsa package 中公私钥的结构体

// PublicKey represents an ECDSA public key.
type PublicKey struct {
    elliptic.Curve
    X, Y *big.Int
}

// PrivateKey represents a ECDSA private key.
type PrivateKey struct {
    PublicKey
    D *big.Int
}

然后是生成密钥对的函数

// GenerateKey generates a public and private key pair.
func GenerateKey(c elliptic.Curve, rand io.Reader) (*PrivateKey, error) {
    k, err := randFieldElement(c, rand)
    if err != nil {
        return nil, err
    }

    priv := new(PrivateKey)
    priv.PublicKey.Curve = c
    priv.D = k
    priv.PublicKey.X, priv.PublicKey.Y = c.ScalarBaseMult(k.Bytes())
    return priv, nil
}

接着封装一个方法通过 GenerateKey 方法生成的密钥对转化为原生的字节码

func newKeyPair() ([]byte, []byte) {
curve := elliptic.P256()
private, err := ecdsa.GenerateKey(curve, rand.Reader)
if err != nil {
    log.Panic(err)
}
d := private.D.Bytes()
b := make([]byte, 0, privKeyBytesLen)
priKet := paddedAppend(privKeyBytesLen, b, d)
pubKey := append(private.PublicKey.X.Bytes(), private.PublicKey.Y.Bytes()...)

return priKet, pubKey
}

// paddedAppend appends the src byte slice to dst, returning the new slice.
// If the length of the source is smaller than the passed size, leading zero
// bytes are appended to the dst slice before appending src.
func paddedAppend(size uint, dst, src []byte) []byte {
for i := 0; i < int(size)-len(src); i++ {
    dst = append(dst, 0)
}
return append(dst, src...)
}

结构体 PrivateKeyD 字段为椭圆曲线算法生成的私钥,然后通过 paddedAppend 方法转化为 32 bytes 的私钥;上文提到通过原生私钥能推导出原生公钥,公钥就是椭圆曲线中的一个点,取 x,y 轴的坐标值拼接即可 pubKey := append(private.PublicKey.X.Bytes(), private.PublicKey.Y.Bytes()...)

接着我们继续往上层封装,一个字节码格式的公私钥的结构体及该结构体对象生成函数

// Wallet stores private and public keys
type Wallet struct {
    PrivateKey []byte
    PublicKey  []byte
}

// NewWallet creates and returns a Wallet
func NewWallet() *Wallet {
    private, public := newKeyPair()
    wallet := Wallet{private, public}

    return &wallet
}

以上使用椭圆曲线加密算法生成了公私钥,接着我们根据公钥生成比特币地址。在 base58check 编码之前,我们首先需要对公钥做处理:

  1. 使用 SHA256 哈希原生公钥
  2. 使用 RIPEMD-160 哈希步骤 1 的结果
// GetAddress returns wallet address
func (w Wallet) GetAddress() (address string) {
    /* See https://en.bitcoin.it/wiki/Technical_background_of_Bitcoin_addresses */
    pub_bytes := w.PublicKey
    /* SHA256 Hash */
    fmt.Println("2 - Perform SHA-256 hashing on the public key")
    sha256_h := sha256.New()
    sha256_h.Reset()
    sha256_h.Write(pub_bytes)
    pub_hash_1 := sha256_h.Sum(nil)
    fmt.Println(byteString(pub_hash_1))
    fmt.Println("=======================")

    /* RIPEMD-160 Hash */
    fmt.Println("3 - Perform RIPEMD-160 hashing on the result of SHA-256")
    ripemd160_h := ripemd160.New()
    ripemd160_h.Reset()
    ripemd160_h.Write(pub_hash_1)
    pub_hash_2 := ripemd160_h.Sum(nil)
    fmt.Println(byteString(pub_hash_2))
    fmt.Println("=======================")
    /* Convert hash bytes to base58 check encoded sequence */
    address = b58checkencode(0x00, pub_hash_2)

    return address
}

到这里已经准备处理好的公钥数据,接下来就是使用 base58check 编码上述步骤得到的数据,流程如下:

  1. 在上述两个步骤得出的结果前面添加比特币地址前缀,主链添加 0x00
  2. 使用 SHA256 哈希步骤 1 的结果
  3. 再次使用 SHA256 哈希步骤 2 的结果
  4. 取步骤 3 结果的前四个字节,作为地址的检验和
  5. 步骤三的结果 + 步骤六的检验和作为 Base58 编码的元数据
// b58checkencode encodes version ver and byte slice b into a base-58 check encoded string.
func b58checkencode(ver uint8, b []byte) (s string) {
    /* Prepend version */
    fmt.Println("4 - Add version byte in front of RIPEMD-160 hash (0x00 for Main Network)")
    bcpy := append([]byte{ver}, b...)
    fmt.Println(byteString(bcpy))
    fmt.Println("=======================")

    /* Create a new SHA256 context */
    sha256H := sha256.New()

    /* SHA256 Hash #1 */
    fmt.Println("5 - Perform SHA-256 hash on the extended RIPEMD-160 result")
    sha256H.Reset()
    sha256H.Write(bcpy)
    hash1 := sha256H.Sum(nil)
    fmt.Println(byteString(hash1))
    fmt.Println("=======================")

    /* SHA256 Hash #2 */
    fmt.Println("6 - Perform SHA-256 hash on the result of the previous SHA-256 hash")
    sha256H.Reset()
    sha256H.Write(hash1)
    hash2 := sha256H.Sum(nil)
    fmt.Println(byteString(hash2))
    fmt.Println("=======================")

    /* Append first four bytes of hash */
    fmt.Println("7 - Take the first 4 bytes of the second SHA-256 hash. This is the address checksum")
    fmt.Println(byteString(hash2[0:4]))
    fmt.Println("=======================")

    fmt.Println("8 - Add the 4 checksum bytes from stage 7 at the end of extended RIPEMD-160 hash from stage 4\. This is the 25-byte binary Bitcoin Address.")
    bcpy = append(bcpy, hash2[0:4]...)
    fmt.Println(byteString(bcpy))
    fmt.Println("=======================")

    /* Encode base58 string */
    s = b58encode(bcpy)

    /* For number of leading 0's in bytes, prepend 1 */
    for _, v := range bcpy {
        if v != 0 {
            break
        }
        s = "1" + s
    }
    fmt.Println("9 - Convert the result from a byte string into a base58 string using Base58Check encoding. This is the most commonly used Bitcoin Address format")
    fmt.Println(s)
    fmt.Println("=======================")

    return s
}

完整源码 ,最后这是程序运行结果:

➜  bitcoin_protocol ./bitcoin_protocol
0 - Having a private ECDSA key
8A7FD53F196F0CCFDC977A1CD0A035ACE70741B6BDB3DE58D5C3C1BEA68A3798
=======================
1 - Take the corresponding public key generated with it (65 bytes, 1 byte 0x04, 32 bytes corresponding to X coordinate, 32 bytes corresponding to Y coordinate)
raw public key 4DB3EADC34F02F92A512730B4B69E7FDC07659A34363AA5B703898E0CA35015F9D1DBE721C39EA8BAABBD9044D249DCE713AF0668237CB8685A7ABE8CEB857CA
=======================
2 - Perform SHA-256 hashing on the public key
4D67DE558B00A9FD3E942CA1A1CCD60FA9E5DBE4CBF69D89ABF1073330C6E240
=======================
3 - Perform RIPEMD-160 hashing on the result of SHA-256
26C31518D393638CCF57DF964C946499E001D6C4
=======================
4 - Add version byte in front of RIPEMD-160 hash (0x00 for Main Network)
0026C31518D393638CCF57DF964C946499E001D6C4
=======================
5 - Perform SHA-256 hash on the extended RIPEMD-160 result
539B1DBC1128381B6B172292C1C3919BF8950E44B0246AB7FB56743D3BBB755F
=======================
6 - Perform SHA-256 hash on the result of the previous SHA-256 hash
010742B8196B1EC4D558359071057F3F6A746D07FD81DC28BC2AE2063D837144
=======================
7 - Take the first 4 bytes of the second SHA-256 hash. This is the address checksum
010742B8
=======================
8 - Add the 4 checksum bytes from stage 7 at the end of extended RIPEMD-160 hash from stage 4\. This is the 25-byte binary Bitcoin Address.
0026C31518D393638CCF57DF964C946499E001D6C4010742B8
=======================
9 - Convert the result from a byte string into a base58 string using Base58Check encoding. This is the most commonly used Bitcoin Address format
14XxNrUcQQ7hM1VMeNqp3vCVvhv3F7ZgPZ
=======================

在区块链浏览器上可查到生成的地址 14XxNrUcQQ7hM1VMeNqp3vCVvhv3F7ZgPZ

bitcoin-key-flow.png

引用

引用 1

未压缩格式公钥使用 04 作为前缀,而压缩格式公钥是以 02 或 03 作为前缀。需要这两种不同前缀的原因是:因为椭圆曲线加密的公式的左边是 y2 ,也就是说 y 的解是来自于一个平方根,可能是正值也可能是负值。更形象地说,y 坐标可能在x坐标轴的上面或者下面。从图4-2的椭圆曲线图中可以看出,曲线是对称的,从x轴看就像对称的镜子两面。因此,如果我们略去y坐标,就必须储存y的符号(正值或者负值)。换句话说,对于给定的x值,我们需要知道y值在x轴的上面还是下面,因为它们代表椭圆曲线上不同的点,即不同的公钥。当我们在素数p阶的有限域上使用二进制算术计算椭圆曲线的时候,y坐标可能是奇数或者偶数,分别对应前面所讲的y值的正负符号。因此,为了区分y坐标的两种可能值,我们在生成压缩格式公钥时,如果y是偶数,则使用 02 作为前缀;如果y是奇数,则使用 03 作为前缀。这样就可以根据公钥中给定的x值,正确推导出对应的y坐标,从而将公钥解压缩为在椭圆曲线上的完整的点坐标。

引用 2

为了更简洁方便地表示长串的数字,许多计算机系统会使用一种以数字和字母组成的大于十进制的表示法。例如,传统的十进制计数系统使用0-9十个数字,而十六进制系统使用了额外的 A-F 六个字母。一个同样的数字,它的十六进制表示就会比十进制表示更短。更进一步,Base64使用了26个小写字母、26个大写字母、10个数字以及两个符号(例如“+”和“/”),用于在电子邮件这样的基于文本的媒介中传输二进制数据。Base64通常用于编码邮件中的附件。Base58是一种基于文本的二进制编码格式,用在比特币和其它的加密货币中。这种编码格式不仅实现了数据压缩,保持了易读性,还具有错误诊断功能。Base58是Base64编码格式的子集,同样使用大小写字母和10个数字,但舍弃了一些容易错读和在特定字体中容易混淆的字符。具体地,Base58不含Base64中的0(数字0)、O(大写字母o)、l(小写字母L)、I(大写字母i),以及“+”和“/”两个字符。简而言之,Base58就是由不包括(0,O,l,I)的大小写字母和数字组成。

4 条评论
您想说点什么吗?
gky899 评论于 2021-10-29 11:42

加密函数是secp256r1(P256), 比特币/以太坊使用的基于C库的实现(S256) secp256k1,所以不要用这代码生成的地址用于BTC

博主 评论于 2020-06-02 11:02

reply 黎连文 感谢指出错误,今天晚点儿我有空验证并修改,谢谢。

黎连文 评论于 2020-06-02 00:09

虽然文章很长很精彩,可惜代码走偏了路。代码里的椭圆曲线选得不对。 curve := elliptic.P256() private, err := ecdsa.GenerateKey(curve, rand.Reader) 你采用golang官方提供的曲线secp256r1,而比特币采用的是另一条椭圆曲线secp256k1,两条曲线的参数不同,这将导致相同的私钥在不同的曲线上会计算出不同的公钥,进而导致计算出不同的地址。 所以你代码里的私钥在比特币区块链上是无法控制你代码里的比特币地址的,如果不相信,可以把你的私钥转成WIF格式导入Electrum钱包,看看Electrum里显示的比特币地址是否和你程序你的结果相同。 我试了,结果是 非压缩私钥:5Kdc3UAwGmHHuj6fQD1LDmKR6J3SwYyFWyHgxKAZ2cKRzVCRETY 地址: 1LPMcSPiVbMKumX7hKg7tWbvwhRzSnMX6Q 这和你文章里的地址14XxNrUcQQ7hM1VMeNqp3vCVvhv3F7ZgPZ不同。 (回复完)

黎连文 评论于 2020-06-01 23:59

虽然文章很长很精彩,可惜代码走偏了路。代码里的椭圆曲线选得不对。 curve := elliptic.P256() private, err := ecdsa.GenerateKey(curve, rand.Reader) 你采用golang官方提供的曲线secp256r1,而比特币采用的是另一条椭圆曲线secp256k1,两条曲线的参数不同,这将导致相同的私钥会计算出不同的公钥,进而导致不同的地址。 所以你代码里的私钥是无法控制你代码里的比特币地址的,如果不相信,可以把你的私钥转成WIF格式导入Electrum钱包,看看Electrum里显示的比特币地址是否和你程序你的结果相同。 我试了,结果是 压缩私钥:5Kdc3UAwGmHHuj6fQD1LDmKR6J3SwYyFWyHgxKAZ2cKRzVCRETY 地址: 1LPMcSPiVbMKumX7hKg7tWbvwhRzSnMX6Q 和你文章里的地址14XxNrUcQQ7hM1VMeNqp3vCVvhv3F7ZgPZ不同。 (回复完)