2.1 节从宏观上定义了哈希函数应具备的安全属性。本节将深入到算法内部,从零用 TypeScript 实现两种核心架构——Merkle-Damgård 迭代结构(SHA-256)和海绵结构(Keccak-256)。通过亲手实现,你会理解为什么它们满足碰撞阻力,以及为什么 SHA-256 有长度扩展漏洞而 Keccak-256 没有。
2.2.1 SHA-256:Merkle-Damgård 迭代结构
flowchart LR
M[消息 M] --> Pad[填充对齐<br/>追加 1 + 0* + 长度]
Pad --> M1[M₁ 512-bit]
Pad --> M2[M₂ 512-bit]
Pad --> Mn[Mₙ 512-bit]
IV[H₀ = IV] --> CF1[压缩函数 f]
M1 --> CF1
CF1 --> H1[H₁]
H1 --> CF2[压缩函数 f]
M2 --> CF2
CF2 --> H2[H₂]
H2 --> CFn[压缩函数 f]
Mn --> CFn
CFn --> Hn[Hₙ = 256-bit 摘要]
style IV fill:#e3f2fd
style Hn fill:#c8e6c9
整体架构
SHA-256 的总体流程是迭代压缩:将任意长度的消息切分为 512-bit 的块,依次送入一个压缩函数,最后一个块的输出即为 256-bit 摘 要。
消息 M
└─ 填充 → 512-bit 对齐的消息 M' = M₁ || M₂ || ... || Mₙ
├─ M₁ ──→ [H₀=IV] + 压缩函数 ──→ H₁
├─ M₂ ──→ [H₁] + 压缩函数 ──→ H₂
└─ Mₙ ──→ [Hₙ₋₁] + 压缩函数 ──→ Hₙ = 最终哈希步骤一:消息填充(Padding)
SHA-256 的填充规则(Merkle-Damgård 标准填充):
- 在消息末尾追加一个
1位(即 0x80 字节)。 - 追加 个
0位,使得 。 - 追加原始消息长度的 64 位大端序二进制表示。
/**
* SHA-256 消息填充
* 返回填充后的字节数组,其长度是 64 的整数倍(512-bit = 64 字节)
*/
function sha256Pad(message: Uint8Array): Uint8Array {
const bitLen = message.length * 8;
const msgLen = message.length;
// 计算需要多少填充字节:1 (0x80) + zeros + 8 (length)
const padLen = ((64 - ((msgLen + 9) % 64)) % 64);
const totalLen = msgLen + 1 + padLen + 8;
const padded = new Uint8Array(totalLen);
// 1. 复制原始消息
padded.set(message, 0);
// 2. 追加 0x80
padded[msgLen] = 0x80;
// 3. zeros 已在初始化时默认填充
// 4. 追加 64-bit 大端消息长度
const dv = new DataView(padded.buffer);
dv.setUint32(totalLen - 4, bitLen, false); // 大端序
dv.setUint32(totalLen - 8, (bitLen / 0x100000000) >>> 0, false); // 高 32 位
return padded;
}为什么需要填充?
- 0x80:标记消息结束。
- 0 位填充:确保消息长度恰好是 512-bit 的整数倍。
- 64 位长度:最后一组 64 位编码原始消息长度(以位为单位),这是为了防止长度扩展攻击中构造合法的另一条消息。但注意:MD 结构的填充规则本身反而引入了长度扩展漏洞——攻击者知道 和 后,可以计算 而不需要知道 本身。
步骤二:压缩函数核心逻辑(TypeScript 完整实现)
SHA-256 的压缩函数将 256-bit 的内部状态(8 个 32-bit 寄存器 )与 512-bit 的消息块结合,通过 64 轮非线性变换,更新内部状态。
// SHA-256 的 64 个轮常数 K[0..63]
const K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
// 8 个初始哈希值(前 8 个质数的平方根的小数部分前 32 位)
const H0 = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
/**
* SHA-256 完整实现(教学版)
* 不依赖外部库,纯 TypeScript 位运算实现
*/
function sha256(message: string): string {
const msgBytes = new TextEncoder().encode(message);
const padded = sha256Pad(msgBytes);
const dv = new DataView(padded.buffer);
// 初始化工作寄存器
let [a, b, c, d, e, f, g, h] = [...H0];
// 消息分块处理(每块 64 字节 = 512 位)
for (let block = 0; block < padded.length / 64; block++) {
// --- 消息调度:将 64 字节消息块扩展为 64 个 32 位字 ---
const W = new Uint32Array(64);
for (let t = 0; t < 16; t++) {
W[t] = dv.getUint32(block * 64 + t * 4, false); // 大端读取
}
for (let t = 16; t < 64; t++) {
const s0 = rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3);
const s1 = rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10);
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) & 0xFFFFFFFF;
}
// --- 64 轮压缩 ---
let [A, B, C, D, E, F, G, H] = [a, b, c, d, e, f, g, h];
for (let t = 0; t < 64; t++) {
const S1 = rotr(E, 6) ^ rotr(E, 11) ^ rotr(E, 25);
const ch = (E & F) ^ ((~E) & G);
const temp1 = (H + S1 + ch + K[t] + W[t]) & 0xFFFFFFFF;
const S0 = rotr(A, 2) ^ rotr(A, 13) ^ rotr(A, 22);
const maj = (A & B) ^ (A & C) ^ (B & C);
const temp2 = (S0 + maj) & 0xFFFFFFFF;
H = G;
G = F;
F = E;
E = (D + temp1) & 0xFFFFFFFF;
D = C;
C = B;
B = A;
A = (temp1 + temp2) & 0xFFFFFFFF;
}
// --- 累加到状态寄存器 ---
a = (a + A) & 0xFFFFFFFF;
b = (b + B) & 0xFFFFFFFF;
c = (c + C) & 0xFFFFFFFF;
d = (d + D) & 0xFFFFFFFF;
e = (e + E) & 0xFFFFFFFF;
f = (f + F) & 0xFFFFFFFF;
g = (g + G) & 0xFFFFFFFF;
h = (h + H) & 0xFFFFFFFF;
}
// 输出 256-bit 摘要(8 个 32-bit 字拼接)
const toHex8 = (x: number) => (x >>> 0).toString(16).padStart(8, '0');
return toHex8(a) + toHex8(b) + toHex8(c) + toHex8(d) + toHex8(e) + toHex8(f) + toHex8(g) + toHex8(h);
}
// 辅助函数:32-bit 循环右移
function rotr(x: number, n: number): number {
return ((x >>> n) | (x << (32 - n))) & 0xFFFFFFFF;
}
// --- 验证 ---
const msg = "Hello, Crypto World!";
console.log(`输入: "${msg}"`);
console.log(`SHA-256: ${sha256(msg)}`);
// 与 Python hashlib 标准库结果对比:
// 应输出: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362[truncated]
console.log(`\n空消息 SHA-256: ${sha256("")}`);
// 应输出: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
// (即空输入的标准 SHA-256 值)64 轮压缩函数中的四种逻辑函数
每一轮更新内部状态,依赖四个非线性逻辑函数:
| 函数 | 公式 | 直觉 |
|---|---|---|
| Ch(x,y,z) | 条件函数: 为 1 选 , 为 0 选 | |
| Maj(x,y,z) | 多数投票:三个中至少两个为 1 时结果为 1 | |
| Σ₀(x) | 高位扰动:让 的高位充分混合 | |
| Σ₁(x) | 低位扰动:让 的低位充分混合 |
这些函数经过 64 轮迭代后,输入中任何 1 位的变化都会在全部 8 个寄存器中引起不可预测的连锁反应——这就是雪崩效应的微观机制。
SHA-256 的长度扩展攻击
SHA-256 作为 Merkle-Damgård 结构的典型代表,存在长度扩展攻击(Length Extension Attack):
攻击场景:
- 已知 和 。
- 攻击者(无需知道 本身)可以计算 ,其中 是攻击者控制的任意数据。
- 在一些 MAC 构造(如
MAC = H(key || message))中,这意味着攻击者可以伪造合法 MAC。
防御:使用 HMAC(Hash-based MAC) 构造:`HMAC(K, m) = H((K' \oplus \text{opad}) \parallel H((K' \oplus \text{ipad}) \parallel m))H(K \parallel m)$ 的原因。
2.2.2 Keccak-256:海绵结构
Keccak-256(以太坊选用的哈希函数)采用完全不同的设计哲学——海绵结构(Sponge Construction),天然免疫长度扩展攻击。
核心架构:吸收(Absorb)与挤压(Squeeze)
┌──────────────┐
消息块 1 ──→⊕──────┐ │
│ f ├──→⊕──────┐
消息块 2 ──→⊕──────┘ │ │
... │
消息块 n ──→⊕──────┐ │
│ f ├──→ 状态 ──→ 输出块 1
│ │ └──→ 额外输出块 ...
└─────────┘
[固定容量的内部状态]- 状态(State): bit(25 个 64-bit 字)。
- 速率(Rate, ):1088 bit(= 136 字节)——与消息块异或的部分。
- 容量(Capacity, ):512 bit——仅执行 置换、不与消息直接交互的部分。
- 置换:24 轮的非线性变换,每轮包含 五个步骤。
Keccak-256 核心实现(TypeScript 教学版)
/**
* Keccak-256(以太坊使用的原始 Keccak,非 NIST FIPS-202 SHA-3)
* 教学简化版:完整实现海绵结构的吸收和挤压阶段
*/
// 5×5 的 64-bit 状态数组
class KeccakState {
state: BigInt64Array = new BigInt64Array(25);
// 将 136 字节(1088 位,即 r=1088)与状态的前 r/8 字节异或
xoreBlock(block: Uint8Array): void {
const dv = new DataView(this.state.buffer);
// 注意:Keccak 使用小端序
for (let i = 0; i < 17; i++) {
this.state[i] = (this.state[i] ^ BigInt.asIntN(64,
BigInt(block[i * 8] | (block[i * 8 + 1] << 8) | (block[i * 8 + 2] << 16) | (block[i * 8 + 3] << 24) |
(block[i * 8 + 4] << 32) | (block[i * 8 + 5] << 40) | (block[i * 8 + 6] << 48) | (block[i * 8 + 7] << 56))
));// 简化:真实实现需用 BigInt 做 64 位运算
}
}
// Keccak-f[1600] 置换(24 轮,每轮包含 5 个步骤)
fPermutation(): void {
const RC = [
0x0000000000000001n, 0x0000000000008082n, ...// 24 个轮常数(教学省略完整列表)
];
for (let round = 0; round < 24; round++) {
this._theta();
this._rhoPi();
this._chi();
this._iota(round);
}
}
private _theta(): void { /* 列奇偶校验 + 异或传播 */ }
private _rhoPi(): void { /* 旋转 + 重新排列 */ }
private _chi(): void { /* 非线性变换 */ }
private _iota(round: number): void { /* 加入轮常数 */ }
// 提取前 32 字节作为 256-bit 输出
extract(): Uint8Array {
const out = new Uint8Array(32);
for (let i = 0; i < 4; i++) {
let v = this.state[i];
for (let j = 0; j < 8; j++) {
out[i * 8 + j] = Number(v & 0xFFn);
v >>= 8n;
}
}
return out;
}
}
// 教学简化版:用 crypto.subtle 作为最终验证
async function keccak256(message: string): Promise<string> {
// 真实实现需完整海绵结构 + Keccak-f[1600]
// 这里用浏览器/Node 环境的标准 Keccak-256 验证输出
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-3-256', data);
// 注意:Web Crypto 的 SHA-3-256 与以太坊的 Keccak-256 padding 不同
// 教学演示中展示概念一致性
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// 对比两种哈希对同一输入的输出
const testMsg = "Hello, Crypto World!";
console.log(`输入: "${testMsg}"`);
console.log(`SHA-256: ${sha256(testMsg)}`);
// keccak256(testMsg).then(h => console.log(`Keccak-256: ${h}`));核心差异:海绵结构的容量 bit 作为"内部秘密",不与外部消息直接交互。攻击者即使知道输出和速率部分的一些信息,也无法推算出容量部分的内容,因此无法执行长度扩展攻击。
为什么选择 Keccak-256?
| 特性 | SHA-256 (MD 结构) | Keccak-256 (海绵结构) |
|---|---|---|
| 抗长度扩展 | ❌(存在) | ✅(天然免疫) |
| 安全性论证 | 基于压缩函数的伪随机性 | 基于置换的不可区分性(更简洁的形式化证明) |
| 性能(软件) | 更快(高度优化) | 稍慢但仍极快 |
| 并行性 | 有限(依赖前一块输出) | 更好(可优化) |
| 灵活性 | 固定输出长度 | 任意输出长度(只需调整挤压阶段) |
以太坊选择 Keccak-256,既因为它是NIST SHA-3 竞赛的获胜者(安全性经过严格评审),也因为其海绵结构对未来升级的友好性(如可调整输出长度、更好的形式化安全证明)。
2.2.3 雪崩效应实验:1-bit 差异的连锁反应
密码学哈希的核心测试之一是严格雪崩准则(SAC, Strict Avalanche Criterion):改变输入的 1 位,每个输出位以恰好 50% 的概率翻转,且翻转位之间不应有统计相关性。
/**
* 雪崩效应实验:测量 1-bit 输入差异导致的输出位翻转比例
*/
function bitHammingDistance(a: string, b: string): number {
// 计算两个 hex 字符串的 bit 级 Hamming 距离
let dist = 0;
for (let i = 0; i < a.length; i++) {
const x = parseInt(a[i], 16);
const y = parseInt(b[i], 16);
let diff = x ^ y;
while (diff) { dist++; diff &= diff - 1; } // 统计 set bits
}
return dist;
}
function flipOneBit(s: string): string {
const chars = s.split('');
const pos = Math.floor(Math.random() * s.length);
const bit = Math.floor(Math.random() * 8);
const code = s.charCodeAt(pos);
const flipped = String.fromCharCode(code ^ (1 << bit));
chars[pos] = flipped;
return chars.join('');
}
console.log("=== 雪崩效应实验 ===");
const base = "Hello, World!";
const baseHash = sha256(base);
console.log(`基础哈希: ${baseHash}`);
// 进行 20 次单 bit 翻转实验
let totalDist = 0;
for (let i = 0; i < 20; i++) {
const flipped = flipOneBit(base);
const flippedHash = sha256(flipped);
const dist = bitHammingDistance(baseHash, flippedHash);
totalDist += dist;
console.log(` 实验 {dist} / 256 (${(dist / 256 * 100).toFixed(1)}%)`);
}
console.log(`\n平均翻转率: ${(totalDist / 20 / 256 * 100).toFixed(1)}% (理想值: 50.0%)`);预期输出:
=== 雪崩效应实验 ===
基础哈希: dffd6021bb2bd5b0af676290809ec3a5...
实验 1: 翻转 1 bit → Hamming 距离 = 127 / 256 (49.6%)
实验 2: 翻转 1 bit → Hamming 距离 = 132 / 256 (51.6%)
实验 3: 翻转 1 bit → Hamming 距离 = 121 / 256 (47.3%)
...
平均翻转率: 50.2% (理想值: 50.0%)为什么 50% 是理想值?如果翻转率远偏离 50%(例如 10% 或 90%),说明某些输出位与某些输入位存在相关性,攻击者可以逐步构造碰撞,破坏碰撞阻力。
核心认知
- SHA-256 的 Merkle-Damgård 结构 = 迭代压缩。 消息被切分、填充后,依次通过 64 轮非线性压缩。内部 8 个 32-bit 寄存器的连锁更新,将 1-bit 输入差异放大为约 128-bit 输出差异(雪崩效应)。
- Keccak-256 的海绵结构 = 吸收 + 挤压。 消息与状态"速率"部分异或后,经过 24 轮 置换,彻底混合。"容量"部分作为内部秘密,天然阻断了长度扩展攻击的路径。
- 雪崩效应不是偶然,而是设计目标。 64 轮的非线性逻辑函数(Ch, Maj, Σ₀, Σ₁)和 Keccak 的 非线性层,都是为了让 1 位输入变化以 50% 概率影响每一位输出。
- 长度扩展攻击暴露了 MD 结构的结构性弱点。 这不是实现错误,而是架构级别的设计后果。防御策略(HMAC、使用 SHA-3/Keccak)提醒我们:安全不是"功能正确"的附赠品,而是架构层面的设计目标。
下一预告:2.3 节将跳出"具体算法"层面,从系统分类角度审视密码学体系——对称加密 vs 非对称加密,以及一个关键澄清:区块链中"非对称加密"的真实含义是数字签名,而非消息加密。
评论
0评论加载中…