深入技术
HD 钱包

HD 钱包

Hierarchical Deterministic Wallets

分层确定性钱包, 简称 HD 钱包, 是一种可以从单个 seed 生成多个密钥对的加密货币钱包系统。

seed 生成拓展秘钥, 拓展秘钥又可以派生出多个子拓展秘钥。每个子拓展秘钥又可以继续派生其下层拓展秘钥, 如此往复, 理论上可以无限派生。每个拓展秘钥都可以计算出一个公私钥作为比特币账户。所以 HD 钱包的优点显而易见, 通过一个 seed 就可以生成无限多的比特币账号, 且无需备份每个私钥, 只需要备份 seed 即可。

派生密钥

BIP32 是 HD 钱包的基础, 定义了如何从 seed 派生出密钥。

seed 是一个随机数。通过 HMAC-SHA512 哈希函数由 seed 生成拓展秘钥(extended key), 拓展秘钥由主私钥和链码(chain code)组成。

拓展秘钥

Seed
拓展秘钥
import { hmac } from '@noble/hashes/hmac'
import { sha512 } from '@noble/hashes/sha512'
 
const extendedKey = hmac(sha512, 'Bitcoin seed', Buffer.from(seed, 'hex'))

HMAC-SHA512 函数的输入是 seed 和密码(BIP 32 规定这个密码是 Bitcoin seed), 输出是 64 字节的拓展秘钥(从 seed 生成的拓展秘钥也叫主拓展秘钥 master extended key )。 前 32 字节作为主私钥, 后 32 字节作为链码。链码作为派生子密钥的 HMAC-SHA512 的密码。

拓展秘钥有两种:

  • 拓展私钥: 主私钥和链码
  • 拓展公钥: 主私钥对应的公钥和链码
import ecc from '@bitcoinerlab/secp256k1'
 
// 前32字节为私钥, 后32字节为链码
const privateKey = extendedKey.slice(0, 32)
const chainCode = extendedKey.slice(32)
 
// 从私钥计算压缩公钥
const publicKey = ecc.pointFromScalar(privateKey, true)
 
// 拓展私钥
const extendedPrivateKey = extendedKey
// 拓展公钥
const extendedPublicKey = Buffer.concat([publicKey, chainCode])

这两种拓展秘钥都能单独的生成子密钥, 从拓展私钥可以派生出子拓展私钥, 但从拓展公钥只能派生出子拓展公钥。从拓展私钥派生出的子拓展私钥所对应的公钥与拓展公钥派生出的子拓展公钥相同。

extended private key -> child extended private key
                                    |
                                    | secp256k1
                                    |

extended public key -> child extended public key

拓展秘钥根据索引生成子密钥, 索引大小为 4 字节, 可以表达的最大值是 4294967295, 即最多可以派生出 4294967296 个子密钥。按照索引值将其分成两种派生方式:

  • Normal Derivation - 普通派生, 索引范围在 0 - 2147483647:
  • Hardened derivation - 硬化派生, 索引范围在 2147483648 - 4294967295:

普通派生

如果索引小于 2147483648, 则为普通派生。

普通派生过程如下:

  • 将索引转换为 4 字节的大端字节序
  • 将压缩公钥和索引拼接, 生成 37 字节的数据作为 HMAC-SHA512 加密数据
  • 将拓展秘钥的链码作为 HMAC-SHA512 密码
  • HMAC-SHA512 生成 64 字节的 I, 前 32 字节为派生因子, 后 32 字节为子链码
    • 已知父私钥: 派生因子与父私钥相加, 并对椭圆曲线的阶取模, 得到子私钥
    • 仅知道父公钥: 派生因子乘以椭圆曲线的基点并与父公钥相加, 得到子公钥
import { hmac } from '@noble/hashes/hmac'
import { sha512 } from '@noble/hashes/sha512'
import ecc, { pointAddScalar, privateAdd } from '@bitcoinerlab/secp256k1'
import { fromHex, toHex } from 'uint8array-tools'
 
// 拓展秘钥的主私钥和链码
const parentPrivateKey = fromHex(extendedKey.slice(0, 64))
const parentChainCode = fromHex(extendedKey.slice(64))
 
// 10进制索引转换为 4 字节
const index = 0
const indexBuffer = new Uint8Array(4)
new DataView(indexBuffer.buffer).setUint32(0, index, false)
 
let data: Uint8Array = new Uint8Array()
if (index < 2147483648) {
  // 计算主公钥
  const parentPublicKey = ecc.pointFromScalar(parentPrivateKey, true)
 
  // 主公钥与索引拼接
  const data = new Uint8Array([...parentPublicKey, ...indexBuffer])
} else {
  // hardened derivation...
}
 
// HMAC-SHA512 生成 64 字节的 I
const I = hmac(sha512, parentChainCode, data)
 
const IL = I.slice(0, 32) // 前 32 字节派生因子
const IR = I.slice(32) // 子链码
 
// 已知父私钥: 与父私钥相加, 并对椭圆曲线的阶取模, 得到子私钥
// 子拓展私钥 = (父私钥 + 派生因子) mod n  || 子链码
const childPrivateKey = privateAdd(parentPrivateKey, IL)
 
// 仅知道父公钥: 与父公钥相加, 得到子公钥
// 子拓展公钥 = (派生因子 * G + 父公钥点) || 子链码
const childPublicKey = pointFromScalar(childPrivateKey, true)

拓展秘钥

Seed
拓展秘钥

子私钥或子公钥和子链码组合又可以作为拓展秘钥, 根据索引继续生成下一级子密钥。

