Changeset b1d5f3f in Klonkt for src


Ignore:
Timestamp:
10/24/2026 11:48:41 AM (26 hours ago)
Author:
Bart <bart@…>
Branches:
main
Children:
5d0d3f35
Parents:
61d52ed
Message:

Identiteitsbewijzen (FEP-c390): een sleutel van elders zegt dat dit account van hem is

Een Ed25519-sleutel die niet op deze server staat -- in Shaer op papier, alleen
ingetypt als hij nodig is -- ondertekent een VerifiableIdentityStatement:
"did:key:... is ook deze actor". Klonkt ondertekent niets. Hij controleert de
verklaring en zet hem onder attachment op de actor, waar iedereen hem zelf
kan nalopen.

Wat dat bewijst: dat dezelfde entiteit achter sleutel en 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 getekend worden.

eddsa-jcs-2022, omdat Mitra en streams dat controleren. IdentityProof.js is
puur: JCS (RFC 8785, in JavaScript precies een gesorteerde JSON.stringify),
base58btc, did:key en de proof zelf. De controle volgt c390 regel voor regel,
met een eigen reden per weigering; verificationMethod moet PRECIES de subject
zijn, zonder het #-fragment dat de W3C-voorbeelden gebruiken.

Getoetst tegen de W3C-testvectoren (vc-di-eddsa B.3): dezelfde did:key, dezelfde
canonieke vorm, dezelfde hashes en byte voor byte dezelfde handtekening. Een
implementatie die het alleen met zichzelf eens is, bewijst niets.

POST /ap/users/:slug/identityProofs (alleen de eigenaar) controleert en
publiceert, één per did:key, hoogstens drie; DELETE ?subject= haalt hem weg.
Beide sturen een Update van de actor naar de volgers. De actor noemt het
eindpunt als endpoints.shaer:identityProofs. Elke verklaring draagt haar eigen
@context, zodat de actor er geen bij hoeft -- did/v1 en data-integrity/v1 hebben
beschermde termen die op actorniveau met onze inline context konden botsen.

shaer-jpxj
Co-Authored-By: Claude Opus 5.5 <claude@…>

