pgkiln 0.66.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 CHANGED
@@ -117,6 +117,7 @@ first version (MVP). Every finding below has a regression test in
117
117
  | Passkey sign-in without a password, workspace rule (0.62.0) | *Sign in with a passkey* asks the browser for any passkey of this site (empty allow list) with `userVerification: required`, and the server refuses an answer whose authenticator data lacks the user-verified flag, so the passkey alone stands for possession plus a fingerprint, face or PIN. The credential id is looked up for this relying party only; a user handle, when the browser sends one, must be the developer's (a SHA-256-derived handle, not the user name). The same one-time challenge, origin, RP ID hash, signature and counter checks as at the second step apply; failures are logged as `login_failed` with the developer's name when the key is known and count for the per-user and per-IP lockout. Administrators turn it off in Instance settings. A workspace's rule (administrators and its own administrators only, CSRF-checked) can only add a requirement; `pgkiln admin set-2fa-policy off` clears every workspace's rule. While a second factor is required of a developer, a sign-in with the default password leads only to the password change. |
118
118
  | Single sign-on for developers (0.63.0) | A provider or directory signs developers in only with *builder* turned on. The flow is the applications' (state, PKCE, nonce, browser-binding cookie, ID token or SAML assertion checks) with a pending row without an application; developers are found by the provider's stable subject in `meta.developer_identity`, never by a name alone. An existing developer is linked by name only when the administrator allows it, once, never a break-glass administrator, and an e-mail user name only when verified; otherwise the developer links the identity on Security, which needs their session and CSRF token to start and the same browser to finish, and one identity can't be linked to two developers. Automatic accounts need the developers group when one is set and start as developers; the administrators group decides the role at every sign-in but never demotes a break-glass administrator. A workspace's single sign-on rule (administrators and its own administrators, CSRF-checked) refuses the pgkiln password and a passkey alone, after the password check so it reveals nothing; it can only be turned on while a break-glass administrator exists and by someone who could still sign in; the last break-glass administrator and a developer's last identity can't be removed while it applies. Failures are logged as `login_failed` with the reason. |
119
119
  | Two-factor sign-in for application users (0.64.0) | Per application (`meta.app.mfa`: off, optional, required, required for roles). The account's TOTP secrets are encrypted with `PGKILN_SECRET_KEY` and stored only after a code confirms them; recovery codes are SHA-256 and used up atomically; each 30-second step once, one step either side. A password or LDAP sign-in that needs the step creates no session: the pending sign-in (5 minutes, 10 while adding an app) lives in the signed-out session, bound to it and to the page's CSRF token, and a forged token ends it. Wrong codes count as failed sign-ins (the per-user and per-IP lockout). A *Keep me signed in* or session-sharing token stands in for the step only when its method ends in `+2fa`; an identity provider's sign-in is left to the provider. Without the instance key the step can't be required and enrolment refuses rather than skips. Removing an app needs a current or recovery code and is refused for the last app while the application requires it; a developer's reset (Users, with a reason) removes the apps and codes and ends the account's sessions, remembered and shared sign-ins. Every enrolment, removal, reset and used recovery code is in the activity log. The factor tables are closed to the runtime roles. |
120
+ | Lockout checks and key rotation (0.67.0) | Only administrators see the checks and confirm a key backup (CSRF); the page shows a 16-character fingerprint of `PGKILN_SECRET_KEY` from a separate derivation, never the key; values from the database (RP IDs, names) are escaped. `pgkiln admin rotate-key` reads both keys from the environment (never arguments, so not in the shell history or the process list), decrypts every secret before it writes, and changes everything or nothing in one transaction; it is logged and mailed to the administrators. The runtime role reads none of the encrypted columns (tested for every `*_enc` column) |
120
121
 
121
122
  ## Findings of the MVP review and their fixes
122
123
 
@@ -138,7 +138,7 @@ the 26.2 comparison.)
138
138
  | APEX accounts | ✅ | Instance-wide user directory, bcrypt, session rotation, idle and absolute timeouts |
