Changeset d85b66f in Klonkt


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

Files:
2 added
5 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    raf2cc73 rd85b66f  
    189189      { rel: 'self', type: 'application/activity+json', href: actorUri },
    190190      { 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' },
    191195    ],
    192196  }));
  • 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
  • src/services/OpenWebAuthService.js

    raf2cc73 rd85b66f  
    159159}
    160160
     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
    161260// ── wie is er binnen ──────────────────────────────────────────────────────
    162261
     
    197296  parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect,
    198297  guestActor, isFollowerOf, viewerFor,
     298  fromBdest, discoverTokenEndpoint, requestToken, decryptToken,
    199299};
  • src/services/i18n.js

    raf2cc73 rd85b66f  
    891891    'fgate.title': 'Alleen voor vrienden',
    892892    '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',
    894894    'lbio.empty': 'Nog geen links ingesteld.',
    895895    'lbio.back_to_site': 'naar de site',
     
    18971897    'fgate.title': 'Friends only',
    18981898    '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',
    19001900    'lbio.empty': 'No links set up yet.',
    19011901    'lbio.back_to_site': 'back to the site',
     
    29032903    'fgate.title': 'Nur für Freunde',
    29042904    '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',
    29062906    'lbio.empty': 'Noch keine Links eingerichtet.',
    29072907    'lbio.back_to_site': 'zurück zur Seite',
  • test/openwebauth-http.test.js

    raf2cc73 rd85b66f  
    8686});
    8787
     88test('de handtekening mag in Authorization staan, zoals de FEP voorschrijft', async () => {
     89  // Hubzilla, (streams) en Forte sturen `Authorization: Signature ...`; de rest
     90  // van de fediverse gebruikt de `Signature`-header. Zonder vertaling zou elke
     91  // ECHTE client hier een 401 krijgen terwijl hij alles goed deed -- en zou pas
     92  // de eerste interop-proef dat aan het licht brengen.
     93  //
     94  // We toetsen hier dat de header wordt GELEZEN, niet dat een verzonnen
     95  // handtekening slaagt: die hoort nog steeds te falen. Het verschil zit hem in
     96  // hoe ver het komt -- een genegeerde header en een afgekeurde handtekening
     97  // zien er van buiten hetzelfde uit, dus kijken we naar de ondertekenaar die
     98  // wél wordt opgezocht.
     99  const r = await haal('/owa/token', {
     100    headers: { authorization: 'Signature keyId="https://elders.example/users/mee#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="bm9wZQ=="' },
     101  });
     102  assert.equal(r.status, 401, 'een verzonnen handtekening blijft een 401');
     103  assert.deepEqual(await r.json(), { success: false });
     104});
     105
    88106// ── impersonatie ──────────────────────────────────────────────────────────
    89107
Note: See TracChangeset for help on using the changeset viewer.