跳到主要內容

分層確定性錢包 HD Wallet 剖析:設計和實現

王仕军(资深前端工程师)
ArcBlockDID WalletWallet

作者: 王仕軍(資深前端工程師)

你真的瞭解錢包麼?

瞭解區塊鏈、或者持有數字貨幣(比如比特幣和以太坊)的同學可能都知道把數字貨幣存在自己的錢包,目前市面上的錢包應用非常多,有支持單鏈的、支持多鏈的,有手機 APP、有網頁、有桌面客戶端,還有瀏覽器插件如 MetaMask。

絕大多數錢包應用在創建錢包時千叮嚀萬囑咐讓你做好備份的助記詞是怎麼回事兒?為什麼這些錢包自稱是 HD 錢包?為什麼說洩露了助記詞就丟失了所有的幣?為什麼用單個助記詞可以產生、控制很多個賬戶?助記詞究竟是怎麼產生私鑰的?安全性有沒有保障?破解的難度究竟有多大?如果你有耐心讀完本文,相信這些問題的答案都會了然於胸。

廢話少說,接下來我們就用層層遞進、抽絲剝繭的方式去了解下分層確定性錢包的設計和實現。

重新認識錢包

分層確定性錢包常被簡寫成 HD Wallet,簡寫來源於 Hierarchical Deterministic Wallet,如果要徹底搞清楚什麼是 Hierarchical Deterministic Wallet,從語言層面來看這個名詞性短語:

  • Hierarchical 是形容詞
  • Deterministic 是形容詞
  • Wallet 是名詞

搞清楚每個詞語在技術上的含義、設計動機,對於我們理解 HD 錢包非常有幫助。

錢包裡面到底有沒有幣?

確切的說,任何區塊鏈錢包裡面都沒有幣,裡面有的只是私鑰、公鑰對,可能有點反常識,但是事實確實如此。錢包可以包含任意數量的私鑰、公鑰對,其中私鑰可以用來簽名交易,從而把這個私鑰能控制的幣花出去,但是幣本身是存在區塊鏈大賬本上的。把區塊鏈錢包理解為鑰匙串可能更形象些,因為鑰匙並不是你的資產,而是控制資產的憑據。

在技術性的區塊鏈文章裡面,私鑰、公鑰、地址都是有特定的符號表示的,如下圖:

  • k,私鑰,通常是隨機產生,需要絕對保密的
  • K,公鑰,由私鑰通過橢圓曲線乘法運算而來,不可倒推出私鑰,可以不保密
  • A,地址,由公鑰通過單向哈希運算而來,不可倒推出公鑰,是完全公開的

什麼是確定性錢包?

比特幣早期的錢包客戶端 Satoshi Client 裡面會自動隨機生成 100 個私鑰、公鑰對,這些私鑰之間完全沒有關聯,這種錢包也叫做隨機錢包(Random Wallet)或者非確定性錢包(Non-Deterministic Wallet),錢包的備份和恢復必須針對每個私鑰進行。

如果能隨機產生一個種子,然後根據這個種子去生成一系列的私鑰、公鑰對,這樣錢包的備份就會容易很多,因為只需要備份隨機的種子就行了,這種根據隨機種子按確定規則生成一系列錢包的方式就叫做種子錢包(Seeded Wallet)或確定性錢包(Deterministic Wallet),種子錢包在生成多個私鑰時會用到序號作為參數,所以這種錢包也叫線性確定性錢包(Sequential Deterministic Wallet)。

種子錢包解決了備份的問題,但是還是不完美,沒有辦法把錢包的一部分共享出去給別人管理,但同時自己保有知情權、控制權。社區的智慧是無窮的,分層確定性錢包應運而生:

因為生成的錢包結構是有層次的,所以就被叫做 Hierarchical Deterministic Wallet:

  • 樹狀的錢包結構可以讓錢包的組織方式更加靈活,或者賦予其現實世界的意義,比如可以用單個 HD 錢包來管理組織的所有資產;
  • 每個節點都會有私鑰、公鑰,也可以派生出更多的子節點;
  • 樹狀結構中的某個分支及其子樹可以根據實際需要共享出去;
  • 備份和恢復只需要關心主節點;

分層確定性錢包的設計和實現

現如今 HD 錢包儼然已經成為事實上的行業標準,知道 HD 錢包的含義之後,我們來看看他的設計和實現。

