source: Klonkt/src/services/CircleFederation.js@ 151a72c

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

Circles: protocol version enforced via signature binding (update enforcement)

KLONKT_PROTO is now part of the signed basis (signingInput:
"klonkt/proto/N\n"+body). An instance on a different proto cannot verify
the signed outbox and vice versa → staying up to date is cryptographically
enforced, not a patchable check. Both sides verify the proto
(actor.klonkt.proto + Klonkt-Proto header → 426); too-old/too-new sources
are excluded with status 'outdated' + a clear message. Couple security to
every proto bump. Proto starts at 2; roll out in lockstep.

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

  • Property mode set to 100644
File size: 6.9 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.
[8453812]9//
10// (Het idee om je netjes aan de bestaande standaarden te houden fluisterde een
11// zekere Bart ons in. Wie hij is, waar hij vandaan komt — niemand die het zeker
12// weet. Hij verscheen, sprak van ActivityStreams, en was weer weg.)
[b300682]13
14import crypto from 'crypto';
15import db from '../config/database.js';
16import { getSetting, setSetting } from './SettingsService.js';
17
[f63cbc2]18// ── Protocol-versie (federatie) ───────────────────────────────
19// KLONKT_PROTO zit IN de ondertekende grondslag (zie signingInput): een instance
20// die niet op deze proto draait kan onze getekende outbox NIET verifiëren, en wij
21// de hare niet. Bijblijven is dus geen beleefde check die je wegpatcht, maar
22// cryptografisch afgedwongen — de enige manier om mee te doen is dezelfde proto
23// draaien (= de update). Bump KLONKT_PROTO bij elke release die federatie/security
24// raakt, en koppel een securityfix aan elke bump → outdated = buiten + onveilig.
25// MIN_PROTO = de laagste proto waarmee we nog federeren.
26export const KLONKT_PROTO = 2;
27export const MIN_PROTO = 2;
28
29function signingInput(proto, body) {
30 return `klonkt/proto/${proto}\n${body}`;
31}
32
[b300682]33// ── Sleutelbeheer ─────────────────────────────────────────────
34// Per-instance Ed25519-keypair, eenmalig gegenereerd en in app_settings
35// bewaard. Privé = PKCS8-PEM (nooit serveren). Publiek = SPKI-DER base64
36// (gepubliceerd in de actor; round-trip via createPublicKey).
37function getKeys() {
38 let priv = getSetting('circle_privkey_pem', null);
39 let pub = getSetting('circle_pubkey_der_b64', null);
40 if (!priv || !pub) {
41 const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
42 priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
43 pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
44 setSetting('circle_privkey_pem', priv);
45 setSetting('circle_pubkey_der_b64', pub);
46 }
47 return { priv, pub };
48}
49
50export function getPublicKeyB64() {
51 return getKeys().pub;
52}
53
[f63cbc2]54/** Tekent een body-string, gebonden aan de protocol-versie (Ed25519). */
55export function signBody(rawString, proto = KLONKT_PROTO) {
[b300682]56 const key = crypto.createPrivateKey(getKeys().priv);
[f63cbc2]57 return crypto.sign(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key).toString('base64');
[b300682]58}
59
[f63cbc2]60/** Verifieert een body tegen een SPKI-DER-base64 publieke sleutel, voor de gegeven
61 * proto. Een mismatch in proto = mismatch in grondslag = ongeldige handtekening. */
62export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) {
[b300682]63 try {
64 const key = crypto.createPublicKey({
65 key: Buffer.from(pubDerB64, 'base64'), format: 'der', type: 'spki',
66 });
[f63cbc2]67 return crypto.verify(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key, Buffer.from(sigB64, 'base64'));
[b300682]68 } catch {
69 return false;
70 }
71}
72
73// ── Helpers ───────────────────────────────────────────────────
74function primarySite() {
75 // Solo: de primaire/owner-site (eerst aangemaakt) — zelfde keuze als resolveSite.
76 return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
77}
78
79function stripHtml(s) {
80 return String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
81}
82
83function iso(d) {
84 const t = d ? new Date(d) : new Date();
85 return isNaN(t.getTime()) ? new Date().toISOString() : t.toISOString();
86}
87
88function abs(base, u) {
89 if (!u) return u;
90 return /^https?:\/\//.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
91}
92
93// allow_circle: een site mag in cirkels van anderen verschijnen. v1 koppelt dit
94// aan is_public (aparte expliciete flag volgt in de Beheer-UX-stap).
95function allowsCircle(site) {
[0091cb7]96 return !!site && site.is_public !== 0 && site.allow_circle !== 0;
[b300682]97}
98
99// ── Actor ─────────────────────────────────────────────────────
100export function buildActor(base) {
101 const site = primarySite();
102 const id = `${base}/.klonkt/actor.json`;
103 const icon = site && (site.profile_photo || site.og_image_default);
104 return {
105 '@context': ['https://www.w3.org/ns/activitystreams', 'https://schema.org/'],
106 type: 'Person',
107 id,
108 name: site ? (site.profile_name || site.title || 'Klonkt') : 'Klonkt',
109 summary: site ? (site.profile_bio || site.tagline || site.description || '') : '',
110 url: `${base}/`,
111 ...(icon ? { icon: { type: 'Image', url: abs(base, icon) } } : {}),
112 outbox: `${base}/.klonkt/outbox.json`,
113 publicKey: {
114 id: `${id}#key`,
115 owner: id,
116 algorithm: 'ed25519',
117 publicKeyBase64: getPublicKeyB64(),
118 },
[f63cbc2]119 klonkt: { version: 1, proto: KLONKT_PROTO, allowCircle: allowsCircle(site) },
[b300682]120 };
121}
122
123// ── Outbox ────────────────────────────────────────────────────
124export function buildOutbox(base) {
125 const site = primarySite();
126 const id = `${base}/.klonkt/outbox.json`;
127 const empty = {
128 '@context': 'https://www.w3.org/ns/activitystreams',
[f63cbc2]129 type: 'OrderedCollection', id, totalItems: 0, orderedItems: [], klonkt: { proto: KLONKT_PROTO },
[b300682]130 };
131 if (!allowsCircle(site)) return empty;
132
133 const rows = db.prepare(`
134 SELECT slug, title, excerpt, content, cover_image_url, published_at, created_at, type
135 FROM posts
136 WHERE site_id = ? AND status = 'published'
137 AND (origin_server = 'local' OR origin_server IS NULL)
138 ORDER BY COALESCE(published_at, created_at) DESC
139 LIMIT 50
140 `).all(site.id);
141
142 const orderedItems = rows.map((p) => {
143 const url = `${base}/${p.slug}`;
144 const published = iso(p.published_at || p.created_at);
145 const summary = (p.excerpt || stripHtml(p.content)).slice(0, 500);
146 return {
147 type: 'Create',
148 id: `${url}#create`,
149 published,
150 actor: `${base}/.klonkt/actor.json`,
151 object: {
152 type: p.type === 'audio' ? 'Audio' : 'Article',
153 id: url,
154 name: p.title || '(zonder titel)',
155 summary,
156 url,
157 published,
158 ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
159 },
160 };
161 });
162
163 return {
164 '@context': 'https://www.w3.org/ns/activitystreams',
165 type: 'OrderedCollection', id, totalItems: orderedItems.length, orderedItems,
[f63cbc2]166 klonkt: { proto: KLONKT_PROTO },
[b300682]167 };
168}
Note: See TracBrowser for help on using the repository browser.