Files

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
});
}