HD 錢包的想法最早出現在比特幣社區,而比特幣社區裡面提出新功能、流程、改進建議都有標準化的流程,發起者需要用文檔的形式把內容書面化,提交給社區去討論、論證,這種文檔就叫做 BIP(Bitcoin Improvement Proposal),比特幣社區甚至連 BIP 本身該如何工作也寫成了 BIP,定義 BIP 格式、工作流的的元 BIP 見這裡,而和 HD 錢包緊密關聯的幾個 BIP 如下:

  • BIP32: HD 錢包的核心提案,說明了自私鑰生成方法以及樹壯結構的構造方式;
  • BIP43: 為 HD 錢包子私鑰派生路徑增加有廣泛共識的段;
  • BIP44: 確定支持多鏈 HD 錢包子私鑰派生路徑的標準格式;

我們先來看 BIP32,其中定義瞭如下兩個內容:

  • 根據父節點公(私)鑰匙派生子節點公(私)鑰的算法;
  • 將派生出來的鑰匙對組織成樹狀結構的方法;

在 BIP32 中根據父節點去派生子節點的方法被稱作 Child Key Derivation Function,簡稱為 CKD,CKD 根據如下 3 個參數去生成子節點:

  • 父節點私鑰或者公鑰(Parent Private/Public Key)
  • 父節點鏈碼(Parent Chain Code)
  • 子節點序號(Child Index)

為了保證生成過程不可逆,CKD 會用到單向哈希函數 HMAC-SHA512,整個 HD Wallet 樹裡面的任何節點都可以有私鑰、公鑰,都具有如下的性質:

  • 各節點的私鑰和隨機生成的私鑰並沒有明顯的區分
  • 節點私鑰可以用來推導出節點公鑰,進而推導出賬戶地址
  • 節點私鑰可以用來簽名交易
  • 至於節點之間的父子、兄弟關係在 HD 錢包之外完全是無感的

如何生成子私鑰?

根據父節點私鑰(Parent Private Key)生成子節點私鑰(Child Private Key)的流程如下圖:

  1. 根據父節點私鑰和橢圓曲線乘法推導出父節點公鑰(Parent Public Key);
  2. 把父節點公鑰、父節點鏈碼、子節點序號作為參數求 HMAC-SHA512 得到 512 位輸出;
  3. 把步驟 2 的輸出拆分為兩個等長的 256 位串,分別標記為 L、R;
  4. 把步驟 3 的輸出 L 和父節點私鑰做運算得到子節點私鑰(Child Private Key);
  5. 把步驟 3 的輸出 R 當做子節點鏈碼(Child Chain Code);

子節點私鑰、子節點鏈碼可以作為輸入傳給 CKD,就可以生成孫節點,以及任意深度的節點。子私鑰生成函數在 BIP32 中被標記為:

如何生成子公鑰?

根據父節點公鑰生成子節點公鑰的流程如下圖:

  1. 把父節點公鑰、父節點鏈碼、子節點序號作為參數求 HMAC-SHA512 得到 512 位輸出;
  2. 把步驟 1 的輸出拆分為兩個等長的 256 位串,分別標記為 L、R;
  3. 把步驟 2 的輸出 L 和父節點公鑰做運算得到子節點公鑰(Child Public Key);
  4. 把步驟 2 的輸出 R 當做子節點鏈碼(Child Chain Code);

子私鑰生成函數在 BIP32 中被標記為:

子節點私鑰和子節點公鑰的生成過程如果用 JS 代碼實現,核心邏輯如下:

javascript
HDKey.prototype.deriveChild = function(index) {
  var indexBuffer = Buffer.allocUnsafe(4);
  indexBuffer.writeUInt32BE(index, 0);

  var data = Buffer.concat([this.publicKey, indexBuffer]);

  var I = crypto
    .createHmac("sha512", this.chainCode)
    .update(data)
    .digest();
  var IL = I.slice(0, 32);
  var IR = I.slice(32);

  var hd = new HDKey(this.versions);

  if (this.privateKey) {
    hd.privateKey = secp256k1.privateKeyTweakAdd(this.privateKey, IL);
  } else {
    hd.publicKey = secp256k1.publicKeyTweakAdd(this.publicKey, IL, true);
  }

  hd.chainCode = IR;
  hd.index = index;

  return hd;
};

如果你讀到這裡可能心裡已經產生 N 多疑問:Chain Code 到底是什麼東西?引入它有什麼好處?每個父節點到底能生成多少個子節點呢?既然確定性錢包是從種子開始的,上面只是提到了從父節點開始生成子節點,怎麼和種子關聯上?請繼續往下讀。

為什麼要有 Chain Code?

錢包安全的核心在私鑰,而公鑰則比較容易被找到,如果子節點生成過程只依賴父節點公鑰和子節點序號,那麼黑客拿到父節點公鑰之後就能復原出所有子節點、孫節點的公鑰,這樣就會破壞隱私性,CKD 裡面引入的 Chain Code 則是在整個子節點派生過程中引入確定的隨機數,為 HD 錢包的隱私性增加了一重保障。

