以太坊 钱包keystore
Geth 在创建账号时会生成一个对应 keystore JSON 文件,Keystore 文件存储加密后的私钥信息,因此我们需要做的就是导入这个 Keystore 文件,这个文件通常在同步区块数据的目录下的 keystore 文件夹。
在 ethers.js 中,简单的使用一个函数就可以完成 keystore 文件的导入,不过理解 Keystore 文件的作用及原理还是非常有必要的。
1. Keystore 文件
1.1 为什么需要 Keystore 文件
通过这篇文章理解开发HD 钱包涉及的 BIP32、BIP44、BIP39,私钥其实就代表了一个账号,最简单的保管账号的方式就是直接把私钥保存起来,如果私钥文件被人盗取,我们的数字资产将洗劫一空。
Keystore 文件就是一种以加密的方式存储密钥的文件,这样的发起交易的时候,先从 Keystore 文件是使用密码解密出私钥,然后进行签名交易。这样做之后就会安全的多,因为只有黑客同时盗取 keystore 文件和密码才能盗取我们的数字资产。
1.2 Keystore 文件如何生成的
以太坊是使用对称加密算法来加密私钥生成Keystore文件,因此对称加密秘钥(注意它其实也是发起交易时需要的解密秘钥)的选择就非常关键,这个秘钥是使用KDF算法推导派生而出。因此在完整介绍Keystore 文件如何生成前,有必要先介绍一下KDF。
使用 KDF 生成秘钥
密码学 KDF(key derivation functions),即密钥派生函数,它的作用是通过一个密码派生出一个或多个秘钥,即从 password 生成加密使用的 key。
其实,在开发HD 钱包涉及的 BIP32、BIP44、BIP39中介绍助记词推导出种子的 PBKDF2 算法就是一种 KDF 函数,其原理是加盐以及增加哈希迭代次数。
而在 Keystore 中,是用的是 Scrypt 算法,用一个公式来表示的话,派生的 Key 生成方程为:
DK = Scrypt(salt, dk_len, n, r, p)
其中的 salt 是一段随机的盐,dk_len 是输出的哈希值的长度。n 是 CPU/Memory 开销值,越高的开销值,计算就越困难。r 表示块大小,p 表示并行度。
实际使用中,还会加上一个密码进行计算,用一张图来表示这个过程就是:

对私钥进行对称加密
上面已经用 KDF 算法生成了一个秘钥,这个秘钥就是接着进行对称加密的秘钥,这里使用的对称加密算法是 aes-128-ctr,aes-128-ctr 加密算法还需要用到一个参数初始化向量 iv。
1.4 Keystore文件
我们现在结合具体 Keystore文件的内容,就很容易理解了Keystore 文件怎么产生的了。
{
"address":"856e604698f79cef417aab...",
"crypto":{
"cipher":"aes-128-ctr",
"ciphertext":"13a3ad2135bef1ff228e399dfc8d7757eb4bb1a81d1b31....",
"cipherparams":{
"iv":"92e7468e8625653f85322fb3c..."
},
"kdf":"scrypt",
"kdfparams":{
"dklen":32,
"n":262144,
"p":1,
"r":8,
"salt":"3ca198ce53513ce01bd651aee54b16b6a...."
},
"mac":"10423d837830594c18a91097d09b7f2316..."
},
"id":"5346bac5-0a6f-4ac6-baba-e2f3ad464f3f",
"version":3
}
来解读一下各个字段:
- address: 账号地址
- version: Keystore文件的版本,目前为第3版,也称为V3 KeyStore。
- id : uuid
- crypto: 加密推倒的相关配置.
- cipher 是用于加密以太坊私钥的对称加密算法。用的是 aes-128-ctr 。
- cipherparams 是 aes-128-ctr 加密算法需要的参数。在这里,用到的唯一的参数 iv。
- ciphertext 是加密算法输出的密文,也是将来解密时的需要的输入。
- kdf: 指定使用哪一个算法,这里使用的是 scrypt。
- kdfparams: scrypt函数需要的参数
- mac: 用来校验密码的正确性, mac= sha3(DK[16:32], ciphertext) 下面一个小节单独分析。
我们来完整梳理一下 Keystore 文件的产生:
- 使用 scrypt 算法 ,根据密码和参数生成秘钥
- 使用对称加密算法 aes 对秘钥 + 账号私钥 + 参数 进行加密
- 把相关参数 和 输出的密文 保存为特定格式的 JSON 文件
1.5 如何确保密码是对的?
当我们在使用Keystore文件来还原私钥时,依然是使用 kdf 生成一个秘钥,然后用秘钥对 ciphertext 进行解密,其过程如下:

