pgkiln 0.59.0 → 0.60.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.
@@ -0,0 +1,261 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ 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,
8
+ } from '../mfa.ts';
9
+ import { qrSvg } from '../qrcode.ts';
10
+ import { secretKeyConfigured } from '../secrets.ts';
11
+ import { clientIp, logActivity, type Session } from '../session.ts';
12
+ import { tr } from './i18n.ts';
13
+ import { isAdmin } from './locks.ts';
14
+ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
15
+
16
+ // (sprint 126, SEC-2 and SEC-6) Developer › Security: a developer's second factors (authenticator apps, TOTP)
17
+ // and recovery codes; the instance's policy (Instance settings); an administrator's reset of another
18
+ // developer's factors (Developers). Adding an app: a new secret is kept in the session until a code from the
19
+ // app confirms it, then stored encrypted with the instance key. Recovery codes are shown once; they count
20
+ // only after "I saved them". Removing a factor asks for the password, and is refused when it would leave a
21
+ // developer the policy covers without one, or the instance without enough administrators who have one.
22
+
23
+ const SECRET_MINUTES = 10;
24
+
25
+ async function verifyPassword(username: string, password: string) {
26
+ return !!(await owner.one('select 1 as ok from meta.developer where username = $1 and crypt($2, password_hash) = password_hash', [username, password]));
27
+ }
28
+
29
+ export async function securityRoutes(app: FastifyInstance) {
30
+ app.get(`${BASE}/security`, async (req: Req, reply) => {
31
+ const s = await developer(req, reply);
32
+ if (!s) return;
33
+ const tx = tr(s);
34
+ 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);
37
+ const pending = (() => {
38
+ try {
39
+ const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
40
+ return p && Date.now() - p.at < SECRET_MINUTES * 60_000 ? (p as { secret: string; at: number }) : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ })();
45
+ const shownCodes: string[] | null = (() => {
46
+ try {
47
+ const c = JSON.parse(String(s.state.__CODES_NEW ?? ''));
48
+ return Array.isArray(c) ? c : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ })();
53
+ delete s.state.__MFA_DUE; // the page says it itself
54
+ const when = (v: string | null) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—');
55
+ const setup = pending
56
+ ? html`<form method="post" action="${BASE}/security/totp/confirm">${csrf(s)}
57
+ <ol class="mfa-steps">
58
+ <li>${tx('mfa.scan')}</li>
59
+ <li>${tx('mfa.or_enter')} <code class="mfa-secret">${pending.secret.replace(/(.{4})/g, '$1 ').trim()}</code></li>
60
+ <li>${tx('mfa.enter_code')}</li>
61
+ </ol>
62
+ <div class="mfa-qr">${raw(qrSvg(otpauthUri(pending.secret, me), { label: tx('mfa.qr_label'), px: 4 }) ?? '')}</div>
63
+ <div class="form-grid">
64
+ ${input('name', tx('mfa.app_name'), tx('mfa.app_default'), { required: true })}
65
+ <div class="field"><label class="label" for="f_code">${tx('mfa.code')}</label><input id="f_code" name="code" required inputmode="numeric" pattern="[0-9 ]{6,7}" autocomplete="one-time-code" maxlength="7"></div>
66
+ </div>
67
+ <div class="buttons"><button class="btn btn-hot">${tx('mfa.confirm_app')}</button></div></form>
68
+ <form method="post" action="${BASE}/security/totp/cancel">${csrf(s)}<button class="link-button">${tx('mfa.cancel_setup')}</button></form>`
69
+ : html`<form method="post" action="${BASE}/security/totp/start">${csrf(s)}
70
+ <div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}"${secretKeyConfigured() ? '' : raw(' disabled')}>${icon('plus')} ${tx('mfa.add_app')}</button></div>
71
+ ${secretKeyConfigured() ? '' : html`<p class="alert alert-error" role="alert">${tx('mfa.no_key')}</p>`}</form>`;
72
+ const main = html`<h1 class="u-mb1">${tx('mfa.title')}</h1>
73
+ <p class="muted u-mt0">${tx('mfa.intro')}</p>
74
+ ${required && !factors.length ? html`<div class="alert alert-warning" role="alert">${tx('mfa.required_you')}</div>` : ''}
75
+ ${shownCodes
76
+ ? region(tx('mfa.codes_title'), html`<p>${tx('mfa.codes_once')}</p>
77
+ <ol class="mfa-codes">${shownCodes.map((c) => html`<li><code>${c}</code></li>`)}</ol>
78
+ <form method="post" action="${BASE}/security/codes/confirm">${csrf(s)}
79
+ <label class="check"><input type="checkbox" name="saved" value="1" required> ${tx('mfa.codes_saved')}</label>
80
+ <div class="buttons"><button class="btn btn-hot">${tx('mfa.codes_done')}</button></div></form>`, '', 'codes')
81
+ : ''}
82
+ <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>
86
+ <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
+ ${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>`)}
89
+ </tbody></table></div>`
90
+ : html`<p class="muted">${tx('mfa.no_apps')}</p>`}
91
+ ${setup}`)}
92
+ ${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
+ ${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>` : ''}`)}
95
+ </div>`;
96
+ return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
97
+ });
98
+
99
+ app.post(`${BASE}/security/totp/start`, async (req: Req, reply) => {
100
+ const s = await developer(req, reply);
101
+ if (!s) return;
102
+ if (!secretKeyConfigured()) return back(reply, s, `${BASE}/security`);
103
+ s.state.__TOTP_NEW = JSON.stringify({ secret: newTotpSecret(), at: Date.now() });
104
+ return back(reply, s, `${BASE}/security`);
105
+ });
106
+
107
+ app.post(`${BASE}/security/totp/cancel`, async (req: Req, reply) => {
108
+ const s = await developer(req, reply);
109
+ if (!s) return;
110
+ delete s.state.__TOTP_NEW;
111
+ return back(reply, s, `${BASE}/security`);
112
+ });
113
+
114
+ app.post(`${BASE}/security/totp/confirm`, async (req: Req, reply) => {
115
+ const s = await developer(req, reply);
116
+ if (!s) return;
117
+ const tx = tr(s);
118
+ let pending: { secret: string; at: number } | null = null;
119
+ try {
120
+ pending = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
121
+ } catch {
122
+ pending = null;
123
+ }
124
+ if (!pending || Date.now() - pending.at > SECRET_MINUTES * 60_000) {
125
+ delete s.state.__TOTP_NEW;
126
+ flash(s, tx('mfa.setup_expired'), 'error');
127
+ return back(reply, s, `${BASE}/security`);
128
+ }
129
+ const step = matchTotp(unbase32(pending.secret), String(req.body?.code ?? '').replace(/\s/g, ''), null);
130
+ if (step === null) {
131
+ flash(s, tx('mfa.wrong_setup'), 'error');
132
+ return back(reply, s, `${BASE}/security`);
133
+ }
134
+ const first = !(await hasFactor(s.username!));
135
+ await addTotp(s.username!, String(req.body?.name ?? ''), pending.secret, step);
136
+ delete s.state.__TOTP_NEW;
137
+ delete s.state.__MFA_DUE;
138
+ await logActivity({ username: s.username, event: '2fa_enrolled', ip: clientIp(req), detail: 'authenticator app' });
139
+ // the first app: recovery codes at once (they count only once the developer saved them)
140
+ if (first || !(await codesLeft(s.username!))) s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(s.username!));
141
+ flash(s, tx('mfa.app_added'));
142
+ return back(reply, s, `${BASE}/security#codes`);
143
+ });
144
+
145
+ app.post(`${BASE}/security/codes`, async (req: Req, reply) => {
146
+ const s = await developer(req, reply);
147
+ if (!s) return;
148
+ if (!(await hasFactor(s.username!))) return back(reply, s, `${BASE}/security`);
149
+ s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(s.username!));
150
+ await logActivity({ username: s.username, event: '2fa_codes', ip: clientIp(req), detail: 'new recovery codes' });
151
+ return back(reply, s, `${BASE}/security#codes`);
152
+ });
153
+
154
+ app.post(`${BASE}/security/codes/confirm`, async (req: Req, reply) => {
155
+ const s = await developer(req, reply);
156
+ if (!s) return;
157
+ if (req.body?.saved !== '1') return back(reply, s, `${BASE}/security#codes`);
158
+ await confirmRecoveryCodes(s.username!);
159
+ delete s.state.__CODES_NEW;
160
+ flash(s, tr(s)('mfa.codes_kept'));
161
+ return back(reply, s, `${BASE}/security`);
162
+ });
163
+
164
+ app.post(`${BASE}/security/factor/:id(^\\d{1,9}$)/delete`, async (req: Req, reply) => {
165
+ const s = await developer(req, reply);
166
+ if (!s) return;
167
+ const tx = tr(s);
168
+ const me = s.username!;
169
+ 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]);
171
+ if (!f) return reply.code(404).send('Not found');
172
+ if (!(await verifyPassword(me, String(req.body?.[`password_${id}`] ?? '')))) {
173
+ await logActivity({ username: me, event: 'login_failed', ip: clientIp(req), detail: 'builder (removing a second factor)' });
174
+ flash(s, tx('mfa.wrong_password'), 'error');
175
+ return back(reply, s, `${BASE}/security`);
176
+ }
177
+ const last = (await factorsOf(me)).length === 1;
178
+ if (last) {
179
+ const pol = await mfaPolicy();
180
+ const admin = await isAdmin(me);
181
+ if (requiredFor(pol.policy, admin)) {
182
+ flash(s, tx('mfa.cannot_remove_required'), 'error');
183
+ return back(reply, s, `${BASE}/security`);
184
+ }
185
+ // (SEC-6) never fewer administrators with a factor than the policy needs
186
+ if (admin && pol.policy !== 'off') {
187
+ const { admins, enrolled } = await adminFactors();
188
+ if (enrolled - 1 < Math.min(2, admins)) {
189
+ flash(s, tx('mfa.cannot_remove_admin'), 'error');
190
+ return back(reply, s, `${BASE}/security`);
191
+ }
192
+ }
193
+ }
194
+ await owner.query('delete from meta.developer_factor where id = $1 and username = $2', [id, me]);
195
+ 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' });
197
+ flash(s, tx('mfa.removed'));
198
+ return back(reply, s, `${BASE}/security`);
199
+ });
200
+
201
+ // (SEC-6) an administrator resets another developer's second factors: logged, the developer and the administrators told
202
+ app.post(`${BASE}/developers/reset-2fa`, async (req: Req, reply) => {
203
+ const s = await developer(req, reply);
204
+ if (!s) return;
205
+ const tx = tr(s);
206
+ if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators reset second factors.');
207
+ const who = String(req.body?.username ?? '');
208
+ const reason = String(req.body?.[`reason_${who.replace(/[^\w]/g, '_')}`] ?? req.body?.reason ?? '').trim().slice(0, 200);
209
+ if (who === s.username) return reply.code(400).send('Use your own Security page.');
210
+ if (!reason) {
211
+ flash(s, tx('mfa.reason_needed'), 'error');
212
+ return back(reply, s, `${BASE}/developers`);
213
+ }
214
+ if (!(await resetFactors(who))) return reply.code(404).send('Not found');
215
+ await logActivity({ username: s.username, event: '2fa_reset', ip: clientIp(req), detail: `${who}: ${reason}` });
216
+ const dev = await owner.one<{ email: string | null }>('select email from meta.developer where username = $1', [who]);
217
+ await notifyMfa([dev?.email ?? '', ...(await adminEmails())], `pgkiln: two-factor sign-in of ${who} reset`,
218
+ `${s.username} removed the second factors of developer ${who} (reason: ${reason}). ${who} signs in with the password only until they set up an authenticator app again.`);
219
+ flash(s, tx('mfa.reset_done', { user: who }));
220
+ return back(reply, s, `${BASE}/developers`);
221
+ });
222
+
223
+ // (SEC-2, SEC-6) the instance's policy: off, administrators or all developers, with a grace period
224
+ app.post(`${BASE}/instance/mfa`, async (req: Req, reply) => {
225
+ const s = await developer(req, reply);
226
+ if (!s) return;
227
+ const tx = tr(s);
228
+ if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change the policy.');
229
+ const policy = MFA_POLICIES.includes(req.body?.policy as MfaPolicy) ? (req.body!.policy as MfaPolicy) : null;
230
+ 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');
232
+ if (await policyProblem(policy)) {
233
+ flash(s, tx('mfa.policy_needs_admins'), 'error');
234
+ return back(reply, s, `${BASE}/instance#mfa`);
235
+ }
236
+ await setPolicy(policy, grace);
237
+ await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days` });
238
+ 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
+ flash(s, tx('mfa.policy_saved'));
240
+ return back(reply, s, `${BASE}/instance#mfa`);
241
+ });
242
+ }
243
+
244
+ /** The Instance settings region for the policy (administrators). */
245
+ export async function policyRegion(s: Session): Promise<Raw> {
246
+ const tx = tr(s);
247
+ const [pol, af, without] = await Promise.all([
248
+ mfaPolicy(), adminFactors(),
249
+ 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
+ ]);
251
+ 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>`;
252
+ return region(tx('mfa.policy_title'), html`<form method="post" action="${BASE}/instance/mfa">${csrf(s)}
253
+ <div class="form-grid">
254
+ <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
+ ${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
256
+ </div>
257
+ <p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
258
+ <div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
259
+ ${without.rows.length ? html`<p class="small">${tx('mfa.without', { list: without.rows.map((d) => `${d.username}${d.is_admin ? ' (admin)' : ''}`).join(', ') })}</p>` : ''}
260
+ <p class="muted small">${tx('mfa.cli_note')}</p>`, '', 'mfa');
261
+ }
package/src/builder/ui.ts CHANGED
@@ -12,6 +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
16
 
16
17
  // Shared building blocks of the builder UI.
17
18
 
@@ -112,6 +113,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
112
113
  // (sprint 74) the default-password warning: on the Workspace home (sprint 109), the App Builder home and the Developers page only, until
113
114
  // dismissed (for this sign-in); a dot on the avatar stays until the password is changed
114
115
  const weak = !!s.state.__WEAK;
116
+ const mfaDue = typeof s.state.__MFA_DUE === 'string' ? s.state.__MFA_DUE : '';
115
117
  const weakBanner = weak && !s.state.__WEAK_HIDDEN && crumbs.length === 1 && (crumbs[0][0] === 'Home' || crumbs[0][0] === 'App Builder' || crumbs[0][0] === 'Developers');
116
118
  const trail = crumbs.map(([label, href]): [string, string?] => [tx.label(label), href]);
117
119
  const back = [...trail].reverse().find(([, href], i) => i > 0 && href);
@@ -175,6 +177,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
175
177
  </form></div>
176
178
  <div class="menu-section menu-links">
177
179
  <a href="${BASE}/developers">${icon('key')} ${tx('user.change_password')}</a>
180
+ <a href="${BASE}/security">${icon('lock')} ${tx('mfa.menu')}</a>
178
181
  <form method="post" action="${BASE}/logout">${csrf(s)}<button>${icon('logout')} ${tx('user.sign_out')}</button></form>
179
182
  </div>
180
183
  </div></details></li>
@@ -191,7 +194,8 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
191
194
  <div class="ide-tools">${opts.toolbar ?? ''}</div>
192
195
  </header>
193
196
  ${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="${tx('bar.environment')}">${s.workspace.banner_label}</div>` : ''}
194
- ${weakBanner || ok || err ? html`<div class="ide-alerts">
197
+ ${weakBanner || mfaDue || ok || err ? html`<div class="ide-alerts">
198
+ ${mfaDue ? html`<div class="alert alert-warning" role="status">${tx.rich('mfa.banner', { date: mfaDue.slice(0, 10), link: html`<a href="${BASE}/security">${tx('mfa.set_up_now')}</a>` })}</div>` : ''}
195
199
  ${weakBanner ? html`<div class="alert alert-error alert-dismissible" role="alert"><span>${tx.rich('weak.banner', { link: html`<a href="${BASE}/developers">${tx('weak.change_now')}</a>` })}</span>
196
200
  <form method="post" action="${BASE}/weak/dismiss">${csrf(s)}<button class="link-button" aria-label="${tx('weak.dismiss')}" title="${tx('weak.dismiss')}">${icon('close')}</button></form></div>` : ''}
197
201
  ${ok ? html`<div class="alert alert-success" role="status">${ok}</div>` : ''}
@@ -317,6 +321,14 @@ export async function developer(req: Req, reply: FastifyReply) {
317
321
  }
318
322
  // (sprint 121) a session from before the developer's language was kept in it: the stored choice (language.ts)
319
323
  if (s.state.__BLANG === undefined) await loadLanguage(s);
324
+ // (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
325
+ delete s.state.__MFA_DUE;
326
+ const due = await enrolmentDue(s.username, await isAdmin(s.username));
327
+ if (due?.due === 'grace') s.state.__MFA_DUE = due.until.toISOString();
328
+ else if (due && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
329
+ reply.redirect(`${BASE}/security`);
330
+ return null;
331
+ }
320
332
  // (079) the developer toolbar on the applications' pages in this browser
321
333
  await ensureDevbar(req, reply, s);
322
334
  // workspaces: an application outside the developer's workspaces doesn't exist for them
package/src/cli/main.ts CHANGED
@@ -469,6 +469,57 @@ const COMMANDS: Record<string, Command> = {
469
469
  });
470
470
  },
