pgkiln 0.65.0 → 0.67.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,260 @@
1
+ import { owner } from './db.ts';
2
+ import { breakGlassAdmins } from './devsso.ts';
3
+ import { mfaPolicy } from './mfa.ts';
4
+ import { remoteClockOffset } from './release-check.ts';
5
+ import { rpIdOf } from './webauthn.ts';
6
+ import { decryptSecret, encryptSecret, keyFingerprint, secretKeyConfigured } from './secrets.ts';
7
+
8
+ // (sprint 133, SEC-6) Lockout checks of the instance: what could keep developers or application users out
9
+ // later, found before it happens. Shown on Instance settings (administrators), counted on the Workspace home
10
+ // and printed by `pgkiln admin check`.
11
+ //
12
+ // - Instance key: secrets stored without PGKILN_SECRET_KEY, secrets the current key can't decrypt (the key
13
+ // changed), and whether an administrator confirmed a backup of this key (meta.setting secret_key_backup,
14
+ // by fingerprint, so a new key asks again). Authenticator apps are encrypted with it; passkeys and the
15
+ // hashed recovery codes are not.
16
+ // - Clock: TOTP codes allow one 30-second step either side, so the server's clock is compared with the
17
+ // database's and, when the release check is on, with GitHub's.
18
+ // - Passkey domain: passkeys made for another RP ID than the one PUBLIC_URL (or the given address) gives,
19
+ // and how many people have no other factor left. `pgkiln admin check --url` asks before a move.
20
+ // - Break-glass: a workspace that requires single sign-on needs a break-glass administrator, and one with
21
+ // a second factor when the policy requires it.
22
+ //
23
+ // rotateKey() re-encrypts every stored secret from an old key to a new one in one transaction.
24
+
25
+ export type Level = 'ok' | 'info' | 'warning' | 'error';
26
+
27
+ export interface Check {
28
+ key: 'secret_key' | 'key_backup' | 'clock' | 'passkey_domain' | 'break_glass';
29
+ level: Level;
30
+ title: string;
31
+ detail: string;
32
+ }
33
+
34
+ /** Every column that holds a secret encrypted with the instance key ("v1:…"). */
35
+ export interface SecretColumn {
36
+ schema: string;
37
+ table: string;
38
+ column: string;
39
+ }
40
+
41
+ export async function encryptedColumns(): Promise<SecretColumn[]> {
42
+ return (await owner.query<SecretColumn>(
43
+ `select table_schema as schema, table_name as table, column_name as column from information_schema.columns
44
+ where table_schema = 'meta' and data_type = 'text'
45
+ and (column_name like '%\\_enc' or (table_name = 'push_key' and column_name = 'private_key'))
46
+ order by 2, 3`)).rows;
47
+ }
48
+
49
+ const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`;
50
+ const tableOf = (c: SecretColumn) => `${quoteIdent(c.schema)}.${quoteIdent(c.table)}`;
51
+
52
+ /** Stored secrets per column, and how many the given key (default: the server's) can't decrypt. */
53
+ export async function secretStatus(withKey?: string) {
54
+ const out: (SecretColumn & { stored: number; unreadable: number })[] = [];
55
+ const usable = (withKey ?? process.env.PGKILN_SECRET_KEY ?? '').length >= 32;
56
+ for (const c of await encryptedColumns()) {
57
+ const rows = (await owner.query<{ v: string }>(`select ${quoteIdent(c.column)} as v from ${tableOf(c)} where ${quoteIdent(c.column)} is not null`)).rows;
58
+ let unreadable = 0;
59
+ for (const r of rows) {
60
+ try {
61
+ if (!usable) throw new Error();
62
+ decryptSecret(r.v, withKey);
63
+ } catch {
64
+ unreadable++;
65
+ }
66
+ }
67
+ if (rows.length) out.push({ ...c, stored: rows.length, unreadable });
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export interface KeyBackup {
73
+ fingerprint: string;
74
+ by: string;
75
+ at: string;
76
+ }
77
+
78
+ export async function keyBackup(): Promise<KeyBackup | null> {
79
+ const v = (await owner.one<{ value: string }>(`select value from meta.setting where name = 'secret_key_backup'`))?.value;
80
+ try {
81
+ const b = v ? JSON.parse(v) : null;
82
+ return b && typeof b.fingerprint === 'string' ? (b as KeyBackup) : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** An administrator says the current key is kept somewhere safe (false: no key configured). */
89
+ export async function confirmKeyBackup(by: string) {
90
+ const fingerprint = keyFingerprint();
91
+ if (!fingerprint) return false;
92
+ const value = JSON.stringify({ fingerprint, by, at: new Date().toISOString() });
93
+ await owner.query(`insert into meta.setting (name, value) values ('secret_key_backup', $1) on conflict (name) do update set value = excluded.value`, [value]);
94
+ return true;
95
+ }
96
+
97
+ /** Milliseconds this server's clock is ahead of the database's (half the round trip taken off). */
98
+ export async function databaseClockOffset() {
99
+ const t0 = Date.now();
100
+ const r = await owner.one<{ ms: string }>(`select (extract(epoch from clock_timestamp()) * 1000)::bigint as ms`);
101
+ const t1 = Date.now();
102
+ return Math.round((t0 + t1) / 2 - Number(r!.ms));
103
+ }
104
+
105
+ export const CLOCK_WARN_MS = 10_000;
106
+ export const CLOCK_ERROR_MS = 30_000;
107
+
108
+ export interface PasskeyDomain {
109
+ rpId: string;
110
+ who: 'developers' | 'app users';
111
+ passkeys: number;
112
+ people: number;
113
+ /** people with no authenticator app and no passkey for the current RP ID */
114
+ stranded: number;
115
+ }
116
+
117
+ /** Passkeys made for another RP ID than rpId. */
118
+ export async function passkeysElsewhere(rpId: string): Promise<PasskeyDomain[]> {
119
+ const part = (table: string, key: string, who: string) => `
120
+ select '${who}'::text as who, coalesce(f.rp_id, '(unknown)') as "rpId", count(*)::int as passkeys, count(distinct f.${key})::int as people,
121
+ count(distinct f.${key}) filter (where not exists (select 1 from meta.${table} o where o.${key} = f.${key}
122
+ and (o.kind = 'totp' or (o.kind = 'passkey' and o.rp_id = $1))))::int as stranded
123
+ from meta.${table} f where f.kind = 'passkey' and f.rp_id is distinct from $1 group by f.rp_id`;
124
+ return (await owner.query<PasskeyDomain>(`${part('developer_factor', 'username', 'developers')} union all ${part('account_factor', 'account_id', 'app users')} order by 2, 1`, [rpId])).rows;
125
+ }
126
+
127
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
128
+
129
+ /** All checks; rpId: the RP ID passkeys must have (from PUBLIC_URL or the request; null skips that check). */
130
+ export async function instanceChecks(rpId: string | null): Promise<Check[]> {
131
+ const checks: Check[] = [];
132
+
133
+ // the instance key
134
+ const status = await secretStatus();
135
+ const stored = status.reduce((n, s) => n + s.stored, 0);
136
+ const unreadable = status.filter((s) => s.unreadable);
137
+ if (!secretKeyConfigured())
138
+ checks.push(stored
139
+ ? { key: 'secret_key', level: 'error', title: 'Instance key missing', detail: `${plural(stored, 'secret')} are stored (${status.map((s) => `${s.table}.${s.column}`).join(', ')}), but PGKILN_SECRET_KEY is not set: authenticator apps, web credentials and mail passwords don't work. Set the key they were stored with.` }
140
+ : { key: 'secret_key', level: 'info', title: 'No instance key', detail: 'PGKILN_SECRET_KEY is not set, so nothing can be stored encrypted (authenticator apps, web credentials, mail and AI keys). Set one of at least 32 characters, e.g. the output of openssl rand -base64 32.' });
141
+ else if (unreadable.length)
142
+ checks.push({ key: 'secret_key', level: 'error', title: 'Secrets the instance key can\'t decrypt', detail: `${unreadable.map((s) => `${s.table}.${s.column}: ${s.unreadable} of ${s.stored}`).join('; ')}. PGKILN_SECRET_KEY changed without pgkiln admin rotate-key: set the old key again and rotate, or enter those secrets again (developers set up their authenticator app again after a reset).` });
143
+ else checks.push({ key: 'secret_key', level: 'ok', title: 'Instance key', detail: stored ? `PGKILN_SECRET_KEY decrypts all ${plural(stored, 'stored secret')}.` : 'PGKILN_SECRET_KEY is set; no secrets are stored yet.' });
144
+
145
+ if (secretKeyConfigured()) {
146
+ const backup = await keyBackup();
147
+ const fp = keyFingerprint()!;
148
+ checks.push(backup?.fingerprint === fp
149
+ ? { key: 'key_backup', level: 'ok', title: 'Instance key backed up', detail: `${backup.by} confirmed a copy of the key with fingerprint ${fp} on ${backup.at.slice(0, 10)}.` }
150
+ : { key: 'key_backup', level: 'warning', title: 'Instance key not confirmed as backed up', detail: `${backup ? 'The key changed since the last confirmation. ' : ''}Keep a copy of PGKILN_SECRET_KEY (fingerprint ${fp}) outside this server, e.g. in a password manager: without it the authenticator apps and stored credentials are lost. Passkeys and recovery codes keep working.` });
151
+ }
152
+
153
+ // the clock
154
+ const db = await databaseClockOffset();
155
+ const remote = remoteClockOffset();
156
+ const worst = Math.max(Math.abs(db), Math.abs(remote?.offsetMs ?? 0));
157
+ const s = (ms: number) => (Math.abs(ms) < 50 ? '0.0 s' : `${ms > 0 ? '+' : '−'}${(Math.abs(ms) / 1000).toFixed(1)} s`);
158
+ const seen = `database (${s(db)})${remote ? ` and GitHub's (${s(remote.offsetMs)}, at the release check)` : ''}`;
159
+ checks.push(worst >= CLOCK_ERROR_MS
160
+ ? { key: 'clock', level: 'error', title: 'Server clock is off', detail: `This server's clock differs from the ${seen}. Authenticator codes fail beyond about 30 seconds: synchronise the clock (NTP, e.g. systemd-timesyncd or chrony).` }
161
+ : worst >= CLOCK_WARN_MS
162
+ ? { key: 'clock', level: 'warning', title: 'Server clock drifts', detail: `This server's clock differs from the ${seen}. Authenticator codes fail beyond about 30 seconds: check the time synchronisation (NTP).` }
163
+ : { key: 'clock', level: 'ok', title: 'Clock', detail: `This server's clock agrees with the ${seen}.` });
164
+
165
+ // passkeys of another domain
166
+ if (rpId) {
167
+ const away = await passkeysElsewhere(rpId);
168
+ const strandedTotal = away.reduce((n, a) => n + a.stranded, 0);
169
+ checks.push(away.length
170
+ ? { key: 'passkey_domain', level: strandedTotal ? 'warning' : 'info', title: `Passkeys for another domain than ${rpId}`,
171
+ detail: `${away.map((a) => `${a.rpId}: ${plural(a.passkeys, 'passkey')} of ${plural(a.people, a.who === 'developers' ? 'developer' : 'app user')}${a.stranded ? `, ${a.stranded} with no other factor` : ''}`).join('; ')}. Passkeys work only on the domain they were made for${strandedTotal ? ': people with no other factor need a recovery code or a reset' : ''}. Set PUBLIC_URL (or PGKILN_RP_ID) back, or have them add passkeys again.` }
172
+ : { key: 'passkey_domain', level: 'ok', title: 'Passkey domain', detail: `Every passkey is for ${rpId}.` });
173
+ }
174
+
175
+ // break-glass administrators
176
+ const ssoOnly = (await owner.query<{ name: string }>(`select name from meta.workspace where sso_only order by name`)).rows.map((r) => r.name);
177
+ if (ssoOnly.length) {
178
+ const glass = await breakGlassAdmins();
179
+ const policy = (await mfaPolicy()).policy;
180
+ const withFactor = glass.length
181
+ ? (await owner.query<{ username: string }>(`select distinct username from meta.developer_factor where username = any($1)`, [glass])).rows.map((r) => r.username)
182
+ : [];
183
+ checks.push(!glass.length
184
+ ? { key: 'break_glass', level: 'error', title: 'No break-glass administrator', detail: `${ssoOnly.join(', ')} require${ssoOnly.length === 1 ? 's' : ''} single sign-on and no administrator may use the pgkiln password: when the identity provider fails, only pgkiln admin builder-sign-in password on the server gets anyone in. Mark an administrator as break-glass on Developers.` }
185
+ : policy !== 'off' && withFactor.length < glass.length
186
+ ? { key: 'break_glass', level: 'warning', title: 'Break-glass administrator without a second factor', detail: `${glass.filter((g) => !withFactor.includes(g)).join(', ')} must use two-factor sign-in but has no authenticator app or passkey: add one on Security before it is needed.` }
187
+ : { key: 'break_glass', level: 'ok', title: 'Break-glass administrators', detail: `${glass.join(', ')} may sign in with the pgkiln password when the identity provider fails (${ssoOnly.join(', ')} require${ssoOnly.length === 1 ? 's' : ''} single sign-on).` });
188
+ }
189
+ return checks;
190
+ }
191
+
192
+ /** How many checks need attention (warnings and errors). */
193
+ export const attention = (checks: Check[]) => checks.filter((c) => c.level === 'warning' || c.level === 'error').length;
194
+
195
+ let cached: { at: number; count: number } | null = null;
196
+
197
+ /** (Workspace home) how many checks need attention, at most five minutes old; never throws. */
198
+ export async function attentionCount(): Promise<number> {
199
+ if (cached && Date.now() - cached.at < 300_000) return cached.count;
200
+ try {
201
+ const pub = process.env.PUBLIC_URL;
202
+ const rpId = pub && URL.canParse(pub) ? rpIdOf(new URL(pub).origin) : null;
203
+ cached = { at: Date.now(), count: attention(await instanceChecks(rpId)) };
204
+ } catch {
205
+ return cached?.count ?? 0;
206
+ }
207
+ return cached.count;
208
+ }
209
+
210
+ /** Forget the cached count (after a change on Instance settings, and in tests). */
211
+ export const forgetAttention = () => {
212
+ cached = null;
213
+ };
214
+
215
+ export class RotateError extends Error {}
216
+
217
+ /**
218
+ * Re-encrypt every stored secret from oldKey to newKey, in one transaction. A value the new key already
219
+ * decrypts is left alone (a second run after a partial restart changes nothing); a value neither key
220
+ * decrypts stops the rotation and nothing is changed. dryRun: count only.
221
+ */
222
+ export async function rotateKey(oldKey: string, newKey: string, { dryRun = false, columns }: { dryRun?: boolean; columns?: SecretColumn[] } = {}) {
223
+ if (oldKey.length < 32 || newKey.length < 32) throw new RotateError('both keys need at least 32 characters');
224
+ if (oldKey === newKey) throw new RotateError('the new key is the old key');
225
+ const cols = columns ?? (await encryptedColumns());
226
+ return owner.tx(async (c) => {
227
+ const counts: (SecretColumn & { rotated: number; already: number })[] = [];
228
+ const failed: string[] = [];
229
+ for (const col of cols) {
230
+ const rows = (await c.query<{ ctid: string; v: string }>(
231
+ `select ctid::text as ctid, ${quoteIdent(col.column)} as v from ${tableOf(col)} where ${quoteIdent(col.column)} is not null for update`)).rows;
232
+ let rotated = 0;
233
+ let already = 0;
234
+ for (const r of rows) {
235
+ let plain: string;
236
+ try {
237
+ decryptSecret(r.v, newKey);
238
+ already++;
239
+ continue;
240
+ } catch {
241
+ // not yet rotated
242
+ }
243
+ try {
244
+ plain = decryptSecret(r.v, oldKey);
245
+ } catch {
246
+ failed.push(`${col.schema}.${col.table}.${col.column}`);
247
+ continue;
248
+ }
249
+ if (!dryRun) await c.query(`update ${tableOf(col)} set ${quoteIdent(col.column)} = $1 where ctid = $2::tid`, [encryptSecret(plain, newKey), r.ctid]);
250
+ rotated++;
251
+ }
252
+ if (rows.length) counts.push({ ...col, rotated, already });
253
+ }
254
+ if (failed.length) {
255
+ const by = [...new Set(failed)].map((f) => `${f} (${failed.filter((x) => x === f).length})`).join(', ');
256
+ throw new RotateError(`secrets that neither key decrypts: ${by}; nothing was changed`);
257
+ }
258
+ return counts;
259
+ });
260
+ }
package/src/instance.ts CHANGED
@@ -120,7 +120,7 @@ export const CONFIGURATION: readonly { env: string; secret?: boolean; default: s
120
120
  { env: 'BACKGROUND_PROCESSES', default: 'on', help: 'This server runs background execution chains' },
121
121
  { env: 'PGKILN_REST_ALLOWED_HOSTS', default: '(none)', help: 'Hosts REST data sources may call' },
122
122
  { env: 'PGKILN_REST_PRIVATE_HOSTS', default: '(none)', help: 'Hosts that may resolve to private addresses' },
123
- { env: 'PGKILN_SECRET_KEY', secret: true, default: '(none)', help: 'Encrypts web credential secrets and AI keys' },
123
+ { env: 'PGKILN_SECRET_KEY', secret: true, default: '(none)', help: 'Encrypts authenticator apps, web credential secrets, mail and AI keys (pgkiln admin rotate-key changes it)' },
124
124
  { env: 'API_URL', default: '(none)', help: 'PostgREST address (REST API pages)' },
125
125
  { env: 'API_JWT_SECRET', secret: true, default: '(none)', help: 'Signs REST API tokens' },
126
126
  { env: 'ANTHROPIC_API_KEY', secret: true, default: '(none)', help: 'Default key of Claude AI services' },
@@ -29,6 +29,12 @@ export interface Release {
29
29
 
30
30
  let cache: { at: number; release: Release | null; error: string | null } | null = null;
31
31
  let inflight: Promise<void> | null = null;
32
+ let remoteClock: { at: number; offsetMs: number } | null = null;
33
+
34
+ /** How far this server's clock was ahead of GitHub's at the last release check (null: not asked yet). */
35
+ export function remoteClockOffset() {
36
+ return remoteClock;
37
+ }
32
38
 
33
39
  /** The address asked (PGKILN_RELEASE_URL for tests and mirrors; http(s) only). */
34
40
  function releaseUrl() {
@@ -91,6 +97,9 @@ async function fetchLatest(): Promise<Release | null> {
91
97
  redirect: 'error',
92
98
  signal: AbortSignal.timeout(TIMEOUT_MS),
93
99
  });
100
+ // (sprint 133, SEC-6) GitHub's clock, for the clock check on Instance settings (the header has whole seconds)
101
+ const date = Date.parse(res.headers.get('date') ?? '');
102
+ if (!Number.isNaN(date)) remoteClock = { at: Date.now(), offsetMs: Date.now() - date };
94
103
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
95
104
  const reader = res.body.getReader();
96
105
  const chunks: Uint8Array[] = [];
@@ -136,4 +145,5 @@ export function cachedRelease() {
136
145
  /** Forget the cache (tests). */
137
146
  export function resetReleaseCache() {
138
147
  cache = null;
148
+ remoteClock = null;
139
149
  }
@@ -49,7 +49,7 @@ import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts
49
49
  import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
50
50
  import { passwordResetRoutes } from './password-reset.ts';
51
51
  import { passwordSignIn, twoStepRoutes } from './two-step.ts';
52
- import { carriesSecondStep } from '../account-mfa.ts';
52
+ import { carriesSecondStep, markSecondStep } from '../account-mfa.ts';
53
53
  import { devbarOf, devbarRoutes, sessionDebugLevel } from './devbar.ts';
54
54
  import { availabilityRefusal } from './availability.ts';
55
55
  import { feedbackRoutes } from './feedback.ts';
@@ -141,6 +141,8 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
141
141
  // (067) session sharing: the session belongs to the group's shared sign-in (a new one after a real sign-in)
142
142
  const shared = opts.shared ?? (await issueShared(req, reply, a, username, { groups: opts.groups, method: opts.method }));
143
143
  if (shared) s.state.__SHARED = shared;
144
+ // (sprint 132) how the session signed in: a step-up after "sso:<provider>" goes back to that provider
145
+ if (opts.method) s.state.__SIGNIN = opts.method;
144
146
  await saveState(s);
145
147
 
146
148
  // the application processes "after login", then (102) the application computations "after authentication"
@@ -162,6 +164,9 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
162
164
  return s;
163
165
  }
164
166
 
167
+ /** (sprint 132) Set by the runtime routes: a step-up through an identity provider (two-step.ts). */
168
+ export let ssoStepUp: (reply: FastifyReply, a: App, s: Session, p: Provider, next: string) => Promise<unknown>;
169
+
165
170
  export async function completeLogin(req: FastifyRequest, reply: FastifyReply, a: App, oldSession: Session, username: string, opts: SignInOptions & { next?: string } = {}) {
166
171
  await signIn(req, reply, a, oldSession, username, opts);
167
172
  return reply.redirect(safeNext(a, opts.next), 303);
@@ -1345,6 +1350,13 @@ export async function runtimeRoutes(app: FastifyInstance) {
1345
1350
  }
1346
1351
  });
1347
1352
 
1353
+ /** (sprint 132) A step-up through the identity provider the session signed in with (two-step.ts). */
1354
+ ssoStepUp = async (reply: FastifyReply, a: App, s: Session, p: Provider, next: string) => {
1355
+ const { url, browserKey } = p.protocol === 'saml' ? await startSamlSignIn(p, a.id, next, undefined, s.id) : await startSignIn(p, a.id, next, undefined, s.id);
1356
+ reply.setCookie(SSO_COOKIE, browserKey, { path: '/sso', httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 600 });
1357
+ return reply.redirect(url, 303);
1358
+ };
1359
+
1348
1360
  app.get<{ Params: { provider: string } }>('/sso/callback/:provider', async (req, reply) => {
1349
1361
  const ip = clientIp(req);
1350
1362
  const p = await loadProvider(req.params.provider);
@@ -1375,9 +1387,24 @@ export async function runtimeRoutes(app: FastifyInstance) {
1375
1387
  return simplePage(reply, 403, english('login.sso_failed'), e.message, result.link ? '/builder/security' : '/builder/login');
1376
1388
  });
1377
1389
  }
1390
+ if (result.stepup) return withAppDb(result.appId, () => finishSsoStepUp(req, reply, p, result, ip));
1378
1391
  return withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
1379
1392
  };
