source: Klonkt/src/services/OpenWebAuthService.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: 13.0 KB
Line 
1/**
2 * OpenWebAuth (FEP-61cf) — de TARGET-kant.
3 *
4 * Waarom dit bestaat: `fan_only` betekent "mijn volgers op de fediverse", maar
5 * de poort vroeg om een KLONKT-ACCOUNT. Dat is de verkeerde vraag: precies de
6 * mensen voor wie de poort openstaat -- volgers elders -- konden er niet door,
7 * en wie er wel door kon had meestal niets met volgen te maken. Hiermee kan een
8 * bezoeker bewijzen dat hij @iemand@ergens is, zonder hier een account, een
9 * wachtwoord of een cookie van een derde partij.
10 *
11 * WIJ ZIJN DE TARGET INSTANCE, nooit de home instance. Dat is de prettige helft:
12 * de home instance heeft de prive-sleutel nodig (om te ondertekenen en om ons
13 * token te ontsleutelen), wij hebben alleen publieke sleutels nodig. Er staat
14 * hier dus geen geheim van iemand anders, en we kunnen ook niemands identiteit
15 * uitgeven. Het spiegelbeeld (Klonkt-gebruikers laten inloggen OP andere sites,
16 * de /magic-kant) is bewust NIET gebouwd: dat is een andere functie.
17 *
18 * De stroom, met de FEP-stappen erbij:
19 * 1. bezoeker geeft zijn adres -> wij webfingeren hem, vinden zijn
20 * redirect-endpoint, sturen hem daarheen
21 * 2. zijn server controleert hem -> en vraagt ONS om een token
22 * 3. wij verifieren die ondertekende -> token terug, versleuteld met ZIJN
23 * aanvraag publieke sleutel
24 * 4. zijn server ontsleutelt -> stuurt hem terug met ?owt=<token>
25 * 5. wij wisselen het token in -> nu weten we wie hij is
26 *
27 * DRIE DINGEN DIE DE FEP ALS AANVAL BESCHRIJFT, en die hieronder staan omdat ze
28 * anders precies de fout worden die je niet ziet:
29 *
30 * - IMPERSONATIE. `?zid=` mag NOOIT iemands identiteit bepalen; alleen het
31 * ingewisselde `?owt=` telt. Mallory kan een link maken met zid=bob@elders,
32 * en komt dan terug met een token dat MALLORY zegt. Wie zid gelooft, laat
33 * Mallory als Bob binnen.
34 * - OPEN REDIRECT. Het redirect-endpoint dat we uit webfinger halen moet
35 * dezelfde host hebben als het adres dat de bezoeker intypte, anders sturen
36 * wij bezoekers naar waar een vreemde maar wil.
37 * - DoS. Tokens vervallen in minuten en gaan na een keer gebruiken weg.
38 */
39import crypto from 'crypto';
40import db from '../config/database.js';
41
42/** Kort, want tussen stap 3 en 5 zit alleen een redirect. De FEP noemt "a couple of minutes". */
43export const TOKEN_TTL_MS = 3 * 60 * 1000;
44
45/** rel-waarden uit de FEP. Letterlijk, want hier hangt de vindbaarheid aan. */
46export const REL_TOKEN = 'http://purl.org/openwebauth/v1';
47export const REL_REDIRECT = 'http://purl.org/openwebauth/v1#redirect';
48
49// ── tokens ────────────────────────────────────────────────────────────────
50
51/** Alles wat over tijd is weg. Draait bij elke uitgifte en elke inwisseling. */
52export function sweepTokens(now = Date.now()) {
53 db.prepare('DELETE FROM owa_tokens WHERE created_at < ?').run(now - TOKEN_TTL_MS);
54}
55
56/**
57 * Stap 3: een token voor deze actor, opgeslagen zodat we hem straks herkennen.
58 * URL-veilig, want hij reist als query-parameter terug.
59 */
60export function issueToken(actorUri, now = Date.now()) {
61 sweepTokens(now);
62 const token = crypto.randomBytes(32).toString('base64url');
63 db.prepare('INSERT INTO owa_tokens (token, actor_uri, created_at) VALUES (?,?,?)')
64 .run(token, String(actorUri), now);
65 return token;
66}
67
68/**
69 * Stap 5: eenmalig inwisselen. Geeft de actor terug, of null.
70 *
71 * Het verwijderen gebeurt ALTIJD, ook als het token te oud bleek: een token dat
72 * eenmaal is aangeboden mag nooit een tweede kans krijgen.
73 */
74export function redeemToken(token, now = Date.now()) {
75 const t = String(token || '');
76 if (!t) return null;
77 const row = db.prepare('SELECT actor_uri, created_at FROM owa_tokens WHERE token = ?').get(t);
78 if (row) db.prepare('DELETE FROM owa_tokens WHERE token = ?').run(t);
79 sweepTokens(now);
80 if (!row) return null;
81 if (now - row.created_at > TOKEN_TTL_MS) return null;
82 return row.actor_uri;
83}
84
85/**
86 * Het token versleuteld met de PUBLIEKE sleutel van de actor, zodat alleen zijn
87 * server het kan lezen. PKCS#1 v1.5 en base64url zonder '=' staan zo in de FEP;
88 * dat is geen smaak maar interop met Hubzilla en (streams).
89 */
90export function encryptTokenFor(token, publicKeyPem) {
91 const buf = crypto.publicEncrypt(
92 { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_PADDING },
93 Buffer.from(String(token), 'utf8'),
94 );
95 return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
96}
97
98// ── ontdekken waar de bezoeker vandaan komt ───────────────────────────────
99
100/** `@iemand@ergens.nl`, `iemand@ergens.nl`, `acct:iemand@ergens.nl` -> {user, host}. */
101export function parseHandle(input) {
102 const m = String(input || '').trim().replace(/^acct:/i, '').replace(/^@/, '')
103 .match(/^([^@\s/]+)@([^@\s/]+)$/);
104 if (!m) return null;
105 const host = m[2].toLowerCase();
106 if (!/^[a-z0-9.-]+(:\d+)?$/i.test(host)) return null;
107 return { user: m[1], host, acct: `${m[1]}@${host}` };
108}
109
110/**
111 * Stap 1: waar stuurt deze bezoeker zich heen om zich te bewijzen?
112 *
113 * De FEP: nieuwe implementaties horen te webfingeren, oude hard-coden /magic.
114 * We doen het eerste en vallen terug op het tweede -- die terugval is veilig
115 * omdat hij per constructie op DEZELFDE host ligt.
116 *
117 * En hier staat de open-redirect-controle: wat webfinger ook teruggeeft, het
118 * moet de host zijn van het adres dat de bezoeker zelf intypte. Zonder die
119 * regel wordt dit formulier een doorgeefluik naar elke gewenste URL.
120 */
121export async function discoverRedirectEndpoint(handle, { fetchImpl = fetch } = {}) {
122 const h = parseHandle(handle);
123 if (!h) return null;
124 const url = `https://${h.host}/.well-known/webfinger?resource=${encodeURIComponent('acct:' + h.acct)}`;
125 let href = null;
126 try {
127 const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
128 if (r.ok) {
129 const jrd = await r.json();
130 const link = (jrd.links || []).find((l) => l && l.rel === REL_REDIRECT && l.href);
131 if (link) href = link.href;
132 }
133 } catch { /* geen webfinger: hieronder de terugval */ }
134 if (!href) href = `https://${h.host}/magic`;
135 try {
136 if (new URL(href).host.toLowerCase() !== h.host) return null; // open redirect
137 } catch { return null; }
138 return { endpoint: href, handle: h };
139}
140
141/** `bdest`: de terugkeer-URL als hex, zo staat het in de FEP. */
142export function toBdest(url) {
143 return Buffer.from(String(url), 'utf8').toString('hex');
144}
145
146/**
147 * De URL waar we de bezoeker heen sturen.
148 *
149 * De terugkeer-URL moet BINNEN onze eigen origin liggen -- en het liefst binnen
150 * de PWA-scope (siteUrlBase), anders komt iemand die de site op zijn
151 * beginscherm heeft na het inloggen terecht in een losse browsertab terwijl de
152 * app uitgelogd blijft. Dat ziet eruit als "inloggen werkt niet" en is het niet.
153 */
154export function buildRedirect(endpoint, returnUrl) {
155 const u = new URL(endpoint);
156 u.searchParams.set('owa', '1');
157 u.searchParams.set('bdest', toBdest(returnUrl));
158 return u.toString();
159}
160
161// ── de HOME-kant: onze gebruiker bewijst zich elders ──────────────────────
162//
163// Hier zijn de rollen omgedraaid. Wij hebben nu de prive-sleutel nodig -- om te
164// ondertekenen en om het token te ontsleutelen -- en dat is precies waarom
165// alleen een echte instance deze kant kan spelen.
166
167/** `bdest` terug naar een URL. Hex in, URL uit; ongeldig = null. */
168export function fromBdest(hex) {
169 const h = String(hex || '');
170 if (!/^[0-9a-f]+$/i.test(h) || h.length % 2) return null;
171 try {
172 const u = new URL(Buffer.from(h, 'hex').toString('utf8'));
173 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
174 return u;
175 } catch { return null; }
176}
177
178/**
179 * Het token-endpoint van de doelsite, gevonden via webfinger op zijn WORTEL.
180 *
181 * En meteen de open-redirect-verdediging van deze kant: het gevonden endpoint
182 * moet dezelfde origin hebben als `bdest`. De FEP zegt het met zoveel woorden --
183 * lukt de ontdekking niet, of wijst hij ergens anders heen, dan sturen we de
184 * browser NIET naar bdest maar geven we een fout. Anders is /magic het
185 * doorgeefluik.
186 */
187export async function discoverTokenEndpoint(bdestUrl, { fetchImpl = fetch } = {}) {
188 let origin;
189 try { origin = new URL(bdestUrl).origin; } catch { return null; }
190 const url = `${origin}/.well-known/webfinger?resource=${encodeURIComponent(origin + '/')}`;
191 try {
192 const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
193 if (!r.ok) return null;
194 const jrd = await r.json();
195 const link = (jrd.links || []).find((l) => l && l.rel === REL_TOKEN && l.href);
196 if (!link) return null;
197 if (new URL(link.href).origin !== origin) return null; // open redirect
198 return link.href;
199 } catch { return null; }
200}
201
202/**
203 * Het token ophalen bij de doelsite, ondertekend namens onze actor.
204 *
205 * De handtekening gaat in `Authorization: Signature ...` -- zo schrijft de FEP
206 * het voor, en niet in de `Signature`-header die de rest van de fediverse
207 * gebruikt. Plus `X-Open-Web-Auth` met willekeur erin: de doelsite doet er
208 * niets mee, het voegt alleen entropie toe aan wat we ondertekenen.
209 */
210export async function requestToken(endpoint, { keyId, privatePem, fetchImpl = fetch } = {}) {
211 const u = new URL(endpoint);
212 const date = new Date().toUTCString();
213 const nonce = crypto.randomBytes(16).toString('hex');
214 const target = `${u.pathname}${u.search || ''}`;
215 const signingString = [
216 `(request-target): get ${target}`,
217 `host: ${u.host}`,
218 `date: ${date}`,
219 `x-open-web-auth: ${nonce}`,
220 ].join('\n');
221 const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
222 const headers = {
223 Accept: 'application/json',
224 Date: date,
225 'X-Open-Web-Auth': nonce,
226 Authorization: `Signature keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date x-open-web-auth",signature="${signature}"`,
227 };
228 const r = await fetchImpl(endpoint, { headers });
229 if (!r.ok) return null;
230 const j = await r.json();
231 if (!j || j.success !== true || !j.encrypted_token) return null;
232 return String(j.encrypted_token);
233}
234
235/** Het token uitpakken met onze eigen prive-sleutel. */
236export function decryptToken(encrypted, privatePem) {
237 const b64 = String(encrypted || '').replace(/-/g, '+').replace(/_/g, '/');
238 const buf = crypto.privateDecrypt(
239 { key: privatePem, padding: crypto.constants.RSA_PKCS1_PADDING },
240 Buffer.from(b64, 'base64'),
241 );
242 const t = buf.toString('utf8');
243 // Een token is URL-veilige tekst. Bij een verkeerde sleutel geeft PKCS#1 v1.5
244 // geen fout maar afgeleide onzin terug (implicit rejection, zie de toelichting
245 // bij encryptTokenFor), en die onzin hoort hier te stranden in plaats van als
246 // token de wereld in te gaan.
247 //
248 // ALLEBEI de voorwaarden doen werk, en dat is gemeten met 300 vreemde sleutels:
249 // - de TEKENSET vangt vrijwel alles. Van die 300 was er geen enkele die
250 // volledig uit URL-veilige tekens bestond.
251 // - de ONDERGRENS vangt de rest. De onzin heeft een willekeurige lengte (5
252 // tot 209 bytes gezien), en 18 van de 300 was korter dan 16 bytes. Bij zo'n
253 // kort stukje is "toevallig allemaal URL-veilig" niet meer verwaarloosbaar:
254 // per byte is die kans ruwweg een kwart.
255 // Zestien is daarmee geen rond getal maar een grens die iets doet. Een echte
256 // implementatie zit er ruim boven (de onze: 43 tekens).
257 return /^[A-Za-z0-9._~-]{16,512}$/.test(t) ? t : null;
258}
259
260// ── wie is er binnen ──────────────────────────────────────────────────────
261
262/** De actor die deze sessie bewees te zijn, of null. */
263export function guestActor(req) {
264 const g = req && req.session && req.session.owa;
265 return (g && typeof g.actor === 'string' && g.actor) ? g.actor : null;
266}
267
268/** Volgt deze actor deze site? Dat is de vraag die `fan_only` altijd al stelde. */
269export function isFollowerOf(slug, actorUri) {
270 if (!slug || !actorUri) return false;
271 const row = db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? LIMIT 1')
272 .get(String(slug), String(actorUri));
273 return !!row;
274}
275
276/**
277 * Alles wat een poort over deze bezoeker moet weten, op één plek.
278 *
279 * Bewust hier en niet in PostAccessService: die module beslist en raakt de
280 * database niet aan. Deze haalt op, die beslist.
281 */
282export function viewerFor(req, site, extra = {}) {
283 const actor = guestActor(req);
284 return {
285 user: (req && req.session && req.session.user) || null,
286 site: site || null,
287 fediActor: actor,
288 isFollower: actor && site ? isFollowerOf(site.slug, actor) : false,
289 ...extra,
290 };
291}
292
293export default {
294 TOKEN_TTL_MS, REL_TOKEN, REL_REDIRECT,
295 sweepTokens, issueToken, redeemToken, encryptTokenFor,
296 parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect,
297 guestActor, isFollowerOf, viewerFor,
298 fromBdest, discoverTokenEndpoint, requestToken, decryptToken,
299};
Note: See TracBrowser for help on using the repository browser.