| 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 | 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 |
|
|---|
| 107 | test('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 |
|
|---|
| 115 | test('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 |
|
|---|
| 123 | test('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 |
|
|---|
| 134 | test('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 |
|
|---|
| 143 | test('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 |
|
|---|
| 149 | test('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 |
|
|---|
| 160 | test('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 |
|
|---|
| 173 | test('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 |
|
|---|
| 180 | test('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 | });
|
|---|