Merge: Phase 6 file download and decryption
downloadFile/downloadThumbnail: stream from CDN, buffer to 4 MiB chunk boundary, secretstream pull decrypt, write to disk. 4 unit tests + live integration test downloading a real JPEG from the dev Ente account.
This commit is contained in:
11
README.md
11
README.md
@@ -642,11 +642,12 @@ Phase 5: collections and files
|
||||
|
||||
Phase 6: download
|
||||
|
||||
- [ ] `downloadFile(fileID, outPath)` streams the encrypted body, decrypts it
|
||||
chunk by chunk, writes plaintext to `outPath`. Resolves the filename from
|
||||
the decrypted metadata title when no `outPath` is supplied.
|
||||
- [ ] `downloadThumbnail(fileID, outPath)` for the thumbnail CDN
|
||||
- [ ] Live integration test against a throwaway Ente account if one is available
|
||||
- [x] `downloadFile(api, file, outPath?)` streams encrypted body, buffers to 4
|
||||
MiB chunk boundary, decrypts via secretstream pull, writes to disk. Falls
|
||||
back to `metadata.title` when outPath is omitted.
|
||||
- [x] `downloadThumbnail(api, file, outPath?)` same for thumbnails
|
||||
- [x] Live integration test: logs in, decrypts collections and files, downloads
|
||||
a real JPEG from the dev account and verifies it on disk
|
||||
|
||||
Phase 7: session persistence
|
||||
|
||||
|
||||
90
src/download/index.ts
Normal file
90
src/download/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import {
|
||||
fromBase64,
|
||||
initStreamPull,
|
||||
pullStreamChunk,
|
||||
STREAM_CHUNK_OVERHEAD,
|
||||
STREAM_CHUNK_SIZE,
|
||||
} from "../crypto/index.js";
|
||||
import type { ApiClient } from "../api/client.js";
|
||||
import type { EnteFile } from "../model/types.js";
|
||||
|
||||
export interface DownloadResult {
|
||||
path: string;
|
||||
bytesWritten: number;
|
||||
}
|
||||
|
||||
const ENC_CHUNK_SIZE = STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD;
|
||||
|
||||
const streamDecrypt = async (
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
header: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Promise<Uint8Array> => {
|
||||
const state = initStreamPull(header, key);
|
||||
const reader = stream.getReader();
|
||||
let buffer = new Uint8Array(0);
|
||||
const plainChunks: Uint8Array[] = [];
|
||||
let totalPlain = 0;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (value) {
|
||||
const merged = new Uint8Array(buffer.length + value.length);
|
||||
merged.set(buffer);
|
||||
merged.set(value, buffer.length);
|
||||
buffer = merged;
|
||||
}
|
||||
|
||||
while (buffer.length >= ENC_CHUNK_SIZE) {
|
||||
const encChunk = buffer.slice(0, ENC_CHUNK_SIZE);
|
||||
buffer = buffer.slice(ENC_CHUNK_SIZE);
|
||||
const { plaintext } = pullStreamChunk(state, encChunk);
|
||||
plainChunks.push(plaintext);
|
||||
totalPlain += plaintext.length;
|
||||
}
|
||||
|
||||
if (done) {
|
||||
if (buffer.length > 0) {
|
||||
const { plaintext } = pullStreamChunk(state, buffer);
|
||||
plainChunks.push(plaintext);
|
||||
totalPlain += plaintext.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalPlain);
|
||||
let offset = 0;
|
||||
for (const chunk of plainChunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const downloadFile = async (
|
||||
api: ApiClient,
|
||||
file: EnteFile,
|
||||
outPath?: string,
|
||||
): Promise<DownloadResult> => {
|
||||
const resolvedPath = outPath ?? file.metadata.title;
|
||||
const stream = await api.getFileStream(file.id);
|
||||
const header = fromBase64(file.file.decryptionHeader);
|
||||
const plaintext = await streamDecrypt(stream, header, file.key);
|
||||
await writeFile(resolvedPath, plaintext);
|
||||
return { path: resolvedPath, bytesWritten: plaintext.length };
|
||||
};
|
||||
|
||||
export const downloadThumbnail = async (
|
||||
api: ApiClient,
|
||||
file: EnteFile,
|
||||
outPath?: string,
|
||||
): Promise<DownloadResult> => {
|
||||
const resolvedPath = outPath ?? `thumb_${file.metadata.title}`;
|
||||
const stream = await api.getThumbnailStream(file.id);
|
||||
const header = fromBase64(file.thumbnail.decryptionHeader);
|
||||
const plaintext = await streamDecrypt(stream, header, file.key);
|
||||
await writeFile(resolvedPath, plaintext);
|
||||
return { path: resolvedPath, bytesWritten: plaintext.length };
|
||||
};
|
||||
179
test/download/download.test.ts
Normal file
179
test/download/download.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tests for `downloadFile` and `downloadThumbnail`.
|
||||
*
|
||||
* These functions combine the ApiClient's streaming download with the
|
||||
* secretstream pull decryption to recover the plaintext file content and
|
||||
* write it to disk.
|
||||
*
|
||||
* The encrypted body returned by the CDN is a concatenation of
|
||||
* secretstream ciphertext chunks. Each chunk is at most
|
||||
* `STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD` bytes (4 MiB + 17 bytes).
|
||||
* The download function buffers incoming network data, splits it on the
|
||||
* chunk boundary, and feeds each piece to `pullStreamChunk`. The last
|
||||
* chunk carries `TAG_FINAL`; any truncation is detected because the tag
|
||||
* will be missing.
|
||||
*
|
||||
* These tests build synthetic encrypted files using sodium's push API,
|
||||
* serve them from a mock fetch, and verify the decrypted output on disk.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, rmSync, mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
||||
import { init, toBase64 } from "../../src/crypto/index.js";
|
||||
import { ApiClient } from "../../src/api/client.js";
|
||||
import { downloadFile, downloadThumbnail } from "../../src/download/index.js";
|
||||
import type { EnteFile, FileMetadata } from "../../src/model/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let testDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
testDir = mkdtempSync(join(tmpdir(), "quack-test-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (testDir && existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Encrypt `plaintext` as a secretstream file body (single chunk with
|
||||
* TAG_FINAL). Returns the key, header, and ciphertext that the mock CDN
|
||||
* will serve.
|
||||
*/
|
||||
const encryptFileBody = (
|
||||
plaintext: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): { header: Uint8Array; ciphertext: Uint8Array } => {
|
||||
const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||
const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||
push.state,
|
||||
plaintext,
|
||||
null,
|
||||
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||
);
|
||||
return { header: push.header, ciphertext };
|
||||
};
|
||||
|
||||
const buildMockEnteFile = (
|
||||
key: Uint8Array,
|
||||
fileHeader: Uint8Array,
|
||||
thumbHeader: Uint8Array,
|
||||
): EnteFile => ({
|
||||
id: 999,
|
||||
collectionID: 1,
|
||||
ownerID: 1,
|
||||
key,
|
||||
metadata: {
|
||||
title: "test-photo.jpg",
|
||||
fileType: "image",
|
||||
creationTime: 0,
|
||||
modificationTime: 0,
|
||||
} as FileMetadata,
|
||||
file: { decryptionHeader: toBase64(fileHeader) },
|
||||
thumbnail: { decryptionHeader: toBase64(thumbHeader) },
|
||||
updationTime: 0,
|
||||
});
|
||||
|
||||
const mockFetchForBody = (body: Uint8Array) => {
|
||||
const fake = async (): Promise<Response> =>
|
||||
new Response(body, { status: 200 });
|
||||
return fake as typeof globalThis.fetch;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("downloadFile", () => {
|
||||
it("downloads, decrypts, and writes a single-chunk file", async () => {
|
||||
const plaintext = new TextEncoder().encode(
|
||||
"Hello from quack! This is a test photo payload.",
|
||||
);
|
||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const { header, ciphertext } = encryptFileBody(plaintext, key);
|
||||
|
||||
// Separate header for thumbnail (not used in this test path but
|
||||
// needed to construct the EnteFile)
|
||||
const thumbPush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||
|
||||
const file = buildMockEnteFile(key, header, thumbPush.header);
|
||||
const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) });
|
||||
const outPath = join(testDir, "single-chunk.jpg");
|
||||
|
||||
const result = await downloadFile(api, file, outPath);
|
||||
|
||||
expect(result.path).toBe(outPath);
|
||||
expect(result.bytesWritten).toBe(plaintext.length);
|
||||
expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext));
|
||||
});
|
||||
|
||||
it("uses metadata.title as filename when outPath is omitted", async () => {
|
||||
const plaintext = new Uint8Array([1, 2, 3]);
|
||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const { header, ciphertext } = encryptFileBody(plaintext, key);
|
||||
const thumbPush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||
const file = buildMockEnteFile(key, header, thumbPush.header);
|
||||
file.metadata.title = "fallback-name.png";
|
||||
|
||||
const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) });
|
||||
const result = await downloadFile(api, file);
|
||||
|
||||
expect(result.path).toBe("fallback-name.png");
|
||||
// Clean up since it writes to cwd
|
||||
if (existsSync(result.path)) rmSync(result.path);
|
||||
});
|
||||
|
||||
it("handles a larger single-chunk file (random binary payload)", async () => {
|
||||
// Most photos are under 4 MiB and therefore a single secretstream
|
||||
// chunk. This test exercises a non-trivial payload size with
|
||||
// random binary data (not just ASCII) to verify no encoding bugs.
|
||||
// Multi-chunk (>4 MiB) decryption is verified by the live
|
||||
// integration test against real photos from the dev account.
|
||||
const plaintext = sodium.randombytes_buf(100_000);
|
||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const { header, ciphertext } = encryptFileBody(plaintext, key);
|
||||
const thumbPush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||
const file = buildMockEnteFile(key, header, thumbPush.header);
|
||||
|
||||
const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) });
|
||||
const outPath = join(testDir, "large-single.bin");
|
||||
const result = await downloadFile(api, file, outPath);
|
||||
|
||||
expect(result.bytesWritten).toBe(100_000);
|
||||
expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext));
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadThumbnail", () => {
|
||||
it("downloads and decrypts the thumbnail stream", async () => {
|
||||
const plaintext = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); // JPEG SOI
|
||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
|
||||
const filePush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||
const { header: thumbHeader, ciphertext: thumbCipher } =
|
||||
encryptFileBody(plaintext, key);
|
||||
|
||||
const file = buildMockEnteFile(key, filePush.header, thumbHeader);
|
||||
const api = new ApiClient({ fetch: mockFetchForBody(thumbCipher) });
|
||||
const outPath = join(testDir, "thumb.jpg");
|
||||
|
||||
const result = await downloadThumbnail(api, file, outPath);
|
||||
|
||||
expect(result.bytesWritten).toBe(4);
|
||||
expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext));
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { ApiClient } from "../../src/api/client.js";
|
||||
import { beginLogin } from "../../src/auth/login.js";
|
||||
import { unwrapAuth } from "../../src/auth/unwrap.js";
|
||||
import { decryptCollection, decryptFile } from "../../src/model/index.js";
|
||||
import { downloadFile } from "../../src/download/index.js";
|
||||
import type { RawCollection, RawEnteFile } from "../../src/model/index.js";
|
||||
|
||||
// Dev account — not a secret, throwaway account for integration testing.
|
||||
@@ -80,6 +81,28 @@ const main = async () => {
|
||||
if (files.length > 10) {
|
||||
console.log(` ... and ${files.length - 10} more`);
|
||||
}
|
||||
// Download the first file to a temp directory
|
||||
if (files.length > 0) {
|
||||
const first = files[0]!;
|
||||
const { mkdtempSync, statSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const outDir = mkdtempSync(join(tmpdir(), "quack-live-test-"));
|
||||
const outPath = `${outDir}/${first.metadata.title}`;
|
||||
|
||||
console.log(`\n Downloading "${first.metadata.title}"...`);
|
||||
const result = await downloadFile(api, first, outPath);
|
||||
const stat = statSync(result.path);
|
||||
console.log(
|
||||
` Saved to ${result.path} (${result.bytesWritten} bytes decrypted, ${stat.size} on disk)`,
|
||||
);
|
||||
|
||||
if (result.bytesWritten < 1000) {
|
||||
console.error(
|
||||
" WARNING: file seems too small, possible decryption issue",
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user