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:
1 parent
df279a5d36
commit
915856e1a4
2 files changed
+129
-4
No files matched your search
+102
-3
@@ -345,6 +345,57 @@ function encrypt(text) {
|
|||||||
return `v2:${iv.toString("hex")}:${cipher.getAuthTag().toString("hex")}:${enc.toString("hex")}`;
|
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) {
|
function decrypt(data) {
|
||||||
const s = String(data);
|
const s = String(data);
|
||||||
if (s.startsWith("v2:")) {
|
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 ALLOWED_ATTACHMENT_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]);
|
||||||
const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
|
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) => {
|
app.post("/api/attachments", authMiddleware, (req, res) => {
|
||||||
const { data, filename } = req.body || {};
|
const { data, filename } = req.body || {};
|
||||||
if (typeof data !== "string") return res.status(400).json({ error: "data required" });
|
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);
|
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 });
|
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);
|
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();
|
const id = uuid();
|
||||||
db.prepare("INSERT INTO attachments (id, user_id, sha256, mime, size, filename) VALUES (?, ?, ?, ?, ?, ?)")
|
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);
|
const filePath = path.join(ATTACHMENT_DIR, a.sha256);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: "File missing" });
|
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-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("Cache-Control", "private, max-age=31536000, immutable"); // content-addressed: never changes
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
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:
|
/* Two routes used to sit here and were removed on 2026-08-08:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
- **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
|
- **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
|
- **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
|
- **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public
|
||||||
sign-up: an administrator creates the accounts
|
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
|
- **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
|
## Attachments
|
||||||
|
|
||||||
Images used to be inlined into the note body as base64 data URIs — a few
|
Images used to be inlined into the note body as base64 data URIs — a few
|
||||||
|
|||||||
Reference in new issue
Block a user