Encrypt uploaded files at rest, like note bodies already were

Note text was encrypted from the start; the files beside it were not. A
copy of the uploads directory handed over every image in the clear while
the notes referring to them stayed unreadable — half a protection, and
the half that is easiest to walk off with.

Same key and cipher as note content, so a backup is worth exactly as much
as its .env either way, which was already true and is now true uniformly.

Files carry an INKENC01 header and say for themselves whether they are
encrypted. That is what makes the startup sweep idempotent, and it had to
be the file rather than a database row: attachments are shared between
users by content hash, so no single row owns one.

Named by the hash of the plaintext, so identical uploads still collapse
into one copy — a fresh IV per file would have defeated deduplication.
Read whole rather than streamed, because GCM only proves integrity at the
last block. Written through a temporary name and renamed, so a crash
cannot strand a half-encrypted file that would fail its tag forever.

Verified: the one existing attachment went from 78 to 114 bytes with the
header in front, a second start did nothing, and images still load in the
browser after a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 19:11:08 +02:00
1 parent df279a5d36
commit 915856e1a4
2 files changed
+129 -4

No files matched your search

+102 -3
View File
@@ -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:
*