source: Klonkt/src/services/OpenWebAuthService.js@ af2cc73

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

Gastlogin via OpenWebAuth: een fan is een volger, geen accounthouder

fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort vroeg
om een KLONKT-ACCOUNT. Dat is de verkeerde vraag, en hij sloot precies de mensen
buiten voor wie de poort openstond. Nu kan een bezoeker bij zijn EIGEN server
bewijzen dat hij @iemand@ergens is (FEP-61cf), en volgt hij deze site, dan is
hij binnen. Geen account hier, geen wachtwoord hier, geen cookie van een derde.

Wij zijn alleen de TARGET instance. Dat is de prettige helft: de home instance
heeft prive-sleutels nodig, wij alleen publieke. Er staat hier dus geen geheim
van iemand anders. De /magic-kant (Klonkt-gebruikers laten inloggen OP andere
sites) is bewust niet gebouwd -- andere functie.

De handtekening-verificatie is NIET opnieuw geschreven: AP.verifyRequest() doet
dit al voor de inbox, inclusief het vastpinnen van de sleutel op de herkomst van
de actor, een replay-venster en een verplichte digest. Een tweede implementatie
van "is deze aanvraag echt van wie hij zegt" is precies wat je niet wilt.

De drie aanvallen die de FEP noemt, hebben elk een toets:

  • IMPERSONATIE: ?zid= bepaalt niets, alleen het ingewisselde ?owt= telt. Mallory kan een link maken met zid=bob, maar komt terug met een token dat Mallory zegt.
  • OPEN REDIRECT: het ontdekte endpoint moet dezelfde host hebben als het adres dat de bezoeker intypte.
  • DoS: tokens vervallen in minuten, gaan na een keer gebruiken weg, en elke uitgifte veegt de oude op.

Onderweg gemeten en vastgelegd: PKCS#1 v1.5 GOOIT GEEN FOUT bij een verkeerde
sleutel. OpenSSL 3 doet aan implicit rejection en geeft afgeleide onzin terug,
juist zodat niemand aan het foutgedrag kan aflezen of zijn gok klopte. 200
vreemde sleutels: 0 fouten, 0 keer het token. De toets test dus "er komt iets
anders uit", niet "het knalt" -- anders schrijft de volgende lezer weer een
assert.throws die per ongeluk slaagt.

Webfinger op de eigen wortel wijst een home instance naar /owa/token. Alleen
origin + '/'; een ACTOR-uri met een pad blijft een 400, want dat legt
webfinger-bare-host.test.js vast en die keuze draai ik niet om als bijvangst.

De fanpoort toont nu het adresveld als hoofdweg en de lokale inlog als tweede,
en fgate.sub zegt niet langer "ingelogde vrienden" maar wat de poort werkelijk
vraagt.

1135 toetsen groen (was 1106). End-to-end nagelopen op een KOPIE van de
database: token inwisselen zet de sessie en haalt het token uit de URL, een
bewezen volger krijgt de tekst, een bewezen niet-volger krijgt de poort en geen
byte van de inhoud, en anoniem idem.

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

  • Property mode set to 100644
File size: 8.5 KB
RevLine 
[af2cc73]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// ── wie is er binnen ──────────────────────────────────────────────────────
162
163/** De actor die deze sessie bewees te zijn, of null. */
164export function guestActor(req) {
165 const g = req && req.session && req.session.owa;
166 return (g && typeof g.actor === 'string' && g.actor) ? g.actor : null;
167}
168
169/** Volgt deze actor deze site? Dat is de vraag die `fan_only` altijd al stelde. */
170export function isFollowerOf(slug, actorUri) {
171 if (!slug || !actorUri) return false;
172 const row = db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? LIMIT 1')
173 .get(String(slug), String(actorUri));
174 return !!row;
175}
176
177/**
178 * Alles wat een poort over deze bezoeker moet weten, op één plek.
179 *
180 * Bewust hier en niet in PostAccessService: die module beslist en raakt de
181 * database niet aan. Deze haalt op, die beslist.
182 */
183export function viewerFor(req, site, extra = {}) {
184 const actor = guestActor(req);
185 return {
186 user: (req && req.session && req.session.user) || null,
187 site: site || null,
188 fediActor: actor,
189 isFollower: actor && site ? isFollowerOf(site.slug, actor) : false,
190 ...extra,
191 };
192}
193
194export default {
195 TOKEN_TTL_MS, REL_TOKEN, REL_REDIRECT,
196 sweepTokens, issueToken, redeemToken, encryptTokenFor,
197 parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect,
198 guestActor, isFollowerOf, viewerFor,
199};
Note: See TracBrowser for help on using the repository browser.