pgkiln 0.60.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,17 @@
1
+ import { isIP } from 'node:net';
1
2
  import type { FastifyInstance } from 'fastify';
2
3
  import { owner } from '../db.ts';
3
4
  import { html, raw, type Raw } from '../html.ts';
4
5
  import { icon } from '../icons.ts';
5
6
  import {
6
- addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy, newRecoveryCodes, newTotpSecret,
7
- notifyMfa, otpauthUri, policyProblem, requiredFor, resetFactors, setPolicy, unbase32, type MfaPolicy,
7
+ addPasskey, addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, forgetBrowsers, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
8
+ newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requirement, resetFactors, setPolicy, setTrustDays, trustDays,
9
+ trustedBrowsers, unbase32, userHandle, passkeySignin, setPasskeySignin, type MfaPolicy,
8
10
  } from '../mfa.ts';
9
11
  import { qrSvg } from '../qrcode.ts';
10
12
  import { secretKeyConfigured } from '../secrets.ts';
11
- import { clientIp, logActivity, type Session } from '../session.ts';
13
+ import { clientIp, logActivity, saveState, type Session } from '../session.ts';
14
+ import { ALGORITHMS, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
12
15
  import { tr } from './i18n.ts';
13
16
  import { isAdmin } from './locks.ts';
14
17
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
@@ -19,6 +22,29 @@ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Re
19
22
  // app confirms it, then stored encrypted with the instance key. Recovery codes are shown once; they count
20
23
  // only after "I saved them". Removing a factor asks for the password, and is refused when it would leave a
21
24
  // developer the policy covers without one, or the instance without enough administrators who have one.
25
+ // (sprint 127) Passkeys (WebAuthn): the browser asks the server for options (a one-time challenge kept in the
26
+ // session for five minutes), calls navigator.credentials.create() and posts the answer in a normal form
27
+ // (public/builder.js, [data-passkey]); without JavaScript the button stays hidden. Trusted browsers can be
28
+ // forgotten here; the instance decides how long a browser may be trusted (0 = never).
29
+
30
+ const CHALLENGE_SECONDS = 300;
31
+
32
+ /** A one-time WebAuthn challenge kept in the session (for this purpose only), or null when there is none or it expired. */
33
+ export function takeChallenge(s: Session, purpose: 'create' | 'get'): string | null {
34
+ try {
35
+ const p = JSON.parse(String(s.state.__PK ?? ''));
36
+ delete s.state.__PK;
37
+ return p && p.purpose === purpose && Date.now() - Number(p.at) < CHALLENGE_SECONDS * 1000 && typeof p.c === 'string' ? p.c : null;
38
+ } catch {
39
+ delete s.state.__PK;
40
+ return null;
41
+ }
42
+ }
43
+ export function giveChallenge(s: Session, purpose: 'create' | 'get'): string {
44
+ const c = newChallenge();
45
+ s.state.__PK = JSON.stringify({ c, purpose, at: Date.now() });
46
+ return c;
47
+ }
22
48
 
23
49
  const SECRET_MINUTES = 10;
24
50
 
@@ -32,8 +58,9 @@ export async function securityRoutes(app: FastifyInstance) {
32
58
  if (!s) return;
33
59
  const tx = tr(s);
34
60
  const me = s.username!;
35
- const [factors, left, pol, admin] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me)]);
36
- const required = requiredFor(pol.policy, admin);
61
+ const [factors, left, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), isAdmin(me), trustDays(), trustedBrowsers(me)]);
62
+ const rp = relyingParty(req);
63
+ const { required } = await requirement(me, admin);
37
64
  const pending = (() => {
38
65
  try {
39
66
  const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
@@ -80,18 +107,34 @@ export async function securityRoutes(app: FastifyInstance) {
80
107
  <div class="buttons"><button class="btn btn-hot">${tx('mfa.codes_done')}</button></div></form>`, '', 'codes')
81
108
  : ''}
82
109
  <div class="columns">
83
- ${region(tx('mfa.apps'), html`${factors.length
84
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('mfa.app_name')}</th><th>${tx('mfa.added')}</th><th>${tx('mfa.last_used')}</th><th></th></tr></thead><tbody>
85
- ${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon('key')} ${f.name}</td><td data-label="${tx('mfa.added')}">${when(f.created_at)}</td><td data-label="${tx('mfa.last_used')}">${when(f.last_used_at)}</td>
110
+ ${region(tx('mfa.factors'), html`${factors.length
111
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('mfa.app_name')}</th><th>${tx('mfa.kind')}</th><th>${tx('mfa.added')}</th><th>${tx('mfa.last_used')}</th><th></th></tr></thead><tbody>
112
+ ${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon(f.kind === 'passkey' ? 'fingerprint' : 'smartphone')} ${f.name}</td>
113
+ <td data-label="${tx('mfa.kind')}">${f.kind === 'passkey' ? tx('mfa.kind_passkey') : tx('mfa.kind_app')}${f.kind === 'passkey' && f.rp_id !== rp.rpId ? html` <span class="badge badge-warn" title="${tx('mfa.other_domain_help', { domain: f.rp_id ?? '' })}">${tx('mfa.other_domain', { domain: f.rp_id ?? '' })}</span>` : ''}</td>
114
+ <td data-label="${tx('mfa.added')}">${when(f.created_at)}</td><td data-label="${tx('mfa.last_used')}">${when(f.last_used_at)}</td>
86
115
  <td><details class="menu"><summary class="btn btn-sm">${tx('mfa.remove')}</summary><div class="menu-panel"><form method="post" action="${BASE}/security/factor/${f.id}/delete" class="menu-section">${csrf(s)}
87
116
  ${input(`password_${f.id}`, tx('mfa.your_password'), '', { type: 'password', required: true, auto: 'current-password' })}
88
- <button class="btn btn-sm btn-danger">${tx('mfa.remove_app')}</button></form></div></details></td></tr>`)}
117
+ <button class="btn btn-sm btn-danger">${tx(f.kind === 'passkey' ? 'mfa.remove_passkey' : 'mfa.remove_app')}</button></form></div></details></td></tr>`)}
89
118
  </tbody></table></div>`
90
119
  : html`<p class="muted">${tx('mfa.no_apps')}</p>`}
120
+ <h3>${tx('mfa.kind_passkey')}</h3>
121
+ <p class="muted small u-mt0">${tx('mfa.passkey_intro')}</p>
122
+ <form method="post" action="${BASE}/security/passkey">${csrf(s)}
123
+ <div class="form-grid">${input('pk_name', tx('mfa.app_name'), tx('mfa.passkey_default'), { required: true })}</div>
124
+ <input type="hidden" name="clientDataJSON"><input type="hidden" name="attestationObject"><input type="hidden" name="transports">
125
+ ${isIP(rp.rpId) ? html`<p class="alert alert-info" role="status">${tx('mfa.passkey_needs_host', { host: rp.rpId })}</p>`
126
+ : html`<div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}" hidden data-passkey="${BASE}/security/passkey/options" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('mfa.add_passkey')}</button></div>`}
127
+ <p class="small" role="status" data-passkey-status></p>
128
+ <noscript><p class="muted small">${tx('mfa.passkey_needs_js')}</p></noscript>
129
+ </form>
130
+ <h3>${tx('mfa.kind_app')}</h3>
91
131
  ${setup}`)}
92
132
  ${region(tx('mfa.codes_title'), html`<p class="u-mt0">${factors.length || left ? tx('mfa.codes_left', { n: left }) : tx('mfa.codes_after')}</p>
93
133
  ${factors.length && left < 3 ? html`<p class="alert alert-warning" role="status">${tx('mfa.codes_low')}</p>` : ''}
94
- ${factors.length ? html`<form method="post" action="${BASE}/security/codes">${csrf(s)}<button class="btn" data-confirm="${tx('mfa.codes_new_confirm')}">${tx('mfa.codes_new')}</button></form>` : ''}`)}
134
+ ${factors.length ? html`<form method="post" action="${BASE}/security/codes">${csrf(s)}<button class="btn" data-confirm="${tx('mfa.codes_new_confirm')}">${tx('mfa.codes_new')}</button></form>` : ''}
135
+ ${days || trusted ? html`<h3>${tx('mfa.trusted_title')}</h3>
136
+ <p class="u-mt0">${days ? tx('mfa.trusted_count', { n: trusted, days }) : tx('mfa.trusted_off')}</p>
137
+ ${trusted ? html`<form method="post" action="${BASE}/security/forget-browsers">${csrf(s)}<button class="btn">${tx('mfa.forget_browsers')}</button></form>` : ''}` : ''}`)}
95
138
  </div>`;
96
139
  return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
97
140
  });
@@ -167,7 +210,7 @@ export async function securityRoutes(app: FastifyInstance) {
167
210
  const tx = tr(s);
168
211
  const me = s.username!;
169
212
  const id = Number(req.params.id);
170
- const f = await owner.one('select id from meta.developer_factor where id = $1 and username = $2', [id, me]);
213
+ const f = await owner.one<{ kind: string }>('select id, kind from meta.developer_factor where id = $1 and username = $2', [id, me]);
171
214
  if (!f) return reply.code(404).send('Not found');
172
215
  if (!(await verifyPassword(me, String(req.body?.[`password_${id}`] ?? '')))) {
173
216
  await logActivity({ username: me, event: 'login_failed', ip: clientIp(req), detail: 'builder (removing a second factor)' });
@@ -178,7 +221,7 @@ export async function securityRoutes(app: FastifyInstance) {
178
221
  if (last) {
179
222
  const pol = await mfaPolicy();
180
223
  const admin = await isAdmin(me);
181
- if (requiredFor(pol.policy, admin)) {
224
+ if ((await requirement(me, admin)).required) {
182
225
  flash(s, tx('mfa.cannot_remove_required'), 'error');
183
226
  return back(reply, s, `${BASE}/security`);
184
227
  }
@@ -193,11 +236,73 @@ export async function securityRoutes(app: FastifyInstance) {
193
236
  }
194
237
  await owner.query('delete from meta.developer_factor where id = $1 and username = $2', [id, me]);
195
238
  if (last) await owner.query('delete from meta.developer_recovery_code where username = $1', [me]);
196
- await logActivity({ username: me, event: '2fa_removed', ip: clientIp(req), detail: last ? 'the last authenticator app (recovery codes removed)' : 'an authenticator app' });
239
+ // a lost phone or key: browsers trusted with it must ask again
240
+ await forgetBrowsers(me);
241
+ const what = f.kind === 'passkey' ? 'a passkey' : 'an authenticator app';
242
+ await logActivity({ username: me, event: '2fa_removed', ip: clientIp(req), detail: last ? `${what}, the last factor (recovery codes removed)` : what });
197
243
  flash(s, tx('mfa.removed'));
198
244
  return back(reply, s, `${BASE}/security`);
199
245
  });
200
246
 
247
+ // (sprint 127) a passkey: the options for navigator.credentials.create(), then the browser's answer
248
+ app.post(`${BASE}/security/passkey/options`, async (req: Req, reply) => {
249
+ const s = await developer(req, reply);
250
+ if (!s) return;
251
+ const me = s.username!;
252
+ const rp = relyingParty(req);
253
+ const challenge = giveChallenge(s, 'create');
254
+ await saveState(s);
255
+ reply.header('cache-control', 'no-store');
256
+ return reply.send({
257
+ mode: 'create', challenge, rp: { id: rp.rpId, name: 'pgkiln' }, algs: ALGORITHMS,
258
+ // a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
259
+ user: { id: userHandle(me), name: me, displayName: me },
260
+ exclude: (await passkeysOf(me, rp.rpId)).map((p) => p.credential_id),
261
+ });
262
+ });
263
+
264
+ app.post(`${BASE}/security/passkey`, async (req: Req, reply) => {
265
+ const s = await developer(req, reply);
266
+ if (!s) return;
267
+ const tx = tr(s);
268
+ const me = s.username!;
269
+ const challenge = takeChallenge(s, 'create');
270
+ if (!challenge) {
271
+ flash(s, tx('mfa.setup_expired'), 'error');
272
+ return back(reply, s, `${BASE}/security`);
273
+ }
274
+ const rp = relyingParty(req);
275
+ let reg;
276
+ try {
277
+ reg = verifyRegistration({ clientDataJSON: String(req.body?.clientDataJSON ?? ''), attestationObject: String(req.body?.attestationObject ?? '') }, { challenge, ...rp });
278
+ } catch (e) {
279
+ await logActivity({ username: me, event: '2fa_refused', ip: clientIp(req), detail: `passkey: ${e instanceof WebAuthnError ? e.message : 'unreadable answer'}` });
280
+ flash(s, tx('mfa.passkey_failed'), 'error');
281
+ return back(reply, s, `${BASE}/security`);
282
+ }
283
+ const first = !(await hasFactor(me));
284
+ try {
285
+ await addPasskey(me, String(req.body?.pk_name ?? ''), { ...reg, rpId: rp.rpId, transports: String(req.body?.transports ?? '').split(',').filter(Boolean) });
286
+ } catch {
287
+ flash(s, tx('mfa.passkey_known'), 'error');
288
+ return back(reply, s, `${BASE}/security`);
289
+ }
290
+ delete s.state.__MFA_DUE;
291
+ await logActivity({ username: me, event: '2fa_enrolled', ip: clientIp(req), detail: `passkey (${rp.rpId})` });
292
+ if (first || !(await codesLeft(me))) s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(me));
293
+ flash(s, tx('mfa.passkey_added'));
294
+ return back(reply, s, `${BASE}/security${s.state.__CODES_NEW ? '#codes' : ''}`);
295
+ });
296
+
297
+ app.post(`${BASE}/security/forget-browsers`, async (req: Req, reply) => {
298
+ const s = await developer(req, reply);
299
+ if (!s) return;
300
+ await forgetBrowsers(s.username!);
301
+ await logActivity({ username: s.username, event: '2fa_forget_browsers', ip: clientIp(req) });
302
+ flash(s, tr(s)('mfa.browsers_forgotten'));
303
+ return back(reply, s, `${BASE}/security`);
304
+ });
305
+
201
306
  // (SEC-6) an administrator resets another developer's second factors: logged, the developer and the administrators told
202
307
  app.post(`${BASE}/developers/reset-2fa`, async (req: Req, reply) => {
203
308
  const s = await developer(req, reply);
@@ -228,13 +333,18 @@ export async function securityRoutes(app: FastifyInstance) {
228
333
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change the policy.');
229
334
  const policy = MFA_POLICIES.includes(req.body?.policy as MfaPolicy) ? (req.body!.policy as MfaPolicy) : null;
230
335
  const grace = Number(req.body?.grace_days);
231
- if (!policy || !Number.isInteger(grace) || grace < 0 || grace > 90) return reply.code(400).send('policy is off, admins or all; grace_days 0 to 90');
336
+ const trust = Number(req.body?.trust_days ?? 0);
337
+ if (!policy || !Number.isInteger(grace) || grace < 0 || grace > 90 || !Number.isInteger(trust) || trust < 0 || trust > 90)
338
+ return reply.code(400).send('policy is off, admins or all; grace_days and trust_days 0 to 90');
232
339
  if (await policyProblem(policy)) {
233
340
  flash(s, tx('mfa.policy_needs_admins'), 'error');
234
341
  return back(reply, s, `${BASE}/instance#mfa`);
235
342
  }
