pgkiln 0.60.0 → 0.61.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.
@@ -1714,16 +1714,16 @@ export const nl: BuilderMessages = {
1714
1714
  // ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
1715
1715
  'mfa.menu': 'Aanmelden in twee stappen',
1716
1716
  'mfa.title': 'Beveiliging',
1717
- 'mfa.intro': 'Meld u na uw wachtwoord aan met een code uit een authenticator-app. Met herstelcodes komt u binnen als u de telefoon kwijtraakt.',
1718
- 'mfa.required_you': 'Uw beheerder vereist aanmelden in twee stappen: voeg een authenticator-app toe.',
1717
+ 'mfa.intro': 'Bevestig na uw wachtwoord dat u het bent met een passkey (vingerafdruk, gezicht, beveiligingssleutel) of een code uit een authenticator-app. Met herstelcodes komt u binnen als u die kwijtraakt.',
1718
+ 'mfa.required_you': 'Uw beheerder vereist aanmelden in twee stappen: voeg een passkey of een authenticator-app toe.',
1719
1719
  'mfa.banner': 'Aanmelden in twee stappen is verplicht. Stel het in vóór {date}: {link}',
1720
1720
  'mfa.set_up_now': 'nu instellen',
1721
- 'mfa.apps': 'Authenticator-apps',
1721
+ 'mfa.factors': 'Passkeys en authenticator-apps',
1722
1722
  'mfa.app_name': 'Naam',
1723
1723
  'mfa.app_default': 'Telefoon',
1724
1724
  'mfa.added': 'Toegevoegd',
1725
1725
  'mfa.last_used': 'Laatst gebruikt',
1726
- 'mfa.no_apps': 'Nog geen authenticator-app: u meldt zich alleen met uw wachtwoord aan.',
1726
+ 'mfa.no_apps': 'Nog geen tweede factor: u meldt zich alleen met uw wachtwoord aan.',
1727
1727
  'mfa.add_app': 'Authenticator-app toevoegen',
1728
1728
  'mfa.no_key': 'Aanmelden in twee stappen heeft de instantiesleutel (PGKILN_SECRET_KEY) nodig om het geheim van de app op te slaan.',
1729
1729
  'mfa.scan': 'Scan de QR-code met een authenticator-app.',
@@ -1739,28 +1739,28 @@ export const nl: BuilderMessages = {
1739
1739
  'mfa.remove_app': 'Deze app verwijderen',
1740
1740
  'mfa.your_password': 'Uw wachtwoord',
1741
1741
  'mfa.wrong_password': 'Onjuist wachtwoord.',
1742
- 'mfa.cannot_remove_required': 'Aanmelden in twee stappen is voor u verplicht: voeg eerst een andere app toe.',
1742
+ 'mfa.cannot_remove_required': 'Aanmelden in twee stappen is voor u verplicht: voeg eerst een andere passkey of app toe.',
1743
1743
  'mfa.cannot_remove_admin': 'Minstens twee beheerders (of alle, als het er minder zijn) moeten aanmelden in twee stappen houden zolang het beleid aan staat.',
1744
1744
  'mfa.keep_admins': 'Nu niet: het beleid vereist minstens twee beheerders met aanmelden in twee stappen (of alle, als het er minder zijn).',
1745
- 'mfa.removed': 'Authenticator-app verwijderd.',
1745
+ 'mfa.removed': 'Verwijderd.',
1746
1746
  'mfa.codes_title': 'Herstelcodes',
1747
1747
  'mfa.codes_once': 'Bewaar deze codes op een veilige plek, niet bij uw telefoon. Elke code vervangt één keer een app-code. Ze worden alleen nu getoond.',
1748
1748
  'mfa.codes_saved': 'Ik heb deze codes bewaard',
1749
1749
  'mfa.codes_done': 'Klaar',
1750
1750
  'mfa.codes_kept': 'Herstelcodes bewaard.',
1751
1751
  'mfa.codes_left': 'Ongebruikte herstelcodes: {n}.',
1752
- 'mfa.codes_after': 'U krijgt herstelcodes zodra u een authenticator-app toevoegt.',
1752
+ 'mfa.codes_after': 'U krijgt herstelcodes zodra u een passkey of authenticator-app toevoegt.',
1753
1753
  'mfa.codes_low': 'Nog maar weinig herstelcodes: maak nieuwe.',
1754
1754
  'mfa.codes_new': 'Nieuwe herstelcodes',
1755
1755
  'mfa.codes_new_confirm': 'Nieuwe herstelcodes maken? De oude werken dan niet meer.',
1756
1756
  'mfa.expired': 'Het aanmelden duurde te lang: meld u opnieuw aan.',
1757
1757
  'mfa.step_title': 'Aanmelden in twee stappen',
1758
- 'mfa.step_intro': 'Voer de code uit uw authenticator-app voor {user} in.',
1758
+ 'mfa.step_intro': 'Bevestig dat u het bent, {user}: gebruik een passkey of voer een code uit uw authenticator-app in.',
1759
1759
  'mfa.code': 'Code',
1760
1760
  'mfa.code_help': '6 cijfers uit de app, of een van uw herstelcodes.',
1761
1761
  'mfa.verify': 'Controleren',
1762
1762
  'mfa.cancel': 'Terug naar aanmelden',
1763
- 'mfa.locked_out': 'Telefoon en codes kwijt? Vraag een beheerder uw aanmelden in twee stappen te resetten.',
1763
+ 'mfa.locked_out': 'Telefoon, sleutel en codes kwijt? Vraag een beheerder uw aanmelden in twee stappen te resetten.',
1764
1764
  'mfa.wrong': 'Die code klopt niet of is al gebruikt.',
1765
1765
  'mfa.column': 'Twee stappen',
1766
1766
  'mfa.on': 'aan',
@@ -1784,4 +1784,28 @@ export const nl: BuilderMessages = {
1784
1784
  'mfa.policy_needs_admins': 'Eerst moeten minstens twee beheerders (of alle, als het er minder zijn) aanmelden in twee stappen instellen, zodat niemand alleen buitengesloten raakt.',
1785
1785
  'mfa.without': 'Zonder aanmelden in twee stappen: {list}.',
1786
1786
  'mfa.cli_note': 'Buitengesloten? Op de server: pgkiln admin reset-2fa <developer> of pgkiln admin set-2fa-policy off.',
1787
+ // ------------------------------------------------------------ Security: passkeys and trusted browsers (sprint 127)
1788
+ 'mfa.kind': 'Soort',
1789
+ 'mfa.kind_passkey': 'Passkeys',
1790
+ 'mfa.kind_app': 'Authenticator-apps',
1791
+ 'mfa.other_domain': 'voor {domain}',
1792
+ 'mfa.other_domain_help': 'Gemaakt voor {domain}: werkt niet op dit adres. Voeg hier een nieuwe passkey toe.',
1793
+ 'mfa.passkey_intro': 'Meld u aan met uw vingerafdruk, gezicht, schermvergrendeling of een beveiligingssleutel. Een passkey werkt alleen op deze site.',
1794
+ 'mfa.passkey_default': 'Passkey',
1795
+ 'mfa.add_passkey': 'Passkey toevoegen',
1796
+ 'mfa.passkey_needs_js': 'Een passkey toevoegen vraagt JavaScript; een authenticator-app werkt zonder.',
1797
+ 'mfa.passkey_failed': 'De passkey werkte niet. Probeer het opnieuw of gebruik een code.',
1798
+ 'mfa.passkey_known': 'Deze passkey is al toegevoegd.',
1799
+ 'mfa.passkey_added': 'Passkey toegevoegd: voortaan kunt u zich ermee aanmelden.',
1800
+ 'mfa.remove_passkey': 'Deze passkey verwijderen',
1801
+ 'mfa.use_passkey': 'Passkey gebruiken',
1802
+ 'mfa.trust_browser': 'Deze browser {days} dagen vertrouwen',
1803
+ 'mfa.trusted_title': 'Vertrouwde browsers',
1804
+ 'mfa.trusted_count': 'Browsers die de tweede stap overslaan: {n} (hoogstens {days} dagen).',
1805
+ 'mfa.trusted_off': 'Browsers vertrouwen staat uit.',
1806
+ 'mfa.forget_browsers': 'Vertrouwde browsers vergeten',
1807
+ 'mfa.browsers_forgotten': 'Elke browser vraagt weer om de tweede stap.',
1808
+ 'mfa.trust_days': 'Browser vertrouwen (dagen)',
1809
+ 'mfa.trust_days_help': 'Na de tweede stap mag een ontwikkelaar die in die browser zoveel dagen overslaan. 0: nooit.',
1810
+ 'mfa.passkey_needs_host': 'Passkeys hebben een hostnaam nodig, geen IP-adres ({host}): open de builder via localhost of de domeinnaam.',
1787
1811
  };
@@ -1714,16 +1714,16 @@ export const pt: BuilderMessages = {
1714
1714
  // ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
1715
1715
  'mfa.menu': 'Login em duas etapas',
1716
1716
  'mfa.title': 'Segurança',
1717
- 'mfa.intro': 'Entre com um código de um aplicativo autenticador depois da senha. Os códigos de recuperação permitem entrar se você perder o telefone.',
1718
- 'mfa.required_you': 'Seu administrador exige login em duas etapas: adicione um aplicativo autenticador.',
1717
+ 'mfa.intro': 'Depois da senha, confirme que é você com uma chave de acesso (digital, rosto, chave de segurança) ou um código de um aplicativo autenticador. Os códigos de recuperação permitem entrar se você os perder.',
1718
+ 'mfa.required_you': 'Seu administrador exige login em duas etapas: adicione uma chave de acesso ou um aplicativo autenticador.',
1719
1719
  'mfa.banner': 'O login em duas etapas é obrigatório. Configure-o antes de {date}: {link}',
1720
1720
  'mfa.set_up_now': 'configurar agora',
1721
- 'mfa.apps': 'Aplicativos autenticadores',
1721
+ 'mfa.factors': 'Chaves de acesso e aplicativos autenticadores',
1722
1722
  'mfa.app_name': 'Nome',
1723
1723
  'mfa.app_default': 'Telefone',
1724
1724
  'mfa.added': 'Adicionado',
1725
1725
  'mfa.last_used': 'Último uso',
1726
- 'mfa.no_apps': 'Nenhum aplicativo autenticador ainda: você entra apenas com sua senha.',
1726
+ 'mfa.no_apps': 'Nenhum segundo fator ainda: você entra apenas com sua senha.',
1727
1727
  'mfa.add_app': 'Adicionar um aplicativo autenticador',
1728
1728
  'mfa.no_key': 'O login em duas etapas precisa da chave da instância (PGKILN_SECRET_KEY) para guardar o segredo do aplicativo.',
1729
1729
  'mfa.scan': 'Leia o código QR com um aplicativo autenticador.',
@@ -1739,28 +1739,28 @@ export const pt: BuilderMessages = {
1739
1739
  'mfa.remove_app': 'Remover este aplicativo',
1740
1740
  'mfa.your_password': 'Sua senha',
1741
1741
  'mfa.wrong_password': 'Senha incorreta.',
1742
- 'mfa.cannot_remove_required': 'O login em duas etapas é obrigatório para você: adicione outro aplicativo antes de remover este.',
1742
+ 'mfa.cannot_remove_required': 'O login em duas etapas é obrigatório para você: adicione outra chave ou aplicativo antes de remover este.',
1743
1743
  'mfa.cannot_remove_admin': 'Pelo menos dois administradores (ou todos, se forem menos) devem manter o login em duas etapas enquanto a política estiver ativa.',
1744
1744
  'mfa.keep_admins': 'Agora não: a política exige pelo menos dois administradores com login em duas etapas (ou todos, se forem menos).',
1745
- 'mfa.removed': 'Aplicativo autenticador removido.',
1745
+ 'mfa.removed': 'Removido.',
1746
1746
  'mfa.codes_title': 'Códigos de recuperação',
1747
1747
  'mfa.codes_once': 'Guarde estes códigos em local seguro, longe do telefone. Cada um substitui uma vez um código do aplicativo. Eles são mostrados só agora.',
1748
1748
  'mfa.codes_saved': 'Guardei estes códigos',
1749
1749
  'mfa.codes_done': 'Concluído',
1750
1750
  'mfa.codes_kept': 'Códigos de recuperação guardados.',
1751
1751
  'mfa.codes_left': 'Códigos de recuperação não usados: {n}.',
1752
- 'mfa.codes_after': 'Você recebe códigos de recuperação ao adicionar um aplicativo autenticador.',
1752
+ 'mfa.codes_after': 'Você recebe códigos de recuperação ao adicionar uma chave de acesso ou um aplicativo autenticador.',
1753
1753
  'mfa.codes_low': 'Restam poucos códigos de recuperação: gere novos.',
1754
1754
  'mfa.codes_new': 'Novos códigos de recuperação',
1755
1755
  'mfa.codes_new_confirm': 'Gerar novos códigos de recuperação? Os antigos deixam de funcionar.',
1756
1756
  'mfa.expired': 'O login demorou demais: entre novamente.',
1757
1757
  'mfa.step_title': 'Login em duas etapas',
1758
- 'mfa.step_intro': 'Digite o código do seu aplicativo autenticador para {user}.',
1758
+ 'mfa.step_intro': 'Confirme que é você, {user}: use uma chave de acesso ou digite um código do seu aplicativo autenticador.',
1759
1759
  'mfa.code': 'Código',
1760
1760
  'mfa.code_help': '6 dígitos do aplicativo ou um dos seus códigos de recuperação.',
1761
1761
  'mfa.verify': 'Verificar',
1762
1762
  'mfa.cancel': 'Voltar ao login',
1763
- 'mfa.locked_out': 'Perdeu o telefone e os códigos? Peça a um administrador para redefinir seu login em duas etapas.',
1763
+ 'mfa.locked_out': 'Perdeu o telefone, a chave e os códigos? Peça a um administrador para redefinir seu login em duas etapas.',
1764
1764
  'mfa.wrong': 'Esse código não está certo ou já foi usado.',
1765
1765
  'mfa.column': 'Duas etapas',
1766
1766
  'mfa.on': 'ativado',
@@ -1784,4 +1784,28 @@ export const pt: BuilderMessages = {
1784
1784
  'mfa.policy_needs_admins': 'Primeiro, pelo menos dois administradores (ou todos, se forem menos) configuram o login em duas etapas, para ninguém ficar bloqueado sozinho.',
1785
1785
  'mfa.without': 'Sem login em duas etapas: {list}.',
1786
1786
  'mfa.cli_note': 'Bloqueado? No servidor: pgkiln admin reset-2fa <developer> ou pgkiln admin set-2fa-policy off.',
1787
+ // ------------------------------------------------------------ Security: passkeys and trusted browsers (sprint 127)
1788
+ 'mfa.kind': 'Tipo',
1789
+ 'mfa.kind_passkey': 'Chaves de acesso',
1790
+ 'mfa.kind_app': 'Aplicativos autenticadores',
1791
+ 'mfa.other_domain': 'para {domain}',
1792
+ 'mfa.other_domain_help': 'Criada para {domain}: não funciona neste endereço. Adicione aqui uma nova chave.',
1793
+ 'mfa.passkey_intro': 'Entre com sua digital, rosto, bloqueio de tela ou uma chave de segurança. Uma chave de acesso só funciona neste site.',
1794
+ 'mfa.passkey_default': 'Chave de acesso',
1795
+ 'mfa.add_passkey': 'Adicionar uma chave de acesso',
1796
+ 'mfa.passkey_needs_js': 'Adicionar uma chave de acesso exige JavaScript; um aplicativo autenticador funciona sem ele.',
1797
+ 'mfa.passkey_failed': 'A chave de acesso não funcionou. Tente de novo ou use um código.',
1798
+ 'mfa.passkey_known': 'Esta chave de acesso já foi adicionada.',
1799
+ 'mfa.passkey_added': 'Chave de acesso adicionada: a partir de agora você pode entrar com ela.',
1800
+ 'mfa.remove_passkey': 'Remover esta chave de acesso',
1801
+ 'mfa.use_passkey': 'Usar uma chave de acesso',
1802
+ 'mfa.trust_browser': 'Confiar neste navegador por {days} dias',
1803
+ 'mfa.trusted_title': 'Navegadores confiáveis',
1804
+ 'mfa.trusted_count': 'Navegadores que pulam a segunda etapa: {n} (até {days} dias).',
1805
+ 'mfa.trusted_off': 'A confiança em navegadores está desativada.',
1806
+ 'mfa.forget_browsers': 'Esquecer os navegadores confiáveis',
1807
+ 'mfa.browsers_forgotten': 'Todos os navegadores voltam a pedir a segunda etapa.',
1808
+ 'mfa.trust_days': 'Confiar em um navegador (dias)',
1809
+ 'mfa.trust_days_help': 'Depois da segunda etapa, um desenvolvedor pode pulá-la naquele navegador por estes dias. 0: nunca.',
1810
+ 'mfa.passkey_needs_host': 'Chaves de acesso precisam de um nome de host, não de um endereço IP ({host}): abra o construtor em localhost ou no nome de domínio.',
1787
1811
  };
@@ -27,7 +27,9 @@ import { appFromFileRoutes } from './appfromfile.ts';
27
27
  import { clearDevbar } from '../runtime/devbar.ts';
28
28
  import { EMAIL } from './find-workspace.ts';
29
29
  import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
30
- import { checkSecondFactor, codesLeft, hasFactor, leavesTooFewAdmins } from '../mfa.ts';
30
+ import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeysOf, trustBrowser, trustDays, usePasskey } from '../mfa.ts';
31
+ import { giveChallenge, takeChallenge } from './security.ts';
32
+ import { relyingParty, verifyAssertion, WebAuthnError } from '../webauthn.ts';
31
33
  import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
32
34
 
33
35
  // Builder pages: sign-in, workspace and app home, settings, activity and
@@ -150,8 +152,9 @@ export async function builderRoutes(app: FastifyInstance) {
150
152
  flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
151
153
  return back(reply, s, again);
152
154
  }
153
- // (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code
154
- if (await hasFactor(dev.username)) {
155
+ // (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code;
156
+ // (sprint 127) unless this browser was trusted after a second factor, for the instance's number of days
157
+ if ((await hasFactor(dev.username)) && !(await isTrustedBrowser(dev.username, req.cookies?.[TRUST_COOKIE]))) {
155
158
  s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, at: Date.now() });
156
159
  await saveState(s);
157
160
  return reply.redirect(`${BASE}/login/2fa`, 303);
@@ -187,8 +190,17 @@ export async function builderRoutes(app: FastifyInstance) {
187
190
  return reply.redirect(next, 303);
188
191
  }
189
192
 
190
- // (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code
193
+ // (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code;
194
+ // (sprint 127) or a passkey, and "trust this browser" when the instance allows it
191
195
  const MFA_SECONDS = 300;
196
+ const TRUST_COOKIE = 'pgkiln_btrust';
197
+ const trustThisBrowser = async (req: Req, reply: Parameters<typeof back>[0], username: string) => {
198
+ const days = await trustDays();
199
+ if (!days || req.body?.trust !== '1') return;
200
+ const token = await trustBrowser(username, days, String(req.headers['user-agent'] ?? ''));
201
+ reply.setCookie(TRUST_COOKIE, token, { path: BASE, httpOnly: true, sameSite: 'lax', maxAge: days * 86400, secure: process.env.COOKIE_SECURE === 'true' });
202
+ logActivity({ username, event: '2fa_trusted_browser', ip: clientIp(req), detail: `${days} days` });
203
+ };
192
204
  const pendingMfa = (s: { state: Record<string, any> }) => {
193
205
  try {
194
206
  const p = JSON.parse(String(s.state.__MFA ?? ''));
@@ -209,6 +221,7 @@ export async function builderRoutes(app: FastifyInstance) {
209
221
  }
210
222
  const error = takeFlash(s, '__ERROR');
211
223
  await saveState(s);
224
+ const [keys, days] = await Promise.all([passkeysOf(p.u, relyingParty(req).rpId), trustDays()]);
212
225
  return reply.type('text/html').send(documentShell(
213
226
  `${tx('mfa.step_title')} · ${tx('shell.builder')}`,
214
227
  html`<main class="signin">
@@ -223,10 +236,14 @@ export async function builderRoutes(app: FastifyInstance) {
223
236
  <p class="muted u-mt0">${tx('mfa.step_intro', { user: p.u })}</p>
224
237
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
225
238
  ${csrf(s)}
239
+ ${keys.length ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature">
240
+ <button class="btn btn-hot signin-submit" hidden data-passkey="${BASE}/login/2fa/passkey/options" data-passkey-submit="${BASE}/login/2fa/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('mfa.use_passkey')}</button>
241
+ <p class="small" role="status" data-passkey-status></p>` : ''}
226
242
  <div class="field signin-field"><label class="label" for="f_code">${tx('mfa.code')}</label>
227
243
  <div class="signin-input">${icon('key')}<input id="f_code" name="code" required autocomplete="one-time-code" autofocus maxlength="20" spellcheck="false" autocapitalize="off"></div>
228
244
  <small class="help">${tx('mfa.code_help')}</small></div>
229
- <button class="btn btn-hot signin-submit">${tx('mfa.verify')}</button>
245
+ ${days ? html`<label class="check"><input type="checkbox" name="trust" value="1"> ${tx('mfa.trust_browser', { days })}</label>` : ''}
246
+ <button class="btn${keys.length ? '' : ' btn-hot'} signin-submit">${tx('mfa.verify')}</button>
230
247
  <p class="muted small signin-hint"><a href="${BASE}/login">${tx('mfa.cancel')}</a> · ${tx('mfa.locked_out')}</p>
231
248
  </form>
232
249
  </section>
@@ -265,11 +282,69 @@ export async function builderRoutes(app: FastifyInstance) {
265
282
  const left = await codesLeft(p.u);
266
283
  await logActivity({ username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
267
284
  // to the Security page: one code fewer, maybe time for new ones
285
+ await trustThisBrowser(req, reply, p.u);
268
286
  return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: `${BASE}/security`, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (recovery code)' });
269
287
  }
288
+ await trustThisBrowser(req, reply, p.u);
270
289
  return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa' });
271
290
  });
272
291
 
292
+ // (sprint 127) the second step with a passkey: options for navigator.credentials.get(), then the browser's answer
293
+ app.post(`${BASE}/login/2fa/passkey/options`, async (req: Req, reply) => {
294
+ const s = await getSession(req, reply, null, BASE);
295
+ const p = pendingMfa(s);
296
+ if (req.body?.__csrf !== s.csrf_token || !p) return reply.code(403).send('Sign in again.');
297
+ const rp = relyingParty(req);
298
+ const keys = await passkeysOf(p.u, rp.rpId);
299
+ if (!keys.length) return reply.code(404).send('No passkey for this site.');
300
+ const challenge = giveChallenge(s as Parameters<typeof giveChallenge>[0], 'get');
301
+ await saveState(s);
302
+ reply.header('cache-control', 'no-store');
303
+ return reply.send({ mode: 'get', challenge, rpId: rp.rpId, allow: keys.map((k) => ({ id: k.credential_id, transports: k.transports })) });
304
+ });
305
+ app.post(`${BASE}/login/2fa/passkey`, async (req: Req, reply) => {
306
+ const s = await getSession(req, reply, null, BASE);
307
+ const ip = clientIp(req);
308
+ const picked = validBuilderLanguage(s.state.__BLANG);
309
+ const tx = translatorFor(picked ?? cookieLanguage(req));
310
+ const p = pendingMfa(s);
311
+ const challenge = takeChallenge(s as Parameters<typeof takeChallenge>[0], 'get');
312
+ if (req.body?.__csrf !== s.csrf_token || !p) {
313
+ delete s.state.__MFA;
314
+ flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
315
+ return back(reply, s, `${BASE}/login`);
316
+ }
317
+ if (await loginThrottled(null, p.u, ip)) {
318
+ logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
319
+ delete s.state.__MFA;
320
+ flash(s, tx('signin.throttled'), 'error');
321
+ return back(reply, s, `${BASE}/login`);
322
+ }
323
+ const rp = relyingParty(req);
324
+ const key = (await passkeysOf(p.u, rp.rpId)).find((k) => k.credential_id === String(req.body?.credentialId ?? ''));
325
+ let why = 'unknown passkey';
326
+ if (key && challenge) {
327
+ try {
328
+ const r = verifyAssertion(
329
+ { clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
330
+ { alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
331
+ if (await usePasskey(key.id, key.sign_count, r.signCount)) why = '';
332
+ else why = 'used at the same time elsewhere';
333
+ } catch (e) {
334
+ why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
335
+ }
336
+ } else if (!challenge) why = 'no or an expired challenge';
337
+ if (why) {
338
+ await logActivity({ username: p.u, event: 'login_failed', ip, detail: `builder 2fa passkey: ${why}` });
339
+ flash(s, tx('mfa.passkey_failed'), 'error');
340
+ return back(reply, s, `${BASE}/login/2fa`);
341
+ }
342
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
343
+ if (!dev) return back(reply, s, `${BASE}/login`);
344
+ await trustThisBrowser(req, reply, p.u);
345
+ return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (passkey)' });
346
+ });
347
+
273
348
  app.post(`${BASE}/logout`, async (req: Req, reply) => {
274
349
  const s = await getSession(req, reply, null, BASE);
275
350
  if (req.body?.__csrf === s.csrf_token) {
@@ -865,8 +940,9 @@ export async function builderRoutes(app: FastifyInstance) {
865
940
  [s.username, req.body?.current ?? '', req.body?.password],
866
941
  );
867
942
  if (r.rowCount !== 1) throw new Error(tr(s)('dev.err.current'));
868
- // End all other builder sessions of this developer.
943
+ // End all other builder sessions of this developer, and (sprint 127) forget the browsers they trusted.
869
944
  await owner.query('delete from meta.session where app_id is null and username = $1 and id <> $2', [s.username, s.id]);
945
+ await forgetBrowsers(s.username!);
870
946
  delete s.state.__WEAK;
871
947
  flash(s, tr(s)('dev.pw_changed'));
872
948
  } catch (e) {
@@ -1,14 +1,18 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isIP } from 'node:net';
1
3
  import type { FastifyInstance } from 'fastify';
2
4
  import { owner } from '../db.ts';
3
5
  import { html, raw, type Raw } from '../html.ts';
4
6
  import { icon } from '../icons.ts';
5
7
  import {
6
- addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy, newRecoveryCodes, newTotpSecret,
7
- notifyMfa, otpauthUri, policyProblem, requiredFor, resetFactors, setPolicy, unbase32, type MfaPolicy,
8
+ addPasskey, addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, forgetBrowsers, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
9
+ newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requiredFor, resetFactors, setPolicy, setTrustDays, trustDays,
10
+ trustedBrowsers, unbase32, type MfaPolicy,
8
11
  } from '../mfa.ts';
9
12
  import { qrSvg } from '../qrcode.ts';
10
13
  import { secretKeyConfigured } from '../secrets.ts';
11
- import { clientIp, logActivity, type Session } from '../session.ts';
14
+ import { clientIp, logActivity, saveState, type Session } from '../session.ts';
15
+ import { ALGORITHMS, b64url, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
12
16
  import { tr } from './i18n.ts';
13
17
  import { isAdmin } from './locks.ts';
14
18
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
@@ -19,6 +23,29 @@ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Re
19
23
  // app confirms it, then stored encrypted with the instance key. Recovery codes are shown once; they count
20
24
  // only after "I saved them". Removing a factor asks for the password, and is refused when it would leave a
21
25
  // developer the policy covers without one, or the instance without enough administrators who have one.
26
+ // (sprint 127) Passkeys (WebAuthn): the browser asks the server for options (a one-time challenge kept in the
27
+ // session for five minutes), calls navigator.credentials.create() and posts the answer in a normal form
28
+ // (public/builder.js, [data-passkey]); without JavaScript the button stays hidden. Trusted browsers can be
29
+ // forgotten here; the instance decides how long a browser may be trusted (0 = never).
30
+
31
+ const CHALLENGE_SECONDS = 300;
32
+
33
+ /** A one-time WebAuthn challenge kept in the session (for this purpose only), or null when there is none or it expired. */
34
+ export function takeChallenge(s: Session, purpose: 'create' | 'get'): string | null {
35
+ try {
36
+ const p = JSON.parse(String(s.state.__PK ?? ''));
37
+ delete s.state.__PK;
38
+ return p && p.purpose === purpose && Date.now() - Number(p.at) < CHALLENGE_SECONDS * 1000 && typeof p.c === 'string' ? p.c : null;
39
+ } catch {
40
+ delete s.state.__PK;
41
+ return null;
42
+ }
43
+ }
44
+ export function giveChallenge(s: Session, purpose: 'create' | 'get'): string {
45
+ const c = newChallenge();
46
+ s.state.__PK = JSON.stringify({ c, purpose, at: Date.now() });
47
+ return c;
48
+ }
22
49
 
23
50
  const SECRET_MINUTES = 10;
24
51
 
@@ -32,7 +59,8 @@ export async function securityRoutes(app: FastifyInstance) {
32
59
  if (!s) return;
33
60
  const tx = tr(s);
34
61
  const me = s.username!;
35
- const [factors, left, pol, admin] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me)]);
62
+ const [factors, left, pol, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me), trustDays(), trustedBrowsers(me)]);
63
+ const rp = relyingParty(req);
36
64
  const required = requiredFor(pol.policy, admin);
37
65
  const pending = (() => {
38
66
  try {
@@ -80,18 +108,34 @@ export async function securityRoutes(app: FastifyInstance) {
80
108
  <div class="buttons"><button class="btn btn-hot">${tx('mfa.codes_done')}</button></div></form>`, '', 'codes')
81
109
  : ''}
82
110
  <div class="columns">
83
- ${region(tx('mfa.apps'), html`${factors.length
84
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('mfa.app_name')}</th><th>${tx('mfa.added')}</th><th>${tx('mfa.last_used')}</th><th></th></tr></thead><tbody>
85
- ${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon('key')} ${f.name}</td><td data-label="${tx('mfa.added')}">${when(f.created_at)}</td><td data-label="${tx('mfa.last_used')}">${when(f.last_used_at)}</td>
111
+ ${region(tx('mfa.factors'), html`${factors.length
112
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('mfa.app_name')}</th><th>${tx('mfa.kind')}</th><th>${tx('mfa.added')}</th><th>${tx('mfa.last_used')}</th><th></th></tr></thead><tbody>
113
+ ${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon(f.kind === 'passkey' ? 'fingerprint' : 'smartphone')} ${f.name}</td>
114
+ <td data-label="${tx('mfa.kind')}">${f.kind === 'passkey' ? tx('mfa.kind_passkey') : tx('mfa.kind_app')}${f.kind === 'passkey' && f.rp_id !== rp.rpId ? html` <span class="badge badge-warn" title="${tx('mfa.other_domain_help', { domain: f.rp_id ?? '' })}">${tx('mfa.other_domain', { domain: f.rp_id ?? '' })}</span>` : ''}</td>
115
+ <td data-label="${tx('mfa.added')}">${when(f.created_at)}</td><td data-label="${tx('mfa.last_used')}">${when(f.last_used_at)}</td>
86
116
  <td><details class="menu"><summary class="btn btn-sm">${tx('mfa.remove')}</summary><div class="menu-panel"><form method="post" action="${BASE}/security/factor/${f.id}/delete" class="menu-section">${csrf(s)}
87
117
  ${input(`password_${f.id}`, tx('mfa.your_password'), '', { type: 'password', required: true, auto: 'current-password' })}
88
- <button class="btn btn-sm btn-danger">${tx('mfa.remove_app')}</button></form></div></details></td></tr>`)}
118
+ <button class="btn btn-sm btn-danger">${tx(f.kind === 'passkey' ? 'mfa.remove_passkey' : 'mfa.remove_app')}</button></form></div></details></td></tr>`)}
89
119
  </tbody></table></div>`
90
120
  : html`<p class="muted">${tx('mfa.no_apps')}</p>`}
121
+ <h3>${tx('mfa.kind_passkey')}</h3>
122
+ <p class="muted small u-mt0">${tx('mfa.passkey_intro')}</p>
123
+ <form method="post" action="${BASE}/security/passkey">${csrf(s)}
124
+ <div class="form-grid">${input('pk_name', tx('mfa.app_name'), tx('mfa.passkey_default'), { required: true })}</div>
125
+ <input type="hidden" name="clientDataJSON"><input type="hidden" name="attestationObject"><input type="hidden" name="transports">
126
+ ${isIP(rp.rpId) ? html`<p class="alert alert-info" role="status">${tx('mfa.passkey_needs_host', { host: rp.rpId })}</p>`
127
+ : html`<div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}" hidden data-passkey="${BASE}/security/passkey/options" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('mfa.add_passkey')}</button></div>`}
128
+ <p class="small" role="status" data-passkey-status></p>
129
+ <noscript><p class="muted small">${tx('mfa.passkey_needs_js')}</p></noscript>
130
+ </form>
131
+ <h3>${tx('mfa.kind_app')}</h3>
91
132
  ${setup}`)}
92
133
  ${region(tx('mfa.codes_title'), html`<p class="u-mt0">${factors.length || left ? tx('mfa.codes_left', { n: left }) : tx('mfa.codes_after')}</p>
93
134
  ${factors.length && left < 3 ? html`<p class="alert alert-warning" role="status">${tx('mfa.codes_low')}</p>` : ''}
94
- ${factors.length ? html`<form method="post" action="${BASE}/security/codes">${csrf(s)}<button class="btn" data-confirm="${tx('mfa.codes_new_confirm')}">${tx('mfa.codes_new')}</button></form>` : ''}`)}
135
+ ${factors.length ? html`<form method="post" action="${BASE}/security/codes">${csrf(s)}<button class="btn" data-confirm="${tx('mfa.codes_new_confirm')}">${tx('mfa.codes_new')}</button></form>` : ''}
136
+ ${days || trusted ? html`<h3>${tx('mfa.trusted_title')}</h3>
137
+ <p class="u-mt0">${days ? tx('mfa.trusted_count', { n: trusted, days }) : tx('mfa.trusted_off')}</p>
138
+ ${trusted ? html`<form method="post" action="${BASE}/security/forget-browsers">${csrf(s)}<button class="btn">${tx('mfa.forget_browsers')}</button></form>` : ''}` : ''}`)}
95
139
  </div>`;
96
140
  return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
97
141
  });
@@ -167,7 +211,7 @@ export async function securityRoutes(app: FastifyInstance) {
167
211
  const tx = tr(s);
168
212
  const me = s.username!;
169
213
  const id = Number(req.params.id);
170
- const f = await owner.one('select id from meta.developer_factor where id = $1 and username = $2', [id, me]);
214
+ const f = await owner.one<{ kind: string }>('select id, kind from meta.developer_factor where id = $1 and username = $2', [id, me]);
171
215
  if (!f) return reply.code(404).send('Not found');
172
216
  if (!(await verifyPassword(me, String(req.body?.[`password_${id}`] ?? '')))) {
173
217
  await logActivity({ username: me, event: 'login_failed', ip: clientIp(req), detail: 'builder (removing a second factor)' });
@@ -193,11 +237,73 @@ export async function securityRoutes(app: FastifyInstance) {
193
237
  }
194
238
  await owner.query('delete from meta.developer_factor where id = $1 and username = $2', [id, me]);
195
239
  if (last) await owner.query('delete from meta.developer_recovery_code where username = $1', [me]);
196
- await logActivity({ username: me, event: '2fa_removed', ip: clientIp(req), detail: last ? 'the last authenticator app (recovery codes removed)' : 'an authenticator app' });
240
+ // a lost phone or key: browsers trusted with it must ask again
241
+ await forgetBrowsers(me);
242
+ const what = f.kind === 'passkey' ? 'a passkey' : 'an authenticator app';
243
+ await logActivity({ username: me, event: '2fa_removed', ip: clientIp(req), detail: last ? `${what}, the last factor (recovery codes removed)` : what });
197
244
  flash(s, tx('mfa.removed'));
198
245
  return back(reply, s, `${BASE}/security`);
199
246
  });
200
247
 
248
+ // (sprint 127) a passkey: the options for navigator.credentials.create(), then the browser's answer
249
+ app.post(`${BASE}/security/passkey/options`, async (req: Req, reply) => {
250
+ const s = await developer(req, reply);
251
+ if (!s) return;
252
+ const me = s.username!;
253
+ const rp = relyingParty(req);
254
+ const challenge = giveChallenge(s, 'create');
255
+ await saveState(s);
256
+ reply.header('cache-control', 'no-store');
257
+ return reply.send({
258
+ mode: 'create', challenge, rp: { id: rp.rpId, name: 'pgkiln' }, algs: ALGORITHMS,
259
+ // a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
260
+ user: { id: b64url(createHash('sha256').update(`pgkiln-developer:${me}`).digest().subarray(0, 16)), name: me, displayName: me },
261
+ exclude: (await passkeysOf(me, rp.rpId)).map((p) => p.credential_id),
262
+ });
263
+ });
264
+
265
+ app.post(`${BASE}/security/passkey`, async (req: Req, reply) => {
266
+ const s = await developer(req, reply);
267
+ if (!s) return;
268
+ const tx = tr(s);
269
+ const me = s.username!;
270
+ const challenge = takeChallenge(s, 'create');
271
+ if (!challenge) {
272
+ flash(s, tx('mfa.setup_expired'), 'error');
273
+ return back(reply, s, `${BASE}/security`);
274
+ }
275
+ const rp = relyingParty(req);
276
+ let reg;
277
+ try {
278
+ reg = verifyRegistration({ clientDataJSON: String(req.body?.clientDataJSON ?? ''), attestationObject: String(req.body?.attestationObject ?? '') }, { challenge, ...rp });
279
+ } catch (e) {
280
+ await logActivity({ username: me, event: '2fa_refused', ip: clientIp(req), detail: `passkey: ${e instanceof WebAuthnError ? e.message : 'unreadable answer'}` });
281
+ flash(s, tx('mfa.passkey_failed'), 'error');
282
+ return back(reply, s, `${BASE}/security`);
283
+ }
284
+ const first = !(await hasFactor(me));
285
+ try {
286
+ await addPasskey(me, String(req.body?.pk_name ?? ''), { ...reg, rpId: rp.rpId, transports: String(req.body?.transports ?? '').split(',').filter(Boolean) });
287
+ } catch {
288
+ flash(s, tx('mfa.passkey_known'), 'error');
289
+ return back(reply, s, `${BASE}/security`);
290
+ }
291
+ delete s.state.__MFA_DUE;
292
+ await logActivity({ username: me, event: '2fa_enrolled', ip: clientIp(req), detail: `passkey (${rp.rpId})` });
293
+ if (first || !(await codesLeft(me))) s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(me));
294
+ flash(s, tx('mfa.passkey_added'));
295
+ return back(reply, s, `${BASE}/security${s.state.__CODES_NEW ? '#codes' : ''}`);
296
+ });
297
+
298
+ app.post(`${BASE}/security/forget-browsers`, async (req: Req, reply) => {
299
+ const s = await developer(req, reply);
300
+ if (!s) return;
301
+ await forgetBrowsers(s.username!);
302
+ await logActivity({ username: s.username, event: '2fa_forget_browsers', ip: clientIp(req) });
303
+ flash(s, tr(s)('mfa.browsers_forgotten'));
304
+ return back(reply, s, `${BASE}/security`);
305
+ });
306
+
201
307
  // (SEC-6) an administrator resets another developer's second factors: logged, the developer and the administrators told
202
308
  app.post(`${BASE}/developers/reset-2fa`, async (req: Req, reply) => {
203
309
  const s = await developer(req, reply);
@@ -228,13 +334,16 @@ export async function securityRoutes(app: FastifyInstance) {
228
334
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change the policy.');
229
335
  const policy = MFA_POLICIES.includes(req.body?.policy as MfaPolicy) ? (req.body!.policy as MfaPolicy) : null;
230
336
  const grace = Number(req.body?.grace_days);
231
- if (!policy || !Number.isInteger(grace) || grace < 0 || grace > 90) return reply.code(400).send('policy is off, admins or all; grace_days 0 to 90');
337
+ const trust = Number(req.body?.trust_days ?? 0);
338
+ if (!policy || !Number.isInteger(grace) || grace < 0 || grace > 90 || !Number.isInteger(trust) || trust < 0 || trust > 90)
339
+ return reply.code(400).send('policy is off, admins or all; grace_days and trust_days 0 to 90');
232
340
  if (await policyProblem(policy)) {
233
341
  flash(s, tx('mfa.policy_needs_admins'), 'error');
234
342
  return back(reply, s, `${BASE}/instance#mfa`);
235
343
  }
236
344
  await setPolicy(policy, grace);
237
- await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days` });
345
+ await setTrustDays(trust);
346
+ await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days` });
238
347
  await notifyMfa(await adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${s.username} set the two-factor policy to ${policy} (grace period ${grace} days).`);
239
348
  flash(s, tx('mfa.policy_saved'));
240
349
  return back(reply, s, `${BASE}/instance#mfa`);
@@ -244,8 +353,8 @@ export async function securityRoutes(app: FastifyInstance) {
244
353
  /** The Instance settings region for the policy (administrators). */
245
354
  export async function policyRegion(s: Session): Promise<Raw> {
246
355
  const tx = tr(s);
247
- const [pol, af, without] = await Promise.all([
248
- mfaPolicy(), adminFactors(),
356
+ const [pol, af, days, without] = await Promise.all([
357
+ mfaPolicy(), adminFactors(), trustDays(),
249
358
  owner.query<{ username: string; is_admin: boolean }>('select username, is_admin from meta.developer d where not exists (select 1 from meta.developer_factor f where f.username = d.username) order by username'),
250
359
  ]);
251
360
  const opt = (v: MfaPolicy) => html`<option value="${v}"${pol.policy === v ? raw(' selected') : ''}>${tx(({ off: 'mfa.policy_off', admins: 'mfa.policy_admins', all: 'mfa.policy_all' } as const)[v])}</option>`;
@@ -253,6 +362,7 @@ export async function policyRegion(s: Session): Promise<Raw> {
253
362
  <div class="form-grid">
254
363
  <div class="field"><label class="label" for="f_policy">${tx('mfa.policy_label')}</label><select id="f_policy" name="policy">${MFA_POLICIES.map(opt)}</select></div>
255
364
  ${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
365
+ ${input('trust_days', tx('mfa.trust_days'), days, { type: 'number', help: tx('mfa.trust_days_help') })}
256
366
  </div>
257
367
  <p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
258
368
  <div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
package/src/instance.ts CHANGED
@@ -108,7 +108,8 @@ export async function saveInstanceSettings(values: Record<string, string | undef
108
108
  export const CONFIGURATION: readonly { env: string; secret?: boolean; default: string; help: string }[] = [
109
109
  { env: 'PORT', default: '3100', help: 'HTTP port' },
110
110
  { env: 'HOST', default: '127.0.0.1', help: 'Address the server listens on' },
111
- { env: 'PUBLIC_URL', default: '(none)', help: 'The public address (single sign-on redirects, links)' },
111
+ { env: 'PUBLIC_URL', default: '(none)', help: 'The public address (single sign-on redirects, links, passkeys)' },
112
+ { env: 'PGKILN_RP_ID', default: '(the host of PUBLIC_URL)', help: 'A parent domain for developers\' passkeys' },
112
113
  { env: 'DATABASE_URL', secret: true, default: '(none)', help: 'Owner connection (migrations, builder, SQL Workshop)' },
113
114
  { env: 'RUNTIME_DATABASE_URL', secret: true, default: 'DATABASE_URL', help: 'Least-privilege connection that runs applications' },
114
115
  { env: 'COOKIE_SECURE', default: 'false', help: 'Cookies only over HTTPS' },