All checks were successful
check / check (push) Successful in 5s
rootDir was ./src while include also matched bin/**/*, which is TS6059: tsc refuses to emit at all when a compiled file sits outside rootDir. rootDir is now the repository root, which is the smallest change that makes the two agree and leaves the source layout the README documents alone. Output keeps the shape of the source tree, so main and types move to dist/src/index.js and dist/src/index.d.ts while bin.quak stays at dist/bin/quak.js. The alternative, moving the CLI body into src/ behind a shim in bin/, would hold main at dist/index.js at the cost of churning the CLI and contradicting the layout diagram in the README. Clearing TS6059 exposed two type errors that had never been reached, because the config error aborts before checking: StateAddress was read as a namespace member off the default import, and the secretstream pull was called without the additional-data argument, which libsodium does not make optional. The type is now taken from the module's named export and the pull passes null for ad, matching the null already passed on the push side in encryptBlob. Neither changes what runs. noEmitOnError stops a failed build from leaving output behind. It emitted despite the error before, which is how a stale bin/quak.js came to sit next to bin/quak.ts in a working tree, where eslint then read it and failed make check on a generated file. script/build compiles and then checks that the files package.json advertises are among the ones the compiler wrote, since tsc knows nothing about the manifest and a green build could still ship a package whose main resolves to nothing. It also sets the executable bit on the bin entries, which tsc does not carry over from the source even though it does copy the shebang. The Makefile target is now a shim over it, as the other targets are, and package.json's build script points at it so yarn build gets the same checks. The Dockerfile runs make build after make check, so a branch that does not compile cannot reach main. What make check itself runs is unchanged. package.json gains a quak script, so the yarn quak commands the README's Getting Started block has always listed resolve to the built CLI.
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
import sodium, { type StateAddress } from "libsodium-wrappers-sumo";
|
|
|
|
// Plaintext chunk size used by Ente for file content streams. Hard-coded by
|
|
// the server; clients must match.
|
|
export const STREAM_CHUNK_SIZE = 4 * 1024 * 1024;
|
|
|
|
// Per-chunk overhead added by libsodium's secretstream construction:
|
|
// 16 bytes of Poly1305 tag plus 1 byte of secretstream tag.
|
|
export const STREAM_CHUNK_OVERHEAD = 17;
|
|
|
|
// libsodium's crypto_secretstream_xchacha20poly1305_TAG_FINAL: the tag that
|
|
// marks the last chunk of a stream. Exported so callers (the download layer)
|
|
// can detect truncation without importing sodium themselves.
|
|
//
|
|
// This is a function rather than a constant, and that is load-bearing:
|
|
// libsodium attaches its constants to the module object inside
|
|
// `ready.then(...)`, which resolves long after this module is evaluated. A
|
|
// module-level read would bind `undefined`, every downstream comparison
|
|
// 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 =>
|
|
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
|
|
|
|
// Encrypt a small blob as a single secretstream chunk with TAG_FINAL.
|
|
// Returns the header and ciphertext. Used for encrypting thumbnails
|
|
// and metadata before upload.
|
|
export const encryptBlob = (
|
|
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 };
|
|
};
|
|
|
|
// Opaque handle to libsodium's secretstream pull state. Threaded through
|
|
// successive pullStreamChunk calls. The type comes from the named export;
|
|
// the default import is the module's value side and has no type namespace
|
|
// under it.
|
|
export type StreamPullState = StateAddress;
|
|
|
|
// Initialise a pull stream from the per-file decryption header and the
|
|
// per-file key.
|
|
export const initStreamPull = (
|
|
header: Uint8Array,
|
|
key: Uint8Array,
|
|
): StreamPullState =>
|
|
sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key);
|
|
|
|
// Decrypt a small blob that was encrypted as a single secretstream chunk
|
|
// with TAG_FINAL. Ente uses this form ("blob") for file metadata and
|
|
// magic metadata — anything under ~1 MiB that isn't chunked.
|
|
export const decryptBlob = (
|
|
ciphertext: Uint8Array,
|
|
header: Uint8Array,
|
|
key: Uint8Array,
|
|
): Uint8Array => {
|
|
const state = initStreamPull(header, key);
|
|
const { plaintext, tag } = pullStreamChunk(state, ciphertext);
|
|
const tagFinal = streamTagFinal();
|
|
if (tag !== tagFinal) {
|
|
throw new Error(
|
|
`decryptBlob: expected TAG_FINAL (${tagFinal}), got tag ${tag}`,
|
|
);
|
|
}
|
|
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 = (
|
|
state: StreamPullState,
|
|
ciphertext: Uint8Array,
|
|
): { plaintext: Uint8Array; tag: number } => {
|
|
// The additional-data argument is not optional in libsodium's signature.
|
|
// null is "no additional data", matching the null passed on the push side
|
|
// in encryptBlob; Ente's file streams carry none.
|
|
const result = sodium.crypto_secretstream_xchacha20poly1305_pull(
|
|
state,
|
|
ciphertext,
|
|
null,
|
|
);
|
|
if (result === false) {
|
|
throw new Error("secretstream chunk authentication failed");
|
|
}
|
|
return { plaintext: result.message, tag: result.tag };
|
|
};
|