- Timestamp:
- 08/18/2026 06:50:08 PM (3 weeks ago)
- Branches:
- main
- Children:
- 192fe28
- Parents:
- af2cc73
- git-author:
- Bart <bart@…> (08/18/2026 06:21:21 PM)
- git-committer:
- Bart <bart@…> (08/18/2026 06:50:08 PM)
- Location:
- src
- Files:
-
- 1 added
- 4 edited
-
routes/activitypub.js (modified) (1 diff)
-
routes/openwebauth.js (modified) (4 diffs)
-
services/OpenWebAuthService.js (modified) (2 diffs)
-
services/i18n.js (modified) (3 diffs)
-
views/pages/owa-consent.ejs (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
raf2cc73 rd85b66f 189 189 { rel: 'self', type: 'application/activity+json', href: actorUri }, 190 190 { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl }, 191 // FEP-61cf: hier stuurt een doelsite deze gebruiker heen om zich te 192 // bewijzen. Zonder deze regel valt zo'n site terug op /magic geraden -- 193 // wat toevallig klopt, maar raden is geen afspraak. 194 { rel: 'http://purl.org/openwebauth/v1#redirect', href: baseUrl(req) + '/magic' }, 191 195 ], 192 196 })); -
src/routes/openwebauth.js
raf2cc73 rd85b66f 14 14 import * as AP from '../services/ActivityPubService.js'; 15 15 import * as OWA from '../services/OpenWebAuthService.js'; 16 import db from '../config/database.js'; 17 import { renderPage } from '../middleware/render.js'; 16 18 17 19 const router = express.Router(); … … 28 30 29 31 /** 32 * De handtekening staat hier in `Authorization`, niet in `Signature`. 33 * 34 * De FEP is er stellig over: "An OpenWebAuth signed request must have an 35 * Authorization header starting with the word Signature". De rest van de 36 * fediverse (en dus AP.verifyRequest) leest de `Signature`-header, zoals 37 * Mastodon die stuurt. Twee gewoontes voor hetzelfde ding. 38 * 39 * Zonder deze vertaling zou elke ECHTE client -- Hubzilla, (streams), Forte -- 40 * hier een 401 krijgen terwijl hij alles goed deed, en zou pas de eerste 41 * interop-proef dat aan het licht brengen. 42 * 43 * Geen mutatie van req: verifyRequest leest maar vier velden, dus we geven een 44 * kopie mee. Zo blijft wat de rest van de keten ziet ongewijzigd. 45 */ 46 function metSignatureHeader(req) { 47 const auth = String((req.headers && req.headers.authorization) || ''); 48 if (req.headers && req.headers.signature) return req; // al in de gewone vorm 49 if (!/^signature\s+/i.test(auth)) return req; // niets te vertalen 50 return { 51 method: req.method, 52 originalUrl: req.originalUrl, 53 rawBody: req.rawBody, 54 headers: { ...req.headers, signature: auth.replace(/^signature\s+/i, '') }, 55 }; 56 } 57 58 /** 30 59 * Stap 2/3: de home instance vraagt ondertekend om een token. 31 60 * … … 41 70 router.all('/owa/token', async (req, res) => { 42 71 let verified = null; 43 try { verified = await AP.verifyRequest( req); } catch { verified = null; }72 try { verified = await AP.verifyRequest(metSignatureHeader(req)); } catch { verified = null; } 44 73 if (!verified || !verified.id) { 45 74 return res.status(401).json({ success: false }); … … 76 105 } 77 106 return res.redirect(OWA.buildRedirect(found.endpoint, terug)); 107 }); 108 109 /** 110 * /magic — de HOME-kant: onze gebruiker bewijst zich bij een andere site. 111 * 112 * Hier zijn de rollen omgedraaid. Een doelsite stuurt onze ingelogde gebruiker 113 * hierheen; wij halen daar ondertekend een token op, ontsleutelen het met onze 114 * eigen prive-sleutel en sturen hem terug met ?owt=. Dit is de enige plek waar 115 * die sleutel nodig is -- en meteen de reden dat alleen een echte instance deze 116 * kant kan spelen. 117 * 118 * WELKE IDENTITEIT? Op Klonkt is de fediverse-identiteit de SITE, niet het 119 * account. Wie één site heeft gaat meteen door; wie er meer heeft kiest er een, 120 * want ondertekenen en ontsleutelen kunnen alleen met een sleutel die hij ook 121 * echt beheert. 122 * 123 * EN ER IS EEN TUSSENSCHERM, met opzet. De FEP waarschuwt onder "Information 124 * leakage": OpenWebAuth geeft een STERKE identiteitsclaim af aan elke site die 125 * erom vraagt, desnoods zonder dat je iets merkt. Deze omweg langs je eigen 126 * server is het enige moment waarop je kunt zeggen: deze site niet. 127 */ 128 function eigenSites(user) { 129 if (!user || !user.id) return []; 130 return db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY is_primary DESC, created_at ASC') 131 .all(user.id); 132 } 133 134 /** De doelsite waar dit heen gaat, alleen om te TONEN. Beslissen doet bdest. */ 135 function doelHost(bdest) { try { return new URL(bdest).host; } catch { return ''; } } 136 137 router.get('/magic', (req, res) => { 138 const bdest = OWA.fromBdest(req.query && req.query.bdest); 139 if (!bdest) return res.status(400).type('text/plain').send('bad bdest'); 140 141 // Niet ingelogd? Dan eerst hier inloggen, en daarna terug naar dit scherm -- 142 // met de bdest nog intact, anders is de hele stroom weg. 143 if (!(req.session && req.session.user)) { 144 const terug = '/magic?owa=1&bdest=' + encodeURIComponent(String(req.query.bdest)); 145 return res.redirect('/auth/login?next=' + encodeURIComponent(terug)); 146 } 147 148 const sites = eigenSites(req.session.user); 149 if (!sites.length) return res.status(403).type('text/plain').send('geen eigen actor om mee te tekenen'); 150 151 return renderPage(req, res, 'pages/owa-consent', { 152 pageTitle: 'Aanmelden bij ' + doelHost(bdest.href), 153 bodyClass: 'on-special', 154 owaHost: doelHost(bdest.href), 155 owaBdest: String(req.query.bdest), 156 owaSites: sites, 157 }); 158 }); 159 160 /** 161 * De gebruiker zei ja. Nu pas gaan we tekenen. 162 * 163 * De open-redirect-verdediging van deze kant zit in discoverTokenEndpoint(): de 164 * FEP zegt dat we bij een mislukte ontdekking NIET naar bdest mogen doorsturen, 165 * want dan is /magic het doorgeefluik. Vandaar dat elke fout hieronder een 166 * foutpagina geeft en geen redirect. 167 */ 168 router.post('/magic', async (req, res) => { 169 const bdest = OWA.fromBdest(req.body && req.body.bdest); 170 if (!bdest) return res.status(400).type('text/plain').send('bad bdest'); 171 if (!(req.session && req.session.user)) return res.status(401).type('text/plain').send('niet ingelogd'); 172 173 // De gekozen site moet er een van HEM zijn: anders tekent hij met andermans 174 // sleutel, en dat is precies het gat dat je hier niet wilt. 175 const sites = eigenSites(req.session.user); 176 const gekozen = sites.find((s) => s.slug === String(req.body.slug || '')) || sites[0]; 177 if (!gekozen) return res.status(403).type('text/plain').send('geen eigen actor'); 178 179 const endpoint = await OWA.discoverTokenEndpoint(bdest.href); 180 if (!endpoint) return res.status(502).type('text/plain').send('die site biedt geen OpenWebAuth aan'); 181 182 try { 183 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 184 const keys = AP.getOrCreateKeys(gekozen.slug); 185 const keyId = AP.actorId(base, gekozen.slug) + '#main-key'; 186 const versleuteld = await OWA.requestToken(endpoint, { keyId, privatePem: keys.private_pem }); 187 if (!versleuteld) return res.status(502).type('text/plain').send('geen token gekregen'); 188 const token = OWA.decryptToken(versleuteld, keys.private_pem); 189 if (!token) return res.status(502).type('text/plain').send('token onleesbaar'); 190 const terug = new URL(bdest.href); 191 terug.searchParams.set('owt', token); 192 return res.redirect(terug.toString()); 193 } catch (e) { 194 console.warn('[owa] /magic mislukte:', e && e.message); 195 return res.status(502).type('text/plain').send('aanmelden mislukte'); 196 } 78 197 }); 79 198 -
src/services/OpenWebAuthService.js
raf2cc73 rd85b66f 159 159 } 160 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. */ 168 export 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 */ 187 export 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 */ 210 export 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. */ 236 export 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 161 260 // ── wie is er binnen ────────────────────────────────────────────────────── 162 261 … … 197 296 parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect, 198 297 guestActor, isFollowerOf, viewerFor, 298 fromBdest, discoverTokenEndpoint, requestToken, decryptToken, 199 299 }; -
src/services/i18n.js
raf2cc73 rd85b66f 891 891 'fgate.title': 'Alleen voor vrienden', 892 892 'fgate.sub': 'Dit bericht is voor wie deze site volgt.', 893 ' fgate.owa_label': 'Log in met je eigen fediverse-adres', 'fgate.owa_go': 'Ga verder', 'fgate.owa_hint': 'Je server bevestigt wie je bent. Hier heb je geen account en geen wachtwoord nodig — volg je deze site, dan ben je binnen.', 'fgate.owa_failed': 'Dat adres kon ik niet bereiken. Klopt de spelling?', 'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',893 'owa.title': 'Aanmelden bij een andere site', 'owa.sub': 'Deze site vraagt je server te bevestigen wie je bent. Ga je door, dan weet die site je adres.', 'owa.as': 'Je meldt je aan als', 'owa.choose': 'Met welke van je sites?', 'owa.go': 'Ja, meld me aan', 'owa.cancel': 'Nee, terug', 'owa.fine': 'Er gaat geen wachtwoord naar die site. Alleen je adres, en alleen als je hier ja zegt.', 'fgate.owa_label': 'Log in met je eigen fediverse-adres', 'fgate.owa_go': 'Ga verder', 'fgate.owa_hint': 'Je server bevestigt wie je bent. Hier heb je geen account en geen wachtwoord nodig — volg je deze site, dan ben je binnen.', 'fgate.owa_failed': 'Dat adres kon ik niet bereiken. Klopt de spelling?', 'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen', 894 894 'lbio.empty': 'Nog geen links ingesteld.', 895 895 'lbio.back_to_site': 'naar de site', … … 1897 1897 'fgate.title': 'Friends only', 1898 1898 'fgate.sub': 'This post is for people who follow this site.', 1899 ' fgate.owa_label': 'Sign in with your own fediverse address', 'fgate.owa_go': 'Continue', 'fgate.owa_hint': 'Your own server confirms who you are. No account and no password here — if you follow this site, you are in.', 'fgate.owa_failed': 'I could not reach that address. Is the spelling right?', 'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',1899 'owa.title': 'Sign in to another site', 'owa.sub': 'This site is asking your server to confirm who you are. If you continue, it learns your address.', 'owa.as': 'You are signing in as', 'owa.choose': 'Which of your sites?', 'owa.go': 'Yes, sign me in', 'owa.cancel': 'No, go back', 'owa.fine': 'No password goes to that site. Only your address, and only if you say yes here.', 'fgate.owa_label': 'Sign in with your own fediverse address', 'fgate.owa_go': 'Continue', 'fgate.owa_hint': 'Your own server confirms who you are. No account and no password here — if you follow this site, you are in.', 'fgate.owa_failed': 'I could not reach that address. Is the spelling right?', 'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions', 1900 1900 'lbio.empty': 'No links set up yet.', 1901 1901 'lbio.back_to_site': 'back to the site', … … 2903 2903 'fgate.title': 'Nur für Freunde', 2904 2904 'fgate.sub': 'Dieser Beitrag ist für alle, die dieser Seite folgen.', 2905 ' fgate.owa_label': 'Mit deiner eigenen Fediverse-Adresse anmelden', 'fgate.owa_go': 'Weiter', 'fgate.owa_hint': 'Dein eigener Server bestätigt, wer du bist. Kein Konto und kein Passwort hier — wenn du dieser Seite folgst, bist du drin.', 'fgate.owa_failed': 'Diese Adresse war nicht erreichbar. Stimmt die Schreibweise?', 'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',2905 'owa.title': 'Bei einer anderen Seite anmelden', 'owa.sub': 'Diese Seite bittet deinen Server zu bestätigen, wer du bist. Wenn du fortfährst, kennt sie deine Adresse.', 'owa.as': 'Du meldest dich an als', 'owa.choose': 'Mit welcher deiner Seiten?', 'owa.go': 'Ja, anmelden', 'owa.cancel': 'Nein, zurück', 'owa.fine': 'Es geht kein Passwort an diese Seite. Nur deine Adresse, und nur wenn du hier ja sagst.', 'fgate.owa_label': 'Mit deiner eigenen Fediverse-Adresse anmelden', 'fgate.owa_go': 'Weiter', 'fgate.owa_hint': 'Dein eigener Server bestätigt, wer du bist. Kein Konto und kein Passwort hier — wenn du dieser Seite folgst, bist du drin.', 'fgate.owa_failed': 'Diese Adresse war nicht erreichbar. Stimmt die Schreibweise?', 'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen', 2906 2906 'lbio.empty': 'Noch keine Links eingerichtet.', 2907 2907 'lbio.back_to_site': 'zurück zur Seite',
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)