source: Klonkt/test/openwebauth.test.js

main
Last change on this file was 58cad21, checked in by Robin <roboburr@…>, 3 weeks ago

OpenWebAuth: PKCS#1 v1.5 zelf uitpakken, met een grens op het aantal pogingen

Node weigert privateDecrypt met RSA_PKCS1_PADDING sinds de mitigatie voor
CVE-2023-46809 (Marvin). Daarmee was gastlogin stuk: vier tests rood, en
in bedrijf een geworpen TypeError midden in de inlogstroom.

De revert-vlag was geen uitweg. Die bestaat alleen op de lijnen 18/20/21
-- Node voegt een security-revert alleen toe aan wat ondersteund was toen
de fix kwam -- en 20 is sinds 30 april 2026 EOL. Node 22+ heeft hem nooit
gehad. FEP-61cf schrijft v1.5 voor, dus OAEP repareert de test en breekt
de interop met Hubzilla. Blijft over: het omhulsel zelf afhalen via
RSA_NO_PADDING.

Dat is precies het stuk dat de CVE veroorzaakte, dus met de zorg erbij:

  • Geen vroege uitgang en geen worp. De scan loopt altijd het hele blok af. Dat is geen echte constant-time -- die krijg je in JavaScript met JIT en GC niet -- maar het haalt het waarneembare verschil weg.
  • Implicit rejection: bij een ongeldig omhulsel een afgeleide waarde in plaats van een fout. DETERMINISTISCH, uit sleutel + ciphertext. Vers willekeurig zou slechter zijn: dezelfde ciphertext twee keer aanbieden gaf dan twee antwoorden, en juist dat verschil wilden we verbergen.
  • Een teller van 20/uur op POST /magic, per SITE-SLUG want daar hangt het sleutelpaar aan. Hij telt ALLE pogingen, niet alleen de mislukte: een teller die alleen faalt meetelt is zelf weer een orakel, en dan staat de vertakking die we bij de ontsleuteling weghaalden aan de achterdeur terug. Een orakel heeft honderdduizenden pogingen nodig; twintig per uur is voor een mens onzichtbaar.

Onderweg bleek een aanname in de bestaande code niet te kloppen: het
commentaar bij decryptToken en bij de test leunden op OpenSSL's eigen
implicit rejection. Die kwam pas in 3.2; Node 20 brengt 3.0.19 mee en
daar WERPT hij. Die meting was dus op een andere machine gedaan dan waar
het draait. Nu maken we het zelf, dus de eigenschap staat vast ongeacht
de OpenSSL eronder.

Suite 1160/1160. Van de drie eigenschappen bijten er twee bij een
controleproef (de PS-ondergrens en het determinisme); de nep-uitkomst
niet, want de andere tak geeft dan een lege string die net zo goed
sneuvelt. Dat staat zo in het commentaar in plaats van dat ik doe alsof
alle drie dragend zijn.

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

  • Property mode set to 100644
