// Symmetric encryption for secrets at rest (paid posts: the site owner's // Patreon creator token, klonkt-demo-aki slice 1). AES-256-GCM with a key // derived from PAID_SECRET (env), so a database dump alone leaks nothing usable. // Format: base64(iv) : base64(tag) : base64(ciphertext). // // The codebase had no symmetric-crypto helper; this is that one, kept tiny and // native (no dependency). If PAID_SECRET is unset, encryption is refused loudly // rather than storing plaintext. import crypto from 'crypto'; let _key = null; function key() { if (_key) return _key; const secret = process.env.PAID_SECRET; if (!secret || String(secret).length < 16) { throw new Error('PAID_SECRET is missing or too short (need >= 16 chars) to encrypt paid-posts secrets'); } _key = crypto.scryptSync(String(secret), 'klonkt-paid', 32); return _key; } // True when a key is configured, so callers can gate the feature instead of throwing. export function cryptoBoxReady() { try { key(); return true; } catch { return false; } } export function encrypt(plaintext) { if (plaintext == null) return null; const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv); const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`; } export function decrypt(blob) { if (blob == null || blob === '') return null; const parts = String(blob).split(':'); if (parts.length !== 3) throw new Error('malformed ciphertext'); const [iv, tag, ct] = parts.map((p) => Buffer.from(p, 'base64')); const decipher = crypto.createDecipheriv('aes-256-gcm', key(), iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); } // The stateless signed blob reused for the OAuth state and the WebAuthn // challenge (design doc "cookie-less trick"): HMAC over a short-lived payload, // so no server session is needed to bind pending state to a browser. export function signBlob(payload, ttlSeconds = 600) { const body = { ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds, nonce: crypto.randomBytes(8).toString('hex') }; const b = Buffer.from(JSON.stringify(body)).toString('base64url'); const tag = crypto.createHmac('sha256', key()).update(b).digest('base64url'); return `${b}.${tag}`; } // Returns the payload if valid and unexpired, else null. Constant-time tag check. export function verifyBlob(token) { const [b, tag] = String(token || '').split('.'); if (!b || !tag) return null; const expected = crypto.createHmac('sha256', key()).update(b).digest('base64url'); const a = Buffer.from(tag); const e = Buffer.from(expected); if (a.length !== e.length || !crypto.timingSafeEqual(a, e)) return null; let payload; try { payload = JSON.parse(Buffer.from(b, 'base64url').toString('utf8')); } catch { return null; } if (!payload || (payload.exp && payload.exp * 1000 < Date.now())) return null; return payload; }