Index: src/services/CryptoBox.js
===================================================================
--- src/services/CryptoBox.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ src/services/CryptoBox.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
@@ -0,0 +1,66 @@
+// 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;
+}
