Changeset 6cbd014 in Klonkt for src/services/PasskeyService.js


Ignore:
Timestamp:
07/21/2026 01:32:23 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
d48ea02
Parents:
d43230f
git-author:
Robin <roboburr@…> (07/21/2026 01:30:40 AM)
git-committer:
Robin <roboburr@…> (07/21/2026 01:32:23 AM)
Message:

Feature: paid posts slice 4, cookie-less per-post unlock

The unlock leg of the paid-posts flow (klonkt-demo-3lz). A supporter who
already made a passkey (slice 3) opens a paid post and unlocks it with a
WebAuthn assertion, no account and no cookie.

  • Cookie-less: GET /paid/challenge hands out authentication options plus a short-lived (300s) signed blob carrying the challenge, the post slug and the post's required cents. The client returns both to POST /paid/unlock; nothing is kept between the two requests.
  • Discoverable credentials: allowCredentials is empty, so the browser offers the site's passkeys and the visitor stays pseudonymous.
  • Gate checks, in order: valid+unexpired entitlement for this passkey and site (else 403 -> the page sends the visitor to /paid/link to register), tier (entitlement cents >= post cents, else 403), then the assertion is verified and the signature counter bumped (clone detection).
  • The full post body is returned in that SAME response (renderPostBodyHtml, extracted from the page pipeline so unlocked HTML matches the normal render exactly). No unlock token becomes state.

Note: injected content covers text, images and external embeds; the
own-hosted audio player binds on load and is not re-initialised in
injected HTML yet (follow-up).

Changed files:
src/routes/posts.js

  • export renderPostBodyHtml (shared by the page and the unlock route)

src/services/PasskeyService.js

  • authenticationOptions, verifyAssertion, bumpCounter

src/routes/paid.js

  • GET /paid/challenge, POST /paid/unlock (cookie-less)

src/views/pages/paid-gate.ejs

  • Ontgrendel button + vendored SimpleWebAuthnBrowser assertion script; swaps the gate for the post on success, links to Patreon on 403

test/paid-unlock.test.js

  • auth options challenge + empty allowCredentials, counter bump, tier gate, expired entitlement not served

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/PasskeyService.js

    rd43230f r6cbd014  
    6363}
    6464
     65// Authentication (assertion) options for the unlock. Discoverable credentials,
     66// so allowCredentials is empty and the browser offers the site's passkeys.
     67export async function authenticationOptions(base) {
     68  const { rpID } = rpFor(base);
     69  const { generateAuthenticationOptions } = await lib();
     70  return generateAuthenticationOptions({ rpID, userVerification: 'preferred', allowCredentials: [] });
     71}
     72
     73// Verify an assertion against a stored entitlement row. Returns { newCounter }
     74// or null. Challenge is read from the signed blob by the caller.
     75export async function verifyAssertion(base, response, expectedChallenge, ent) {
     76  const { rpID, origin } = rpFor(base);
     77  let v;
     78  try {
     79    const { verifyAuthenticationResponse } = await lib();
     80    v = await verifyAuthenticationResponse({
     81      response,
     82      expectedChallenge,
     83      expectedOrigin: origin,
     84      expectedRPID: rpID,
     85      requireUserVerification: false,
     86      credential: {
     87        id: ent.credential_id,
     88        publicKey: Buffer.from(ent.public_key, 'base64url'),
     89        counter: ent.counter || 0,
     90        transports: ent.transports ? JSON.parse(ent.transports) : undefined,
     91      },
     92    });
     93  } catch { return null; }
     94  if (!v || !v.verified) return null;
     95  return { newCounter: v.authenticationInfo.newCounter };
     96}
     97
     98// Bump the signature counter after a successful assertion (clone detection).
     99export function bumpCounter(credentialId, newCounter) {
     100  db.prepare('UPDATE paid_entitlements SET counter = ? WHERE credential_id = ?').run(newCounter || 0, credentialId);
     101}
     102
    65103// Store (or refresh) a pseudonymous entitlement for this passkey.
    66104export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) {
     
    96134  rpFor, registrationOptions, verifyRegistration, storeEntitlement,
    97135  getEntitlement, deleteEntitlement, pruneExpired,
     136  authenticationOptions, verifyAssertion, bumpCounter,
    98137};
Note: See TracChangeset for help on using the changeset viewer.