343
+ const alone = req.body?.passkey_signin === '1';
236
344
  await setPolicy(policy, grace);
237
- await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days` });
345
+ await setTrustDays(trust);
346
+ await setPasskeySignin(alone);
347
+ await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days, passkey sign-in ${alone ? 'on' : 'off'}` });
238
348
  await notifyMfa(await adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${s.username} set the two-factor policy to ${policy} (grace period ${grace} days).`);
239
349
  flash(s, tx('mfa.policy_saved'));
240
350
  return back(reply, s, `${BASE}/instance#mfa`);
@@ -244,8 +354,8 @@ export async function securityRoutes(app: FastifyInstance) {
244
354
  /** The Instance settings region for the policy (administrators). */
245
355
  export async function policyRegion(s: Session): Promise<Raw> {
246
356
  const tx = tr(s);
247
- const [pol, af, without] = await Promise.all([
248
- mfaPolicy(), adminFactors(),
357
+ const [pol, af, days, alone, without] = await Promise.all([
358
+ mfaPolicy(), adminFactors(), trustDays(), passkeySignin(),
249
359
  owner.query<{ username: string; is_admin: boolean }>('select username, is_admin from meta.developer d where not exists (select 1 from meta.developer_factor f where f.username = d.username) order by username'),
250
360
  ]);
251
361
  const opt = (v: MfaPolicy) => html`<option value="${v}"${pol.policy === v ? raw(' selected') : ''}>${tx(({ off: 'mfa.policy_off', admins: 'mfa.policy_admins', all: 'mfa.policy_all' } as const)[v])}</option>`;
@@ -253,7 +363,10 @@ export async function policyRegion(s: Session): Promise<Raw> {
253
363
  <div class="form-grid">
254
364
  <div class="field"><label class="label" for="f_policy">${tx('mfa.policy_label')}</label><select id="f_policy" name="policy">${MFA_POLICIES.map(opt)}</select></div>
255
365
  ${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
366
+ ${input('trust_days', tx('mfa.trust_days'), days, { type: 'number', help: tx('mfa.trust_days_help') })}
256
367
  </div>
368
+ <div class="field"><label class="check"><input type="checkbox" name="passkey_signin" value="1"${alone ? raw(' checked') : ''}> ${tx('mfa.passkey_signin')}</label>
369
+ <small class="help">${tx('mfa.passkey_signin_help')}</small></div>
257
370
  <p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
258
371
  <div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
259
372
  ${without.rows.length ? html`<p class="small">${tx('mfa.without', { list: without.rows.map((d) => `${d.username}${d.is_admin ? ' (admin)' : ''}`).join(', ') })}</p>` : ''}
package/src/builder/ui.ts CHANGED
@@ -12,7 +12,7 @@ import { ensureDevbar } from '../runtime/devbar.ts';
12
12
  import { noteChange } from './app-updates.ts';
13
13
  import { clientTexts, tr } from './i18n.ts';
14
14
  import { languageMenu, loadLanguage } from './language.ts';
15
- import { enrolmentDue } from '../mfa.ts';
15
+ import { enrolmentDue, requirement } from '../mfa.ts';
16
16
 
17
17
  // Shared building blocks of the builder UI.
18
18
 
@@ -323,9 +323,19 @@ export async function developer(req: Req, reply: FastifyReply) {
323
323
  if (s.state.__BLANG === undefined) await loadLanguage(s);
324
324
  // (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
325
325
  delete s.state.__MFA_DUE;
326
- const due = await enrolmentDue(s.username, await isAdmin(s.username));
326
+ const admin = await isAdmin(s.username);
327
+ // (sprint 128) signed in with the default password while a second factor is required: change it first
328
+ // (on Developers), then the enrolment below
329
+ const weakFirst = !!s.state.__WEAK && (await requirement(s.username, admin)).required;
330
+ if (weakFirst && !/^\/builder\/(developers|logout|language|theme)(?:[/?#]|$)/.test(req.url)) {
331
+ flash(s, tr(s)('mfa.weak_required'), 'error');
332
+ await saveState(s);
333
+ reply.redirect(`${BASE}/developers`);
334
+ return null;
335
+ }
336
+ const due = await enrolmentDue(s.username, admin);
327
337
  if (due?.due === 'grace') s.state.__MFA_DUE = due.until.toISOString();
328
- else if (due && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
338
+ else if (due && !weakFirst && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
329
339
  reply.redirect(`${BASE}/security`);
330
340
  return null;
331
341
  }
@@ -8,6 +8,7 @@ import { html, raw } from '../html.ts';
8
8
  import { icon } from '../icons.ts';
9
9
  import { clientIp, logActivity, type BuilderWorkspace, type Session } from '../session.ts';
10
10
  import { isAdmin } from './locks.ts';
11
+ import { setWorkspaceRequired } from '../mfa.ts';
11
12
  import { back, BANNER_COLORS, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
12
13
 
13
14
  // Workspaces (064; APEX: workspaces). A workspace groups applications and the
@@ -215,7 +216,7 @@ function serviceRegion(s: Session, w: { id: number; message: string | null; bann
215
216
  }
216
217
 
217
218
  /** (093) Workspace preferences: the SQL Workshop and the applications' REST modules on or off. */
218
- function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null }) {
219
+ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null; mfa_required_since: Date | null }, without: string[]) {
219
220
  const check = (name: string, on: boolean, label: string, help: string) =>
220
221
  html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
221
222
  const tx = tr(s);
@@ -223,7 +224,12 @@ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; r
223
224
  ${check('sql_workshop', w.sql_workshop, tx('wsp.sql_on'), tx('wsp.sql_on_help'))}
224
225
  ${check('rest_services', w.rest_services, tx('wsp.rest_on'), tx('wsp.rest_on_help', { path: '/a/<alias>/rest/…' }))}
225
226
  ${select('builder_home', tx('wsp.builder_home'), w.builder_home === 'home' ? 'home' : 'apps', [['apps', tx('wsp.home.apps')], ['home', tx('wsp.home.home')]], tx('wsp.builder_home_help'))}
226
- <div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form>`, '', 'preferences');
227
+ <div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form>
228
+ <h3 id="mfa">${tx('wsp.mfa')}</h3>
229
+ <form method="post" action="${BASE}/workspaces/${w.id}/mfa">${csrf(s)}
230
+ ${check('required', !!w.mfa_required_since, tx('wsp.mfa_required'), tx('wsp.mfa_required_help'))}
231
+ ${w.mfa_required_since ? html`<p class="small">${tx('wsp.mfa_since', { date: new Date(w.mfa_required_since).toISOString().slice(0, 10) })}${without.length ? html` ${tx('wsp.mfa_without', { list: without.join(', ') })}` : ''}</p>` : ''}
232
+ <div class="buttons"><button class="btn">${tx('wsp.mfa_save')}</button></div></form>`, '', 'preferences');
227
233
  }
228
234
 
229
235
  /** (093) Extension links: links to other tools in the builder's Extensions menu. */
@@ -341,6 +347,10 @@ export async function workspaceRoutes(app: FastifyInstance) {
341
347
  ]);
342
348
  const roles = rolesQ;
343
349
  const links = (await owner.query('select id, label, url, description, position from meta.workspace_link where workspace_id = $1 order by position, lower(label)', [w.id])).rows;
350
+ // (sprint 128) the members still without a second factor
351
+ const without = (await owner.query<{ username: string }>(
352
+ `select m.username from meta.workspace_member m where m.workspace_id = $1
353
+ and not exists (select 1 from meta.developer_factor f where f.username = m.username) order by 1`, [w.id])).rows.map((r) => r.username);
344
354
  const main = html`
345
355
  <div class="title-row"><h1>${w.name}</h1></div>
346
356
  <div class="columns wide-left">
@@ -365,7 +375,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
365
375
  </div>
366
376
  ${serviceRegion(s, w)}
367
377
  ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
368
- <div class="columns">${preferencesRegion(s, w)}${linksRegion(s, w, links)}</div>
378
+ <div class="columns">${preferencesRegion(s, w, without)}${linksRegion(s, w, links)}</div>
369
379
  ${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
370
380
  ${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
371
381
  <thead><tr><th>${tx('wsp.application')}</th><th>${tx('wsp.move_to')}</th></tr></thead>
@@ -559,6 +569,17 @@ export async function workspaceRoutes(app: FastifyInstance) {
559
569
  return back(reply, s, `${BASE}/workspaces/${req.params.id}`);
560
570
  });
561
571
 
572
+ // (sprint 128, SEC-2) a workspace requires a second factor of its developers (the instance's grace period from now)
573
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/mfa`, async (req: Req, reply) => {
574
+ const s = await developer(req, reply);
575
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
576
+ const on = req.body?.required === 'true';
577
+ if (!(await setWorkspaceRequired(Number(req.params.id), on))) return reply.code(404).send('Not found');
578
+ await logActivity({ appId: null, username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `workspace ${req.params.id}: two-factor sign-in ${on ? 'required' : 'not required'}` });
579
+ flash(s, tr(s)(on ? 'wsp.mfa_on' : 'wsp.mfa_off'));
580
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}#mfa`);
581
+ });
582
+
562
583
  // (093) extension links
563
584
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/links`, async (req: Req, reply) => {
564
585
  const s = await developer(req, reply);
package/src/cli/main.ts CHANGED
@@ -477,9 +477,9 @@ const COMMANDS: Record<string, Command> = {
477
477
  'pgkiln admin reset-2fa <developer> [--reason <text>]\n' +
478
478
  'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n\n' +
479
479
  "reset-2fa removes the developer's authenticator apps and recovery codes and ends their builder\n" +
480
- 'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works;\n' +
481
- 'admins and all need at least two administrators with two-factor sign-in (or every one, when there\n' +
482
- 'are fewer). Both are written to the activity log and mailed to the administrators.',
480
+ 'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works\n' +
481
+ "(and clears the workspaces' own two-factor rules); admins and all need at least two administrators\n" +
482
+ 'with two-factor sign-in (or every one, when there are fewer). Both are written to the activity log and mailed to the administrators.',
483
483
  options: { reason: { type: 'string' }, grace: { type: 'string' } },
484
484
  optionHelp: [
485
485
  ['--reason <text>', 'reset-2fa: why (in the activity log and the mail)'],
@@ -509,9 +509,11 @@ const COMMANDS: Record<string, Command> = {
509
509
  const policy = arg as 'off' | 'admins' | 'all';
510
510
  if (await mfa.policyProblem(policy)) throw new Error('at least two administrators (or every one, when there are fewer) need two-factor sign-in first');
511
511
  await mfa.setPolicy(policy, grace);
512
- await logActivity({ event: '2fa_policy', ip: 'cli', detail: `${policy}, grace ${grace} days (${by})` });
512
+ // (sprint 128) off means off: no workspace requires a second factor either
513
+ const cleared = policy === 'off' ? await mfa.clearWorkspaceRequirements() : 0;
514
+ await logActivity({ event: '2fa_policy', ip: 'cli', detail: `${policy}, grace ${grace} days${cleared ? `, ${cleared} workspace rule(s) cleared` : ''} (${by})` });
513
515
  const sent = await mfa.notifyMfa(await mfa.adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${by} set the two-factor policy to ${policy} on the server.`);
514
- out(`Two-factor policy: ${policy}${policy === 'off' ? '' : `, grace ${grace} days`}.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
516
+ out(`Two-factor policy: ${policy}${policy === 'off' ? '' : `, grace ${grace} days`}.${cleared ? ` ${cleared} workspace(s) no longer require it.` : ''}${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
515
517
  return EXIT.ok;
516
518
  }
517
519
  throw new UsageError(`unknown action ${action}: use reset-2fa or set-2fa-policy`);
package/src/instance.ts CHANGED
@@ -108,7 +108,8 @@ export async function saveInstanceSettings(values: Record<string, string | undef
108
108
  export const CONFIGURATION: readonly { env: string; secret?: boolean; default: string; help: string }[] = [
109
109
  { env: 'PORT', default: '3100', help: 'HTTP port' },
110
110
  { env: 'HOST', default: '127.0.0.1', help: 'Address the server listens on' },
111
- { env: 'PUBLIC_URL', default: '(none)', help: 'The public address (single sign-on redirects, links)' },
111
+ { env: 'PUBLIC_URL', default: '(none)', help: 'The public address (single sign-on redirects, links, passkeys)' },
112
+ { env: 'PGKILN_RP_ID', default: '(the host of PUBLIC_URL)', help: 'A parent domain for developers\' passkeys' },
112
113
  { env: 'DATABASE_URL', secret: true, default: '(none)', help: 'Owner connection (migrations, builder, SQL Workshop)' },
113
114
  { env: 'RUNTIME_DATABASE_URL', secret: true, default: 'DATABASE_URL', help: 'Least-privilege connection that runs applications' },
114
115
  { env: 'COOKIE_SECURE', default: 'false', help: 'Cookies only over HTTPS' },
package/src/mfa.ts CHANGED
@@ -9,6 +9,10 @@ import { decryptSecret, encryptSecret } from './secrets.ts';
9
9
  // setting; it can only be required while two administrators (or every administrator, when there are fewer)
10
10
  // have a working second factor, so it never locks the instance out. The command line resets a developer's
11
11
  // factors and turns the policy off without signing in (src/cli/main.ts, `pgkiln admin`).
12
+ // (sprint 127) Passkeys and security keys (WebAuthn, src/webauthn.ts) are the second kind of factor, and a
13
+ // developer may trust a browser for a number of days (meta.setting mfa_trust_days; 0 = never).
14
+ // (sprint 128) A passkey the authenticator verified the developer with may stand in for both the password and
15
+ // the second factor (mfa_passkey_signin), and a workspace may require a second factor of its developers.
12
16
 
13
17
  export const TOTP_STEP = 30;
14
18
  export const TOTP_DIGITS = 6;
@@ -92,10 +96,10 @@ export const hashCode = (code: string) => createHash('sha256').update(normaliseC
92
96
 
93
97
  // ---------------------------------------------------------------- the developer's factors
94
98
 
95
- export interface Factor { id: number; kind: 'totp'; name: string; created_at: string; last_used_at: string | null }
99
+ export interface Factor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
96
100
 
97
101
  export async function factorsOf(username: string, db: Pick<pg.ClientBase, 'query'> = owner.pool): Promise<Factor[]> {
98
- return (await db.query<Factor>('select id, kind, name, created_at, last_used_at from meta.developer_factor where username = $1 order by id', [username])).rows;
102
+ return (await db.query<Factor>('select id, kind, name, created_at, last_used_at, rp_id from meta.developer_factor where username = $1 order by kind desc, id', [username])).rows;
99
103
  }
100
104
 
101
105
  /** Recovery codes left (confirmed, unused). */
@@ -116,6 +120,96 @@ export async function addTotp(username: string, name: string, secret: string, st
116
120
  );
117
121
  }
118
122
 
123
+ // ---------------------------------------------------------------- passkeys (sprint 127)
124
+
125
+ export interface Passkey { id: number; name: string; credential_id: string; public_key: Record<string, string>; alg: number; sign_count: string; transports: string[] }
126
+
127
+ /** Add a passkey (after src/webauthn.ts checked the browser's answer). */
128
+ export async function addPasskey(username: string, name: string, p: { credentialId: string; jwk: Record<string, string>; alg: number; signCount: number; rpId: string; transports: string[] }) {
129
+ await owner.query(
130
+ `insert into meta.developer_factor (username, kind, name, credential_id, public_key, alg, sign_count, rp_id, transports, last_used_at)
131
+ values ($1, 'passkey', $2, $3, $4, $5, $6, $7, $8, now())`,
132
+ [username, name.trim().slice(0, 60) || 'Passkey', p.credentialId, JSON.stringify(p.jwk), p.alg, p.signCount, p.rpId,
133
+ p.transports.filter((t) => /^[a-z-]{1,20}$/.test(t)).slice(0, 8)],
134
+ );
135
+ }
136
+
137
+ /** The developer's passkeys for this relying party (a passkey only works on the domain it was made for). */
138
+ export async function passkeysOf(username: string, rpId: string): Promise<Passkey[]> {
139
+ return (await owner.query<Passkey>(
140
+ `select id, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
141
+ where username = $1 and kind = 'passkey' and rp_id = $2 order by id`, [username, rpId])).rows;
142
+ }
143
+
144
+ /** (sprint 128) The passkey with this credential id for this relying party, and whose it is (sign-in without a user name). */
145
+ export async function passkeyByCredential(credentialId: string, rpId: string): Promise<(Passkey & { username: string }) | null> {
146
+ if (!credentialId || credentialId.length > 1400) return null;
147
+ return (await owner.one<Passkey & { username: string }>(
148
+ `select id, username, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
149
+ where kind = 'passkey' and credential_id = $1 and rp_id = $2`, [credentialId, rpId])) ?? null;
150
+ }
151
+
152
+ /** The user handle a developer's passkeys carry: stable, and not the user name. */
153
+ export const userHandle = (username: string) => createHash('sha256').update(`pgkiln-developer:${username}`).digest().subarray(0, 16).toString('base64url');
154
+
155
+ /** (sprint 128) Whether a passkey alone (user verified) may sign a developer in. */
156
+ export async function passkeySignin(): Promise<boolean> {
157
+ return (await owner.one<{ value: string }>(`select value from meta.setting where name = 'mfa_passkey_signin'`))?.value !== 'off';
158
+ }
159
+
160
+ export async function setPasskeySignin(on: boolean) {
161
+ await owner.query(`insert into meta.setting (name, value) values ('mfa_passkey_signin', $1) on conflict (name) do update set value = excluded.value`, [on ? 'on' : 'off']);
162
+ }
163
+
164
+ /** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
165
+ export async function usePasskey(id: number, oldCount: string, newCount: number): Promise<boolean> {
166
+ return !!(await owner.query(
167
+ 'update meta.developer_factor set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint',
168
+ [id, oldCount, newCount])).rowCount;
169
+ }
170
+
171
+ // ---------------------------------------------------------------- trusted browsers (sprint 127)
172
+
173
+ /** Days a browser may skip the second step after one (0 = never). */
174
+ export async function trustDays(): Promise<number> {
175
+ const v = Number((await owner.one<{ value: string }>(`select value from meta.setting where name = 'mfa_trust_days'`))?.value);
176
+ return Number.isInteger(v) && v >= 0 && v <= 90 ? v : 0;
177
+ }
178
+
179
+ export async function setTrustDays(days: number) {
180
+ await owner.query(`insert into meta.setting (name, value) values ('mfa_trust_days', $1) on conflict (name) do update set value = excluded.value`, [String(days)]);
181
+ if (days === 0) await owner.query('delete from meta.developer_trusted_browser');
182
+ else await owner.query(`delete from meta.developer_trusted_browser where expires_at > now() + make_interval(days => $1)`, [days]);
183
+ }
184
+
185
+ const tokenHash = (token: string) => createHash('sha256').update(token).digest('hex');
186
+
187
+ /** Trust this browser: a random token for the cookie (only its hash is stored). */
188
+ export async function trustBrowser(username: string, days: number, userAgent: string): Promise<string> {
189
+ const token = randomBytes(32).toString('base64url');
190
+ await owner.query(
191
+ `insert into meta.developer_trusted_browser (token_hash, username, expires_at, user_agent) values ($1, $2, now() + make_interval(days => $3), $4)`,
192
+ [tokenHash(token), username, days, userAgent.slice(0, 200)]);
193
+ return token;
194
+ }
195
+
196
+ /** Whether this browser's token is trusted for this developer (and trusting is still on). */
197
+ export async function isTrustedBrowser(username: string, token: string | undefined): Promise<boolean> {
198
+ if (!token || token.length > 100) return false;
199
+ return !!(await owner.one(
200
+ `select 1 as ok from meta.developer_trusted_browser t where token_hash = $1 and username = $2 and expires_at > now()
201
+ and exists (select 1 from meta.setting where name = 'mfa_trust_days' and value <> '0')`,
202
+ [tokenHash(token), username]));
203
+ }
204
+
205
+ export async function trustedBrowsers(username: string): Promise<number> {
206
+ return (await owner.one<{ n: number }>('select count(*)::int as n from meta.developer_trusted_browser where username = $1 and expires_at > now()', [username]))!.n;
207
+ }
208
+
209
+ export async function forgetBrowsers(username: string) {
210
+ await owner.query('delete from meta.developer_trusted_browser where username = $1', [username]);
211
+ }
212
+
119
213
  /** New recovery codes (the old ones stop working); shown once, counted only when confirmed. */
120
214
  export async function newRecoveryCodes(username: string): Promise<string[]> {
121
215
  const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
@@ -212,14 +306,47 @@ export async function setPolicy(policy: MfaPolicy, graceDays: number) {
212
306
  });
213
307
  }
214
308
 
309
+ /**
310
+ * (sprint 128) Whether a second factor is required of this developer, by the instance's policy or by one of
311
+ * their workspaces, and since when (the grace period counts from the earliest).
312
+ */
313
+ export async function requirement(username: string, isAdmin: boolean): Promise<{ required: boolean; since: Date | null; graceDays: number; workspaces: string[] }> {
314
+ const [{ policy, since, graceDays }, ws] = await Promise.all([
315
+ mfaPolicy(),
316
+ owner.query<{ name: string; since: Date }>(
317
+ `select w.name, w.mfa_required_since as since from meta.workspace w join meta.workspace_member m on m.workspace_id = w.id
318
+ where m.username = $1 and w.mfa_required_since is not null order by w.mfa_required_since`, [username]),
319
+ ]);
320
+ const byInstance = requiredFor(policy, isAdmin);
321
+ const times = [...(byInstance ? [since ?? new Date()] : []), ...ws.rows.map((r) => new Date(r.since))];
322
+ return {
323
+ required: times.length > 0,
324
+ since: times.length ? new Date(Math.min(...times.map((t) => t.getTime()))) : null,
325
+ graceDays,
326
+ workspaces: ws.rows.map((r) => r.name),
327
+ };
328
+ }
329
+
330
+ /** (sprint 128) Require a second factor of a workspace's developers (from now), or stop requiring it. */
331
+ export async function setWorkspaceRequired(workspaceId: number, on: boolean): Promise<boolean> {
332
+ return !!(await owner.query(
333
+ `update meta.workspace set mfa_required_since = case when $2 then coalesce(mfa_required_since, now()) end where id = $1`,
334
+ [workspaceId, on])).rowCount;
335
+ }
336
+
337
+ /** The command line's "off": no workspace requires a second factor either. */
338
+ export async function clearWorkspaceRequirements(): Promise<number> {
339
+ return (await owner.query('update meta.workspace set mfa_required_since = null where mfa_required_since is not null')).rowCount ?? 0;
340
+ }
341
+
215
342
  /**
216
343
  * A developer without a factor while the policy requires one: 'grace' until the grace period ends (a
217
344
  * banner), then 'enrol' (only the Security page and signing out until they enrol); null otherwise.
218
345
  */
219
346
  export async function enrolmentDue(username: string, isAdmin: boolean): Promise<{ due: 'grace' | 'enrol'; until: Date } | null> {
220
- const { policy, since, graceDays } = await mfaPolicy();
221
- if (!requiredFor(policy, isAdmin) || (await hasFactor(username))) return null;
222
- const until = new Date((since ?? new Date()).getTime() + graceDays * 86_400_000);
347
+ const r = await requirement(username, isAdmin);
348
+ if (!r.required || (await hasFactor(username))) return null;
349
+ const until = new Date((r.since ?? new Date()).getTime() + r.graceDays * 86_400_000);
223
350
  return { due: Date.now() < until.getTime() ? 'grace' : 'enrol', until };
224
351
  }
225
352
 
@@ -230,6 +357,7 @@ export async function resetFactors(username: string): Promise<boolean> {
230
357
  if (!known) return false;
231
358
  await c.query('delete from meta.developer_factor where username = $1', [username]);
232
359
  await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
360
+ await c.query('delete from meta.developer_trusted_browser where username = $1', [username]);
233
361
  await c.query('delete from meta.session where app_id is null and username = $1', [username]);
234
362
  return true;
235
363
  });