Index: src/middleware/rate-limit.js
===================================================================
--- src/middleware/rate-limit.js	(revision 5869b1f0dd97ec47d1df7926e2a730513071c2c6)
+++ src/middleware/rate-limit.js	(revision 58cad21190614d41cd361551c6063c848db6d3e1)
@@ -101,4 +101,33 @@
 });
 
+// ─── OpenWebAuth /magic ───────────────────────────────────────────
+// Elke poging doet EEN RSA-ontsleuteling met de actorsleutel van een site. Dat
+// is precies de vorm waar een Bleichenbacher/Marvin-orakel op draait: veel
+// aangepaste ciphertexts, en uit de antwoorden de sleutel afleiden. De
+// ontsleuteling zelf is daartegen gehard (implicit rejection in
+// OpenWebAuthService.decryptToken), maar echte constant-time code bestaat niet
+// in JavaScript. Een grens op het AANTAL pogingen doet daarom het zware werk:
+// een orakel heeft er honderdduizenden nodig.
+//
+// TELT ALLE POGINGEN, niet alleen de mislukte. Een teller die alleen faalt
+// meetelt is zelf weer een orakel -- dan leest een aanvaller aan het knijpen af
+// of zijn padding klopte, en is de vertakking die we bij de ontsleuteling
+// weghaalden aan de achterdeur terug.
+//
+// Per SITE-SLUG, want dat is wat een sleutelpaar heeft (getOrCreateKeys(slug)):
+// de grens hoort bij de sleutel die beschermd wordt, niet bij het IP van de
+// eigenaar of bij de doel-host die de aanvaller zelf kiest.
+//
+// Twintig per uur is voor een mens onzichtbaar -- je klikt een handvol keer per
+// dag naar een andere site -- en voor een orakel dodelijk.
+export const owaMagicLimiter = rateLimit({
+  windowMs: 60 * 60 * 1000,
+  max: 20,
+  standardHeaders: true,
+  legacyHeaders: false,
+  keyGenerator: (req) => 'owa:' + String((req.body && req.body.slug) || (req.session && req.session.user && req.session.user.id) || 'onbekend'),
+  validate: { ip: false },
+});
+
 // Inbox POSTs each trigger an outbound actor fetch (signature verify) → cap the
 // amplification/queue-inflation a single source can drive. 120/min/IP is still
Index: src/routes/openwebauth.js
===================================================================
--- src/routes/openwebauth.js	(revision 5869b1f0dd97ec47d1df7926e2a730513071c2c6)
+++ src/routes/openwebauth.js	(revision 58cad21190614d41cd361551c6063c848db6d3e1)
@@ -16,4 +16,5 @@
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
+import { owaMagicLimiter } from '../middleware/rate-limit.js';
 
 const router = express.Router();
@@ -166,5 +167,5 @@
  * foutpagina geeft en geen redirect.
  */