什麼是 Extended Key?

因為在子節點生成過程中會同時用到父節點公鑰和父節點鏈碼,BIP32 裡面約定把兩者拼接再做特定結構編碼產生的結果叫做 Extended Key,也叫做可擴展的鑰匙,顧名思義就是根據 Extended Key 我們就可以開始派生子節點。父節點公鑰、私鑰和鏈碼結合產生的 Extended Key 分別是:

  • Extended Private Key = Private Key + Chain Code, 標記為 xpriv,可用於派生子節點私鑰和公鑰
  • Extended Public Key = Public Key + Chain Code, 標記為 xpub,只能用於派生出子節點公鑰

因為從 Extended Key 可以解出父節點私鑰、公鑰和鏈碼,可以說 Extended Key 代表了 HD 錢包中某個分支、子樹的根或者起點,也正是因為這種特性,對 Extended Key 的數據保密要格外小心。

什麼是 Master Key?

定義清楚 CKD 之後我們該從哪裡開始生成節點呢?必須得有個主節點,主節點的生成有兩種可能的方案:

  • 隨機生成 512 位的隨機數開始,拆分為兩個 256 位的數字,分別作為主節點私鑰和主節點鏈碼,而後遞歸的生成子節點,雖然這種方式生成的隨機數有 2^512 個,但是在生成主節點私鑰的時候只用到了 256 位,實際上主節點私鑰的可能取值就縮小到 2^256 個;
  • 隨機的生成特定位數的隨機數,位數越大越好,然後將該隨機數進行 HMAC-SHA256 計算,得到 512 位的哈希,將其拆分為主節點私鑰和鏈碼,根據單項哈希函數的性質,只要隨機數種子不同,得到的哈希值也會不同,私鑰也會不同,這樣生成 HD 錢包主節點的私鑰就可以有更大的值域空間、更好的隨機性;

第 2 中方案的流程可以用下圖來表示(其中主節點私鑰在 BIP32 中被稱為 Master Private Key,主節點鏈碼被稱為 Master Chain Code):

安全增強的 CKD 函數

因為區塊鏈錢包裡面保存的私鑰能轉移用戶的資產,對安全性再怎麼強調都不為過,對於上面的子節點私鑰和公鑰生成函數是否足夠安全呢?我們設想下面的場景:

  • 如果黑客知道了父節點的公鑰和鏈碼,那麼他可以生成所有子節點、孫節點的公鑰、地址,這樣會嚴重破壞 HD 錢包的隱私性;
  • 如果黑客在上面的基礎上知道了某個孫節點的私鑰,那麼所有重孫節點的私鑰都能被推導出來,父節點的私鑰也可能被推導出來,這樣整個 HD 錢包就淪陷了;

如果安全問題是沒有辦法徹底避免的,如何在某個子節點私鑰洩露的時候把破壞性降到最低呢?這就需要對 CKD 函數稍作改進,在 BIP32 中稱之為 安全增強的子私鑰派生函數,記為 HCKD(Hardened Child Key Derivation),原來的 CKD 函數(Normal Child Key Derivation)和安全增強的 CKD 函數流程對比如下圖:

安全增強的 CKD 函數產生的節點屬性稱呼也響應的發生變化:

  • 增強的子節點私鑰:Hardened Child Private Key
  • 增強的子節點公鑰:Hardened Child Public Key,只能根據增強的子節點私鑰推導而來

不同點在於,安全增強的 CKD 函數中子節點私鑰的生成不再使用父節點公鑰,而是直接使用父節點私鑰,因為相比私鑰而言公鑰更容易被黑客截獲,這樣必須在有父節點私鑰的情況下才能推導出子節點私鑰,只靠父節點的公鑰和鏈碼不能推導出增強的子節點公鑰。這樣子節點之間的兄弟關係就不那麼容易被獲悉,而即使某個增強子節點私鑰洩露,也不會影響到父節點。

BIP32 約定 CKD 函數的節點序號取值範圍在 0 ~ 2^31 之間,而 HCKD 的節點序號在 2^31 ~ 2^32 之間,這樣每個節點就可以生成 2^32 個子節點。

節點派生路徑標記