普通派生的要求必须要知道父公钥, 但如果只知道父公钥, 就只能派生出子公钥, 则无法派生出子私钥。

硬化派生

如果索引大于等于 2147483648, 则为硬化派生 hardened derivation

硬化派生过程如下:

  • 将索引转换为 4 字节的大端字节序
  • 将主私钥和索引拼接后, 最前面补充一个字节 0, 生成 37 字节的数据作为 HMAC-SHA512 加密数据
  • 将拓展秘钥的链码作为 HMAC-SHA512 密码
  • HMAC-SHA512 生成 64 字节的 I, 前 32 字节为派生因子, 后 32 字节为子链码, 派生因子与主私钥相加, 并对椭圆曲线的阶取模, 得到子私钥。
if (index < 2147483648) {
  // normal derivation...
} else {
  // hardened derivation
  data = new Uint8Array([0, ...parentPrivateKey, ...indexBuffer])
}

硬化派生与普通派生过程基本一致, 区别在于硬化派生以主私钥和索引拼接, 而普通派生则压缩公钥和索引拼接。

秘钥序列化

BIP32 定义了拓展秘钥序列化格式。序列化格式字段包括:

  • 版本号 - 4 字节
    • 主网: 拓展私钥 - xprv(0x0488ADE4), 拓展公钥 - xpub(0x0488B21E)
    • 测试网: 拓展私钥 - tprv(0x04358394), 拓展公钥 - tpub(0x043587CF)
  • 深度 - 1 字节, 表示节点在树中的深度
  • 父指纹 - 4 字节, 父节点公钥进行 Hash160 运算后取前 4 字节作为指纹
  • 索引 - 4 字节, 节点在当前层的索引
  • 链码 - 32 字节, 拓展秘钥的后 32 字节
  • 秘钥 - 33 字节, 压缩公钥或私钥

将上述字段按顺序拼接, 生成 78 字节的序列化数据。并对序列化数据进行 Base58Check 编码, 生成拓展秘钥的字符串表示。

拓展秘钥

Seed
拓展秘钥

在已知序列化格式的拓展秘钥情况下, 可以通过 Base58Check 解码, 得到拓展秘钥的各个字段。

助记词

助记词是 seed 的人类可读形式, 通常是 12 或 24 个单词。通过助记词可以生成 seed, 再由 seed 生成多个拓展秘钥。

BIP39 定义了助记词的生成规则, 过程如下:

  • 生成随机数 - 这个随机数叫 entropy, 大小为 4 的倍数的字节, 范围在 16 - 32 字节。即 entropy 大小可以是 16, 20, 24, 28 或 32 字节。entropy 的比特位长度称作 ENT

  • 计算 checksum - 取 SHA256(entropy) 的前 ENT/32 比特位作 checksum

  • entropychecksum 比特位拼接, 并按照每11比特位分组。每组转换为10进制作为 单词表 的索引从单词表中取出单词, 生成助记词。

  • 通过 PBKDF2 函数将助记词转换为512位的 seed, PBKDF2 函数的密码是 mnemonic${passphrase}, passphrase 是用户自定义的密码。

助记词生成器

助记词

结合 BIP39BIP32, 可以总结出生成多个比特币账户的过程:

  • 通过助记词生成 seed
  • 通过 seed 生成主拓展秘钥
  • 通过主拓展秘钥生成多层子密钥
  • 每个子密钥对应一个比特币账户

助记词的目的是为了提供一种更友好的方式来记忆 seed。 如果你有更安全且方便的方法来保存 seed, 那么助记词就不是必须的。例如, 你可以用你喜欢的一句话转成 16 进制作为 seed

使用助记词生成 seed 时, 更安全的做法是设置 passphrase, 这样即使助记词泄露, 也无法生成 seed

派生路径

派生路径用于定位从 seed 生成的子密钥的位置。从秘钥树的第 0 层开始, 每一层的索引作为路径的一部分, 用 / 分隔。如果是硬化派生, 索引需要减去 2312^{31} 并在后面加上 '

例如: m/8'/2/0':

  • m 表示从 seed 生成的主拓展秘钥, 是密钥树的第 0 层,
  • 8' 表示从 m 派生出的第一层索引是 231+8 2^{31} + 8
  • 2 表示从 m/8' 派生出的第二层索引是 2
  • 0' 表示从 m/8'/2 派生出的第三层索引是 231+02^{31} + 0 个子密钥。

在 BIP44 中定义了派生路径固定的结构:

m / purpose' / coin_type' / account' / change / address_index
  • purpose - 目的, 例如是 BIP44, 则为 44', BIP49 则为 49', BIP84 则为 84'
  • coin_type - 币种, 一个 seed 可以生成多个币种的密钥, 每个币种有一个唯一的索引。比特币的索引是 0', 比特币测试网的索引是 1', 以太坊的索引是 60'
  • account - 账户索引, 从 0 开始
  • change - 0 表示外部地址, 1 表示内部地址。外部地址用于钱包外可见的地址, 例如接收付款。内部地址用于钱包内部的地址, 例如找零地址。
  • address_index - 地址索引

BIP44 路径是 m/44'/0'/0'/0/0, 定义了生成 P2PKH 地址使用的公私钥。

BIP49 路径是 m/49'/0'/0'/0/0, 定义了生成 P2SH-P2WPKH 地址使用的公私钥。

BIP84 路径是 m/84'/0'/0'/0/0, 定义了生成 P2WPKH 地址使用的公私钥。

BIP86 路径是 m/86'/0'/0'/0/0, 定义了生成 P2TR 地址使用的公私钥。

Copyright © 2024 HeapUp