add btv2 infohash
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@ export const codeTable = {
|
||||
[CID_ALGORITHM_NAMES.sha256]: CID_ALGORITHM_CODES.sha256,
|
||||
[CID_ALGORITHM_NAMES.sha3_256]: CID_ALGORITHM_CODES.sha3_256,
|
||||
[CID_ALGORITHM_NAMES.sha3_384]: CID_ALGORITHM_CODES.sha3_384,
|
||||
[CID_ALGORITHM_NAMES.midhash256]: CID_ALGORITHM_CODES.midhash256
|
||||
[CID_ALGORITHM_NAMES.midhash256]: CID_ALGORITHM_CODES.midhash256,
|
||||
[CID_ALGORITHM_NAMES.btih_v2]: CID_ALGORITHM_CODES.btih_v2
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData";
|
||||
import {SimpleHash} from "./SimpleHash";
|
||||
import {createHash} from "crypto";
|
||||
import {Crc32Hash} from "./Crc32Hash";
|
||||
import {BtihV2Hasher} from "./BtihV2Hasher";
|
||||
|
||||
/**
|
||||
* Compute the CIDs of a file using specific algorithms
|
||||
@@ -22,6 +23,8 @@ export async function createHasher(algo: CID_ALGORITHM_NAMES): Promise<SimpleHas
|
||||
return createHash("sha3-384");
|
||||
case CID_ALGORITHM_NAMES.crc32:
|
||||
return new Crc32Hash();
|
||||
case CID_ALGORITHM_NAMES.btih_v2:
|
||||
return new BtihV2Hasher();
|
||||
default:
|
||||
throw new Error(`Unsupported algorithm: ${algo}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import {MultiHashData} from '../hash-compute/MultiHashData';
|
||||
import {CID} from 'multiformats/cid';
|
||||
|
||||
/**
|
||||
* Magnet Link Generator for BitTorrent v2
|
||||
*
|
||||
* Generates BitTorrent v2 magnet links from file metadata.
|
||||
*
|
||||
* Format: magnet:?xt=urn:btmh:1220<info-hash-hex>&dn=<filename>&xl=<filesize>
|
||||
*
|
||||
* Components:
|
||||
* - xt: eXact Topic (torrent identifier)
|
||||
* - btmh: BitTorrent Merkle Hash (v2 URN scheme)
|
||||
* - 1220: Multihash prefix (0x12 = SHA-256, 0x20 = 32 bytes)
|
||||
* - info-hash-hex: 64-character hex string (32 bytes SHA-256)
|
||||
* - dn: Display Name (filename)
|
||||
* - xl: eXact Length (file size in bytes)
|
||||
*
|
||||
* Reference: BEP 52, BEP 9 (magnet URIs)
|
||||
*/
|
||||
export class MagnetLinkGenerator {
|
||||
/**
|
||||
* Generate BitTorrent v2 magnet link from metadata
|
||||
*
|
||||
* @param metadata - File metadata containing cid_btih_v2
|
||||
* @param fileName - Original filename for display name
|
||||
* @param fileSize - File size in bytes
|
||||
* @returns Magnet link string, or null if btih_v2 not available
|
||||
*
|
||||
* @example
|
||||
* const metadata = {cid_btih_v2: 'bafyb...'};
|
||||
* const magnet = MagnetLinkGenerator.generate(metadata, 'video.mp4', 1024000);
|
||||
* // Returns: magnet:?xt=urn:btmh:1220abc...&dn=video.mp4&xl=1024000
|
||||
*/
|
||||
static generate(
|
||||
metadata: MultiHashData,
|
||||
fileName: string,
|
||||
fileSize: number
|
||||
): string | null {
|
||||
const cidString = metadata.cid_btih_v2;
|
||||
if (!cidString) {
|
||||
return null; // BitTorrent v2 hash not computed
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode CID to get raw info hash
|
||||
const cid = CID.parse(cidString);
|
||||
const infoHash = cid.multihash.digest; // Raw 32-byte SHA-256 hash
|
||||
|
||||
// Convert to hex for magnet link
|
||||
const infoHashHex = Buffer.from(infoHash).toString('hex');
|
||||
|
||||
// Build magnet link components
|
||||
const components = [
|
||||
`xt=urn:btmh:1220${infoHashHex}`, // 1220 = multihash prefix (SHA-256, 32 bytes)
|
||||
`dn=${encodeURIComponent(fileName)}`, // Display name (URL encoded)
|
||||
`xl=${fileSize}` // Exact length in bytes
|
||||
];
|
||||
|
||||
return `magnet:?${components.join('&')}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate magnet link from CID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract info hash from magnet link
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns Info hash as hex string, or null if invalid
|
||||
*
|
||||
* @example
|
||||
* const hash = MagnetLinkGenerator.extractInfoHash('magnet:?xt=urn:btmh:1220abc...');
|
||||
* // Returns: 'abc...'
|
||||
*/
|
||||
static extractInfoHash(magnetLink: string): string | null {
|
||||
try {
|
||||
const match = magnetLink.match(/xt=urn:btmh:1220([0-9a-f]{64})/i);
|
||||
return match ? match[1] : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from magnet link
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns Decoded filename, or null if not present
|
||||
*/
|
||||
static extractFileName(magnetLink: string): string | null {
|
||||
try {
|
||||
const match = magnetLink.match(/dn=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file size from magnet link
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns File size in bytes, or null if not present
|
||||
*/
|
||||
static extractFileSize(magnetLink: string): number | null {
|
||||
try {
|
||||
const match = magnetLink.match(/xl=(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate BitTorrent v2 magnet link format
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns True if valid BitTorrent v2 magnet link
|
||||
*/
|
||||
static isValid(magnetLink: string): boolean {
|
||||
if (!magnetLink.startsWith('magnet:?')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have btmh (BitTorrent Merkle Hash) identifier
|
||||
if (!magnetLink.includes('xt=urn:btmh:1220')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have 64-character hex hash (32 bytes SHA-256)
|
||||
const hashMatch = magnetLink.match(/1220([0-9a-f]{64})/i);
|
||||
if (!hashMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse magnet link into components
|
||||
*
|
||||
* @param magnetLink - Magnet link string
|
||||
* @returns Object with parsed components, or null if invalid
|
||||
*/
|
||||
static parse(magnetLink: string): {
|
||||
infoHash: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
} | null {
|
||||
if (!this.isValid(magnetLink)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const infoHash = this.extractInfoHash(magnetLink);
|
||||
if (!infoHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
infoHash,
|
||||
fileName: this.extractFileName(magnetLink) || undefined,
|
||||
fileSize: this.extractFileSize(magnetLink) || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate magnet link from CID string directly
|
||||
*
|
||||
* @param cidString - CID string (e.g., 'bafyb...')
|
||||
* @param fileName - Original filename
|
||||
* @param fileSize - File size in bytes
|
||||
* @returns Magnet link string, or null if CID invalid
|
||||
*/
|
||||
static fromCID(
|
||||
cidString: string,
|
||||
fileName: string,
|
||||
fileSize: number
|
||||
): string | null {
|
||||
return this.generate({cid_btih_v2: cidString}, fileName, fileSize);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user