Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 61d52ede440cc84be8e3617e1cc0851eb3a9ba31)
+++ src/config/database.js	(revision b1d5f3fe649d958099ec7a8e2ff39b44708ad824)
@@ -245,4 +245,8 @@
   // kort bericht een halve pagina leegte gaf; nu is het een keuze.
   ensureColumn('sites', 'reader_full_page', 'INTEGER DEFAULT 0');
+  // Identiteitsbewijzen (FEP-c390, shaer-jpxj): de ondertekende verklaringen,
+  // als JSON-lijst, precies zoals ze binnenkwamen -- een handtekening hangt aan
+  // elke byte van de canonieke vorm, dus herschrijven zou hem breken.
+  ensureColumn('sites', 'identity_proofs', 'TEXT');
   ensureColumn('sites', 'show_search',     'INTEGER DEFAULT 1');
   ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 61d52ede440cc84be8e3617e1cc0851eb3a9ba31)
+++ src/routes/activitypub.js	(revision b1d5f3fe649d958099ec7a8e2ff39b44708ad824)
@@ -21,4 +21,5 @@
 import * as Guardianship from '../services/guardianship/index.js';
 import * as ConversationKey from '../services/conversation-key.js';
+import * as IdentityProof from '../services/IdentityProof.js';
 import * as Migration from '../services/MigrationService.js';
 import { getPrimarySite } from '../middleware/site.js';
@@ -1029,4 +1030,51 @@
   },
 });
+// ── Identiteitsbewijzen (FEP-c390, shaer-jpxj) ────────────────────
+//
+// De eigenaar biedt een verklaring aan die met een sleutel van ELDERS is
+// ondertekend: "did:key:... is ook deze actor". Deze server ondertekent niets;
+// hij controleert (IdentityProof.checkStatement) en publiceert onder
+// `attachment` op de actor, waar iedereen hem zelf kan nalopen.
+//
+// Eén per did:key: een nieuwe verklaring met dezelfde sleutel vervangt de
+// vorige, want een verhuizing of een vernieuwde datum is dezelfde bewering.
+// Een handvol is genoeg; meer is geen identiteit meer maar een verzameling.
+const MAX_IDENTITY_PROOFS = 3;
+function readProofs(site) {
+  try { const a = JSON.parse(site.identity_proofs || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
+}
+function writeProofs(site, list) {
+  db.prepare('UPDATE sites SET identity_proofs = ? WHERE id = ?').run(list.length ? JSON.stringify(list) : null, site.id);
+  // De volgers horen dat de actor veranderde; anders blijft hun kopie de oude.
+  const vers = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
+  if (vers) AP.deliverActorUpdate(vers).catch((e) => console.warn('[c390] actor-update:', e && e.message));
+}
+router.post('/ap/users/:slug/identityProofs', express.json({ limit: '16kb', type: ['application/json', 'application/activity+json', 'application/ld+json'] }), (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const base = baseUrl(req);
+  const me = AP.actorId(base, auth.site.slug);
+  const stmt = req.body;
+  const uitslag = IdentityProof.checkStatement(stmt, me);
+  if (!uitslag.ok) return res.status(422).json({ error: uitslag.error });
+  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(auth.site.id);
+  const rest = readProofs(site).filter((b) => b && b.subject !== uitslag.subject);
+  if (rest.length >= MAX_IDENTITY_PROOFS) return res.status(409).json({ error: 'too_many_identity_proofs', max: MAX_IDENTITY_PROOFS });
+  writeProofs(site, [...rest, stmt]);
+  res.status(201).json({ subject: uitslag.subject, alsoKnownAs: me });
+});
+router.delete('/ap/users/:slug/identityProofs', (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const subject = String(req.query.subject || '');
+  if (!IdentityProof.publicKeyFromDidKey(subject)) return res.status(400).json({ error: 'subject must be an Ed25519 did:key' });
+  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(auth.site.id);
+  const voor = readProofs(site);
+  const na = voor.filter((b) => b && b.subject !== subject);
+  if (na.length === voor.length) return res.status(404).json({ error: 'no_such_identity_proof' });
+  writeProofs(site, na);
+  res.status(204).end();
+});
+
 router.post('/ap/users/:slug/uploadMedia', (req, res) => {
   const auth = OAuth.verifyBearer(req.headers.authorization);
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 61d52ede440cc84be8e3617e1cc0851eb3a9ba31)
+++ src/services/ActivityPubService.js	(revision b1d5f3fe649d958099ec7a8e2ff39b44708ad824)
@@ -393,4 +393,7 @@
       oauthTokenEndpoint: `${base}/oauth/token`,
       uploadMedia: `${id}/uploadMedia`,
+      // FEP-c390 (shaer-jpxj): waar de eigenaar een identiteitsbewijs aanbiedt.
+      // Alleen met zijn token; Shaer vindt het hier in plaats van het te raden.
+      'shaer:identityProofs': `${id}/identityProofs`,
     },
     publicKey: {
@@ -449,4 +452,14 @@
     }
   } catch { /* skip malformed profile_links */ }
+  // FEP-c390: de identiteitsbewijzen, ACHTER de profiellinks. Ongewijzigd uit de
+  // database: ze werden bij binnenkomst gecontroleerd, en een handtekening hangt
+  // aan elke byte. Elke verklaring draagt haar eigen @context, dus de actor zelf
+  // hoeft er geen bij.
+  try {
+    const bewijzen = JSON.parse(site.identity_proofs || '[]');
+    if (Array.isArray(bewijzen) && bewijzen.length) {
+      actor.attachment = [...(actor.attachment || []), ...bewijzen.filter((b) => b && b.type === 'VerifiableIdentityStatement')];
+    }
+  } catch { /* skip malformed identity_proofs */ }
   return actor;
 }
