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