Location:
src
Files:
1 added
3 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r61d52ed rb1d5f3f  
    245245  // kort bericht een halve pagina leegte gaf; nu is het een keuze.
    246246  ensureColumn('sites', 'reader_full_page', 'INTEGER DEFAULT 0');
     247  // Identiteitsbewijzen (FEP-c390, shaer-jpxj): de ondertekende verklaringen,
     248  // als JSON-lijst, precies zoals ze binnenkwamen -- een handtekening hangt aan
     249  // elke byte van de canonieke vorm, dus herschrijven zou hem breken.
     250  ensureColumn('sites', 'identity_proofs', 'TEXT');
    247251  ensureColumn('sites', 'show_search',     'INTEGER DEFAULT 1');
    248252  ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
  • src/routes/activitypub.js

    r61d52ed rb1d5f3f  
    2121import * as Guardianship from '../services/guardianship/index.js';
    2222import * as ConversationKey from '../services/conversation-key.js';
     23import * as IdentityProof from '../services/IdentityProof.js';
    2324import * as Migration from '../services/MigrationService.js';
    2425import { getPrimarySite } from '../middleware/site.js';
    … …  
    10291030  },
    10301031});
     1032// ── Identiteitsbewijzen (FEP-c390, shaer-jpxj) ────────────────────
     1033//
     1034// De eigenaar biedt een verklaring aan die met een sleutel van ELDERS is
     1035// ondertekend: "did:key:... is ook deze actor". Deze server ondertekent niets;
     1036// hij controleert (IdentityProof.checkStatement) en publiceert onder
     1037// `attachment` op de actor, waar iedereen hem zelf kan nalopen.
     1038//
     1039// Eén per did:key: een nieuwe verklaring met dezelfde sleutel vervangt de
     1040// vorige, want een verhuizing of een vernieuwde datum is dezelfde bewering.
     1041// Een handvol is genoeg; meer is geen identiteit meer maar een verzameling.
     1042const MAX_IDENTITY_PROOFS = 3;
     1043function readProofs(site) {
     1044  try { const a = JSON.parse(site.identity_proofs || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
     1045}
     1046function writeProofs(site, list) {
     1047  db.prepare('UPDATE sites SET identity_proofs = ? WHERE id = ?').run(list.length ? JSON.stringify(list) : null, site.id);
     1048  // De volgers horen dat de actor veranderde; anders blijft hun kopie de oude.
     1049  const vers = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
     1050  if (vers) AP.deliverActorUpdate(vers).catch((e) => console.warn('[c390] actor-update:', e && e.message));
     1051}
     1052router.post('/ap/users/:slug/identityProofs', express.json({ limit: '16kb', type: ['application/json', 'application/activity+json', 'application/ld+json'] }), (req, res) => {
     1053  const auth = OAuth.verifyBearer(req.headers.authorization);
     1054  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     1055  const base = baseUrl(req);
     1056  const me = AP.actorId(base, auth.site.slug);
     1057  const stmt = req.body;
     1058  const uitslag = IdentityProof.checkStatement(stmt, me);
     1059  if (!uitslag.ok) return res.status(422).json({ error: uitslag.error });
     1060  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(auth.site.id);
     1061  const rest = readProofs(site).filter((b) => b && b.subject !== uitslag.subject);
     1062  if (rest.length >= MAX_IDENTITY_PROOFS) return res.status(409).json({ error: 'too_many_identity_proofs', max: MAX_IDENTITY_PROOFS });
     1063  writeProofs(site, [...rest, stmt]);
     1064  res.status(201).json({ subject: uitslag.subject, alsoKnownAs: me });
     1065});
     1066router.delete('/ap/users/:slug/identityProofs', (req, res) => {
     1067  const auth = OAuth.verifyBearer(req.headers.authorization);
     1068  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     1069  const subject = String(req.query.subject || '');
     1070  if (!IdentityProof.publicKeyFromDidKey(subject)) return res.status(400).json({ error: 'subject must be an Ed25519 did:key' });
     1071  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(auth.site.id);
     1072  const voor = readProofs(site);
     1073  const na = voor.filter((b) => b && b.subject !== subject);
     1074  if (na.length === voor.length) return res.status(404).json({ error: 'no_such_identity_proof' });
     1075  writeProofs(site, na);
     1076  res.status(204).end();
     1077});
     1078
    10311079router.post('/ap/users/:slug/uploadMedia', (req, res) => {
    10321080  const auth = OAuth.verifyBearer(req.headers.authorization);
  • src/services/ActivityPubService.js

    r61d52ed rb1d5f3f  
    393393      oauthTokenEndpoint: `${base}/oauth/token`,
    394394      uploadMedia: `${id}/uploadMedia`,
     395      // FEP-c390 (shaer-jpxj): waar de eigenaar een identiteitsbewijs aanbiedt.
     396      // Alleen met zijn token; Shaer vindt het hier in plaats van het te raden.
     397      'shaer:identityProofs': `${id}/identityProofs`,
    395398    },
    396399    publicKey: {
    … …  
    449452    }
    450453  } catch { /* skip malformed profile_links */ }
     454  // FEP-c390: de identiteitsbewijzen, ACHTER de profiellinks. Ongewijzigd uit de
     455  // database: ze werden bij binnenkomst gecontroleerd, en een handtekening hangt
     456  // aan elke byte. Elke verklaring draagt haar eigen @context, dus de actor zelf
     457  // hoeft er geen bij.
     458  try {
     459    const bewijzen = JSON.parse(site.identity_proofs || '[]');
     460    if (Array.isArray(bewijzen) && bewijzen.length) {
     461      actor.attachment = [...(actor.attachment || []), ...bewijzen.filter((b) => b && b.type === 'VerifiableIdentityStatement')];
     462    }
     463  } catch { /* skip malformed identity_proofs */ }
    451464  return actor;
    452465}
Note: See TracChangeset for help on using the changeset viewer.