Changeset f63cbc2 in Klonkt


Ignore:
Timestamp:
06/16/2026 06:33:38 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
4fee228
Parents:
1b4d5dd
Message:

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@…>

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/federation.js

    r1b4d5dd rf63cbc2  
    88
    99import express from 'express';
    10 import { buildActor, buildOutbox, signBody } from '../services/CircleFederation.js';
     10import { buildActor, buildOutbox, signBody, KLONKT_PROTO, MIN_PROTO } from '../services/CircleFederation.js';
    1111import { getTenancy } from '../services/SettingsService.js';
    1212
     
    1818}
    1919
     20// De proto die de consument zegt te draaien (uit z'n request-header), of 0.
     21function consumerProto(req) {
     22  return parseInt(req.get('Klonkt-Proto') || '0', 10) || 0;
     23}
     24
    2025router.get('/.klonkt/actor.json', (req, res) => {
    2126  // Cirkels = solo-naar-solo; hubs publiceren geen federatie-actor.
    2227  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
     28  // De actor serveren we ALTIJD (ook aan oudere consumenten) zodat zij onze proto
     29  // kunnen lezen en een nette "update vereist"-melding kunnen tonen.
    2330  const body = JSON.stringify(buildActor(baseUrl(req)), null, 2);
    2431  res.type('application/activity+json; charset=utf-8');
     32  res.set('Klonkt-Proto', String(KLONKT_PROTO));
    2533  res.set('Cache-Control', 'public, max-age=300');
    2634  res.send(body);
     
    2937router.get('/.klonkt/outbox.json', (req, res) => {
    3038  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
     39  res.set('Klonkt-Proto', String(KLONKT_PROTO));
     40  // Te-oude consument? Weiger met 426 Upgrade Required (de crypto-binding sluit 'm
     41  // sowieso al uit; dit geeft een expliciet, leesbaar signaal). proto 0 = geen
     42  // header (bv. een browser/curl) → toestaan, die verifieert toch niet.
     43  const cp = consumerProto(req);
     44  if (cp && cp < MIN_PROTO) {
     45    return res.status(426).type('text/plain')
     46      .send(`Upgrade Required: deze cirkel draait proto ${KLONKT_PROTO}; jouw Klonkt (proto ${cp}) is te oud.`);
     47  }
    3148  const body = JSON.stringify(buildOutbox(baseUrl(req)), null, 2);
    3249  res.type('application/activity+json; charset=utf-8');
  • src/services/CircleFederation.js

    r1b4d5dd rf63cbc2  
    1515import db from '../config/database.js';
    1616import { getSetting, setSetting } from './SettingsService.js';
     17
     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}
    1732
    1833// ── Sleutelbeheer ─────────────────────────────────────────────
     
    3752}
    3853
    39 /** Tekent een exacte body-string met de instance-privésleutel (Ed25519). */
    40 export function signBody(rawString) {
     54/** Tekent een body-string, gebonden aan de protocol-versie (Ed25519). */
     55export function signBody(rawString, proto = KLONKT_PROTO) {
    4156  const key = crypto.createPrivateKey(getKeys().priv);
    42   return crypto.sign(null, Buffer.from(rawString, 'utf8'), key).toString('base64');
     57  return crypto.sign(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key).toString('base64');
    4358}
    4459
    45 /** Verifieert een body tegen een SPKI-DER-base64 publieke sleutel (voor sync/tests). */
    46 export function verifyBody(rawString, sigB64, pubDerB64) {
     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) {
    4763  try {
    4864    const key = crypto.createPublicKey({
    4965      key: Buffer.from(pubDerB64, 'base64'), format: 'der', type: 'spki',
    5066    });
    51     return crypto.verify(null, Buffer.from(rawString, 'utf8'), key, Buffer.from(sigB64, 'base64'));
     67    return crypto.verify(null, Buffer.from(signingInput(proto, rawString), 'utf8'), key, Buffer.from(sigB64, 'base64'));
    5268  } catch {
    5369    return false;
     
    101117      publicKeyBase64: getPublicKeyB64(),
    102118    },
    103     klonkt: { version: 1, allowCircle: allowsCircle(site) },
     119    klonkt: { version: 1, proto: KLONKT_PROTO, allowCircle: allowsCircle(site) },
    104120  };
    105121}
     
    111127  const empty = {
    112128    '@context': 'https://www.w3.org/ns/activitystreams',
    113     type: 'OrderedCollection', id, totalItems: 0, orderedItems: [],
     129    type: 'OrderedCollection', id, totalItems: 0, orderedItems: [], klonkt: { proto: KLONKT_PROTO },
    114130  };
    115131  if (!allowsCircle(site)) return empty;
     
    148164    '@context': 'https://www.w3.org/ns/activitystreams',
    149165    type: 'OrderedCollection', id, totalItems: orderedItems.length, orderedItems,
     166    klonkt: { proto: KLONKT_PROTO },
    150167  };
    151168}
  • src/services/CircleService.js

    r1b4d5dd rf63cbc2  
    66
    77import db from '../config/database.js';
    8 import { verifyBody } from './CircleFederation.js';
     8import { verifyBody, KLONKT_PROTO, MIN_PROTO } from './CircleFederation.js';
    99import { getTenancy } from './SettingsService.js';
    1010
     
    2525function baseOf(remoteUrl) {
    2626  return String(remoteUrl).replace(/\/+$/, '');
     27}
     28
     29// Bron buiten de cirkel zetten met een leesbare reden (geen stille mislukking).
     30// Aparte status 'outdated' zodat de Beheer-UI er een nette "update vereist"-
     31// melding van kan maken i.p.v. een generieke fout.
     32function markOutdated(link, msg) {
     33  db.prepare("UPDATE circle_links SET status='outdated', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
     34    .run(String(msg).slice(0, 300), link.id);
     35  return { ok: false, outdated: true, link: link.remote_url, error: msg };
    2736}
    2837
     
    3645      signal: ac.signal,
    3746      redirect: 'follow',
    38       headers: { Accept: 'application/activity+json, application/json' },
     47      headers: {
     48        Accept: 'application/activity+json, application/json',
     49        // Vertel de publisher onze proto → die kan ons met 426 weren als we te oud zijn.
     50        'Klonkt-Proto': String(KLONKT_PROTO),
     51      },
    3952    });
    4053    if (!res.ok) throw new Error(`HTTP ${res.status}`);
     
    8497  if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL');
    8598
     99  // Protocol-versie-gate. De proto zit óók in de outbox-handtekening-grondslag,
     100  // dus liegen in de (ongetekende) actor helpt niet: bij een echte mismatch faalt
     101  // de verificatie verderop alsnog. Hier vooral voor een DUIDELIJKE melding +
     102  // buitensluiten zonder stille mislukking.
     103  const remoteProto = Number(actor.klonkt && actor.klonkt.proto) || 1;
     104  if (remoteProto > KLONKT_PROTO) {
     105    return markOutdated(link,
     106      `Jouw Klonkt (proto ${KLONKT_PROTO}) is ouder dan ${base} (proto ${remoteProto}). Werk je eigen instance bij om te blijven federeren.`);
     107  }
     108  if (remoteProto < MIN_PROTO) {
     109    return markOutdated(link,
     110      `${base} draait een oudere Klonkt (proto ${remoteProto}; minimaal ${MIN_PROTO} vereist). Vraag ze te updaten.`);
     111  }
     112
    86113  // TOFU: een sleutelwissel vereist expliciete herbevestiging (anti-hijack)
    87114  const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId);
     
    104131  const sigHeader = o.headers.get('klonkt-signature') || '';
    105132  const sig = (sigHeader.match(/ed25519=(.+)\s*$/) || [])[1];
    106   if (!sig || !verifyBody(o.text, sig, pubKey)) {
     133  if (!sig || !verifyBody(o.text, sig, pubKey, remoteProto)) {
    107134    throw new Error('outbox-handtekening ongeldig of ontbreekt');
    108135  }
Note: See TracChangeset for help on using the changeset viewer.