139
139
  | Application Access Control (roles per app, any-user switch) | ✅ | Access control per application |
140
140
  | Social sign-in / OpenID Connect | ✅ | Any OIDC provider (Entra ID, Google, Okta, Keycloak, …): PKCE, group → role mapping, account linking, automatic accounts |
141
- | Multi-factor authentication | ✅ | As in APEX: through the identity provider (OpenID Connect, SAML). Beyond APEX (sprints 126–128): the builder itself has two-factor sign-in with passkeys (WebAuthn, also on their own without a password), authenticator apps (TOTP) and recovery codes, trusted browsers, an instance and per-workspace policy with a grace period and lockout recovery ([chapter 3](guide/03-builder.md#two-factor-sign-in)). Sprint 130: application users too, per application (off, optional, required, required for roles) with authenticator apps and recovery codes, added while signing in or on My account, reset from Users; sprint 131: passkeys, e-mail codes as a fallback, step-up per authorization scheme and REST API tokens only after a second step ([chapter 8](guide/08-security.md#two-factor-sign-in)). Sprint 132: pgkiln checks that the identity provider did multi-factor sign-in (`amr` / `acr`, SAML `AuthnContextClassRef`), requests it, steps up through the provider, or asks its own second step after it ([chapter 8](guide/08-security.md#multi-factor-sign-in-at-the-identity-provider)) |
141
+ | Multi-factor authentication | ✅ | As in APEX: through the identity provider (OpenID Connect, SAML). Beyond APEX (sprints 126–128): the builder itself has two-factor sign-in with passkeys (WebAuthn, also on their own without a password), authenticator apps (TOTP) and recovery codes, trusted browsers, an instance and per-workspace policy with a grace period and lockout recovery ([chapter 3](guide/03-builder.md#two-factor-sign-in)). Sprint 130: application users too, per application (off, optional, required, required for roles) with authenticator apps and recovery codes, added while signing in or on My account, reset from Users; sprint 131: passkeys, e-mail codes as a fallback, step-up per authorization scheme and REST API tokens only after a second step ([chapter 8](guide/08-security.md#two-factor-sign-in)). Sprint 132: pgkiln checks that the identity provider did multi-factor sign-in (`amr` / `acr`, SAML `AuthnContextClassRef`), requests it, steps up through the provider, or asks its own second step after it ([chapter 8](guide/08-security.md#multi-factor-sign-in-at-the-identity-provider)). Sprint 133: lockout checks (instance key and its backup, clock, passkey domain, break-glass administrators) and `pgkiln admin rotate-key` ([chapter 3](guide/03-builder.md#lockout-checks)) |
142
142
  | Change own password | ✅ | A built-in *My account* page (APEX offers the API `CHANGE_CURRENT_USER_PW`) |
143
143
  | Password expiry, change on first use, admin reset, complexity | ✅ | Lifetime in days, expire/unexpire, admin reset, length and letters+digits rules, unlock |
144
144
  | Lockout after failed sign-ins | ✅ | Per user and per IP, time-based (APEX: until an administrator unlocks) |
@@ -309,6 +309,11 @@ Small but real differences, for teams comparing the two:
309
309
  the ID token's `amr` / `acr` or the SAML `AuthnContextClassRef`, step-up goes back to it for a fresh multi-factor
310
310
  sign-in, and pgkiln's own second step can follow a provider that cannot enforce MFA
311
311
  ([chapter 8](guide/08-security.md#multi-factor-sign-in-at-the-identity-provider)). APEX trusts the provider's policy.
312
+ - (sprint 133) **Lockout checks**: Instance settings and `pgkiln admin check` warn before anyone is locked out: secrets
313
+ the instance key can't decrypt, a key without a confirmed backup, clock drift that breaks authenticator codes,
314
+ passkeys of another domain (also before a move, with `--url`) and single sign-on without a break-glass
315
+ administrator; `pgkiln admin rotate-key` changes the instance key in one transaction
316
+ ([chapter 3](guide/03-builder.md#lockout-checks)).
312
317
  - One RLS policy set protects both the web UI and the REST API.
313
318
  - An import can be checked before it commits (`pgkiln import --check`): SQL that names a missing
314
319
  table or column refuses the deployment instead of failing at run time.
@@ -185,7 +185,8 @@ which is read at startup; real environment variables take precedence.
185
185
  | `OPENAI_API_KEY` | *(none)* | Default API key of AI services with the provider OpenAI that have no key of their own |
186
186
  | `GEMINI_API_KEY` | *(none)* | (sprint 111) Default API key of AI services with the provider Gemini that have no key of their own |
187
187
  | `OPENAI_COMPATIBLE_API_KEY` | *(none)* | (sprint 111) Default API key of OpenAI-compatible AI services (Ollama, vLLM, Azure OpenAI) that have no key of their own; without either, no key is sent (`none`) |
188
- | `PGKILN_SECRET_KEY` | *(none)* | Encrypts the secrets of web credentials and the API keys of AI services; at least 32 characters (e.g. `openssl rand -base64 32`). Keep it outside the database; changing it means entering the secrets again ([chapter 19](19-rest-data-sources.md)) |
188
+ | `PGKILN_SECRET_KEY` | *(none)* | Encrypts the secrets of web credentials and the API keys of AI services; at least 32 characters (e.g. `openssl rand -base64 32`). Keep it outside the database; change it with `pgkiln admin rotate-key` ([chapter 3](03-builder.md#lockout-checks)), otherwise the secrets must be entered again |
189
+ | `PGKILN_OLD_SECRET_KEY` | *(none)* | (0.67) Only for `pgkiln admin rotate-key`: the key the secrets are stored with now |
189
190
  | `PGKILN_REST_ALLOWED_HOSTS` | *(none: no outgoing calls)* | Hosts REST data sources and `invoke_api` may call: `api.example.com`, `*.example.com`, `host:8443`, `*` (any public host) ([chapter 19](19-rest-data-sources.md#server-configuration-and-the-allow-list)); a workspace's [network access list](03-builder.md#network-access) can narrow it, and the SMTP server of a workspace's own mail service must be on it |
190
191
  | `PGKILN_REST_PRIVATE_HOSTS` | *(none)* | Hosts that may resolve to private, loopback or link-local addresses (also allows them) |
191
192
  | `PGKILN_PUSH_HOSTS` | the browsers' push services | The hosts push notifications may be posted to: `fcm.googleapis.com,updates.push.services.mozilla.com,web.push.apple.com,*.notify.windows.com` when unset ([chapter 17](17-mobile.md#push-notifications)) |
@@ -272,7 +273,9 @@ fixed version in `PGKILN_IMAGE`, change it first); `up -d` replaces the containe
272
273
  container migrates the database before it starts serving. A `compose.yaml` from pgkiln 0.51.0 or older has
273
274
  `pull_policy: missing` under `pgkiln`, which makes `docker compose pull` skip an image that is already present:
274
275
  delete that line (or take the current `deploy/compose.yaml`) before the first upgrade this way. Back up first (`docker compose exec db pg_dump -U pgkiln pgkiln > backup.sql`).
275
- Keep `PGKILN_SECRET_KEY`: without it, stored secrets (web credentials, AI keys, push keys) can't be decrypted.
276
+ Keep `PGKILN_SECRET_KEY`: without it, stored secrets (web credentials, AI keys, push keys, authenticator apps) can't
277
+ be decrypted. Keep a copy outside the server and confirm it on Instance settings; to change it, use
278
+ `pgkiln admin rotate-key` ([chapter 3](03-builder.md#lockout-checks)).
276
279
 
277
280
  ## Upgrading
278
281
 
@@ -170,13 +170,46 @@ Sign-ins are in the activity log as `login` with *builder sso:company* or *build
170
170
  | No administrator can sign in | On the server: `pgkiln admin reset-2fa <developer> --reason "…"` ([chapter 18](18-cli.md)), then sign in with the password |
171
171
  | The policy locks everyone out | On the server: `pgkiln admin set-2fa-policy off` (also clears the workspaces' own rules) |
172
172
  | The identity provider is down and a workspace requires single sign-on | A break-glass administrator signs in with the password and turns the workspace's rule off; without one, on the server: `pgkiln admin builder-sign-in password` |
173
- | Codes are always wrong | Check the time on the phone and on the server (codes allow 30 seconds either way) |
174
- | The instance key was lost or changed | The apps' secrets can't be read: reset the developers (`pgkiln admin reset-2fa`); recovery codes still work |
173
+ | Codes are always wrong | Check the time on the phone and on the server (codes allow 30 seconds either way); *Lockout checks* compares the server's clock |
174
+ | The instance key was changed | Set the old key again and rotate properly: `pgkiln admin rotate-key` ([below](#lockout-checks)) |
175
+ | The instance key was lost | The apps' secrets can't be read: reset the developers (`pgkiln admin reset-2fa`); passkeys and recovery codes still work |
175
176
 
176
177
  The command line needs the owner's database connection (`DATABASE_URL`), so only someone with access to the server
177
178
  can use it. It writes to the activity log and mails the administrators too. With Docker:
178
179
  `docker compose exec pgkiln node bin/pgkiln.js admin reset-2fa admin`.
179
180
 
181
+ ### Lockout checks
182
+
183
+ (0.67) **Instance settings** starts with *Lockout checks*: what could keep developers or application users out later,
184
+ found before it happens. The Workspace home tells administrators how many need attention.
185
+
186
+ | Check | Needs attention when |
187
+ |---|---|
188
+ | Instance key | Secrets are stored but `PGKILN_SECRET_KEY` is not set, or some can't be decrypted with it (the key changed without a rotation) |
189
+ | Instance key backed up | No administrator confirmed a copy of the current key (*I have a copy of this key*, or `pgkiln admin key-backed-up`). The confirmation is kept per key fingerprint, so a new key asks again |
190
+ | Clock | The server's clock is 10 seconds or more off the database's, or GitHub's when the [release check](#workspace-home) is on; from 30 seconds authenticator codes fail |
191
+ | Passkey domain | Passkeys were made for another domain than `PUBLIC_URL` (or `PGKILN_RP_ID`) gives, with the number of people who have no other factor |
192
+ | Break-glass administrators | A workspace requires single sign-on and there is no break-glass administrator, or one without a second factor while two-factor sign-in is required |
193
+
194
+ The server writes the same problems to its log when it starts, and `pgkiln admin check` prints them on the server
195
+ (exit code 1 when one needs attention). **Before moving to another address**, run `pgkiln admin check --url
196
+ https://new.example.com`: it lists the passkeys that won't work there and who has no other factor, so they can add an
197
+ authenticator app or keep their recovery codes at hand first.
198
+
199
+ **Rotating the instance key.** Authenticator apps of developers and app users, web credentials, mail and AI keys and
200
+ the push key are encrypted with `PGKILN_SECRET_KEY`; passkeys and recovery codes are not. To change the key, stop the
201
+ servers (or accept that sign-ins with an authenticator app fail for the minute in between), then on the server:
202
+
203
+ ```bash
204
+ PGKILN_OLD_SECRET_KEY='the current key' PGKILN_SECRET_KEY='the new key' pgkiln admin rotate-key --dry-run
205
+ PGKILN_OLD_SECRET_KEY='the current key' PGKILN_SECRET_KEY='the new key' pgkiln admin rotate-key
206
+ ```
207
+
208
+ It re-encrypts every stored secret in one transaction: a secret that neither key decrypts stops it and nothing
209
+ changes; a secret already under the new key is left alone, so running it twice is harmless. Start the servers with the
210
+ new key (an isolated workspace's database logins get their new passwords on first use), keep a copy of it and confirm
211
+ that on Instance settings. The rotation is in the activity log (`secret_key`) and mailed to the administrators.
212
+
180
213
  ## Workspaces
181
214
 
182
215
  A workspace (APEX: a workspace) groups applications and the developers who build them, for example per team or
@@ -22,6 +22,10 @@ src/
22
22
  env.ts .env loader (imported first)
23
23
  migrate.ts applies db/migrations and examples (scripts/migrate.ts, pgkiln migrate); logs each run that applies
24
24
  instance.ts instance settings (meta.setting over environment variables, cached 30 s) and the configuration overview
25
+ instance-checks.ts (sprint 133, SEC-6) lockout checks: instance key (unreadable secrets, backup confirmed per fingerprint in
26
+ meta.setting secret_key_backup), clock against the database and GitHub's Date header, passkeys of another
27
+ RP ID, break-glass administrators; rotateKey() re-encrypts every *_enc column in one transaction;
28
+ used by Instance settings, the Workspace home (cached count), server start-up and `pgkiln admin check`
25
29
  release-check.ts (sprint 109) "pgkiln X is available": meta.setting 'release_check' over PGKILN_RELEASE_CHECK (off by default);
26
30
  GitHub's releases/latest (PGKILN_RELEASE_URL in tests), 5 s timeout, 1 MB, cached a day per process,
27
31
  never under node --test without PGKILN_RELEASE_URL; parseRelease keeps a semver and an https link only
@@ -143,7 +147,8 @@ src/
143
147
  nextRuns in a time zone, describeSchedule in words, the builder's presets and fields
144
148
  pgcron.ts (sprint 101, DA-12) pg_cron for SQL Workshop → Scheduling: cronState (installed, loaded, cron.database_name,
145
149
  access), jobs and runs, jobSql/activeSql/unscheduleSql/purgeSql (reviewed, then applySql), runNow
146
- secrets.ts secrets at rest (web credentials): AES-256-GCM with PGKILN_SECRET_KEY
150
+ secrets.ts secrets at rest (web credentials): AES-256-GCM with PGKILN_SECRET_KEY (or a key given, for rotate-key);
151
+ keyFingerprint()
147
152
  websources.ts web credentials (OAuth2 client credentials/password/refresh token grants, token cache, stored refresh
148
153
  tokens) and REST data sources: requests, JSON paths, typed rows, response cache, write-back operations
149
154
  (callOperation); invoke(): the invoke API call shared by the invoke_api process and workflow step
@@ -420,7 +425,8 @@ src/
420
425
  write-back operations, synchronisation settings, Synchronise now and run history
421
426
  templates.ts template components: preview, plug-in export/import, region settings, report column templates
422
427
  code-editor.ts code fields (data-code marks), /builder/code/completions (scoped to the app's role), /builder/code/check
423
- instance.ts Workspace utilities → Instance settings (src/instance.ts: session and sign-in settings, configuration overview)
428
+ instance.ts Workspace utilities → Instance settings (src/instance.ts: session and sign-in settings, configuration overview;
429
+ Lockout checks from src/instance-checks.ts and POST /instance/key-backup)
424
430
  gallery.ts the App Gallery (sprint 61, 120): examples/gallery/gallery.json, sample apps (administrators: migrate --example,
425
431
  or one SQL file of examples/ or examples/gallery/samples/ in one transaction), starter and utility apps
426
432
  (blueprints of starters/ and utilities/ → openBlueprint's review), custom apps (the workspace's boilerplates);
@@ -55,6 +55,9 @@ any (see [what is not exported](03-builder.md#export-format)).
55
55
  | `pgkiln admin reset-2fa <developer> [--reason <text>]` | (0.60) Removes a developer's authenticator apps and recovery codes and ends their builder sessions: [locked out?](03-builder.md#locked-out) |
56
56
  | `pgkiln admin set-2fa-policy off\|admins\|all [--grace <days>]` | (0.60) Sets the builder's [two-factor policy](03-builder.md#two-factor-sign-in); `off` always works (and, 0.62, clears every workspace's own rule), the others need two administrators with an app |
57
57
  | `pgkiln admin builder-sign-in password` | (0.63) Turns off every workspace's [single sign-on rule](03-builder.md#single-sign-on-for-developers), so developers may sign in with the pgkiln password again; logged and mailed to the administrators |
58
+ | `pgkiln admin rotate-key [--dry-run]` | (0.67) Re-encrypts every stored secret from `PGKILN_OLD_SECRET_KEY` to `PGKILN_SECRET_KEY` in one transaction ([rotating the instance key](03-builder.md#lockout-checks)); logged and mailed to the administrators |
59
+ | `pgkiln admin key-backed-up` | (0.67) Records that a copy of the current `PGKILN_SECRET_KEY` is kept outside the server |
60
+ | `pgkiln admin check [--url <address>]` | (0.67) Prints the [lockout checks](03-builder.md#lockout-checks); `--url` checks passkeys against a new public address. Exit code 1 when one needs attention |
58
61
 
59
62
  `users` reads the password from standard input (the first line, e.g. from a secret store:
60
63
  `printf '%s\n' "$PW" | pgkiln users add ops --developer`) or asks for it twice on a terminal. It is
@@ -63,8 +63,8 @@ sources (including write-back, synchronisation and the response cache), `meta.we
63
63
  API, token endpoints, object storage and REST source catalogs. Refusals are logged in the
64
64
  application's debug log and listed under *Refused calls*.
65
65
 
66
- **Changing `PGKILN_SECRET_KEY`** makes the stored secrets unreadable: pgkiln then reports that
67
- the secret must be entered again. Keep the key with your other server secrets, outside the
66
+ **Changing `PGKILN_SECRET_KEY`** without `pgkiln admin rotate-key` ([chapter 3](03-builder.md#lockout-checks))
67
+ makes the stored secrets unreadable: pgkiln then reports that the secret must be entered again. Keep the key with your other server secrets, outside the
68
68
  database: a database dump alone does not reveal the secrets.
69
69
 
70
70
  ## Web credentials
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
@@ -171,6 +171,8 @@ export const de: BuilderMessages = {
171
171
  'wh.how_upgrade': 'So aktualisieren Sie',
172
172
  'wh.upgrade_steps': 'Zum Aktualisieren: Sichern Sie die Datenbank, installieren Sie die neue Version ({npm} oder das Docker-Image {docker}), führen Sie {migrate} aus und starten Sie den Server neu.',
173
173
  'wh.check_off': 'Die Prüfung auf neue Versionen ist deaktiviert.',
174
+ 'wh.lockout': 'Prüfungen gegen Aussperren, die Aufmerksamkeit brauchen: {count}.',
175
+ 'wh.lockout_open': 'Instanzeinstellungen',
174
176
  'wh.check_failed': 'Die letzte Prüfung ist fehlgeschlagen ({error}).',
175
177
  'wh.check_failed_older': 'Die letzte Prüfung ist fehlgeschlagen ({error}); das obige Ergebnis ist älter.',
176
178
  'wh.checked': 'Geprüft {ago}.',
@@ -174,6 +174,8 @@ export const en = {
174
174
  'wh.how_upgrade': 'How to upgrade',
175
175
  'wh.upgrade_steps': 'To upgrade: back up the database, install the new version ({npm}, or the Docker image {docker}), run {migrate} and restart the server.',
176
176
  'wh.check_off': 'The check for new versions is off.',
177
+ 'wh.lockout': 'Lockout checks that need attention: {count}.',
178
+ 'wh.lockout_open': 'Instance settings',
177
179
  'wh.check_failed': 'The last check failed ({error}).',
178
180
  'wh.check_failed_older': 'The last check failed ({error}); the result above is older.',
179
181
  'wh.checked': 'Checked {ago}.',
@@ -171,6 +171,8 @@ export const es: BuilderMessages = {
171
171
  'wh.how_upgrade': 'Cómo actualizar',
172
172
  'wh.upgrade_steps': 'Para actualizar: haga una copia de seguridad de la base de datos, instale la nueva versión ({npm}, o la imagen Docker {docker}), ejecute {migrate} y reinicie el servidor.',
173
173
  'wh.check_off': 'La comprobación de nuevas versiones está desactivada.',
174
+ 'wh.lockout': 'Comprobaciones de bloqueo que requieren atención: {count}.',
175
+ 'wh.lockout_open': 'Ajustes de la instancia',
174
176
  'wh.check_failed': 'La última comprobación ha fallado ({error}).',
175
177
  'wh.check_failed_older': 'La última comprobación ha fallado ({error}); el resultado anterior es más antiguo.',
176
178
  'wh.checked': 'Comprobado {ago}.',
@@ -171,6 +171,8 @@ export const fr: BuilderMessages = {
171
171
  'wh.how_upgrade': 'Comment mettre à niveau',
172
172
  'wh.upgrade_steps': "Pour mettre à niveau : sauvegardez la base de données, installez la nouvelle version ({npm}, ou l'image Docker {docker}), exécutez {migrate} et redémarrez le serveur.",
173
173
  'wh.check_off': 'La recherche de nouvelles versions est désactivée.',
174
+ 'wh.lockout': 'Contrôles contre le verrouillage à examiner : {count}.',
175
+ 'wh.lockout_open': 'Paramètres de l’instance',
174
176
  'wh.check_failed': 'La dernière vérification a échoué ({error}).',
175
177
  'wh.check_failed_older': 'La dernière vérification a échoué ({error}) ; le résultat ci-dessus est plus ancien.',
176
178
  'wh.checked': 'Vérifié {ago}.',
@@ -171,6 +171,8 @@ export const nl: BuilderMessages = {
171
171
  'wh.how_upgrade': 'Zo upgrade je',
172
172
  'wh.upgrade_steps': 'Upgraden: maak een back-up van de database, installeer de nieuwe versie ({npm}, of de Docker-image {docker}), voer {migrate} uit en herstart de server.',
173
173
  'wh.check_off': 'Het controleren op nieuwe versies staat uit.',
174
+ 'wh.lockout': 'Controles tegen buitensluiten die aandacht nodig hebben: {count}.',
175
+ 'wh.lockout_open': 'Instantie-instellingen',
174
176
  'wh.check_failed': 'De laatste controle is mislukt ({error}).',
175
177
  'wh.check_failed_older': 'De laatste controle is mislukt ({error}); het resultaat hierboven is ouder.',
176
178
  'wh.checked': '{ago} gecontroleerd.',
@@ -171,6 +171,8 @@ export const pt: BuilderMessages = {
171
171
  'wh.how_upgrade': 'Como atualizar',
172
172
  'wh.upgrade_steps': 'Para atualizar: faça backup do banco de dados, instale a nova versão ({npm}, ou a imagem Docker {docker}), execute {migrate} e reinicie o servidor.',
173
173
  'wh.check_off': 'A verificação de novas versões está desativada.',
174
+ 'wh.lockout': 'Verificações contra bloqueio que precisam de atenção: {count}.',
175
+ 'wh.lockout_open': 'Configurações da instância',
174
176
  'wh.check_failed': 'A última verificação falhou ({error}).',
175
177
  'wh.check_failed_older': 'A última verificação falhou ({error}); o resultado acima é mais antigo.',
176
178
  'wh.checked': 'Verificado {ago}.',
@@ -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
- return region(tx('wh.system'), html`${body}${admins}`, '', 'system-message');
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: reset a developer\'s two-factor sign-in, set the two-factor policy, allow the password again',
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\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
- options: { reason: { type: 'string' }, grace: { type: 'string' } },
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: [2, 2],
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
- throw new UsageError(`unknown action ${action}: use reset-2fa, set-2fa-policy or builder-sign-in`);
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' },
@@ -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
- 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
+ }
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
- const rpId = wanted && (host === wanted || host.endsWith(`.${wanted}`)) ? wanted : host;
224
- return { rpId, origin };
228
+ return wanted && (host === wanted || host.endsWith(`.${wanted}`)) ? wanted : host;
225
229
  }