diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 75375e6fe410..856a5e71928d 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -63,6 +63,28 @@ Signal Desktop makes use of the following open source projects. FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## @indutny/range-finder + + Copyright Fedor Indutny, 2022. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + ## @indutny/sneequals Copyright Fedor Indutny, 2022. diff --git a/app/attachment_channel.ts b/app/attachment_channel.ts index 028818f90071..cad9a21170ad 100644 --- a/app/attachment_channel.ts +++ b/app/attachment_channel.ts @@ -4,10 +4,10 @@ import { ipcMain, protocol } from 'electron'; import { createReadStream } from 'node:fs'; import { join, normalize } from 'node:path'; -import { Readable, Transform, PassThrough } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; +import { Readable, PassThrough } from 'node:stream'; import z from 'zod'; import * as rimraf from 'rimraf'; +import { RangeFinder, DefaultStorage } from '@indutny/range-finder'; import { getAllAttachments, getAvatarsPath, @@ -29,6 +29,7 @@ import { sleep } from '../ts/util/sleep'; import { isPathInside } from '../ts/util/isPathInside'; import { missingCaseError } from '../ts/util/missingCaseError'; import { safeParseInteger } from '../ts/util/numbers'; +import { SECOND } from '../ts/util/durations'; import { drop } from '../ts/util/drop'; import { strictAssert } from '../ts/util/assert'; import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto'; @@ -43,6 +44,81 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; const INTERACTIVITY_DELAY = 50; +type RangeFinderContextType = Readonly< + | { + type: 'ciphertext'; + path: string; + keysBase64: string; + size: number; + } + | { + type: 'plaintext'; + path: string; + } +>; + +async function safeDecryptToSink( + ...args: Parameters +): Promise { + try { + await decryptAttachmentV2ToSink(...args); + } catch (error) { + // These errors happen when canceling fetch from `attachment://` urls, + // ignore them to avoid noise in the logs. + if ( + error.name === 'AbortError' || + error.code === 'ERR_STREAM_PREMATURE_CLOSE' + ) { + return; + } + + console.error( + 'handleAttachmentRequest: decryption error', + Errors.toLogFormat(error) + ); + } +} + +const storage = new DefaultStorage( + ctx => { + if (ctx.type === 'plaintext') { + return createReadStream(ctx.path); + } + + if (ctx.type === 'ciphertext') { + const options = { + ciphertextPath: ctx.path, + idForLogging: 'attachment_channel', + keysBase64: ctx.keysBase64, + type: 'local' as const, + size: ctx.size, + }; + + const plaintext = new PassThrough(); + drop(safeDecryptToSink(options, plaintext)); + return plaintext; + } + + throw missingCaseError(ctx); + }, + { + maxSize: 10, + ttl: SECOND, + cacheKey: ctx => { + if (ctx.type === 'ciphertext') { + return `${ctx.type}:${ctx.path}:${ctx.size}:${ctx.keysBase64}`; + } + if (ctx.type === 'plaintext') { + return `${ctx.type}:${ctx.path}`; + } + throw missingCaseError(ctx); + }, + } +); +const rangeFinder = new RangeFinder(storage, { + noActiveReuse: true, +}); + const dispositionSchema = z.enum([ 'attachment', 'temporary', @@ -310,81 +386,58 @@ export async function handleAttachmentRequest(req: Request): Promise { } } + let context: RangeFinderContextType; + // Legacy plaintext attachments if (url.host === 'v1') { + context = { + type: 'plaintext', + path, + }; + } else { + // Encrypted attachments + + // Get AES+MAC key + const maybeKeysBase64 = url.searchParams.get('key'); + if (maybeKeysBase64 == null) { + return new Response('Missing key', { status: 400 }); + } + + // Size is required for trimming padding + if (maybeSize == null) { + return new Response('Missing size', { status: 400 }); + } + + context = { + type: 'ciphertext', + path, + keysBase64: maybeKeysBase64, + size: maybeSize, + }; + } + + try { return handleRangeRequest({ request: req, size: maybeSize, - plaintext: createReadStream(path), + context, }); + } catch (error) { + console.error('handleAttachmentRequest: error', Errors.toLogFormat(error)); + throw error; } - - // Encrypted attachments - - // Get AES+MAC key - const maybeKeysBase64 = url.searchParams.get('key'); - if (maybeKeysBase64 == null) { - return new Response('Missing key', { status: 400 }); - } - - // Size is required for trimming padding - if (maybeSize == null) { - return new Response('Missing size', { status: 400 }); - } - - // Pacify typescript - const size = maybeSize; - const keysBase64 = maybeKeysBase64; - - const plaintext = new PassThrough(); - - async function runSafe(): Promise { - try { - await decryptAttachmentV2ToSink( - { - ciphertextPath: path, - idForLogging: 'attachment_channel', - keysBase64, - type: 'local', - size, - }, - plaintext - ); - } catch (error) { - plaintext.destroy(error); - - // These errors happen when canceling fetch from `attachment://` urls, - // ignore them to avoid noise in the logs. - if (error.name === 'AbortError') { - return; - } - - console.error( - 'handleAttachmentRequest: decryption error', - Errors.toLogFormat(error) - ); - } - } - - drop(runSafe()); - - return handleRangeRequest({ - request: req, - size: maybeSize, - plaintext, - }); } type HandleRangeRequestOptionsType = Readonly<{ request: Request; size: number | undefined; - plaintext: Readable; + context: RangeFinderContextType; }>; function handleRangeRequest({ request, size, - plaintext, + context, }: HandleRangeRequestOptionsType): Response { const url = new URL(request.url); @@ -401,6 +454,7 @@ function handleRangeRequest({ } const create200Response = (): Response => { + const plaintext = rangeFinder.get(0, context); return new Response(Readable.toWeb(plaintext) as ReadableStream, { status: 200, headers, @@ -412,7 +466,8 @@ function handleRangeRequest({ return create200Response(); } - const match = range.match(/^bytes=(\d+)-(\d+)?$/); + // Chromium only sends open-ended ranges: "start-" + const match = range.match(/^bytes=(\d+)-$/); if (match == null) { console.error(`attachment_channel: invalid range header: ${range}`); return create200Response(); @@ -424,68 +479,16 @@ function handleRangeRequest({ return create200Response(); } - let endParam: number | undefined; - if (match[2] != null) { - const intValue = safeParseInteger(match[2]); - if (intValue == null) { - console.error(`attachment_channel: invalid range header: ${range}`); - return create200Response(); - } - endParam = intValue; - } - const start = Math.min(startParam, size || Infinity); - let end: number; - if (endParam === undefined) { - end = size || Infinity; - } else { - // Supplied range is inclusive - end = Math.min(endParam + 1, size || Infinity); + + headers['content-range'] = `bytes ${start}-/${size ?? '*'}`; + + if (size !== undefined) { + headers['content-length'] = (size - start).toString(); } - let offset = 0; - const transform = new Transform({ - transform(data, _enc, callback) { - if (offset + data.byteLength >= start && offset <= end) { - this.push(data.subarray(Math.max(0, start - offset), end - offset)); - } - - offset += data.byteLength; - callback(); - }, - }); - - headers['content-range'] = - size === undefined - ? `bytes ${start}-${endParam === undefined ? '' : end - 1}/*` - : `bytes ${start}-${end - 1}/${size}`; - - if (endParam !== undefined || size !== undefined) { - headers['content-length'] = (end - start).toString(); - } - - drop( - (async () => { - try { - await pipeline(plaintext, transform); - } catch (error) { - transform.destroy(error); - - // These errors happen when canceling fetch from `attachment://` urls, - // ignore them to avoid noise in the logs. - if (error.name === 'AbortError') { - return; - } - - console.error( - 'handleAttachmentRequest: range transform error', - Errors.toLogFormat(error) - ); - } - })() - ); - - return new Response(Readable.toWeb(transform) as ReadableStream, { + const stream = rangeFinder.get(start, context); + return new Response(Readable.toWeb(stream) as ReadableStream, { status: 206, headers, }); diff --git a/package-lock.json b/package-lock.json index 1dac8f756eff..8a30e3553f02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@formatjs/icu-messageformat-parser": "2.3.0", "@formatjs/intl-localematcher": "0.2.32", "@indutny/dicer": "0.3.2", + "@indutny/range-finder": "1.3.0", "@indutny/sneequals": "4.0.0", "@nodert-win10-rs4/windows.data.xml.dom": "0.4.4", "@nodert-win10-rs4/windows.ui.notifications": "0.4.4", @@ -4077,6 +4078,40 @@ "node": ">= 10" } }, + "node_modules/@indutny/range-finder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@indutny/range-finder/-/range-finder-1.3.0.tgz", + "integrity": "sha512-nQ/rnV4824CSkONVpbPdi4YA69cgG7uzqwz8snbU8Mz80o0KFcYC7lYvsSpQvT6TwxI1XY1J92wMns+lWSoiFQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.5.2" + } + }, + "node_modules/@indutny/range-finder/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@indutny/range-finder/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/@indutny/rezip-electron": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@indutny/rezip-electron/-/rezip-electron-1.3.1.tgz", diff --git a/package.json b/package.json index 36ef48d3790c..39b0734bc118 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@formatjs/icu-messageformat-parser": "2.3.0", "@formatjs/intl-localematcher": "0.2.32", "@indutny/dicer": "0.3.2", + "@indutny/range-finder": "1.3.0", "@indutny/sneequals": "4.0.0", "@nodert-win10-rs4/windows.data.xml.dom": "0.4.4", "@nodert-win10-rs4/windows.ui.notifications": "0.4.4", diff --git a/ts/AttachmentCrypto.ts b/ts/AttachmentCrypto.ts index ab40b4bd6895..71afdede0617 100644 --- a/ts/AttachmentCrypto.ts +++ b/ts/AttachmentCrypto.ts @@ -442,7 +442,10 @@ export async function decryptAttachmentV2ToSink( } catch (error) { // These errors happen when canceling fetch from `attachment://` urls, // ignore them to avoid noise in the logs. - if (error.name === 'AbortError') { + if ( + error.name === 'AbortError' || + error.code === 'ERR_STREAM_PREMATURE_CLOSE' + ) { throw error; }