471
471
  },
472
+ // (sprint 126, SEC-6) lockout recovery on the server, when nobody can sign in to the builder
473
+ admin: {
474
+ usage: 'pgkiln admin reset-2fa <developer> | set-2fa-policy off|admins|all [options]',
475
+ summary: 'lockout recovery: reset a developer\'s two-factor sign-in, set the two-factor policy',
476
+ details:
477
+ 'pgkiln admin reset-2fa <developer> [--reason <text>]\n' +
478
+ 'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n\n' +
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.',
483
+ options: { reason: { type: 'string' }, grace: { type: 'string' } },
484
+ optionHelp: [
485
+ ['--reason <text>', 'reset-2fa: why (in the activity log and the mail)'],
486
+ ['--grace <days>', 'set-2fa-policy: days developers have to set up an app (0 to 90, default 7)'],
487
+ ],
488
+ positionals: [2, 2],
489
+ async run(v, [action, arg]) {
490
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL; // the CLI runs as the owner
491
+ const mfa = await import('../mfa.ts');
492
+ const { logActivity } = await import('../session.ts');
493
+ const { closePools } = await import('../db.ts');
494
+ const by = `pgkiln admin (${userInfo().username})`;
495
+ try {
496
+ if (action === 'reset-2fa') {
497
+ const reason = String(v.reason ?? 'command line').slice(0, 200);
498
+ if (!(await mfa.resetFactors(arg))) throw new Error(`no developer ${arg}`);
499
+ await logActivity({ username: arg, event: '2fa_reset', ip: 'cli', detail: `${arg}: ${reason} (${by})` });
500
+ const sent = await mfa.notifyMfa(await mfa.adminEmails(), `pgkiln: two-factor sign-in of ${arg} reset`,
501
+ `${by} removed the second factors of developer ${arg} on the server (reason: ${reason}).`);
502
+ out(`Two-factor sign-in of ${arg} reset; their builder sessions ended.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
503
+ return EXIT.ok;
504
+ }
505
+ if (action === 'set-2fa-policy') {
506
+ if (!mfa.MFA_POLICIES.includes(arg as never)) throw new UsageError('set-2fa-policy takes off, admins or all');
507
+ const grace = v.grace === undefined ? (await mfa.mfaPolicy()).graceDays : Number(v.grace);
508
+ if (!Number.isInteger(grace) || grace < 0 || grace > 90) throw new UsageError('--grace takes 0 to 90 days');
509
+ const policy = arg as 'off' | 'admins' | 'all';
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
+ await mfa.setPolicy(policy, grace);
512
+ await logActivity({ event: '2fa_policy', ip: 'cli', detail: `${policy}, grace ${grace} days (${by})` });
513
+ 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`);
515
+ return EXIT.ok;
516
+ }
517
+ throw new UsageError(`unknown action ${action}: use reset-2fa or set-2fa-policy`);
518
+ } finally {
519
+ await closePools();
520
+ }
521
+ },
522
+ },
472
523
  };
