source: Klonkt/test/openwebauth-home.test.js@ d85b66f

main
Last change on this file since d85b66f was d85b66f, checked in by Bart <bart@…>, 3 weeks ago

De home-kant erbij: twee Klonkts kunnen elkaar nu aanmelden

Er was alleen de ontvangende helft: een bezoeker van Hubzilla kwam wel door onze
fanpoort, maar onze eigen gebruikers konden zich nergens bewijzen. Nu allebei.

/magic doet het spiegelbeeld: onze ingelogde gebruiker komt binnen met een
bdest, wij halen ondertekend een token bij die site, ontsleutelen het met onze
eigen prive-sleutel en sturen hem terug met ?owt=. Dit is de enige plek waar die
sleutel nodig is -- en meteen waarom alleen een echte instance deze kant speelt.

WELKE IDENTITEIT: op Klonkt is de fediverse-identiteit de SITE, niet het account.
Eén site gaat meteen door, meer sites laat kiezen. Ondertekenen en ontsleutelen
kan alleen met een sleutel die de gebruiker ook echt beheert, dus de gekozen
site wordt getoetst tegen zijn eigen sites.

EN ER IS EEN TUSSENSCHERM. De FEP waarschuwt onder "Information leakage" dat
OpenWebAuth een sterke identiteitsclaim afgeeft aan elke site die erom vraagt,
desnoods ongemerkt. De omweg langs je eigen server is het enige moment waarop je
kunt zeggen: deze site niet. Vandaar dat de doelhost er groot staat.

EEN FOUT DIE IK ONDERWEG IN MIJN EIGEN WERK VOND: de FEP schrijft voor dat de
handtekening in Authorization: Signature ... gaat, terwijl de rest van de
fediverse (en dus AP.verifyRequest) de Signature-header leest. Ons
token-endpoint las alleen die laatste, dus elke ECHTE client -- Hubzilla,
(streams), Forte -- had een 401 gekregen terwijl hij alles goed deed. Dat was
pas bij de eerste interop-proef opgevallen. Nu leest het endpoint allebei.

Open redirect, nu ook van deze kant: lukt het ontdekken van het token-endpoint
niet, of wijst het naar een andere origin dan bdest, dan volgt een fout en GEEN
doorverwijzing. Anders is /magic het doorgeefluik.

Gemeten en vastgelegd bij decryptToken: de ondergrens van 16 tekens is geen rond
getal. Implicit rejection geeft onzin van willekeurige lengte (5 tot 209 bytes
gezien, 18 van de 300 korter dan 16), en bij zo'n kort stukje is "toevallig
allemaal URL-veilig" niet verwaarloosbaar. De tekenset ving 300 van de 300, de
ondergrens dekt de staart.

1145 toetsen groen (was 1135).

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

  • Property mode set to 100644
File size: 7.4 KB
Line 
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.
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://thuis.example';
13
14const dbMod = await import('../src/config/database.js');
15dbMod.initializeDatabase();
16const OWA = await import('../src/services/OpenWebAuthService.js');
17
18// ── bdest heen en terug ───────────────────────────────────────────────────
19
20test('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
25test('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
35test('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
47test('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
56test('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
67test('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
104test('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
118test('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
130test('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});
Note: See TracBrowser for help on using the repository browser.