source: Klonkt/src/routes/openwebauth.js@ 58cad21

main
Last change on this file since 58cad21 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: 10.5 KB
Line 
1/**
2 * OpenWebAuth (FEP-61cf), de drie plekken waar de stroom ons raakt.
3 *
4 * POST /owa/login de bezoeker geeft zijn adres, wij sturen hem naar huis
5 * ALL /owa/token zijn server vraagt ondertekend om een token (stap 2/3)
6 * GET /owa/logout weer anoniem
7 *
8 * Plus de middleware onderaan, die op ELK verzoek naar `?owt=` en `?zid=` kijkt.
9 *
10 * Zie OpenWebAuthService voor de stroom als geheel en voor de drie aanvallen
11 * die de FEP beschrijft.
12 */
13import express from 'express';
14import * as AP from '../services/ActivityPubService.js';
15import * as OWA from '../services/OpenWebAuthService.js';
16import db from '../config/database.js';
17import { renderPage } from '../middleware/render.js';
18import { owaMagicLimiter } from '../middleware/rate-limit.js';
19
20const router = express.Router();
21
22/** Waar de bezoeker weer uitkomt. Altijd binnen onze eigen origin. */
23function returnUrlFor(req, path) {
24 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
25 // Alleen een pad, nooit iets wat de bezoeker meegaf als volledige URL: dat is
26 // hoe een aanmeldformulier een open redirect wordt.
27 let p = String(path || '/');
28 if (!p.startsWith('/') || p.startsWith('//')) p = '/';
29 return base + p;
30}
31
32/**
33 * De handtekening staat hier in `Authorization`, niet in `Signature`.
34 *
35 * De FEP is er stellig over: "An OpenWebAuth signed request must have an
36 * Authorization header starting with the word Signature". De rest van de
37 * fediverse (en dus AP.verifyRequest) leest de `Signature`-header, zoals
38 * Mastodon die stuurt. Twee gewoontes voor hetzelfde ding.
39 *
40 * Zonder deze vertaling zou elke ECHTE client -- Hubzilla, (streams), Forte --
41 * hier een 401 krijgen terwijl hij alles goed deed, en zou pas de eerste
42 * interop-proef dat aan het licht brengen.
43 *
44 * Geen mutatie van req: verifyRequest leest maar vier velden, dus we geven een
45 * kopie mee. Zo blijft wat de rest van de keten ziet ongewijzigd.
46 */
47function metSignatureHeader(req) {
48 const auth = String((req.headers && req.headers.authorization) || '');
49 if (req.headers && req.headers.signature) return req; // al in de gewone vorm
50 if (!/^signature\s+/i.test(auth)) return req; // niets te vertalen
51 return {
52 method: req.method,
53 originalUrl: req.originalUrl,
54 rawBody: req.rawBody,
55 headers: { ...req.headers, signature: auth.replace(/^signature\s+/i, '') },
56 };
57}
58
59/**
60 * Stap 2/3: de home instance vraagt ondertekend om een token.
61 *
62 * GET EN POST, want de FEP zegt dat sommige home instances een POST met een
63 * willekeurig lijf sturen.
64 *
65 * De verificatie is niet nieuw geschreven: AP.verifyRequest() doet dit al voor
66 * de inbox, inclusief het vastpinnen van de sleutel op de herkomst van de actor
67 * (anders host je een document met andermans id naast je eigen sleutel), een
68 * replay-venster op Date, en een verplichte digest zodra er een lijf is. Eén
69 * implementatie van "is deze aanvraag echt van wie hij zegt".
70 */
71router.all('/owa/token', async (req, res) => {
72 let verified = null;
73 try { verified = await AP.verifyRequest(metSignatureHeader(req)); } catch { verified = null; }
74 if (!verified || !verified.id) {
75 return res.status(401).json({ success: false });
76 }
77 const pem = verified.publicKey && verified.publicKey.publicKeyPem;
78 if (!pem) return res.status(400).json({ success: false });
79
80 try {
81 const token = OWA.issueToken(verified.id);
82 // Versleuteld met ZIJN publieke sleutel: alleen de server die de bijbehorende
83 // prive-sleutel heeft kan hem lezen. Daarmee bewijst de teruggave dat de
84 // ondertekenaar ook echt die actor beheert, en niet alleen zijn document kent.
85 return res.json({ success: true, encrypted_token: OWA.encryptTokenFor(token, pem) });
86 } catch (e) {
87 console.warn('[owa] token uitgeven mislukte:', e && e.message);
88 return res.status(500).json({ success: false });
89 }
90});
91
92/**
93 * Stap 1: de bezoeker typt zijn adres en wij sturen hem naar zijn eigen server.
94 *
95 * We slaan hier NIETS op over wie hij zegt te zijn. Dat is opzet: pas het
96 * ingewisselde token bepaalt de identiteit (zie de impersonatie-aanval in de
97 * FEP). Een `zid`/handle is niet meer dan een routeringshint.
98 */
99router.post('/owa/login', async (req, res) => {
100 const terug = returnUrlFor(req, req.body && req.body.next);
101 const found = await OWA.discoverRedirectEndpoint(req.body && req.body.handle);
102 if (!found) {
103 const u = new URL(terug);
104 u.searchParams.set('owa_error', '1');
105 return res.redirect(u.toString());
106 }
107 return res.redirect(OWA.buildRedirect(found.endpoint, terug));
108});
109
110/**
111 * /magic — de HOME-kant: onze gebruiker bewijst zich bij een andere site.
112 *
113 * Hier zijn de rollen omgedraaid. Een doelsite stuurt onze ingelogde gebruiker
114 * hierheen; wij halen daar ondertekend een token op, ontsleutelen het met onze
115 * eigen prive-sleutel en sturen hem terug met ?owt=. Dit is de enige plek waar
116 * die sleutel nodig is -- en meteen de reden dat alleen een echte instance deze
117 * kant kan spelen.
118 *
119 * WELKE IDENTITEIT? Op Klonkt is de fediverse-identiteit de SITE, niet het
120 * account. Wie één site heeft gaat meteen door; wie er meer heeft kiest er een,
121 * want ondertekenen en ontsleutelen kunnen alleen met een sleutel die hij ook
122 * echt beheert.
123 *
124 * EN ER IS EEN TUSSENSCHERM, met opzet. De FEP waarschuwt onder "Information
125 * leakage": OpenWebAuth geeft een STERKE identiteitsclaim af aan elke site die
126 * erom vraagt, desnoods zonder dat je iets merkt. Deze omweg langs je eigen
127 * server is het enige moment waarop je kunt zeggen: deze site niet.
128 */
129function eigenSites(user) {
130 if (!user || !user.id) return [];
131 return db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY is_primary DESC, created_at ASC')
132 .all(user.id);
133}
134
135/** De doelsite waar dit heen gaat, alleen om te TONEN. Beslissen doet bdest. */
136function doelHost(bdest) { try { return new URL(bdest).host; } catch { return ''; } }
137
138router.get('/magic', (req, res) => {
139 const bdest = OWA.fromBdest(req.query && req.query.bdest);
140 if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
141
142 // Niet ingelogd? Dan eerst hier inloggen, en daarna terug naar dit scherm --
143 // met de bdest nog intact, anders is de hele stroom weg.
144 if (!(req.session && req.session.user)) {
145 const terug = '/magic?owa=1&bdest=' + encodeURIComponent(String(req.query.bdest));
146 return res.redirect('/auth/login?next=' + encodeURIComponent(terug));
147 }
148
149 const sites = eigenSites(req.session.user);
150 if (!sites.length) return res.status(403).type('text/plain').send('geen eigen actor om mee te tekenen');
151
152 return renderPage(req, res, 'pages/owa-consent', {
153 pageTitle: 'Aanmelden bij ' + doelHost(bdest.href),
154 bodyClass: 'on-special',
155 owaHost: doelHost(bdest.href),
156 owaBdest: String(req.query.bdest),
157 owaSites: sites,
158 });
159});
160
161/**
162 * De gebruiker zei ja. Nu pas gaan we tekenen.
163 *
164 * De open-redirect-verdediging van deze kant zit in discoverTokenEndpoint(): de
165 * FEP zegt dat we bij een mislukte ontdekking NIET naar bdest mogen doorsturen,
166 * want dan is /magic het doorgeefluik. Vandaar dat elke fout hieronder een
167 * foutpagina geeft en geen redirect.
168 */
169router.post('/magic', owaMagicLimiter, async (req, res) => {
170 const bdest = OWA.fromBdest(req.body && req.body.bdest);
171 if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
172 if (!(req.session && req.session.user)) return res.status(401).type('text/plain').send('niet ingelogd');
173
174 // De gekozen site moet er een van HEM zijn: anders tekent hij met andermans
175 // sleutel, en dat is precies het gat dat je hier niet wilt.
176 const sites = eigenSites(req.session.user);
177 const gekozen = sites.find((s) => s.slug === String(req.body.slug || '')) || sites[0];
178 if (!gekozen) return res.status(403).type('text/plain').send('geen eigen actor');
179
180 const endpoint = await OWA.discoverTokenEndpoint(bdest.href);
181 if (!endpoint) return res.status(502).type('text/plain').send('die site biedt geen OpenWebAuth aan');
182
183 try {
184 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
185 const keys = AP.getOrCreateKeys(gekozen.slug);
186 const keyId = AP.actorId(base, gekozen.slug) + '#main-key';
187 const versleuteld = await OWA.requestToken(endpoint, { keyId, privatePem: keys.private_pem });
188 if (!versleuteld) return res.status(502).type('text/plain').send('geen token gekregen');
189 const token = OWA.decryptToken(versleuteld, keys.private_pem);
190 if (!token) return res.status(502).type('text/plain').send('token onleesbaar');
191 const terug = new URL(bdest.href);
192 terug.searchParams.set('owt', token);
193 return res.redirect(terug.toString());
194 } catch (e) {
195 console.warn('[owa] /magic mislukte:', e && e.message);
196 return res.status(502).type('text/plain').send('aanmelden mislukte');
197 }
198});
199
200/** Weer anoniem. Raakt een eventuele lokale sessie niet aan. */
201router.get('/owa/logout', (req, res) => {
202 if (req.session) delete req.session.owa;
203 res.redirect(returnUrlFor(req, req.query && req.query.next));
204});
205
206/**
207 * Op elk verzoek: is er een token ingewisseld, of wil iemand de stroom starten?
208 *
209 * `owt` BEPAALT de identiteit. `zid` start hooguit de stroom en wordt verder
210 * genegeerd -- dat onderscheid IS de impersonatie-verdediging uit de FEP.
211 */
212export function owaMiddleware(req, res, next) {
213 if (req.method !== 'GET' || !req.query) return next();
214
215 if (req.query.owt) {
216 let actor = null;
217 try { actor = OWA.redeemToken(req.query.owt); } catch { actor = null; }
218 if (actor) req.session.owa = { actor, at: Date.now() };
219 // Het token uit de URL halen: hij is toch al opgebruikt, en zo blijft hij
220 // niet in de geschiedenis, de titelbalk of een gedeelde link staan.
221 const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
222 u.searchParams.delete('owt');
223 u.searchParams.delete('zid');
224 return res.redirect(u.pathname + (u.search || ''));
225 }
226
227 if (req.query.zid && !OWA.guestActor(req)) {
228 const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
229 u.searchParams.delete('zid');
230 const terug = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '') + u.pathname + (u.search || '');
231 // Geen await in een middleware-keten die verder synchroon is: bij een fout
232 // gaat de bezoeker gewoon door naar de pagina, alleen zonder inlog.
233 OWA.discoverRedirectEndpoint(req.query.zid)
234 .then((found) => {
235 if (found) return res.redirect(OWA.buildRedirect(found.endpoint, terug));
236 return res.redirect(terug);
237 })
238 .catch(() => res.redirect(terug));
239 return;
240 }
241
242 return next();
243}
244
245export default router;
Note: See TracBrowser for help on using the repository browser.