-router.post('/magic', async (req, res) => {
+router.post('/magic', owaMagicLimiter, async (req, res) => {
   const bdest = OWA.fromBdest(req.body && req.body.bdest);
   if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
Index: src/services/OpenWebAuthService.js
===================================================================
--- src/services/OpenWebAuthService.js	(revision 5869b1f0dd97ec47d1df7926e2a730513071c2c6)
+++ src/services/OpenWebAuthService.js	(revision 58cad21190614d41cd361551c6063c848db6d3e1)
@@ -233,24 +233,97 @@
 }
 
+/**
+ * Een deterministische nep-uitkomst, afgeleid uit de ciphertext en onze eigen
+ * sleutel. Dit is de kern van implicit rejection: bij ongeldige padding geven we
+ * GEEN fout maar een waarde, zodat "klopte de padding" nergens af te lezen is.
+ *
+ * DETERMINISTISCH, en dat is geen detail. Zou dit verse willekeur zijn, dan
+ * geeft dezelfde ciphertext twee keer aanbieden twee verschillende antwoorden --
+ * en juist dat verschil is het onderscheid dat we wilden verbergen. Zo doen TLS
+ * en OpenSSL 3.2 het ook: afgeleid uit sleutel + ciphertext, dus stabiel bij
+ * herhaling en onvoorspelbaar voor wie de sleutel niet heeft.
+ *
+ * Geëxporteerd omdat die eigenschap toetsbaar moet zijn; buiten de tests heeft
+ * niemand hem nodig.
+ *
+ * EERLIJK OVER WAT DIT WEL EN NIET DRAAGT (gemeten 19-8): haal je hem weg, dan
+ * blijft de suite groen. De andere tak geeft dan een LEGE string terug, en die
+ * sneuvelt net zo goed op de tekenset-controle hieronder -- "werpt niet" en
+ * "levert geen token" zijn dus al gedekt zonder deze functie. Wat hij toevoegt
+ * is dat ALLE faalwegen dezelfde vorm teruggeven: verkeerde sleutel, verkeerde
+ * lengte, kapotte base64, ongeldige padding. Een lege string is een verklikker
+ * voor wie ooit naar de rauwe waarde kijkt in plaats van naar het eindoordeel;
+ * afgeleide bytes zijn dat niet. Zo doen TLS en OpenSSL 3.2 het ook.
+ */
+export function _nepUitkomst(privatePem, ct) {
+  const geheim = crypto.createHash('sha256').update(String(privatePem)).digest();
+  return crypto.createHmac('sha256', geheim).update(ct).digest().toString('latin1');
+}
+
+/**
+ * PKCS#1 v1.5 zelf uitpakken (EME-PKCS1-v1_5: 00 02 PS 00 M).
+ *
+ * WAAROM ZELF: Node weigert `privateDecrypt` met RSA_PKCS1_PADDING sinds de
+ * mitigatie voor CVE-2023-46809 (Marvin). De revert-vlag bestaat alleen op de
+ * lijnen 18/20/21 -- Node 22+ heeft hem nooit gehad, en 20 is sinds 30 april
+ * 2026 EOL. Er is dus geen weg terug; zie shaer-r15.
+ *
+ * OpenWebAuth (FEP-61cf) schrijft v1.5 voor, dus overstappen op OAEP repareert
+ * de fout en breekt de interop met Hubzilla. Blijft over: `RSA_NO_PADDING` en
+ * het omhulsel er zelf afhalen -- precies het stuk dat de CVE veroorzaakte, dus
+ * met de zorg die daarbij hoort.
+ *
+ * GEEN VROEGE UITGANG EN GEEN WORP. De scan loopt altijd het hele blok af en
+ * beide takken doen hetzelfde werk. Dat is geen echte constant-time -- die
+ * krijg je in JavaScript met JIT en GC niet -- maar het haalt wel het
+ * waarneembare verschil weg. Wat de aanval hier echt begrenst is de teller op
+ * /magic: een orakel heeft honderdduizenden pogingen nodig.
+ */
+function pakUit(blok, privatePem, ct) {
+  const k = blok.length;
+  // Kop: 00 02. Als getal uitrekenen, niet als vertakking.
+  let goed = ((blok[0] === 0x00) & (blok[1] === 0x02));
+  // Eerste nulbyte vanaf 2 zoeken ZONDER de lus te verlaten.
+  let sep = -1;
+  for (let i = 2; i < k; i++) {
+    const isNul = blok[i] === 0x00 ? 1 : 0;
+    const nogNiet = sep === -1 ? 1 : 0;
+    sep = sep + (isNul & nogNiet) * (i - sep);
+  }
+  // PS moet minstens 8 bytes zijn (RFC 8017), dus de scheider ligt op >= 10.
+  goed = goed & (sep >= 10 ? 1 : 0) & (sep < k ? 1 : 0);
+  const echt = blok.subarray(goed ? sep + 1 : k).toString('utf8');
+  const nep = _nepUitkomst(privatePem, ct);
+  return goed ? echt : nep;
+}
+
 /** Het token uitpakken met onze eigen prive-sleutel. */
 export function decryptToken(encrypted, privatePem) {
   const b64 = String(encrypted || '').replace(/-/g, '+').replace(/_/g, '/');
-  const buf = crypto.privateDecrypt(
-    { key: privatePem, padding: crypto.constants.RSA_PKCS1_PADDING },
-    Buffer.from(b64, 'base64'),
-  );
-  const t = buf.toString('utf8');
-  // Een token is URL-veilige tekst. Bij een verkeerde sleutel geeft PKCS#1 v1.5
-  // geen fout maar afgeleide onzin terug (implicit rejection, zie de toelichting
-  // bij encryptTokenFor), en die onzin hoort hier te stranden in plaats van als
-  // token de wereld in te gaan.
+  const ct = Buffer.from(b64, 'base64');
+  let k = 0;
+  try { k = crypto.createPublicKey(privatePem).asymmetricKeyDetails.modulusLength / 8; } catch { k = 0; }
+
+  // Een blok van de verkeerde lengte zegt niets over de sleutel, maar het zou
+  // wel werpen -- en een worp is precies het signaal dat we kwijt willen. Dus
+  // dezelfde weg als een ongeldige padding.
+  let blok = null;
+  if (k && ct.length === k) {
+    try {
+      blok = crypto.privateDecrypt({ key: privatePem, padding: crypto.constants.RSA_NO_PADDING }, ct);
+    } catch { blok = null; }
+  }
+  const t = (blok && blok.length === k) ? pakUit(blok, privatePem, ct) : _nepUitkomst(privatePem, ct);
+
+  // Een token is URL-veilige tekst. Wat hierboven uit een mislukking komt is
+  // afgeleide onzin, en die hoort hier te stranden in plaats van als token de
+  // wereld in te gaan.
   //
   // ALLEBEI de voorwaarden doen werk, en dat is gemeten met 300 vreemde sleutels:
   //  - de TEKENSET vangt vrijwel alles. Van die 300 was er geen enkele die
   //    volledig uit URL-veilige tekens bestond.
-  //  - de ONDERGRENS vangt de rest. De onzin heeft een willekeurige lengte (5
-  //    tot 209 bytes gezien), en 18 van de 300 was korter dan 16 bytes. Bij zo'n
-  //    kort stukje is "toevallig allemaal URL-veilig" niet meer verwaarloosbaar:
-  //    per byte is die kans ruwweg een kwart.
+  //  - de ONDERGRENS vangt de rest. De onzin heeft een willekeurige lengte, en
+  //    bij een kort stukje is "toevallig allemaal URL-veilig" niet meer
+  //    verwaarloosbaar: per byte is die kans ruwweg een kwart.
   // Zestien is daarmee geen rond getal maar een grens die iets doet. Een echte
   // implementatie zit er ruim boven (de onze: 43 tekens).
Index: test/openwebauth.test.js
===================================================================
--- test/openwebauth.test.js	(revision 5869b1f0dd97ec47d1df7926e2a730513071c2c6)
+++ test/openwebauth.test.js	(revision 58cad21190614d41cd361551c6063c848db6d3e1)
@@ -74,8 +74,9 @@
   assert.ok(!/[+/]/.test(versleuteld), 'en URL-veilig');
 
-  const terug = crypto.privateDecrypt(
-    { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
-    Buffer.from(versleuteld.replace(/-/g, '+').replace(/_/g, '/'), 'base64'),
-  ).toString('utf8');
+  // Door ONZE eigen uitpakker, niet rechtstreeks door crypto.privateDecrypt:
+  // die weigert PKCS#1 v1.5 sinds de mitigatie voor CVE-2023-46809, en de
+  // revert-vlag bestaat alleen op Node 18/20/21 -- allemaal EOL. decryptToken
+  // haalt het omhulsel zelf af en werkt dus op een Node die nog leeft.
+  const terug = OWA.decryptToken(versleuteld, privateKey.export({ type: 'pkcs8', format: 'pem' }));
   assert.equal(terug, token);
 
@@ -83,20 +84,24 @@
   // de voor de hand liggende toets (assert.throws) is hier fout.
   //
-  // PKCS#1 v1.5 gooit bij een verkeerde sleutel geen fout: OpenSSL 3 doet aan
-  // "implicit rejection" en geeft afgeleide onzin terug in plaats van te falen,
-  // juist zodat een aanvaller niet aan het foutgedrag kan aflezen of zijn gok
-  // klopte (Bleichenbacher/Marvin). Gemeten: 200 vreemde sleutels, 0 fouten,
-  // 200 keer bytes -- en 0 keer het token.
+  // Bij een verkeerde sleutel hoort er geen FOUT te komen maar afgeleide onzin:
+  // implicit rejection. Anders leest een aanvaller aan het foutgedrag af of zijn
+  // gok klopte (Bleichenbacher/Marvin).
+  //
+  // LET OP waar dat vandaan komt. OpenSSL doet dit pas zelf vanaf 3.2; op 3.0.19
+  // -- wat Node 20 meebrengt en wat wij draaien -- WERPT hij gewoon. Gemeten op
+  // 19-8-2026. Een eerdere versie van deze test ging uit van OpenSSL's gedrag en
+  // was daarmee afhankelijk van welke Node de meting toevallig deed. Nu maakt
+  // decryptToken de implicit rejection ZELF, dus de eigenschap staat vast
+  // ongeacht de OpenSSL eronder.
   //
   // De eigenschap die telt is dus niet "het knalt" maar "er komt iets anders
   // uit". Wat de home instance daarna terugstuurt matcht geen enkel opgeslagen
   // token, en de inlog mislukt gewoon.
-  const ruw = Buffer.from(versleuteld.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
   for (let i = 0; i < 5; i++) {
-    const vreemde = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
+    const vreemde = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
+      .privateKey.export({ type: 'pkcs8', format: 'pem' });
     let uit = null;
-    try {
-      uit = crypto.privateDecrypt({ key: vreemde, padding: crypto.constants.RSA_PKCS1_PADDING }, ruw).toString('utf8');
-    } catch { uit = null; }                     // een fout mag, maar is niet de regel
+    assert.doesNotThrow(() => { uit = OWA.decryptToken(versleuteld, vreemde); },
+      'werpen is het signaal waar Bleichenbacher op draait');
     assert.notEqual(uit, token, 'andermans sleutel levert nooit het token');
   }
Index: test/owa-decrypt-v15.test.js
===================================================================
--- test/owa-decrypt-v15.test.js	(revision 58cad21190614d41cd361551c6063c848db6d3e1)
+++ test/owa-decrypt-v15.test.js	(revision 58cad21190614d41cd361551c6063c848db6d3e1)
@@ -0,0 +1,111 @@
+// PKCS#1 v1.5 zelf uitpakken (shaer-r15).
+//
+// Node weigert privateDecrypt met RSA_PKCS1_PADDING sinds de mitigatie voor
+// CVE-2023-46809, en de revert-vlag bestaat alleen op 18/20/21 -- allemaal EOL.
+// FEP-61cf schrijft v1.5 voor, dus OAEP breekt de interop. Blijft over: het
+// omhulsel zelf afhalen, met implicit rejection erin.
+//
+// DEZE TESTS DRAAIEN ZONDER --security-revert. Dat is de hele inzet: als ze
+// groen zijn, werkt gastlogin op een Node die nog ondersteund wordt.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import { decryptToken, encryptTokenFor, _nepUitkomst } from '../src/services/OpenWebAuthService.js';
+
+const paar = () => crypto.generateKeyPairSync('rsa', {
+  modulusLength: 2048,
+  publicKeyEncoding: { type: 'spki', format: 'pem' },
+  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+});
+const A = paar();
+const TOKEN = 'abcdefghijklmnopqrstuvwxyz0123456789._~-';
+
+test('een echt token komt er heel uit, zonder revert-vlag', () => {
+  const ct = encryptTokenFor(TOKEN, A.publicKey);
+  assert.equal(decryptToken(ct, A.privateKey), TOKEN);
+});
+
+test('een verkeerde sleutel WERPT niet maar levert niets op', () => {
+  const B = paar();
+  const ct = encryptTokenFor(TOKEN, A.publicKey);
+  // Werpen is het signaal waar Bleichenbacher op draait. Dat mag hier niet.
+  assert.doesNotThrow(() => decryptToken(ct, B.privateKey));
+  assert.equal(decryptToken(ct, B.privateKey), null);
+});
+
+// Dezelfde meting als waar het oude commentaar op stond, nu tegen de nieuwe weg.
+test('300 vreemde sleutels: geen enkele worp, geen enkel token', () => {
+  const ct = encryptTokenFor(TOKEN, A.publicKey);
+  let worpen = 0, tokens = 0;
+  for (let i = 0; i < 300; i++) {
+    try { if (decryptToken(ct, paar().privateKey) !== null) tokens++; } catch { worpen++; }
+  }
+  assert.equal(worpen, 0, 'geen enkele aanroep mag werpen');
+  assert.equal(tokens, 0, 'afgeleide onzin mag nooit als token doorgaan');
+});
+
+// De eigenschap waar implicit rejection op staat of valt.
+test('de nep-uitkomst is DETERMINISTISCH, niet vers willekeurig', () => {
+  const ct = Buffer.from('een-ciphertext-om-te-herhalen');
+  assert.equal(_nepUitkomst(A.privateKey, ct), _nepUitkomst(A.privateKey, ct),
+    'dezelfde ciphertext moet hetzelfde antwoord geven; anders verklikt herhaling het verschil');
+  assert.notEqual(_nepUitkomst(A.privateKey, ct), _nepUitkomst(paar().privateKey, ct),
+    'en hij moet aan de sleutel hangen, anders is hij te voorspellen');
+  assert.notEqual(_nepUitkomst(A.privateKey, ct), _nepUitkomst(A.privateKey, Buffer.from('iets anders')));
+});
+
+test('rommel erin werpt ook niet', () => {
+  for (const rommel of ['', 'geen base64!!', 'a'.repeat(10), 'x'.repeat(400), null, undefined]) {
+    assert.doesNotThrow(() => decryptToken(rommel, A.privateKey), String(rommel).slice(0, 12));
+    assert.equal(decryptToken(rommel, A.privateKey), null);
+  }
+});
+
+// De regels van het omhulsel, ECHT getoetst. Met RSA_NO_PADDING kun je een blok
+// naar keuze versleutelen (rauw m^e mod n), dus we kunnen precies bepalen wat er
+// na ontsleuteling uit komt -- en dus elke regel apart uitproberen.
+const rauwVersleutel = (blok) =>
+  crypto.publicEncrypt({ key: A.publicKey, padding: crypto.constants.RSA_NO_PADDING }, blok)
+    .toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+
+/**
+ * 00 02 <ps bytes vulling> 00 <boodschap>, met de vulling instelbaar.
+ *
+ * De boodschap staat tegen het EINDE van het blok: bij PKCS#1 vult hij precies
+ * de rest op. Zet je hem er los achter, dan houd je staartnullen over en die
+ * sneuvelen terecht op de tekenset-controle -- daar liep mijn eerste opzet op
+ * vast, en dat is meteen het bewijs dat deze test iets doet.
+ */
+function bouwBlok({ kop = [0x00, 0x02], ps = 200, scheider = true } = {}) {
+  const k = 256;
+  const boodschap = 'a'.repeat(k - 3 - ps);
+  const b = Buffer.alloc(k, 0x00);
+  b[0] = kop[0]; b[1] = kop[1];
+  for (let i = 2; i < 2 + ps; i++) b[i] = 0xAB;      // vulling, nooit nul
+  b[2 + ps] = scheider ? 0x00 : 0xAB;
+  Buffer.from(boodschap).copy(b, 2 + ps + 1);
+  return { blok: b, boodschap };
+}
+
+test('een handgemaakt GELDIG omhulsel levert de boodschap op', () => {
+  const { blok, boodschap } = bouwBlok({ ps: 200 });
+  assert.equal(decryptToken(rauwVersleutel(blok), A.privateKey), boodschap,
+    'dit bewijst dat de uitpakker echt uitpakt');
+});
+
+test('te korte PS wordt geweigerd: RFC 8017 eist er acht', () => {
+  const kort = bouwBlok({ ps: 7 });     // scheider op 9, grens ligt op 10
+  assert.equal(decryptToken(rauwVersleutel(kort.blok), A.privateKey), null);
+  const net = bouwBlok({ ps: 8 });      // scheider op 10, precies goed
+  assert.equal(decryptToken(rauwVersleutel(net.blok), A.privateKey), net.boodschap);
+});
+
+test('een verkeerde kop wordt geweigerd', () => {
+  for (const kop of [[0x00, 0x01], [0x01, 0x02], [0x00, 0x00]])
+    assert.equal(decryptToken(rauwVersleutel(bouwBlok({ kop }).blok), A.privateKey), null, String(kop));
+});
+
+test('zonder scheidende nulbyte komt er niets uit', () => {
+  const { blok } = bouwBlok({ ps: 200, scheider: false });   // nergens een nulbyte
+  assert.equal(decryptToken(rauwVersleutel(blok), A.privateKey), null);
+});
