Files
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

199 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { createHash } from 'node:crypto';
import { Sha1Stream, computeSha1StateV } from '@snowluma/protocol/highway/sha1-stream';
// SHA1 标准填充:msg + 0x80 + 0x00...(k 个) + 8 字节大端 bit 长度,总长为 64 倍数。
// 用于把任意输入变成「已 finalize」的完整 block 序列——这样 Sha1Stream 对它逐 block
// transform 后取大端 state,应当等于 Node crypto 对原始 msg 的 digest。
function sha1Pad(msg: Uint8Array): Uint8Array {
const msgLen = msg.length;
const k = (56 - ((msgLen + 1) % 64) + 64) % 64;
const padded = new Uint8Array(msgLen + 1 + k + 8);
padded.set(msg, 0);
padded[msgLen] = 0x80;
const view = new DataView(padded.buffer, padded.byteOffset + padded.byteLength - 8, 8);
view.setBigUint64(0, BigInt(msgLen) * 8n, false); // 大端 bit 长度
return padded;
}
// padding oracle 的核心:Node crypto 的 SHA1 digest 内部就是对 padding 后的输入逐 block
// transform、取最终 state 的大端表示。Sha1Stream 自行实现 transform 且不 finalize,
// 因此对「已 padding 的输入」transform 后大端输出必须等于 digest。Node crypto 走
// OpenSSL,与本实现完全独立,是验证 transform 正确性的可靠参照。
function expectSha1StreamMatchesCrypto(msg: Uint8Array): void {
const expected = createHash('sha1').update(Buffer.from(msg)).digest();
const stream = new Sha1Stream();
stream.update(sha1Pad(msg));
const bigEndian = stream.hash(false);
expect(Buffer.from(bigEndian).equals(expected)).toBe(true);
}
describe('Sha1Stream transform (padding oracle vs Node crypto)', () => {
it('matches for empty input (single padded block)', () => {
expectSha1StreamMatchesCrypto(Buffer.alloc(0));
});
it('matches for "abc" (single padded block)', () => {
expectSha1StreamMatchesCrypto(Buffer.from('abc', 'utf8'));
});
it('matches across the 56-byte boundary (forces a second padded block)', () => {
// 56 字节:0x80 后只剩 0 字节给 zeros 不够放 8 字节长度,padding 溢出到第二块。
expectSha1StreamMatchesCrypto(Buffer.alloc(56, 0x41));
});
it('matches for exactly one block (64 bytes → two padded blocks)', () => {
expectSha1StreamMatchesCrypto(Buffer.alloc(64, 0x42));
});
it('matches for multi-block input (200 bytes → four padded blocks)', () => {
expectSha1StreamMatchesCrypto(Buffer.alloc(200, 0x43));
});
it('matches for pseudo-random multi-block input', () => {
const data = Buffer.alloc(313, 0);
for (let i = 0; i < data.length; i++) {
data[i] = (i * 1103515245 + 12345) & 0xff;
}
expectSha1StreamMatchesCrypto(data);
});
});
describe('Sha1Stream update chunk-equivalence', () => {
// update 内部维护 64 字节 buffer + 128 位 bit 计数;分块喂入必须与一次性喂入
// 产生相同的内部 state。切分点覆盖 block 边界附近以暴露 buffer/count 管理 bug。
it('two-chunk split at various boundaries yields the same state as one update', () => {
const data = Buffer.alloc(200, 0x44);
const full = new Sha1Stream();
full.update(data);
const fullHash = Buffer.from(full.hash(true));
for (const split of [0, 1, 32, 63, 64, 65, 127, 128, 129, 199]) {
const s = new Sha1Stream();
s.update(data.subarray(0, split));
s.update(data.subarray(split));
expect(Buffer.from(s.hash(true)).equals(fullHash)).toBe(true);
}
});
it('three-chunk split yields the same state as one update', () => {
const data = Buffer.alloc(300, 0x45);
const full = new Sha1Stream();
full.update(data);
const fullHash = Buffer.from(full.hash(true));
const s = new Sha1Stream();
s.update(data.subarray(0, 100));
s.update(data.subarray(100, 200));
s.update(data.subarray(200));
expect(Buffer.from(s.hash(true)).equals(fullHash)).toBe(true);
});
it('empty updates do not change the state', () => {
const data = Buffer.alloc(130, 0x46);
const full = new Sha1Stream();
full.update(data);
const fullHash = Buffer.from(full.hash(true));
const s = new Sha1Stream();
s.update(Buffer.alloc(0));
s.update(data);
s.update(Buffer.alloc(0));
expect(Buffer.from(s.hash(true)).equals(fullHash)).toBe(true);
});
});
describe('Sha1Stream hash endianness', () => {
// hash(true) 小端与 hash(false) 大端是同一组 5 个 state word 的不同字节序表示。
// 结合上面的 padding oracle(大端 == digest),即可间接确认小端中间 state 的值。
it('little-endian is the per-word byte-swap of big-endian', () => {
const s = new Sha1Stream();
s.update(Buffer.alloc(64, 0x47));
const le = Buffer.from(s.hash(true));
const be = Buffer.from(s.hash(false));
expect(le.length).toBe(20);
for (let i = 0; i < 5; i++) {
expect(le.readUInt32LE(i * 4)).toBe(be.readUInt32BE(i * 4));
}
});
it('reset restores the initial SHA1 state', () => {
const s = new Sha1Stream();
s.update(Buffer.alloc(200, 0x48));
s.reset();
const initial = Buffer.from(s.hash(false));
const expected = Buffer.from([
0x67, 0x45, 0x23, 0x01, 0xef, 0xcd, 0xab, 0x89, 0x98, 0xba, 0xdc, 0xfe, 0x10, 0x32,
0x54, 0x76, 0xc3, 0xd2, 0xe1, 0xf0,
]);
expect(initial.equals(expected)).toBe(true);
});
});
describe('computeSha1StateV', () => {
const SLICE = 1024 * 1024; // 1 MB,与上传链路一致,且是 64 的倍数
it('returns one 20-byte state per slice', () => {
const data = Buffer.alloc(SLICE + 100, 0x49); // 2 片
const states = computeSha1StateV(data, 2, SLICE);
expect(states.length).toBe(2);
for (const st of states) {
expect(st.length).toBe(20);
}
});
// 末片定义为「标准整文件 SHA1(有 finalize/padding)」,直接对齐 Node crypto。
it('last slice equals the standard full-file SHA1 digest', () => {
const data = Buffer.alloc(SLICE + 100, 0x4a);
const states = computeSha1StateV(data, 2, SLICE);
const expected = createHash('sha1').update(data).digest();
expect(Buffer.from(states[1]).equals(expected)).toBe(true);
});
it('last slice equals full-file SHA1 for a three-slice file', () => {
const data = Buffer.alloc(SLICE * 2 + 1, 0x4b);
const states = computeSha1StateV(data, 3, SLICE);
expect(Buffer.from(states[2]).equals(createHash('sha1').update(data).digest())).toBe(true);
});
// 非末片是「累积 SHA1 中间 state(不 finalize,小端)」。若误用标准 finalize
// 计算,会得到 file[0:slice] 的完整 digest,与本实现必然不等——以此锁定
// 「中间 state 而非独立片 digest」的语义。
it('non-last slice is NOT the finalized digest of the prefix', () => {
const data = Buffer.alloc(SLICE + 100, 0x4c);
const states = computeSha1StateV(data, 2, SLICE);
const prefixDigest = createHash('sha1').update(data.subarray(0, SLICE)).digest();
expect(Buffer.from(states[0]).equals(prefixDigest)).toBe(false);
});
// 非末片中间 state 的正确性来自 transform(padding oracle 已验)与 update
// 分块等价(已验)。这里再做一次直接对照:computeSha1StateV 对首片的处理
// 等价于手动 reset + update(prefix) + 小端输出。
it('non-last slice equals manual Sha1Stream reset+update of the prefix', () => {
const data = Buffer.alloc(SLICE + 100, 0x4d);
const states = computeSha1StateV(data, 2, SLICE);
const manual = new Sha1Stream();
manual.update(data.subarray(0, SLICE));
expect(Buffer.from(states[0]).equals(Buffer.from(manual.hash(true)))).toBe(true);
});
// 单片文件只有末片,直接是整文件 digest。
it('single-slice file has only the finalized digest', () => {
const data = Buffer.alloc(100, 0x4e);
const states = computeSha1StateV(data, 1, SLICE);
expect(states.length).toBe(1);
expect(Buffer.from(states[0]).equals(createHash('sha1').update(data).digest())).toBe(true);
});
// 中间 state 与「把它当末片算出的 digest」必须不同——这是 sliceupload 服务端
// 校验「累积 state 而非独立片 digest」的核心区分点。
it('non-last slice differs from a single-slice digest of the same prefix', () => {
const data = Buffer.alloc(SLICE + 100, 0x4f);
const multiFirst = computeSha1StateV(data, 2, SLICE)[0];
const singleAsLast = computeSha1StateV(data.subarray(0, SLICE), 1, SLICE)[0];
expect(Buffer.from(multiFirst).equals(Buffer.from(singleAsLast))).toBe(false);
});
});