Index: src/services/IdentityProof.js
===================================================================
--- src/services/IdentityProof.js	(revision b1d5f3fe649d958099ec7a8e2ff39b44708ad824)
+++ src/services/IdentityProof.js	(revision b1d5f3fe649d958099ec7a8e2ff39b44708ad824)
@@ -0,0 +1,213 @@
+/**
+ * Identiteitsbewijzen (FEP-c390, shaer-jpxj).
+ *
+ * Een sleutel die NIET op deze server staat -- bij Shaer op papier, 24 woorden,
+ * alleen ingetypt als hij nodig is -- ondertekent een verklaring: "did:key:...
+ * is ook <deze actor>". Deze server ondertekent NIETS. Hij controleert de
+ * verklaring en zet hem onder `attachment` op de actor, waar iedereen hem zelf
+ * kan nalopen.
+ *
+ * Wat dat bewijst, en wat niet: dat dezelfde entiteit achter de sleutel en het
+ * account zit. Verhuist het account, dan draagt het nieuwe dezelfde did:key, en
+ * dat bewijst de continuiteit ook als de oude server weg of vijandig is -- een
+ * Move kan dat niet, want die moet door de OUDE server ondertekend worden. Het
+ * bewijst NIET dat elke post van jou is: een gecompromitteerde server tekent nog
+ * steeds met zijn eigen sleutel.
+ *
+ * DE CRYPTOGRAFIE is Data Integrity met eddsa-jcs-2022 (W3C vc-di-eddsa), omdat
+ * Mitra en streams dat controleren. Getoetst tegen de testvectoren uit B.3 van
+ * die specificatie, zodat dit niet alleen het met zichzelf eens is.
+ *
+ * Puur, zonder database: de regels moeten te toetsen zijn zonder server.
+ */
+import crypto from 'crypto';
+
+// ── base58btc (Bitcoin-alfabet), en multibase met het 'z'-voorvoegsel ──
+
+const ALFABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
+const WAARDE = new Map([...ALFABET].map((c, i) => [c, i]));
+
+export function base58Encode(bytes) {
+  const b = Buffer.from(bytes);
+  let nullen = 0;
+  while (nullen < b.length && b[nullen] === 0) nullen++;
+  let n = BigInt('0x' + (b.length ? b.toString('hex') : '0'));
+  let uit = '';
+  while (n > 0n) { uit = ALFABET[Number(n % 58n)] + uit; n /= 58n; }
+  return '1'.repeat(nullen) + uit;
+}
+
+export function base58Decode(str) {
+  if (typeof str !== 'string') return null;
+  let nullen = 0;
+  while (nullen < str.length && str[nullen] === '1') nullen++;
+  let n = 0n;
+  for (const c of str) {
+    const v = WAARDE.get(c);
+    if (v === undefined) return null;
+    n = n * 58n + BigInt(v);
+  }
+  let hex = n === 0n ? '' : n.toString(16);
+  if (hex.length % 2) hex = '0' + hex;
+  return Buffer.concat([Buffer.alloc(nullen), Buffer.from(hex, 'hex')]);
+}
+
+// ── JCS (RFC 8785) ─────────────────────────────────────────────────────
+//
+// RFC 8785 is precies om ECMAScript heen gebouwd: getallen en strings zoals
+// JSON.stringify ze schrijft, sleutels op UTF-16-code-eenheden gesorteerd --
+// wat Array.prototype.sort op strings al doet. In JavaScript is de canonieke
+// vorm dus een gesorteerde JSON.stringify, en niets eigen.
+
+export function jcs(value) {
+  if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
+  if (typeof value === 'number') {
+    if (!Number.isFinite(value)) throw new Error('JCS: geen NaN of oneindig');
+    return JSON.stringify(value);
+  }
+  if (Array.isArray(value)) return `[${value.map(jcs).join(',')}]`;
+  if (typeof value === 'object') {
+    const sleutels = Object.keys(value).filter((k) => value[k] !== undefined).sort();
+    return `{${sleutels.map((k) => `${JSON.stringify(k)}:${jcs(value[k])}`).join(',')}}`;
+  }
+  throw new Error(`JCS: ${typeof value} is geen JSON`);
+}
+
+// ── did:key voor Ed25519 ───────────────────────────────────────────────
+//
+// did:key:z<base58btc(0xed 0x01 || publieke sleutel van 32 bytes)>. Alleen
+// Ed25519 (het "z6Mk"-begin): dat is wat eddsa-jcs-2022 kan controleren.
+
+const ED25519_PUB = Buffer.from([0xed, 0x01]);
+const ED25519_PRIV = Buffer.from([0x80, 0x26]);
+
+export function didKeyFromPublicKey(raw) {
+  const b = Buffer.from(raw);
+  if (b.length !== 32) throw new Error('een Ed25519-sleutel is 32 bytes');
+  return `did:key:z${base58Encode(Buffer.concat([ED25519_PUB, b]))}`;
+}
+
+/** De ruwe publieke sleutel achter een did:key, of null als het geen Ed25519-did:key is. */
+export function publicKeyFromDidKey(did) {
+  const m = /^did:key:z([1-9A-HJ-NP-Za-km-z]+)$/.exec(String(did || ''));
+  if (!m) return null;
+  const b = base58Decode(m[1]);
+  if (!b || b.length !== 34 || b[0] !== 0xed || b[1] !== 0x01) return null;
+  return b.subarray(2);
+}
+
+/** Een multibase-geheime-sleutel (z3u2...) naar zijn zaad van 32 bytes. Voor de toetsen. */
+export function seedFromSecretKeyMultibase(mb) {
+  const b = typeof mb === 'string' && mb[0] === 'z' ? base58Decode(mb.slice(1)) : null;
+  if (!b || b.length !== 34 || b[0] !== ED25519_PRIV[0] || b[1] !== ED25519_PRIV[1]) return null;
+  return b.subarray(2);
+}
+
+const publicKeyObject = (raw) => crypto.createPublicKey({
+  key: { kty: 'OKP', crv: 'Ed25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk',
+});
+const privateKeyObject = (seed, raw) => crypto.createPrivateKey({
+  key: {
+    kty: 'OKP', crv: 'Ed25519',
+    d: Buffer.from(seed).toString('base64url'),
+    x: Buffer.from(raw).toString('base64url'),
+  },
+  format: 'jwk',
+});
+
+/** De publieke sleutel bij een zaad van 32 bytes. */
+export function publicKeyFromSeed(seed) {
+  // Node leidt de publieke sleutel af als je alleen `d` geeft via PKCS#8.
+  const pkcs8 = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), Buffer.from(seed)]);
+  const priv = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
+  const jwk = crypto.createPublicKey(priv).export({ format: 'jwk' });
+  return Buffer.from(jwk.x, 'base64url');
+}
+
+// ── eddsa-jcs-2022 ────────────────────────────────────────────────────
+
+const sha256 = (s) => crypto.createHash('sha256').update(s, 'utf8').digest();
+
+/** De 64 bytes die ondertekend worden: hash(proofConfig) || hash(document). */
+export function hashData(proofConfig, unsecured) {
+  return Buffer.concat([sha256(jcs(proofConfig)), sha256(jcs(unsecured))]);
+}
+
+/**
+ * Onderteken een document (vc-di-eddsa 3.3.1). De server doet dit nooit voor een
+ * identiteitsbewijs; het staat hier voor de toetsen en om de andere kant te
+ * kunnen nabootsen.
+ */
+export function signEddsaJcs2022(unsecured, seed, options) {
+  const raw = publicKeyFromSeed(seed);
+  const proofConfig = { ...options, type: 'DataIntegrityProof', cryptosuite: 'eddsa-jcs-2022' };
+  if (unsecured['@context'] !== undefined) proofConfig['@context'] = unsecured['@context'];
+  const sig = crypto.sign(null, hashData(proofConfig, unsecured), privateKeyObject(seed, raw));
+  return { ...unsecured, proof: { ...proofConfig, proofValue: `z${base58Encode(sig)}` } };
+}
+
+/**
+ * Controleer een ondertekend document (vc-di-eddsa 3.3.2) met een ruwe publieke
+ * sleutel. Geeft true of false; nooit een uitzondering voor kwade invoer.
+ */
+export function verifyEddsaJcs2022(secured, publicKeyRaw) {
+  try {
+    if (!secured || typeof secured !== 'object' || Array.isArray(secured)) return false;
+    const { proof, ...unsecured } = secured;
+    if (!proof || typeof proof !== 'object') return false;
+    if (proof.type !== 'DataIntegrityProof' || proof.cryptosuite !== 'eddsa-jcs-2022') return false;
+    const { proofValue, ...proofOptions } = proof;
+    if (typeof proofValue !== 'string' || proofValue[0] !== 'z') return false;
+    // "If proofOptions.@context exists": het document moet met precies die
+    // contexten beginnen, in die volgorde, en krijgt ze dan voor het hashen.
+    if (proofOptions['@context'] !== undefined) {
+      const pc = [].concat(proofOptions['@context']);
+      const dc = [].concat(unsecured['@context'] ?? []);
+      if (pc.some((c, i) => jcs(c) !== jcs(dc[i]))) return false;
+      unsecured['@context'] = proofOptions['@context'];
+    }
+    const sig = base58Decode(proofValue.slice(1));
+    if (!sig || sig.length !== 64) return false;
+    return crypto.verify(null, hashData(proofOptions, unsecured), publicKeyObject(publicKeyRaw), sig);
+  } catch {
+    return false;
+  }
+}
+
+// ── FEP-c390: de verklaring ──────────────────────────────────────────
+
+/** Groter dan dit is geen identiteitsverklaring meer maar iets anders. */
+export const MAX_STATEMENT_BYTES = 4096;
+
+/**
+ * Is dit een geldige verklaring voor deze actor? Geeft `{ ok: true, subject }`
+ * of `{ ok: false, error }`. De volgorde van de controles is de volgorde van
+ * FEP-c390, en elke weigering zegt welke regel het was.
+ */
+export function checkStatement(stmt, actorId) {
+  if (!stmt || typeof stmt !== 'object' || Array.isArray(stmt)) return { ok: false, error: 'not_an_object' };
+  let grootte = 0;
+  try { grootte = Buffer.byteLength(JSON.stringify(stmt), 'utf8'); } catch { return { ok: false, error: 'not_json' }; }
+  if (grootte > MAX_STATEMENT_BYTES) return { ok: false, error: 'too_large' };
+  if (stmt.type !== 'VerifiableIdentityStatement') return { ok: false, error: 'wrong_type' };
+  if (stmt.alsoKnownAs !== actorId) return { ok: false, error: 'alsoKnownAs_is_not_this_actor' };
+  const raw = publicKeyFromDidKey(stmt.subject);
+  if (!raw) return { ok: false, error: 'subject_is_not_an_ed25519_did_key' };
+  const proof = stmt.proof;
+  if (!proof || typeof proof !== 'object') return { ok: false, error: 'no_proof' };
+  // c390: "verificationMethod MUST match subject". Precies, zonder #-fragment.
+  if (proof.verificationMethod !== stmt.subject) return { ok: false, error: 'verificationMethod_is_not_subject' };
+  if (proof.proofPurpose !== 'assertionMethod') return { ok: false, error: 'wrong_proofPurpose' };
+  if (!verifyEddsaJcs2022(stmt, raw)) return { ok: false, error: 'bad_signature' };
+  return { ok: true, subject: stmt.subject };
+}
+
+/** De contexten die een verklaring zelf draagt. Zo hoeft de actor er geen bij,
+ *  waar did/v1 en data-integrity/v1 met hun beschermde termen met Klonkts eigen
+ *  inline context zouden kunnen botsen. */
+export const STATEMENT_CONTEXT = [
+  'https://www.w3.org/ns/activitystreams',
+  'https://www.w3.org/ns/did/v1',
+  'https://w3id.org/security/data-integrity/v1',
+  'https://w3id.org/fep/c390',
+];
Index: test/activitypub-as2.test.js
===================================================================
--- test/activitypub-as2.test.js	(revision 61d52ede440cc84be8e3617e1cc0851eb3a9ba31)
+++ test/activitypub-as2.test.js	(revision b1d5f3fe649d958099ec7a8e2ff39b44708ad824)
@@ -58,4 +58,8 @@
   // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
   'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
