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/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};
Note: See TracChangeset for help on using the changeset viewer.