File size: 10.3 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 // Door ONZE eigen uitpakker, niet rechtstreeks door crypto.privateDecrypt:
77 // die weigert PKCS#1 v1.5 sinds de mitigatie voor CVE-2023-46809, en de
78 // revert-vlag bestaat alleen op Node 18/20/21 -- allemaal EOL. decryptToken
79 // haalt het omhulsel zelf af en werkt dus op een Node die nog leeft.
80 const terug = OWA.decryptToken(versleuteld, privateKey.export({ type: 'pkcs8', format: 'pem' }));
81 assert.equal(terug, token);
82
83 // Een ANDERE sleutel komt er niet bij -- maar LET OP HOE dat eruitziet, want
84 // de voor de hand liggende toets (assert.throws) is hier fout.
85 //
86 // Bij een verkeerde sleutel hoort er geen FOUT te komen maar afgeleide onzin:
87 // implicit rejection. Anders leest een aanvaller aan het foutgedrag af of zijn
88 // gok klopte (Bleichenbacher/Marvin).
89 //
90 // LET OP waar dat vandaan komt. OpenSSL doet dit pas zelf vanaf 3.2; op 3.0.19
91 // -- wat Node 20 meebrengt en wat wij draaien -- WERPT hij gewoon. Gemeten op
92 // 19-8-2026. Een eerdere versie van deze test ging uit van OpenSSL's gedrag en
93 // was daarmee afhankelijk van welke Node de meting toevallig deed. Nu maakt
94 // decryptToken de implicit rejection ZELF, dus de eigenschap staat vast
95 // ongeacht de OpenSSL eronder.
96 //
97 // De eigenschap die telt is dus niet "het knalt" maar "er komt iets anders
98 // uit". Wat de home instance daarna terugstuurt matcht geen enkel opgeslagen
99 // token, en de inlog mislukt gewoon.
100 for (let i = 0; i < 5; i++) {
101 const vreemde = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
102 .privateKey.export({ type: 'pkcs8', format: 'pem' });
103 let uit = null;
104 assert.doesNotThrow(() => { uit = OWA.decryptToken(versleuteld, vreemde); },
105 'werpen is het signaal waar Bleichenbacher op draait');
106 assert.notEqual(uit, token, 'andermans sleutel levert nooit het token');
107 }
108});
109
110// ── adressen lezen ────────────────────────────────────────────────────────
111
112test('een adres mag op vier manieren geschreven worden', () => {
113 for (const vorm of ['mee@elders.example', '@mee@elders.example', 'acct:mee@elders.example', ' @mee@elders.example ']) {
114 const h = OWA.parseHandle(vorm);
115 assert.ok(h, vorm);
116 assert.equal(h.acct, 'mee@elders.example');
117 }
118});
119
120test('en onzin is geen adres', () => {
121 for (const vorm of ['', null, 'geen-apenstaartje', 'twee@@apen', 'met/schuine@streep', 'a@b@c']) {
122 assert.equal(OWA.parseHandle(vorm), null, JSON.stringify(vorm));
123 }
124});
125
126// ── open redirect ─────────────────────────────────────────────────────────
127
128test('een endpoint op een ANDERE host wordt geweigerd', async () => {
129 // De aanval: een server antwoordt op webfinger met een redirect-endpoint dat
130 // ergens anders wijst, en ons inlogformulier wordt een doorgeefluik.
131 const nep = async () => ({
132 ok: true,
133 json: async () => ({ links: [{ rel: OWA.REL_REDIRECT, href: 'https://kwaadaardig.example/magic' }] }),
134 });
135 const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
136 assert.equal(r, null, 'andere host dan het ingetypte adres → niet doen');
137});
138
139test('een endpoint op de eigen host is prima', async () => {
140 const nep = async () => ({
141 ok: true,
142 json: async () => ({ links: [{ rel: OWA.REL_REDIRECT, href: 'https://elders.example/owa/hier' }] }),
143 });
144 const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
145 assert.equal(r.endpoint, 'https://elders.example/owa/hier');
146});
147
148test('zonder webfinger-link vallen we terug op /magic, op dezelfde host', async () => {
149 const nep = async () => ({ ok: false, json: async () => ({}) });
150 const r = await OWA.discoverRedirectEndpoint('mee@elders.example', { fetchImpl: nep });
151 assert.equal(r.endpoint, 'https://elders.example/magic', 'Hubzilla en (streams) doen het zo');
152});
153
154test('de redirect draagt owa=1 en een hex-bdest', () => {
155 const terug = 'https://test.example/een-bericht';
156 const url = new URL(OWA.buildRedirect('https://elders.example/magic', terug));
157 assert.equal(url.searchParams.get('owa'), '1');
158 const bdest = url.searchParams.get('bdest');
159 assert.match(bdest, /^[0-9a-f]+$/i, 'hexadecimaal, zoals de FEP zegt');
160 assert.equal(Buffer.from(bdest, 'hex').toString('utf8'), terug);
161});
162
163// ── wie is er binnen ──────────────────────────────────────────────────────
164
165test('volgerschap is de vraag die fan_only stelt', () => {
166 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
167 .run('u-owa', 'u-owa', 'u@t', 'x', 'god');
168 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
169 .run('s-owa', 'kid', 'kid', 'u-owa');
170 db.prepare('INSERT INTO ap_followers (slug, actor_uri) VALUES (?,?)').run('kid', ACTOR);
171
172 assert.equal(OWA.isFollowerOf('kid', ACTOR), true);
173 assert.equal(OWA.isFollowerOf('kid', ANDER), false, 'een vreemde volgt niet');
174 assert.equal(OWA.isFollowerOf('kid', null), false, 'en niemand al helemaal niet');
175 assert.equal(OWA.isFollowerOf('andere-site', ACTOR), false, 'volgen doe je een SITE, niet de server');
176});
177
178test('een WACHTEND volgverzoek opent de fanpoort niet', () => {
179 // Waar twee functies elkaar raken, en geen van beide dat wist.
180 //
181 // Met approve_followers aan (Robins eigenaarspoort) wordt een Follow niet meer
182 // automatisch geaccepteerd: hij wacht in ap_pending_follows tot de eigenaar op
183 // /connect ja zegt, en pas dan komt hij in ap_followers. Onze fanpoort leest
184 // ap_followers, dus een verzoek dat nog wacht hoort NIETS te openen.
185 //
186 // Dat klopt vandaag vanzelf, en juist daarom staat het hier vast: wie de poort
187 // ooit "soepeler" maakt door ook wachtende verzoeken mee te tellen, geeft
188 // daarmee iedereen toegang die op Volgen heeft geklikt -- precies wat de
189 // eigenaarspoort moest voorkomen.
190 const WACHTEND = 'https://elders.example/users/wachtend';
191 db.prepare("INSERT INTO ap_pending_follows (id, ward_slug, follower_uri, quorum, status) VALUES (?,?,?,'owner','pending')")
192 .run('pf-1', 'kid', WACHTEND);
193 assert.equal(OWA.isFollowerOf('kid', WACHTEND), false, 'wachten is niet volgen');
194});
195
196test('een sessie zonder bewijs levert geen actor', () => {
197 assert.equal(OWA.guestActor(null), null);
198 assert.equal(OWA.guestActor({ session: {} }), null);
199 assert.equal(OWA.guestActor({ session: { owa: {} } }), null);
200 assert.equal(OWA.guestActor({ session: { owa: { actor: ACTOR } } }), ACTOR);
201});
202
203test('viewerFor zet het bewijs om in een besluitbare vorm', () => {
204 const req = { session: { owa: { actor: ACTOR } } };
205 const v = OWA.viewerFor(req, { slug: 'kid' });
206 assert.equal(v.fediActor, ACTOR);
207 assert.equal(v.isFollower, true);
208 assert.equal(v.user, null, 'een gast is geen lokale gebruiker');
209});
Note: See TracBrowser for help on using the repository browser.