Verify secretstream TAG_FINAL and write downloads atomically (closes #1) #20

Merged
clawbot merged 4 commits from download-tag-final-atomic-write into main 2026-08-09 04:59:44 +02:00
5 changed files with 129 additions and 29 deletions
Showing only changes of commit 99905277a3 - Show all commits

View File

@@ -20,6 +20,10 @@ downloads, cover it with mock-server tests, and update the README TODO checkbox.
# Completed Steps # Completed Steps
- 2026-08-09: Downloads verify the secretstream terminated on `TAG_FINAL` and
write output atomically: a truncated body is rejected instead of landing on
disk as a short file, and plaintext is staged in a sibling temp file and
renamed into place, so a failed download leaves the destination untouched.
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section shims, README Entrypoints section
- 2026-06-10: Decrypted collections shared by other users (sealed-box keys); - 2026-06-10: Decrypted collections shared by other users (sealed-box keys);

View File

@@ -14,5 +14,6 @@ export {
pullStreamChunk, pullStreamChunk,
STREAM_CHUNK_OVERHEAD, STREAM_CHUNK_OVERHEAD,
STREAM_CHUNK_SIZE, STREAM_CHUNK_SIZE,
STREAM_TAG_FINAL,
type StreamPullState, type StreamPullState,
} from "./stream.js"; } from "./stream.js";

View File

