Changeset d85b66f in Klonkt for src/services


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

Location:
src/services
Files:
2 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};
  • 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',
Note: See TracChangeset for help on using the changeset viewer.