source: Klonkt/src/services/PasskeyService.js@ 598f090

main
Last change on this file since 598f090 was 6cbd014, checked in by Robin <roboburr@…>, 7 weeks ago

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

  • Property mode set to 100644
File size: 5.8 KB
Line 
1// Paid posts (klonkt-demo-aki) slice 3: passkey registration + verification for
2// pseudonymous entitlements. Uses @simplewebauthn/server. Cookie-less: the
3// challenge is not kept in a session but travels inside a signed blob
4// (CryptoBox.signBlob) that the client returns, so there is nothing to store
5// between the two requests. An entitlement is {passkey, site, cents, expiry}
6// with NO patron identity.
7import crypto from 'crypto';
8import db from '../config/database.js';
9
10// Lazy so a not-yet-installed dependency can never crash app boot; only the
11// paid passkey flow fails until `npm ci` has run.
12let _lib = null;
13async function lib() { if (!_lib) _lib = await import('@simplewebauthn/server'); return _lib; }
14
15const DEFAULT_TTL_DAYS = 32; // aligns with Patreon's monthly cycle; re-link after
16
17// rpID is the site host; origin is the full base URL.
18export function rpFor(base) {
19 let host = ''; try { host = new URL(base).host.split(':')[0]; } catch { /* keep empty */ }
20 return { rpID: host, origin: String(base).replace(/\/+$/, '') };
21}
22
23// Registration options for a fresh, discoverable (usernameless) passkey. The
24// user handle is random: the credential is pseudonymous by design.
25export async function registrationOptions(base, siteSlug) {
26 const { rpID } = rpFor(base);
27 const { generateRegistrationOptions } = await lib();
28 return generateRegistrationOptions({
29 rpName: `Supporter of ${siteSlug}`,
30 rpID,
31 userName: 'supporter',
32 userDisplayName: 'Supporter',
33 userID: crypto.randomBytes(16),
34 attestationType: 'none',
35 authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
36 timeout: 120000,
37 });
38}
39
40// Verify a registration response against the challenge (read from the signed
41// blob by the caller). Returns the credential to store, or null.
42export async function verifyRegistration(base, response, expectedChallenge) {
43 const { rpID, origin } = rpFor(base);
44 let v;
45 try {
46 const { verifyRegistrationResponse } = await lib();
47 v = await verifyRegistrationResponse({
48 response,
49 expectedChallenge,
50 expectedOrigin: origin,
51 expectedRPID: rpID,
52 requireUserVerification: false,
53 });
54 } catch { return null; }
55 if (!v || !v.verified || !v.registrationInfo) return null;
56 const cred = v.registrationInfo.credential;
57 return {
58 credentialId: cred.id, // base64url string
59 publicKey: Buffer.from(cred.publicKey).toString('base64url'), // COSE key bytes
60 counter: cred.counter || 0,
61 transports: response.response && response.response.transports ? JSON.stringify(response.response.transports) : null,
62 };
63}
64
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
103// Store (or refresh) a pseudonymous entitlement for this passkey.
104export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) {
105 const expiresAt = Math.floor(Date.now() / 1000) + ttlDays * 86400;
106 db.prepare(`INSERT INTO paid_entitlements
107 (credential_id, site_id, public_key, counter, transports, min_cents, expires_at, created_at)
108 VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
109 ON CONFLICT(credential_id) DO UPDATE SET
110 public_key=excluded.public_key, counter=excluded.counter, transports=excluded.transports,
111 min_cents=excluded.min_cents, expires_at=excluded.expires_at`)
112 .run(credentialId, siteId, publicKey, counter || 0, transports || null, Math.max(0, minCents || 0), expiresAt);
113 return expiresAt;
114}
115
116// A valid, unexpired entitlement for this passkey on this site, else null.
117export function getEntitlement(credentialId, siteId) {
118 const row = db.prepare('SELECT * FROM paid_entitlements WHERE credential_id = ? AND site_id = ?').get(credentialId, siteId);
119 if (!row) return null;
120 if ((row.expires_at || 0) < Math.floor(Date.now() / 1000)) return null;
121 return row;
122}
123
124export function deleteEntitlement(credentialId) {
125 return db.prepare('DELETE FROM paid_entitlements WHERE credential_id = ?').run(credentialId).changes > 0;
126}
127
128// Prune expired entitlements (Scheduler, slice 5).
129export function pruneExpired() {
130 return db.prepare('DELETE FROM paid_entitlements WHERE expires_at < ?').run(Math.floor(Date.now() / 1000)).changes;
131}
132
133export default {
134 rpFor, registrationOptions, verifyRegistration, storeEntitlement,
135 getEntitlement, deleteEntitlement, pruneExpired,
136 authenticationOptions, verifyAssertion, bumpCounter,
137};
Note: See TracBrowser for help on using the repository browser.