source: Klonkt/src/services/PasskeyService.js@ d43230f

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

Harden: lazy-load @simplewebauthn/server so a missing dep never crashes boot

A canary that autofollows main before npm ci has run (or if it fails) would otherwise crash on the static import. The paid flow now imports the lib on first use, so only that flow degrades until the dependency is installed.

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

  • Property mode set to 100644
File size: 4.3 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// Store (or refresh) a pseudonymous entitlement for this passkey.
66export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) {
67 const expiresAt = Math.floor(Date.now() / 1000) + ttlDays * 86400;
68 db.prepare(`INSERT INTO paid_entitlements
69 (credential_id, site_id, public_key, counter, transports, min_cents, expires_at, created_at)
70 VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
71 ON CONFLICT(credential_id) DO UPDATE SET
72 public_key=excluded.public_key, counter=excluded.counter, transports=excluded.transports,
73 min_cents=excluded.min_cents, expires_at=excluded.expires_at`)
74 .run(credentialId, siteId, publicKey, counter || 0, transports || null, Math.max(0, minCents || 0), expiresAt);
75 return expiresAt;
76}
77
78// A valid, unexpired entitlement for this passkey on this site, else null.
79export function getEntitlement(credentialId, siteId) {
80 const row = db.prepare('SELECT * FROM paid_entitlements WHERE credential_id = ? AND site_id = ?').get(credentialId, siteId);
81 if (!row) return null;
82 if ((row.expires_at || 0) < Math.floor(Date.now() / 1000)) return null;
83 return row;
84}
85
86export function deleteEntitlement(credentialId) {
87 return db.prepare('DELETE FROM paid_entitlements WHERE credential_id = ?').run(credentialId).changes > 0;
88}
89
90// Prune expired entitlements (Scheduler, slice 5).
91export function pruneExpired() {
92 return db.prepare('DELETE FROM paid_entitlements WHERE expires_at < ?').run(Math.floor(Date.now() / 1000)).changes;
93}
94
95export default {
96 rpFor, registrationOptions, verifyRegistration, storeEntitlement,
97 getEntitlement, deleteEntitlement, pruneExpired,
98};
Note: See TracBrowser for help on using the repository browser.