source: Klonkt/src/services/CryptoBox.js@ b05eb97

main
Last change on this file since b05eb97 was 4590e66, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: auto-generate the paid-posts encryption key (no env needed)

Nobody should have to hand-edit the env to use paid posts. CryptoBox now
resolves its key as: PAID_SECRET (env) if set, else a persisted key file
next to the database, auto-generated (0600) on first use. New installs and
existing users after an update get a key with zero config; a self-hoster
who set PAID_SECRET (Bart already did) keeps working unchanged, env wins.

The key lives OUTSIDE the sqlite DB on purpose: encrypting the Patreon
secrets is pointless if the key sits in the same file a DB dump would leak.
If the file can't be persisted (read-only fs) the box stays "not ready"
rather than using an ephemeral key that a restart would lose, so ciphertext
never becomes undecryptable.

Admin copy that referenced PAID_SECRET is updated: the warning now describes
the real remaining failure (key can't be created/read), not a missing env.

Changed files:
src/services/CryptoBox.js

  • resolve secret: env, else auto-generated 0600 key file by the database

src/services/PaidPatreonService.js

  • save error no longer names PAID_SECRET

src/routes/admin-paid.js, src/views/pages/admin-paid.ejs

  • not-ready copy describes the key file, not a missing env var

New file:
test/paid-secret.test.js

  • without PAID_SECRET: key file generated (0600), encrypt/decrypt roundtrips, key persists

remarks: the generated storage/.paid-secret must be backed up alongside the
DB, or the stored Patreon secrets can't be decrypted after a restore.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 4.5 KB
Line 
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.
14import crypto from 'crypto';
15import fs from 'fs';
16import path from 'path';
17import { fileURLToPath } from 'url';
18
19const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
21// The key file sits in the same directory as the database.
22function 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.
29function 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.
45function resolveSecret() {
46 const env = process.env.PAID_SECRET;
47 if (env && String(env).length >= 16) return String(env);
48 return fileSecret();
49}
50
51let _key = null;
52function 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.
59export function cryptoBoxReady() {
60 try { key(); return true; } catch { return false; }
61}
62
63export 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
72export 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.
85export 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.
93export 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}
Note: See TracBrowser for help on using the repository browser.