Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ src/routes/activitypub.js	(revision d85b66f559540eafc15e272713f7309f657ceb42)
@@ -189,4 +189,8 @@
       { rel: 'self', type: 'application/activity+json', href: actorUri },
       { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl },
+      // FEP-61cf: hier stuurt een doelsite deze gebruiker heen om zich te
+      // bewijzen. Zonder deze regel valt zo'n site terug op /magic geraden --
+      // wat toevallig klopt, maar raden is geen afspraak.
+      { rel: 'http://purl.org/openwebauth/v1#redirect', href: baseUrl(req) + '/magic' },
     ],
   }));
Index: src/routes/openwebauth.js
===================================================================
--- src/routes/openwebauth.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ src/routes/openwebauth.js	(revision d85b66f559540eafc15e272713f7309f657ceb42)
@@ -14,4 +14,6 @@
 import * as AP from '../services/ActivityPubService.js';
 import * as OWA from '../services/OpenWebAuthService.js';
+import db from '../config/database.js';
+import { renderPage } from '../middleware/render.js';
 
 const router = express.Router();
@@ -28,4 +30,31 @@
 
 /**
+ * De handtekening staat hier in `Authorization`, niet in `Signature`.
+ *
+ * De FEP is er stellig over: "An OpenWebAuth signed request must have an
+ * Authorization header starting with the word Signature". De rest van de
+ * fediverse (en dus AP.verifyRequest) leest de `Signature`-header, zoals
+ * Mastodon die stuurt. Twee gewoontes voor hetzelfde ding.
+ *
+ * Zonder deze vertaling zou elke ECHTE client -- Hubzilla, (streams), Forte --
+ * hier een 401 krijgen terwijl hij alles goed deed, en zou pas de eerste
+ * interop-proef dat aan het licht brengen.
+ *
+ * Geen mutatie van req: verifyRequest leest maar vier velden, dus we geven een
+ * kopie mee. Zo blijft wat de rest van de keten ziet ongewijzigd.
+ */
+function metSignatureHeader(req) {
+  const auth = String((req.headers && req.headers.authorization) || '');
+  if (req.headers && req.headers.signature) return req;      // al in de gewone vorm
+  if (!/^signature\s+/i.test(auth)) return req;              // niets te vertalen
+  return {
+    method: req.method,
+    originalUrl: req.originalUrl,
+    rawBody: req.rawBody,
+    headers: { ...req.headers, signature: auth.replace(/^signature\s+/i, '') },
+  };
+}
+
+/**
  * Stap 2/3: de home instance vraagt ondertekend om een token.
  *
@@ -41,5 +70,5 @@
 router.all('/owa/token', async (req, res) => {
   let verified = null;
-  try { verified = await AP.verifyRequest(req); } catch { verified = null; }
+  try { verified = await AP.verifyRequest(metSignatureHeader(req)); } catch { verified = null; }
   if (!verified || !verified.id) {
     return res.status(401).json({ success: false });
@@ -76,4 +105,94 @@
   }
   return res.redirect(OWA.buildRedirect(found.endpoint, terug));
+});
+
+/**
+ * /magic — de HOME-kant: onze gebruiker bewijst zich bij een andere site.
+ *
+ * Hier zijn de rollen omgedraaid. Een doelsite stuurt onze ingelogde gebruiker
+ * hierheen; wij halen daar ondertekend een token op, 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 de reden dat alleen een echte instance deze
+ * kant kan spelen.
+ *
+ * WELKE IDENTITEIT? Op Klonkt is de fediverse-identiteit de SITE, niet het
+ * account. Wie één site heeft gaat meteen door; wie er meer heeft kiest er een,
+ * want ondertekenen en ontsleutelen kunnen alleen met een sleutel die hij ook
+ * echt beheert.
+ *
+ * EN ER IS EEN TUSSENSCHERM, met opzet. De FEP waarschuwt onder "Information
+ * leakage": OpenWebAuth geeft een STERKE identiteitsclaim af aan elke site die
+ * erom vraagt, desnoods zonder dat je iets merkt. Deze omweg langs je eigen
+ * server is het enige moment waarop je kunt zeggen: deze site niet.
+ */
+function eigenSites(user) {
+  if (!user || !user.id) return [];
+  return db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY is_primary DESC, created_at ASC')
+    .all(user.id);
+}
+
+/** De doelsite waar dit heen gaat, alleen om te TONEN. Beslissen doet bdest. */
+function doelHost(bdest) { try { return new URL(bdest).host; } catch { return ''; } }
+
+router.get('/magic', (req, res) => {
+  const bdest = OWA.fromBdest(req.query && req.query.bdest);
+  if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
+
+  // Niet ingelogd? Dan eerst hier inloggen, en daarna terug naar dit scherm --
+  // met de bdest nog intact, anders is de hele stroom weg.
+  if (!(req.session && req.session.user)) {
+    const terug = '/magic?owa=1&bdest=' + encodeURIComponent(String(req.query.bdest));
+    return res.redirect('/auth/login?next=' + encodeURIComponent(terug));
+  }
+
+  const sites = eigenSites(req.session.user);
+  if (!sites.length) return res.status(403).type('text/plain').send('geen eigen actor om mee te tekenen');
+
+  return renderPage(req, res, 'pages/owa-consent', {
+    pageTitle: 'Aanmelden bij ' + doelHost(bdest.href),
+    bodyClass: 'on-special',
+    owaHost: doelHost(bdest.href),
+    owaBdest: String(req.query.bdest),
+    owaSites: sites,
+  });
+});
+
+/**
+ * De gebruiker zei ja. Nu pas gaan we tekenen.
+ *
+ * De open-redirect-verdediging van deze kant zit in discoverTokenEndpoint(): de
+ * FEP zegt dat we bij een mislukte ontdekking NIET naar bdest mogen doorsturen,
+ * want dan is /magic het doorgeefluik. Vandaar dat elke fout hieronder een
+ * foutpagina geeft en geen redirect.
+ */
+router.post('/magic', async (req, res) => {
+  const bdest = OWA.fromBdest(req.body && req.body.bdest);
+  if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
+  if (!(req.session && req.session.user)) return res.status(401).type('text/plain').send('niet ingelogd');
+
+  // De gekozen site moet er een van HEM zijn: anders tekent hij met andermans
+  // sleutel, en dat is precies het gat dat je hier niet wilt.
+  const sites = eigenSites(req.session.user);
+  const gekozen = sites.find((s) => s.slug === String(req.body.slug || '')) || sites[0];
+  if (!gekozen) return res.status(403).type('text/plain').send('geen eigen actor');
+
+  const endpoint = await OWA.discoverTokenEndpoint(bdest.href);
+  if (!endpoint) return res.status(502).type('text/plain').send('die site biedt geen OpenWebAuth aan');
+
+  try {
+    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+    const keys = AP.getOrCreateKeys(gekozen.slug);
+    const keyId = AP.actorId(base, gekozen.slug) + '#main-key';
+    const versleuteld = await OWA.requestToken(endpoint, { keyId, privatePem: keys.private_pem });
+    if (!versleuteld) return res.status(502).type('text/plain').send('geen token gekregen');
+    const token = OWA.decryptToken(versleuteld, keys.private_pem);
+    if (!token) return res.status(502).type('text/plain').send('token onleesbaar');
+    const terug = new URL(bdest.href);
+    terug.searchParams.set('owt', token);
+    return res.redirect(terug.toString());
+  } catch (e) {
+    console.warn('[owa] /magic mislukte:', e && e.message);
+    return res.status(502).type('text/plain').send('aanmelden mislukte');
+  }
 });
 
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',
Index: src/views/pages/owa-consent.ejs
===================================================================
--- src/views/pages/owa-consent.ejs	(revision d85b66f559540eafc15e272713f7309f657ceb42)
+++ src/views/pages/owa-consent.ejs	(revision d85b66f559540eafc15e272713f7309f657ceb42)
@@ -0,0 +1,69 @@
+<%
+// Het toestemmingsscherm van /magic.
+//
+// Dit scherm bestaat omdat de FEP er onder "Information leakage" om vraagt:
+// OpenWebAuth geeft een STERKE identiteitsclaim af aan elke site die erom
+// vraagt, en desnoods zonder dat je het merkt. De omweg langs je eigen server is
+// het enige moment waarop je nog kunt zeggen: deze site niet.
+//
+// Daarom staat de doelhost groot en apart. Niet als versiering: wie hier
+// doorklikt vertelt die host wie hij is, en dat hoort de zin te zijn die hij
+// gelezen heeft.
+var _sites = (typeof owaSites !== 'undefined' && owaSites) ? owaSites : [];
+%>
+
+<section class="owa">
+  <div class="owa-card">
+    <h1 class="owa-h1"><%= t('owa.title') %></h1>
+
+    <p class="owa-host"><%= owaHost %></p>
+    <p class="owa-sub"><%= t('owa.sub') %></p>
+
+    <form method="post" action="/magic">
+      <input type="hidden" name="bdest" value="<%= owaBdest %>">
+
+      <% if (_sites.length === 1) { %>
+        <input type="hidden" name="slug" value="<%= _sites[0].slug %>">
+        <p class="owa-as"><%= t('owa.as') %> <strong><%= _sites[0].title || _sites[0].slug %></strong></p>
+      <% } else { %>
+        <%# Meer dan één site: kiezen is hier geen luxe. Ondertekenen en
+            ontsleutelen kan alleen met een sleutel die hij ook echt beheert. %>
+        <p class="owa-as"><%= t('owa.choose') %></p>
+        <ul class="owa-list">
+          <% _sites.forEach(function(s, i) { %>
+            <li>
+              <label>
+                <input type="radio" name="slug" value="<%= s.slug %>"<%= i === 0 ? ' checked' : '' %>>
+                <span><%= s.title || s.slug %></span>
+              </label>
+            </li>
+          <% }); %>
+        </ul>
+      <% } %>
+
+      <div class="owa-actions">
+        <button class="owa-btn" type="submit"><%= t('owa.go') %></button>
+        <a class="owa-no" href="/"><%= t('owa.cancel') %></a>
+      </div>
+    </form>
+
+    <p class="owa-fine"><%= t('owa.fine') %></p>
+  </div>
+</section>
+
+<style>
+  .owa { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
+  .owa-card { border: 1px solid rgba(128,128,128,.2); border-radius: 18px; padding: 36px 28px; }
+  .owa-h1 { font-size: 22px; margin: 0 0 14px; }
+  .owa-host { font-size: 20px; font-weight: 600; margin: 0 0 6px; word-break: break-all; }
+  .owa-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
+  .owa-as { font-size: .95rem; margin: 0 0 10px; }
+  .owa-list { list-style: none; padding: 0; margin: 0 0 18px; text-align: left; }
+  .owa-list li { padding: 6px 0; }
+  .owa-list label { display: flex; gap: 10px; align-items: center; cursor: pointer; }
+  .owa-actions { display: flex; gap: 14px; align-items: center; justify-content: center; flex-wrap: wrap; }
+  .owa-btn { padding: 12px 22px; border-radius: 10px; border: 0; background: var(--accent,#6b8f71);
+             color: #fff; font-weight: 600; font-family: inherit; font-size: 1rem; cursor: pointer; }
+  .owa-no { font-size: .9rem; opacity: .7; }
+  .owa-fine { font-size: .8rem; opacity: .6; line-height: 1.5; margin: 18px 0 0; }
+</style>
Index: test/openwebauth-home.test.js
===================================================================
--- test/openwebauth-home.test.js	(revision d85b66f559540eafc15e272713f7309f657ceb42)
+++ test/openwebauth-home.test.js	(revision d85b66f559540eafc15e272713f7309f657ceb42)
@@ -0,0 +1,149 @@
+// OpenWebAuth, de HOME-kant: onze gebruiker bewijst zich bij een andere site.
+//
+// De rollen zijn hier omgedraaid, en daarmee de gevaren. Als target riskeer je
+// dat je iemand binnenlaat die je niet kent; als home riskeer je dat je je
+// BEZOEKER ergens heen stuurt waar hij niet heen wilde, of dat je namens hem
+// tekent met een sleutel die niet van hem is.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://thuis.example';
+
+const dbMod = await import('../src/config/database.js');
+dbMod.initializeDatabase();
+const OWA = await import('../src/services/OpenWebAuthService.js');
+
+// ── bdest heen en terug ───────────────────────────────────────────────────
+
+test('bdest is hex, en alleen hex', () => {
+  const url = 'https://doel.example/een-bericht?a=1';
+  assert.equal(OWA.fromBdest(OWA.toBdest(url)).href, url);
+});
+
+test('rommel in bdest levert niets op', () => {
+  // Dit is de eerste zeef: wat hier doorheen komt bepaalt straks waar we de
+  // bezoeker naartoe sturen.
+  for (const rot of ['', null, 'geenhex', 'abc', '6z6z', Buffer.from('javascript:alert(1)').toString('hex')]) {
+    assert.equal(OWA.fromBdest(rot), null, JSON.stringify(rot));
+  }
+});
+
+// ── open redirect, de kant van de home instance ───────────────────────────
+
+test('een token-endpoint op een ANDERE origin wordt geweigerd', async () => {
+  // De aanval: de doelsite (of iemand die zich ervoor uitgeeft) wijst ons naar
+  // een endpoint elders. De FEP: alleen doorsturen als het endpoint dezelfde
+  // origin heeft als bdest.
+  const nep = async () => ({
+    ok: true,
+    json: async () => ({ links: [{ rel: OWA.REL_TOKEN, href: 'https://ergens-anders.example/owa/token' }] }),
+  });
+  const r = await OWA.discoverTokenEndpoint('https://doel.example/pagina', { fetchImpl: nep });
+  assert.equal(r, null);
+});
+
+test('en zonder ontdekking sturen we nergens heen', async () => {
+  // Belangrijk: geen endpoint betekent NIET "stuur hem dan maar gewoon terug".
+  // Dat zou /magic tot doorgeefluik maken. De route geeft hier een 502.
+  const leeg = async () => ({ ok: true, json: async () => ({ links: [] }) });
+  assert.equal(await OWA.discoverTokenEndpoint('https://doel.example/p', { fetchImpl: leeg }), null);
+  const stuk = async () => ({ ok: false, json: async () => ({}) });
+  assert.equal(await OWA.discoverTokenEndpoint('https://doel.example/p', { fetchImpl: stuk }), null);
+});
+
+test('op dezelfde origin is het goed', async () => {
+  const nep = async () => ({
+    ok: true,
+    json: async () => ({ links: [{ rel: OWA.REL_TOKEN, href: 'https://doel.example/owa/token' }] }),
+  });
+  const r = await OWA.discoverTokenEndpoint('https://doel.example/een-bericht', { fetchImpl: nep });
+  assert.equal(r, 'https://doel.example/owa/token');
+});
+
+// ── de ondertekende aanvraag ──────────────────────────────────────────────
+
+test('de aanvraag tekent zoals de FEP het voorschrijft', async () => {
+  const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+  const priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
+  const pub = publicKey.export({ type: 'spki', format: 'pem' });
+
+  let gezien = null;
+  const nep = async (url, opts) => {
+    gezien = { url, headers: opts.headers };
+    // Doe alsof wij de doelsite zijn: geef een token terug voor deze sleutel.
+    return { ok: true, json: async () => ({ success: true, encrypted_token: OWA.encryptTokenFor('proef-token-abcdefghijklmnop', pub) }) };
+  };
+
+  const enc = await OWA.requestToken('https://doel.example/owa/token', {
+    keyId: 'https://thuis.example/ap/users/kid#main-key', privatePem: priv, fetchImpl: nep,
+  });
+
+  // Authorization, NIET Signature -- dat is precies waar echte clients op vallen.
+  assert.match(gezien.headers.Authorization, /^Signature keyId="https:\/\/thuis\.example\/ap\/users\/kid#main-key"/);
+  assert.ok(gezien.headers['X-Open-Web-Auth'], 'de entropie-header uit de FEP staat erop');
+  assert.match(gezien.headers.Authorization, /headers="\(request-target\) host date x-open-web-auth"/,
+    'en hij wordt MEE-ondertekend, anders voegt hij niets toe');
+
+  // En de handtekening klopt ook echt.
+  const p = Object.fromEntries([...gezien.headers.Authorization.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
+  const signingString = [
+    '(request-target): get /owa/token',
+    'host: doel.example',
+    `date: ${gezien.headers.Date}`,
+    `x-open-web-auth: ${gezien.headers['X-Open-Web-Auth']}`,
+  ].join('\n');
+  assert.ok(crypto.verify('sha256', Buffer.from(signingString), pub, Buffer.from(p.signature, 'base64')),
+    'de handtekening dekt precies wat hij zegt te dekken');
+
+  // En het token komt er heelhuids uit.
+  assert.equal(OWA.decryptToken(enc, priv), 'proef-token-abcdefghijklmnop');
+});
+
+test('een mislukt antwoord levert geen token op', async () => {
+  const priv = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' });
+  for (const antwoord of [
+    { ok: false, json: async () => ({}) },
+    { ok: true, json: async () => ({ success: false }) },
+    { ok: true, json: async () => ({ success: true }) },              // geen token erbij
+  ]) {
+    const r = await OWA.requestToken('https://doel.example/owa/token', {
+      keyId: 'k', privatePem: priv, fetchImpl: async () => antwoord,
+    });
+    assert.equal(r, null);
+  }
+});
+
+test('onzin uit de ontsleuteling gaat niet door voor een token', () => {
+  // PKCS#1 v1.5 geeft bij een verkeerde sleutel geen fout maar afgeleide bytes
+  // (implicit rejection). Die mogen hier niet als token de wereld in gaan.
+  const a = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+  const b = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+  const enc = OWA.encryptTokenFor('een-net-token', a.publicKey.export({ type: 'spki', format: 'pem' }));
+  const uit = OWA.decryptToken(enc, b.privateKey.export({ type: 'pkcs8', format: 'pem' }));
+  assert.equal(uit, null, 'andermans sleutel levert geen token, ook niet stiekem');
+});
+
+// ── de twee kanten samen ──────────────────────────────────────────────────
+
+test('twee Klonkts: de home-kant en de target-kant passen op elkaar', async () => {
+  // De hele reden dat beide helften bestaan. Hier speelt één proces allebei de
+  // rollen: de home tekent en ontsleutelt, de target verifieert en versleutelt.
+  const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+  const priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
+  const pub = publicKey.export({ type: 'spki', format: 'pem' });
+  const ACTOR = 'https://thuis.example/ap/users/kid';
+
+  // TARGET: geeft een token uit voor deze actor en versleutelt het met zijn sleutel.
+  const token = OWA.issueToken(ACTOR);
+  const enc = OWA.encryptTokenFor(token, pub);
+
+  // HOME: ontsleutelt het met zijn eigen helft.
+  const terug = OWA.decryptToken(enc, priv);
+  assert.equal(terug, token);
+
+  // TARGET: wisselt het in en weet nu wie er binnenkomt.
+  assert.equal(OWA.redeemToken(terug), ACTOR);
+  assert.equal(OWA.redeemToken(terug), null, 'en daarna is hij op');
+});
Index: test/openwebauth-http.test.js
===================================================================
--- test/openwebauth-http.test.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ test/openwebauth-http.test.js	(revision d85b66f559540eafc15e272713f7309f657ceb42)
@@ -86,4 +86,22 @@
 });
 
+test('de handtekening mag in Authorization staan, zoals de FEP voorschrijft', async () => {
+  // Hubzilla, (streams) en Forte sturen `Authorization: Signature ...`; de rest
+  // van de fediverse gebruikt de `Signature`-header. Zonder vertaling zou elke
+  // ECHTE client hier een 401 krijgen terwijl hij alles goed deed -- en zou pas
+  // de eerste interop-proef dat aan het licht brengen.
+  //
+  // We toetsen hier dat de header wordt GELEZEN, niet dat een verzonnen
+  // handtekening slaagt: die hoort nog steeds te falen. Het verschil zit hem in
+  // hoe ver het komt -- een genegeerde header en een afgekeurde handtekening
+  // zien er van buiten hetzelfde uit, dus kijken we naar de ondertekenaar die
+  // wél wordt opgezocht.
+  const r = await haal('/owa/token', {
+    headers: { authorization: 'Signature keyId="https://elders.example/users/mee#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="bm9wZQ=="' },
+  });
+  assert.equal(r.status, 401, 'een verzonnen handtekening blijft een 401');
+  assert.deepEqual(await r.json(), { success: false });
+});
+
 // ── impersonatie ──────────────────────────────────────────────────────────
 