理論上 HD 錢包中任何節點的生成都會有路徑,因為我們能找到從主節點到該節點的不同深度各節點的序號,這樣我們就可以用統一的路徑符號來標記每個節點,在做節點派生的時候也只需要聲明路徑即可。舉幾個常見派生過程和對應的派生路徑:

  • CKDpriv(CKDpriv(CKDpriv(m,3),2),5) => m/3/2/5
  • CKDpriv(CKDpriv(CKDpriv(m,3H),2H),5H) => m/3'/2'/5'
  • CKDpub(CKDpub(CKDpub(m,0),0),0) => M/0/0/0

其中 m 表示私鑰,而 M 表示公鑰。m/3/2/5 表示從主節點派生出來的第 4 個子節點的第 3 個孫節點的第 6 個重孫節點,派生過程中使用的是 CKD 函數,而 m/3'/2'/5' 則表示派生過程中使用的是 HCKD 函數。

知道每個節點的派生路徑之後,通過合併路徑相同前綴的方法,不難得到如下的樹狀 HD 錢包節點結構圖:

為什麼需要 BIP44?

顯然,BIP32 的在錢包安全性、易用性方面做了比較不錯的平衡,但是不同的錢包應用開發者可以自定義自己的節點結構,這就很容易導致沒有辦法 100% 保證在使用了 HD 錢包 A 的用戶能將自己的種子導入到 HD 錢包 B 中還能正常工作;也沒有辦法保證 HD 錢包能支持多個鏈的私鑰管理。

因為這個原因,比特幣社區在 BIP32 的基礎上提出了比較範的 BIP43 和比較具體的 BIP44,兩者的目的在於就 HD 錢包子節點派生路徑的模式、每段的含義上做出具體的規定,形成共識,事實上現如今的 HD 錢包都遵循了 BIP32 和 BIP44 的規定,也只有遵循了這兩個規範的錢包應用才是大概率完全兼容的。

春秋戰國時期的中國不同小國的文字、馬車輪距不同導致了較高的社會交易成本,秦始皇統一六國之後實施了“車同軌、書同文”的政策,BIP44 之於 BIP32 的作用和 “車同軌、書同文” 政策的效果非常類似,也正是他倆的結合才讓 BIP 錢包成了事實上的行業標準。

BIP44 的內容相比 BIP32 就簡單很多,裡面規定了子節點派生路徑的範式:

text
m / purpose' / coin_type' / account' / chain / address_index

示例如下:

text
m/44'/60'/0'/0/0

每個段的含義分別是:

  • CKD: m: 使用 CKDpriv, M 則表示使用 CKDPub
  • Purpose: 44' , hardened, 遵循哪個規範, 44 意味著 BIP44
  • Coin: 60', hardened, 60 指代以太坊, 完整的鏈代碼
  • Account: 0' , hardened, 賬戶編號
  • Chain: 0 , 對於非比特幣路徑都是 0
  • Index: 0, 具體的賬戶節點

如何讓錢包更加用戶友好?

講到這裡,HD 錢包的基本原理已經都理清了,但是開篇提到的助記詞是咋回事兒呢?

互聯網發展了 20 多年,所有的互聯網用戶都熟悉了賬戶要輸入密碼的同時,生成、設置、記住密碼對人來來說卻變的很難,因為為了安全需要設置很複雜的密碼,但是複雜的密碼卻不是那麼容易記住。區塊鏈錢包管理的私鑰可以認為是隨機生成的密碼,有沒有辦法讓這個密碼變的更加用戶友好呢?

BIP39 提出的助記詞機制就很好的解決了這個問題,讓錢包私鑰(對 HD 錢包來說就是種子)在安全性方面不打折扣,但是更容易識記。BIP39 主要描述了兩個過程:

  • 根據隨機數生成助記詞的流程
  • 根據助記詞推導 HD 錢包種子的流程

從隨機數到助記詞

有個廣泛流傳的誤解說助記詞是隨機生成的,實際上助記詞並不是隨機生成的,而是隨機生成的種子的一種呈現方式。助記詞究竟是怎麼生成的呢?整個過程如下圖:

  1. 生成 128 位的隨機數,這個隨機數在 BIP29 中叫做熵(Entropy,簡寫為 ENT);
  2. 對隨機數做 SHA256,取前 4 位為校驗碼(Checksum);
  3. 把步驟 1、2 中的結果拼接得到 132 位的結果,然後分割成 12 個長度為 11 位的串;
  4. 將 12 個串轉化為十進制數字去此表中查找對應的單詞;
  5. 把查找到的單詞按順序拼接起來構成助記詞;

整個過程的代碼實現如下:

javascript
function generateMnemonic(strength, rng, wordlist) {
  strength = strength || 128;
  if (strength % 32 !== 0) throw new TypeError(INVALID_ENTROPY);
  rng = rng || randomBytes;

  return entropyToMnemonic(rng(strength / 8), wordlist);
}
function entropyToMnemonic(entropy, wordlist) {
  if (!Buffer.isBuffer(entropy)) entropy = Buffer.from(entropy, "hex");
  wordlist = wordlist || DEFAULT_WORDLIST;

  var entropyBits = bytesToBinary([].slice.call(entropy));
  var checksumBits = deriveChecksumBits(entropy);

  var bits = entropyBits + checksumBits;
  var chunks = bits.match(/(.{1,11})/g);
  var words = chunks.map(function(binary) {
    var index = binaryToByte(binary);
    return wordlist[index];
  });

  return wordlist === JAPANESE_WORDLIST
    ? words.join("\u3000")
    : words.join(" ");
}

在助記詞生成過程中,不同長度的隨機數所需要的校驗碼不同,最後產生的助記詞長度不同,如下表:

EntropyChecksumEntropy + ChecksumMnemonic Length
128413212
160516515
192619818
224723121
256826424

BIP39 中目前支持多種語言的此表,每個詞表的長度是 2^11 = 2048。

可能有同學會問,表面看起來助記詞就是隨機排列的 12 詞語,應該很容易暴力破解?下面我們以 12 長度的助記詞為例分析下暴力破解的難度:

  • 可能的助記詞數量 = 2048!/(2048 - 12)! = 5.27e+39
  • 每秒嘗試 10000 次,每年能嘗試的數量為 10000 * 60 * 60 * 24 * 364 = 3.15*e+11
  • 需要 1.67e+28 年才能窮舉所有的助記詞

還不用說助記詞的長度是可變的,每個助記詞下面的錢包數量也是近乎無限的。暴力破解的難度不用多說。有了助記詞之後,怎麼生成 HD 錢包呢,HD 錢包的關鍵是種子,只要從助記詞恢復出種子即可,整個過程如下圖:

JS 代碼實現如下:

javascript
function mnemonicToSeed(mnemonic, password) {
  var mnemonicBuffer = Buffer.from(unorm.nfkd(mnemonic), "utf8");
  var saltBuffer = Buffer.from(salt(unorm.nfkd(password)), "utf8");

  return pbkdf2(mnemonicBuffer, saltBuffer, 2048, 64, "sha512");
}

可以看到,從助記詞到種子的過程中,加了兩個機制來增加暴力破解的難度:

  • password 機制,這樣助記詞即使洩露,密碼不對也無法拿到正確的種子,算是雙保險;
  • pbkdf2 機制,比較弱的密碼經過這個環節隨機性會大大增強,也正是這個運算會增加暴力破解的計算量;

從助記詞到 HD 錢包

到這裡,BIP32、BIP44、BIP39 的核心內容我們都理清楚了,從助記詞生成 HD 錢包的流程如下圖:

{width=100%}

以以太坊為例的助記詞 HD 錢包生成過程簡化如下(這裡用到了 bip39、hdkey、ethereumjs-util 等庫):

javascript
const bip39 = require("bip39");
const HDKey = require("hdkey");
const EthUtil = require("ethereumjs-util");

const mnemonic = bip39.generateMnemonic(128);
const seed = bip39.mnemonicToSeed(mnemonic, "");
const master = HDKey.fromMasterSeed(seed);

const account = master.derive("m/44'/60'/0'");
const addr = account.deriveChild(0).deriveChild(0);
const pubKey = EthUtil.privateToPublic(addr.privateKey);
const address = EthUtil.publicToAddress(pubKey).toString("hex");

錢包有了之後,用它去簽名交易、再把交易廣播出去就不在本文的討論範疇之內,感興趣的同學可以自行研究。

讀到這裡相信你對於 HD 錢包的幾個核心提案、安全隱患以及助記詞的基本原理已經有比較不錯的理解,如果還有疑問,或者想了解更多細節,可以去仔細研讀下面這些資料。

One More Thing

區塊鏈技術的出現絕非偶然,長遠來看對這個世界的改變也會超乎你我的想象,如果你對區塊鏈行業感興趣,看好區塊鏈方向,或者欣賞我們的做事方式,我們還在大量招前端、後端工程師,北京和西雅圖都要,歡迎來撩,職位介紹見這裡,當然,你也可以直接把簡歷給我(微信:feweekly)。

本頁涉及

產品

  • DID Wallet renamed

    在一個應用程式裡建立和管理數位身分(DID),並管理相關憑證。

術語

  • Wallet

    持有方這一側存放金鑰、並且越來越多地也存放憑證的那個東西。用「錢包」來命名是歷史造成的:登入和出示憑證用的是和付款同一套金鑰。