1380
- const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
1393
+ /** (sprint 132) A step-up confirmed by the provider: the session that asked for it gets its second step. */
1394
+ const finishSsoStepUp = async (req: FastifyRequest, reply: FastifyReply, p: Provider, result: SsoResult, ip: string) => {
1395
+ const s = await runtime.one<{ id: string; app_id: number; username: string | null; state: Record<string, unknown> }>(
1396
+ 'select id, app_id, username, state from meta.session where id = $1', [result.stepup]);
1397
+ const a = s ? await loadApp((await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [s.app_id]))?.alias ?? '') : undefined;
1398
+ if (!s || !a || s.app_id !== result.appId || s.username?.toLowerCase() !== result.username.toLowerCase()) {
1399
+ logActivity({ appId: result.appId, username: result.username, event: 'login_failed', ip, detail: `sso:${p.name}: step-up for another session or user` });
1400
+ return simplePage(reply, 403, english('login.sso_failed'), english('login.sso_incomplete'));
1401
+ }
1402
+ markSecondStep(s.state);
1403
+ await runtime.query('update meta.session set state = $2 where id = $1', [s.id, s.state]);
1404
+ await logActivity({ appId: a.id, username: s.username, event: '2fa_stepup', ip, detail: `sso:${p.name}` });
1405
+ return reply.redirect(safeNext(a, result.next ?? undefined), 303);
1406
+ };
1407
+ const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: Provider, result: SsoResult, ip: string) => {
1381
1408
  const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
1382
1409
  const loaded = alias ? await appWithLocale(req, alias) : undefined;
1383
1410
  if (!loaded || loaded.app.authentication !== 'app_users' || !loaded.app.sso_providers.includes(p.name)) return simplePage(reply, 403, english('login.sso_failed'), english('login.method_unavailable'));
@@ -1388,7 +1415,16 @@ export async function runtimeRoutes(app: FastifyInstance) {
1388
1415
  return simplePage(reply, 403, locale.t('login.no_access_title'), locale.t('login.no_access', { user: result.username, app: a.name }), `/a/${a.alias}/login`, locale);
1389
1416
  }
1390
1417
  const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
1391
- return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, method: `sso:${p.name}`, detail: `sso:${p.name}` });
1418
+ const opts = { extraRoles: access.roles, next: result.next ?? undefined, method: `sso:${p.name}`, detail: `sso:${p.name}${result.mfa ? ' (mfa)' : ''}` };
1419
+ // (sprint 132) pgkiln's own second step after the provider, as after a password
1420
+ if (p.local_second_step) return passwordSignIn(req, reply, a, session, result.username, opts);
1421
+ const s = await signIn(req, reply, a, session, result.username, opts);
1422
+ // the provider's second factor counts for step-up (authorization schemes with mfa_minutes)
1423
+ if (result.mfa) {
1424
+ markSecondStep(s.state);
1425
+ await saveState(s);
1426
+ }
1427
+ return reply.redirect(safeNext(a, opts.next), 303);
1392
1428
  };