473
524
 
474
525
  // ------------------------------------------------------------------ help and dispatch
package/src/mfa.ts ADDED
@@ -0,0 +1,260 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import type pg from 'pg';
3
+ import { owner } from './db.ts';
4
+ import { decryptSecret, encryptSecret } from './secrets.ts';
5
+
6
+ // (sprint 126, SEC-2 and SEC-6) Two-factor sign-in for developers: authenticator apps (TOTP, RFC 6238:
7
+ // HMAC-SHA1, 30-second steps, 6 digits; one step either side is accepted for clock drift, and a step is
8
+ // accepted only once) and one-time recovery codes. The policy (off, administrators, all developers) is a
9
+ // setting; it can only be required while two administrators (or every administrator, when there are fewer)
10
+ // have a working second factor, so it never locks the instance out. The command line resets a developer's
11
+ // factors and turns the policy off without signing in (src/cli/main.ts, `pgkiln admin`).
12
+
13
+ export const TOTP_STEP = 30;
14
+ export const TOTP_DIGITS = 6;
15
+ export const RECOVERY_CODES = 10;
16
+ export type MfaPolicy = 'off' | 'admins' | 'all';
17
+ export const MFA_POLICIES: MfaPolicy[] = ['off', 'admins', 'all'];
18
+
19
+ const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
20
+ export function base32(buf: Buffer): string {
21
+ let bits = 0, value = 0, out = '';
22
+ for (const b of buf) {
23
+ value = (value << 8) | b;
24
+ bits += 8;
25
+ while (bits >= 5) {
26
+ out += B32[(value >>> (bits - 5)) & 31];
27
+ bits -= 5;
28
+ }
29
+ }
30
+ if (bits > 0) out += B32[(value << (5 - bits)) & 31];
31
+ return out;
32
+ }
33
+ export function unbase32(text: string): Buffer {
34
+ const clean = text.toUpperCase().replace(/[\s=]/g, '');
35
+ let bits = 0, value = 0;
36
+ const out: number[] = [];
37
+ for (const ch of clean) {
38
+ const i = B32.indexOf(ch);
39
+ if (i < 0) throw new Error('not base32');
40
+ value = (value << 5) | i;
41
+ bits += 5;
42
+ if (bits >= 8) {
43
+ out.push((value >>> (bits - 8)) & 255);
44
+ bits -= 8;
45
+ }
46
+ }
47
+ return Buffer.from(out);
48
+ }
49
+
50
+ /** A new authenticator secret: 160 random bits, base32 (what apps expect). */
51
+ export const newTotpSecret = () => base32(randomBytes(20));
52
+
53
+ /** The code of one 30-second step (RFC 4226 dynamic truncation). */
54
+ export function totpAt(secret: Buffer, step: number, digits = TOTP_DIGITS): string {
55
+ const msg = Buffer.alloc(8);
56
+ msg.writeBigUInt64BE(BigInt(step));
57
+ const h = createHmac('sha1', secret).update(msg).digest();
58
+ const o = h[h.length - 1] & 15;
59
+ const n = ((h[o] & 127) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3];
60
+ return String(n % 10 ** digits).padStart(digits, '0');
61
+ }
62
+
63
+ export const stepNow = (ms = Date.now()) => Math.floor(ms / 1000 / TOTP_STEP);
64
+
65
+ /** The step a code matches (now, or one step either side), newer than `after`; null otherwise. */
66
+ export function matchTotp(secret: Buffer, code: string, after: number | null, now = stepNow()): number | null {
67
+ if (!/^\d{6}$/.test(code)) return null;
68
+ const given = Buffer.from(code);
69
+ let found: number | null = null;
70
+ for (const step of [now - 1, now, now + 1]) {
71
+ if (after !== null && step <= after) continue;
72
+ if (timingSafeEqual(Buffer.from(totpAt(secret, step)), given)) found = step;
73
+ }
74
+ return found;
75
+ }
76
+
77
+ /** The otpauth URI an authenticator app reads from the QR code. */
78
+ export const otpauthUri = (secret: string, account: string, issuer = 'pgkiln') =>
79
+ `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP}`;
80
+
81
+ // ---------------------------------------------------------------- recovery codes
82
+
83
+ const CODE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; // no 0/o, 1/l/i
84
+ /** A recovery code: ten characters in two groups (xxxxx-xxxxx), about 50 bits. */
85
+ export function newRecoveryCode(): string {
86
+ const bytes = randomBytes(10);
87
+ const chars = [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
88
+ return `${chars.slice(0, 5)}-${chars.slice(5)}`;
89
+ }
90
+ export const normaliseCode = (code: string) => code.toLowerCase().replace(/[^a-z0-9]/g, '');
91
+ export const hashCode = (code: string) => createHash('sha256').update(normaliseCode(code)).digest('hex');
92
+
93
+ // ---------------------------------------------------------------- the developer's factors
94
+
95
+ export interface Factor { id: number; kind: 'totp'; name: string; created_at: string; last_used_at: string | null }
96
+
97
+ 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;
99
+ }
100
+
101
+ /** Recovery codes left (confirmed, unused). */
102
+ export async function codesLeft(username: string): Promise<number> {
103
+ return (await owner.one<{ n: number }>('select count(*)::int as n from meta.developer_recovery_code where username = $1 and confirmed and used_at is null', [username]))!.n;
104
+ }
105
+
106
+ /** Whether the developer has a working second factor (an authenticator app; recovery codes back it up). */
107
+ export async function hasFactor(username: string): Promise<boolean> {
108
+ return !!(await owner.one('select 1 as ok from meta.developer_factor where username = $1', [username]));
109
+ }
110
+
111
+ /** Add an authenticator app (after the developer confirmed a code from it). */
112
+ export async function addTotp(username: string, name: string, secret: string, step: number) {
113
+ await owner.query(
114
+ `insert into meta.developer_factor (username, kind, name, secret_enc, last_step, last_used_at) values ($1, 'totp', $2, $3, $4, now())`,
115
+ [username, name.trim().slice(0, 60) || 'Authenticator', encryptSecret(secret), step],
116
+ );
117
+ }
118
+
119
+ /** New recovery codes (the old ones stop working); shown once, counted only when confirmed. */
120
+ export async function newRecoveryCodes(username: string): Promise<string[]> {
121
+ const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
122
+ await owner.tx(async (c) => {
123
+ await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
124
+ for (const code of codes) await c.query('insert into meta.developer_recovery_code (username, code_hash) values ($1, $2)', [username, hashCode(code)]);
125
+ });
126
+ return codes;
127
+ }
128
+
129
+ export async function confirmRecoveryCodes(username: string) {
130
+ await owner.query('update meta.developer_recovery_code set confirmed = true where username = $1 and used_at is null', [username]);
131
+ }
132
+
133
+ /**
134
+ * Check a second-factor code at sign-in: an authenticator code (each step once) or a confirmed, unused
135
+ * recovery code (then used up). Returns what matched, or null.
136
+ */
137
+ export async function checkSecondFactor(username: string, code: string): Promise<{ kind: 'totp' | 'recovery'; name?: string } | null> {
138
+ const digits = code.replace(/\s/g, '');
139
+ if (/^\d{6}$/.test(digits)) {
140
+ const rows = (await owner.query<{ id: number; name: string; secret_enc: string; last_step: string | null }>(
141
+ 'select id, name, secret_enc, last_step from meta.developer_factor where username = $1 and kind = $2', [username, 'totp'])).rows;
142
+ for (const f of rows) {
143
+ let secret: Buffer;
144
+ try {
145
+ secret = unbase32(decryptSecret(f.secret_enc));
146
+ } catch {
147
+ continue; // the instance key changed: recovery codes still work
148
+ }
149
+ const step = matchTotp(secret, digits, f.last_step === null ? null : Number(f.last_step));
150
+ // the update only wins once per step, also with two requests at the same time
151
+ if (step !== null && (await owner.query('update meta.developer_factor set last_step = $2, last_used_at = now() where id = $1 and (last_step is null or last_step < $2)', [f.id, step])).rowCount)
152
+ return { kind: 'totp', name: f.name };
153
+ }
154
+ return null;
155
+ }
156
+ if (normaliseCode(code).length !== 10) return null;
157
+ const used = await owner.query(
158
+ 'update meta.developer_recovery_code set used_at = now() where username = $1 and code_hash = $2 and confirmed and used_at is null',
159
+ [username, hashCode(code)]);
160
+ return used.rowCount ? { kind: 'recovery' } : null;
161
+ }
162
+
163
+ // ---------------------------------------------------------------- policy
164
+
165
+ export async function mfaPolicy(): Promise<{ policy: MfaPolicy; since: Date | null; graceDays: number }> {
166
+ const rows = (await owner.query<{ name: string; value: string }>(`select name, value from meta.setting where name in ('mfa_required', 'mfa_required_since', 'mfa_grace_days')`)).rows;
167
+ const get = (n: string) => rows.find((r) => r.name === n)?.value;
168
+ const p = get('mfa_required');
169
+ const since = get('mfa_required_since');
170
+ const grace = Number(get('mfa_grace_days'));
171
+ return { policy: MFA_POLICIES.includes(p as MfaPolicy) ? (p as MfaPolicy) : 'off', since: since ? new Date(since) : null, graceDays: Number.isInteger(grace) && grace >= 0 && grace <= 90 ? grace : 7 };
172
+ }
173
+
174
+ /** Does the policy require a second factor of this developer? */
175
+ export const requiredFor = (policy: MfaPolicy, isAdmin: boolean) => policy === 'all' || (policy === 'admins' && isAdmin);
176
+
177
+ /** The administrators, and how many of them have a working second factor. */
178
+ export async function adminFactors(): Promise<{ admins: number; enrolled: number }> {
179
+ return (await owner.one<{ admins: number; enrolled: number }>(
180
+ `select count(*)::int as admins,
181
+ count(*) filter (where exists (select 1 from meta.developer_factor f where f.username = d.username))::int as enrolled
182
+ from meta.developer d where d.is_admin`))!;
183
+ }
184
+
185
+ /** Why the policy can't be required now (two administrators with a factor, or every one when fewer), or null. */
186
+ export async function policyProblem(policy: MfaPolicy): Promise<'admins' | null> {
187
+ if (policy === 'off') return null;
188
+ const { admins, enrolled } = await adminFactors();
189
+ return enrolled >= Math.min(2, admins) && enrolled > 0 ? null : 'admins';
190
+ }
191
+
192
+ /** (SEC-6) Would taking `username` out of the administrators (a role change, removal) break the rule above? */
193
+ export async function leavesTooFewAdmins(username: string): Promise<boolean> {
194
+ const { policy } = await mfaPolicy();
195
+ if (policy === 'off') return false;
196
+ const r = (await owner.one<{ admins: number; enrolled: number }>(
197
+ `select count(*)::int as admins,
198
+ count(*) filter (where exists (select 1 from meta.developer_factor f where f.username = d.username))::int as enrolled
199
+ from meta.developer d where d.is_admin and d.username <> $1`, [username]))!;
200
+ return !(r.enrolled >= Math.min(2, r.admins) && r.enrolled > 0);
201
+ }
202
+
203
+ /** Set the policy; `force` skips the two-administrator rule (only the command line uses it, to turn it off). */
204
+ export async function setPolicy(policy: MfaPolicy, graceDays: number) {
205
+ await owner.tx(async (c) => {
206
+ const cur = (await c.query<{ value: string }>(`select value from meta.setting where name = 'mfa_required'`)).rows[0]?.value ?? 'off';
207
+ await c.query(`insert into meta.setting (name, value) values ('mfa_required', $1), ('mfa_grace_days', $2) on conflict (name) do update set value = excluded.value`, [policy, String(graceDays)]);
208
+ // the grace period counts from when a stricter policy was turned on
209
+ if (policy !== 'off' && (cur === 'off' || (cur === 'admins' && policy === 'all')))
210
+ await c.query(`insert into meta.setting (name, value) values ('mfa_required_since', now()::text) on conflict (name) do update set value = excluded.value`);
211
+ if (policy === 'off') await c.query(`delete from meta.setting where name = 'mfa_required_since'`);
212
+ });
213
+ }
214
+
215
+ /**
216
+ * A developer without a factor while the policy requires one: 'grace' until the grace period ends (a
217
+ * banner), then 'enrol' (only the Security page and signing out until they enrol); null otherwise.
218
+ */
219
+ 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);
223
+ return { due: Date.now() < until.getTime() ? 'grace' : 'enrol', until };
224
+ }
225
+
226
+ /** Remove every second factor of a developer (an administrator, or the command line) and end their sessions. */
227
+ export async function resetFactors(username: string): Promise<boolean> {
228
+ return owner.tx(async (c) => {
229
+ const known = (await c.query('select 1 from meta.developer where username = $1', [username])).rowCount;
230
+ if (!known) return false;
231
+ await c.query('delete from meta.developer_factor where username = $1', [username]);
232
+ await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
233
+ await c.query('delete from meta.session where app_id is null and username = $1', [username]);
234
+ return true;
235
+ });
236
+ }
237
+
238
+ /** The e-mail addresses of the administrators (to tell them about a reset or a policy change). */
239
+ export async function adminEmails(): Promise<string[]> {
240
+ return (await owner.query<{ email: string }>(`select email from meta.developer where is_admin and email is not null`)).rows.map((r) => r.email);
241
+ }
242
+
243
+ /** Tell people about a reset or a policy change by mail, when the builder has a mail service (best effort). */
244
+ export async function notifyMfa(to: string[], subject: string, text: string): Promise<number> {
245
+ const list = [...new Set(to.filter(Boolean))];
246
+ if (!list.length) return 0;
247
+ const { builderMail } = await import('./builder/find-workspace.ts');
248
+ const { sendWith } = await import('./mail/mail.ts');
249
+ const mail = await builderMail().catch(() => null);
250
+ if (!mail) return 0;
251
+ let sent = 0;
252
+ for (const address of list)
253
+ try {
254
+ await sendWith(mail.service, { from: mail.from, to: [address], cc: [], bcc: [], replyTo: null, subject, text, html: null, attachments: [] });
255
+ sent++;
256
+ } catch {
257
+ // the activity log has the event; a mail that fails doesn't undo it
258
+ }
259
+ return sent;
260
+ }