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
2 changed files with 67 additions and 9 deletions
Showing only changes of commit 937bcb7aee - Show all commits

View File

@@ -12,11 +12,20 @@ export const STREAM_CHUNK_OVERHEAD = 17;
// marks the last chunk of a stream. Exported so callers (the download layer) // marks the last chunk of a stream. Exported so callers (the download layer)
// can detect truncation without importing sodium themselves. // can detect truncation without importing sodium themselves.
// //
// This is a function rather than a constant because libsodium attaches its // This is a function rather than a constant, and that is load-bearing:
// constants to the module object only once `sodium.ready` has resolved, which // libsodium attaches its constants to the module object inside
// is long after this module is evaluated — a module-level read would bind // `ready.then(...)`, which resolves long after this module is evaluated. A
// `undefined`. Reading it at call time returns the library's own value, so // module-level read would bind `undefined`, every downstream comparison
// there is no second copy of a protocol constant to keep in sync. // against it would then be false, and every valid download would be rejected
// as truncated. Reading at call time returns the library's own value, so
// there is also no second copy of a protocol constant to keep in sync.
//
// Under vitest sodium is already initialised in the worker before this module
// is evaluated, so an eager read would pick up a real value there and the
// ordinary tests could not tell the difference. The regression guard is
// "streamTagFinal() reads the constant at call time, not at import time" in
// test/crypto/stream.test.ts, which reproduces the plain-Node ESM ordering
// against a stand-in sodium module; it goes red if this becomes eager.
export const streamTagFinal = (): number => export const streamTagFinal = (): number =>
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL; sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;

View File

@@ -18,6 +18,9 @@
* - The chunk-size constants match Ente's expectations, and the * - The chunk-size constants match Ente's expectations, and the
* re-exported `streamTagFinal()` matches the tag a real final chunk * re-exported `streamTagFinal()` matches the tag a real final chunk
* carries. * carries.
* - `streamTagFinal()` reads libsodium's constant at call time rather than
* at import time, which it must, because the constant does not exist yet
* when this library's modules are evaluated.
* - The pull state can decrypt a multi-chunk stream produced by * - The pull state can decrypt a multi-chunk stream produced by
* sodium.crypto_secretstream_xchacha20poly1305_push, in order. * sodium.crypto_secretstream_xchacha20poly1305_push, in order.
* - The tag byte is propagated to the caller. * - The tag byte is propagated to the caller.
@@ -25,7 +28,7 @@
*/ */
import sodium from "libsodium-wrappers-sumo"; import sodium from "libsodium-wrappers-sumo";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it, vi } from "vitest";
import { import {
init, init,
initStreamPull, initStreamPull,
@@ -60,9 +63,9 @@ describe("crypto stream constants", () => {
* evaluated. Reading the value at call time yields libsodium's number; * evaluated. Reading the value at call time yields libsodium's number;
* reading it at module scope would yield `undefined`, and every * reading it at module scope would yield `undefined`, and every
* truncation check downstream would then compare against `undefined` and * truncation check downstream would then compare against `undefined` and
* reject good downloads. This test pins the behaviour end to end — it * reject good downloads. The eagerness itself is guarded by the next test;
* compares against the tag observed on a real final chunk pulled back off * this one pins the value, comparing it against the tag observed on a real
* the wire format, so it fails if the accessor is ever made eager. * final chunk pulled back off the wire format.
*/ */
it("streamTagFinal() is the tag carried by a real final chunk", async () => { it("streamTagFinal() is the tag carried by a real final chunk", async () => {
await init(); await init();
@@ -81,6 +84,52 @@ describe("crypto stream constants", () => {
const state = initStreamPull(push.header, key); const state = initStreamPull(push.header, key);
expect(pullStreamChunk(state, ciphertext).tag).toBe(streamTagFinal()); expect(pullStreamChunk(state, ciphertext).tag).toBe(streamTagFinal());
}); });
/**
* The regression guard for the eagerness property described above.
*
* Under vitest, sodium is already initialised in the worker process by
* the time any source module is evaluated, so an eager module-level read
* would happen to pick up a real value and no ordinary test could tell
* the difference. This test recreates the ordering that a plain Node ESM
* consumer sees: a stand-in sodium module whose `TAG_FINAL` property does
* not exist yet when `src/crypto/stream.ts` is evaluated and only appears
* afterwards, exactly as libsodium attaches its constants inside
* `ready.then(...)`.
*
* A call-time read observes the value that appeared after evaluation; a
* module-level read binds `undefined` and this test fails. The stand-in
* uses a sentinel rather than the real tag number so that a read which
* somehow reached the real libsodium would fail too.
*/
it("streamTagFinal() reads the constant at call time, not at import time", async () => {
const SENTINEL = 42;
const late: { tagFinal: number | undefined } = { tagFinal: undefined };
vi.resetModules();
vi.doMock("libsodium-wrappers-sumo", () => ({
default: {
ready: Promise.resolve(),
get crypto_secretstream_xchacha20poly1305_TAG_FINAL() {
return late.tagFinal;
},
},
}));
try {
// Evaluated while the constant is still absent, as it is before
// `sodium.ready` resolves.
const fresh = await import("../../src/crypto/stream.js");
expect(late.tagFinal).toBeUndefined();
// libsodium attaches its constants; a lazy accessor sees them.
late.tagFinal = SENTINEL;
expect(fresh.streamTagFinal()).toBe(SENTINEL);
} finally {
vi.doUnmock("libsodium-wrappers-sumo");
vi.resetModules();
}
});
}); });
describe("crypto.initStreamPull / pullStreamChunk", () => { describe("crypto.initStreamPull / pullStreamChunk", () => {