1393
1429
 
1394
1430
  // SAML: the IdP posts the response cross-site, where SameSite=Lax cookies aren't sent; post it on
@@ -19,7 +19,8 @@ import { ALGORITHMS, giveChallenge, relyingParty, takeChallenge, verifyAssertion
19
19
  import type { PageContext } from './context.ts';
20
20
  import type { Locale } from './locale.ts';
21
21
  import { pwaHead } from './pwa.ts';
22
- import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn, type Req } from './routes.ts';
22
+ import { loadProvider, type Provider } from '../sso.ts';
23
+ import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn, ssoStepUp, type Req } from './routes.ts';
23
24
 
24
25
  // (sprint 130, SEC-3) The second step of signing in to an application, and "Two-step sign-in" on My account.
25
26
  //
@@ -34,6 +35,9 @@ import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn,
34
35
  // and on My account, also as the first factor a user adds while signing in; a code by e-mail when the application
35
36
  // allows it, for users who have another factor; step-up: a signed-in user whose page needs a more recent second step
36
37
  // comes here with ?next= and goes back after it (the session's __MFA_AT); and My account → REST API token.
38
+ //
39
+ // (sprint 132, SEC-4) A session signed in through an identity provider steps up there instead: the provider signs the
40
+ // user in again with a second factor (sso.ts, saml.ts), unless the provider leaves the second step to pgkiln.
37
41
 
