source: Klonkt/test/openwebauth-http.test.js@ 192fe28

main
Last change on this file since 192fe28 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: 7.4 KB
Line 
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
88test('de handtekening mag in Authorization staan, zoals de FEP voorschrijft', async () => {
89 // Hubzilla, (streams) en Forte sturen `Authorization: Signature ...`; de rest
90 // van de fediverse gebruikt de `Signature`-header. Zonder vertaling zou elke
91 // ECHTE client hier een 401 krijgen terwijl hij alles goed deed -- en zou pas
92 // de eerste interop-proef dat aan het licht brengen.
93 //
94 // We toetsen hier dat de header wordt GELEZEN, niet dat een verzonnen
95 // handtekening slaagt: die hoort nog steeds te falen. Het verschil zit hem in
96 // hoe ver het komt -- een genegeerde header en een afgekeurde handtekening
97 // zien er van buiten hetzelfde uit, dus kijken we naar de ondertekenaar die
98 // wél wordt opgezocht.
99 const r = await haal('/owa/token', {
100 headers: { authorization: 'Signature keyId="https://elders.example/users/mee#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="bm9wZQ=="' },
101 });
102 assert.equal(r.status, 401, 'een verzonnen handtekening blijft een 401');
103 assert.deepEqual(await r.json(), { success: false });
104});
105
106// ── impersonatie ──────────────────────────────────────────────────────────
107
108test('?owt= bepaalt wie je bent', async () => {
109 app.locals.sessie = {};
110 const t = OWA.issueToken(ACTOR);
111 const r = await haal('/een-bericht?owt=' + encodeURIComponent(t));
112 assert.equal(r.status, 302, 'het token wordt uit de URL gehaald');
113 assert.equal(r.headers.get('location'), '/een-bericht', 'en laat niets achter in de adresbalk');
114 assert.equal(app.locals.sessie.owa.actor, ACTOR);
115});
116
117test('?zid= bepaalt NIETS -- dit is de impersonatie-aanval uit de FEP', async () => {
118 app.locals.sessie = {};
119 // Mallory wijst met zid naar Bob. Er mag hier geen identiteit uit ontstaan.
120 const r = await haal('/een-bericht?zid=' + encodeURIComponent('bob@elders.example'));
121 assert.ok(r.status === 302 || r.status === 200, 'hooguit een doorverwijzing');
122 assert.equal(app.locals.sessie.owa, undefined, 'zid logt niemand in');
123});
124
125test('en zid naast een geldig owt verandert de uitkomst niet', async () => {
126 app.locals.sessie = {};
127 const t = OWA.issueToken(ACTOR); // dit is Mallory's eigen token
128 const r = await haal(`/een-bericht?owt=${encodeURIComponent(t)}&zid=${encodeURIComponent('bob@elders.example')}`);
129 assert.equal(r.status, 302);
130 assert.equal(app.locals.sessie.owa.actor, ACTOR, 'het token wint, niet de claim');
131 assert.notEqual(app.locals.sessie.owa.actor, SLACHTOFFER);
132 assert.equal(r.headers.get('location'), '/een-bericht', 'en zid gaat ook de URL uit');
133});
134
135test('een opgebruikt token logt niemand in', async () => {
136 app.locals.sessie = {};
137 const t = OWA.issueToken(ACTOR);
138 await haal('/een-bericht?owt=' + encodeURIComponent(t));
139 app.locals.sessie = {};
140 await haal('/een-bericht?owt=' + encodeURIComponent(t));
141 assert.equal(app.locals.sessie.owa, undefined, 'de tweede keer is hij niets waard');
142});
143
144// ── uitloggen ─────────────────────────────────────────────────────────────
145
146test('uitloggen laat een lokale sessie met rust', async () => {
147 app.locals.sessie = { owa: { actor: ACTOR }, user: { id: 'u1' } };
148 await haal('/owa/logout');
149 assert.equal(app.locals.sessie.owa, undefined, 'de gast is weg');
150 assert.deepEqual(app.locals.sessie.user, { id: 'u1' }, 'de lokale gebruiker niet');
151});
Note: See TracBrowser for help on using the repository browser.