source: Klonkt/src/services/OpenWebAuthService.js

main
Last change on this file was 58cad21, checked in by Robin <roboburr@…>, 3 weeks ago

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

  • Property mode set to 100644
File size: 16.5 KB
Line 
1/**
2 * OpenWebAuth (FEP-61cf) — de TARGET-kant.
3 *
4 * Waarom dit bestaat: `fan_only` betekent "mijn volgers op de fediverse", maar
5 * de poort vroeg om een KLONKT-ACCOUNT. Dat is de verkeerde vraag: precies de
6 * mensen voor wie de poort openstaat -- volgers elders -- konden er niet door,
7 * en wie er wel door kon had meestal niets met volgen te maken. Hiermee kan een
8 * bezoeker bewijzen dat hij @iemand@ergens is, zonder hier een account, een
9 * wachtwoord of een cookie van een derde partij.
10 *
11 * WIJ ZIJN DE TARGET INSTANCE, nooit de home instance. Dat is de prettige helft:
12 * de home instance heeft de prive-sleutel nodig (om te ondertekenen en om ons
13 * token te ontsleutelen), wij hebben alleen publieke sleutels nodig. Er staat
14 * hier dus geen geheim van iemand anders, en we kunnen ook niemands identiteit
15 * uitgeven. Het spiegelbeeld (Klonkt-gebruikers laten inloggen OP andere sites,
16 * de /magic-kant) is bewust NIET gebouwd: dat is een andere functie.
17 *
18 * De stroom, met de FEP-stappen erbij:
19 * 1. bezoeker geeft zijn adres -> wij webfingeren hem, vinden zijn
20 * redirect-endpoint, sturen hem daarheen
21 * 2. zijn server controleert hem -> en vraagt ONS om een token
22 * 3. wij verifieren die ondertekende -> token terug, versleuteld met ZIJN
23 * aanvraag publieke sleutel
24 * 4. zijn server ontsleutelt -> stuurt hem terug met ?owt=<token>
25 * 5. wij wisselen het token in -> nu weten we wie hij is
26 *
27 * DRIE DINGEN DIE DE FEP ALS AANVAL BESCHRIJFT, en die hieronder staan omdat ze
28 * anders precies de fout worden die je niet ziet:
29 *
30 * - IMPERSONATIE. `?zid=` mag NOOIT iemands identiteit bepalen; alleen het
31 * ingewisselde `?owt=` telt. Mallory kan een link maken met zid=bob@elders,
32 * en komt dan terug met een token dat MALLORY zegt. Wie zid gelooft, laat
33 * Mallory als Bob binnen.
34 * - OPEN REDIRECT. Het redirect-endpoint dat we uit webfinger halen moet
35 * dezelfde host hebben als het adres dat de bezoeker intypte, anders sturen
36 * wij bezoekers naar waar een vreemde maar wil.
37 * - DoS. Tokens vervallen in minuten en gaan na een keer gebruiken weg.
38 */
39import crypto from 'crypto';
40import db from '../config/database.js';
41
42/** Kort, want tussen stap 3 en 5 zit alleen een redirect. De FEP noemt "a couple of minutes". */
43export const TOKEN_TTL_MS = 3 * 60 * 1000;
44
45/** rel-waarden uit de FEP. Letterlijk, want hier hangt de vindbaarheid aan. */
46export const REL_TOKEN = 'http://purl.org/openwebauth/v1';
47export const REL_REDIRECT = 'http://purl.org/openwebauth/v1#redirect';
48
49// ── tokens ────────────────────────────────────────────────────────────────
50
51/** Alles wat over tijd is weg. Draait bij elke uitgifte en elke inwisseling. */
52export function sweepTokens(now = Date.now()) {
53 db.prepare('DELETE FROM owa_tokens WHERE created_at < ?').run(now - TOKEN_TTL_MS);
54}
55
56/**
57 * Stap 3: een token voor deze actor, opgeslagen zodat we hem straks herkennen.
58 * URL-veilig, want hij reist als query-parameter terug.
59 */
60export function issueToken(actorUri, now = Date.now()) {
61 sweepTokens(now);
62 const token = crypto.randomBytes(32).toString('base64url');
63 db.prepare('INSERT INTO owa_tokens (token, actor_uri, created_at) VALUES (?,?,?)')
64 .run(token, String(actorUri), now);
65 return token;
66}
67
68/**
69 * Stap 5: eenmalig inwisselen. Geeft de actor terug, of null.
70 *
71 * Het verwijderen gebeurt ALTIJD, ook als het token te oud bleek: een token dat
72 * eenmaal is aangeboden mag nooit een tweede kans krijgen.
73 */
74export function redeemToken(token, now = Date.now()) {
75 const t = String(token || '');
76 if (!t) return null;
77 const row = db.prepare('SELECT actor_uri, created_at FROM owa_tokens WHERE token = ?').get(t);
78 if (row) db.prepare('DELETE FROM owa_tokens WHERE token = ?').run(t);
79 sweepTokens(now);
80 if (!row) return null;
81 if (now - row.created_at > TOKEN_TTL_MS) return null;
82 return row.actor_uri;
83}
84
85/**
86 * Het token versleuteld met de PUBLIEKE sleutel van de actor, zodat alleen zijn
87 * server het kan lezen. PKCS#1 v1.5 en base64url zonder '=' staan zo in de FEP;
88 * dat is geen smaak maar interop met Hubzilla en (streams).
89 */
90export function encryptTokenFor(token, publicKeyPem) {
91 const buf = crypto.publicEncrypt(
92 { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_PADDING },
93 Buffer.from(String(token), 'utf8'),
94 );
95 return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
96}
97
98// ── ontdekken waar de bezoeker vandaan komt ───────────────────────────────
99
100/** `@iemand@ergens.nl`, `iemand@ergens.nl`, `acct:iemand@ergens.nl` -> {user, host}. */
101export function parseHandle(input) {
102 const m = String(input || '').trim().replace(/^acct:/i, '').replace(/^@/, '')
103 .match(/^([^@\s/]+)@([^@\s/]+)$/);
104 if (!m) return null;
105 const host = m[2].toLowerCase();
106 if (!/^[a-z0-9.-]+(:\d+)?$/i.test(host)) return null;
107 return { user: m[1], host, acct: `${m[1]}@${host}` };
108}
109
110/**
111 * Stap 1: waar stuurt deze bezoeker zich heen om zich te bewijzen?
112 *
113 * De FEP: nieuwe implementaties horen te webfingeren, oude hard-coden /magic.
114 * We doen het eerste en vallen terug op het tweede -- die terugval is veilig
115 * omdat hij per constructie op DEZELFDE host ligt.
116 *
117 * En hier staat de open-redirect-controle: wat webfinger ook teruggeeft, het
118 * moet de host zijn van het adres dat de bezoeker zelf intypte. Zonder die
119 * regel wordt dit formulier een doorgeefluik naar elke gewenste URL.
120 */
121export async function discoverRedirectEndpoint(handle, { fetchImpl = fetch } = {}) {
122 const h = parseHandle(handle);
123 if (!h) return null;
124 const url = `https://${h.host}/.well-known/webfinger?resource=${encodeURIComponent('acct:' + h.acct)}`;
125 let href = null;
126 try {
127 const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
128 if (r.ok) {
129 const jrd = await r.json();
130 const link = (jrd.links || []).find((l) => l && l.rel === REL_REDIRECT && l.href);
131 if (link) href = link.href;
132 }
133 } catch { /* geen webfinger: hieronder de terugval */ }
134 if (!href) href = `https://${h.host}/magic`;
135 try {
136 if (new URL(href).host.toLowerCase() !== h.host) return null; // open redirect
137 } catch { return null; }
138 return { endpoint: href, handle: h };
139}
140
141/** `bdest`: de terugkeer-URL als hex, zo staat het in de FEP. */
142export function toBdest(url) {
143 return Buffer.from(String(url), 'utf8').toString('hex');
144}
145
146/**
147 * De URL waar we de bezoeker heen sturen.
148 *
149 * De terugkeer-URL moet BINNEN onze eigen origin liggen -- en het liefst binnen
150 * de PWA-scope (siteUrlBase), anders komt iemand die de site op zijn
151 * beginscherm heeft na het inloggen terecht in een losse browsertab terwijl de
152 * app uitgelogd blijft. Dat ziet eruit als "inloggen werkt niet" en is het niet.
153 */
154export function buildRedirect(endpoint, returnUrl) {
155 const u = new URL(endpoint);
156 u.searchParams.set('owa', '1');
157 u.searchParams.set('bdest', toBdest(returnUrl));
158 return u.toString();
159}
160
161// ── de HOME-kant: onze gebruiker bewijst zich elders ──────────────────────
162//
163// Hier zijn de rollen omgedraaid. Wij hebben nu de prive-sleutel nodig -- om te
164// ondertekenen en om het token te ontsleutelen -- en dat is precies waarom
165// alleen een echte instance deze kant kan spelen.
166
167/** `bdest` terug naar een URL. Hex in, URL uit; ongeldig = null. */
168export function fromBdest(hex) {
169 const h = String(hex || '');
170 if (!/^[0-9a-f]+$/i.test(h) || h.length % 2) return null;
171 try {
172 const u = new URL(Buffer.from(h, 'hex').toString('utf8'));
173 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
174 return u;
175 } catch { return null; }
176}
177
178/**
179 * Het token-endpoint van de doelsite, gevonden via webfinger op zijn WORTEL.
180 *
181 * En meteen de open-redirect-verdediging van deze kant: het gevonden endpoint
182 * moet dezelfde origin hebben als `bdest`. De FEP zegt het met zoveel woorden --
183 * lukt de ontdekking niet, of wijst hij ergens anders heen, dan sturen we de
184 * browser NIET naar bdest maar geven we een fout. Anders is /magic het
185 * doorgeefluik.
186 */
187export async function discoverTokenEndpoint(bdestUrl, { fetchImpl = fetch } = {}) {
188 let origin;
189 try { origin = new URL(bdestUrl).origin; } catch { return null; }
190 const url = `${origin}/.well-known/webfinger?resource=${encodeURIComponent(origin + '/')}`;
191 try {
192 const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
193 if (!r.ok) return null;
194 const jrd = await r.json();
195 const link = (jrd.links || []).find((l) => l && l.rel === REL_TOKEN && l.href);
196 if (!link) return null;
197 if (new URL(link.href).origin !== origin) return null; // open redirect
198 return link.href;
199 } catch { return null; }
200}
201
202/**
203 * Het token ophalen bij de doelsite, ondertekend namens onze actor.
204 *
205 * De handtekening gaat in `Authorization: Signature ...` -- zo schrijft de FEP
206 * het voor, en niet in de `Signature`-header die de rest van de fediverse
207 * gebruikt. Plus `X-Open-Web-Auth` met willekeur erin: de doelsite doet er
208 * niets mee, het voegt alleen entropie toe aan wat we ondertekenen.
209 */
210export async function requestToken(endpoint, { keyId, privatePem, fetchImpl = fetch } = {}) {
211 const u = new URL(endpoint);
212 const date = new Date().toUTCString();
213 const nonce = crypto.randomBytes(16).toString('hex');
214 const target = `${u.pathname}${u.search || ''}`;
215 const signingString = [
216 `(request-target): get ${target}`,
217 `host: ${u.host}`,
218 `date: ${date}`,
219 `x-open-web-auth: ${nonce}`,
220 ].join('\n');
221 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
222 const headers = {
223 Accept: 'application/json',
224 Date: date,
225 'X-Open-Web-Auth': nonce,
226 Authorization: `Signature keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date x-open-web-auth",signature="${signature}"`,
227 };
228 const r = await fetchImpl(endpoint, { headers });
229 if (!r.ok) return null;
230 const j = await r.json();
231 if (!j || j.success !== true || !j.encrypted_token) return null;
232 return String(j.encrypted_token);
233}
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 */
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
300/** Het token uitpakken met onze eigen prive-sleutel. */
301export function decryptToken(encrypted, privatePem) {
302 const b64 = String(encrypted || '').replace(/-/g, '+').replace(/_/g, '/');
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.
321 //
322 // ALLEBEI de voorwaarden doen werk, en dat is gemeten met 300 vreemde sleutels:
323 // - de TEKENSET vangt vrijwel alles. Van die 300 was er geen enkele die
324 // volledig uit URL-veilige tekens bestond.
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.
328 // Zestien is daarmee geen rond getal maar een grens die iets doet. Een echte
329 // implementatie zit er ruim boven (de onze: 43 tekens).
330 return /^[A-Za-z0-9._~-]{16,512}$/.test(t) ? t : null;
331}
332
333// ── wie is er binnen ──────────────────────────────────────────────────────
334
335/** De actor die deze sessie bewees te zijn, of null. */
336export function guestActor(req) {
337 const g = req && req.session && req.session.owa;
338 return (g && typeof g.actor === 'string' && g.actor) ? g.actor : null;
339}
340
341/** Volgt deze actor deze site? Dat is de vraag die `fan_only` altijd al stelde. */
342export function isFollowerOf(slug, actorUri) {
343 if (!slug || !actorUri) return false;
344 const row = db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? LIMIT 1')
345 .get(String(slug), String(actorUri));
346 return !!row;
347}
348
349/**
350 * Alles wat een poort over deze bezoeker moet weten, op één plek.
351 *
352 * Bewust hier en niet in PostAccessService: die module beslist en raakt de
353 * database niet aan. Deze haalt op, die beslist.
354 */
355export function viewerFor(req, site, extra = {}) {
356 const actor = guestActor(req);
357 return {
358 user: (req && req.session && req.session.user) || null,
359 site: site || null,
360 fediActor: actor,
361 isFollower: actor && site ? isFollowerOf(site.slug, actor) : false,
362 ...extra,
363 };
364}
365
366export default {
367 TOKEN_TTL_MS, REL_TOKEN, REL_REDIRECT,
368 sweepTokens, issueToken, redeemToken, encryptTokenFor,
369 parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect,
370 guestActor, isFollowerOf, viewerFor,
371 fromBdest, discoverTokenEndpoint, requestToken, decryptToken,
372};
Note: See TracBrowser for help on using the repository browser.