201 lines
6.1 KiB
TypeScript
201 lines
6.1 KiB
TypeScript
import {createHash} from 'crypto';
|
|
import {SimpleHash} from './SimpleHash';
|
|
import {encode} from 'bencodec';
|
|
import {basename} from 'path';
|
|
|
|
/**
|
|
* BitTorrent v2 Info Hash Hasher (BEP 52)
|
|
*
|
|
* Implements BitTorrent v2 file hashing with:
|
|
* - Fixed 16KB block size
|
|
* - SHA-256 merkle tree
|
|
* - Info hash computation
|
|
*
|
|
* Architecture:
|
|
* 1. Streaming: Accumulates file data into 16KB blocks
|
|
* 2. Block hashing: SHA-256 hash each complete block
|
|
* 3. Merkle tree: Build balanced tree from block hashes
|
|
* 4. Info hash: SHA-256(bencode(info_dict))
|
|
*/
|
|
export class BtihV2Hasher implements SimpleHash {
|
|
private static readonly BLOCK_SIZE = 16 * 1024; // 16KB fixed by BEP 52
|
|
|
|
private blockHashes: Buffer[] = [];
|
|
private currentBlock: Buffer[] = [];
|
|
private currentBlockSize: number = 0;
|
|
private fileSize: number = 0;
|
|
private fileName: string = 'unknown';
|
|
|
|
/**
|
|
* Optional: Set filename for info dictionary
|
|
* If not set, defaults to 'unknown'
|
|
*/
|
|
setFileName(fileName: string): void {
|
|
this.fileName = basename(fileName);
|
|
}
|
|
|
|
/**
|
|
* Update hasher with new data chunk
|
|
* Accumulates chunks into 16KB blocks and hashes each complete block
|
|
*/
|
|
update(data: Uint8Array): SimpleHash {
|
|
const buffer = Buffer.from(data);
|
|
this.fileSize += buffer.length;
|
|
|
|
let offset = 0;
|
|
while (offset < buffer.length) {
|
|
const remaining = BtihV2Hasher.BLOCK_SIZE - this.currentBlockSize;
|
|
const toRead = Math.min(remaining, buffer.length - offset);
|
|
|
|
// Accumulate chunk into current block
|
|
this.currentBlock.push(buffer.subarray(offset, offset + toRead));
|
|
this.currentBlockSize += toRead;
|
|
offset += toRead;
|
|
|
|
// When block is complete, hash it
|
|
if (this.currentBlockSize === BtihV2Hasher.BLOCK_SIZE) {
|
|
this.finalizeBlock();
|
|
}
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Finalize current block by hashing it and storing the hash
|
|
*/
|
|
private finalizeBlock(): void {
|
|
if (this.currentBlock.length === 0) return;
|
|
|
|
const blockData = Buffer.concat(this.currentBlock);
|
|
const blockHash = createHash('sha256').update(blockData).digest();
|
|
this.blockHashes.push(blockHash);
|
|
|
|
// Reset current block
|
|
this.currentBlock = [];
|
|
this.currentBlockSize = 0;
|
|
}
|
|
|
|
/**
|
|
* Compute final info hash
|
|
*
|
|
* Steps:
|
|
* 1. Finalize any partial block
|
|
* 2. Build merkle tree from block hashes
|
|
* 3. Construct info dictionary (name, length, pieces_root)
|
|
* 4. Bencode info dict
|
|
* 5. SHA-256 of bencoded data = info hash
|
|
*/
|
|
digest(): Buffer {
|
|
// Finalize any remaining partial block
|
|
if (this.currentBlockSize > 0) {
|
|
this.finalizeBlock();
|
|
}
|
|
|
|
// Handle empty file edge case
|
|
if (this.blockHashes.length === 0) {
|
|
// Empty file: hash empty block
|
|
const emptyHash = createHash('sha256').update(Buffer.alloc(0)).digest();
|
|
this.blockHashes.push(emptyHash);
|
|
}
|
|
|
|
// Build merkle tree to get pieces root
|
|
const piecesRoot = this.buildMerkleTree(this.blockHashes);
|
|
|
|
// Construct info dictionary (BEP 52 v2 format)
|
|
const infoDict = {
|
|
name: this.fileName,
|
|
length: this.fileSize,
|
|
'piece length': this.computePieceLength(this.fileSize),
|
|
'pieces root': piecesRoot,
|
|
'meta version': 2 // BitTorrent v2
|
|
};
|
|
|
|
// Bencode the info dictionary
|
|
const bencodedInfo = encode(infoDict);
|
|
|
|
// Info hash = SHA-256 of bencoded info dict
|
|
const infoHash = createHash('sha256').update(bencodedInfo).digest();
|
|
|
|
return infoHash;
|
|
}
|
|
|
|
/**
|
|
* Build balanced merkle tree from leaf hashes
|
|
*
|
|
* Algorithm (from BEP 52 reference implementation):
|
|
* 1. Pad to power of 2 with zero hashes
|
|
* 2. Recursively pair adjacent hashes
|
|
* 3. Hash each pair: SHA-256(left || right)
|
|
* 4. Repeat until single root hash remains
|
|
*/
|
|
private buildMerkleTree(hashes: Buffer[]): Buffer {
|
|
if (hashes.length === 0) {
|
|
throw new Error('Cannot build merkle tree from empty hash list');
|
|
}
|
|
|
|
// Pad to next power of 2 with zero hashes
|
|
let paddedHashes = [...hashes];
|
|
const targetSize = this.nextPowerOfTwo(paddedHashes.length);
|
|
const zeroHash = Buffer.alloc(32); // 32 bytes of zeros for SHA-256
|
|
while (paddedHashes.length < targetSize) {
|
|
paddedHashes.push(zeroHash);
|
|
}
|
|
|
|
// Build tree by repeatedly pairing and hashing
|
|
while (paddedHashes.length > 1) {
|
|
const parentHashes: Buffer[] = [];
|
|
|
|
for (let i = 0; i < paddedHashes.length; i += 2) {
|
|
const left = paddedHashes[i];
|
|
const right = paddedHashes[i + 1];
|
|
const combined = Buffer.concat([left, right]);
|
|
const parentHash = createHash('sha256').update(combined).digest();
|
|
parentHashes.push(parentHash);
|
|
}
|
|
|
|
paddedHashes = parentHashes;
|
|
}
|
|
|
|
return paddedHashes[0];
|
|
}
|
|
|
|
/**
|
|
* Compute piece length using BEP 52 formula
|
|
*
|
|
* Formula: piece_length = 16KB * 2^n
|
|
* where: file_size / piece_length < 2048
|
|
*
|
|
* This ensures each file has between 1024 and 2047 pieces
|
|
*/
|
|
private computePieceLength(fileSize: number): number {
|
|
if (fileSize === 0) {
|
|
return BtihV2Hasher.BLOCK_SIZE; // 16KB for empty files
|
|
}
|
|
|
|
let pieceLength = BtihV2Hasher.BLOCK_SIZE; // Start at 16KB
|
|
const maxPieces = 2048;
|
|
|
|
// Increase piece length until we have < 2048 pieces
|
|
while (Math.ceil(fileSize / pieceLength) >= maxPieces) {
|
|
pieceLength *= 2;
|
|
}
|
|
|
|
return pieceLength;
|
|
}
|
|
|
|
/**
|
|
* Find next power of 2 >= n
|
|
*/
|
|
private nextPowerOfTwo(n: number): number {
|
|
if (n === 0) return 1;
|
|
n--;
|
|
n |= n >> 1;
|
|
n |= n >> 2;
|
|
n |= n >> 4;
|
|
n |= n >> 8;
|
|
n |= n >> 16;
|
|
return n + 1;
|
|
}
|
|
}
|