diff --git a/backend/server.js b/backend/server.js index 082a09c..1490a76 100644 --- a/backend/server.js +++ b/backend/server.js @@ -345,6 +345,57 @@ function encrypt(text) { return `v2:${iv.toString("hex")}:${cipher.getAuthTag().toString("hex")}:${enc.toString("hex")}`; } +/* ─── Files at rest ─── + * + * Note bodies were encrypted from the start, but uploaded files were not: a + * copy of the uploads directory handed over every image in the clear while the + * notes around them stayed unreadable. Same key, same cipher, so a backup is + * worth exactly as much (or as little) as its .env either way. + * + * The header lets a file say for itself whether it is encrypted, which is what + * makes the migration below safe to run again and again: files are shared + * between users by content hash, so marking a database row would not have been + * enough to tell. + */ +const FILE_MAGIC = Buffer.from("INKENC01", "ascii"); // 8 bytes +const IV_BYTES = 12; +const TAG_BYTES = 16; + +function encryptBuffer(plain) { + const iv = crypto.randomBytes(IV_BYTES); + const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv); + const enc = Buffer.concat([cipher.update(plain), cipher.final()]); + return Buffer.concat([FILE_MAGIC, iv, cipher.getAuthTag(), enc]); +} + +const isEncryptedFile = (buf) => + buf.length >= FILE_MAGIC.length && buf.subarray(0, FILE_MAGIC.length).equals(FILE_MAGIC); + +/** + * Writes through a temporary file and renames. + * + * A rename is atomic on the same filesystem, so a crash mid-write leaves either + * the old file or the new one — never a half-written attachment that would fail + * its authentication tag and read as corrupt forever after. + */ +function writeFileAtomic(filePath, bytes) { + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, bytes); + fs.renameSync(tmp, filePath); +} + +function decryptBuffer(buf) { + // Files written before this existed are still plain bytes; they read straight + // through until the sweep below gets to them. + if (!isEncryptedFile(buf)) return buf; + const at = FILE_MAGIC.length; + const iv = buf.subarray(at, at + IV_BYTES); + const tag = buf.subarray(at + IV_BYTES, at + IV_BYTES + TAG_BYTES); + const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(buf.subarray(at + IV_BYTES + TAG_BYTES)), decipher.final()]); +} + function decrypt(data) { const s = String(data); if (s.startsWith("v2:")) { @@ -1029,6 +1080,42 @@ if (!fs.existsSync(ATTACHMENT_DIR)) fs.mkdirSync(ATTACHMENT_DIR, { recursive: tr const ALLOWED_ATTACHMENT_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]); const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024; +/** + * Encrypts any attachment still lying on disk in the clear. + * + * Runs at every start and is idempotent: the header says whether a file has + * already been done, so an interrupted sweep simply resumes. Each file is + * written through a temporary name and renamed, so a crash cannot leave one + * half-encrypted — the state on disk is always either fully one or the other. + */ +(function encryptExistingAttachments() { + let files; + try { + files = fs.readdirSync(ATTACHMENT_DIR); + } catch { + return; + } + + let done = 0; + let failed = 0; + for (const name of files) { + if (name.includes(".tmp-")) continue; // leftover from an interrupted run + const filePath = path.join(ATTACHMENT_DIR, name); + try { + if (!fs.statSync(filePath).isFile()) continue; + const buf = fs.readFileSync(filePath); + if (isEncryptedFile(buf)) continue; + writeFileAtomic(filePath, encryptBuffer(buf)); + done++; + } catch (err) { + console.error(` ⚠️ could not encrypt attachment ${name}: ${err.message}`); + failed++; + } + } + if (done) console.log(` 🔒 encrypted ${done} attachment file(s) that were stored in the clear`); + if (failed) console.error(` ⚠️ ${failed} attachment file(s) could not be encrypted — they still read fine`); +})(); + app.post("/api/attachments", authMiddleware, (req, res) => { const { data, filename } = req.body || {}; if (typeof data !== "string") return res.status(400).json({ error: "data required" }); @@ -1052,8 +1139,10 @@ app.post("/api/attachments", authMiddleware, (req, res) => { const existing = db.prepare("SELECT id, mime, size FROM attachments WHERE user_id = ? AND sha256 = ?").get(req.userId, sha256); if (existing) return res.json({ id: existing.id, mime: existing.mime, size: existing.size, deduplicated: true }); + // Named by the hash of the plaintext, so identical files still land on one + // copy — encryption uses a fresh IV each time and would otherwise defeat that. const filePath = path.join(ATTACHMENT_DIR, sha256); - if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, buf); + if (!fs.existsSync(filePath)) writeFileAtomic(filePath, encryptBuffer(buf)); const id = uuid(); db.prepare("INSERT INTO attachments (id, user_id, sha256, mime, size, filename) VALUES (?, ?, ?, ?, ?, ?)") @@ -1071,11 +1160,21 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => { const filePath = path.join(ATTACHMENT_DIR, a.sha256); if (!fs.existsSync(filePath)) return res.status(404).json({ error: "File missing" }); + let bytes; + try { + // Read whole rather than streamed: GCM only proves the file is intact once + // the last block is in, and half a corrupt image is worse than an error. + bytes = decryptBuffer(fs.readFileSync(filePath)); + } catch (err) { + console.error(` ❌ could not decrypt attachment ${a.id} (${a.sha256}): ${err.message}`); + return res.status(500).json({ error: "File could not be read" }); + } + res.setHeader("Content-Type", a.mime); - res.setHeader("Content-Length", a.size); + res.setHeader("Content-Length", bytes.length); res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); // content-addressed: never changes res.setHeader("X-Content-Type-Options", "nosniff"); - fs.createReadStream(filePath).pipe(res); + res.end(bytes); }); /* Two routes used to sit here and were removed on 2026-08-08: * diff --git a/readme.md b/readme.md index 38ab89c..07027d0 100644 --- a/readme.md +++ b/readme.md @@ -24,7 +24,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing, - **Offline-First** — Works fully offline, syncs when connection returns - **Conflict-Aware Sync** — A write from a device that missed another's changes keeps the state it replaces as a version, rather than burying it - **WebSocket Real-Time Push** — Changes appear on other devices instantly -- **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server +- **Encrypted at Rest** — AES-256-GCM for note content *and* uploaded files on the server - **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public sign-up: an administrator creates the accounts - **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density @@ -409,6 +409,32 @@ a snippet around the first match. --- +## Encryption at rest + +Note bodies and uploaded files are both encrypted with AES-256-GCM under +`ENCRYPTION_KEY`. Losing that key loses the notes — back it up with the database, +not next to it. + +Files carry an 8-byte `INKENC01` header, so a file says for itself whether it is +encrypted. That is what lets the startup sweep run on every boot and encrypt only +what is still in the clear: files are shared between users by content hash, so a +flag on a database row could not have answered the question. + +Two consequences worth knowing: + +- **Deduplication still works.** Files are named by the hash of the *plaintext*, + so two identical uploads share one copy — encrypting each with a fresh IV would + otherwise produce two different files from the same picture. +- **Reads are not streamed.** GCM only proves a file is intact once the final + block is in, so an attachment is read whole and verified before anything is + sent. Half of a corrupt image is worse than an error. + +Uploads are written to a temporary name and renamed into place. A rename is +atomic on one filesystem, so a crash mid-write leaves the old file or the new +one, never a half-written one that would fail its tag forever after. + +--- + ## Attachments Images used to be inlined into the note body as base64 data URIs — a few