Changeset 58cad21 in Klonkt for src


Ignore:
Timestamp:
08/19/2026 07:44:50 PM (3 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
598f090
Parents:
5869b1f
git-author:
Robin <roboburr@…> (08/19/2026 07:30:37 PM)
git-committer:
Robin <roboburr@…> (08/19/2026 07:44:50 PM)
Message:

OpenWebAuth: PKCS#1 v1.5 zelf uitpakken, met een grens op het aantal pogingen

Node weigert privateDecrypt met RSA_PKCS1_PADDING sinds de mitigatie voor
CVE-2023-46809 (Marvin). Daarmee was gastlogin stuk: vier tests rood, en
in bedrijf een geworpen TypeError midden in de inlogstroom.

De revert-vlag was geen uitweg. Die bestaat alleen op de lijnen 18/20/21
-- Node voegt een security-revert alleen toe aan wat ondersteund was toen
de fix kwam -- en 20 is sinds 30 april 2026 EOL. Node 22+ heeft hem nooit
gehad. FEP-61cf schrijft v1.5 voor, dus OAEP repareert de test en breekt
de interop met Hubzilla. Blijft over: het omhulsel zelf afhalen via
RSA_NO_PADDING.

Dat is precies het stuk dat de CVE veroorzaakte, dus met de zorg erbij:

  • Geen vroege uitgang en geen worp. De scan loopt altijd het hele blok af. Dat is geen echte constant-time -- die krijg je in JavaScript met JIT en GC niet -- maar het haalt het waarneembare verschil weg.
  • Implicit rejection: bij een ongeldig omhulsel een afgeleide waarde in plaats van een fout. DETERMINISTISCH, uit sleutel + ciphertext. Vers willekeurig zou slechter zijn: dezelfde ciphertext twee keer aanbieden gaf dan twee antwoorden, en juist dat verschil wilden we verbergen.
  • Een teller van 20/uur op POST /magic, per SITE-SLUG want daar hangt het sleutelpaar aan. Hij telt ALLE pogingen, niet alleen de mislukte: een teller die alleen faalt meetelt is zelf weer een orakel, en dan staat de vertakking die we bij de ontsleuteling weghaalden aan de achterdeur terug. Een orakel heeft honderdduizenden pogingen nodig; twintig per uur is voor een mens onzichtbaar.

Onderweg bleek een aanname in de bestaande code niet te kloppen: het
commentaar bij decryptToken en bij de test leunden op OpenSSL's eigen
implicit rejection. Die kwam pas in 3.2; Node 20 brengt 3.0.19 mee en
daar WERPT hij. Die meting was dus op een andere machine gedaan dan waar
het draait. Nu maken we het zelf, dus de eigenschap staat vast ongeacht
de OpenSSL eronder.

Suite 1160/1160. Van de drie eigenschappen bijten er twee bij een
controleproef (de PS-ondergrens en het determinisme); de nep-uitkomst
niet, want de andere tak geeft dan een lege string die net zo goed
sneuvelt. Dat staat zo in het commentaar in plaats van dat ik doe alsof
alle drie dragend zijn.

Co-Authored-By: Claude Opus 5 <noreply@…>

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/middleware/rate-limit.js

    r5869b1f r58cad21  
    101101});
    102102
     103// ─── OpenWebAuth /magic ───────────────────────────────────────────
     104// Elke poging doet EEN RSA-ontsleuteling met de actorsleutel van een site. Dat
     105// is precies de vorm waar een Bleichenbacher/Marvin-orakel op draait: veel
     106// aangepaste ciphertexts, en uit de antwoorden de sleutel afleiden. De
     107// ontsleuteling zelf is daartegen gehard (implicit rejection in
     108// OpenWebAuthService.decryptToken), maar echte constant-time code bestaat niet
     109// in JavaScript. Een grens op het AANTAL pogingen doet daarom het zware werk:
     110// een orakel heeft er honderdduizenden nodig.
     111//
     112// TELT ALLE POGINGEN, niet alleen de mislukte. Een teller die alleen faalt
     113// meetelt is zelf weer een orakel -- dan leest een aanvaller aan het knijpen af
     114// of zijn padding klopte, en is de vertakking die we bij de ontsleuteling
     115// weghaalden aan de achterdeur terug.
     116//
     117// Per SITE-SLUG, want dat is wat een sleutelpaar heeft (getOrCreateKeys(slug)):
     118// de grens hoort bij de sleutel die beschermd wordt, niet bij het IP van de
     119// eigenaar of bij de doel-host die de aanvaller zelf kiest.
     120//
     121// Twintig per uur is voor een mens onzichtbaar -- je klikt een handvol keer per
     122// dag naar een andere site -- en voor een orakel dodelijk.
     123export const owaMagicLimiter = rateLimit({
     124  windowMs: 60 * 60 * 1000,
     125  max: 20,
     126  standardHeaders: true,
     127  legacyHeaders: false,
     128  keyGenerator: (req) => 'owa:' + String((req.body && req.body.slug) || (req.session && req.session.user && req.session.user.id) || 'onbekend'),
     129  validate: { ip: false },
     130});
     131
    103132// Inbox POSTs each trigger an outbound actor fetch (signature verify) → cap the
    104133// amplification/queue-inflation a single source can drive. 120/min/IP is still
  • src/routes/openwebauth.js

    r5869b1f r58cad21  
    1616import db from '../config/database.js';
    1717import { renderPage } from '../middleware/render.js';
     18import { owaMagicLimiter } from '../middleware/rate-limit.js';
    1819
    1920const router = express.Router();
     
    166167 * foutpagina geeft en geen redirect.
    167168 */
    168 router.post('/magic', async (req, res) => {
     169router.post('/magic', owaMagicLimiter, async (req, res) => {
    169170  const bdest = OWA.fromBdest(req.body && req.body.bdest);
    170171  if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
  • src/services/OpenWebAuthService.js

    r5869b1f r58cad21  
    233233}
    234234
     235/**
     236 * Een deterministische nep-uitkomst, afgeleid uit de ciphertext en onze eigen
     237 * sleutel. Dit is de kern van implicit rejection: bij ongeldige padding geven we
     238 * GEEN fout maar een waarde, zodat "klopte de padding" nergens af te lezen is.
     239 *
     240 * DETERMINISTISCH, en dat is geen detail. Zou dit verse willekeur zijn, dan
     241 * geeft dezelfde ciphertext twee keer aanbieden twee verschillende antwoorden --
     242 * en juist dat verschil is het onderscheid dat we wilden verbergen. Zo doen TLS
     243 * en OpenSSL 3.2 het ook: afgeleid uit sleutel + ciphertext, dus stabiel bij
     244 * herhaling en onvoorspelbaar voor wie de sleutel niet heeft.
     245 *
     246 * Geëxporteerd omdat die eigenschap toetsbaar moet zijn; buiten de tests heeft
     247 * niemand hem nodig.
     248 *
     249 * EERLIJK OVER WAT DIT WEL EN NIET DRAAGT (gemeten 19-8): haal je hem weg, dan
     250 * blijft de suite groen. De andere tak geeft dan een LEGE string terug, en die
     251 * sneuvelt net zo goed op de tekenset-controle hieronder -- "werpt niet" en
     252 * "levert geen token" zijn dus al gedekt zonder deze functie. Wat hij toevoegt
     253 * is dat ALLE faalwegen dezelfde vorm teruggeven: verkeerde sleutel, verkeerde
     254 * lengte, kapotte base64, ongeldige padding. Een lege string is een verklikker
     255 * voor wie ooit naar de rauwe waarde kijkt in plaats van naar het eindoordeel;
     256 * afgeleide bytes zijn dat niet. Zo doen TLS en OpenSSL 3.2 het ook.
     257 */
     258export function _nepUitkomst(privatePem, ct) {
     259  const geheim = crypto.createHash('sha256').update(String(privatePem)).digest();
     260  return crypto.createHmac('sha256', geheim).update(ct).digest().toString('latin1');
     261}
     262
     263/**
     264 * PKCS#1 v1.5 zelf uitpakken (EME-PKCS1-v1_5: 00 02 PS 00 M).
     265 *
     266 * WAAROM ZELF: Node weigert `privateDecrypt` met RSA_PKCS1_PADDING sinds de
     267 * mitigatie voor CVE-2023-46809 (Marvin). De revert-vlag bestaat alleen op de
     268 * lijnen 18/20/21 -- Node 22+ heeft hem nooit gehad, en 20 is sinds 30 april
     269 * 2026 EOL. Er is dus geen weg terug; zie shaer-r15.
     270 *
     271 * OpenWebAuth (FEP-61cf) schrijft v1.5 voor, dus overstappen op OAEP repareert
     272 * de fout en breekt de interop met Hubzilla. Blijft over: `RSA_NO_PADDING` en
     273 * het omhulsel er zelf afhalen -- precies het stuk dat de CVE veroorzaakte, dus
     274 * met de zorg die daarbij hoort.
     275 *
     276 * GEEN VROEGE UITGANG EN GEEN WORP. De scan loopt altijd het hele blok af en
     277 * beide takken doen hetzelfde werk. Dat is geen echte constant-time -- die
     278 * krijg je in JavaScript met JIT en GC niet -- maar het haalt wel het
     279 * waarneembare verschil weg. Wat de aanval hier echt begrenst is de teller op
     280 * /magic: een orakel heeft honderdduizenden pogingen nodig.
     281 */
     282function pakUit(blok, privatePem, ct) {
     283  const k = blok.length;
     284  // Kop: 00 02. Als getal uitrekenen, niet als vertakking.
     285  let goed = ((blok[0] === 0x00) & (blok[1] === 0x02));
     286  // Eerste nulbyte vanaf 2 zoeken ZONDER de lus te verlaten.
     287  let sep = -1;
     288  for (let i = 2; i < k; i++) {
     289    const isNul = blok[i] === 0x00 ? 1 : 0;
     290    const nogNiet = sep === -1 ? 1 : 0;
     291    sep = sep + (isNul & nogNiet) * (i - sep);
     292  }
     293  // PS moet minstens 8 bytes zijn (RFC 8017), dus de scheider ligt op >= 10.
     294  goed = goed & (sep >= 10 ? 1 : 0) & (sep < k ? 1 : 0);
     295  const echt = blok.subarray(goed ? sep + 1 : k).toString('utf8');
     296  const nep = _nepUitkomst(privatePem, ct);
     297  return goed ? echt : nep;
     298}
     299
    235300/** Het token uitpakken met onze eigen prive-sleutel. */
    236301export function decryptToken(encrypted, privatePem) {
    237302  const b64 = String(encrypted || '').replace(/-/g, '+').replace(/_/g, '/');
    238   const buf = crypto.privateDecrypt(
    239     { key: privatePem, padding: crypto.constants.RSA_PKCS1_PADDING },
    240     Buffer.from(b64, 'base64'),
    241   );
    242   const t = buf.toString('utf8');
    243   // Een token is URL-veilige tekst. Bij een verkeerde sleutel geeft PKCS#1 v1.5
    244   // geen fout maar afgeleide onzin terug (implicit rejection, zie de toelichting
    245   // bij encryptTokenFor), en die onzin hoort hier te stranden in plaats van als
    246   // token de wereld in te gaan.
     303  const ct = Buffer.from(b64, 'base64');
     304  let k = 0;
     305  try { k = crypto.createPublicKey(privatePem).asymmetricKeyDetails.modulusLength / 8; } catch { k = 0; }
     306
     307  // Een blok van de verkeerde lengte zegt niets over de sleutel, maar het zou
     308  // wel werpen -- en een worp is precies het signaal dat we kwijt willen. Dus
     309  // dezelfde weg als een ongeldige padding.
     310  let blok = null;
     311  if (k && ct.length === k) {
     312    try {
     313      blok = crypto.privateDecrypt({ key: privatePem, padding: crypto.constants.RSA_NO_PADDING }, ct);
     314    } catch { blok = null; }
     315  }
     316  const t = (blok && blok.length === k) ? pakUit(blok, privatePem, ct) : _nepUitkomst(privatePem, ct);
     317
     318  // Een token is URL-veilige tekst. Wat hierboven uit een mislukking komt is
     319  // afgeleide onzin, en die hoort hier te stranden in plaats van als token de
     320  // wereld in te gaan.
    247321  //
    248322  // ALLEBEI de voorwaarden doen werk, en dat is gemeten met 300 vreemde sleutels:
    249323  //  - de TEKENSET vangt vrijwel alles. Van die 300 was er geen enkele die
    250324  //    volledig uit URL-veilige tekens bestond.
    251   //  - de ONDERGRENS vangt de rest. De onzin heeft een willekeurige lengte (5
    252   //    tot 209 bytes gezien), en 18 van de 300 was korter dan 16 bytes. Bij zo'n
    253   //    kort stukje is "toevallig allemaal URL-veilig" niet meer verwaarloosbaar:
    254   //    per byte is die kans ruwweg een kwart.
     325  //  - de ONDERGRENS vangt de rest. De onzin heeft een willekeurige lengte, en
     326  //    bij een kort stukje is "toevallig allemaal URL-veilig" niet meer
     327  //    verwaarloosbaar: per byte is die kans ruwweg een kwart.
    255328  // Zestien is daarmee geen rond getal maar een grens die iets doet. Een echte
    256329  // implementatie zit er ruim boven (de onze: 43 tekens).
Note: See TracChangeset for help on using the changeset viewer.