ユーザーごとの入庫用アドレスを生成する
単純に作るだけなら
ユーザー毎のウォレットアドレスを単純に作るだけなら、ethersでも使って以下のようにすればいいでしょう。
import { Wallet } from 'ethers';
const wallet = Wallet.createRandom();
しかし、これだと管理が面倒です。もしDBレコードの作成などに失敗したらそのアドレスは永久に失われてしまいます。
同じindexから常に同じアドレスが生成されるとかだといいですね。
決定的アドレス生成
ランダムな生成では、確実に記録しておかないとすぐに値が散逸してしまいます。
同じ入力からいつでも同じように(決定的に)アドレスを生成できるのが理想です。
実は、たった1文のニーモニック(mnemonic)フレーズさえあれば、それを元に決定的にアドレスを生成できます。
これも生成するだけなら簡単で、viemでもethersでも少しのコードで実現できます。
import { mnemonicToAccount } from 'viem/accounts';
import { HDNodeWallet } from 'ethers';
// NOTE: 運用環境でプロセスに平文でニーモニックを展開するのは非推奨
const mnemonic = process.env.MNEMONIC!;
const n = 100;
for (let i = 0; i < n; i++) {
const viem = mnemonicToAccount(mnemonic, { accountIndex: i }).address;
const ethers = HDNodeWallet.fromPhrase(mnemonic, undefined, `m/44'/60'/${i}'/0/0`).address;
}
ただ単にユーザーごとのアドレスを用意するだけならこれで十分ですが、成り立ちを理解しておくことが大事です。
関連するBIP
上記の決定的アドレス生成に関して、関連するBIPをいくつか紹介します。
- BIP-32: 階層的決定性鍵
- BIP-39: ニーモニック
- BIP-44: パス構造の標準
BIPはBitcoin Improvement Proposalの略で、Bitcoinのプロポーザルを指します。
BIPはBitcoinのプロトコルの改善提案ですが、chain agnosticなものについては他のブロックチェーンでも広く利用されています。
BIP-32: 階層的決定性鍵
HDKey (階層的決定性鍵 - Hierarchical Deterministic Key) は、BIP-32で定義された鍵管理システムです。
- 単一のシードから複数の鍵を生成可能
- 公開鍵からさらに公開鍵を導出可能(ハード化されていない場合)
詳細は後述しますが、たとえば先程のコードで指定していた m/44'/60'/${i}'/0/0
のような階層的なパスでアドレスを管理できます。
シードは一般的に「Mnemonic(ニーモニック、BIP-39)」から生成されます。
BIP-39: ニーモニック
ニーモニックはBIP-39で定義されており、具体的には以下のような英単語の羅列です。
mansion board shell peasant slush arrive insane organ power horse code glide
これは完全に出鱈目な英単語の羅列というわけではなく、前提として特定のビット表現に対応した単語リストが定められています。
その単語リストから規定の個数をランダムピックして所定のビット長に変換できるようにしてあるものをニーモニックフレーズと呼んでいます。
viemで生成する例を挙げておきます。
import { english, generateMnemonic } from 'viem/accounts';
const mnemonic = generateMnemonic(english);
BIP-44: パス構造の標準
BIP-44は、BIP-32のパス構造の標準を定めています。
We define the following 5 levels in BIP32 path:
m / purpose' / coin_type' / account' / change / address_index
たとえば、Ethereumだとm/44'/60'/0'
です。
0
と0'
の違い
パスの数字末尾に'
がつくとハード化を意味します(ちなみに別領域から輸入したなどの由来は特にない記法のようです)。
ハード化すると親の公開鍵から子の公開鍵を派生できません。子の生成に秘密鍵を使用するためです。
ハード化する場合、つまり生成の際に親の秘密鍵を必要とする場合は、index指定を2^31以上の範囲にすることが定められています。
これを簡素に記述するため、x' = 0x80000000 + x
という記法を用います。
ハード化しない場合の危険性
ハード化しない場合、子の秘密鍵が漏洩すると親の秘密鍵まで漏洩する危険があります。
その場合、その親の配下の秘密鍵まで芋づる式にすべて流出します。
具体的には以下のセットが流出した場合が危険です。
- 分岐の根(親)となる拡張公開鍵:
X_{\text{parent}} = (K_{\text{parent}},\,c_{\text{parent}}) - 分岐の葉(子)のうち、いずれかの秘密鍵:
k_{\text{child}}
これは、ハード化されていない子の秘密鍵
上記の式を見ていくと、流出した値から親の秘密鍵を逆算できることがわかります。
-
は流出したIL から計算できるX_{\text{parent}} -
はそれ自体が流出しているk_{\text{child}}
したがって、以下の式で親の秘密鍵を求めることができ、芋づる式に他の子の秘密鍵もすべて手に入ります。
その代わり、公開アドレスの生成には一切の秘密鍵が必要ありません。
つまりサーバーサイドに親の公開鍵のみをおいて子のアドレスを逐次生成することができます。
ここに一定のトレードオフがあります。
- ハード化しない場合
- 入庫のみできればよく、資産操作しないのであれば、アドレス生成でも秘密鍵を一切扱う必要がない
- アドレス逐次生成に向くが、子の秘密鍵を万が一にでも流出させるとリスクがかなり大きい
- ハード化する場合
- アドレス生成時に親の秘密鍵が必要なため、基本的にはコールド環境で大量に事前生成する必要がある
- 子の秘密鍵が漏洩してもそのアドレス以下のみの被害にとどめられる
Bitcoin以外の通貨
SLIP-44(Simple Ledger Improvement Proposal)でBitcoin以外の通過についても番号が定義されています。
Ethereumアドレスへの変換
BIPはあくまでBitcoin用のアドレス生成を定義しているため、Ethereumでは、BIPで定義された鍵からアドレスを導出するための独自の処理が加わります。
前提として、BitcoinでもEthereumでもsecp256k1の楕円曲線上の座標が公開鍵に相当します。
まず、楕円曲線上での座標表現について圧縮形式と非圧縮形式があることを押さえましょう。
Bitcoinのアドレスは圧縮形式がベースであり、一方でEthereumのアドレスは非圧縮形式を所定の方法でハッシュ化したものです。
非圧縮形式
secp256k1はその名の通り256ビットのため、座標は256ビット(=32バイト)です。
prefix04
に座標(x, y)を32バイトずつ、そのまま繋げた形式を非圧縮形式と呼んでいます。
圧縮形式
prefix02
あるいは03
にxのみを繋げた形式を圧縮形式と呼んでいます。
x座標が決まると楕円曲線上のy座標は2通りに定まるため、yの偶奇で02
か03
かを決定します。
あとは楕円曲線の式からyを求めれば座標(≒非圧縮形式)に戻せます。
BIPとsecp256k1のライブラリを用いて実際に生成してみた例が以下になります。
import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { HDKey } from '@scure/bip32';
import { Point, recoverPublicKey, sign } from 'noble-secp256k1';
const mnemonic = generateMnemonic(wordlist, 128);
const seed = mnemonicToSeedSync(mnemonic);
const root = HDKey.fromMasterSeed(seed);
const compressed = root.publicKey; // 圧縮形式
const uncompressed = Point.fromHex(publicKey).toRawBytes(false); // 非圧縮形式
const xPub = root.publicExtendedKey; // 拡張公開鍵(HDのためのchain codeを含む形式)
Ethereumのアドレス形式
非圧縮形式のprefix04
を除いた64バイトをkeccak256にかけて、下位20バイトを抜き出すとEthereumアドレスになります。
つまり、より小さい空間への射となるため非可逆圧縮(衝突があり得る)ですが、十分にsparseであり安全性は問題ないとされています。
こちらもあえて自前で再現してみましょう。
const prefixRemoved = uncompressed.slice(1); // 04を除く
// keccak256でハッシュ化
const hash = keccak_256(prefixRemoved);
// 下位20バイトを抜き出し先頭に0xを付与してEthereumアドレスに変換
const address = '0x' + Buffer.from(hash.slice(-20)).toString('hex');
ERC-55: Mixed-case checksum address encoding
アドレスのチェックサムも実施する場合はEIP-55に従って大文字・小文字の変換を行う下記のような関数を通します。
function toChecksumAddress(address: `0x${string}`): `0x${string}` {
const original = address.toLowerCase().replace(/^0x/i, '');
const hash = keccak_256(new TextEncoder().encode(original));
const criteria = Buffer.from(hash).toString('hex');
let result = '0x';
for (let i = 0; i < original.length; i++) {
if (parseInt(criteria[i]!, 16) >= 8) {
result += original[i]!.toUpperCase();
} else {
result += original[i]!;
}
}
return result;
}
上記の関数は私が簡易的に書いたものなので、実際にはethersのgetAddress
など信頼のおける実装を使うとよいでしょう。
おまけ:viemでのパス指定
ethersでは、パスを直接指定するしか方法がないですが、viemではBIP-44に則って各種indexを引数で受け取ってくれるので便利です。
// どちらも同じアドレスを生成する
mnemonicToAccount(mnemonic, {
accountIndex: 0,
changeIndex: 0,
addressIndex: 0,
});
mnemonicToAccount(mnemonic, { path: `m/44'/60'/0'/0/0` });
Discussion