背景
Bitcoin のマイニングに興味を持って調べていたところ、Proof of Work のコア計算に SHA-256 が使われていることを知りました。
SHA-256 自体をちゃんと理解したら、マイニングの仕組みも理解できて億万長者じゃん★
と思い、本記事を作成しました。
結論
SHA-256 は
- 入力を規定の形に整える
- 32bit の値をひたすら回転・XOR・加算する
- それを 64 round 回す
という、かなり脳筋アルゴリズムでした。
SHA-256 の全体像
SHA-256 の流れはざっくりこうです。
- 入力をバイト列にする
- padding を入れて、長さが 512bit の倍数になるよう整える
- 512bit ごとの
ブロックに分割する - 各ブロックを 32bit × 64 語のスケジュールに展開する
- 8 個のワーキング変数を 64 round 更新する
- 最後に 256bit の digest を出力する
以下、この 1 から 6 に対応させながら見ていきます。
1. 入力をバイト列にする
アプリケーション開発では、文字列をそのまま文字列として扱うことが多いと思います。
ただし SHA-256 が直接扱うのは String ではなく、あくまで byte の並び です。
なので最初にやることは、「文字列を UTF-8 の byte 列に変換する」です。
static Uint8List hashUtf8(String message) {
return hashBytes(utf8.encode(message));
}
たとえば "abc" は UTF-8 にすると 3 byte です。
"a" -> 0x61
"b" -> 0x62
"c" -> 0x63
ここでは「SHA-256 は文字列を見ているのではなく、最終的には byte 配列を見ている」と押さえれば十分です。
2. padding を入れて、長さが 512bit の倍数になるよう整える
SHA-256 は入力を 512bit ごとの固定長ブロックで処理します。
なので、長さが中途半端な入力でも必ずその形に揃える必要があります。
そのために入れるのが padding です。
元データ || 1 || 0...0 || 元データ長(64bit)
コードにするとこうなります。
static List<int> padMessage(List<int> messageBytes) {
final padded = List<int>.from(messageBytes);
final messageLengthInBits =
BigInt.from(messageBytes.length) * BigInt.from(8);
padded.add(0x80);
while (padded.length % 64 != 56) {
padded.add(0x00);
}
for (var shift = 56; shift >= 0; shift -= 8) {
final byteValue = (messageLengthInBits >> shift).toInt() & 0xff;
padded.add(byteValue);
}
return padded;
}
やっていることは 3 つだけです。
- 元データの後ろに
1bitだけ足す - その後ろを
0で埋める - 最後の
64bitに「元データの長さ」を入れる
ここで混乱しやすいポイント 1: 56 は bit ではなく byte
56 は 56 bit ではなく 56 byte です。
- 1 block =
64 byte = 512 bit - 最後の
8 byte = 64 bitは長さ欄に使う - だからその直前の
56 byte地点まで 0 埋めする
つまり、
56 byte + 8 byte = 64 byte
というだけです。
ここで混乱しやすいポイント 2: なぜ元データ長を入れるのか
もし長さを書かなければ、
"abc""abc" + たくさんの 0
の区別がつきにくくなります。
そこで「元のデータは何 bit だったか」を末尾に明示して、入力を一意に復元しやすい形にしています。
3. 512bit ごとのブロックに分割する
padding が終わったら、あとは 64 byte ごとに順番に処理します。
static Uint8List hashBytes(List<int> messageBytes) {
final paddedMessage = padMessage(messageBytes);
var state = List<int>.from(_initialHashValues);
for (var offset = 0; offset < paddedMessage.length; offset += 64) {
final chunk = paddedMessage.sublist(offset, offset + 64);
state = compressChunk(chunk, initialState: state);
}
return _stateToBytes(state);
}
この chunk が、SHA-256 の 1 ブロックです。
ここで大事なのは、各ブロックが独立しているわけではないことです。
前のブロックの計算結果を、次のブロックへ引き継ぎます。
つまりイメージとしては、
block 0 を処理 -> state 更新
block 1 を処理 -> さっきの state を使ってさらに更新
block 2 を処理 -> また続きを更新
というストリーム処理に近いです。
4. 各ブロックを 32bit × 64 語のスケジュールに展開する
1 ブロックは 64 byte ですが、そのまま 1 byte ずつ処理するわけではありません。
SHA-256 は基本的に 32bit 単位で計算するので、まずは 64 byte を 32bit の値へ組み替えます。
そのうえで、最終的に 64 個の値へ拡張します。
4-1. まず 4 byte を 1 つの 32bit 値にまとめる
SHA-256 は 4 byte を 1 つの 32bit 値として扱います。
int _toWord(int b0, int b1, int b2, int b3) {
return ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) & _mask32;
}
abc の先頭は padding 後にこうなります。
0x61 0x62 0x63 0x80
これを 1 つの 32bit 値としてまとめると 0x61626380 になります。
4-2. 16 個の値を 64 個に拡張する
static List<int> buildMessageSchedule(List<int> chunkBytes) {
final schedule = List<int>.filled(64, 0);
for (var index = 0; index < 16; index++) {
final base = index * 4;
schedule[index] = _toWord(
chunkBytes[base],
chunkBytes[base + 1],
chunkBytes[base + 2],
chunkBytes[base + 3],
);
}
for (var index = 16; index < 64; index++) {
final s0 = _smallSigma0(schedule[index - 15]);
final s1 = _smallSigma1(schedule[index - 2]);
schedule[index] =
(schedule[index - 16] + s0 + schedule[index - 7] + s1) & _mask32;
}
return schedule;
}
-
W[0]からW[15]は元データ -
W[16]以降は前の値から拡張
という構造です。
つまり最初の 16 個は「入力そのもの」、残り 48 個は「計算の都合で作る補助データ」です。
ここで使う sigma は、rotate と shift を使って値をうまく散らすための関数です。
5. 8 個のワーキング変数を 64 round 更新する
ここが SHA-256 の心臓部です。
-
aからhまで 8 個の作業用変数を用意する - 64 個の schedule を 1 個ずつ使う
- 毎 round ごとに値を混ぜて更新する
という流れになっています。
static List<int> compressChunk(
List<int> chunkBytes, {
List<int>? initialState,
}) {
final state = List<int>.from(initialState ?? _initialHashValues);
final schedule = buildMessageSchedule(chunkBytes);
var a = state[0];
var b = state[1];
var c = state[2];
var d = state[3];
var e = state[4];
var f = state[5];
var g = state[6];
var h = state[7];
for (var index = 0; index < 64; index++) {
final temp1 =
(h +
_bigSigma1(e) +
_choose(e, f, g) +
_roundConstants[index] +
schedule[index]) &
_mask32;
final temp2 = (_bigSigma0(a) + _majority(a, b, c)) & _mask32;
h = g;
g = f;
f = e;
e = (d + temp1) & _mask32;
d = c;
c = b;
b = a;
a = (temp1 + temp2) & _mask32;
}
state[0] = (state[0] + a) & _mask32;
state[1] = (state[1] + b) & _mask32;
state[2] = (state[2] + c) & _mask32;
state[3] = (state[3] + d) & _mask32;
state[4] = (state[4] + e) & _mask32;
state[5] = (state[5] + f) & _mask32;
state[6] = (state[6] + g) & _mask32;
state[7] = (state[7] + h) & _mask32;
return state;
}
ここでやっているのは、
- 8 個の 32bit 値を持つ
- それを 64 回更新する
- 最後に元の state に足し戻す
だけです。
本当にこれだけです。
ただし、更新ルールがよくできていて、少し入力が違うだけで出力が大きく変わるようになっています。
5-1. round の中で使う補助関数
rotate
int _rightRotate(int value, int amount) {
final normalized = value & _mask32;
return ((normalized >>> amount) | ((normalized << (32 - amount)) & _mask32)) &
_mask32;
}
普通の右シフトは、右に押し出した bit が消えます。
rotate は、押し出した bit を左端に戻します。
choose
int _choose(int x, int y, int z) {
return ((x & y) ^ ((~x) & z)) & _mask32;
}
これは bit ごとに
-
xが 1 ならy -
xが 0 ならz
を選ぶ関数です。
majority
int _majority(int x, int y, int z) {
return ((x & y) ^ (x & z) ^ (y & z)) & _mask32;
}
これは bit ごとの多数決です。
アプリケーション開発者向けに雑に言うと、
-
rotate: 値の並びをずらして再配置する -
choose: 条件分岐を bit 単位でまとめてやる -
majority: 3 つの候補から多数派を取る
という役割です。
6. 最後に 256bit の digest を出力する
64 round の計算が終わったら、内部で持っている 8 個の 32bit 値を 32 byte に並べ直します。
Uint8List _stateToBytes(List<int> state) {
final digest = Uint8List(32);
for (var index = 0; index < state.length; index++) {
final word = state[index];
final base = index * 4;
digest[base] = (word >>> 24) & 0xff;
digest[base + 1] = (word >>> 16) & 0xff;
digest[base + 2] = (word >>> 8) & 0xff;
digest[base + 3] = word & 0xff;
}
return digest;
}
これで最終的な 256bit のハッシュ値が得られます。
普段よく見る ba7816bf... のような表記は、この 32 byte を 16 進文字列に直したものです。
定数の正体
初期値 _initialHashValues は、最初の 8 個の素数の平方根の小数部分から作られています。
一方、_roundConstants は最初の 64 個の素数の立方根の小数部分から作られています。
つまり「なんか適当に並べた値」ではなく、恣意性が少ないように選ばれた定数です。
まとめ
実装してみて感じたのは、SHA-256 は
- 入力を決まった形に整える
- 固定の定数とビット演算で混ぜる
- それを 64 round 回す
という、かなりストレートな構造だということでした。
シンプルだからこそ破られない。なんかアニメの強キャラみたいでいいですね。
終わりに
気になる点があれば是非コメントお願いします!