此时细心的同学会发现,无论使用说明密码,来进行这个操作,都会生成一个私钥,但是最终计算的以太坊私钥到底是不是正确的,却不得而知。
这就是 keystore 文件中 mac 值的作用。mac 值是 kdf输出 和 ciphertext 密文进行SHA3-256运算的结果,显然密码不同,计算的mac 值也不同,因此可以用来检验密码的正确性。检验过程用图表示如下:

现在我们以解密的角度完整的梳理下流程,就可以得到以下图:

2. 用ethers.js 实现账号导出导入
ethers.js 直接提供了加载keystore JSON来创建钱包对象以及加密生成keystore文件的方法,方法如下:
// 导入keystore Json
ethers.Wallet.fromEncryptedJson(json, password, [progressCallback]).then(function(wallet) {
// wallet
});
// 使用钱包对象 导出keystore Json
wallet.encrypt(pwd, [progressCallback].then(function(json) {
// 保存json
});
现在结合界面来完整的实现账号导出及导入,先看看导出,UI图如下:

HTML 代码如下:
<h3>KeyStore 导出:</h3>
<table>
<tr>
<th>密码:</th>
<td><input type="text" placeholder="(password)" id="save-keystore-file-pwd" /></td>
</tr>
<tr>
<td> </td>
<td>
<div id="save-keystore" class="submit">导出</div>
</td>
</tr>
</table>
上面主要定义了一个密码输入框和一个导出按钮,点击“导出”后,处理逻辑代码如下:
// "导出" 按钮,执行exportKeystore函数
$('#save-keystore').click(exportKeystore);
exportKeystore: function() {
// 获取密码
var pwd = $('#save-keystore-file-pwd');
// wallet 是上一篇文章中生成的钱包对象
wallet.encrypt(pwd.val()).then(function(json) {
var blob = new Blob([json], {type: "text/plain;charset=utf-8"});
// 使用了FileSaver.js 进行文件保存
saveAs(blob, "keystore.json");
});
}
FileSaver.js 是可以用来在页面保存文件的一个库。
再来看看导入keystore 文件, UI图如下:

<h2>加载账号Keystore文件</h2>
<table>
<tr>
<th>Keystore:</th>
<td><div class="file" id="select-wallet-drop">把Json文件拖动到这里</div><input type="file" id="select-wallet-file" /></td>
</tr>
<tr>
<th>密码:</th>
<td><input type="password" placeholder="(password)" id="select-wallet-password" /></td>
</tr>
<tr>
<td> </td>
<td>
<div id="select-submit-wallet" class="submit disable">解密</div>
</td>
</tr>
</table>
上面主要定义了一个文件输入框、一个密码输入框及一个“解密“按钮,因此处理逻辑包含两部分,一是读取文件,二是解析加载账号,关键代码如下:
// 使用FileReader读取文件,
var fileReader = new FileReader();
fileReader.onload = function(e) {
var json = e.target.result;
// 从加载
ethers.Wallet.fromEncryptedJson(json, password).then(function(wallet) {
}, function(error) {
});
};
fileReader.readAsText(inputFile.files[0]);
3. 生成keystore 数据 go语言源码
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/ethereum/go-ethereum/crypto"
"golang.org/x/crypto/scrypt"
"io"
"math/big"
)
const (
keyHeaderKDF = "scrypt"
StandardScryptN = 1 << 18
// StandardScryptP is the P parameter of Scrypt encryption algorithm, using 256MB
// memory and taking approximately 1s CPU time on a modern processor.
StandardScryptP = 1
// LightScryptN is the N parameter of Scrypt encryption algorithm, using 4MB
// memory and taking approximately 100ms CPU time on a modern processor.
LightScryptN = 1 << 12
// LightScryptP is the P parameter of Scrypt encryption algorithm, using 4MB
// memory and taking approximately 100ms CPU time on a modern processor.
LightScryptP = 6
scryptR = 8
scryptDKLen = 32
)
type cipherparamsJSON struct {
IV string `json:"iv"`
}
type CryptoJSON struct {
Cipher string `json:"cipher"`
CipherText string `json:"ciphertext"`
CipherParams cipherparamsJSON `json:"cipherparams"`
KDF string `json:"kdf"`
KDFParams map[string]interface{} `json:"kdfparams"`
MAC string `json:"mac"`
}
type encryptedKeyJSONV3 struct {
Address string `json:"address"`
Crypto CryptoJSON `json:"crypto"`
Id string `json:"id"`
Version int `json:"version"`
}
type Key struct {
Id string // Version 4 "random" for unique id not derived from key data
// to simplify lookups we also store the address
Address []byte
// we only store privkey as pubkey/address can be derived from it
// privkey in this struct is always in plaintext
PrivateKey *ecdsa.PrivateKey
}
func EncryptDataV3(data, auth []byte, scryptN, scryptP int) (CryptoJSON, error) {
salt := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
panic("reading from crypto/rand failed: " + err.Error())
}
derivedKey, err := scrypt.Key(auth, salt, scryptN, scryptR, scryptP, scryptDKLen)
if err != nil {
return CryptoJSON{}, err
}
encryptKey := derivedKey[:16]
iv := make([]byte, aes.BlockSize) // 16
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
panic("reading from crypto/rand failed: " + err.Error())
}
cipherText, err := aesCTRXOR(encryptKey, data, iv)
if err != nil {
return CryptoJSON{}, err
}
mac := crypto.Keccak256(derivedKey[16:32], cipherText)
scryptParamsJSON := make(map[string]interface{}, 5)
scryptParamsJSON["n"] = scryptN
scryptParamsJSON["r"] = scryptR
scryptParamsJSON["p"] = scryptP
scryptParamsJSON["dklen"] = scryptDKLen
scryptParamsJSON["salt"] = hex.EncodeToString(salt)
cipherParamsJSON := cipherparamsJSON{
IV: hex.EncodeToString(iv),
}
cryptoStruct := CryptoJSON{
Cipher: "aes-128-ctr",
CipherText: hex.EncodeToString(cipherText),
CipherParams: cipherParamsJSON,
KDF: keyHeaderKDF,
KDFParams: scryptParamsJSON,
MAC: hex.EncodeToString(mac),
}
return cryptoStruct, nil
}
func EncryptKey(key *Key, auth string, scryptN, scryptP int) ([]byte, error) {
keyBytes := key.PrivateKey.D.Bytes()
cryptoStruct, err := EncryptDataV3(keyBytes, []byte(auth), scryptN, scryptP)
if err != nil {
return nil, err
}
encryptedKeyJSONV3 := encryptedKeyJSONV3{
hex.EncodeToString(key.Address[:]),
cryptoStruct,
key.Id,
3,
}
return json.Marshal(encryptedKeyJSONV3)
}
func aesCTRXOR(key, inText, iv []byte) ([]byte, error) {
// AES-128 is selected due to size of encryptKey.
aesBlock, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
stream := cipher.NewCTR(aesBlock, iv)
outText := make([]byte, len(inText))
stream.XORKeyStream(outText, inText)
return outText, err
}
func main() {
key := &Key{
Id: "1",
Address: []byte("address1234567890123"),
PrivateKey: &ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{
X: big.NewInt(1),
Y: big.NewInt(2),
},
D: big.NewInt(3),
},
}
content, err := EncryptKey(key, "password", StandardScryptN, StandardScryptP)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(content))
}
下一章:Go 内嵌静态资源 go-bindata 的安装及使用
Go内嵌静态资源 go-bindata 的安装及使用:我们在开发应用的时候,有时会遇到需要使用静态资源文件的情况。对于Go语言来讲,编码,编译,生成一个单一的可执行文件,感觉特别干净清爽。但是有了静态资源文件,还得一同发布相应的文件,也给程 ...
AI 中文社