diff --git a/src/lib/__tests__/crypto-bytes.test.ts b/src/lib/__tests__/crypto-bytes.test.ts new file mode 100644 index 000000000..196b10e71 Binary files /dev/null and b/src/lib/__tests__/crypto-bytes.test.ts differ diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 3d67e2639..bd2e015f3 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -264,6 +264,105 @@ export function reencryptToActive(encoded: string): string { return encrypt(decrypt(encoded)); } +// ─── Binary codec ("binary2") ──────────────────────────────────────────────── +// +// AES-256-GCM over raw bytes, skipping the base64 detour the string codec +// takes (−33 % at rest, one fewer full copy in memory). Written for large +// binary payloads (the document vault's `contentEncrypted`); the consumer +// records which codec a row uses in an explicit column — the layout is never +// header-sniffed against the string codec. +// +// Layout: [version 0x02][keyIdLen u8][keyId ascii][iv 12][tag 16][ciphertext] +// +// Same versioned-key discipline as the string codec: encrypt always writes +// the active key id, decrypt is fail-closed on an unknown version, a +// malformed header, or an unconfigured key id. + +/** Version byte identifying the binary2 layout. */ +const BYTES_CODEC_VERSION = 0x02; + +/** Encrypt raw bytes with the active key into the binary2 layout. */ +export function encryptBytes(plaintext: Buffer): Buffer { + const { id, key } = getActiveKey(); + const keyId = Buffer.from(id, "ascii"); + if (keyId.byteLength < 1 || keyId.byteLength > 32) { + // loadKeys() already constrains ids to [A-Za-z0-9_-]{1,32}; belt-and-braces. + throw new Error(`Encryption key id '${id}' is not header-encodable`); + } + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + const header = Buffer.from([BYTES_CODEC_VERSION, keyId.byteLength]); + return Buffer.concat([header, keyId, iv, tag, ct]); +} + +/** Parse a binary2 header. Throws on any malformation (fail-closed). */ +function parseBytesHeader(payload: Buffer): { + keyId: string; + iv: Buffer; + tag: Buffer; + ct: Buffer; +} { + if (payload.byteLength < 2) { + throw new Error("binary2 payload is truncated (no header)"); + } + if (payload[0] !== BYTES_CODEC_VERSION) { + throw new Error( + `Unknown binary ciphertext version ${payload[0]} (expected ${BYTES_CODEC_VERSION})`, + ); + } + const keyIdLen = payload[1]; + if (keyIdLen < 1 || keyIdLen > 32) { + throw new Error("binary2 payload has an invalid key-id length"); + } + const fixed = 2 + keyIdLen + IV_LENGTH + AUTH_TAG_LENGTH; + if (payload.byteLength < fixed) { + throw new Error("binary2 payload is truncated"); + } + const keyId = payload.subarray(2, 2 + keyIdLen).toString("ascii"); + if (!/^[A-Za-z0-9_-]{1,32}$/.test(keyId)) { + throw new Error("binary2 payload carries a malformed key id"); + } + const iv = payload.subarray(2 + keyIdLen, 2 + keyIdLen + IV_LENGTH); + const tag = payload.subarray(2 + keyIdLen + IV_LENGTH, fixed); + const ct = payload.subarray(fixed); + return { keyId, iv, tag, ct }; +} + +/** + * Decrypt a binary2 payload back to its raw bytes. Fail-closed: an unknown + * version, a malformed header, or an unconfigured key id throws — the caller + * must treat a throw as "cannot serve", never fall back to the ciphertext. + */ +export function decryptBytes(payload: Buffer): Buffer { + const { keyId, iv, tag, ct } = parseBytesHeader(payload); + const key = getKeyById(keyId); + if (!key) { + throw new Error( + `Encryption key id '${keyId}' is not configured. Add it to ` + + `ENCRYPTION_KEYS before decrypting rows written under that key.`, + ); + } + const dec = createDecipheriv(ALGORITHM, key, iv); + dec.setAuthTag(tag); + return Buffer.concat([dec.update(ct), dec.final()]); +} + +/** The key id a binary2 payload was written under, or null when unparsable. */ +export function extractKeyIdFromBytes(payload: Buffer): string | null { + try { + return parseBytesHeader(payload).keyId; + } catch { + return null; + } +} + +/** Re-encrypt a binary2 payload with the active key. Used by rotation. */ +export function reencryptBytesToActive(payload: Buffer): Buffer { + return encryptBytes(decryptBytes(payload)); +} + /** Returns the key id portion of a versioned ciphertext, or null for legacy. */ export function extractKeyId(encoded: string): string | null { const dot = encoded.indexOf("."); diff --git a/src/lib/documents/__tests__/store-codec.test.ts b/src/lib/documents/__tests__/store-codec.test.ts new file mode 100644 index 000000000..8b4017ba7 --- /dev/null +++ b/src/lib/documents/__tests__/store-codec.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Buffer } from "node:buffer"; + +/** + * Document content codec dispatch: new uploads write "binary2", pre-vault + * rows keep decoding via the legacy "base64v1" string path, and an unknown + * codec value fails closed instead of guessing. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +import { _resetCryptoCacheForTests } from "@/lib/crypto"; +import { + ACTIVE_DOCUMENT_CODEC, + decryptDocumentContent, + decryptDocumentFromBytes, + encryptDocumentContent, + encryptDocumentToBytes, +} from "../store"; + +const KEY_V1 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const SAMPLE = Buffer.concat([ + Buffer.from("%PDF-1.7\n"), + Buffer.from([0x00, 0xff, 0x80, 0x7f]), + Buffer.alloc(2048, 0xab), +]); + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY_V1); + _resetCryptoCacheForTests(); +}); + +describe("document content codecs", () => { + it("writes new uploads with the binary2 codec and round-trips them", () => { + const { content, codec } = encryptDocumentContent(SAMPLE); + expect(codec).toBe("binary2"); + expect(codec).toBe(ACTIVE_DOCUMENT_CODEC); + expect(Buffer.from(content).equals(SAMPLE)).toBe(false); + expect(decryptDocumentContent(content, codec).equals(SAMPLE)).toBe(true); + }); + + it("still decodes legacy base64v1 rows byte-identically", () => { + // A pre-vault row: base64-of-binary → encrypt() string → UTF-8 bytes. + const legacy = encryptDocumentToBytes(SAMPLE); + expect(decryptDocumentContent(legacy, "base64v1").equals(SAMPLE)).toBe( + true, + ); + // The legacy helper itself stays the exact inverse. + expect(decryptDocumentFromBytes(legacy).equals(SAMPLE)).toBe(true); + }); + + it("binary2 is ~25 % smaller at rest than the base64 detour", () => { + const legacy = encryptDocumentToBytes(SAMPLE); + const { content } = encryptDocumentContent(SAMPLE); + expect(content.byteLength).toBeLessThan(legacy.byteLength * 0.8); + }); + + it("fails closed on an unknown codec value", () => { + const { content } = encryptDocumentContent(SAMPLE); + expect(() => decryptDocumentContent(content, "codec9")).toThrow( + /unknown document content codec/i, + ); + expect(() => decryptDocumentContent(content, "")).toThrow(); + }); + + it("fails closed when codecs are crossed (no silent fallback)", () => { + const legacy = encryptDocumentToBytes(SAMPLE); + const { content: binary } = encryptDocumentContent(SAMPLE); + // Legacy bytes read as binary2 → header parse fails. + expect(() => decryptDocumentContent(legacy, "binary2")).toThrow(); + // Binary bytes read as base64v1 → string decrypt fails. + expect(() => decryptDocumentContent(binary, "base64v1")).toThrow(); + }); +}); diff --git a/src/lib/documents/__tests__/upload-policy.test.ts b/src/lib/documents/__tests__/upload-policy.test.ts new file mode 100644 index 000000000..e83bd36fc --- /dev/null +++ b/src/lib/documents/__tests__/upload-policy.test.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Document-vault policy layer: exhaustive accept-matrix coverage. + * + * Every Class A and Class B format is detected from real magic-byte + * fixtures, every deny case is denied, the wire Content-Type never plays a + * role (the detector does not even receive it), container ambiguity (ZIP → + * OOXML only under an Office extension) is pinned, adversarial polyglots + * behave as designed, and the limits resolver clamps to the hard ceiling. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +import { prisma } from "@/lib/db"; +import { + DOCUMENT_DEFAULT_MAX_FILE_BYTES, + DOCUMENT_DEFAULT_QUOTA_BYTES, + DOCUMENT_HARD_MAX_FILE_BYTES, + detectDocumentType, + resolveDocumentLimits, + servingClassFor, +} from "../upload-policy"; + +// ─── Magic-byte fixtures ───────────────────────────────────────────────────── + +const PDF = Buffer.from("%PDF-1.7\n%âãÏÓ\n1 0 obj\n", "latin1"); +const JPEG = Buffer.concat([ + Buffer.from([0xff, 0xd8, 0xff, 0xe0]), + Buffer.alloc(16, 0x11), +]); +const PNG = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(16, 0x22), +]); +const WEBP = Buffer.concat([ + Buffer.from("RIFF"), + Buffer.from([0x24, 0x00, 0x00, 0x00]), + Buffer.from("WEBPVP8 "), + Buffer.alloc(8, 0x33), +]); +const GIF87 = Buffer.concat([Buffer.from("GIF87a"), Buffer.alloc(16, 0x44)]); +const GIF89 = Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(16, 0x44)]); +const ZIP = Buffer.concat([ + Buffer.from([0x50, 0x4b, 0x03, 0x04]), + Buffer.alloc(32, 0x55), +]); +const CFB = Buffer.concat([ + Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), + Buffer.alloc(32, 0x66), +]); +const RTF = Buffer.from("{\\rtf1\\ansi Hello}", "latin1"); +const TIFF_LE = Buffer.concat([ + Buffer.from([0x49, 0x49, 0x2a, 0x00]), + Buffer.alloc(16, 0x77), +]); +const TIFF_BE = Buffer.concat([ + Buffer.from([0x4d, 0x4d, 0x00, 0x2a]), + Buffer.alloc(16, 0x77), +]); + +/** ISO-BMFF ftyp box with the given major + compatible brands. */ +function ftyp(major: string, compat: string[] = []): Buffer { + const brands = Buffer.from([major, "0000", ...compat].join(""), "ascii"); + const len = Buffer.alloc(4); + len.writeUInt32BE(8 + brands.byteLength); + return Buffer.concat([len, Buffer.from("ftyp"), brands, Buffer.alloc(8)]); +} + +const TEXT = Buffer.from("Befund: alles unauffällig.\nZeile 2\n", "utf8"); +const MZ = Buffer.concat([Buffer.from("MZ"), Buffer.alloc(64, 0x90)]); +const ELF = Buffer.concat([ + Buffer.from([0x7f, 0x45, 0x4c, 0x46]), + Buffer.alloc(32, 0), +]); +const MACHO = Buffer.concat([ + Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), + Buffer.alloc(32, 0), +]); + +describe("detectDocumentType — Class A (inline; magic bytes only)", () => { + it.each([ + ["report.pdf", PDF, "application/pdf"], + ["photo.jpg", JPEG, "image/jpeg"], + ["scan.png", PNG, "image/png"], + ["scan.webp", WEBP, "image/webp"], + ["anim87.gif", GIF87, "image/gif"], + ["anim89.gif", GIF89, "image/gif"], + ])("%s → %s inline", (name, bytes, mime) => { + expect(detectDocumentType(bytes, name)).toEqual({ + mimeType: mime, + servingClass: "inline", + }); + }); + + it("classifies by bytes, not extension (JPEG named .dat is still a JPEG)", () => { + expect(detectDocumentType(JPEG, "export.dat")).toEqual({ + mimeType: "image/jpeg", + servingClass: "inline", + }); + }); +}); + +describe("detectDocumentType — Class B (attachment-only)", () => { + it.each([ + [ + "letter.docx", + ZIP, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ], + [ + "values.xlsx", + ZIP, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ], + [ + "slides.pptx", + ZIP, + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ], + ["letter.doc", CFB, "application/msword"], + ["values.xls", CFB, "application/vnd.ms-excel"], + ["slides.ppt", CFB, "application/vnd.ms-powerpoint"], + ["note.rtf", RTF, "application/rtf"], + ["scan.tif", TIFF_LE, "image/tiff"], + ["scan.tiff", TIFF_BE, "image/tiff"], + ["befund.txt", TEXT, "text/plain"], + ["befund.md", TEXT, "text/plain"], + ["werte.csv", TEXT, "text/csv"], + ["export.xml", TEXT, "application/xml"], + ["export.json", TEXT, "application/json"], + ])("%s → %s attachment", (name, bytes, mime) => { + expect(detectDocumentType(bytes, name)).toEqual({ + mimeType: mime, + servingClass: "attachment", + }); + }); + + it.each([ + ["heic (major brand)", ftyp("heic")], + ["heix (major brand)", ftyp("heix")], + ["hevc (major brand)", ftyp("hevc")], + ["mif1 (major brand)", ftyp("mif1")], + ["msf1 (major brand)", ftyp("msf1")], + ["compatible-brand heic", ftyp("isom", ["heic"])], + ])("HEIC/HEIF %s → image/heic attachment", (_label, bytes) => { + expect(detectDocumentType(bytes, "photo.heic")).toEqual({ + mimeType: "image/heic", + servingClass: "attachment", + }); + }); +}); + +describe("detectDocumentType — deny", () => { + it.each([ + ["MZ executable", MZ, "payload.pdf"], + ["ELF executable", ELF, "scan.png"], + ["Mach-O executable", MACHO, "report.txt"], + ])("%s is denied regardless of a friendly extension", (_l, bytes, name) => { + expect(detectDocumentType(bytes, name)).toBeNull(); + }); + + it.each([ + ".exe", + ".dll", + ".bat", + ".cmd", + ".sh", + ".ps1", + ".js", + ".mjs", + ".jar", + ".apk", + ".html", + ".htm", + ".xhtml", + ".svg", + ".zip", + ".7z", + ".rar", + ".tar", + ".gz", + ".iso", + ".img", + ".dmg", + ])("extension %s is denied even with harmless text bytes", (ext) => { + expect(detectDocumentType(TEXT, `file${ext}`)).toBeNull(); + }); + + it("denies a ZIP container without an Office extension (incl. no name)", () => { + expect(detectDocumentType(ZIP, "archive.zip")).toBeNull(); + expect(detectDocumentType(ZIP, "archive")).toBeNull(); + expect(detectDocumentType(ZIP, null)).toBeNull(); + // CFB without a legacy-Office extension is equally refused. + expect(detectDocumentType(CFB, "blob.bin")).toBeNull(); + }); + + it("denies text-extension files smuggling NUL bytes", () => { + const binaryish = Buffer.concat([TEXT, Buffer.from([0x00]), TEXT]); + expect(detectDocumentType(binaryish, "befund.txt")).toBeNull(); + }); + + it("denies unidentifiable bytes and empty files", () => { + expect(detectDocumentType(Buffer.from([1, 2, 3, 4, 5, 6]), "x.bin")).toBe( + null, + ); + expect(detectDocumentType(Buffer.alloc(0), "empty.txt")).toBeNull(); + // Text-like bytes under a non-text extension stay unidentifiable. + expect(detectDocumentType(TEXT, "notes.abc")).toBeNull(); + }); + + it("adversarial: SVG content cannot sneak in under an image extension", () => { + const svg = Buffer.from(''); + expect(detectDocumentType(svg, "image.svg")).toBeNull(); // ext deny + expect(detectDocumentType(svg, "image.png")).toBeNull(); // no PNG magic, no text ext + }); + + it("adversarial: HTML under a text extension stays attachment-only", () => { + // Accepted as text/plain — but Class B never renders inline, so the + // markup can never execute in-origin. + const html = Buffer.from(""); + expect(detectDocumentType(html, "page.txt")).toEqual({ + mimeType: "text/plain", + servingClass: "attachment", + }); + expect(detectDocumentType(html, "page.html")).toBeNull(); + }); + + it("adversarial: a PDF with an HTML head is still a PDF (sandboxed inline)", () => { + const polyglot = Buffer.concat([ + Buffer.from("%PDF-1.4\n"), + Buffer.from(""), + ]); + expect(detectDocumentType(polyglot, "poly.pdf")).toEqual({ + mimeType: "application/pdf", + servingClass: "inline", + }); + }); +}); + +describe("servingClassFor", () => { + it("marks exactly the browser-native passive set inline", () => { + for (const mime of [ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + ]) { + expect(servingClassFor(mime)).toBe("inline"); + } + }); + + it("marks every other stored type attachment (incl. unknowns)", () => { + for (const mime of [ + "image/heic", + "image/tiff", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain", + "text/csv", + "application/rtf", + "application/xml", + "application/json", + "text/html", // must never happen at rest, but even then: attachment + "image/svg+xml", + "application/octet-stream", + ]) { + expect(servingClassFor(mime)).toBe("attachment"); + } + }); +}); + +describe("resolveDocumentLimits", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("falls back to the defaults with no settings row and no override", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + await expect(resolveDocumentLimits("u1")).resolves.toEqual({ + maxFileBytes: DOCUMENT_DEFAULT_MAX_FILE_BYTES, + quotaBytes: DOCUMENT_DEFAULT_QUOTA_BYTES, + }); + }); + + it("clamps the per-file cap to the hard 100 MiB ceiling", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + documentMaxFileBytes: 999_999_999_999, + documentQuotaBytes: BigInt(DOCUMENT_DEFAULT_QUOTA_BYTES), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + const limits = await resolveDocumentLimits("u1"); + expect(limits.maxFileBytes).toBe(DOCUMENT_HARD_MAX_FILE_BYTES); + }); + + it("prefers the per-user quota override over the instance default", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + documentMaxFileBytes: 26_214_400, + documentQuotaBytes: BigInt(2_000_000_000), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentQuotaBytes: BigInt(50_000_000), + } as never); + const limits = await resolveDocumentLimits("u1"); + expect(limits.quotaBytes).toBe(50_000_000); + }); + + it("uses the instance quota when the user carries no override", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + documentMaxFileBytes: 26_214_400, + documentQuotaBytes: BigInt(2_000_000_000), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentQuotaBytes: null, + } as never); + const limits = await resolveDocumentLimits("u1"); + expect(limits.quotaBytes).toBe(2_000_000_000); + }); +}); diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 8b9f90254..d10a12925 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -9,14 +9,16 @@ import { Buffer } from "node:buffer"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { decrypt, encrypt } from "@/lib/crypto"; +import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto"; import type { ExtractedFact, ExtractedFactStatus, InboundDocument, InboundDocumentStatus, } from "@/generated/prisma/client"; +import { servingClassFor } from "@/lib/documents/upload-policy"; import type { + DocumentConditionLinkDto, ExtractedFactDto, FactData, FactProvenance, @@ -26,6 +28,20 @@ import type { InboundFactType, } from "@/lib/validations/inbound-documents"; +/** + * The two storage codecs `InboundDocument.contentEncrypted` may carry, + * recorded per row in the explicit `contentCodec` column (never sniffed): + * - "base64v1" — the legacy string path (base64-of-binary → `encrypt()` + * string → UTF-8 bytes). Pre-vault rows only; kept read-compatible. + * - "binary2" — the binary AES-256-GCM layout (`encryptBytes()`), no + * base64 detour. Every new upload writes this. + */ +export const DOCUMENT_CONTENT_CODECS = ["base64v1", "binary2"] as const; +export type DocumentContentCodec = (typeof DOCUMENT_CONTENT_CODECS)[number]; + +/** The codec every NEW upload is written with. */ +export const ACTIVE_DOCUMENT_CODEC: DocumentContentCodec = "binary2"; + /** Encrypt the raw document bytes into the `Bytes` payload the schema stores. */ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array { const ciphertext = encrypt(bytes.toString("base64")); @@ -49,6 +65,31 @@ export function decryptDocumentFromBytes(buf: Uint8Array): Buffer { return Buffer.from(base64, "base64"); } +/** + * Encrypt raw document bytes with the ACTIVE codec (binary2). Returns the + * `Bytes` payload plus the codec label the row must persist alongside it. + */ +export function encryptDocumentContent(bytes: Buffer): { + content: Uint8Array; + codec: DocumentContentCodec; +} { + const encrypted = encryptBytes(bytes); + const out = new Uint8Array(new ArrayBuffer(encrypted.byteLength)); + out.set(encrypted); + return { content: out, codec: ACTIVE_DOCUMENT_CODEC }; +} + +/** + * Decrypt a stored document's payload, dispatching on the row's persisted + * codec. Fail-closed: an unknown codec value throws — a silent fallback to + * either codec would either return garbage or mask data corruption. + */ +export function decryptDocumentContent(buf: Uint8Array, codec: string): Buffer { + if (codec === "base64v1") return decryptDocumentFromBytes(buf); + if (codec === "binary2") return decryptBytes(Buffer.from(buf)); + throw new Error(`Unknown document content codec '${codec}'`); +} + /** * Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the * schema stores. The structured clinical values (diagnosis text, lab values, @@ -80,10 +121,19 @@ export function decryptFactProvenance(buf: Uint8Array): FactProvenance { return JSON.parse(decryptFromBytes(buf)) as FactProvenance; } -/** Map a persisted document row (+ counts) to the list/detail DTO. */ +/** + * The row fields the serialisers read. Deliberately WITHOUT + * `contentEncrypted`: the list path must never select the blob column + * (hardening — see the list route's `omit`), so the DTO mappers cannot + * require it either. + */ +export type SerialisableDocument = Omit; + +/** Map a persisted document row (+ counts + links) to the list/detail DTO. */ export function serialiseDocument( - doc: InboundDocument, + doc: SerialisableDocument, counts: { factCount: number; pendingCount: number }, + conditionLinks: DocumentConditionLinkDto[] = [], ): InboundDocumentDto { return { id: doc.id, @@ -103,6 +153,8 @@ export function serialiseDocument( errorReason: doc.errorReason, factCount: counts.factCount, pendingCount: counts.pendingCount, + conditionLinks, + servingClass: servingClassFor(doc.mimeType), createdAt: doc.createdAt.toISOString(), updatedAt: doc.updatedAt.toISOString(), }; @@ -123,17 +175,18 @@ export function serialiseFact(fact: ExtractedFact): ExtractedFactDto { }; } -/** Assemble the detail DTO (document + its staged facts). */ +/** Assemble the detail DTO (document + its staged facts + links). */ export function serialiseDocumentDetail( - doc: InboundDocument, + doc: SerialisableDocument, facts: ExtractedFact[], + conditionLinks: DocumentConditionLinkDto[] = [], ): InboundDocumentDetailDto { const pendingCount = facts.filter((f) => f.status === "PENDING").length; // `factCount` excludes REJECTED facts (a rejected fact is discarded, not part // of the document's tally) so the badge matches the list query. const factCount = facts.filter((f) => f.status !== "REJECTED").length; return { - ...serialiseDocument(doc, { factCount, pendingCount }), + ...serialiseDocument(doc, { factCount, pendingCount }, conditionLinks), facts: facts.map(serialiseFact), }; } diff --git a/src/lib/documents/upload-policy.ts b/src/lib/documents/upload-policy.ts new file mode 100644 index 000000000..c1aa63555 --- /dev/null +++ b/src/lib/documents/upload-policy.ts @@ -0,0 +1,370 @@ +/** + * Document-vault upload policy: the accept matrix, magic-byte sniffers, the + * serving-class function, and the admin-tunable limits resolver. + * + * Three classes, closed deny-list, serving posture as the security boundary: + * + * - Class A (inline): PDF, JPEG, PNG, WebP, GIF — browser-native passive + * formats the serve route renders `Content-Disposition: inline` (PDF + * additionally under a `Content-Security-Policy: sandbox` response + * header). + * - Class B (attachment-only): Office (OOXML + legacy CFB), text/CSV/RTF/ + * Markdown, TIFF, HEIC/HEIF, XML/JSON — stored verbatim, served ONLY as + * `application/octet-stream` + `Content-Disposition: attachment` + + * `nosniff`. A misclassified Class B file therefore cannot execute in + * the app origin. + * - Deny: executables, scripts, HTML/XHTML/SVG (the only classes whose + * inline render executes script — zero medical-artefact value), generic + * archives / disk images, anything unidentifiable. + * + * The wire Content-Type is NEVER trusted anywhere — detection is magic-bytes + * first, extension only where a container format is ambiguous (ZIP → OOXML, + * CFB → legacy Office) or where no magic exists (plain-text formats). We + * never parse, rewrite, or "sanitise" stored documents: the original is the + * artefact; safety comes from what the serve route will and won't serve + * inline. + * + * The labs OCR path (`src/lib/labs/ocr-upload.ts`) keeps its own 12 MiB cap + * and narrow allowlist unchanged; this module only reuses its bounded-read + * primitive at the route layer. + */ +import { detectAvatarMimeType } from "@/lib/avatar"; +import { prisma } from "@/lib/db"; + +// ─── Limits (§3.3) ────────────────────────────────────────────────────────── + +/** Default per-file cap: 25 MiB (a ~50-page 300-dpi scan PDF, any phone photo). */ +export const DOCUMENT_DEFAULT_MAX_FILE_BYTES = 26_214_400; + +/** + * Hard server ceiling for the admin-tunable per-file cap: 100 MiB. Single-shot + * AES-256-GCM + bounded in-memory reads are load-bearing; past this the + * architecture (chunking, streaming crypto) would have to change, so the + * setting cannot be pushed there. + */ +export const DOCUMENT_HARD_MAX_FILE_BYTES = 104_857_600; + +/** Default per-user quota: 1 GiB. */ +export const DOCUMENT_DEFAULT_QUOTA_BYTES = 1_073_741_824; + +export interface DocumentLimits { + /** Per-file upload cap in bytes (admin-tunable, clamped to the ceiling). */ + maxFileBytes: number; + /** Per-user storage quota in bytes (user override ?? instance default). */ + quotaBytes: number; +} + +/** + * Resolve the effective limits for a user: `AppSettings.documentMaxFileBytes` + * clamped to the hard ceiling, and quota = `User.documentQuotaBytes` ?? + * `AppSettings.documentQuotaBytes`. Missing rows fall back to the defaults. + */ +export async function resolveDocumentLimits( + userId: string, +): Promise { + const [settings, user] = await Promise.all([ + prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { documentMaxFileBytes: true, documentQuotaBytes: true }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { documentQuotaBytes: true }, + }), + ]); + const rawMax = + settings?.documentMaxFileBytes ?? DOCUMENT_DEFAULT_MAX_FILE_BYTES; + const maxFileBytes = Math.min( + Math.max(rawMax, 1), + DOCUMENT_HARD_MAX_FILE_BYTES, + ); + const quotaBytes = Number( + user?.documentQuotaBytes ?? + settings?.documentQuotaBytes ?? + DOCUMENT_DEFAULT_QUOTA_BYTES, + ); + return { maxFileBytes, quotaBytes }; +} + +// ─── Accept matrix (§3.1) ─────────────────────────────────────────────────── + +export type DocumentServingClass = "inline" | "attachment"; + +/** Class A — browser-native passive formats served inline. */ +const CLASS_A_MIMES: ReadonlySet = new Set([ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]); + +/** + * The one source of truth the serve route uses: Class A renders inline with + * its true Content-Type; EVERYTHING else stored leaves the origin only as an + * opaque attachment download. + */ +export function servingClassFor(mimeType: string): DocumentServingClass { + return CLASS_A_MIMES.has(mimeType) ? "inline" : "attachment"; +} + +/** + * Extensions the upload UI's `accept` attribute mirrors (Class A + B minus + * HEIC — its exclusion makes the iOS picker auto-transcode camera photos to + * JPEG so the common path stays inline-previewable). Server-side policy + * remains the enforcement; this list is advisory for pickers. + */ +export const DOCUMENT_ACCEPTED_EXTENSIONS: readonly string[] = [ + ".pdf", + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".docx", + ".xlsx", + ".pptx", + ".doc", + ".xls", + ".ppt", + ".txt", + ".md", + ".csv", + ".rtf", + ".tif", + ".tiff", + ".xml", + ".json", +]; + +/** + * Extension deny-list: executables and scripts (regardless of content), + * HTML/XHTML/SVG (inline render executes script), generic archives and disk + * images. A ZIP container is allowed ONLY under an Office extension. + */ +const DENY_EXTENSIONS: ReadonlySet = new Set([ + ".exe", + ".dll", + ".bat", + ".cmd", + ".sh", + ".ps1", + ".js", + ".mjs", + ".jar", + ".apk", + ".html", + ".htm", + ".xhtml", + ".svg", + ".zip", + ".7z", + ".rar", + ".tar", + ".gz", + ".iso", + ".img", + ".dmg", +]); + +/** OOXML extensions → stored MIME (ZIP magic + Office extension required). */ +const OOXML_BY_EXTENSION: ReadonlyMap = new Map([ + [ + ".docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ], + [ + ".xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ], + [ + ".pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ], +]); + +/** Legacy Office extensions → stored MIME (CFB magic + extension required). */ +const CFB_BY_EXTENSION: ReadonlyMap = new Map([ + [".doc", "application/msword"], + [".xls", "application/vnd.ms-excel"], + [".ppt", "application/vnd.ms-powerpoint"], +]); + +/** Text-format extensions → stored MIME (heuristic-gated, no magic bytes). */ +const TEXT_BY_EXTENSION: ReadonlyMap = new Map([ + [".txt", "text/plain"], + [".md", "text/plain"], + [".csv", "text/csv"], + [".xml", "application/xml"], + [".json", "application/json"], +]); + +/** Lower-cased extension (with dot) of a filename, or "" when absent. */ +function extensionOf(filename: string | null | undefined): string { + if (!filename) return ""; + const idx = filename.lastIndexOf("."); + if (idx <= 0 || idx === filename.length - 1) return ""; + return filename.slice(idx).toLowerCase(); +} + +function startsWith(buffer: Buffer, bytes: readonly number[]): boolean { + if (buffer.length < bytes.length) return false; + for (let i = 0; i < bytes.length; i++) { + if (buffer[i] !== bytes[i]) return false; + } + return true; +} + +/** `%PDF-` */ +function isPdf(buffer: Buffer): boolean { + return startsWith(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]); +} + +/** `GIF87a` / `GIF89a` */ +function isGif(buffer: Buffer): boolean { + return ( + startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || + startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + ); +} + +/** ZIP local-file header `PK\x03\x04` (OOXML containers; also generic ZIPs). */ +function isZip(buffer: Buffer): boolean { + return startsWith(buffer, [0x50, 0x4b, 0x03, 0x04]); +} + +/** Compound File Binary (legacy Office) `D0 CF 11 E0 A1 B1 1A E1`. */ +function isCfb(buffer: Buffer): boolean { + return startsWith(buffer, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); +} + +/** RTF `{\rtf` */ +function isRtf(buffer: Buffer): boolean { + return startsWith(buffer, [0x7b, 0x5c, 0x72, 0x74, 0x66]); +} + +/** TIFF `II*\0` (little-endian) or `MM\0*` (big-endian). */ +function isTiff(buffer: Buffer): boolean { + return ( + startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) || + startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a]) + ); +} + +/** HEIC/HEIF — ISO-BMFF `ftyp` box with a HEIF-family brand. */ +const HEIF_BRANDS: ReadonlySet = new Set([ + "heic", + "heix", + "hevc", + "mif1", + "msf1", +]); + +function isHeif(buffer: Buffer): boolean { + if (buffer.length < 12) return false; + if (buffer.toString("ascii", 4, 8) !== "ftyp") return false; + const major = buffer.toString("ascii", 8, 12).toLowerCase(); + if (HEIF_BRANDS.has(major)) return true; + // Compatible-brand scan inside the ftyp box (bounded by the box length). + const boxLen = Math.min(buffer.readUInt32BE(0), buffer.length); + for (let off = 16; off + 4 <= boxLen; off += 4) { + const brand = buffer.toString("ascii", off, off + 4).toLowerCase(); + if (HEIF_BRANDS.has(brand)) return true; + } + return false; +} + +/** Executable magic: MZ (PE), ELF, Mach-O (32/64, both endians, fat). */ +function isExecutable(buffer: Buffer): boolean { + if (startsWith(buffer, [0x4d, 0x5a])) return true; // MZ + if (startsWith(buffer, [0x7f, 0x45, 0x4c, 0x46])) return true; // ELF + const machO = [ + [0xfe, 0xed, 0xfa, 0xce], + [0xfe, 0xed, 0xfa, 0xcf], + [0xce, 0xfa, 0xed, 0xfe], + [0xcf, 0xfa, 0xed, 0xfe], + [0xca, 0xfe, 0xba, 0xbe], + ] as const; + return machO.some((m) => startsWith(buffer, m)); +} + +/** + * Plain-text heuristic for the extension-gated text formats: no NUL byte in + * the first 8 KiB and the sample decodes as UTF-8 or Latin-1 (any byte + * sequence is valid Latin-1, so the effective gate is "no NUL" — a binary + * payload smuggled under `.txt` almost always carries NULs; if one does not, + * it is still stored Class B and can only ever leave as an opaque download). + */ +function looksLikeText(buffer: Buffer): boolean { + if (buffer.length === 0) return false; + const sample = buffer.subarray(0, 8192); + return !sample.includes(0x00); +} + +export interface DetectedDocumentType { + /** The MIME type the row stores (sniffed, never the wire Content-Type). */ + mimeType: string; + /** Derived serving class (same as `servingClassFor(mimeType)`). */ + servingClass: DocumentServingClass; +} + +/** + * Classify an upload from its bytes (+ the claimed filename, used only where + * a container/text format needs an extension gate). Returns null for every + * deny case: executables, script/markup extensions, generic archives, ZIPs + * without an Office extension, and anything unidentifiable. + */ +export function detectDocumentType( + buffer: Buffer, + filename: string | null | undefined, +): DetectedDocumentType | null { + const ext = extensionOf(filename); + + // Hard denies first: content-level executables, then the extension + // deny-list (an .exe named payload is refused even if its bytes look like + // text; HTML/SVG are refused regardless of content). + if (isExecutable(buffer)) return null; + if (DENY_EXTENSIONS.has(ext)) return null; + + // ── Class A (magic bytes only — extension is irrelevant) ── + if (isPdf(buffer)) { + return { mimeType: "application/pdf", servingClass: "inline" }; + } + if (isGif(buffer)) { + return { mimeType: "image/gif", servingClass: "inline" }; + } + const image = detectAvatarMimeType(buffer); + if (image) { + return { mimeType: image, servingClass: "inline" }; + } + + // ── Class B (magic where it exists; containers need the extension) ── + if (isZip(buffer)) { + // A ZIP container is acceptable ONLY as an OOXML Office file; a generic + // archive (any other extension, incl. none) is denied. + const ooxml = OOXML_BY_EXTENSION.get(ext); + return ooxml ? { mimeType: ooxml, servingClass: "attachment" } : null; + } + if (isCfb(buffer)) { + const legacy = CFB_BY_EXTENSION.get(ext); + return legacy ? { mimeType: legacy, servingClass: "attachment" } : null; + } + if (isRtf(buffer)) { + return { mimeType: "application/rtf", servingClass: "attachment" }; + } + if (isTiff(buffer)) { + return { mimeType: "image/tiff", servingClass: "attachment" }; + } + if (isHeif(buffer)) { + return { mimeType: "image/heic", servingClass: "attachment" }; + } + + // ── Text formats (extension + heuristic; no magic bytes exist) ── + const text = TEXT_BY_EXTENSION.get(ext); + if (text && looksLikeText(buffer)) { + return { mimeType: text, servingClass: "attachment" }; + } + + // Unidentifiable → deny. + return null; +}