@@ -8,6 +8,18 @@ export const STREAM_CHUNK_SIZE = 4 * 1024 * 1024;
// 16 bytes of Poly1305 tag plus 1 byte of secretstream tag. // 16 bytes of Poly1305 tag plus 1 byte of secretstream tag.
export const STREAM_CHUNK_OVERHEAD = 17; export const STREAM_CHUNK_OVERHEAD = 17;
// The secretstream tag that marks the last chunk of a stream, i.e.
// libsodium's crypto_secretstream_xchacha20poly1305_TAG_FINAL. Exported so
// callers (the download layer) can detect truncation without importing
// sodium themselves.
//
// Written as a literal because libsodium only attaches its constants to the
// module object once `sodium.ready` has resolved, which is long after this
// module is evaluated. The value is fixed at 3 by the secretstream wire
// format; test/crypto/stream.test.ts pins it against libsodium's own
// constant so the two cannot drift apart unnoticed.
export const STREAM_TAG_FINAL = 3;
// Encrypt a small blob as a single secretstream chunk with TAG_FINAL. // Encrypt a small blob as a single secretstream chunk with TAG_FINAL.
// Returns the header and ciphertext. Used for encrypting thumbnails // Returns the header and ciphertext. Used for encrypting thumbnails
// and metadata before upload. // and metadata before upload.
@@ -37,9 +49,6 @@ export const initStreamPull = (
): StreamPullState => ): StreamPullState =>
sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key); sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key);
// Decrypt one ciphertext chunk. Returns the plaintext and the secretstream
// tag (0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL). The caller should verify the
// stream ended on TAG_FINAL to detect truncation.
// Decrypt a small blob that was encrypted as a single secretstream chunk // Decrypt a small blob that was encrypted as a single secretstream chunk
// with TAG_FINAL. Ente uses this form ("blob") for file metadata and // with TAG_FINAL. Ente uses this form ("blob") for file metadata and
// magic metadata — anything under ~1 MiB that isn't chunked. // magic metadata — anything under ~1 MiB that isn't chunked.
@@ -50,12 +59,15 @@ export const decryptBlob = (
): Uint8Array => { ): Uint8Array => {
const state = initStreamPull(header, key); const state = initStreamPull(header, key);
const { plaintext, tag } = pullStreamChunk(state, ciphertext); const { plaintext, tag } = pullStreamChunk(state, ciphertext);
if (tag !== sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL) { if (tag !== STREAM_TAG_FINAL) {
throw new Error(`decryptBlob: expected TAG_FINAL (3), got tag ${tag}`); throw new Error(`decryptBlob: expected TAG_FINAL (3), got tag ${tag}`);
} }
return plaintext; return plaintext;
}; };
// Decrypt one ciphertext chunk. Returns the plaintext and the secretstream
// tag (0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL). The caller must verify the
// stream ended on TAG_FINAL to detect truncation.
export const pullStreamChunk = ( export const pullStreamChunk = (
state: StreamPullState, state: StreamPullState,
ciphertext: Uint8Array, ciphertext: Uint8Array,

View File

@@ -1,10 +1,13 @@
import { writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto";
import { rename, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { import {
fromBase64, fromBase64,
initStreamPull, initStreamPull,
pullStreamChunk, pullStreamChunk,
STREAM_CHUNK_OVERHEAD, STREAM_CHUNK_OVERHEAD,
STREAM_CHUNK_SIZE, STREAM_CHUNK_SIZE,
STREAM_TAG_FINAL,
} from "../crypto/index.js"; } from "../crypto/index.js";
import type { ApiClient } from "../api/client.js"; import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js"; import type { EnteFile } from "../model/types.js";
@@ -26,6 +29,8 @@ const streamDecrypt = async (
let buffer = new Uint8Array(0); let buffer = new Uint8Array(0);
const plainChunks: Uint8Array[] = []; const plainChunks: Uint8Array[] = [];
let totalPlain = 0; let totalPlain = 0;
let chunksPulled = 0;
let lastTag = -1;
for (;;) { for (;;) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -39,21 +44,41 @@ const streamDecrypt = async (
while (buffer.length >= ENC_CHUNK_SIZE) { while (buffer.length >= ENC_CHUNK_SIZE) {
const encChunk = buffer.slice(0, ENC_CHUNK_SIZE); const encChunk = buffer.slice(0, ENC_CHUNK_SIZE);
buffer = buffer.slice(ENC_CHUNK_SIZE); buffer = buffer.slice(ENC_CHUNK_SIZE);
const { plaintext } = pullStreamChunk(state, encChunk); const { plaintext, tag } = pullStreamChunk(state, encChunk);
plainChunks.push(plaintext); plainChunks.push(plaintext);
totalPlain += plaintext.length; totalPlain += plaintext.length;
chunksPulled++;
lastTag = tag;
} }
if (done) { if (done) {
if (buffer.length > 0) { if (buffer.length > 0) {
const { plaintext } = pullStreamChunk(state, buffer); const { plaintext, tag } = pullStreamChunk(state, buffer);
plainChunks.push(plaintext); plainChunks.push(plaintext);
totalPlain += plaintext.length; totalPlain += plaintext.length;
chunksPulled++;
lastTag = tag;
} }
break; break;
} }
} }
// Only the last chunk of a secretstream carries TAG_FINAL. Everything a
// dropped connection did deliver still decrypts and authenticates, so the
// absence of TAG_FINAL is the only evidence that the body was cut short.
// Returning a short plaintext here would put a corrupt file on disk that
// later backup runs would treat as complete.
if (chunksPulled === 0) {
throw new Error(
"download: stream truncated: response body contained no secretstream chunks",
);
}
if (lastTag !== STREAM_TAG_FINAL) {
throw new Error(
`download: stream truncated: last chunk tag ${lastTag}, expected TAG_FINAL (${STREAM_TAG_FINAL})`,
);
}
const result = new Uint8Array(totalPlain); const result = new Uint8Array(totalPlain);
let offset = 0; let offset = 0;
for (const chunk of plainChunks) { for (const chunk of plainChunks) {
@@ -63,6 +88,29 @@ const streamDecrypt = async (
return result; return result;
}; };
// Write `plaintext` to `destination` atomically: stage it in a temporary
// sibling file (same directory, so the rename cannot cross a filesystem
// boundary) and rename it into place. Callers therefore never observe a
// partially written destination, and a pre-existing file at that path is
// replaced only once the new contents are complete on disk.
const writeAtomic = async (
destination: string,
plaintext: Uint8Array,
): Promise<void> => {
// The random suffix keeps concurrent downloads of the same destination
// from stepping on each other's temporary file.
const tmpPath = join(dirname(destination), `.quak-${randomUUID()}.tmp`);
try {
await writeFile(tmpPath, plaintext);
await rename(tmpPath, destination);
} catch (err) {
// Best-effort cleanup. A failure to remove the temporary file must
// never replace the error that actually explains what went wrong.
await rm(tmpPath, { force: true }).catch(() => undefined);
throw err;
}
};
export const downloadFile = async ( export const downloadFile = async (
api: ApiClient, api: ApiClient,
file: EnteFile, file: EnteFile,
@@ -72,7 +120,7 @@ export const downloadFile = async (
const stream = await api.getFileStream(file.id); const stream = await api.getFileStream(file.id);
const header = fromBase64(file.file.decryptionHeader); const header = fromBase64(file.file.decryptionHeader);
const plaintext = await streamDecrypt(stream, header, file.key); const plaintext = await streamDecrypt(stream, header, file.key);
await writeFile(resolvedPath, plaintext); await writeAtomic(resolvedPath, plaintext);
return { path: resolvedPath, bytesWritten: plaintext.length }; return { path: resolvedPath, bytesWritten: plaintext.length };
}; };
@@ -85,6 +133,6 @@ export const downloadThumbnail = async (
const stream = await api.getThumbnailStream(file.id); const stream = await api.getThumbnailStream(file.id);
const header = fromBase64(file.thumbnail.decryptionHeader); const header = fromBase64(file.thumbnail.decryptionHeader);
const plaintext = await streamDecrypt(stream, header, file.key); const plaintext = await streamDecrypt(stream, header, file.key);
await writeFile(resolvedPath, plaintext); await writeAtomic(resolvedPath, plaintext);
return { path: resolvedPath, bytesWritten: plaintext.length }; return { path: resolvedPath, bytesWritten: plaintext.length };
}; };

View File

@@ -45,6 +45,7 @@ import {
} from "node:fs"; } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { createHash } from "node:crypto";
import sodium from "libsodium-wrappers-sumo"; import sodium from "libsodium-wrappers-sumo";
import { beforeAll, afterAll, describe, expect, it } from "vitest"; import { beforeAll, afterAll, describe, expect, it } from "vitest";
import { init, toBase64, STREAM_CHUNK_SIZE } from "../../src/crypto/index.js"; import { init, toBase64, STREAM_CHUNK_SIZE } from "../../src/crypto/index.js";
@@ -188,6 +189,42 @@ const mockFetchForBody = (body: Uint8Array) => {
return fake as typeof globalThis.fetch; return fake as typeof globalThis.fetch;
}; };
/**
* Encrypt a body consisting of one chunk that is *not* tagged TAG_FINAL.
*
* This is the cheap way to present a stream that ended without its final
* chunk: the downloader pulls it, authenticates it, and finds the stream
* over on a TAG_MESSAGE chunk — the same terminal condition as a large file
* whose last chunk was lost, without paying for a 4 MiB fixture. The
* multi-chunk fixture above covers the realistic wire shape; this one is
* used where the test is really about what happens on disk afterwards.
*/
const encryptNonFinalBody = (
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_MESSAGE,
);
return { header: push.header, ciphertext };
};
/**
* Compare file contents by digest rather than with `toEqual`. Vitest's deep
* equality walks multi-megabyte buffers byte by byte, which costs seconds on
* the 4 MiB fixtures; a digest comparison is exact and effectively free.
*/
const expectSameBytes = (actual: Uint8Array, expected: Uint8Array): void => {
expect(actual.length).toBe(expected.length);
expect(createHash("sha256").update(actual).digest("hex")).toBe(
createHash("sha256").update(expected).digest("hex"),
);
};
/** /**
* A multi-chunk fixture shared by the truncation tests. Building it costs a * A multi-chunk fixture shared by the truncation tests. Building it costs a
* few MiB of encryption, so it is built once: one full 4 MiB `TAG_MESSAGE` * few MiB of encryption, so it is built once: one full 4 MiB `TAG_MESSAGE`
@@ -291,7 +328,7 @@ describe("downloadFile", () => {
const result = await downloadFile(api, file, outPath); const result = await downloadFile(api, file, outPath);
expect(result.bytesWritten).toBe(100_000); expect(result.bytesWritten).toBe(100_000);
expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext)); expectSameBytes(readFileSync(outPath), plaintext);
}); });
it("decrypts a body that spans several secretstream chunks", async () => { it("decrypts a body that spans several secretstream chunks", async () => {
@@ -311,9 +348,7 @@ describe("downloadFile", () => {
const result = await downloadFile(api, file, outPath); const result = await downloadFile(api, file, outPath);
expect(result.bytesWritten).toBe(multiChunk.plaintext.length); expect(result.bytesWritten).toBe(multiChunk.plaintext.length);
expect(readFileSync(outPath)).toEqual( expectSameBytes(readFileSync(outPath), multiChunk.plaintext);
Buffer.from(multiChunk.plaintext),
);
}); });
}); });
@@ -402,15 +437,18 @@ describe.each(entryPoints)(
// The caller's contract: if the promise rejects, the destination // The caller's contract: if the promise rejects, the destination
// path does not exist. Nothing downstream should ever have to guess // path does not exist. Nothing downstream should ever have to guess
// whether a leftover file is complete. // whether a leftover file is complete.
const truncatedBody = multiChunk.body.slice( //
0, // The body here is a single chunk that was never tagged TAG_FINAL,
multiChunk.finalChunkOffset, // which puts the downloader in exactly the terminal state a lost
); // last chunk produces, without the cost of a 4 MiB fixture. What
const { api, file } = fixtureFor( // this test is really about is the state of the filesystem after
multiChunkKey, // the rejection.
multiChunk.header, const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
truncatedBody, const { header, ciphertext } = encryptNonFinalBody(
sodium.randombytes_buf(256),
key,
); );
const { api, file } = fixtureFor(key, header, ciphertext);
const dir = freshDir(); const dir = freshDir();
const outPath = join(dir, "absent.bin"); const outPath = join(dir, "absent.bin");
@@ -458,15 +496,12 @@ describe.each(entryPoints)(
const existing = new TextEncoder().encode( const existing = new TextEncoder().encode(
"previously downloaded, known-good contents", "previously downloaded, known-good contents",
); );
const truncatedBody = multiChunk.body.slice( const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
0, const { header, ciphertext } = encryptNonFinalBody(
multiChunk.finalChunkOffset, sodium.randombytes_buf(256),
); key,
const { api, file } = fixtureFor(
multiChunkKey,
multiChunk.header,
truncatedBody,
); );
const { api, file } = fixtureFor(key, header, ciphertext);
const dir = freshDir(); const dir = freshDir();
const outPath = join(dir, "existing.bin"); const outPath = join(dir, "existing.bin");
writeFileSync(outPath, existing); writeFileSync(outPath, existing);