Changeset 4590e66 in Klonkt


Ignore:
Timestamp:
07/21/2026 03:39:35 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
f574435
Parents:
b9beb16
git-author:
Robin <roboburr@…> (07/21/2026 03:39:33 AM)
git-committer:
Robin <roboburr@…> (07/21/2026 03:39:35 AM)
Message:

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@…>

Files:
1 added
4 edited

Legend:

Unmodified
Added
Removed
  • src/routes/admin-paid.js

    rb9beb16 r4590e66  
    4848router.post('/', requireGod, (req, res) => {
    4949  if (!gate(req, res)) return;
    50   if (!cryptoBoxReady()) return res.redirect('/admin/paid?error=' + encodeURIComponent('PAID_SECRET ontbreekt in de serverconfig; secrets kunnen niet versleuteld worden opgeslagen.'));
     50  if (!cryptoBoxReady()) return res.redirect('/admin/paid?error=' + encodeURIComponent('De encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?); secrets kunnen niet veilig worden opgeslagen.'));
    5151  const b = req.body || {};
    5252  const eur = String(b.default_min_eur || '').replace(',', '.').trim();
  • src/services/CryptoBox.js

    rb9beb16 r4590e66  
    11// Symmetric encryption for secrets at rest (paid posts: the site owner's
    22// 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.
     3// derived from a secret, so a database dump alone leaks nothing usable.
    44// Format: base64(iv) : base64(tag) : base64(ciphertext).
    55//
    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.
     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.
    914import 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}
    1050
    1151let _key = null;
    1252function key() {
    1353  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);
     54  _key = crypto.scryptSync(resolveSecret(), 'klonkt-paid', 32);
    1955  return _key;
    2056}
  • src/services/PaidPatreonService.js

    rb9beb16 r4590e66  
    5050// the admin form can be re-saved without re-pasting the secret.
    5151export function saveOwnerConfig(siteId, patch) {
    52   if (!cryptoBoxReady()) throw new Error('PAID_SECRET is not set: cannot store Patreon secrets');
     52  if (!cryptoBoxReady()) throw new Error('encryption key unavailable: cannot store Patreon secrets');
    5353  const cur = getOwnerConfig(siteId) || {};
    5454  const merged = {
  • src/views/pages/admin-paid.ejs

    rb9beb16 r4590e66  
    1010  <% if (error) { %><p style="color:#c0392b"><%= error %></p><% } %>
    1111  <% if (!secretReady) { %>
    12     <p style="color:#c0392b">Let op: <code>PAID_SECRET</code> staat niet in de serverconfig. Zonder die sleutel kunnen secrets niet versleuteld worden opgeslagen.</p>
     12    <p style="color:#c0392b">Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.</p>
    1313  <% } %>
    1414
Note: See TracChangeset for help on using the changeset viewer.