| 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.
|
|---|
| 7 | import { test } from 'node:test';
|
|---|
| 8 | import assert from 'node:assert/strict';
|
|---|
| 9 | import crypto from 'crypto';
|
|---|
| 10 |
|
|---|
| 11 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 12 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 13 |
|
|---|
| 14 | const dbMod = await import('../src/config/database.js');
|
|---|
| 15 | const db = dbMod.default;
|
|---|
| 16 | dbMod.initializeDatabase();
|
|---|
| 17 | const OWA = await import('../src/services/OpenWebAuthService.js');
|
|---|
| 18 |
|
|---|
| 19 | const ACTOR = 'https://elders.example/users/mee';
|
|---|
| 20 | const ANDER = 'https://elders.example/users/iemand';
|
|---|
| 21 |
|
|---|
| 22 | // ── tokens ────────────────────────────────────────────────────────────────
|
|---|
| 23 |
|
|---|
| 24 | test('een token wijst naar de actor die hem kreeg', () => {
|
|---|
| 25 | const t = OWA.issueToken(ACTOR);
|
|---|
| 26 | assert.equal(OWA.redeemToken(t), ACTOR);
|
|---|
| 27 | });
|
|---|
| 28 |
|
|---|
| 29 | test('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 |
|
|---|
| 37 | test('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 |
|
|---|
| 43 | test('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 |
|
|---|
| 51 | test('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 |
|
|---|
| 65 | test('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 |
|
|---|
| 112 | test('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 |
|
|---|
| 120 | test('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 |
|
|---|
| 128 | test('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 |
|
|---|
| 139 | test('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 |
|
|---|
| 148 | test('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 |
|
|---|
| 154 | test('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 |
|
|---|
| 165 | test('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 |
|
|---|
| 178 | test('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 |
|
|---|
| 196 | test('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 |
|
|---|
| 203 | test('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 | });
|
|---|