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

main
Last change on this file since c3d12a6 was 61e3daf, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: paid posts slice 1, owner Patreon config (encrypted)

The site owner can connect their OWN Patreon campaign for paid posts
(klonkt-demo-aki), premium-gated in Beheer. Client id/secret, campaign
id and the creator access/refresh token are stored ENCRYPTED at rest
(new CryptoBox AES-256-GCM helper, key from PAID_SECRET), so a database
dump leaks nothing usable; the token auto-refreshes. Separate from
Klonkt Premium's license flow, which is untouched. Degrades gracefully:
without PAID_SECRET the admin page refuses to save rather than storing
plaintext. Nothing patron-facing yet (posts.paid + unlock come in
slices 2 to 4), so no changelog entry.

CryptoBox also carries the cookie-less signed-blob helper (signBlob/
verifyBlob) that slices 3 and 4 reuse for the OAuth state and the
WebAuthn challenge.

Changed files:
src/config/database.js

  • paid_patreon table (site_id PK, secrets encrypted)

src/server.js

  • mount /admin/paid

src/views/pages/admin.ejs

  • "Betaalde posts" button in Beheer

New file:
src/services/CryptoBox.js

  • aes-256-gcm encrypt/decrypt + HMAC signBlob/verifyBlob

src/services/PaidPatreonService.js

  • owner config CRUD (encrypted), token refresh, creatorAccessToken

src/routes/admin-paid.js

  • premium-gated config form (GET/POST/disconnect)

src/views/pages/admin-paid.ejs

  • the form + status

test/paid-patreon.test.js

  • crypto roundtrip, no-plaintext-in-DB, refresh, blob signing

docs/paid-posts-design.md, docs/privacy-betaalde-posts.md

  • concurrency property documented

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

  • Property mode set to 100644
File size: 3.0 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 PAID_SECRET (env), so a database dump alone leaks nothing usable.
4// Format: base64(iv) : base64(tag) : base64(ciphertext).
5//
6// The codebase had no symmetric-crypto helper; this is that one, kept tiny and
7// native (no dependency). If PAID_SECRET is unset, encryption is refused loudly
8// rather than storing plaintext.
9import crypto from 'crypto';
10
11let _key = null;
12function key() {
13 if (_key) return _key;
14 const secret = process.env.PAID_SECRET;
15 if (!secret || String(secret).length < 16) {
16 throw new Error('PAID_SECRET is missing or too short (need >= 16 chars) to encrypt paid-posts secrets');
17 }
18 _key = crypto.scryptSync(String(secret), 'klonkt-paid', 32);
19 return _key;
20}
21
22// True when a key is configured, so callers can gate the feature instead of throwing.
23export function cryptoBoxReady() {
24 try { key(); return true; } catch { return false; }
25}
26
27export function encrypt(plaintext) {
28 if (plaintext == null) return null;
29 const iv = crypto.randomBytes(12);
30 const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
31 const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
32 const tag = cipher.getAuthTag();
33 return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
34}
35
36export function decrypt(blob) {
37 if (blob == null || blob === '') return null;
38 const parts = String(blob).split(':');
39 if (parts.length !== 3) throw new Error('malformed ciphertext');
40 const [iv, tag, ct] = parts.map((p) => Buffer.from(p, 'base64'));
41 const decipher = crypto.createDecipheriv('aes-256-gcm', key(), iv);
42 decipher.setAuthTag(tag);
43 return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
44}
45
46// The stateless signed blob reused for the OAuth state and the WebAuthn
47// challenge (design doc "cookie-less trick"): HMAC over a short-lived payload,
48// so no server session is needed to bind pending state to a browser.
49export function signBlob(payload, ttlSeconds = 600) {
50 const body = { ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds, nonce: crypto.randomBytes(8).toString('hex') };
51 const b = Buffer.from(JSON.stringify(body)).toString('base64url');
52 const tag = crypto.createHmac('sha256', key()).update(b).digest('base64url');
53 return `${b}.${tag}`;
54}
55
56// Returns the payload if valid and unexpired, else null. Constant-time tag check.
57export function verifyBlob(token) {
58 const [b, tag] = String(token || '').split('.');
59 if (!b || !tag) return null;
60 const expected = crypto.createHmac('sha256', key()).update(b).digest('base64url');
61 const a = Buffer.from(tag); const e = Buffer.from(expected);
62 if (a.length !== e.length || !crypto.timingSafeEqual(a, e)) return null;
63 let payload; try { payload = JSON.parse(Buffer.from(b, 'base64url').toString('utf8')); } catch { return null; }
64 if (!payload || (payload.exp && payload.exp * 1000 < Date.now())) return null;
65 return payload;
66}
Note: See TracBrowser for help on using the repository browser.