Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 12bed59eaefe12a4ed9c2c3cc267cb448ae42b43)
+++ src/services/ActivityPubService.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
@@ -1238,4 +1238,21 @@
   const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
   if (!pem) return null;
+  // Bind the key to the actor it speaks for. Without this we hand back whatever
+  // `id` the fetched document claims, so anyone could host a document carrying a
+  // VICTIM's id next to their OWN public key, sign with their own private half,
+  // and be believed: the victim's server is never contacted. The caller decides on
+  // `verified.id`, so the identity has to come from where the key was FETCHED,
+  // never from what the document says about itself.
+  // Adds conditions only, and there is no exemption list on purpose: an
+  // "unless it's a known peer" escape hatch is exactly the door this closes.
+  // Note this does not narrow what we accept in practice, since the line above
+  // already requires the embedded publicKey object (an array or a bare URI
+  // reference never worked here).
+  const key = actor.publicKey;
+  try {
+    if (new URL(p.keyId).host !== new URL(actor.id).host) return null;   // same origin as the key
+    if (key.id && key.id !== p.keyId) return null;                       // this key, not a neighbour's
+    if (key.owner && key.owner !== actor.id) return null;                // and it belongs to this actor
+  } catch { return null; }                                               // unparseable id or keyId
   const hs = (p.headers || '(request-target) host date').split(/\s+/);
   // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
