source: Klonkt/test/openwebauth-http.test.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: 6.3 KB
RevLine 
[af2cc73]1// OpenWebAuth over HTTP: de vindbaarheid, de poort van het token-endpoint, en
2// de impersonatie-verdediging.
3//
4// Die laatste is de belangrijkste toets in dit bestand. De FEP beschrijft hem
5// zo: Mallory maakt een link naar ONZE site met ?zid=bob@elders, klikt hem zelf,
6// en komt terug met een token dat MALLORY zegt. Wie `zid` gelooft in plaats van
7// het ingewisselde `owt`, laat Mallory als Bob binnen.
8import { test } from 'node:test';
9import assert from 'node:assert/strict';
10import express from 'express';
11
12process.env.DATABASE_PATH = ':memory:';
13process.env.PUBLIC_BASE_URL = 'https://test.example';
14
15const dbMod = await import('../src/config/database.js');
16const db = dbMod.default;
17dbMod.initializeDatabase();
18const OWA = await import('../src/services/OpenWebAuthService.js');
19const apRoutes = (await import('../src/routes/activitypub.js')).default;
20const owaMod = await import('../src/routes/openwebauth.js');
21
22db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
23 .run('u1', 'u1', 'u1@t', 'x', 'god');
24db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, created_at) VALUES (?,?,?,?,1,?)')
25 .run('s1', 'kid', 'kid', 'u1', '2026-01-01 00:00:00');
26
27const ACTOR = 'https://elders.example/users/mee';
28const SLACHTOFFER = 'https://elders.example/users/bob';
29
30// Eén app met een sessie-nep: we willen hier de middleware toetsen, niet
31// express-session.
32const app = express();
33app.use((req, _res, next) => { req.session = app.locals.sessie; next(); });
34app.use(apRoutes);
35app.use(owaMod.default);
36app.use(owaMod.owaMiddleware);
37app.get('/een-bericht', (req, res) => res.json({ owa: (req.session && req.session.owa) || null }));
38
39const server = app.listen(0);
40await new Promise((r) => server.once('listening', r));
41const port = server.address().port;
42test.after(() => server.close());
43
44const haal = (pad, opts) => fetch(`http://127.0.0.1:${port}${pad}`, { redirect: 'manual', ...opts });
45
46// ── vindbaarheid ──────────────────────────────────────────────────────────
47
48test('de wortel wijst een home instance naar ons token-endpoint', async () => {
49 const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://test.example/'));
50 assert.equal(r.status, 200);
51 const jrd = await r.json();
52 const link = jrd.links.find((l) => l.rel === 'http://purl.org/openwebauth/v1');
53 assert.ok(link, 'de rel uit de FEP staat erin');
54 assert.equal(link.href, 'https://test.example/owa/token');
55});
56
57test('en een ACTOR-uri blijft een 400, zoals eerder besloten', async () => {
58 // Dit is geen detail: test/webfinger-bare-host.test.js legt het vast, en de
59 // wortel-uitzondering mocht die keuze niet stiekem omdraaien.
60 const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://test.example/ap/users/kid'));
61 assert.equal(r.status, 400);
62});
63
64test('een wortel van een ANDERE host is niet van ons', async () => {
65 const r = await haal('/.well-known/webfinger?resource=' + encodeURIComponent('https://ergens-anders.example/'));
66 assert.equal(r.status, 400);
67});
68
69// ── het token-endpoint ────────────────────────────────────────────────────
70
71test('zonder handtekening geen token', async () => {
72 // De hele waarde van dit endpoint zit in de handtekening: wie hem niet zet,
73 // bewijst niets en krijgt niets.
74 for (const method of ['GET', 'POST']) {
75 const r = await haal('/owa/token', { method });
76 assert.equal(r.status, 401, method);
77 assert.deepEqual(await r.json(), { success: false });
78 }
79});
80
81test('een verzonnen handtekening ook niet', async () => {
82 const r = await haal('/owa/token', {
83 headers: { signature: 'keyId="https://elders.example/users/mee#main-key",signature="bm9wZQ=="' },
84 });
85 assert.equal(r.status, 401);
86});
87
88// ── impersonatie ──────────────────────────────────────────────────────────
89
90test('?owt= bepaalt wie je bent', async () => {
91 app.locals.sessie = {};
92 const t = OWA.issueToken(ACTOR);
93 const r = await haal('/een-bericht?owt=' + encodeURIComponent(t));
94 assert.equal(r.status, 302, 'het token wordt uit de URL gehaald');
95 assert.equal(r.headers.get('location'), '/een-bericht', 'en laat niets achter in de adresbalk');
96 assert.equal(app.locals.sessie.owa.actor, ACTOR);
97});
98
99test('?zid= bepaalt NIETS -- dit is de impersonatie-aanval uit de FEP', async () => {
100 app.locals.sessie = {};
101 // Mallory wijst met zid naar Bob. Er mag hier geen identiteit uit ontstaan.
102 const r = await haal('/een-bericht?zid=' + encodeURIComponent('bob@elders.example'));
103 assert.ok(r.status === 302 || r.status === 200, 'hooguit een doorverwijzing');
104 assert.equal(app.locals.sessie.owa, undefined, 'zid logt niemand in');
105});
106
107test('en zid naast een geldig owt verandert de uitkomst niet', async () => {
108 app.locals.sessie = {};
109 const t = OWA.issueToken(ACTOR); // dit is Mallory's eigen token
110 const r = await haal(`/een-bericht?owt=${encodeURIComponent(t)}&zid=${encodeURIComponent('bob@elders.example')}`);
111 assert.equal(r.status, 302);
112 assert.equal(app.locals.sessie.owa.actor, ACTOR, 'het token wint, niet de claim');
113 assert.notEqual(app.locals.sessie.owa.actor, SLACHTOFFER);
114 assert.equal(r.headers.get('location'), '/een-bericht', 'en zid gaat ook de URL uit');
115});
116
117test('een opgebruikt token logt niemand in', async () => {
118 app.locals.sessie = {};
119 const t = OWA.issueToken(ACTOR);
120 await haal('/een-bericht?owt=' + encodeURIComponent(t));
121 app.locals.sessie = {};
122 await haal('/een-bericht?owt=' + encodeURIComponent(t));
123 assert.equal(app.locals.sessie.owa, undefined, 'de tweede keer is hij niets waard');
124});
125
126// ── uitloggen ─────────────────────────────────────────────────────────────
127
128test('uitloggen laat een lokale sessie met rust', async () => {
129 app.locals.sessie = { owa: { actor: ACTOR }, user: { id: 'u1' } };
130 await haal('/owa/logout');
131 assert.equal(app.locals.sessie.owa, undefined, 'de gast is weg');
132 assert.deepEqual(app.locals.sessie.user, { id: 'u1' }, 'de lokale gebruiker niet');
133});
Note: See TracBrowser for help on using the repository browser.