Changeset af2cc73 in Klonkt for src


Ignore:
Timestamp:
08/18/2026 06:50:08 PM (3 weeks ago)
Author:
Bart <bart@…>
Branches:
main
Children:
d85b66f
Parents:
9da8027
git-author:
Bart <bart@…> (08/18/2026 06:05:22 PM)
git-committer:
Bart <bart@…> (08/18/2026 06:50:08 PM)
Message:

Gastlogin via OpenWebAuth: een fan is een volger, geen accounthouder

fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort vroeg
om een KLONKT-ACCOUNT. Dat is de verkeerde vraag, en hij sloot precies de mensen
buiten voor wie de poort openstond. Nu kan een bezoeker bij zijn EIGEN server
bewijzen dat hij @iemand@ergens is (FEP-61cf), en volgt hij deze site, dan is
hij binnen. Geen account hier, geen wachtwoord hier, geen cookie van een derde.

Wij zijn alleen de TARGET instance. Dat is de prettige helft: de home instance
heeft prive-sleutels nodig, wij alleen publieke. Er staat hier dus geen geheim
van iemand anders. De /magic-kant (Klonkt-gebruikers laten inloggen OP andere
sites) is bewust niet gebouwd -- andere functie.

De handtekening-verificatie is NIET opnieuw geschreven: AP.verifyRequest() doet
dit al voor de inbox, inclusief het vastpinnen van de sleutel op de herkomst van
de actor, een replay-venster en een verplichte digest. Een tweede implementatie
van "is deze aanvraag echt van wie hij zegt" is precies wat je niet wilt.

De drie aanvallen die de FEP noemt, hebben elk een toets:

  • IMPERSONATIE: ?zid= bepaalt niets, alleen het ingewisselde ?owt= telt. Mallory kan een link maken met zid=bob, maar komt terug met een token dat Mallory zegt.
  • OPEN REDIRECT: het ontdekte endpoint moet dezelfde host hebben als het adres dat de bezoeker intypte.
  • DoS: tokens vervallen in minuten, gaan na een keer gebruiken weg, en elke uitgifte veegt de oude op.

Onderweg gemeten en vastgelegd: PKCS#1 v1.5 GOOIT GEEN FOUT bij een verkeerde
sleutel. OpenSSL 3 doet aan implicit rejection en geeft afgeleide onzin terug,
juist zodat niemand aan het foutgedrag kan aflezen of zijn gok klopte. 200
vreemde sleutels: 0 fouten, 0 keer het token. De toets test dus "er komt iets
anders uit", niet "het knalt" -- anders schrijft de volgende lezer weer een
assert.throws die per ongeluk slaagt.

Webfinger op de eigen wortel wijst een home instance naar /owa/token. Alleen
origin + '/'; een ACTOR-uri met een pad blijft een 400, want dat legt
webfinger-bare-host.test.js vast en die keuze draai ik niet om als bijvangst.

De fanpoort toont nu het adresveld als hoofdweg en de lokale inlog als tweede,
en fgate.sub zegt niet langer "ingelogde vrienden" maar wat de poort werkelijk
vraagt.

1135 toetsen groen (was 1106). End-to-end nagelopen op een KOPIE van de
database: token inwisselen zet de sessie en haalt het token uit de URL, een
bewezen volger krijgt de tekst, een bewezen niet-volger krijgt de poort en geen
byte van de inhoud, en anoniem idem.

Co-Authored-By: Claude Opus 5 <claude@…>

