1219 lines
35 KiB
JavaScript
1219 lines
35 KiB
JavaScript
import aesjs from '../vendor/aes-js-esm.js';
|
|
|
|
const AES_BLOCK_SIZE = 16;
|
|
const HEADER_MAGIC = 'AES';
|
|
const ENCRYPTED_SESSION_LENGTH = 48;
|
|
const HEADER_HMAC_LENGTH = 32;
|
|
const FOOTER_TOTAL_LENGTH = 33;
|
|
const VERSION_2_ITERATIONS = 8192;
|
|
const SHA256_BLOCK_SIZE = 64;
|
|
const SHA256_INITIAL_STATE = Uint32Array.from([
|
|
0x6a09e667,
|
|
0xbb67ae85,
|
|
0x3c6ef372,
|
|
0xa54ff53a,
|
|
0x510e527f,
|
|
0x9b05688c,
|
|
0x1f83d9ab,
|
|
0x5be0cd19
|
|
]);
|
|
const SHA256_ROUND_CONSTANTS = Uint32Array.from([
|
|
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
|
|
]);
|
|
|
|
function rotateRight(value, bits) {
|
|
return (value >>> bits) | (value << (32 - bits));
|
|
}
|
|
|
|
class Sha256Hasher {
|
|
constructor() {
|
|
this.state = Uint32Array.from(SHA256_INITIAL_STATE);
|
|
this.buffer = new Uint8Array(SHA256_BLOCK_SIZE);
|
|
this.bufferLength = 0;
|
|
this.bytesHashed = 0n;
|
|
this.finished = false;
|
|
this.schedule = new Uint32Array(64);
|
|
}
|
|
|
|
update(bytes) {
|
|
if (this.finished) {
|
|
throw new Error('SHA256_FINALIZED');
|
|
}
|
|
|
|
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
this.bytesHashed += BigInt(input.length);
|
|
|
|
let offset = 0;
|
|
while (offset < input.length) {
|
|
const remaining = SHA256_BLOCK_SIZE - this.bufferLength;
|
|
const length = Math.min(remaining, input.length - offset);
|
|
this.buffer.set(input.subarray(offset, offset + length), this.bufferLength);
|
|
this.bufferLength += length;
|
|
offset += length;
|
|
|
|
if (this.bufferLength === SHA256_BLOCK_SIZE) {
|
|
this.processChunk(this.buffer);
|
|
this.bufferLength = 0;
|
|
}
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
processChunk(chunk) {
|
|
for (let index = 0; index < 16; index += 1) {
|
|
const offset = index * 4;
|
|
this.schedule[index] =
|
|
(chunk[offset] << 24) |
|
|
(chunk[offset + 1] << 16) |
|
|
(chunk[offset + 2] << 8) |
|
|
chunk[offset + 3];
|
|
}
|
|
|
|
for (let index = 16; index < 64; index += 1) {
|
|
const word15 = this.schedule[index - 15];
|
|
const word2 = this.schedule[index - 2];
|
|
const sigma0 = rotateRight(word15, 7) ^ rotateRight(word15, 18) ^ (word15 >>> 3);
|
|
const sigma1 = rotateRight(word2, 17) ^ rotateRight(word2, 19) ^ (word2 >>> 10);
|
|
this.schedule[index] =
|
|
(this.schedule[index - 16] + sigma0 + this.schedule[index - 7] + sigma1) >>> 0;
|
|
}
|
|
|
|
let a = this.state[0];
|
|
let b = this.state[1];
|
|
let c = this.state[2];
|
|
let d = this.state[3];
|
|
let e = this.state[4];
|
|
let f = this.state[5];
|
|
let g = this.state[6];
|
|
let h = this.state[7];
|
|
|
|
for (let index = 0; index < 64; index += 1) {
|
|
const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
|
const choice = (e & f) ^ (~e & g);
|
|
const temp1 = (h + sigma1 + choice + SHA256_ROUND_CONSTANTS[index] + this.schedule[index]) >>> 0;
|
|
const sigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
|
const majority = (a & b) ^ (a & c) ^ (b & c);
|
|
const temp2 = (sigma0 + majority) >>> 0;
|
|
|
|
h = g;
|
|
g = f;
|
|
f = e;
|
|
e = (d + temp1) >>> 0;
|
|
d = c;
|
|
c = b;
|
|
b = a;
|
|
a = (temp1 + temp2) >>> 0;
|
|
}
|
|
|
|
this.state[0] = (this.state[0] + a) >>> 0;
|
|
this.state[1] = (this.state[1] + b) >>> 0;
|
|
this.state[2] = (this.state[2] + c) >>> 0;
|
|
this.state[3] = (this.state[3] + d) >>> 0;
|
|
this.state[4] = (this.state[4] + e) >>> 0;
|
|
this.state[5] = (this.state[5] + f) >>> 0;
|
|
this.state[6] = (this.state[6] + g) >>> 0;
|
|
this.state[7] = (this.state[7] + h) >>> 0;
|
|
}
|
|
|
|
finish() {
|
|
if (this.finished) {
|
|
return;
|
|
}
|
|
|
|
this.buffer[this.bufferLength] = 0x80;
|
|
this.bufferLength += 1;
|
|
|
|
if (this.bufferLength > 56) {
|
|
this.buffer.fill(0, this.bufferLength);
|
|
this.processChunk(this.buffer);
|
|
this.bufferLength = 0;
|
|
}
|
|
|
|
this.buffer.fill(0, this.bufferLength, 56);
|
|
|
|
const bitLength = (this.bytesHashed * 8n) & 0xffffffffffffffffn;
|
|
for (let index = 0; index < 8; index += 1) {
|
|
this.buffer[63 - index] = Number((bitLength >> BigInt(index * 8)) & 0xffn);
|
|
}
|
|
|
|
this.processChunk(this.buffer);
|
|
this.bufferLength = 0;
|
|
this.finished = true;
|
|
}
|
|
|
|
digest() {
|
|
this.finish();
|
|
const output = new Uint8Array(32);
|
|
for (let index = 0; index < this.state.length; index += 1) {
|
|
const word = this.state[index];
|
|
output[index * 4] = (word >>> 24) & 0xff;
|
|
output[index * 4 + 1] = (word >>> 16) & 0xff;
|
|
output[index * 4 + 2] = (word >>> 8) & 0xff;
|
|
output[index * 4 + 3] = word & 0xff;
|
|
}
|
|
return output;
|
|
}
|
|
}
|
|
|
|
class HmacSha256 {
|
|
constructor(keyBytes) {
|
|
const key = keyBytes instanceof Uint8Array ? keyBytes : new Uint8Array(keyBytes);
|
|
let normalizedKey = key;
|
|
|
|
if (normalizedKey.length > SHA256_BLOCK_SIZE) {
|
|
normalizedKey = new Sha256Hasher().update(normalizedKey).digest();
|
|
}
|
|
|
|
const paddedKey = new Uint8Array(SHA256_BLOCK_SIZE);
|
|
paddedKey.set(normalizedKey);
|
|
|
|
this.inner = new Sha256Hasher();
|
|
this.outer = new Sha256Hasher();
|
|
|
|
const innerPad = new Uint8Array(SHA256_BLOCK_SIZE);
|
|
const outerPad = new Uint8Array(SHA256_BLOCK_SIZE);
|
|
for (let index = 0; index < SHA256_BLOCK_SIZE; index += 1) {
|
|
innerPad[index] = paddedKey[index] ^ 0x36;
|
|
outerPad[index] = paddedKey[index] ^ 0x5c;
|
|
}
|
|
|
|
this.inner.update(innerPad);
|
|
this.outer.update(outerPad);
|
|
}
|
|
|
|
update(bytes) {
|
|
this.inner.update(bytes);
|
|
return this;
|
|
}
|
|
|
|
digest() {
|
|
const innerDigest = this.inner.digest();
|
|
return this.outer.update(innerDigest).digest();
|
|
}
|
|
}
|
|
|
|
function createMediaError(code, message) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
function createAbortMediaError() {
|
|
const error = createMediaError('MEDIA_REQUEST_ABORTED', 'The active media request was aborted.');
|
|
error.name = 'AbortError';
|
|
return error;
|
|
}
|
|
|
|
function throwIfAborted(signal) {
|
|
if (!signal?.aborted) {
|
|
return;
|
|
}
|
|
|
|
throw createAbortMediaError();
|
|
}
|
|
|
|
function encodeUtf16Le(value) {
|
|
const text = String(value ?? '');
|
|
const bytes = new Uint8Array(text.length * 2);
|
|
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
const code = text.charCodeAt(index);
|
|
bytes[index * 2] = code & 0xff;
|
|
bytes[index * 2 + 1] = code >>> 8;
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
function concatUint8Arrays(chunks) {
|
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
const merged = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
|
|
for (const chunk of chunks) {
|
|
merged.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function bytesEqual(left, right) {
|
|
if (left.length !== right.length) {
|
|
return false;
|
|
}
|
|
|
|
let diff = 0;
|
|
for (let index = 0; index < left.length; index += 1) {
|
|
diff |= left[index] ^ right[index];
|
|
}
|
|
|
|
return diff === 0;
|
|
}
|
|
|
|
function readUInt16BE(bytes, offset) {
|
|
return (bytes[offset] << 8) | bytes[offset + 1];
|
|
}
|
|
|
|
function normalizeMethod(method) {
|
|
if (typeof method === 'string') {
|
|
const lower = method.toLowerCase();
|
|
if (lower === 'deflate' || lower === '8') {
|
|
return 'deflate';
|
|
}
|
|
|
|
if (lower === 'store' || lower === '0') {
|
|
return 'store';
|
|
}
|
|
}
|
|
|
|
if (Number(method) === 8) {
|
|
return 'deflate';
|
|
}
|
|
|
|
if (Number(method) === 0) {
|
|
return 'store';
|
|
}
|
|
|
|
throw createMediaError('ZIP_COMPRESSION_UNSUPPORTED', `Unsupported ZIP compression method: ${method}`);
|
|
}
|
|
|
|
function ensureTrailingSlash(url) {
|
|
return url.endsWith('/') ? url : `${url}/`;
|
|
}
|
|
|
|
function sha256(bytes) {
|
|
return new Sha256Hasher().update(bytes).digest();
|
|
}
|
|
|
|
function hmacSha256(keyBytes, dataBytes) {
|
|
return new HmacSha256(keyBytes).update(dataBytes).digest();
|
|
}
|
|
|
|
function aesCbcDecrypt(keyBytes, ivBytes, dataBytes) {
|
|
try {
|
|
return new Uint8Array(new aesjs.ModeOfOperation.cbc(keyBytes, ivBytes).decrypt(dataBytes));
|
|
} catch (error) {
|
|
throw createMediaError('AESC_DECRYPTION_INVALID', 'AES-CBC decryption failed for this volume.');
|
|
}
|
|
}
|
|
|
|
function stretchPasswordV2(passphrase, externalIv) {
|
|
const passwordBytes = encodeUtf16Le(passphrase);
|
|
let digest = concatUint8Arrays([externalIv, new Uint8Array(16)]);
|
|
|
|
for (let index = 0; index < VERSION_2_ITERATIONS; index += 1) {
|
|
digest = sha256(concatUint8Arrays([digest, passwordBytes]));
|
|
}
|
|
|
|
return digest;
|
|
}
|
|
|
|
function bytesToHex(bytes) {
|
|
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function tryParseAesCryptHeaderPrefix(bytes) {
|
|
if (bytes.length < 5 || new TextDecoder().decode(bytes.subarray(0, 3)) !== HEADER_MAGIC) {
|
|
if (bytes.length < 5) {
|
|
return null;
|
|
}
|
|
|
|
throw createMediaError('AESC_HEADER_INVALID', 'Remote volume does not start with a valid AES Crypt header.');
|
|
}
|
|
|
|
const version = bytes[3];
|
|
const reserved = bytes[4];
|
|
if (reserved !== 0) {
|
|
throw createMediaError('AESC_HEADER_INVALID', 'AES Crypt header reserved byte is invalid.');
|
|
}
|
|
|
|
if (version !== 2) {
|
|
throw createMediaError('AESC_VERSION_UNSUPPORTED', `AES Crypt version ${version} is not supported.`);
|
|
}
|
|
|
|
let offset = 5;
|
|
while (true) {
|
|
if (offset + 2 > bytes.length) {
|
|
return null;
|
|
}
|
|
|
|
const extensionLength = readUInt16BE(bytes, offset);
|
|
offset += 2;
|
|
if (extensionLength === 0) {
|
|
break;
|
|
}
|
|
|
|
if (extensionLength < 2) {
|
|
throw createMediaError('AESC_HEADER_INVALID', 'AES Crypt extension record length was invalid.');
|
|
}
|
|
|
|
if (offset + extensionLength > bytes.length) {
|
|
return null;
|
|
}
|
|
|
|
offset += extensionLength;
|
|
}
|
|
|
|
const headerLength = offset + AES_BLOCK_SIZE + ENCRYPTED_SESSION_LENGTH + HEADER_HMAC_LENGTH;
|
|
if (headerLength > bytes.length) {
|
|
return null;
|
|
}
|
|
|
|
const externalIv = bytes.subarray(offset, offset + AES_BLOCK_SIZE);
|
|
offset += AES_BLOCK_SIZE;
|
|
const encryptedSession = bytes.subarray(offset, offset + ENCRYPTED_SESSION_LENGTH);
|
|
offset += ENCRYPTED_SESSION_LENGTH;
|
|
const headerHmac = bytes.subarray(offset, offset + HEADER_HMAC_LENGTH);
|
|
offset += HEADER_HMAC_LENGTH;
|
|
|
|
return {
|
|
headerLength: offset,
|
|
externalIv,
|
|
encryptedSession,
|
|
headerHmac,
|
|
headerProbeBytes: offset,
|
|
kdfIterations: VERSION_2_ITERATIONS,
|
|
streamFormat: 'v2'
|
|
};
|
|
}
|
|
|
|
function parseAesCryptHeader(bytes) {
|
|
const header = tryParseAesCryptHeaderPrefix(bytes);
|
|
if (!header || bytes.length < header.headerLength + FOOTER_TOTAL_LENGTH) {
|
|
throw createMediaError('AESC_FILE_TOO_SMALL', 'Encrypted volume is too small to contain a valid AES Crypt payload.');
|
|
}
|
|
|
|
return header;
|
|
}
|
|
|
|
function deriveAesCryptSession(passphrase, header) {
|
|
const stretchedKey = stretchPasswordV2(passphrase, header.externalIv);
|
|
const expectedHeaderHmac = hmacSha256(stretchedKey, header.encryptedSession);
|
|
if (!bytesEqual(expectedHeaderHmac, header.headerHmac)) {
|
|
throw createMediaError('AESC_INVALID_PASSPHRASE', 'Header HMAC mismatch. Check the passphrase.');
|
|
}
|
|
|
|
const sessionBytes = aesCbcDecrypt(stretchedKey, header.externalIv, header.encryptedSession);
|
|
if (sessionBytes.length !== ENCRYPTED_SESSION_LENGTH) {
|
|
throw createMediaError('AESC_HEADER_INVALID', 'Session payload length was invalid.');
|
|
}
|
|
|
|
return {
|
|
internalIv: sessionBytes.subarray(0, 16),
|
|
internalKey: sessionBytes.subarray(16),
|
|
meta: {
|
|
streamFormat: header.streamFormat,
|
|
headerProbeBytes: header.headerProbeBytes,
|
|
kdfIterations: header.kdfIterations,
|
|
ivHex: bytesToHex(header.externalIv)
|
|
}
|
|
};
|
|
}
|
|
|
|
function parseAesCryptFooter(bytes) {
|
|
if (bytes.length !== FOOTER_TOTAL_LENGTH) {
|
|
throw createMediaError('AESC_FILE_TOO_SMALL', 'Encrypted volume is too small to contain a valid footer.');
|
|
}
|
|
|
|
return {
|
|
fileSizeModulo: bytes[0],
|
|
payloadHmac: bytes.subarray(1)
|
|
};
|
|
}
|
|
|
|
export async function decryptAesCryptV2Bytes(encryptedBytes, passphrase) {
|
|
const bytes = encryptedBytes instanceof Uint8Array ? encryptedBytes : new Uint8Array(encryptedBytes);
|
|
const header = parseAesCryptHeader(bytes);
|
|
const footerOffset = bytes.length - FOOTER_TOTAL_LENGTH;
|
|
if (footerOffset <= header.headerLength) {
|
|
throw createMediaError('AESC_FILE_TOO_SMALL', 'Encrypted volume is too small to contain ciphertext.');
|
|
}
|
|
|
|
const fileSizeModulo = bytes[footerOffset];
|
|
const payloadHmac = bytes.subarray(footerOffset + 1);
|
|
const ciphertext = bytes.subarray(header.headerLength, footerOffset);
|
|
|
|
if (ciphertext.length % AES_BLOCK_SIZE !== 0) {
|
|
throw createMediaError('AESC_CIPHERTEXT_INVALID', 'Ciphertext length is invalid for AES-CBC.');
|
|
}
|
|
|
|
const session = deriveAesCryptSession(passphrase, header);
|
|
const actualPayloadHmac = hmacSha256(session.internalKey, ciphertext);
|
|
if (!bytesEqual(actualPayloadHmac, payloadHmac)) {
|
|
throw createMediaError('AESC_PAYLOAD_HMAC_MISMATCH', 'Payload HMAC mismatch. Remote volume may be corrupted.');
|
|
}
|
|
|
|
const paddedPlain = aesCbcDecrypt(session.internalKey, session.internalIv, ciphertext);
|
|
const finalLength = fileSizeModulo === 0 ? paddedPlain.length : paddedPlain.length - (AES_BLOCK_SIZE - fileSizeModulo);
|
|
if (finalLength < 0 || finalLength > paddedPlain.length) {
|
|
throw createMediaError('AESC_FOOTER_INVALID', 'Final plaintext length derived from the AES footer was invalid.');
|
|
}
|
|
|
|
return {
|
|
plainBytes: paddedPlain.subarray(0, finalLength),
|
|
meta: session.meta
|
|
};
|
|
}
|
|
|
|
export async function inflateRawBytes(compressedBytes) {
|
|
const stream = new Blob([compressedBytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
|
|
const buffer = await new Response(stream).arrayBuffer();
|
|
return new Uint8Array(buffer);
|
|
}
|
|
|
|
export async function extractZipEntryBytesFromBuffer(plainZipBytes, zipDescriptor) {
|
|
const bytes = plainZipBytes instanceof Uint8Array ? plainZipBytes : new Uint8Array(plainZipBytes);
|
|
const start = Number(zipDescriptor.dataOffsetPlain);
|
|
const end = start + Number(zipDescriptor.compressedSize);
|
|
const method = normalizeMethod(zipDescriptor.compressionMethod);
|
|
const compressed = bytes.subarray(start, end);
|
|
|
|
const restored =
|
|
method === 'store'
|
|
? compressed.slice()
|
|
: await inflateRawBytes(compressed);
|
|
|
|
const expectedSize = Number(zipDescriptor.uncompressedSize);
|
|
if (Number.isFinite(expectedSize) && restored.length !== expectedSize) {
|
|
throw createMediaError(
|
|
'ZIP_ENTRY_SIZE_MISMATCH',
|
|
`ZIP entry restored length ${restored.length} did not match expected ${expectedSize}.`
|
|
);
|
|
}
|
|
|
|
return restored;
|
|
}
|
|
|
|
export function parseSingleRange(rangeHeader, totalSize) {
|
|
const size = Number(totalSize);
|
|
if (!rangeHeader) {
|
|
return {
|
|
start: 0,
|
|
endInclusive: size - 1,
|
|
endExclusive: size,
|
|
contentLength: size,
|
|
partial: false
|
|
};
|
|
}
|
|
|
|
const header = String(rangeHeader).trim();
|
|
if (!header.startsWith('bytes=')) {
|
|
throw createMediaError('RANGE_NOT_SATISFIABLE', 'Only bytes ranges are supported.');
|
|
}
|
|
|
|
const ranges = header.slice(6).split(',');
|
|
if (ranges.length !== 1) {
|
|
throw createMediaError('RANGE_NOT_SATISFIABLE', 'Multiple byte ranges are not supported.');
|
|
}
|
|
|
|
const [startRaw, endRaw] = ranges[0].split('-');
|
|
const startText = startRaw?.trim() ?? '';
|
|
const endText = endRaw?.trim() ?? '';
|
|
|
|
let start;
|
|
let endInclusive;
|
|
|
|
if (!startText) {
|
|
const suffixLength = Number(endText);
|
|
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
|
|
throw createMediaError('RANGE_NOT_SATISFIABLE', 'Invalid suffix byte range.');
|
|
}
|
|
|
|
start = Math.max(size - suffixLength, 0);
|
|
endInclusive = size - 1;
|
|
} else {
|
|
start = Number(startText);
|
|
if (!Number.isFinite(start) || start < 0 || start >= size) {
|
|
throw createMediaError('RANGE_NOT_SATISFIABLE', 'Range start was outside the file size.');
|
|
}
|
|
|
|
if (!endText) {
|
|
endInclusive = size - 1;
|
|
} else {
|
|
endInclusive = Number(endText);
|
|
if (!Number.isFinite(endInclusive) || endInclusive < start) {
|
|
throw createMediaError('RANGE_NOT_SATISFIABLE', 'Range end was invalid.');
|
|
}
|
|
|
|
endInclusive = Math.min(endInclusive, size - 1);
|
|
}
|
|
}
|
|
|
|
return {
|
|
start,
|
|
endInclusive,
|
|
endExclusive: endInclusive + 1,
|
|
contentLength: endInclusive - start + 1,
|
|
partial: true
|
|
};
|
|
}
|
|
|
|
export function buildContentRange(start, endInclusive, totalSize) {
|
|
return `bytes ${start}-${endInclusive}/${totalSize}`;
|
|
}
|
|
|
|
export function buildVolumeMap(volumes = []) {
|
|
return new Map(volumes.map((volume) => [volume.volumeId, volume]));
|
|
}
|
|
|
|
export function selectSegmentsForRange(segments, start, endExclusive) {
|
|
return segments
|
|
.map((segment) => {
|
|
const segmentStart = Number(segment.logicalOffset);
|
|
const segmentEnd = segmentStart + Number(segment.logicalSize);
|
|
const sliceStart = Math.max(start, segmentStart);
|
|
const sliceEnd = Math.min(endExclusive, segmentEnd);
|
|
|
|
if (sliceEnd <= sliceStart) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
segment,
|
|
sliceStart,
|
|
sliceEnd,
|
|
offsetWithinSegmentStart: sliceStart - segmentStart,
|
|
offsetWithinSegmentEnd: sliceEnd - segmentStart
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export function isVideoMime(mime) {
|
|
return String(mime ?? '').toLowerCase().startsWith('video/');
|
|
}
|
|
|
|
export function supportsOpfs() {
|
|
return Boolean(globalThis.navigator?.storage?.getDirectory);
|
|
}
|
|
|
|
export function supportsVideoPreview() {
|
|
return Boolean(
|
|
globalThis.isSecureContext &&
|
|
globalThis.navigator?.serviceWorker &&
|
|
typeof globalThis.DecompressionStream === 'function' &&
|
|
supportsOpfs()
|
|
);
|
|
}
|
|
|
|
export function supportsFileDownload() {
|
|
return Boolean(
|
|
typeof globalThis.showSaveFilePicker === 'function' &&
|
|
typeof globalThis.DecompressionStream === 'function' &&
|
|
supportsOpfs()
|
|
);
|
|
}
|
|
|
|
function createEmptyCacheStats() {
|
|
return {
|
|
sourceCount: 0,
|
|
volumeCount: 0,
|
|
totalBytes: 0
|
|
};
|
|
}
|
|
|
|
async function getCacheRootDirectory() {
|
|
if (!supportsOpfs()) {
|
|
throw createMediaError('OPFS_UNSUPPORTED', 'Browser does not support Origin Private File System.');
|
|
}
|
|
|
|
const root = await navigator.storage.getDirectory();
|
|
return root.getDirectoryHandle('duplicati-media-cache', { create: true });
|
|
}
|
|
|
|
async function getSourceDirectoryHandle(sourceId, create = true) {
|
|
const cacheRoot = await getCacheRootDirectory();
|
|
return cacheRoot.getDirectoryHandle(String(sourceId), { create });
|
|
}
|
|
|
|
async function getPlainZipFileHandle(sourceId, volumeName, create = true) {
|
|
const sourceDirectory = await getSourceDirectoryHandle(sourceId, create);
|
|
return sourceDirectory.getFileHandle(String(volumeName), { create });
|
|
}
|
|
|
|
function buildTempVolumeFileName(volumeName) {
|
|
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
return `.__partial__.${encodeURIComponent(String(volumeName))}.${nonce}.tmp`;
|
|
}
|
|
|
|
function isNotFoundError(error) {
|
|
return error?.name === 'NotFoundError' || error?.code === 'ENOENT';
|
|
}
|
|
|
|
async function readFileSlice(fileHandle, start, endExclusive) {
|
|
const file = await fileHandle.getFile();
|
|
const buffer = await file.slice(start, endExclusive).arrayBuffer();
|
|
return new Uint8Array(buffer);
|
|
}
|
|
|
|
async function readEntireFile(fileHandle) {
|
|
const file = await fileHandle.getFile();
|
|
return new Uint8Array(await file.arrayBuffer());
|
|
}
|
|
|
|
function toUint8Array(value) {
|
|
if (value instanceof Uint8Array) {
|
|
return value;
|
|
}
|
|
|
|
return new Uint8Array(value);
|
|
}
|
|
|
|
async function summarizeDirectoryHandle(directoryHandle) {
|
|
const summary = createEmptyCacheStats();
|
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for await (const [, handle] of directoryHandle.entries()) {
|
|
if (handle.kind === 'file') {
|
|
const file = await handle.getFile();
|
|
summary.volumeCount += 1;
|
|
summary.totalBytes += Number(file.size ?? 0);
|
|
continue;
|
|
}
|
|
|
|
if (handle.kind === 'directory') {
|
|
summary.sourceCount += 1;
|
|
const nested = await summarizeDirectoryHandle(handle);
|
|
summary.sourceCount += nested.sourceCount;
|
|
summary.volumeCount += nested.volumeCount;
|
|
summary.totalBytes += nested.totalBytes;
|
|
}
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
async function collectDirectoryEntryNames(directoryHandle) {
|
|
const names = [];
|
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for await (const [name] of directoryHandle.entries()) {
|
|
names.push(name);
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
async function deleteCacheFileIfExists(sourceId, fileName) {
|
|
try {
|
|
const directory = await getSourceDirectoryHandle(sourceId, false);
|
|
await directory.removeEntry(String(fileName));
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readUntilAesHeader(reader) {
|
|
let buffered = new Uint8Array(0);
|
|
|
|
while (true) {
|
|
const header = tryParseAesCryptHeaderPrefix(buffered);
|
|
if (header) {
|
|
return {
|
|
header,
|
|
buffered
|
|
};
|
|
}
|
|
|
|
const { value, done } = await reader.read();
|
|
if (done) {
|
|
throw createMediaError('AESC_FILE_TOO_SMALL', 'Encrypted volume ended before a full AES Crypt header was available.');
|
|
}
|
|
|
|
buffered = concatUint8Arrays([buffered, toUint8Array(value)]);
|
|
}
|
|
}
|
|
|
|
function splitDecryptableCiphertext(bytes, keepFooter = true) {
|
|
const footerReserve = keepFooter ? FOOTER_TOTAL_LENGTH : 0;
|
|
const decryptableLength = Math.max(bytes.length - footerReserve, 0);
|
|
const alignedLength = decryptableLength - (decryptableLength % AES_BLOCK_SIZE);
|
|
|
|
return {
|
|
decryptable: bytes.subarray(0, alignedLength),
|
|
remaining: bytes.subarray(alignedLength)
|
|
};
|
|
}
|
|
|
|
function decryptCbcChunk(aesKey, initialVector, ciphertext) {
|
|
if (ciphertext.length === 0) {
|
|
return {
|
|
plainBytes: new Uint8Array(0),
|
|
lastCipherBlock: initialVector
|
|
};
|
|
}
|
|
|
|
if (ciphertext.length % AES_BLOCK_SIZE !== 0) {
|
|
throw createMediaError('AESC_CIPHERTEXT_INVALID', 'Ciphertext length is invalid for AES-CBC.');
|
|
}
|
|
|
|
const aes = new aesjs.AES(aesKey);
|
|
const plainBytes = new Uint8Array(ciphertext.length);
|
|
let previousCipherBlock = initialVector;
|
|
|
|
for (let offset = 0; offset < ciphertext.length; offset += AES_BLOCK_SIZE) {
|
|
const cipherBlock = ciphertext.subarray(offset, offset + AES_BLOCK_SIZE);
|
|
const decryptedBlock = aes.decrypt(cipherBlock);
|
|
|
|
for (let index = 0; index < AES_BLOCK_SIZE; index += 1) {
|
|
plainBytes[offset + index] = decryptedBlock[index] ^ previousCipherBlock[index];
|
|
}
|
|
|
|
previousCipherBlock = cipherBlock.slice();
|
|
}
|
|
|
|
return {
|
|
plainBytes,
|
|
lastCipherBlock: previousCipherBlock
|
|
};
|
|
}
|
|
|
|
async function writePlainChunkKeepingFinalBlock(writable, trailingBlock, plainChunk) {
|
|
const combined = trailingBlock.length > 0
|
|
? concatUint8Arrays([trailingBlock, plainChunk])
|
|
: plainChunk;
|
|
|
|
if (combined.length > AES_BLOCK_SIZE) {
|
|
await writable.write(combined.subarray(0, combined.length - AES_BLOCK_SIZE));
|
|
}
|
|
|
|
return combined.slice(Math.max(0, combined.length - AES_BLOCK_SIZE));
|
|
}
|
|
|
|
export async function decryptAesCryptV2StreamToWriter(
|
|
encryptedStream,
|
|
passphrase,
|
|
writable,
|
|
onStatus = () => {},
|
|
signal = null
|
|
) {
|
|
if (!encryptedStream?.getReader) {
|
|
throw createMediaError('AESC_STREAM_UNSUPPORTED', 'Encrypted volume response body is not stream-readable.');
|
|
}
|
|
|
|
const reader = encryptedStream.getReader();
|
|
try {
|
|
throwIfAborted(signal);
|
|
const { header, buffered } = await readUntilAesHeader(reader);
|
|
throwIfAborted(signal);
|
|
const session = deriveAesCryptSession(passphrase, header);
|
|
const payloadHmac = new HmacSha256(session.internalKey);
|
|
|
|
let pending = buffered.subarray(header.headerLength);
|
|
let previousCipherBlock = session.internalIv.slice();
|
|
let trailingPlainBlock = new Uint8Array(0);
|
|
let sawPlaintext = false;
|
|
|
|
onStatus({
|
|
phase: 'decrypting'
|
|
});
|
|
|
|
while (true) {
|
|
throwIfAborted(signal);
|
|
const { value, done } = await reader.read();
|
|
throwIfAborted(signal);
|
|
if (done) {
|
|
break;
|
|
}
|
|
|
|
pending = concatUint8Arrays([pending, toUint8Array(value)]);
|
|
const { decryptable, remaining } = splitDecryptableCiphertext(pending, true);
|
|
pending = remaining;
|
|
|
|
if (decryptable.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
payloadHmac.update(decryptable);
|
|
const decryptedChunk = decryptCbcChunk(session.internalKey, previousCipherBlock, decryptable);
|
|
previousCipherBlock = decryptedChunk.lastCipherBlock;
|
|
trailingPlainBlock = await writePlainChunkKeepingFinalBlock(writable, trailingPlainBlock, decryptedChunk.plainBytes);
|
|
sawPlaintext = true;
|
|
}
|
|
|
|
if (pending.length < FOOTER_TOTAL_LENGTH) {
|
|
throw createMediaError('AESC_FILE_TOO_SMALL', 'Encrypted volume is too small to contain ciphertext and footer.');
|
|
}
|
|
|
|
const footerOffset = pending.length - FOOTER_TOTAL_LENGTH;
|
|
const footer = parseAesCryptFooter(pending.subarray(footerOffset));
|
|
const finalCiphertext = pending.subarray(0, footerOffset);
|
|
if (finalCiphertext.length % AES_BLOCK_SIZE !== 0) {
|
|
throw createMediaError('AESC_CIPHERTEXT_INVALID', 'Ciphertext length is invalid for AES-CBC.');
|
|
}
|
|
|
|
if (finalCiphertext.length > 0) {
|
|
throwIfAborted(signal);
|
|
payloadHmac.update(finalCiphertext);
|
|
const finalDecryptedChunk = decryptCbcChunk(session.internalKey, previousCipherBlock, finalCiphertext);
|
|
trailingPlainBlock = await writePlainChunkKeepingFinalBlock(
|
|
writable,
|
|
trailingPlainBlock,
|
|
finalDecryptedChunk.plainBytes
|
|
);
|
|
sawPlaintext = true;
|
|
}
|
|
|
|
onStatus({
|
|
phase: 'verifying'
|
|
});
|
|
|
|
throwIfAborted(signal);
|
|
const actualPayloadHmac = payloadHmac.digest();
|
|
if (!bytesEqual(actualPayloadHmac, footer.payloadHmac)) {
|
|
throw createMediaError('AESC_PAYLOAD_HMAC_MISMATCH', 'Payload HMAC mismatch. Remote volume may be corrupted.');
|
|
}
|
|
|
|
if (trailingPlainBlock.length > 0) {
|
|
if (trailingPlainBlock.length < AES_BLOCK_SIZE) {
|
|
throw createMediaError('AESC_DECRYPTION_INVALID', 'The AES volume did not yield a complete final plaintext block.');
|
|
}
|
|
|
|
const finalLength = footer.fileSizeModulo === 0 ? trailingPlainBlock.length : footer.fileSizeModulo;
|
|
if (finalLength < 0 || finalLength > trailingPlainBlock.length) {
|
|
throw createMediaError('AESC_FOOTER_INVALID', 'Final plaintext length derived from the AES footer was invalid.');
|
|
}
|
|
|
|
throwIfAborted(signal);
|
|
await writable.write(trailingPlainBlock.subarray(0, finalLength));
|
|
} else if (sawPlaintext) {
|
|
throw createMediaError('AESC_DECRYPTION_INVALID', 'The AES volume produced no final plaintext block after decryption.');
|
|
}
|
|
|
|
return {
|
|
meta: session.meta
|
|
};
|
|
} catch (error) {
|
|
try {
|
|
await reader.cancel();
|
|
} catch (cancelError) {
|
|
// ignore reader cancel failures and surface the original error
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function buildAuthorizationHeaders(secrets) {
|
|
if (secrets.authMode !== 'basic') {
|
|
return {};
|
|
}
|
|
|
|
const credentialBytes = new TextEncoder().encode(`${secrets.username ?? ''}:${secrets.password ?? ''}`);
|
|
let binary = '';
|
|
for (const byte of credentialBytes) {
|
|
binary += String.fromCharCode(byte);
|
|
}
|
|
|
|
return {
|
|
Authorization: `Basic ${btoa(binary)}`
|
|
};
|
|
}
|
|
|
|
function buildRemoteUrl(baseUrl, volumeName) {
|
|
return new URL(volumeName, ensureTrailingSlash(baseUrl)).toString();
|
|
}
|
|
|
|
export async function listCachedVolumeNames(sourceId) {
|
|
try {
|
|
const directory = await getSourceDirectoryHandle(sourceId, false);
|
|
const names = [];
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for await (const [name, handle] of directory.entries()) {
|
|
if (handle.kind === 'file' && !String(name).startsWith('.__partial__.')) {
|
|
names.push(name);
|
|
}
|
|
}
|
|
|
|
return names.sort();
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return [];
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getCachedVolumeStats(sourceId) {
|
|
try {
|
|
const directory = await getSourceDirectoryHandle(sourceId, false);
|
|
return summarizeDirectoryHandle(directory);
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return createEmptyCacheStats();
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getGlobalCacheStats() {
|
|
try {
|
|
const cacheRoot = await getCacheRootDirectory();
|
|
const summary = createEmptyCacheStats();
|
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for await (const [, handle] of cacheRoot.entries()) {
|
|
if (handle.kind !== 'directory') {
|
|
continue;
|
|
}
|
|
|
|
summary.sourceCount += 1;
|
|
const sourceSummary = await summarizeDirectoryHandle(handle);
|
|
summary.volumeCount += sourceSummary.volumeCount;
|
|
summary.totalBytes += sourceSummary.totalBytes;
|
|
}
|
|
|
|
return summary;
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return createEmptyCacheStats();
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function clearCachedVolumesForSource(sourceId) {
|
|
try {
|
|
const cacheRoot = await getCacheRootDirectory();
|
|
const sourceDirectory = await getSourceDirectoryHandle(sourceId, false);
|
|
const summary = await summarizeDirectoryHandle(sourceDirectory);
|
|
await cacheRoot.removeEntry(String(sourceId), { recursive: true });
|
|
|
|
return {
|
|
removedSources: 1,
|
|
removedVolumes: summary.volumeCount,
|
|
removedBytes: summary.totalBytes
|
|
};
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return {
|
|
removedSources: 0,
|
|
removedVolumes: 0,
|
|
removedBytes: 0
|
|
};
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function clearAllCachedVolumes() {
|
|
try {
|
|
const cacheRoot = await getCacheRootDirectory();
|
|
const names = await collectDirectoryEntryNames(cacheRoot);
|
|
const summary = await getGlobalCacheStats();
|
|
|
|
for (const name of names) {
|
|
await cacheRoot.removeEntry(name, { recursive: true });
|
|
}
|
|
|
|
return {
|
|
removedSources: summary.sourceCount,
|
|
removedVolumes: summary.volumeCount,
|
|
removedBytes: summary.totalBytes
|
|
};
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return {
|
|
removedSources: 0,
|
|
removedVolumes: 0,
|
|
removedBytes: 0
|
|
};
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function publishTempVolumeFile(sourceId, tempFileName, volumeName, signal = null) {
|
|
throwIfAborted(signal);
|
|
const sourceDirectory = await getSourceDirectoryHandle(sourceId, true);
|
|
const tempHandle = await sourceDirectory.getFileHandle(String(tempFileName), { create: false });
|
|
const finalHandle = await sourceDirectory.getFileHandle(String(volumeName), { create: true });
|
|
const tempFile = await tempHandle.getFile();
|
|
const writable = await finalHandle.createWritable();
|
|
|
|
try {
|
|
throwIfAborted(signal);
|
|
await tempFile.stream().pipeTo(writable, signal ? { signal } : undefined);
|
|
} catch (error) {
|
|
try {
|
|
await writable.abort();
|
|
} catch (abortError) {
|
|
// ignore writable abort cleanup failures and surface the original error
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
await sourceDirectory.removeEntry(String(tempFileName));
|
|
return finalHandle;
|
|
}
|
|
|
|
export async function ensurePlainZipVolume({ sourceId, volume, secrets, onStatus = () => {}, signal = null }) {
|
|
throwIfAborted(signal);
|
|
try {
|
|
const fileHandle = await getPlainZipFileHandle(sourceId, volume.name, false);
|
|
return {
|
|
fileHandle,
|
|
cached: true
|
|
};
|
|
} catch (error) {
|
|
if (!isNotFoundError(error)) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (!secrets.webdavBaseUrl || !secrets.passphrase) {
|
|
throw createMediaError('CLIENT_SECRETS_MISSING', 'Browser-side WebDAV credentials and passphrase are required.');
|
|
}
|
|
|
|
onStatus({
|
|
phase: 'fetching',
|
|
currentVolume: volume.name
|
|
});
|
|
|
|
const response = await fetch(buildRemoteUrl(secrets.webdavBaseUrl, volume.name), {
|
|
headers: buildAuthorizationHeaders(secrets),
|
|
signal
|
|
});
|
|
if (!response.ok) {
|
|
throw createMediaError(
|
|
'WEBDAV_FETCH_FAILED',
|
|
`Fetching ${volume.name} failed with HTTP ${response.status}.`
|
|
);
|
|
}
|
|
const tempFileName = buildTempVolumeFileName(volume.name);
|
|
const sourceDirectory = await getSourceDirectoryHandle(sourceId, true);
|
|
const tempFileHandle = await sourceDirectory.getFileHandle(tempFileName, { create: true });
|
|
const writable = await tempFileHandle.createWritable();
|
|
let meta;
|
|
|
|
try {
|
|
if (response.body) {
|
|
const streamed = await decryptAesCryptV2StreamToWriter(
|
|
response.body,
|
|
secrets.passphrase,
|
|
writable,
|
|
(status) => {
|
|
onStatus({
|
|
...status,
|
|
currentVolume: volume.name
|
|
});
|
|
},
|
|
signal
|
|
);
|
|
meta = streamed.meta;
|
|
} else {
|
|
onStatus({
|
|
phase: 'decrypting',
|
|
currentVolume: volume.name
|
|
});
|
|
|
|
throwIfAborted(signal);
|
|
const encryptedBytes = new Uint8Array(await response.arrayBuffer());
|
|
throwIfAborted(signal);
|
|
const decrypted = await decryptAesCryptV2Bytes(encryptedBytes, secrets.passphrase);
|
|
throwIfAborted(signal);
|
|
await writable.write(decrypted.plainBytes);
|
|
meta = decrypted.meta;
|
|
}
|
|
|
|
throwIfAborted(signal);
|
|
await writable.close();
|
|
} catch (error) {
|
|
try {
|
|
await writable.abort();
|
|
} catch (abortError) {
|
|
// ignore abort cleanup failures and surface the original error
|
|
}
|
|
|
|
await deleteCacheFileIfExists(sourceId, tempFileName);
|
|
throw error;
|
|
}
|
|
|
|
let fileHandle;
|
|
try {
|
|
fileHandle = await publishTempVolumeFile(sourceId, tempFileName, volume.name, signal);
|
|
} catch (error) {
|
|
await deleteCacheFileIfExists(sourceId, tempFileName);
|
|
throw error;
|
|
}
|
|
|
|
onStatus({
|
|
phase: 'ready',
|
|
currentVolume: volume.name
|
|
});
|
|
|
|
return {
|
|
fileHandle,
|
|
cached: false,
|
|
meta
|
|
};
|
|
}
|
|
|
|
export async function readCachedZipEntryBytes({ sourceId, volumeName, zip, signal = null }) {
|
|
throwIfAborted(signal);
|
|
const fileHandle = await getPlainZipFileHandle(sourceId, volumeName, false);
|
|
const bytes = await readFileSlice(
|
|
fileHandle,
|
|
Number(zip.dataOffsetPlain),
|
|
Number(zip.dataOffsetPlain) + Number(zip.compressedSize)
|
|
);
|
|
throwIfAborted(signal);
|
|
|
|
const method = normalizeMethod(zip.compressionMethod);
|
|
const restored =
|
|
method === 'store'
|
|
? bytes
|
|
: await inflateRawBytes(bytes);
|
|
throwIfAborted(signal);
|
|
|
|
const expectedSize = Number(zip.uncompressedSize);
|
|
if (Number.isFinite(expectedSize) && restored.length !== expectedSize) {
|
|
throw createMediaError(
|
|
'ZIP_ENTRY_SIZE_MISMATCH',
|
|
`ZIP entry restored length ${restored.length} did not match expected ${expectedSize}.`
|
|
);
|
|
}
|
|
|
|
return restored;
|
|
}
|
|
|
|
export async function loadCachedPlainZipBytes(sourceId, volumeName) {
|
|
const fileHandle = await getPlainZipFileHandle(sourceId, volumeName, false);
|
|
return readEntireFile(fileHandle);
|
|
}
|
|
|
|
export async function restoreSegmentBytes({ sourceId, volume, zip, secrets, onStatus = () => {}, signal = null }) {
|
|
await ensurePlainZipVolume({
|
|
sourceId,
|
|
volume,
|
|
secrets,
|
|
onStatus,
|
|
signal
|
|
});
|
|
|
|
return readCachedZipEntryBytes({
|
|
sourceId,
|
|
volumeName: volume.name,
|
|
zip,
|
|
signal
|
|
});
|
|
}
|