| 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.
|
|---|
| 9 | import crypto from 'crypto';
|
|---|
| 10 |
|
|---|
| 11 | let _key = null;
|
|---|
| 12 | function 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.
|
|---|
| 23 | export function cryptoBoxReady() {
|
|---|
| 24 | try { key(); return true; } catch { return false; }
|
|---|
| 25 | }
|
|---|
| 26 |
|
|---|
| 27 | export 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 |
|
|---|
| 36 | export 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.
|
|---|
| 49 | export 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.
|
|---|
| 57 | export 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 | }
|
|---|