All checks were successful
check / check (push) Successful in 4s
Rework against review finding B-A on the branch.
The claim was false as written. Commit 8a200be's message, the PR body and
the comments on both sides said the repurposed pinning test in
test/crypto/stream.test.ts fails if streamTagFinal() is ever made eager.
It does not: substituting
const EAGER: number = sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
export const streamTagFinal = (): number => EAGER;
leaves the whole suite green. Under vitest, sodium is already initialised
in the worker process by the time a source module is evaluated, so an
eager read picks up a real value there and value equality cannot see the
difference. The danger it claimed to cover is real: a direct probe of the
vendored libsodium-wrappers-sumo gives undefined before await sodium.ready
and 3 after, so an eager read would ship a library that rejects every
valid download as truncated for anyone importing it in plain Node ESM,
while make check stayed green.
Rather than drop the claim, this makes it true. A new test reproduces the
plain-Node ESM ordering that vitest hides: vi.resetModules() plus a
doMock'd stand-in sodium whose TAG_FINAL property is absent while
src/crypto/stream.ts is evaluated and appears only afterwards, exactly as
libsodium attaches its constants inside ready.then(...). A call-time read
sees the value that appeared after evaluation; a module-level read binds
undefined and the test fails. The stand-in hands back a sentinel rather
than 3, so a read that somehow reached the real library, or a return to a
hardcoded literal, fails too.
Demonstrated rather than argued: with the two-line eager variant above in
place, make test reports 1 failed | 140 passed, "expected undefined to be
42", exit 2. Restored with git checkout -- src/crypto/stream.ts, make test
reports 141 passed. The eager variant was applied with the editor and
reverted with git, not by scripted substitution.
The pinning test keeps its original job — value equality against the tag
observed on a real final chunk — and its doc comment now says only that.
The comment on the accessor in src/crypto/stream.ts names the guard test,
and records why the ordinary tests cannot see the bug on their own.
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
/**
|
|
* Tests for `crypto.initStreamPull` and `crypto.pullStreamChunk`.
|
|
*
|
|
* Ente encrypts file content with libsodium's secretstream construction
|
|
* (XChaCha20-Poly1305) in chunked mode. Each plaintext chunk is at most
|
|
* `STREAM_CHUNK_SIZE` bytes (4 MiB); each ciphertext chunk is exactly 17
|
|
* bytes longer than its plaintext (16-byte Poly1305 tag plus a 1-byte
|
|
* secretstream tag).
|
|
*
|
|
* The decryption header is delivered separately from the encrypted body in
|
|
* the file metadata (`file.file.decryptionHeader`). Once `initStreamPull`
|
|
* has consumed it, the body is read in order, one ciphertext chunk at a
|
|
* time, and each chunk is fed to `pullStreamChunk`. The library exposes
|
|
* the secretstream tag on each pulled chunk so the caller can verify the
|
|
* stream ended on a `TAG_FINAL` chunk and was therefore not truncated.
|
|
*
|
|
* These tests pin:
|
|
* - The chunk-size constants match Ente's expectations, and the
|
|
* re-exported `streamTagFinal()` matches the tag a real final chunk
|
|
* 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
|
|
* sodium.crypto_secretstream_xchacha20poly1305_push, in order.
|
|
* - The tag byte is propagated to the caller.
|
|
* - Tampered or out-of-order ciphertext is rejected.
|
|
*/
|
|
|
|
import sodium from "libsodium-wrappers-sumo";
|
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
init,
|
|
initStreamPull,
|
|
pullStreamChunk,
|
|
STREAM_CHUNK_OVERHEAD,
|
|
STREAM_CHUNK_SIZE,
|
|
streamTagFinal,
|
|
} from "../../src/crypto/index.js";
|
|
|
|
describe("crypto stream constants", () => {
|
|
/**
|
|
* These constants match the values hard-coded into Ente's web client
|
|
* and Go CLI. If Ente ever changes them server-side, every client
|
|
* must change in lockstep.
|
|
*/
|
|
it("STREAM_CHUNK_SIZE is 4 MiB", () => {
|
|
expect(STREAM_CHUNK_SIZE).toBe(4 * 1024 * 1024);
|
|
});
|
|
|
|
it("STREAM_CHUNK_OVERHEAD is 17 bytes", () => {
|
|
expect(STREAM_CHUNK_OVERHEAD).toBe(17);
|
|
});
|
|
|
|
/**
|
|
* `streamTagFinal()` is re-exported so callers can detect a truncated
|
|
* stream (a body that ended on a non-final chunk) without importing
|
|
* libsodium themselves.
|
|
*
|
|
* It is a function, not a constant, and that is load-bearing: libsodium
|
|
* attaches its own constants to the module object only after
|
|
* `sodium.ready` resolves, which is long after this library's modules are
|
|
* evaluated. Reading the value at call time yields libsodium's number;
|
|
* reading it at module scope would yield `undefined`, and every
|
|
* truncation check downstream would then compare against `undefined` and
|
|
* reject good downloads. The eagerness itself is guarded by the next test;
|
|
* this one pins the value, comparing it against the tag observed on a real
|
|
* final chunk pulled back off the wire format.
|
|
*/
|
|
it("streamTagFinal() is the tag carried by a real final chunk", async () => {
|
|
await init();
|
|
await sodium.ready;
|
|
|
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const push =
|
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
|
const ciphertext = sodium.crypto_secretstream_xchacha20poly1305_push(
|
|
push.state,
|
|
new TextEncoder().encode("last chunk"),
|
|
null,
|
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
|
);
|
|
|
|
const state = initStreamPull(push.header, key);
|
|
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", () => {
|
|
beforeAll(async () => {
|
|
await init();
|
|
await sodium.ready;
|
|
});
|
|
|
|
/**
|
|
* Helper: encrypt a sequence of plaintext chunks with sodium's push
|
|
* API and return the header plus the encrypted chunks. Marks the
|
|
* final chunk with `TAG_FINAL` (3); intermediate chunks use
|
|
* `TAG_MESSAGE` (0).
|
|
*/
|
|
const encryptChunks = (
|
|
key: Uint8Array,
|
|
chunks: Uint8Array[],
|
|
): { header: Uint8Array; encrypted: Uint8Array[] } => {
|
|
const push =
|
|
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
|
const encrypted: Uint8Array[] = [];
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
const isLast = i === chunks.length - 1;
|
|
const tag = isLast
|
|
? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
|
|
: sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
|
encrypted.push(
|
|
sodium.crypto_secretstream_xchacha20poly1305_push(
|
|
push.state,
|
|
chunks[i]!,
|
|
null,
|
|
tag,
|
|
),
|
|
);
|
|
}
|
|
return { header: push.header, encrypted };
|
|
};
|
|
|
|
it("decrypts a single-chunk stream marked TAG_FINAL", () => {
|
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const plaintext = new TextEncoder().encode("a small file's contents");
|
|
const { header, encrypted } = encryptChunks(key, [plaintext]);
|
|
|
|
const state = initStreamPull(header, key);
|
|
const result = pullStreamChunk(state, encrypted[0]!);
|
|
expect(result.plaintext).toEqual(plaintext);
|
|
expect(result.tag).toBe(
|
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
|
);
|
|
});
|
|
|
|
it("decrypts a multi-chunk stream in order, exposing tags per chunk", () => {
|
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const plaintexts = [
|
|
new Uint8Array([1, 2, 3]),
|
|
new Uint8Array([4, 5, 6, 7, 8]),
|
|
new Uint8Array([9, 10]),
|
|
];
|
|
const { header, encrypted } = encryptChunks(key, plaintexts);
|
|
|
|
const state = initStreamPull(header, key);
|
|
const results = encrypted.map((c) => pullStreamChunk(state, c));
|
|
|
|
// Plaintext is recovered chunk-for-chunk, in order.
|
|
expect(results.map((r) => r.plaintext)).toEqual(plaintexts);
|
|
|
|
// Intermediate chunks carry TAG_MESSAGE; the last carries TAG_FINAL.
|
|
// The caller can use this to detect a truncated stream: if the
|
|
// last chunk seen does not have TAG_FINAL, the body was cut off.
|
|
const TAG_MESSAGE =
|
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
|
|
const TAG_FINAL =
|
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
|
|
expect(results[0]!.tag).toBe(TAG_MESSAGE);
|
|
expect(results[1]!.tag).toBe(TAG_MESSAGE);
|
|
expect(results[2]!.tag).toBe(TAG_FINAL);
|
|
});
|
|
|
|
it("rejects a tampered ciphertext chunk", () => {
|
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const { header, encrypted } = encryptChunks(key, [
|
|
new Uint8Array([1, 2, 3]),
|
|
]);
|
|
encrypted[0]![0] = encrypted[0]![0]! ^ 0x01;
|
|
|
|
const state = initStreamPull(header, key);
|
|
expect(() => pullStreamChunk(state, encrypted[0]!)).toThrow();
|
|
});
|
|
|
|
it("rejects a chunk decrypted with the wrong key", () => {
|
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const wrongKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const { header, encrypted } = encryptChunks(key, [
|
|
new Uint8Array([1, 2, 3]),
|
|
]);
|
|
const state = initStreamPull(header, wrongKey);
|
|
expect(() => pullStreamChunk(state, encrypted[0]!)).toThrow();
|
|
});
|
|
|
|
it("rejects chunks pulled out of order", () => {
|
|
// The secretstream construction binds each chunk to its position in
|
|
// the stream. Feeding chunk 1's ciphertext after chunk 0 was
|
|
// skipped, or in the wrong order, must fail authentication.
|
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const { header, encrypted } = encryptChunks(key, [
|
|
new Uint8Array([1, 2, 3]),
|
|
new Uint8Array([4, 5, 6]),
|
|
]);
|
|
const state = initStreamPull(header, key);
|
|
// Skip chunk 0 entirely and try to pull chunk 1 first.
|
|
expect(() => pullStreamChunk(state, encrypted[1]!)).toThrow();
|
|
});
|
|
|
|
it("ciphertext chunks are exactly STREAM_CHUNK_OVERHEAD longer than plaintext", () => {
|
|
// Sanity check on the overhead constant. If libsodium ever changes
|
|
// this (it won't), the constant in our crypto module must change
|
|
// with it.
|
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
|
const plaintext = new Uint8Array(123).fill(0x55);
|
|
const { encrypted } = encryptChunks(key, [plaintext]);
|
|
expect(encrypted[0]!.length).toBe(
|
|
plaintext.length + STREAM_CHUNK_OVERHEAD,
|
|
);
|
|
});
|
|
});
|