- Timestamp:
- 08/19/2026 07:44:50 PM (3 weeks ago)
- 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)
- Location:
- src
- Files:
-
- 3 edited
-
middleware/rate-limit.js (modified) (1 diff)
-
routes/openwebauth.js (modified) (2 diffs)
-
services/OpenWebAuthService.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/middleware/rate-limit.js
r5869b1f r58cad21 101 101 }); 102 102 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. 123 export 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 103 132 // Inbox POSTs each trigger an outbound actor fetch (signature verify) → cap the 104 133 // amplification/queue-inflation a single source can drive. 120/min/IP is still -
src/routes/openwebauth.js
r5869b1f r58cad21 16 16 import db from '../config/database.js'; 17 17 import { renderPage } from '../middleware/render.js'; 18 import { owaMagicLimiter } from '../middleware/rate-limit.js'; 18 19 19 20 const router = express.Router(); … … 166 167 * foutpagina geeft en geen redirect. 167 168 */ 168 router.post('/magic', async (req, res) => {169 router.post('/magic', owaMagicLimiter, async (req, res) => { 169 170 const bdest = OWA.fromBdest(req.body && req.body.bdest); 170 171 if (!bdest) return res.status(400).type('text/plain').send('bad bdest'); -
src/services/OpenWebAuthService.js
r5869b1f r58cad21 233 233 } 234 234 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 */ 258 export 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 */ 282 function 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 235 300 /** Het token uitpakken met onze eigen prive-sleutel. */ 236 301 export function decryptToken(encrypted, privatePem) { 237 302 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. 247 321 // 248 322 // ALLEBEI de voorwaarden doen werk, en dat is gemeten met 300 vreemde sleutels: 249 323 // - de TEKENSET vangt vrijwel alles. Van die 300 was er geen enkele die 250 324 // 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. 255 328 // Zestien is daarmee geen rond getal maar een grens die iets doet. Een echte 256 329 // implementatie zit er ruim boven (de onze: 43 tekens).
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)