source: Klonkt/src/services/CircleFederation.js@ b300682

main
Last change on this file since b300682 was b300682, checked in by roboburr <roboburr@…>, 3 months ago

Circles v1 — foundation: migration + signed own publication

Steps 1+2 of the Circles federation (3rd tenancy mode alongside solo/hub):

  • DB: circle_links / remote_actors / remote_posts (idempotent in initializeDatabase)
  • SettingsService: tenancy now accepts 'circle'
  • CircleFederation.js: per-instance Ed25519 keypair (app_settings) + actor/outbox builders + sign/verify (SPKI-DER pubkey, signature over raw body)
  • routes/federation.js: GET /.klonkt/actor.json + signed /.klonkt/outbox.json, mounted in server.js before resolveSite
  • docs/cirkels-v1-spec.md: full v1 spec

Still to do: CircleService.sync (pull+verify), Admin UI, /cirkel feed, hardening.
Ed25519 sign/verify round-trip verified in isolation; syntax clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 5.6 KB
RevLine 
[b300682]1// CircleFederation.js — eigen publicatie-kant van "Cirkels" (v1).
2//
3// Publiceert deze instance als een ActivityStreams-actor met een Ed25519-
4// sleutel, plus een outbox van publieke posts. De outbox wordt getekend zodat
5// consumenten (andere Klonkt-instances) de herkomst kunnen verifiëren.
6//
7// v1 = alleen PUBLICEREN + tekenen. Het pullen/verifiëren van remote cirkels
8// (CircleService.sync) komt in een volgende stap. Zie docs/cirkels-v1-spec.md.
9
10import crypto from 'crypto';
11import db from '../config/database.js';
12import { getSetting, setSetting } from './SettingsService.js';
13
14// ── Sleutelbeheer ─────────────────────────────────────────────
15// Per-instance Ed25519-keypair, eenmalig gegenereerd en in app_settings
16// bewaard. Privé = PKCS8-PEM (nooit serveren). Publiek = SPKI-DER base64
17// (gepubliceerd in de actor; round-trip via createPublicKey).
18function getKeys() {
19 let priv = getSetting('circle_privkey_pem', null);
20 let pub = getSetting('circle_pubkey_der_b64', null);
21 if (!priv || !pub) {
22 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
23 priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
24 pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
25 setSetting('circle_privkey_pem', priv);
26 setSetting('circle_pubkey_der_b64', pub);
27 }
28 return { priv, pub };
29}
30
31export function getPublicKeyB64() {
32 return getKeys().pub;
33}
34
35/** Tekent een exacte body-string met de instance-privésleutel (Ed25519). */
36export function signBody(rawString) {
37 const key = crypto.createPrivateKey(getKeys().priv);
38 return crypto.sign(null, Buffer.from(rawString, 'utf8'), key).toString('base64');
39}
40
41/** Verifieert een body tegen een SPKI-DER-base64 publieke sleutel (voor sync/tests). */
42export function verifyBody(rawString, sigB64, pubDerB64) {
43 try {
44 const key = crypto.createPublicKey({
45 key: Buffer.from(pubDerB64, 'base64'), format: 'der', type: 'spki',
46 });
47 return crypto.verify(null, Buffer.from(rawString, 'utf8'), key, Buffer.from(sigB64, 'base64'));
48 } catch {
49 return false;
50 }
51}
52
53// ── Helpers ───────────────────────────────────────────────────
54function primarySite() {
55 // Solo: de primaire/owner-site (eerst aangemaakt) — zelfde keuze als resolveSite.
56 return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
57}
58
59function stripHtml(s) {
60 return String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
61}
62
63function iso(d) {
64 const t = d ? new Date(d) : new Date();
65 return isNaN(t.getTime()) ? new Date().toISOString() : t.toISOString();
66}
67
68function abs(base, u) {
69 if (!u) return u;
70 return /^https?:\/\//.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
71}
72
73// allow_circle: een site mag in cirkels van anderen verschijnen. v1 koppelt dit
74// aan is_public (aparte expliciete flag volgt in de Beheer-UX-stap).
75function allowsCircle(site) {
76 return !!site && site.is_public !== 0;
77}
78
79// ── Actor ─────────────────────────────────────────────────────
80export function buildActor(base) {
81 const site = primarySite();
82 const id = `${base}/.klonkt/actor.json`;
83 const icon = site && (site.profile_photo || site.og_image_default);
84 return {
85 '@context': ['https://www.w3.org/ns/activitystreams', 'https://schema.org/'],
86 type: 'Person',
87 id,
88 name: site ? (site.profile_name || site.title || 'Klonkt') : 'Klonkt',
89 summary: site ? (site.profile_bio || site.tagline || site.description || '') : '',
90 url: `${base}/`,
91 ...(icon ? { icon: { type: 'Image', url: abs(base, icon) } } : {}),
92 outbox: `${base}/.klonkt/outbox.json`,
93 publicKey: {
94 id: `${id}#key`,
95 owner: id,
96 algorithm: 'ed25519',
97 publicKeyBase64: getPublicKeyB64(),
98 },
99 klonkt: { version: 1, allowCircle: allowsCircle(site) },
100 };
101}
102
103// ── Outbox ────────────────────────────────────────────────────
104export function buildOutbox(base) {
105 const site = primarySite();
106 const id = `${base}/.klonkt/outbox.json`;
107 const empty = {
108 '@context': 'https://www.w3.org/ns/activitystreams',
109 type: 'OrderedCollection', id, totalItems: 0, orderedItems: [],
110 };
111 if (!allowsCircle(site)) return empty;
112
113 const rows = db.prepare(`
114 SELECT slug, title, excerpt, content, cover_image_url, published_at, created_at, type
115 FROM posts
116 WHERE site_id = ? AND status = 'published'
117 AND (origin_server = 'local' OR origin_server IS NULL)
118 ORDER BY COALESCE(published_at, created_at) DESC
119 LIMIT 50
120 `).all(site.id);
121
122 const orderedItems = rows.map((p) => {
123 const url = `${base}/${p.slug}`;
124 const published = iso(p.published_at || p.created_at);
125 const summary = (p.excerpt || stripHtml(p.content)).slice(0, 500);
126 return {
127 type: 'Create',
128 id: `${url}#create`,
129 published,
130 actor: `${base}/.klonkt/actor.json`,
131 object: {
132 type: p.type === 'audio' ? 'Audio' : 'Article',
133 id: url,
134 name: p.title || '(zonder titel)',
135 summary,
136 url,
137 published,
138 ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
139 },
140 };
141 });
142
143 return {
144 '@context': 'https://www.w3.org/ns/activitystreams',
145 type: 'OrderedCollection', id, totalItems: orderedItems.length, orderedItems,
146 };
147}
Note: See TracBrowser for help on using the repository browser.