| 1 | // Symmetric encryption for secrets at rest (paid posts: the site owner's
|
|---|
| 2 | // Patreon creator token, klonkt-demo-aki slice 1). AES-256-GCM with a key
|
|---|
| 3 | // derived from a secret, so a database dump alone leaks nothing usable.
|
|---|
| 4 | // Format: base64(iv) : base64(tag) : base64(ciphertext).
|
|---|
| 5 | //
|
|---|
| 6 | // The secret is resolved in this order so nobody has to edit the env:
|
|---|
| 7 | // 1. PAID_SECRET (env) — authoritative; a self-hoster who set it by hand
|
|---|
| 8 | // (or Bart, who already did) keeps working unchanged.
|
|---|
| 9 | // 2. a persisted key file next to the database, auto-generated on first use
|
|---|
| 10 | // (0600). This is what "first run" and "existing users after an update"
|
|---|
| 11 | // get automatically.
|
|---|
| 12 | // The key lives OUTSIDE the sqlite DB on purpose: encrypting the Patreon
|
|---|
| 13 | // secrets is pointless if the key sits in the same file a DB dump would leak.
|
|---|
| 14 | import crypto from 'crypto';
|
|---|
| 15 | import fs from 'fs';
|
|---|
| 16 | import path from 'path';
|
|---|
| 17 | import { fileURLToPath } from 'url';
|
|---|
| 18 |
|
|---|
| 19 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| 20 |
|
|---|
| 21 | // The key file sits in the same directory as the database.
|
|---|
| 22 | function keyFilePath() {
|
|---|
| 23 | const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
|
|---|
| 24 | const dir = dbPath === ':memory:' ? path.join(__dirname, '../../storage') : path.dirname(dbPath);
|
|---|
| 25 | return path.join(dir, '.paid-secret');
|
|---|
| 26 | }
|
|---|
| 27 |
|
|---|
| 28 | // Read the persisted key, generating + writing it (0600) the first time.
|
|---|
| 29 | function fileSecret() {
|
|---|
| 30 | const file = keyFilePath();
|
|---|
| 31 | try {
|
|---|
| 32 | const existing = fs.readFileSync(file, 'utf8').trim();
|
|---|
| 33 | if (existing.length >= 16) return existing;
|
|---|
| 34 | } catch { /* not created yet */ }
|
|---|
| 35 | const generated = crypto.randomBytes(32).toString('base64');
|
|---|
| 36 | fs.mkdirSync(path.dirname(file), { recursive: true });
|
|---|
| 37 | fs.writeFileSync(file, generated, { mode: 0o600 });
|
|---|
| 38 | try { fs.chmodSync(file, 0o600); } catch { /* non-POSIX fs */ }
|
|---|
| 39 | return generated;
|
|---|
| 40 | }
|
|---|
| 41 |
|
|---|
| 42 | // env wins; otherwise the auto-generated file. Never returns an ephemeral key:
|
|---|
| 43 | // if the file can't be persisted, fileSecret throws and the feature stays gated
|
|---|
| 44 | // (cryptoBoxReady false) rather than encrypting with a key lost on restart.
|
|---|
| 45 | function resolveSecret() {
|
|---|
| 46 | const env = process.env.PAID_SECRET;
|
|---|
| 47 | if (env && String(env).length >= 16) return String(env);
|
|---|
| 48 | return fileSecret();
|
|---|
| 49 | }
|
|---|
| 50 |
|
|---|
| 51 | let _key = null;
|
|---|
| 52 | function key() {
|
|---|
| 53 | if (_key) return _key;
|
|---|
| 54 | _key = crypto.scryptSync(resolveSecret(), 'klonkt-paid', 32);
|
|---|
| 55 | return _key;
|
|---|
| 56 | }
|
|---|
| 57 |
|
|---|
| 58 | // True when a key is configured, so callers can gate the feature instead of throwing.
|
|---|
| 59 | export function cryptoBoxReady() {
|
|---|
| 60 | try { key(); return true; } catch { return false; }
|
|---|
| 61 | }
|
|---|
| 62 |
|
|---|
| 63 | export function encrypt(plaintext) {
|
|---|
| 64 | if (plaintext == null) return null;
|
|---|
| 65 | const iv = crypto.randomBytes(12);
|
|---|
| 66 | const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
|---|
| 67 | const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
|---|
| 68 | const tag = cipher.getAuthTag();
|
|---|
| 69 | return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
|
|---|
| 70 | }
|
|---|
| 71 |
|
|---|
| 72 | export function decrypt(blob) {
|
|---|
| 73 | if (blob == null || blob === '') return null;
|
|---|
| 74 | const parts = String(blob).split(':');
|
|---|
| 75 | if (parts.length !== 3) throw new Error('malformed ciphertext');
|
|---|
| 76 | const [iv, tag, ct] = parts.map((p) => Buffer.from(p, 'base64'));
|
|---|
| 77 | const decipher = crypto.createDecipheriv('aes-256-gcm', key(), iv);
|
|---|
| 78 | decipher.setAuthTag(tag);
|
|---|
| 79 | return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|---|
| 80 | }
|
|---|
| 81 |
|
|---|
| 82 | // The stateless signed blob reused for the OAuth state and the WebAuthn
|
|---|
| 83 | // challenge (design doc "cookie-less trick"): HMAC over a short-lived payload,
|
|---|
| 84 | // so no server session is needed to bind pending state to a browser.
|
|---|
| 85 | export function signBlob(payload, ttlSeconds = 600) {
|
|---|
| 86 | const body = { ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds, nonce: crypto.randomBytes(8).toString('hex') };
|
|---|
| 87 | const b = Buffer.from(JSON.stringify(body)).toString('base64url');
|
|---|
| 88 | const tag = crypto.createHmac('sha256', key()).update(b).digest('base64url');
|
|---|
| 89 | return `${b}.${tag}`;
|
|---|
| 90 | }
|
|---|
| 91 |
|
|---|
| 92 | // Returns the payload if valid and unexpired, else null. Constant-time tag check.
|
|---|
| 93 | export function verifyBlob(token) {
|
|---|
| 94 | const [b, tag] = String(token || '').split('.');
|
|---|
| 95 | if (!b || !tag) return null;
|
|---|
| 96 | const expected = crypto.createHmac('sha256', key()).update(b).digest('base64url');
|
|---|
| 97 | const a = Buffer.from(tag); const e = Buffer.from(expected);
|
|---|
| 98 | if (a.length !== e.length || !crypto.timingSafeEqual(a, e)) return null;
|
|---|
| 99 | let payload; try { payload = JSON.parse(Buffer.from(b, 'base64url').toString('utf8')); } catch { return null; }
|
|---|
| 100 | if (!payload || (payload.exp && payload.exp * 1000 < Date.now())) return null;
|
|---|
| 101 | return payload;
|
|---|
| 102 | }
|
|---|