Files
HealthLog/src/lib/__tests__/crypto-bytes.test.ts
T
Marc-André Bombeck bd64b78e5d feat(documents): upload policy layer and binary content codec
The policy module (src/lib/documents/upload-policy.ts) is the one source of
truth for what the vault accepts and how it serves: Class A (PDF/JPEG/PNG/
WebP/GIF) renders inline; Class B (Office OOXML+CFB, text/CSV/Markdown/RTF,
TIFF, HEIC/HEIF, XML/JSON) is stored verbatim and leaves the origin only as
an opaque attachment download; executables, HTML/XHTML/SVG, and generic
archives are denied. Detection is magic-bytes first — the wire Content-Type
is never trusted; containers (ZIP → OOXML, CFB → legacy Office) and
extension-only text formats gate on the filename plus a no-NUL heuristic.
resolveDocumentLimits() reads the admin-tunable per-file cap (clamped to
the hard 100 MiB ceiling — single-shot GCM and bounded in-memory reads are
load-bearing past that) and the quota (user override ?? instance default).

crypto.ts gains the binary2 codec: encryptBytes/decryptBytes do AES-256-GCM
over raw bytes with a versioned key-id header, skipping the base64 detour
(−33 % at rest, one fewer copy). Same fail-closed discipline as the string
codec. The store dispatches per row on the explicit contentCodec column:
new uploads write binary2, pre-existing base64v1 rows keep decoding
byte-identically, and an unknown codec throws instead of guessing.

Exhaustive unit coverage: every accept class from real magic-byte fixtures,
every deny case (incl. the PDF-with-HTML-head, SVG-as-image, and
ZIP-renamed-docx polyglots), header-matrix source of truth, limit clamping,
codec round-trips both ways, crossed-codec and unknown-codec fail-closed.

Tasks: W1-T3, W1-T4.
2026-07-07 11:04:51 +02:00

120 lines
4.3 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { Buffer } from "node:buffer";
import {
encryptBytes,
decryptBytes,
extractKeyIdFromBytes,
reencryptBytesToActive,
encrypt,
_resetCryptoCacheForTests,
} from "../crypto";
/**
* The binary "binary2" codec: AES-256-GCM over raw bytes with a key-id
* header, no base64 detour. Same versioned-key discipline as the string
* codec — fail-closed on unknown versions, malformed headers, and
* unconfigured key ids.
*/
const KEY_V1 =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const KEY_V2 =
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
function useSingleKey() {
vi.unstubAllEnvs();
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY_V1);
_resetCryptoCacheForTests();
}
function useKeyMap(active: "v1" | "v2") {
vi.unstubAllEnvs();
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_V1, v2: KEY_V2 }));
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", active);
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
}
describe("crypto binary codec (binary2)", () => {
beforeEach(useSingleKey);
it("round-trips raw binary payloads verbatim", () => {
const payloads = [
Buffer.alloc(0),
Buffer.from([0x00, 0x01, 0xff, 0xfe]),
Buffer.from("%PDF-1.7 with ünïcode and NULs", "utf8"),
Buffer.from(Array.from({ length: 100_000 }, (_, i) => i % 251)),
];
for (const plain of payloads) {
const encrypted = encryptBytes(plain);
expect(encrypted.equals(plain)).toBe(false);
expect(decryptBytes(encrypted).equals(plain)).toBe(true);
}
});
it("writes the active key id into the header", () => {
const payload = encryptBytes(Buffer.from("x"));
expect(extractKeyIdFromBytes(payload)).toBe("v1");
});
it("costs a fixed small header, not the base64 +33 %", () => {
const plain = Buffer.alloc(30_000, 7);
const encrypted = encryptBytes(plain);
// version(1) + idLen(1) + "v1"(2) + iv(12) + tag(16) = 32 bytes overhead.
expect(encrypted.byteLength).toBe(plain.byteLength + 32);
});
it("decrypts rows written under an older configured key after rotation", () => {
const plain = Buffer.from("clinical letter bytes");
const oldPayload = encryptBytes(plain);
useKeyMap("v2");
// v1 is still in the map — decrypt succeeds; re-encrypt moves to v2.
expect(decryptBytes(oldPayload).equals(plain)).toBe(true);
const rotated = reencryptBytesToActive(oldPayload);
expect(extractKeyIdFromBytes(rotated)).toBe("v2");
expect(decryptBytes(rotated).equals(plain)).toBe(true);
});
it("fails closed on an unconfigured key id", () => {
const payload = encryptBytes(Buffer.from("secret"));
vi.stubEnv("ENCRYPTION_KEY", KEY_V2); // v1 material replaced
_resetCryptoCacheForTests();
// Header still says v1 but v1 now decodes to different key material →
// GCM auth failure, never silent junk.
expect(() => decryptBytes(payload)).toThrow();
});
it("fails closed on an unknown version byte", () => {
const payload = encryptBytes(Buffer.from("secret"));
const tampered = Buffer.from(payload);
tampered[0] = 0x09;
expect(() => decryptBytes(tampered)).toThrow(/version/i);
});
it("fails closed on truncated payloads", () => {
const payload = encryptBytes(Buffer.from("secret"));
expect(() => decryptBytes(payload.subarray(0, 1))).toThrow();
expect(() => decryptBytes(payload.subarray(0, 10))).toThrow();
expect(() => decryptBytes(Buffer.alloc(0))).toThrow();
});
it("fails closed on a tampered ciphertext (GCM auth)", () => {
const payload = encryptBytes(Buffer.from("secret"));
const tampered = Buffer.from(payload);
tampered[tampered.byteLength - 1] ^= 0x01;
expect(() => decryptBytes(tampered)).toThrow();
});
it("extractKeyIdFromBytes returns null for garbage instead of throwing", () => {
expect(extractKeyIdFromBytes(Buffer.alloc(0))).toBeNull();
expect(extractKeyIdFromBytes(Buffer.from([0x01, 0x02, 0x03]))).toBeNull();
// A legacy string-codec payload (UTF-8 of an encrypt() string) is not a
// binary2 header either.
expect(
extractKeyIdFromBytes(Buffer.from(encrypt("hello"), "utf8")),
).toBeNull();
});
});