38
42
  const PENDING = '__MFA';
39
43
  const STEP_SECONDS = 300;
@@ -228,6 +232,15 @@ async function locked(reply: FastifyReply, a: App, locale: Locale, s: Session, p
228
232
  return reply.code(429).type('text/html').send(card(a, locale, locale.t('mfa.step_title'), alert(locale.t('login.throttled', { minutes: loginWindowMinutes() }))));
229
233
  }
230
234
 
235
+ /** (sprint 132) The identity provider a step-up goes back to: the one the session signed in with, if it decides itself. */
236
+ async function stepUpProvider(a: App, s: Session): Promise<Provider | null> {
237
+ const method = String(s.state.__SIGNIN ?? '');
238
+ if (!method.startsWith('sso:')) return null;
239
+ const name = method.slice(4).replace(/\+2fa$/, '');
240
+ const p = a.sso_providers.includes(name) ? await loadProvider(name) : undefined;
241
+ return p && !p.local_second_step ? p : null;
242
+ }
243
+
231
244
  export async function twoStepRoutes(app: FastifyInstance) {
232
245
  app.get('/a/:alias/login/2fa', async (req: Req, reply) => {
233
246
  const r = await start(req, reply);
@@ -237,6 +250,13 @@ export async function twoStepRoutes(app: FastifyInstance) {
237
250
  // (sprint 131) step-up: a signed-in user sent here by a page that needs a more recent second step
238
251
  const next = typeof (req.query as { next?: unknown }).next === 'string' ? String((req.query as { next: string }).next).slice(0, 2000) : undefined;
239
252
  if (s.username && (!p || (next && p.next !== next))) {
253
+ const via = await stepUpProvider(a, s);
254
+ if (via)
255
+ return ssoStepUp(reply, a, s, via, next ?? `/a/${a.alias}/${a.home_page}`).catch((e) => {
256
+ req.log.warn({ err: e }, 'sso step-up failed');
257
+ return reply.code(502).type('text/html').send(card(a, locale, locale.t('mfa.stepup_title'),
258
+ alert(locale.t('login.sso_unavailable', { provider: via.display_name })), `/a/${a.alias}/${a.home_page}`));
259
+ });
240
260
  const id = await accountId(s.username);
241
261
  if (id === null || !(await accountHasFactor(id)))
242
262
  return reply.code(403).type('text/html').send(card(a, locale, locale.t('mfa.stepup_title'),
package/src/saml.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { SAML, ValidateInResponseTo, type CacheProvider, type Profile } from '@node-saml/node-saml';
3
3
  import { owner } from './db.ts';
4
- import { publicUrl, resolveAccount, sha256, SsoError, usableUsername, type Provider, type SsoResult } from './sso.ts';
4
+ import { MFA_REFUSED, mfaRequest, provedMfa, publicUrl, resolveAccount, sha256, SsoError, stepUpResult, usableUsername, type Provider, type SsoResult } from './sso.ts';
5
5
 
6
6
  // SAML 2.0 sign-in (APEX: SAML Sign-In), service-provider initiated:
7
7
  //
@@ -18,6 +18,9 @@ import { publicUrl, resolveAccount, sha256, SsoError, usableUsername, type Provi
18
18
  // answer an AuthnRequest of this flow, once. On top: the assertion's issuer
19
19
  // (node-saml doesn't check it for responses), the one-time state and the browser
20
20
  // that started the flow (as for OpenID Connect).
21
+ // (sprint 132, SEC-4) The assertion's AuthnContextClassRef tells how the user signed in: with mfa_required it must
22
+ // be one of the provider's mfa_values; mfa_request asks for those (RequestedAuthnContext); a step-up adds ForceAuthn
23
+ // and checks the AuthnInstant.
21
24
 
22
25
  const PENDING_MINUTES = 10;
23
26
  const b64url = (b: Buffer) => b.toString('base64url');
@@ -54,8 +57,9 @@ function requestCache(onSave: (id: string) => void, only?: string): CacheProvide
54
57
  };
55
58
  }
56
59
 
57
- function client(p: Provider, cache: CacheProvider) {
60
+ function client(p: Provider, cache: CacheProvider, stepup = false) {
58
61
  if (!p.idp_sso_url || !p.idp_cert) throw new SsoError('The SAML provider is not configured completely.');
62
+ const requested = mfaRequest(p);
59
63
  return new SAML({
60
64
  entryPoint: p.idp_sso_url,
61
65
  issuer: spEntityId(p),
@@ -69,37 +73,51 @@ function client(p: Provider, cache: CacheProvider) {
69
73
  requestIdExpirationPeriodMs: PENDING_MINUTES * 60_000,
70
74
  cacheProvider: cache,
71
75
  identifierFormat: null,
72
- disableRequestedAuthnContext: true,
76
+ // (sprint 132) a RequestedAuthnContext only when the administrator names the classes
77
+ disableRequestedAuthnContext: !requested.length,
78
+ authnContext: requested,
79
+ racComparison: 'exact',
80
+ forceAuthn: stepup,
73
81
  acceptedClockSkewMs: 60_000,
74
82
  signatureAlgorithm: 'sha256',
75
83
  });
76
84
  }
77
85
 
78
86
  /** Start: the IdP URL with the AuthnRequest, and the browser-binding cookie value. */
79
- export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
87
+ export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string, stepup?: string) {
80
88
  const state = b64url(randomBytes(32));
81
89
  const browserKey = b64url(randomBytes(32));
82
90
  await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
83
91
  await owner.query(`delete from meta.saml_request where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
84
92
  let requestId = '';
85
- const url = await client(p, requestCache((id) => (requestId = id))).getAuthorizeUrlAsync(state, undefined, {});
93
+ const url = await client(p, requestCache((id) => (requestId = id)), !!stepup).getAuthorizeUrlAsync(state, undefined, {});
86
94
  if (!requestId) throw new SsoError('The AuthnRequest has no ID.');
87
95
  // code_verifier is unused for SAML; nonce holds the AuthnRequest ID the response must answer
88
96
  await owner.query(
89
- `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
90
- values ($1, $2, $3, '', $4, $5, $6, $7)`,
91
- [state, appId, p.id, requestId, sha256(browserKey), next ?? null, link ?? null],
97
+ `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username, stepup_session)
98
+ values ($1, $2, $3, '', $4, $5, $6, $7, $8)`,
99
+ [state, appId, p.id, requestId, sha256(browserKey), next ?? null, link ?? null, stepup ?? null],
92
100
  );
93
101
  return { url, browserKey };
94
102
  }
95
103
 
104
+ /** (sprint 132) The signed assertion's AuthnContextClassRefs and AuthnInstant (ms), read after node-saml checked it. */
105
+ export function authnContext(assertionXml: string): { classes: string[]; at: number | null } {
106
+ // comments are outside the signature (exclusive c14n drops them): text split by one is read as one
107
+ assertionXml = assertionXml.replace(/<!--[\s\S]*?-->/g, '');
108
+ const classes = [...assertionXml.matchAll(/<(?:[\w-]+:)?AuthnContextClassRef\b[^>]*>\s*([^<\s]+)\s*</g)].map((m) => m[1]);
109
+ const instant = /<(?:[\w-]+:)?AuthnStatement\b[^>]*\bAuthnInstant="([^"]+)"/.exec(assertionXml)?.[1];
110
+ const at = instant ? Date.parse(instant) : NaN;
111
+ return { classes, at: Number.isFinite(at) ? at : null };
112
+ }
113
+
96
114
  const values = (v: unknown): string[] => (v === undefined || v === null ? [] : (Array.isArray(v) ? v : [v]).map(String).filter(Boolean));
97
115
 
98
116
  /** Finish: check state, browser and the response; the account and its groups. */
99
117
  export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: string; RelayState?: string }, browserKey: string | undefined): Promise<SsoResult> {
100
118
  const pending = await owner.one(
101
119
  `delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
102
- returning app_id, nonce, browser_hash, next, link_username`,
120
+ returning app_id, nonce, browser_hash, next, link_username, stepup_session`,
103
121
  [body.RelayState ?? '', p.id, PENDING_MINUTES],
104
122
  );
105
123
  if (!pending) throw new SsoError('This sign-in has expired or was already used. Please try again.');
@@ -115,6 +133,11 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
115
133
  // node-saml checks the issuer of logout messages only: the assertion must come from the configured IdP
116
134
  if (profile?.issuer !== p.issuer) throw new SsoError(`The SAML response comes from ${profile?.issuer ?? 'an unknown issuer'}, not from ${p.issuer}.`);
117
135
  if (!profile?.nameID) throw new SsoError('The SAML response names no user.');
136
+ // (sprint 132, SEC-4) how the user signed in
137
+ const how = authnContext(profile.getAssertionXml?.() ?? '');
138
+ const mfa = provedMfa(p, how.classes);
139
+ if (pending.stepup_session) return stepUpResult(p, pending, profile.nameID, mfa, how.at);
140
+ if (p.mfa_required && !mfa) throw new SsoError(MFA_REFUSED);
118
141
  const username = p.username_claim === 'nameID' ? profile.nameID : values(profile[p.username_claim])[0];
119
142
  if (!usableUsername(username)) throw new SsoError(`The SAML response has no usable "${p.username_claim}" attribute.`);
120
143
  const name = values(profile.displayName ?? profile.cn)[0];
@@ -122,9 +145,9 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
122
145
  const groups = values(profile[p.groups_claim]);
123
146
  if (pending.app_id === null)
124
147
  // the builder: devsso.ts finds the developer (sprint 129)
125
- return { appId: null, next: pending.next, username, groups, link: pending.link_username, identity: { subject: profile.nameID, name: name ?? null, email: email ?? null, mayLink: true } };
148
+ return { appId: null, next: pending.next, username, groups, link: pending.link_username, mfa, identity: { subject: profile.nameID, name: name ?? null, email: email ?? null, mayLink: true } };
126
149
  const account = await resolveAccount(p, { sub: profile.nameID, name, email: profile.email ?? profile.mail }, username);
127
- return { appId: pending.app_id, next: pending.next, username: account, groups };
150
+ return { appId: pending.app_id, next: pending.next, username: account, groups, mfa };
128
151
  }
129
152
 
130
153
  /** Service provider metadata (entity ID, ACS) to register pgkiln at the IdP. */
package/src/secrets.ts CHANGED
@@ -14,26 +14,36 @@ export class SecretKeyMissing extends Error {
14
14
  }
15
15
  }
16
16
 
17
- function key() {
18
- const k = process.env.PGKILN_SECRET_KEY ?? '';
17
+ function key(k = process.env.PGKILN_SECRET_KEY ?? '') {
19
18
  if (k.length < 32) throw new SecretKeyMissing();
20
19
  return createHash('sha256').update(`pgkiln-secret-v1\0${k}`).digest();
21
20
  }
22
21
 
23
22
  export const secretKeyConfigured = () => (process.env.PGKILN_SECRET_KEY ?? '').length >= 32;
24
23
 
25
- export function encryptSecret(plain: string): string {
24
+ /**
25
+ * (sprint 133, SEC-6) A short fingerprint of a key, to tell which key a backup confirmation was for. A
26
+ * different derivation than the encryption key's, so it reveals nothing about that key.
27
+ */
28
+ export function keyFingerprint(k = process.env.PGKILN_SECRET_KEY ?? ''): string | null {
29
+ if (k.length < 32) return null;
30
+ return createHash('sha256').update(`pgkiln-key-fingerprint\0${k}`).digest('hex').slice(0, 16).replace(/(.{4})(?!$)/g, '$1-');
31
+ }
32
+
33
+ /** Encrypt with the server's key, or (rotate-key) with the key given. */
34
+ export function encryptSecret(plain: string, withKey?: string): string {
26
35
  const iv = randomBytes(12);
27
- const c = createCipheriv('aes-256-gcm', key(), iv);
36
+ const c = createCipheriv('aes-256-gcm', key(withKey), iv);
28
37
  const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
29
38
  return VERSION + Buffer.concat([iv, c.getAuthTag(), ct]).toString('base64');
30
39
  }
31
40
 
32
- export function decryptSecret(stored: string): string {
41
+ /** Decrypt with the server's key, or (rotate-key, checks) with the key given. */
42
+ export function decryptSecret(stored: string, withKey?: string): string {
33
43
  if (!stored.startsWith(VERSION)) throw new Error('Unknown secret format.');
34
44
  const buf = Buffer.from(stored.slice(VERSION.length), 'base64');
35
45
  if (buf.length < 28) throw new Error('Damaged secret.');
36
- const d = createDecipheriv('aes-256-gcm', key(), buf.subarray(0, 12));
46
+ const d = createDecipheriv('aes-256-gcm', key(withKey), buf.subarray(0, 12));
37
47
  d.setAuthTag(buf.subarray(12, 28));
38
48
  try {
39
49
  return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
package/src/server.ts CHANGED
@@ -14,3 +14,13 @@ await startWorkflowRunner();
14
14
  await startProcessJobRunner();
15
15
  await startPushListener();
16
16
  await startMailListener();
17
+ // (sprint 133, SEC-6) say at start-up what could lock people out: a changed instance key or address, the clock
18
+ try {
19
+ const { instanceChecks } = await import('./instance-checks.ts');
20
+ const { rpIdOf } = await import('./webauthn.ts');
21
+ const pub = process.env.PUBLIC_URL;
22
+ for (const c of await instanceChecks(pub && URL.canParse(pub) ? rpIdOf(new URL(pub).origin) : null))
23
+ if (c.level === 'error' || (c.level === 'warning' && c.key !== 'key_backup')) console.warn(`pgkiln: ${c.title}: ${c.detail}`);
24
+ } catch (e) {
25
+ console.warn('pgkiln: lockout checks failed:', (e as Error).message);
26
+ }