Index: test/ap-signature-keyid-binding.test.js
===================================================================
--- test/ap-signature-keyid-binding.test.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ test/ap-signature-keyid-binding.test.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
@@ -0,0 +1,167 @@
+// De sleutel moet horen bij de actor waarvoor hij spreekt.
+//
+// verifyRequest haalde het actor-document op bij de keyId uit de handtekening en gaf
+// dat document daarna terug zoals het was. De inbox beslist op `verified.id`, dus op
+// wat het document over ZICHZELF beweert. Wie een document neerzet met de id van zijn
+// slachtoffer naast zijn EIGEN publieke sleutel, en ondertekent met zijn eigen private
+// helft, werd geloofd. De server van het slachtoffer wordt daarbij nooit geraadpleegd.
+//
+// Alle hosts hier zijn IP-literals uit de documentatiereeksen: assertPublicHost doet
+// dan geen DNS-opzoeking, en globalThis.fetch is gestubd, dus deze test gaat nooit
+// echt het netwerk op.
+import { test, beforeEach, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+const AANVALLER = 'https://203.0.113.90';   // TEST-NET-3, de server van de aanvaller
+const SLACHTOFFER = 'https://198.51.100.7'; // TEST-NET-2, de echte server
+
+// Eén sleutelpaar: dat van de aanvaller. Het slachtoffer geeft zijn private helft
+// natuurlijk nooit weg, en dat is precies waarom de aanval op de identiteit mikt
+// in plaats van op de wiskunde.
+const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
+  modulusLength: 2048,
+  publicKeyEncoding: { type: 'spki', format: 'pem' },
+  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+});
+
+let documenten;      // url -> JSON dat de nep-fetch teruggeeft
+let opgehaald;       // welke urls zijn werkelijk opgevraagd
+const echteFetch = globalThis.fetch;
+
+beforeEach(() => {
+  documenten = new Map();
+  opgehaald = [];
+  globalThis.fetch = async (url) => {
+    const u = String(url);
+    opgehaald.push(u);
+    const doc = documenten.get(u);
+    if (!doc) return new Response('niet gevonden', { status: 404 });
+    return new Response(JSON.stringify(doc), {
+      status: 200,
+      headers: { 'Content-Type': 'application/activity+json' },
+    });
+  };
+});
+
+afterEach(() => { globalThis.fetch = echteFetch; });
+
+// Bouwt een echt ondertekend verzoek, zoals een federerende server het stuurt.
+function ondertekendVerzoek(keyId, { host = 'test.example', pad = '/ap/users/robin/inbox' } = {}) {
+  const datum = new Date().toUTCString();
+  const regels = [
+    `(request-target): post ${pad}`,
+    `host: ${host}`,
+    `date: ${datum}`,
+  ].join('\n');
+  const signature = crypto.sign('sha256', Buffer.from(regels), privateKey).toString('base64');
+  return {
+    method: 'POST',
+    originalUrl: pad,
+    headers: {
+      host,
+      date: datum,
+      signature: `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,
+    },
+  };
+}
+
+test('een document dat een ANDERE actor claimt wordt geweigerd', async () => {
+  // De aanvaller zet op zijn eigen server een document neer dat zegt dat het het
+  // slachtoffer is, met zijn eigen sleutel erin.
+  documenten.set(`${AANVALLER}/users/dief`, {
+    id: `${SLACHTOFFER}/users/slachtoffer`,          // <-- de gestolen naam
+    type: 'Person',
+    publicKey: {
+      id: `${AANVALLER}/users/dief#main-key`,
+      owner: `${AANVALLER}/users/dief`,
+      publicKeyPem: publicKey,
+    },
+  });
+
+  const req = ondertekendVerzoek(`${AANVALLER}/users/dief#main-key`);
+  const uit = await AP.verifyRequest(req);
+
+  assert.equal(uit, null, 'een document mag niet de identiteit van een andere host kunnen claimen');
+  // En het belangrijkste: de echte server van het slachtoffer is nooit geraadpleegd,
+  // dus die kan dit ook niet merken of tegenspreken.
+  assert.ok(
+    !opgehaald.some((u) => u.startsWith(SLACHTOFFER)),
+    'de server van het slachtoffer wordt niet eens bevraagd, vandaar de controle aan onze kant',
+  );
+});
+
+test('een geldige ondertekenaar op zijn eigen host komt er wel door', async () => {
+  documenten.set(`${AANVALLER}/users/eerlijk`, {
+    id: `${AANVALLER}/users/eerlijk`,
+    type: 'Person',
+    publicKey: {
+      id: `${AANVALLER}/users/eerlijk#main-key`,
+      owner: `${AANVALLER}/users/eerlijk`,
+      publicKeyPem: publicKey,
+    },
+  });
+
+  const uit = await AP.verifyRequest(ondertekendVerzoek(`${AANVALLER}/users/eerlijk#main-key`));
+
+  assert.ok(uit, 'gewone federatie mag hier niet op stuklopen');
+  assert.equal(uit.id, `${AANVALLER}/users/eerlijk`);
+});
+
+test('de sleutel van een buurman op dezelfde host werkt niet', async () => {
+  // Zelfde host, dus de herkomstcontrole alleen is niet genoeg: dit document plakt
+  // het sleutelblok van iemand anders in.
+  documenten.set(`${AANVALLER}/users/buurman`, {
+    id: `${AANVALLER}/users/buurman`,
+    type: 'Person',
+    publicKey: {
+      id: `${AANVALLER}/users/iemand-anders#main-key`,   // <-- niet de keyId waarmee ondertekend is
+      owner: `${AANVALLER}/users/iemand-anders`,
+      publicKeyPem: publicKey,
+    },
+  });
+
+  const uit = await AP.verifyRequest(ondertekendVerzoek(`${AANVALLER}/users/buurman#main-key`));
+  assert.equal(uit, null, 'de keyId moet de sleutel zijn die de actor adverteert');
+});
+
+test('een sleutelblok met een vreemde owner wordt geweigerd', async () => {
+  documenten.set(`${AANVALLER}/users/knip`, {
+    id: `${AANVALLER}/users/knip`,
+    type: 'Person',
+    publicKey: {
+      id: `${AANVALLER}/users/knip#main-key`,
+      owner: `${SLACHTOFFER}/users/slachtoffer`,          // <-- owner wijst ergens anders
+      publicKeyPem: publicKey,
+    },
+  });
+
+  const uit = await AP.verifyRequest(ondertekendVerzoek(`${AANVALLER}/users/knip#main-key`));
+  assert.equal(uit, null, 'owner moet naar de actor zelf wijzen');
+});
+
+test('een verdraaide handtekening blijft falen, ook bij een kloppend document', async () => {
+  // Bewijst dat de nieuwe controles de handtekeningcontrole niet vervangen maar
+  // ervoor komen: de wiskunde moet nog steeds kloppen.
+  documenten.set(`${AANVALLER}/users/eerlijk`, {
+    id: `${AANVALLER}/users/eerlijk`,
+    type: 'Person',
+    publicKey: {
+      id: `${AANVALLER}/users/eerlijk#main-key`,
+      owner: `${AANVALLER}/users/eerlijk`,
+      publicKeyPem: publicKey,
+    },
+  });
+
+  const req = ondertekendVerzoek(`${AANVALLER}/users/eerlijk#main-key`);
+  req.headers.signature = req.headers.signature.replace(/signature="[^"]*"$/, 'signature="AAAA"');
+
+  assert.equal(await AP.verifyRequest(req), null);
+});
