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

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

Gastlogin via OpenWebAuth: een fan is een volger, geen accounthouder

fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort vroeg
om een KLONKT-ACCOUNT. Dat is de verkeerde vraag, en hij sloot precies de mensen
buiten voor wie de poort openstond. Nu kan een bezoeker bij zijn EIGEN server
bewijzen dat hij @iemand@ergens is (FEP-61cf), en volgt hij deze site, dan is
hij binnen. Geen account hier, geen wachtwoord hier, geen cookie van een derde.

Wij zijn alleen de TARGET instance. Dat is de prettige helft: de home instance
heeft prive-sleutels nodig, wij alleen publieke. Er staat hier dus geen geheim
van iemand anders. De /magic-kant (Klonkt-gebruikers laten inloggen OP andere
sites) is bewust niet gebouwd -- andere functie.

De handtekening-verificatie is NIET opnieuw geschreven: AP.verifyRequest() doet
dit al voor de inbox, inclusief het vastpinnen van de sleutel op de herkomst van
de actor, een replay-venster en een verplichte digest. Een tweede implementatie
van "is deze aanvraag echt van wie hij zegt" is precies wat je niet wilt.

De drie aanvallen die de FEP noemt, hebben elk een toets:

  • IMPERSONATIE: ?zid= bepaalt niets, alleen het ingewisselde ?owt= telt. Mallory kan een link maken met zid=bob, maar komt terug met een token dat Mallory zegt.
  • OPEN REDIRECT: het ontdekte endpoint moet dezelfde host hebben als het adres dat de bezoeker intypte.
  • DoS: tokens vervallen in minuten, gaan na een keer gebruiken weg, en elke uitgifte veegt de oude op.

Onderweg gemeten en vastgelegd: PKCS#1 v1.5 GOOIT GEEN FOUT bij een verkeerde
sleutel. OpenSSL 3 doet aan implicit rejection en geeft afgeleide onzin terug,
juist zodat niemand aan het foutgedrag kan aflezen of zijn gok klopte. 200
vreemde sleutels: 0 fouten, 0 keer het token. De toets test dus "er komt iets
anders uit", niet "het knalt" -- anders schrijft de volgende lezer weer een
assert.throws die per ongeluk slaagt.

Webfinger op de eigen wortel wijst een home instance naar /owa/token. Alleen
origin + '/'; een ACTOR-uri met een pad blijft een 400, want dat legt
webfinger-bare-host.test.js vast en die keuze draai ik niet om als bijvangst.

De fanpoort toont nu het adresveld als hoofdweg en de lokale inlog als tweede,
en fgate.sub zegt niet langer "ingelogde vrienden" maar wat de poort werkelijk
vraagt.

1135 toetsen groen (was 1106). End-to-end nagelopen op een KOPIE van de
database: token inwisselen zet de sessie en haalt het token uit de URL, een
bewezen volger krijgt de tekst, een bewezen niet-volger krijgt de poort en geen
byte van de inhoud, en anoniem idem.

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

  • Property mode set to 100644