+  // FEP-c390 (shaer-jpxj): waar de eigenaar een identiteitsbewijs aanbiedt. De
+  // verklaringen zelf staan onder `attachment` en dragen hun eigen @context
+  // (did/v1, data-integrity/v1, fep/c390), dus hun termen horen niet hier.
+  'shaer:identityProofs',
   'publicKey', 'owner', 'publicKeyPem',
   'Note', 'Person', 'Create', 'Update', 'Delete', 'Tombstone', 'Announce', 'Like', 'Follow',
Index: test/identity-proofs.test.js
===================================================================
--- test/identity-proofs.test.js	(revision b1d5f3fe649d958099ec7a8e2ff39b44708ad824)
+++ test/identity-proofs.test.js	(revision b1d5f3fe649d958099ec7a8e2ff39b44708ad824)
@@ -0,0 +1,152 @@
+// Identiteitsbewijzen (FEP-c390, shaer-jpxj): een sleutel van elders zegt dat
+// dit account van hem is; Klonkt controleert en publiceert, en ondertekent zelf
+// niets.
+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://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const I = await import('../src/services/IdentityProof.js');
+
+// ── De W3C-testvector (vc-di-eddsa, B.3) ──────────────────────────
+//
+// Ed25519 is deterministisch, dus dezelfde sleutel en hetzelfde document moeten
+// byte voor byte dezelfde handtekening geven. Is dat zo, dan kloppen JCS, de
+// hashes en de volgorde ervan allemaal -- niet alleen met zichzelf.
+const W3C_SECRET = 'z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq';
+const W3C_DID = 'did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2';
+const W3C_DOC = {
+  '@context': ['https://www.w3.org/ns/credentials/v2', 'https://www.w3.org/ns/credentials/examples/v2'],
+  id: 'urn:uuid:58172aac-d8ba-11ed-83dd-0b3aef56cc33',
+  type: ['VerifiableCredential', 'AlumniCredential'],
+  name: 'Alumni Credential',
+  description: 'A minimum viable example of an Alumni Credential.',
+  issuer: 'https://vc.example/issuers/5678',
+  validFrom: '2023-01-01T00:00:00Z',
+  credentialSubject: { id: 'did:example:abcdefgh', alumniOf: 'The School of Examples' },
+};
+const seed = I.seedFromSecretKeyMultibase(W3C_SECRET);
+
+test('W3C: de sleutel geeft de did:key uit de specificatie', () => {
+  assert.equal(I.didKeyFromPublicKey(I.publicKeyFromSeed(seed)), W3C_DID);
+});
+
+test('W3C: de canonieke vorm en zijn hash zijn die uit voorbeeld 31 en 32', () => {
+  const canon = I.jcs(W3C_DOC);
+  assert.equal(canon, '{"@context":["https://www.w3.org/ns/credentials/v2","https://www.w3.org/ns/credentials/examples/v2"],"credentialSubject":{"alumniOf":"The School of Examples","id":"did:example:abcdefgh"},"description":"A minimum viable example of an Alumni Credential.","id":"urn:uuid:58172aac-d8ba-11ed-83dd-0b3aef56cc33","issuer":"https://vc.example/issuers/5678","name":"Alumni Credential","type":["VerifiableCredential","AlumniCredential"],"validFrom":"2023-01-01T00:00:00Z"}');
+  assert.equal(crypto.createHash('sha256').update(canon).digest('hex'), '59b7cb6251b8991add1ce0bc83107e3db9dbbab5bd2c28f687db1a03abc92f19');
+});
+
+test('W3C: de handtekening is byte voor byte die uit voorbeeld 38, en klopt', () => {
+  const vm = `${W3C_DID}#${W3C_DID.slice('did:key:'.length)}`;
+  const s = I.signEddsaJcs2022(W3C_DOC, seed, { created: '2023-02-24T23:36:38Z', verificationMethod: vm, proofPurpose: 'assertionMethod' });
+  assert.equal(s.proof.proofValue, 'z2HnFSSPPBzR36zdDgK8PbEHeXbR56YF24jwMpt3R1eHXQzJDMWS93FCzpvJpwTWd3GAVFuUfjoJdcnTMuVor51aX');
+  assert.equal(I.verifyEddsaJcs2022(s, I.publicKeyFromDidKey(W3C_DID)), true);
+});
+
+// ── De bouwstenen ─────────────────────────────────────────────────
+
+test('JCS sorteert op UTF-16-code-eenheden, en schrijft getallen zoals ECMAScript', () => {
+  // U+1F600 (😀) is in UTF-16 😀, en dat valt VOOR U+FB01 (ﬁ, ﬁ)
+  // -- op codepunt zou het erna komen. RFC 8785 kiest UTF-16.
+  assert.equal(I.jcs({ 'ﬁ': 1, '😀': 2, a: 3 }), '{"a":3,"😀":2,"ﬁ":1}');
+  assert.equal(I.jcs({ n: 1e21, m: 0.1, z: -0 }), '{"m":0.1,"n":1e+21,"z":0}');
+  assert.throws(() => I.jcs({ x: NaN }));
+});
+
+test('base58 overleeft voorloopnullen en een retour', () => {
+  for (const hex of ['', '00', '0000ff', 'deadbeef', '00'.repeat(5) + '01']) {
+    const b = Buffer.from(hex, 'hex');
+    assert.deepEqual(I.base58Decode(I.base58Encode(b)), b, hex);
+  }
+  assert.equal(I.base58Decode('0OIl'), null, 'tekens buiten het alfabet');
+});
+
+test('alleen een Ed25519-did:key wordt als sleutel gelezen', () => {
+  assert.ok(I.publicKeyFromDidKey(W3C_DID));
+  assert.equal(I.publicKeyFromDidKey('did:key:zDnaerDaTF5BXEavCrfRZEk316dpbLsfPDZ3WJ5hRTPFU2169'), null, 'P-256');
+  assert.equal(I.publicKeyFromDidKey('did:web:example.com'), null);
+  assert.equal(I.publicKeyFromDidKey(`${W3C_DID}#frag`), null);
+});
+
+// ── FEP-c390: de verklaring ───────────────────────────────────────
+
+const ACTOR = 'https://test.example/ap/users/kind';
+function verklaring(extra = {}, { actor = ACTOR, vm = W3C_DID } = {}) {
+  const doc = { '@context': I.STATEMENT_CONTEXT, type: 'VerifiableIdentityStatement', subject: W3C_DID, alsoKnownAs: actor, ...extra };
+  return I.signEddsaJcs2022(doc, seed, { created: '2026-10-24T10:00:00Z', verificationMethod: vm, proofPurpose: 'assertionMethod' });
+}
+
+test('een goede verklaring wordt aangenomen', () => {
+  assert.deepEqual(I.checkStatement(verklaring(), ACTOR), { ok: true, subject: W3C_DID });
+});
+
+test('elke regel van c390 weigert met zijn eigen reden', () => {
+  assert.equal(I.checkStatement(verklaring({}, { actor: 'https://elders.example/users/x' }), ACTOR).error,
+    'alsoKnownAs_is_not_this_actor', 'een verklaring voor een ander account');
+  assert.equal(I.checkStatement(verklaring({}, { vm: `${W3C_DID}#${W3C_DID.slice(8)}` }), ACTOR).error,
+    'verificationMethod_is_not_subject', 'c390 eist precies de subject, zonder fragment');
+  const anders = { ...verklaring(), type: 'Note' };
+  assert.equal(I.checkStatement(anders, ACTOR).error, 'wrong_type');
+  const geknoeid = verklaring(); geknoeid.proof = { ...geknoeid.proof, created: '2030-01-01T00:00:00Z' };
+  assert.equal(I.checkStatement(geknoeid, ACTOR).error, 'bad_signature', 'een gewijzigd veld in de proof');
+  const ander = verklaring(); ander.extra = 'erbij';
+  assert.equal(I.checkStatement(ander, ACTOR).error, 'bad_signature', 'een veld erbij in het document');
+  assert.equal(I.checkStatement({ ...verklaring(), proof: undefined }, ACTOR).error, 'no_proof');
+  assert.equal(I.checkStatement(verklaring({ ballast: 'x'.repeat(5000) }), ACTOR).error, 'too_large');
+});
+
+// ── Door de route en de actor heen ───────────────────────────────
+
+test('POST zet de verklaring op de actor, DELETE haalt hem weg, en alleen de eigenaar', async (t) => {
+  const express = (await import('express')).default;
+  const routes = (await import('../src/routes/activitypub.js')).default;
+  const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@t', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public, is_primary, profile_links) VALUES (?,?,?,?,1,1,?)')
+    .run('s1', 'kind', 'Kind', 'u1', JSON.stringify([{ platform: 'Website', url: 'https://kind.example' }]));
+  const bearer = 'test-token-' + 'c'.repeat(24);
+  db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
+    .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'kind', 'read write');
+
+  const app = express(); app.use(routes);
+  const server = app.listen(0);
+  t.after(() => server.close());
+  const url = `http://127.0.0.1:${server.address().port}/ap/users/kind/identityProofs`;
+  const post = (body, auth = true) => fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: `Bearer ${bearer}` } : {}) },
+    body: JSON.stringify(body),
+  });
+
+  assert.equal((await post(verklaring(), false)).status, 403, 'zonder token');
+  const fout = await post(verklaring({}, { actor: 'https://elders.example/users/x' }));
+  assert.equal(fout.status, 422);
+  assert.equal((await fout.json()).error, 'alsoKnownAs_is_not_this_actor');
+
+  const goed = await post(verklaring());
+  assert.equal(goed.status, 201);
+
+  const site = () => db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
+  const actor = AP.buildActor('https://test.example', site());
+  assert.equal(actor.endpoints['shaer:identityProofs'], 'https://test.example/ap/users/kind/identityProofs');
+  assert.equal(actor.attachment[0].type, 'PropertyValue', 'de profiellink blijft vooraan staan');
+  const bewijs = actor.attachment.find((a) => a.type === 'VerifiableIdentityStatement');
+  assert.ok(bewijs, 'de verklaring staat onder attachment');
+  assert.equal(I.checkStatement(bewijs, actor.id).ok, true, 'en is daar nog steeds na te lopen');
+
+  // Dezelfde sleutel opnieuw vervangt, verdubbelt niet.
+  assert.equal((await post(verklaring())).status, 201);
+  assert.equal(JSON.parse(site().identity_proofs).length, 1);
+
+  const weg = await fetch(`${url}?subject=${encodeURIComponent(W3C_DID)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${bearer}` } });
+  assert.equal(weg.status, 204);
+  assert.equal(site().identity_proofs, null);
+  assert.ok(!AP.buildActor('https://test.example', site()).attachment.some((a) => a.type === 'VerifiableIdentityStatement'));
+});
