pgkiln 0.66.0 → 0.68.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/docs/apex-feature-parity.md +10 -5
- package/docs/guide/01-installation.md +5 -2
- package/docs/guide/03-builder.md +40 -4
- package/docs/guide/12-development.md +15 -6
- 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 +15 -0
- package/src/builder/i18n/de.ts +2 -0
- package/src/builder/i18n/en.ts +3 -1
- package/src/builder/i18n/es.ts +2 -0
- package/src/builder/i18n/fr.ts +2 -0
- package/src/builder/i18n/it.ts +1894 -0
- package/src/builder/i18n/ja.ts +1894 -0
- package/src/builder/i18n/ko.ts +1894 -0
- package/src/builder/i18n/nl.ts +2 -0
- package/src/builder/i18n/pt.ts +2 -0
- package/src/builder/i18n/zh-cn.ts +1894 -0
- package/src/builder/i18n/zh-tw.ts +1894 -0
- package/src/builder/i18n.ts +13 -3
- package/src/builder/instance.ts +27 -0
- 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/secrets.ts +16 -6
- package/src/server.ts +10 -0
- package/src/webauthn.ts +6 -2
package/src/builder/i18n.ts
CHANGED
|
@@ -19,20 +19,30 @@ import { de } from './i18n/de.ts';
|
|
|
19
19
|
import { en } from './i18n/en.ts';
|
|
20
20
|
import { es } from './i18n/es.ts';
|
|
21
21
|
import { fr } from './i18n/fr.ts';
|
|
22
|
+
import { it } from './i18n/it.ts';
|
|
23
|
+
import { ja } from './i18n/ja.ts';
|
|
24
|
+
import { ko } from './i18n/ko.ts';
|
|
22
25
|
import { nl } from './i18n/nl.ts';
|
|
23
26
|
import { pt } from './i18n/pt.ts';
|
|
27
|
+
import { zhCN } from './i18n/zh-cn.ts';
|
|
28
|
+
import { zhTW } from './i18n/zh-tw.ts';
|
|
24
29
|
|
|
25
30
|
export type BuilderKey = keyof typeof en;
|
|
26
31
|
export type BuilderMessages = Record<BuilderKey, string>;
|
|
27
32
|
export type Params = Record<string, string | number | null | undefined>;
|
|
28
33
|
|
|
29
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The builder's languages, in the order of the language menu and the sign-in page's links (names in their own
|
|
36
|
+
* language): APEX's ten (sprint 134, SEC-5: Italiano, both Chinese scripts, Japanese, Korean) and Nederlands.
|
|
37
|
+
* The codes are BCP 47 tags, used as they are in <html lang>, the cookie and meta.developer.language.
|
|
38
|
+
*/
|
|
30
39
|
export const BUILDER_LANGUAGES = [
|
|
31
|
-
['de', 'Deutsch'], ['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['nl', 'Nederlands'], ['pt', 'Português'],
|
|
40
|
+
['de', 'Deutsch'], ['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['it', 'Italiano'], ['nl', 'Nederlands'], ['pt', 'Português'],
|
|
41
|
+
['zh-CN', '中文 (简体)'], ['zh-TW', '中文 (繁體)'], ['ja', '日本語'], ['ko', '한국어'],
|
|
32
42
|
] as const;
|
|
33
43
|
export type BuilderLang = (typeof BUILDER_LANGUAGES)[number][0];
|
|
34
44
|
|
|
35
|
-
const MESSAGES: Record<BuilderLang, Readonly<Record<string, string>>> = { en, de, es, fr, nl, pt };
|
|
45
|
+
const MESSAGES: Record<BuilderLang, Readonly<Record<string, string>>> = { en, de, es, fr, it, nl, pt, 'zh-CN': zhCN, 'zh-TW': zhTW, ja, ko };
|
|
36
46
|
|
|
37
47
|
/** The cookie with the sign-in page's language on this device (the builder's path only). */
|
|
38
48
|
export const LANG_COOKIE = 'pgkiln_blang';
|
package/src/builder/instance.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
2
|
import { html } from '../html.ts';
|
|
3
|
+
import { attention, confirmKeyBackup, forgetAttention, instanceChecks, type Level } from '../instance-checks.ts';
|
|
4
|
+
import { relyingParty } from '../webauthn.ts';
|
|
3
5
|
import { configurationValue, CONFIGURATION, INSTANCE_SETTINGS, instanceSetting, origin, refreshInstanceSettings, saveInstanceSettings } from '../instance.ts';
|
|
4
6
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
5
7
|
import { isAdmin } from './locks.ts';
|
|
@@ -20,6 +22,20 @@ async function adminOnly(s: Session, reply: FastifyReply) {
|
|
|
20
22
|
return false;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
const LEVEL_TEXT: Record<Level, string> = { ok: 'OK', info: 'Note', warning: 'Warning', error: 'Error' };
|
|
26
|
+
|
|
27
|
+
/** (sprint 133, SEC-6) Lockout checks: what could keep people out later (src/instance-checks.ts). */
|
|
28
|
+
async function checksRegion(s: Session, rpId: string) {
|
|
29
|
+
const checks = await instanceChecks(rpId);
|
|
30
|
+
const n = attention(checks);
|
|
31
|
+
return region('Lockout checks', html`<p class="muted u-mt0">${n ? `${n} of these need attention.` : 'Nothing needs attention.'}
|
|
32
|
+
The same checks run on the server with <code>pgkiln admin check</code>; <code>--url</code> shows which passkeys a new address would break.</p>
|
|
33
|
+
<ul class="hits findings">${checks.map((c) => html`<li class="finding-${c.level}"><strong>${c.title}</strong><span></span>
|
|
34
|
+
<span class="tag tag-${c.level}">${LEVEL_TEXT[c.level]}</span><span class="hit-snippet">${c.detail}</span>
|
|
35
|
+
${c.key === 'key_backup' && c.level === 'warning' ? html`<form method="post" action="${BASE}/instance/key-backup" class="u-mt0">${csrf(s)}
|
|
36
|
+
<button class="btn btn-sm">I have a copy of this key</button></form>` : ''}</li>`)}</ul>`, '', 'lockout-checks');
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
const ORIGIN_TEXT = { builder: 'set here', environment: 'environment variable', default: 'default' } as const;
|
|
24
40
|
|
|
25
41
|
export async function instanceRoutes(app: FastifyInstance) {
|
|
@@ -39,6 +55,7 @@ export async function instanceRoutes(app: FastifyInstance) {
|
|
|
39
55
|
<small class="help">${x.help} In effect: <b>${instanceSetting(x.key)}</b> (${ORIGIN_TEXT[from]}; <code>${x.env}</code>, default ${x.default}).</small></div>`;
|
|
40
56
|
})}</div>
|
|
41
57
|
<div class="buttons"><button class="btn btn-hot">Save</button></div></form>`)}
|
|
58
|
+
${await checksRegion(s, relyingParty(req).rpId)}
|
|
42
59
|
${await releaseCheckRegion(s)}
|
|
43
60
|
${await policyRegion(s)}
|
|
44
61
|
${region('Configuration', html`<p class="muted u-mt0">Environment variables of this server (chapter 1 of the guide). Secrets show only whether they are set.</p>
|
|
@@ -51,6 +68,16 @@ export async function instanceRoutes(app: FastifyInstance) {
|
|
|
51
68
|
return send(reply, s, shell(s, 'Instance settings', crumbs, main));
|
|
52
69
|
});
|
|
53
70
|
|
|
71
|
+
app.post(`${BASE}/instance/key-backup`, async (req: Req, reply) => {
|
|
72
|
+
const s = await developer(req, reply);
|
|
73
|
+
if (!s || !(await adminOnly(s, reply))) return;
|
|
74
|
+
const ok = await confirmKeyBackup(s.username!);
|
|
75
|
+
forgetAttention();
|
|
76
|
+
if (ok) await logActivity({ username: s.username, event: 'secret_key', ip: clientIp(req), detail: 'backup confirmed' });
|
|
77
|
+
flash(s, ok ? 'Backup of the instance key confirmed.' : 'PGKILN_SECRET_KEY is not set.', ok ? 'ok' : 'error');
|
|
78
|
+
return back(reply, s, `${BASE}/instance#lockout-checks`);
|
|
79
|
+
});
|
|
80
|
+
|
|
54
81
|
app.post(`${BASE}/instance`, async (req: Req, reply) => {
|
|
55
82
|
const s = await developer(req, reply);
|
|
56
83
|
if (!s || !(await adminOnly(s, reply))) return;
|
|
@@ -9,6 +9,7 @@ import { isAdmin } from './locks.ts';
|
|
|
9
9
|
import { back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
|
|
10
10
|
import { currentWorkspace, inWorkspaceSql, workspaceAdmin } from './workspaces.ts';
|
|
11
11
|
import { teamHomeBlock } from './team.ts';
|
|
12
|
+
import { attentionCount } from '../instance-checks.ts';
|
|
12
13
|
import { tr } from './i18n.ts';
|
|
13
14
|
|
|
14
15
|
// (sprint 109, WS-1) The Workspace home (APEX: the workspace home page), the page the builder opens after
|
|
@@ -112,7 +113,12 @@ async function systemMessage(s: Session, admin: boolean) {
|
|
|
112
113
|
? html`<button class="btn btn-sm" name="check" value="now">${icon('refresh')} ${tx('wh.check_now')}</button><button class="btn btn-sm" name="check" value="off">${tx('wh.turn_off')}</button>`
|
|
113
114
|
: html`<button class="btn btn-sm" name="check" value="on">${tx('wh.check_on')}</button>`}</div></form>`
|
|
114
115
|
: '';
|
|
115
|
-
|
|
116
|
+
// (sprint 133, SEC-6) administrators hear about lockout risks here, not only on Instance settings
|
|
117
|
+
const lockout = admin ? await attentionCount() : 0;
|
|
118
|
+
const checks = lockout
|
|
119
|
+
? html`<p class="alert alert-warning" role="status">${icon('alert')} ${tx('wh.lockout', { count: lockout })} <a href="${BASE}/instance#lockout-checks">${tx('wh.lockout_open')}</a></p>`
|
|
120
|
+
: '';
|
|
121
|
+
return region(tx('wh.system'), html`${checks}${body}${admins}`, '', 'system-message');
|
|
116
122
|
}
|
|
117
123
|
|
|
118
124
|
/** (Instance settings page) the release check: on, off, or as the environment says. */
|
package/src/cli/main.ts
CHANGED
|
@@ -471,24 +471,35 @@ const COMMANDS: Record<string, Command> = {
|
|
|
471
471
|
},
|
|
472
472
|
// (sprint 126, SEC-6) lockout recovery on the server, when nobody can sign in to the builder
|
|
473
473
|
admin: {
|
|
474
|
-
usage: 'pgkiln admin reset-2fa <developer> | set-2fa-policy off|admins|all | builder-sign-in password [options]',
|
|
475
|
-
summary: 'lockout recovery
|
|
474
|
+
usage: 'pgkiln admin reset-2fa <developer> | set-2fa-policy off|admins|all | builder-sign-in password | rotate-key | key-backed-up | check [options]',
|
|
475
|
+
summary: 'lockout recovery and checks: two-factor reset and policy, the builder password, the instance key',
|
|
476
476
|
details:
|
|
477
477
|
'pgkiln admin reset-2fa <developer> [--reason <text>]\n' +
|
|
478
478
|
'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n' +
|
|
479
|
-
'pgkiln admin builder-sign-in password\n
|
|
479
|
+
'pgkiln admin builder-sign-in password\n' +
|
|
480
|
+
'pgkiln admin rotate-key [--dry-run]\n' +
|
|
481
|
+
'pgkiln admin key-backed-up\n' +
|
|
482
|
+
'pgkiln admin check [--url <public url>]\n\n' +
|
|
480
483
|
"reset-2fa removes the developer's authenticator apps and recovery codes and ends their builder\n" +
|
|
481
484
|
'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works\n' +
|
|
482
485
|
"(and clears the workspaces' own two-factor rules); admins and all need at least two administrators\n" +
|
|
483
486
|
'with two-factor sign-in (or every one, when there are fewer). builder-sign-in password turns off the\n' +
|
|
484
487
|
"workspaces' single sign-on rule, for when the identity provider fails and no break-glass administrator\n" +
|
|
485
|
-
'can sign in. All are written to the activity log and mailed to the administrators
|
|
486
|
-
|
|
488
|
+
'can sign in. All are written to the activity log and mailed to the administrators.\n\n' +
|
|
489
|
+
'rotate-key re-encrypts every stored secret (authenticator apps, web credentials, mail and AI keys)\n' +
|
|
490
|
+
'from PGKILN_OLD_SECRET_KEY to PGKILN_SECRET_KEY in one transaction; stop the servers first and start\n' +
|
|
491
|
+
'them with the new key. key-backed-up records that the current key is kept outside the server.\n' +
|
|
492
|
+
'check prints the lockout checks of Instance settings (instance key, clock, passkey domain,\n' +
|
|
493
|
+
'break-glass administrators); --url shows which passkeys a move to that address would break.\n' +
|
|
494
|
+
'Exit code 1 when a check needs attention.',
|
|
495
|
+
options: { reason: { type: 'string' }, grace: { type: 'string' }, 'dry-run': { type: 'boolean' }, url: { type: 'string' } },
|
|
487
496
|
optionHelp: [
|
|
488
497
|
['--reason <text>', 'reset-2fa: why (in the activity log and the mail)'],
|
|
489
498
|
['--grace <days>', 'set-2fa-policy: days developers have to set up an app (0 to 90, default 7)'],
|
|
499
|
+
['--dry-run', 'rotate-key: count what would be re-encrypted, change nothing'],
|
|
500
|
+
['--url <url>', 'check: the public address to check passkeys against (default PUBLIC_URL)'],
|
|
490
501
|
],
|
|
491
|
-
positionals: [
|
|
502
|
+
positionals: [1, 2],
|
|
492
503
|
async run(v, [action, arg]) {
|
|
493
504
|
process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL; // the CLI runs as the owner
|
|
494
505
|
const mfa = await import('../mfa.ts');
|
|
@@ -497,6 +508,7 @@ const COMMANDS: Record<string, Command> = {
|
|
|
497
508
|
const by = `pgkiln admin (${userInfo().username})`;
|
|
498
509
|
try {
|
|
499
510
|
if (action === 'reset-2fa') {
|
|
511
|
+
if (!arg) throw new UsageError('reset-2fa takes a developer');
|
|
500
512
|
const reason = String(v.reason ?? 'command line').slice(0, 200);
|
|
501
513
|
if (!(await mfa.resetFactors(arg))) throw new Error(`no developer ${arg}`);
|
|
502
514
|
await logActivity({ username: arg, event: '2fa_reset', ip: 'cli', detail: `${arg}: ${reason} (${by})` });
|
|
@@ -530,7 +542,46 @@ const COMMANDS: Record<string, Command> = {
|
|
|
530
542
|
out(`Developers may sign in with the pgkiln password again; ${cleared} workspace(s) no longer require single sign-on.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
|
|
531
543
|
return EXIT.ok;
|
|
532
544
|
}
|
|
533
|
-
|
|
545
|
+
// (sprint 133, SEC-6) the instance key and the lockout checks
|
|
546
|
+
if (action === 'rotate-key' || action === 'key-backed-up' || action === 'check') {
|
|
547
|
+
if (arg !== undefined) throw new UsageError(`${action} takes no argument`);
|
|
548
|
+
const checks = await import('../instance-checks.ts');
|
|
549
|
+
if (action === 'rotate-key') {
|
|
550
|
+
const oldKey = process.env.PGKILN_OLD_SECRET_KEY ?? '';
|
|
551
|
+
const newKey = process.env.PGKILN_SECRET_KEY ?? '';
|
|
552
|
+
if (!oldKey) throw new UsageError('set PGKILN_OLD_SECRET_KEY to the key the secrets are stored with, and PGKILN_SECRET_KEY to the new one');
|
|
553
|
+
const dry = !!v['dry-run'];
|
|
554
|
+
const counts = await checks.rotateKey(oldKey, newKey, { dryRun: dry });
|
|
555
|
+
const rotated = counts.reduce((n, c) => n + c.rotated, 0);
|
|
556
|
+
for (const c of counts) out(` ${c.schema}.${c.table}.${c.column}: ${c.rotated} ${dry ? 'to re-encrypt' : 're-encrypted'}${c.already ? `, ${c.already} already with the new key` : ''}\n`);
|
|
557
|
+
if (dry) {
|
|
558
|
+
out(`Dry run: ${rotated} secret(s) would be re-encrypted; nothing changed.\n`);
|
|
559
|
+
return EXIT.ok;
|
|
560
|
+
}
|
|
561
|
+
await logActivity({ event: 'secret_key', ip: 'cli', detail: `rotated, ${rotated} secret(s) re-encrypted (${by})` });
|
|
562
|
+
const sent = await mfa.notifyMfa(await mfa.adminEmails(), 'pgkiln: the instance key was rotated',
|
|
563
|
+
`${by} re-encrypted ${rotated} stored secret(s) with a new PGKILN_SECRET_KEY on the server. Keep a copy of the new key and confirm it on Instance settings.`);
|
|
564
|
+
out(`${rotated} secret(s) re-encrypted with the new key. Start the servers with it, keep a copy, then run 'pgkiln admin key-backed-up'.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
|
|
565
|
+
return EXIT.ok;
|
|
566
|
+
}
|
|
567
|
+
if (action === 'key-backed-up') {
|
|
568
|
+
if (!(await checks.confirmKeyBackup(by))) throw new Error('PGKILN_SECRET_KEY is not set (at least 32 characters)');
|
|
569
|
+
await logActivity({ event: 'secret_key', ip: 'cli', detail: `backup confirmed (${by})` });
|
|
570
|
+
out('Backup of the instance key confirmed.\n');
|
|
571
|
+
return EXIT.ok;
|
|
572
|
+
}
|
|
573
|
+
let rpId: string | null = null;
|
|
574
|
+
const url = (v.url as string | undefined) ?? process.env.PUBLIC_URL;
|
|
575
|
+
if (url) {
|
|
576
|
+
if (!URL.canParse(url) || !/^https?:$/.test(new URL(url).protocol)) throw new UsageError('--url takes an http(s) address');
|
|
577
|
+
rpId = (await import('../webauthn.ts')).rpIdOf(new URL(url).origin);
|
|
578
|
+
}
|
|
579
|
+
const list = await checks.instanceChecks(rpId);
|
|
580
|
+
for (const c of list) out(`${c.level.toUpperCase().padEnd(7)} ${c.title}\n ${c.detail}\n`);
|
|
581
|
+
if (!rpId) out('(passkey domain not checked: set PUBLIC_URL or pass --url)\n');
|
|
582
|
+
return checks.attention(list) ? EXIT.differences : EXIT.ok;
|
|
583
|
+
}
|
|
584
|
+
throw new UsageError(`unknown action ${action}: use reset-2fa, set-2fa-policy, builder-sign-in, rotate-key, key-backed-up or check`);
|
|
534
585
|
} finally {
|
|
535
586
|
await closePools();
|
|
536
587
|
}
|
|
@@ -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/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
|
+
}
|
package/src/webauthn.ts
CHANGED
|
@@ -218,8 +218,12 @@ export function relyingParty(req: { protocol: string; host: string }): { rpId: s
|
|
|
218
218
|
// a bad PUBLIC_URL: the browser's address
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
+
return { rpId: rpIdOf(origin), origin };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The RP ID of an origin: its host, or PGKILN_RP_ID when that is the host or a parent domain of it. */
|
|
225
|
+
export function rpIdOf(origin: string): string {
|
|
221
226
|
const host = new URL(origin).hostname;
|
|
222
227
|
const wanted = (process.env.PGKILN_RP_ID ?? '').trim().toLowerCase();
|
|
223
|
-
|
|
224
|
-
return { rpId, origin };
|
|
228
|
+
return wanted && (host === wanted || host.endsWith(`.${wanted}`)) ? wanted : host;
|
|
225
229
|
}
|