File size: 5.0 KB
RevLine 
[af2cc73]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';
16
17const router = express.Router();
18
19/** Waar de bezoeker weer uitkomt. Altijd binnen onze eigen origin. */
20function returnUrlFor(req, path) {
21 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
22 // Alleen een pad, nooit iets wat de bezoeker meegaf als volledige URL: dat is
23 // hoe een aanmeldformulier een open redirect wordt.
24 let p = String(path || '/');
25 if (!p.startsWith('/') || p.startsWith('//')) p = '/';
26 return base + p;
27}
28
29/**
30 * Stap 2/3: de home instance vraagt ondertekend om een token.
31 *
32 * GET EN POST, want de FEP zegt dat sommige home instances een POST met een
33 * willekeurig lijf sturen.
34 *
35 * De verificatie is niet nieuw geschreven: AP.verifyRequest() doet dit al voor
36 * de inbox, inclusief het vastpinnen van de sleutel op de herkomst van de actor
37 * (anders host je een document met andermans id naast je eigen sleutel), een
38 * replay-venster op Date, en een verplichte digest zodra er een lijf is. Eén
39 * implementatie van "is deze aanvraag echt van wie hij zegt".
40 */
41router.all('/owa/token', async (req, res) => {
42 let verified = null;
43 try { verified = await AP.verifyRequest(req); } catch { verified = null; }
44 if (!verified || !verified.id) {
45 return res.status(401).json({ success: false });
46 }
47 const pem = verified.publicKey && verified.publicKey.publicKeyPem;
48 if (!pem) return res.status(400).json({ success: false });
49
50 try {
51 const token = OWA.issueToken(verified.id);
52 // Versleuteld met ZIJN publieke sleutel: alleen de server die de bijbehorende
53 // prive-sleutel heeft kan hem lezen. Daarmee bewijst de teruggave dat de
54 // ondertekenaar ook echt die actor beheert, en niet alleen zijn document kent.
55 return res.json({ success: true, encrypted_token: OWA.encryptTokenFor(token, pem) });
56 } catch (e) {
57 console.warn('[owa] token uitgeven mislukte:', e && e.message);
58 return res.status(500).json({ success: false });
59 }
60});
61
62/**
63 * Stap 1: de bezoeker typt zijn adres en wij sturen hem naar zijn eigen server.
64 *
65 * We slaan hier NIETS op over wie hij zegt te zijn. Dat is opzet: pas het
66 * ingewisselde token bepaalt de identiteit (zie de impersonatie-aanval in de
67 * FEP). Een `zid`/handle is niet meer dan een routeringshint.
68 */
69router.post('/owa/login', async (req, res) => {
70 const terug = returnUrlFor(req, req.body && req.body.next);
71 const found = await OWA.discoverRedirectEndpoint(req.body && req.body.handle);
72 if (!found) {
73 const u = new URL(terug);
74 u.searchParams.set('owa_error', '1');
75 return res.redirect(u.toString());
76 }
77 return res.redirect(OWA.buildRedirect(found.endpoint, terug));
78});
79
80/** Weer anoniem. Raakt een eventuele lokale sessie niet aan. */
81router.get('/owa/logout', (req, res) => {
82 if (req.session) delete req.session.owa;
83 res.redirect(returnUrlFor(req, req.query && req.query.next));
84});
85
86/**
87 * Op elk verzoek: is er een token ingewisseld, of wil iemand de stroom starten?
88 *
89 * `owt` BEPAALT de identiteit. `zid` start hooguit de stroom en wordt verder
90 * genegeerd -- dat onderscheid IS de impersonatie-verdediging uit de FEP.
91 */
92export function owaMiddleware(req, res, next) {
93 if (req.method !== 'GET' || !req.query) return next();
94
95 if (req.query.owt) {
96 let actor = null;
97 try { actor = OWA.redeemToken(req.query.owt); } catch { actor = null; }
98 if (actor) req.session.owa = { actor, at: Date.now() };
99 // Het token uit de URL halen: hij is toch al opgebruikt, en zo blijft hij
100 // niet in de geschiedenis, de titelbalk of een gedeelde link staan.
101 const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
102 u.searchParams.delete('owt');
103 u.searchParams.delete('zid');
104 return res.redirect(u.pathname + (u.search || ''));
105 }
106
107 if (req.query.zid && !OWA.guestActor(req)) {
108 const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
109 u.searchParams.delete('zid');
110 const terug = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '') + u.pathname + (u.search || '');
111 // Geen await in een middleware-keten die verder synchroon is: bij een fout
112 // gaat de bezoeker gewoon door naar de pagina, alleen zonder inlog.
113 OWA.discoverRedirectEndpoint(req.query.zid)
114 .then((found) => {
115 if (found) return res.redirect(OWA.buildRedirect(found.endpoint, terug));
116 return res.redirect(terug);
117 })
118 .catch(() => res.redirect(terug));
119 return;
120 }
121
122 return next();
123}
124
125export default router;
Note: See TracBrowser for help on using the repository browser.