SHA256
235 lines
7.9 KiB
JavaScript
235 lines
7.9 KiB
JavaScript
import { bytesToBase64Url, sha256Bytes } from './crypto-utils.js';
|
|
|
|
export const TORRENT_V2_BLOCK_BYTES = 16 * 1024;
|
|
export const TORRENT_V2_PIECE_BYTES = 1024 * 1024;
|
|
|
|
const ZERO_HASH = new Uint8Array(32);
|
|
const encoder = new TextEncoder();
|
|
|
|
function concatBytes(parts = []) {
|
|
const arrays = parts.map((part) => part instanceof Uint8Array ? part : new Uint8Array(part || 0));
|
|
const total = arrays.reduce((sum, part) => sum + part.byteLength, 0);
|
|
const out = new Uint8Array(total);
|
|
let offset = 0;
|
|
for (const part of arrays) {
|
|
out.set(part, offset);
|
|
offset += part.byteLength;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function hashPair(left, right) {
|
|
return sha256Bytes(concatBytes([left, right]));
|
|
}
|
|
|
|
function nextPowerOfTwo(value) {
|
|
let n = 1;
|
|
while (n < value) n *= 2;
|
|
return n;
|
|
}
|
|
|
|
async function reduceMerkleLevel(nodes) {
|
|
if (nodes.length <= 1) return nodes;
|
|
const next = [];
|
|
for (let i = 0; i < nodes.length; i += 2) {
|
|
next.push(hashPair(nodes[i], nodes[i + 1]));
|
|
}
|
|
return Promise.all(next);
|
|
}
|
|
|
|
/**
|
|
* BEP 52 pieces root / piece-layer hash for one piece.
|
|
* Leaves are SHA-256 hashes of 16 KiB blocks. Padding leaves are 32 zero bytes,
|
|
* exactly as required by BitTorrent v2.
|
|
*/
|
|
export async function computeTorrentV2PieceRoot(pieceBytes, { padToPieceLength = false } = {}) {
|
|
const bytes = pieceBytes instanceof Uint8Array ? pieceBytes : new Uint8Array(pieceBytes || 0);
|
|
if (!bytes.byteLength) return null;
|
|
|
|
const blockCount = Math.ceil(bytes.byteLength / TORRENT_V2_BLOCK_BYTES);
|
|
const leafPromises = [];
|
|
for (let offset = 0; offset < bytes.byteLength; offset += TORRENT_V2_BLOCK_BYTES) {
|
|
leafPromises.push(sha256Bytes(bytes.subarray(offset, Math.min(bytes.byteLength, offset + TORRENT_V2_BLOCK_BYTES))));
|
|
}
|
|
let level = await Promise.all(leafPromises);
|
|
const targetLeaves = padToPieceLength
|
|
? (TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES)
|
|
: nextPowerOfTwo(Math.max(1, blockCount));
|
|
while (level.length < targetLeaves) level.push(ZERO_HASH.slice());
|
|
while (level.length > 1) level = await reduceMerkleLevel(level);
|
|
return level[0];
|
|
}
|
|
|
|
let zeroPieceRootPromise = null;
|
|
export function getTorrentV2ZeroPieceRoot() {
|
|
if (!zeroPieceRootPromise) {
|
|
zeroPieceRootPromise = (async () => {
|
|
let level = Array.from(
|
|
{ length: TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES },
|
|
() => ZERO_HASH.slice(),
|
|
);
|
|
while (level.length > 1) level = await reduceMerkleLevel(level);
|
|
return level[0];
|
|
})();
|
|
}
|
|
return zeroPieceRootPromise;
|
|
}
|
|
|
|
/**
|
|
* Streaming Merkle accumulator for the BEP 52 piece layer. It keeps only O(log n)
|
|
* hashes in memory and pads the right side with the standard zero-piece subtree.
|
|
*/
|
|
export class TorrentV2PieceAccumulator {
|
|
constructor() {
|
|
this.stack = [];
|
|
this.count = 0;
|
|
}
|
|
|
|
async #addSubtree(hash, level) {
|
|
let current = hash;
|
|
let currentLevel = level;
|
|
while (this.stack[currentLevel]) {
|
|
current = await hashPair(this.stack[currentLevel], current);
|
|
this.stack[currentLevel] = null;
|
|
currentLevel += 1;
|
|
}
|
|
this.stack[currentLevel] = current;
|
|
}
|
|
|
|
async addPieceRoot(hash) {
|
|
if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) {
|
|
throw new Error('Некорректный torrent-v2 piece hash');
|
|
}
|
|
await this.#addSubtree(hash, 0);
|
|
this.count += 1;
|
|
}
|
|
|
|
async finalize() {
|
|
if (this.count <= 0) return null;
|
|
let target = 1;
|
|
while (target < this.count) target *= 2;
|
|
let remaining = target - this.count;
|
|
let count = this.count;
|
|
const zeroRoots = [await getTorrentV2ZeroPieceRoot()];
|
|
const ensureZeroLevel = async (level) => {
|
|
while (zeroRoots.length <= level) {
|
|
const previous = zeroRoots[zeroRoots.length - 1];
|
|
zeroRoots.push(await hashPair(previous, previous));
|
|
}
|
|
return zeroRoots[level];
|
|
};
|
|
|
|
while (remaining > 0) {
|
|
let maxByRemaining = Math.floor(Math.log2(remaining));
|
|
let alignmentLevel = 0;
|
|
let aligned = count;
|
|
while (aligned > 0 && aligned % 2 === 0) {
|
|
alignmentLevel += 1;
|
|
aligned /= 2;
|
|
}
|
|
const level = Math.min(maxByRemaining, alignmentLevel);
|
|
const blockLeaves = 2 ** level;
|
|
await this.#addSubtree(await ensureZeroLevel(level), level);
|
|
count += blockLeaves;
|
|
remaining -= blockLeaves;
|
|
}
|
|
|
|
const root = this.stack.findLast?.((value) => value) || [...this.stack].reverse().find((value) => value) || null;
|
|
return root ? root.slice() : null;
|
|
}
|
|
}
|
|
|
|
function encodeBString(bytes) {
|
|
const data = bytes instanceof Uint8Array ? bytes : encoder.encode(String(bytes ?? ''));
|
|
return concatBytes([encoder.encode(`${data.byteLength}:`), data]);
|
|
}
|
|
|
|
function compareByteArrays(left, right) {
|
|
const limit = Math.min(left.byteLength, right.byteLength);
|
|
for (let i = 0; i < limit; i += 1) {
|
|
if (left[i] !== right[i]) return left[i] - right[i];
|
|
}
|
|
return left.byteLength - right.byteLength;
|
|
}
|
|
|
|
function encodeBValue(value) {
|
|
if (value instanceof Uint8Array) return encodeBString(value);
|
|
if (typeof value === 'string') return encodeBString(encoder.encode(value));
|
|
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
const integer = typeof value === 'bigint' ? value : BigInt(Math.trunc(value));
|
|
return encoder.encode(`i${integer.toString()}e`);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return concatBytes([encoder.encode('l'), ...value.map(encodeBValue), encoder.encode('e')]);
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const entries = Object.entries(value).map(([key, item]) => ({
|
|
keyBytes: encoder.encode(key),
|
|
value: item,
|
|
})).sort((a, b) => compareByteArrays(a.keyBytes, b.keyBytes));
|
|
const parts = [encoder.encode('d')];
|
|
for (const entry of entries) {
|
|
parts.push(encodeBString(entry.keyBytes), encodeBValue(entry.value));
|
|
}
|
|
parts.push(encoder.encode('e'));
|
|
return concatBytes(parts);
|
|
}
|
|
throw new Error('Неподдерживаемое значение bencode');
|
|
}
|
|
|
|
export function buildTorrentV2InfoBytes({ name, size, piecesRoot, pieceLength = TORRENT_V2_PIECE_BYTES } = {}) {
|
|
const cleanName = String(name || 'file');
|
|
const fileData = { length: Math.max(0, Math.trunc(Number(size || 0))) };
|
|
if (fileData.length > 0) {
|
|
if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) {
|
|
throw new Error('Для непустого файла нужен 32-байтный pieces root');
|
|
}
|
|
fileData['pieces root'] = piecesRoot;
|
|
}
|
|
return encodeBValue({
|
|
'file tree': {
|
|
[cleanName]: {
|
|
'': fileData,
|
|
},
|
|
},
|
|
'meta version': 2,
|
|
name: cleanName,
|
|
'piece length': Math.trunc(pieceLength),
|
|
});
|
|
}
|
|
|
|
export async function computeTorrentV2InfoHash({ name, size, piecesRoot } = {}) {
|
|
const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot });
|
|
const hash = await sha256Bytes(infoBytes);
|
|
return {
|
|
infoBytes,
|
|
infoHash: hash,
|
|
infoHashB64Url: bytesToBase64Url(hash),
|
|
};
|
|
}
|
|
|
|
/** Builds a tracker-less BitTorrent v2 metainfo file. */
|
|
export function buildTorrentV2MetainfoBytes({ name, size, piecesRoot, pieceLayer = [] } = {}) {
|
|
const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot });
|
|
const outer = [encoder.encode('d'), encodeBString(encoder.encode('info')), infoBytes];
|
|
if (Number(size || 0) > TORRENT_V2_PIECE_BYTES) {
|
|
if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) {
|
|
throw new Error('Некорректный pieces root');
|
|
}
|
|
const hashes = (Array.isArray(pieceLayer) ? pieceLayer : []).map((hash) => {
|
|
if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) throw new Error('Некорректный piece layer');
|
|
return hash;
|
|
});
|
|
const layerBytes = concatBytes(hashes);
|
|
outer.push(
|
|
encodeBString(encoder.encode('piece layers')),
|
|
encoder.encode('d'),
|
|
encodeBString(piecesRoot),
|
|
encodeBString(layerBytes),
|
|
encoder.encode('e'),
|
|
);
|
|
}
|
|
outer.push(encoder.encode('e'));
|
|
return concatBytes(outer);
|
|
}
|