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.
- package/SECURITY.md +1 -0
- package/db/migrations/134_idp_mfa.sql +46 -0
- package/docs/apex-feature-parity.md +10 -1
- package/docs/guide/01-installation.md +5 -2
- package/docs/guide/03-builder.md +40 -3
- package/docs/guide/08-security.md +41 -3
- package/docs/guide/12-development.md +8 -2
- package/docs/guide/18-cli.md +3 -0
- package/docs/guide/19-rest-data-sources.md +2 -2
- package/package.json +1 -1
- package/public/builder.css +6 -0
- package/src/builder/i18n/de.ts +15 -0
- package/src/builder/i18n/en.ts +15 -0
- package/src/builder/i18n/es.ts +15 -0
- package/src/builder/i18n/fr.ts +15 -0
- package/src/builder/i18n/nl.ts +15 -0
- package/src/builder/i18n/pt.ts +15 -0
- package/src/builder/instance.ts +27 -0
- package/src/builder/login-page.ts +14 -16
- package/src/builder/routes.ts +24 -8
- package/src/builder/users.ts +24 -5
- package/src/builder/workspace-home.ts +7 -1
- package/src/cli/main.ts +58 -7
- package/src/instance-checks.ts +260 -0
- package/src/instance.ts +1 -1
- package/src/release-check.ts +10 -0
- package/src/runtime/routes.ts +39 -3
- package/src/runtime/two-step.ts +21 -1
- package/src/saml.ts +34 -11
- package/src/secrets.ts +16 -6
- package/src/server.ts +10 -0
- package/src/sso.ts +72 -11
- package/src/webauthn.ts +6 -2
|
@@ -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' },
|
package/src/release-check.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtime/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/src/runtime/two-step.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|