Location:
src
Files:
2 added
7 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r9da8027 raf2cc73  
    427427    );
    428428
     429    -- OpenWebAuth (FEP-61cf): eenmalige tokens waarmee een BEZOEKER van elders
     430    -- bewijst wie hij is. Klonkt is hier de 'target instance': we hebben nooit
     431    -- iemands prive-sleutel nodig, alleen zijn publieke -- dus staat hier ook
     432    -- geen geheim van een ander in.
     433    --
     434    -- Het token is kort houdbaar (minuten, zie OpenWebAuthService) en gaat na
     435    -- inwisselen meteen weg: eenmalig is de hele bedoeling. De FEP noemt de
     436    -- opruiming expliciet als DoS-verdediging -- zonder vervaltijd vult iemand
     437    -- deze tabel met tokens die hij nooit inwisselt.
     438    CREATE TABLE IF NOT EXISTS owa_tokens (
     439      token TEXT PRIMARY KEY,
     440      actor_uri TEXT NOT NULL,
     441      created_at INTEGER NOT NULL          -- ms sinds epoch
     442    );
     443    CREATE INDEX IF NOT EXISTS idx_owa_tokens_created ON owa_tokens(created_at);
     444
    429445    CREATE TABLE IF NOT EXISTS ap_followers (
    430446      id INTEGER PRIMARY KEY AUTOINCREMENT,
  • src/routes/activitypub.js

    r9da8027 raf2cc73  
    130130}
    131131
     132/**
     133 * Is dit een vraag naar de WORTEL van deze server?
     134 *
     135 * FEP-61cf laat de home instance webfingeren op "the root URL of the destination
     136 * site" om ons token-endpoint te vinden. Dat is een URL en geen `acct:`, dus hij
     137 * strandde hierboven op de 400 -- terwijl een ACTOR-URI met een pad wel degelijk
     138 * een 400 hoort te blijven (dat legt test/webfinger-bare-host.test.js vast, en
     139 * dat is een uitgesproken keuze van eerder). Vandaar: alleen origin + '/' telt,
     140 * alles met een pad niet.
     141 */
     142function isEigenWortel(resource, req) {
     143  const r = String(resource || '').trim();
     144  if (!/^https?:\/\//i.test(r)) return false;
     145  try {
     146    const u = new URL(r);
     147    if (u.pathname && u.pathname !== '/') return false;
     148    if (u.search || u.hash) return false;
     149    return asciiHost(u.host) === asciiHost(hostOf(req));
     150  } catch { return false; }
     151}
     152
    132153router.get('/.well-known/webfinger', (req, res) => {
     154  if (isEigenWortel(req.query.resource, req)) {
     155    res.type('application/jrd+json; charset=utf-8');
     156    res.set('Cache-Control', 'public, max-age=300');
     157    return res.send(JSON.stringify({
     158      subject: baseUrl(req) + '/',
     159      links: [
     160        // Waar een home instance ondertekend een token mag ophalen (FEP-61cf
     161        // stap 2/3). Host-niveau en niet per site: het token zegt WIE er binnen
     162        // is, niet waar hij binnen mag -- dat besluit valt bij de poort.
     163        { rel: 'http://purl.org/openwebauth/v1', href: baseUrl(req) + '/owa/token' },
     164      ],
     165    }));
     166  }
    133167  const user = webfingerGebruiker(req.query.resource);
    134168  if (!user) return res.status(400).type('text/plain').send('bad resource');
  • src/routes/posts.js

    r9da8027 raf2cc73  
    2525import { verifyBlob } from '../services/CryptoBox.js';
    2626import { postEntry } from '../services/PostAccessService.js';
     27import * as OWA from '../services/OpenWebAuthService.js';
    2728import MusicMeta from '../services/MusicMeta.js';
    2829import { mediaDir } from '../config/paths.js';
     
    702703// tekst op, want wat niet gerenderd wordt kan ook niet lekken.
    703704function readerItems(site, rows, req) {
    704   const viewer = { user: req.session?.user || null, site, unlockedSlug: null };
     705  const viewer = OWA.viewerFor(req, site, { unlockedSlug: null });
    705706  return rows.map((post) => ({
    706707    post,
     
    15891590  // Anonymous visitors get a clean login gate instead of the content (the title/
    15901591  // teaser may still appear elsewhere as a teaser).
    1591   if (post.fan_only && !(req.session && req.session.user)) {
     1592  // Een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn EN deze site
     1593  // volgt, is precies wie fan_only bedoelde. Die hoeft geen poort te zien.
     1594  const _fediVolger = OWA.isFollowerOf(site.slug, OWA.guestActor(req));
     1595  if (post.fan_only && !(req.session && req.session.user) && !_fediVolger) {
    15921596    // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
    15931597    // stuck on the fan gate but can keep browsing.
     
    15981602      fgTitle: post.title || '',
    15991603      fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
     1604      owaError: !!(req.query && req.query.owa_error),
    16001605      newerPost,
    16011606      olderPost,
  • src/server.js

    r9da8027 raf2cc73  
    6666import ogRoutes from './routes/og.js';
    6767import apRoutes from './routes/activitypub.js';
     68import owaRoutes, { owaMiddleware } from './routes/openwebauth.js';
    6869import oauthRoutes from './routes/oauth.js';
    6970import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
     
    311312// ActivityPub: WebFinger + /ap/* (site-agnostic, resolves the site by slug).
    312313app.use(apRoutes);
     314// OpenWebAuth (FEP-61cf): het token-endpoint en het inlogformulier.
     315app.use(owaRoutes);
     316// En op elk GET-verzoek kijken of er een token wordt ingewisseld (?owt=) of een
     317// stroom gestart (?zid=). Na de sessie, want het resultaat gaat IN de sessie;
     318// voor de pagina's, want een poort verderop moet de uitkomst al kunnen zien.
     319app.use(owaMiddleware);
    313320// ActivityPub C2S: OAuth 2.0 (native/web clients). Site-agnostic; the consent
    314321// screen picks which site the token can post as.
  • src/services/PostAccessService.js

    r9da8027 raf2cc73  
    4444 *
    4545 * @param post     de rij uit `posts`
    46  * @param viewer   { user, site, unlockedSlug } -- unlockedSlug is de slug uit
    47  *                 een vers ?u=-bewijs van /paid/unlock, al geverifieerd door de
    48  *                 aanroeper. Deze module doet geen crypto.
     46 * @param viewer   { user, site, unlockedSlug, isFollower } -- unlockedSlug is de
     47 *                 slug uit een vers ?u=-bewijs van /paid/unlock, al geverifieerd
     48 *                 door de aanroeper; isFollower komt uit OpenWebAuthService en
     49 *                 zegt dat een bewezen fedi-actor deze site volgt. Deze module
     50 *                 doet geen crypto en raakt de database niet aan: ophalen doet
     51 *                 de aanroeper, beslissen doet deze.
    4952 */
    50 export function postAccess(post, { user = null, site = null, unlockedSlug = null } = {}) {
     53export function postAccess(post, { user = null, site = null, unlockedSlug = null, isFollower = false } = {}) {
    5154  if (!post) return 'forbidden';
    5255
     
    6265
    6366  if (post.paid && String(unlockedSlug || '') !== String(post.slug)) return 'paid';
    64   if (post.fan_only && !user) return 'fan';
     67  // `fan_only` betekende altijd al "mijn volgers op de fediverse", maar de poort
     68  // vroeg om een lokaal account -- de verkeerde vraag, die juist de mensen
     69  // buitensloot voor wie hij openstond. `isFollower` is het antwoord op de
     70  // ECHTE vraag: een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn
     71  // en die deze site volgt. Wie dat bewijs levert, komt binnen.
     72  //
     73  // Het lokale account blijft er ook door, zoals het altijd deed: dat is een
     74  // andere manier om te weten wie iemand is, niet een mindere.
     75  if (post.fan_only && !user && !isFollower) return 'fan';
    6576  return 'full';
    6677}
  • src/services/i18n.js

    r9da8027 raf2cc73  
    890890    'epk.recent': 'Recent',
    891891    'fgate.title': 'Alleen voor vrienden',
    892     'fgate.sub': 'Dit is exclusief voor ingelogde vrienden. Log in om het te bekijken.',
    893     'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',
     892    'fgate.sub': 'Dit bericht is voor wie deze site volgt.',
     893    'fgate.owa_label': 'Log in met je eigen fediverse-adres', 'fgate.owa_go': 'Ga verder', 'fgate.owa_hint': 'Je server bevestigt wie je bent. Hier heb je geen account en geen wachtwoord nodig — volg je deze site, dan ben je binnen.', 'fgate.owa_failed': 'Dat adres kon ik niet bereiken. Klopt de spelling?', 'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',
    894894    'lbio.empty': 'Nog geen links ingesteld.',
    895895    'lbio.back_to_site': 'naar de site',
     
    18961896    'epk.recent': 'Recent',
    18971897    'fgate.title': 'Friends only',
    1898     'fgate.sub': 'This is exclusive to logged-in friends. Log in to view it.',
    1899     'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',
     1898    'fgate.sub': 'This post is for people who follow this site.',
     1899    'fgate.owa_label': 'Sign in with your own fediverse address', 'fgate.owa_go': 'Continue', 'fgate.owa_hint': 'Your own server confirms who you are. No account and no password here — if you follow this site, you are in.', 'fgate.owa_failed': 'I could not reach that address. Is the spelling right?', 'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',
    19001900    'lbio.empty': 'No links set up yet.',
    19011901    'lbio.back_to_site': 'back to the site',
     
    29022902    'epk.recent': 'Aktuell',
    29032903    'fgate.title': 'Nur für Freunde',
    2904     'fgate.sub': 'Dies ist exklusiv für eingeloggte Freunde. Melde dich an, um es anzusehen.',
    2905     'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',
     2904    'fgate.sub': 'Dieser Beitrag ist für alle, die dieser Seite folgen.',
     2905    'fgate.owa_label': 'Mit deiner eigenen Fediverse-Adresse anmelden', 'fgate.owa_go': 'Weiter', 'fgate.owa_hint': 'Dein eigener Server bestätigt, wer du bist. Kein Konto und kein Passwort hier — wenn du dieser Seite folgst, bist du drin.', 'fgate.owa_failed': 'Diese Adresse war nicht erreichbar. Stimmt die Schreibweise?', 'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',
    29062906    'lbio.empty': 'Noch keine Links eingerichtet.',
    29072907    'lbio.back_to_site': 'zurück zur Seite',
  • src/views/pages/fan-gate.ejs

    r9da8027 raf2cc73  
    1212    <% if (typeof fgTitle !== 'undefined' && fgTitle) { %><p class="fg-which">"<%= fgTitle %>"</p><% } %>
    1313    <p class="fg-sub"><%= t('fgate.sub') %></p>
    14     <p><a class="fg-btn" href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p>
     14
     15    <%# De hoofdweg: bewijs bij je EIGEN server wie je bent (OpenWebAuth,
     16        FEP-61cf). Dit is de vraag die fan_only altijd al stelde -- volg je deze
     17        site? -- in plaats van "heb je hier een account", wat juist de mensen
     18        buitensloot voor wie de poort openstond. Geen account hier, geen
     19        wachtwoord hier, geen cookie van een derde. %>
     20    <form class="fg-owa" method="post" action="/owa/login">
     21      <input type="hidden" name="next" value="<%= fgNext || '/' %>">
     22      <label class="fg-label" for="fg-handle"><%= t('fgate.owa_label') %></label>
     23      <div class="fg-row">
     24        <input class="fg-input" id="fg-handle" name="handle" type="text" inputmode="email"
     25               autocomplete="username" spellcheck="false" placeholder="@jij@jouwserver.nl" required>
     26        <button class="fg-btn" type="submit"><%= t('fgate.owa_go') %></button>
     27      </div>
     28      <p class="fg-hint"><%= t('fgate.owa_hint') %></p>
     29      <% if (typeof owaError !== 'undefined' && owaError) { %>
     30        <p class="fg-error"><%= t('fgate.owa_failed') %></p>
     31      <% } %>
     32    </form>
     33
     34    <%# De lokale inlog blijft, maar als tweede: die is voor wie hier echt een
     35        account heeft, en dat is de uitzondering. %>
     36    <p class="fg-alt"><a href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p>
    1537  </div>
    1638</section>
     
    2648  .fg-which { font-style: italic; opacity: .8; margin: 0 0 12px; }
    2749  .fg-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
    28   .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; }
     50  .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; border: 0; font-family: inherit; font-size: 1rem; cursor: pointer; }
     51  .fg-owa { margin: 0 0 18px; text-align: left; }
     52  .fg-label { display: block; font-size: .85rem; opacity: .8; margin-bottom: 6px; }
     53  .fg-row { display: flex; gap: 8px; flex-wrap: wrap; }
     54  .fg-input { flex: 1 1 12rem; min-width: 0; padding: 12px 14px; border-radius: 10px;
     55              border: 1px solid rgba(128,128,128,.35); background: transparent; color: inherit;
     56              font-family: inherit; font-size: 1rem; }
     57  .fg-hint { font-size: .8rem; opacity: .65; margin: 8px 0 0; line-height: 1.5; }
     58  .fg-error { font-size: .85rem; margin: 8px 0 0; color: #d66; }
     59  .fg-alt { margin: 0; font-size: .85rem; opacity: .7; }
    2960</style>
Note: See TracChangeset for help on using the changeset viewer.