source: Klonkt/test/openwebauth.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: 8.9 KB
Line 
1// OpenWebAuth (FEP-61cf), de target-kant.
2//
3// Dit is een AUTHENTICATIEPAD, dus de toetsen hier staan scherper dan elders:
4// niet alleen "werkt de gelukkige route", maar met name de drie aanvallen die de
5// FEP zelf beschrijft -- impersonatie via ?zid=, open redirect via het
6// ontdekte endpoint, en tokens die blijven liggen.
7import { test } from 'node:test';
8import assert from 'node:assert/strict';
9import crypto from 'crypto';
10
11process.env.DATABASE_PATH = ':memory:';
12process.env.PUBLIC_BASE_URL = 'https://test.example';
13
14const dbMod = await import('../src/config/database.js');
15const db = dbMod.default;
16dbMod.initializeDatabase();
17const OWA = await import('../src/services/OpenWebAuthService.js');
18
19const ACTOR = 'https://elders.example/users/mee';
20const ANDER = 'https://elders.example/users/iemand';
21
22// ── tokens ────────────────────────────────────────────────────────────────
23
24test('een token wijst naar de actor die hem kreeg', () => {
25 const t = OWA.issueToken(ACTOR);
26 assert.equal(OWA.redeemToken(t), ACTOR);
27});
28
29test('een token werkt precies ÉÉN keer', () => {
30 // Anders is een token dat ergens in een log of een history-item blijft hangen
31 // een sleutel die blijft werken.
32 const t = OWA.issueToken(ACTOR);
33 assert.equal(OWA.redeemToken(t), ACTOR);
34 assert.equal(OWA.redeemToken(t), null, 'de tweede keer is niets meer waard');
35});
36
37test('een verzonnen token levert niemand op', () => {
38 assert.equal(OWA.redeemToken('zomaar-wat'), null);
39 assert.equal(OWA.redeemToken(''), null);
40 assert.equal(OWA.redeemToken(null), null);
41});
42
43test('een token verloopt, en verlopen aanbieden verbrandt hem ook', () => {
44 const t = OWA.issueToken(ACTOR, Date.now() - (OWA.TOKEN_TTL_MS + 1000));
45 assert.equal(OWA.redeemToken(t), null, 'te oud');
46 // En hij is weg: opnieuw aanbieden binnen de tijd kan niet alsnog lukken.
47 const rij = db.prepare('SELECT 1 FROM owa_tokens WHERE token = ?').get(t);
48 assert.equal(rij, undefined, 'een aangeboden token blijft niet liggen');
49});
50
51test('oude tokens worden opgeruimd (de DoS uit de FEP)', () => {
52 db.prepare('DELETE FROM owa_tokens').run();
53 const oud = Date.now() - (OWA.TOKEN_TTL_MS + 60_000);
54 for (let i = 0; i < 5; i++) {
55 db.prepare('INSERT INTO owa_tokens (token, actor_uri, created_at) VALUES (?,?,?)')
56 .run('oud-' + i, ACTOR, oud);
57 }
58 OWA.issueToken(ACTOR); // elke uitgifte veegt
59 const over = db.prepare('SELECT COUNT(*) AS n FROM owa_tokens').get().n;
60 assert.equal(over, 1, 'alleen de verse blijft staan');
61});
62
63// ── versleuteling ─────────────────────────────────────────────────────────
64
65test('het token is alleen leesbaar voor wie de privésleutel heeft', () => {
66 // De hele reden dat deze stap bestaat: de ondertekenaar bewijst dat hij de
67 // actor BEHEERT, niet alleen dat hij zijn document kent.
68 const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
69 const pem = publicKey.export({ type: 'spki', format: 'pem' });
70 const token = OWA.issueToken(ACTOR);
71 const versleuteld = OWA.encryptTokenFor(token, pem);
72
73 assert.ok(!versleuteld.includes('='), 'base64url zonder padding, zoals de FEP zegt');
74 assert.ok(!/[+/]/.test(versleuteld), 'en URL-veilig');
75
76 const terug = crypto.privateDecrypt(
77 { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
78 Buffer.from(versleuteld.replace(/-/g, '+').replace(/_/g, '/'), 'base64'),
79 ).toString('utf8');
80 assert.equal(terug, token);
81
82 // Een ANDERE sleutel komt er niet bij -- maar LET OP HOE dat eruitziet, want
83 // de voor de hand liggende toets (assert.throws) is hier fout.
84 //
85 // PKCS#1 v1.5 gooit bij een verkeerde sleutel geen fout: OpenSSL 3 doet aan
86 // "implicit rejection" en geeft afgeleide onzin terug in plaats van te falen,
87 // juist zodat een aanvaller niet aan het foutgedrag kan aflezen of zijn gok
88 // klopte (Bleichenbacher/Marvin). Gemeten: 200 vreemde sleutels, 0 fouten,
89 // 200 keer bytes -- en 0 keer het token.
90 //
91 // De eigenschap die telt is dus niet "het knalt" maar "er komt iets anders
92 // uit". Wat de home instance daarna terugstuurt matcht geen enkel opgeslagen
93 // token, en de inlog mislukt gewoon.
94 const ruw = Buffer.from(versleuteld.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
95 for (let i = 0; i < 5; i++) {
96 const vreemde = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
97 let uit = null;
98 try {
99 uit = crypto.privateDecrypt({ key: vreemde, padding: crypto.constants.RSA_PKCS1_PADDING }, ruw).toString('utf8');
100 } catch { uit = null; } // een fout mag, maar is niet de regel
101 assert.notEqual(uit, token, 'andermans sleutel levert nooit het token');
102 }
103});
104
105// ── adressen lezen ────────────────────────────────────────────────────────
106
107test('een adres mag op vier manieren geschreven worden', () => {
108 for (const vorm of ['mee@elders.example', '@mee@elders.example', 'acct:mee@elders.example', ' @mee@elders.example ']) {
109 const h = OWA.parseHandle(vorm);
110 assert.ok(h, vorm);
111 assert.equal(h.acct, 'mee@elders.example');
112 }
113});
114
115test('en onzin is geen adres', () => {
116 for (const vorm of ['', null, 'geen-apenstaartje', 'twee@@apen', 'met/schuine@streep', 'a@b@c']) {
117 assert.equal(OWA.parseHandle(vorm), null, JSON.stringify(vorm));
118 }
119});
120
121// ── open redirect ─────────────────────────────────────────────────────────
122
123test('een endpoint op een ANDERE host wordt geweigerd', async () => {
124 // De aanval: een server antwoordt op webfinger met een redirect-endpoint dat
125 // ergens anders wijst, en ons inlogformulier wordt een doorgeefluik.
126 const nep = async () => ({
127 ok: true,
128 json: async () => ({ links: [{ rel: OWA.REL_REDIRECT, href: 'https://kwaadaardig.example/magic' }] }),
129 });
130 const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
131 assert.equal(r, null, 'andere host dan het ingetypte adres → niet doen');
132});
133
134test('een endpoint op de eigen host is prima', async () => {
135 const nep = async () => ({
136 ok: true,
137 json: async () => ({ links: [{ rel: OWA.REL_REDIRECT, href: 'https://elders.example/owa/hier' }] }),
138 });
139 const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
140 assert.equal(r.endpoint, 'https://elders.example/owa/hier');
141});
142
143test('zonder webfinger-link vallen we terug op /magic, op dezelfde host', async () => {
144 const nep = async () => ({ ok: false, json: async () => ({}) });
145 const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
146 assert.equal(r.endpoint, 'https://elders.example/magic', 'Hubzilla en (streams) doen het zo');
147});
148
149test('de redirect draagt owa=1 en een hex-bdest', () => {
150 const terug = 'https://test.example/een-bericht';
151 const url = new URL(OWA.buildRedirect('https://elders.example/magic', terug));
152 assert.equal(url.searchParams.get('owa'), '1');
153 const bdest = url.searchParams.get('bdest');
154 assert.match(bdest, /^[0-9a-f]+$/i, 'hexadecimaal, zoals de FEP zegt');
155 assert.equal(Buffer.from(bdest, 'hex').toString('utf8'), terug);
156});
157
158// ── wie is er binnen ──────────────────────────────────────────────────────
159
160test('volgerschap is de vraag die fan_only stelt', () => {
161 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
162 .run('u-owa', 'u-owa', 'u@t', 'x', 'god');
163 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
164 .run('s-owa', 'kid', 'kid', 'u-owa');
165 db.prepare('INSERT INTO ap_followers (slug, actor_uri) VALUES (?,?)').run('kid', ACTOR);
166
167 assert.equal(OWA.isFollowerOf('kid', ACTOR), true);
168 assert.equal(OWA.isFollowerOf('kid', ANDER), false, 'een vreemde volgt niet');
169 assert.equal(OWA.isFollowerOf('kid', null), false, 'en niemand al helemaal niet');
170 assert.equal(OWA.isFollowerOf('andere-site', ACTOR), false, 'volgen doe je een SITE, niet de server');
171});
172
173test('een sessie zonder bewijs levert geen actor', () => {
174 assert.equal(OWA.guestActor(null), null);
175 assert.equal(OWA.guestActor({ session: {} }), null);
176 assert.equal(OWA.guestActor({ session: { owa: {} } }), null);
177 assert.equal(OWA.guestActor({ session: { owa: { actor: ACTOR } } }), ACTOR);
178});
179
180test('viewerFor zet het bewijs om in een besluitbare vorm', () => {
181 const req = { session: { owa: { actor: ACTOR } } };
182 const v = OWA.viewerFor(req, { slug: 'kid' });
183 assert.equal(v.fediActor, ACTOR);
184 assert.equal(v.isFollower, true);
185 assert.equal(v.user, null, 'een gast is geen lokale gebruiker');
186});
Note: See TracBrowser for help on using the repository browser.