Changeset d85b66f in Klonkt for src/routes/openwebauth.js


Ignore:
Timestamp:
08/18/2026 06:50:08 PM (3 weeks ago)
Author:
Bart <bart@…>
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)
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/routes/openwebauth.js

    raf2cc73 rd85b66f  
    1414import * as AP from '../services/ActivityPubService.js';
    1515import * as OWA from '../services/OpenWebAuthService.js';
     16import db from '../config/database.js';
     17import { renderPage } from '../middleware/render.js';
    1618
    1719const router = express.Router();
     
    2830
    2931/**
     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 */
     46function 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/**
    3059 * Stap 2/3: de home instance vraagt ondertekend om een token.
    3160 *
     
    4170router.all('/owa/token', async (req, res) => {
    4271  let verified = null;
    43   try { verified = await AP.verifyRequest(req); } catch { verified = null; }
     72  try { verified = await AP.verifyRequest(metSignatureHeader(req)); } catch { verified = null; }
    4473  if (!verified || !verified.id) {
    4574    return res.status(401).json({ success: false });
     
    76105  }
    77106  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 */
     128function 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. */
     135function doelHost(bdest) { try { return new URL(bdest).host; } catch { return ''; } }
     136
     137router.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 */
     168router.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  }
    78197});
    79198
Note: See TracChangeset for help on using the changeset viewer.