| 1 | // OpenWebAuth, de HOME-kant: onze gebruiker bewijst zich bij een andere site.
|
|---|
| 2 | //
|
|---|
| 3 | // De rollen zijn hier omgedraaid, en daarmee de gevaren. Als target riskeer je
|
|---|
| 4 | // dat je iemand binnenlaat die je niet kent; als home riskeer je dat je je
|
|---|
| 5 | // BEZOEKER ergens heen stuurt waar hij niet heen wilde, of dat je namens hem
|
|---|
| 6 | // tekent met een sleutel die niet van hem is.
|
|---|
| 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://thuis.example';
|
|---|
| 13 |
|
|---|
| 14 | const dbMod = await import('../src/config/database.js');
|
|---|
| 15 | dbMod.initializeDatabase();
|
|---|
| 16 | const OWA = await import('../src/services/OpenWebAuthService.js');
|
|---|
| 17 |
|
|---|
| 18 | // ── bdest heen en terug ───────────────────────────────────────────────────
|
|---|
| 19 |
|
|---|
| 20 | test('bdest is hex, en alleen hex', () => {
|
|---|
| 21 | const url = 'https://doel.example/een-bericht?a=1';
|
|---|
| 22 | assert.equal(OWA.fromBdest(OWA.toBdest(url)).href, url);
|
|---|
| 23 | });
|
|---|
| 24 |
|
|---|
| 25 | test('rommel in bdest levert niets op', () => {
|
|---|
| 26 | // Dit is de eerste zeef: wat hier doorheen komt bepaalt straks waar we de
|
|---|
| 27 | // bezoeker naartoe sturen.
|
|---|
| 28 | for (const rot of ['', null, 'geenhex', 'abc', '6z6z', Buffer.from('javascript:alert(1)').toString('hex')]) {
|
|---|
| 29 | assert.equal(OWA.fromBdest(rot), null, JSON.stringify(rot));
|
|---|
| 30 | }
|
|---|
| 31 | });
|
|---|
| 32 |
|
|---|
| 33 | // ── open redirect, de kant van de home instance ───────────────────────────
|
|---|
| 34 |
|
|---|
| 35 | test('een token-endpoint op een ANDERE origin wordt geweigerd', async () => {
|
|---|
| 36 | // De aanval: de doelsite (of iemand die zich ervoor uitgeeft) wijst ons naar
|
|---|
| 37 | // een endpoint elders. De FEP: alleen doorsturen als het endpoint dezelfde
|
|---|
| 38 | // origin heeft als bdest.
|
|---|
| 39 | const nep = async () => ({
|
|---|
| 40 | ok: true,
|
|---|
| 41 | json: async () => ({ links: [{ rel: OWA.REL_TOKEN, href: 'https://ergens-anders.example/owa/token' }] }),
|
|---|
| 42 | });
|
|---|
| 43 | const r = await OWA.discoverTokenEndpoint('https://doel.example/pagina', { fetchImpl: nep });
|
|---|
| 44 | assert.equal(r, null);
|
|---|
| 45 | });
|
|---|
| 46 |
|
|---|
| 47 | test('en zonder ontdekking sturen we nergens heen', async () => {
|
|---|
| 48 | // Belangrijk: geen endpoint betekent NIET "stuur hem dan maar gewoon terug".
|
|---|
| 49 | // Dat zou /magic tot doorgeefluik maken. De route geeft hier een 502.
|
|---|
| 50 | const leeg = async () => ({ ok: true, json: async () => ({ links: [] }) });
|
|---|
| 51 | assert.equal(await OWA.discoverTokenEndpoint('https://doel.example/p', { fetchImpl: leeg }), null);
|
|---|
| 52 | const stuk = async () => ({ ok: false, json: async () => ({}) });
|
|---|
| 53 | assert.equal(await OWA.discoverTokenEndpoint('https://doel.example/p', { fetchImpl: stuk }), null);
|
|---|
| 54 | });
|
|---|
| 55 |
|
|---|
| 56 | test('op dezelfde origin is het goed', async () => {
|
|---|
| 57 | const nep = async () => ({
|
|---|
| 58 | ok: true,
|
|---|
| 59 | json: async () => ({ links: [{ rel: OWA.REL_TOKEN, href: 'https://doel.example/owa/token' }] }),
|
|---|
| 60 | });
|
|---|
| 61 | const r = await OWA.discoverTokenEndpoint('https://doel.example/een-bericht', { fetchImpl: nep });
|
|---|
| 62 | assert.equal(r, 'https://doel.example/owa/token');
|
|---|
| 63 | });
|
|---|
| 64 |
|
|---|
| 65 | // ── de ondertekende aanvraag ──────────────────────────────────────────────
|
|---|
| 66 |
|
|---|
| 67 | test('de aanvraag tekent zoals de FEP het voorschrijft', async () => {
|
|---|
| 68 | const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|---|
| 69 | const priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|---|
| 70 | const pub = publicKey.export({ type: 'spki', format: 'pem' });
|
|---|
| 71 |
|
|---|
| 72 | let gezien = null;
|
|---|
| 73 | const nep = async (url, opts) => {
|
|---|
| 74 | gezien = { url, headers: opts.headers };
|
|---|
| 75 | // Doe alsof wij de doelsite zijn: geef een token terug voor deze sleutel.
|
|---|
| 76 | return { ok: true, json: async () => ({ success: true, encrypted_token: OWA.encryptTokenFor('proef-token-abcdefghijklmnop', pub) }) };
|
|---|
| 77 | };
|
|---|
| 78 |
|
|---|
| 79 | const enc = await OWA.requestToken('https://doel.example/owa/token', {
|
|---|
| 80 | keyId: 'https://thuis.example/ap/users/kid#main-key', privatePem: priv, fetchImpl: nep,
|
|---|
| 81 | });
|
|---|
| 82 |
|
|---|
| 83 | // Authorization, NIET Signature -- dat is precies waar echte clients op vallen.
|
|---|
| 84 | assert.match(gezien.headers.Authorization, /^Signature keyId="https:\/\/thuis\.example\/ap\/users\/kid#main-key"/);
|
|---|
| 85 | assert.ok(gezien.headers['X-Open-Web-Auth'], 'de entropie-header uit de FEP staat erop');
|
|---|
| 86 | assert.match(gezien.headers.Authorization, /headers="\(request-target\) host date x-open-web-auth"/,
|
|---|
| 87 | 'en hij wordt MEE-ondertekend, anders voegt hij niets toe');
|
|---|
| 88 |
|
|---|
| 89 | // En de handtekening klopt ook echt.
|
|---|
| 90 | const p = Object.fromEntries([...gezien.headers.Authorization.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
|
|---|
| 91 | const signingString = [
|
|---|
| 92 | '(request-target): get /owa/token',
|
|---|
| 93 | 'host: doel.example',
|
|---|
| 94 | `date: ${gezien.headers.Date}`,
|
|---|
| 95 | `x-open-web-auth: ${gezien.headers['X-Open-Web-Auth']}`,
|
|---|
| 96 | ].join('\n');
|
|---|
| 97 | assert.ok(crypto.verify('sha256', Buffer.from(signingString), pub, Buffer.from(p.signature, 'base64')),
|
|---|
| 98 | 'de handtekening dekt precies wat hij zegt te dekken');
|
|---|
| 99 |
|
|---|
| 100 | // En het token komt er heelhuids uit.
|
|---|
| 101 | assert.equal(OWA.decryptToken(enc, priv), 'proef-token-abcdefghijklmnop');
|
|---|
| 102 | });
|
|---|
| 103 |
|
|---|
| 104 | test('een mislukt antwoord levert geen token op', async () => {
|
|---|
| 105 | const priv = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|---|
| 106 | for (const antwoord of [
|
|---|
| 107 | { ok: false, json: async () => ({}) },
|
|---|
| 108 | { ok: true, json: async () => ({ success: false }) },
|
|---|
| 109 | { ok: true, json: async () => ({ success: true }) }, // geen token erbij
|
|---|
| 110 | ]) {
|
|---|
| 111 | const r = await OWA.requestToken('https://doel.example/owa/token', {
|
|---|
| 112 | keyId: 'k', privatePem: priv, fetchImpl: async () => antwoord,
|
|---|
| 113 | });
|
|---|
| 114 | assert.equal(r, null);
|
|---|
| 115 | }
|
|---|
| 116 | });
|
|---|
| 117 |
|
|---|
| 118 | test('onzin uit de ontsleuteling gaat niet door voor een token', () => {
|
|---|
| 119 | // PKCS#1 v1.5 geeft bij een verkeerde sleutel geen fout maar afgeleide bytes
|
|---|
| 120 | // (implicit rejection). Die mogen hier niet als token de wereld in gaan.
|
|---|
| 121 | const a = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|---|
| 122 | const b = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|---|
| 123 | const enc = OWA.encryptTokenFor('een-net-token', a.publicKey.export({ type: 'spki', format: 'pem' }));
|
|---|
| 124 | const uit = OWA.decryptToken(enc, b.privateKey.export({ type: 'pkcs8', format: 'pem' }));
|
|---|
| 125 | assert.equal(uit, null, 'andermans sleutel levert geen token, ook niet stiekem');
|
|---|
| 126 | });
|
|---|
| 127 |
|
|---|
| 128 | // ── de twee kanten samen ──────────────────────────────────────────────────
|
|---|
| 129 |
|
|---|
| 130 | test('twee Klonkts: de home-kant en de target-kant passen op elkaar', async () => {
|
|---|
| 131 | // De hele reden dat beide helften bestaan. Hier speelt één proces allebei de
|
|---|
| 132 | // rollen: de home tekent en ontsleutelt, de target verifieert en versleutelt.
|
|---|
| 133 | const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|---|
| 134 | const priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|---|
| 135 | const pub = publicKey.export({ type: 'spki', format: 'pem' });
|
|---|
| 136 | const ACTOR = 'https://thuis.example/ap/users/kid';
|
|---|
| 137 |
|
|---|
| 138 | // TARGET: geeft een token uit voor deze actor en versleutelt het met zijn sleutel.
|
|---|
| 139 | const token = OWA.issueToken(ACTOR);
|
|---|
| 140 | const enc = OWA.encryptTokenFor(token, pub);
|
|---|
| 141 |
|
|---|
| 142 | // HOME: ontsleutelt het met zijn eigen helft.
|
|---|
| 143 | const terug = OWA.decryptToken(enc, priv);
|
|---|
| 144 | assert.equal(terug, token);
|
|---|
| 145 |
|
|---|
| 146 | // TARGET: wisselt het in en weet nu wie er binnenkomt.
|
|---|
| 147 | assert.equal(OWA.redeemToken(terug), ACTOR);
|
|---|
| 148 | assert.equal(OWA.redeemToken(terug), null, 'en daarna is hij op');
|
|---|
| 149 | });
|
|---|