source: Klonkt/src/routes/openwebauth.js@ d85b66f

main
Last change on this file since d85b66f was d85b66f, checked in by Bart <bart@…>, 3 weeks ago

De home-kant erbij: twee Klonkts kunnen elkaar nu aanmelden

Er was alleen de ontvangende helft: een bezoeker van Hubzilla kwam wel door onze
fanpoort, maar onze eigen gebruikers konden zich nergens bewijzen. Nu allebei.

/magic doet het spiegelbeeld: onze ingelogde gebruiker komt binnen met een
bdest, wij halen ondertekend een token bij die site, ontsleutelen het met onze
eigen prive-sleutel en sturen hem terug met ?owt=. Dit is de enige plek waar die
sleutel nodig is -- en meteen waarom alleen een echte instance deze kant speelt.

WELKE IDENTITEIT: op Klonkt is de fediverse-identiteit de SITE, niet het account.
Eén site gaat meteen door, meer sites laat kiezen. Ondertekenen en ontsleutelen
kan alleen met een sleutel die de gebruiker ook echt beheert, dus de gekozen
site wordt getoetst tegen zijn eigen sites.

EN ER IS EEN TUSSENSCHERM. De FEP waarschuwt onder "Information leakage" dat
OpenWebAuth een sterke identiteitsclaim afgeeft aan elke site die erom vraagt,
desnoods ongemerkt. De omweg langs je eigen server is het enige moment waarop je
kunt zeggen: deze site niet. Vandaar dat de doelhost er groot staat.

EEN FOUT DIE IK ONDERWEG IN MIJN EIGEN WERK VOND: de FEP schrijft voor dat de
handtekening in Authorization: Signature ... gaat, terwijl de rest van de
fediverse (en dus AP.verifyRequest) de Signature-header leest. Ons
token-endpoint las alleen die laatste, dus elke ECHTE client -- Hubzilla,
(streams), Forte -- had een 401 gekregen terwijl hij alles goed deed. Dat was
pas bij de eerste interop-proef opgevallen. Nu leest het endpoint allebei.

Open redirect, nu ook van deze kant: lukt het ontdekken van het token-endpoint
niet, of wijst het naar een andere origin dan bdest, dan volgt een fout en GEEN
doorverwijzing. Anders is /magic het doorgeefluik.

Gemeten en vastgelegd bij decryptToken: de ondergrens van 16 tekens is geen rond
getal. Implicit rejection geeft onzin van willekeurige lengte (5 tot 209 bytes
gezien, 18 van de 300 korter dan 16), en bij zo'n kort stukje is "toevallig
allemaal URL-veilig" niet verwaarloosbaar. De tekenset ving 300 van de 300, de
ondergrens dekt de staart.

1145 toetsen groen (was 1135).

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

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