Index: src/services/OpenWebAuthService.js
===================================================================
--- src/services/OpenWebAuthService.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ src/services/OpenWebAuthService.js	(revision d85b66f559540eafc15e272713f7309f657ceb42)
@@ -159,4 +159,103 @@
 }
 
+// ── de HOME-kant: onze gebruiker bewijst zich elders ──────────────────────
+//
+// Hier zijn de rollen omgedraaid. Wij hebben nu de prive-sleutel nodig -- om te
+// ondertekenen en om het token te ontsleutelen -- en dat is precies waarom
+// alleen een echte instance deze kant kan spelen.
+
+/** `bdest` terug naar een URL. Hex in, URL uit; ongeldig = null. */
+export function fromBdest(hex) {
+  const h = String(hex || '');
+  if (!/^[0-9a-f]+$/i.test(h) || h.length % 2) return null;
+  try {
+    const u = new URL(Buffer.from(h, 'hex').toString('utf8'));
+    if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
+    return u;
+  } catch { return null; }
+}
+
+/**
+ * Het token-endpoint van de doelsite, gevonden via webfinger op zijn WORTEL.
+ *
+ * En meteen de open-redirect-verdediging van deze kant: het gevonden endpoint
+ * moet dezelfde origin hebben als `bdest`. De FEP zegt het met zoveel woorden --
+ * lukt de ontdekking niet, of wijst hij ergens anders heen, dan sturen we de
+ * browser NIET naar bdest maar geven we een fout. Anders is /magic het
+ * doorgeefluik.
+ */
+export async function discoverTokenEndpoint(bdestUrl, { fetchImpl = fetch } = {}) {
+  let origin;
+  try { origin = new URL(bdestUrl).origin; } catch { return null; }
+  const url = `${origin}/.well-known/webfinger?resource=${encodeURIComponent(origin + '/')}`;
+  try {
+    const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
+    if (!r.ok) return null;
+    const jrd = await r.json();
+    const link = (jrd.links || []).find((l) => l && l.rel === REL_TOKEN && l.href);
+    if (!link) return null;
+    if (new URL(link.href).origin !== origin) return null;   // open redirect
+    return link.href;
+  } catch { return null; }
+}
+
+/**
+ * Het token ophalen bij de doelsite, ondertekend namens onze actor.
+ *
+ * De handtekening gaat in `Authorization: Signature ...` -- zo schrijft de FEP
+ * het voor, en niet in de `Signature`-header die de rest van de fediverse
+ * gebruikt. Plus `X-Open-Web-Auth` met willekeur erin: de doelsite doet er
+ * niets mee, het voegt alleen entropie toe aan wat we ondertekenen.
+ */
+export async function requestToken(endpoint, { keyId, privatePem, fetchImpl = fetch } = {}) {
+  const u = new URL(endpoint);
+  const date = new Date().toUTCString();
+  const nonce = crypto.randomBytes(16).toString('hex');
+  const target = `${u.pathname}${u.search || ''}`;
+  const signingString = [
+    `(request-target): get ${target}`,
+    `host: ${u.host}`,
+    `date: ${date}`,
+    `x-open-web-auth: ${nonce}`,
+  ].join('\n');
+  const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
+  const headers = {
+    Accept: 'application/json',
+    Date: date,
+    'X-Open-Web-Auth': nonce,
+    Authorization: `Signature keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date x-open-web-auth",signature="${signature}"`,
+  };
+  const r = await fetchImpl(endpoint, { headers });
+  if (!r.ok) return null;
+  const j = await r.json();
+  if (!j || j.success !== true || !j.encrypted_token) return null;
+  return String(j.encrypted_token);
+}
+
+/** Het token uitpakken met onze eigen prive-sleutel. */
+export function decryptToken(encrypted, privatePem) {
+  const b64 = String(encrypted || '').replace(/-/g, '+').replace(/_/g, '/');
+  const buf = crypto.privateDecrypt(
+    { key: privatePem, padding: crypto.constants.RSA_PKCS1_PADDING },
+    Buffer.from(b64, 'base64'),
+  );
+  const t = buf.toString('utf8');
+  // Een token is URL-veilige tekst. Bij een verkeerde sleutel geeft PKCS#1 v1.5
+  // geen fout maar afgeleide onzin terug (implicit rejection, zie de toelichting
+  // bij encryptTokenFor), en die onzin hoort hier te stranden in plaats van als
+  // token de wereld in te gaan.
+  //
+  // ALLEBEI de voorwaarden doen werk, en dat is gemeten met 300 vreemde sleutels:
+  //  - de TEKENSET vangt vrijwel alles. Van die 300 was er geen enkele die
+  //    volledig uit URL-veilige tekens bestond.
+  //  - de ONDERGRENS vangt de rest. De onzin heeft een willekeurige lengte (5
+  //    tot 209 bytes gezien), en 18 van de 300 was korter dan 16 bytes. Bij zo'n
+  //    kort stukje is "toevallig allemaal URL-veilig" niet meer verwaarloosbaar:
+  //    per byte is die kans ruwweg een kwart.
+  // Zestien is daarmee geen rond getal maar een grens die iets doet. Een echte
+  // implementatie zit er ruim boven (de onze: 43 tekens).
+  return /^[A-Za-z0-9._~-]{16,512}$/.test(t) ? t : null;
+}
+
 // ── wie is er binnen ──────────────────────────────────────────────────────
 
@@ -197,3 +296,4 @@
   parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect,
   guestActor, isFollowerOf, viewerFor,
+  fromBdest, discoverTokenEndpoint, requestToken, decryptToken,
 };
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ src/services/i18n.js	(revision d85b66f559540eafc15e272713f7309f657ceb42)
@@ -891,5 +891,5 @@
     'fgate.title': 'Alleen voor vrienden',
     'fgate.sub': 'Dit bericht is voor wie deze site volgt.',
-    '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',
+    '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',
     'lbio.empty': 'Nog geen links ingesteld.',
     'lbio.back_to_site': 'naar de site',
@@ -1897,5 +1897,5 @@
     'fgate.title': 'Friends only',
     'fgate.sub': 'This post is for people who follow this site.',
-    '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',
+    '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',
     'lbio.empty': 'No links set up yet.',
     'lbio.back_to_site': 'back to the site',
@@ -2903,5 +2903,5 @@
     'fgate.title': 'Nur für Freunde',
     'fgate.sub': 'Dieser Beitrag ist für alle, die dieser Seite folgen.',
-    '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',
+    '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',
     'lbio.empty': 'Noch keine Links eingerichtet.',
     'lbio.back_to_site': 'zurück zur Seite',
