diff --git a/CHANGELOG.md b/CHANGELOG.md index 464538b90..95ec55088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ the second factor needed confirming. They now open the confirmation dialog and retry once confirmed, also after a single sign-on login (Refs #959). The refusal names the methods the account has in `meta.methods`. +- **A passkey counts as a second factor for managed profiles.** An account + whose only strong sign-in is a passkey was told to set up a second factor + before it could create, edit, hand over or delete a managed profile or + manage its guardians. A passkey sign-in, or confirming the passkey in the + dialog, is now enough; the admin key rotation follows the same rule. + Password sign-in, the native app and the actions that ask for a fresh + factor only from accounts with TOTP or a security key are unchanged. - An account that signs in only through single sign-on is no longer offered a password field it cannot fill when it sets up a second factor; it is asked to sign in again. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7173b8160..229b3002d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21687,9 +21687,10 @@ paths: "401": description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: - auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot - create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including - for a Bearer caller, whose transport this gate does not read." + auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor + a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only + strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401 + arrives here too, including for a Bearer caller, whose transport this gate does not read." content: application/json: schema: @@ -34618,8 +34619,9 @@ components: Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent - management relationship, and an account with no second factor enrolled is refused rather than waved through. - Rate-limited to ten an hour per caller." + management relationship, and an account with no second factor enrolled is refused rather than waved through. A + primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof + stamps the session. Rate-limited to ten an hour per caller." UpdateManagedProfileRequest: type: object properties: diff --git a/docs/ops/encryption-key-rotation.md b/docs/ops/encryption-key-rotation.md index b48c64a72..a1417998b 100644 --- a/docs/ops/encryption-key-rotation.md +++ b/docs/ops/encryption-key-rotation.md @@ -43,12 +43,12 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`). retained. 3. **Re-encrypt the stored data under the new key.** Open **Admin → Encryption** and press **Rotate now**. The button needs an admin account - with a second factor, confirmed recently; it queues one background run + with a second factor or a passkey, confirmed recently; it queues one background run (`POST /api/admin/encryption/rotate`) on the app's own worker, so it works on the published image with nothing else installed. The same page shows the per-column counts and the result of the last run. - Without a second factor on any admin account, run the rotation script + Without a second factor or a passkey on any admin account, run the rotation script instead. It imports the application code and the generated database client, so it runs from a source checkout of the same release, not inside the production container (the image has no package manager and diff --git a/docs/self-hosting/account-sharing.md b/docs/self-hosting/account-sharing.md index 62df60982..7cc76a1de 100644 --- a/docs/self-hosting/account-sharing.md +++ b/docs/self-hosting/account-sharing.md @@ -189,9 +189,11 @@ no password, no passkey and no e-mail address, and it is never presented as something to log in as. - It is created from **Settings → Shared access**, under a fresh second - factor. An account with no second factor enrolled is refused rather than - waved through, because the act creates a permanent management - relationship over a person who cannot object to it. + factor. A passkey counts as one: a guardian who signs in with a passkey, + or confirms it in the dialog the app opens, gets through. An account with + neither a second factor nor a passkey is refused rather than waved + through, because the act creates a permanent management relationship + over a person who cannot object to it. - Creation takes a display name, optionally a real date of birth, and the language and timezone the record's own days and reminders are measured in. A year on its own is never turned into the first of January. The @@ -356,10 +358,11 @@ Four operational notes: and no address to look it up by. - **The second-factor requirement is not the same on both features, and the difference is deliberate.** Creating a managed profile, deleting - one and inviting a second guardian require a second factor - unconditionally: an account with none enrolled is refused and told to - set one up, because those acts create or end a permanent relationship - over somebody who cannot object. Offering manage access on an ordinary - invitation asks for a fresh factor only from an account that has one, so + one, handing it over and inviting a second guardian require a second + factor unconditionally, and a passkey counts as one there: an account + with neither is refused and told to set one up, because those acts + create or end a permanent relationship over somebody who cannot object. + Offering manage access on an ordinary invitation asks for a fresh factor + only from an account with TOTP or a security key, so an instance where nobody has enrolled a factor can still use every level of ordinary sharing. All of them are browser-only either way. diff --git a/messages/de.json b/messages/de.json index 71c80c016..016675ed8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5160,7 +5160,7 @@ "changeInSettings": "Unter Einstellungen, Konto ändern", "cta": "Bestätigen und weiter", "managedTitle": "Die Person, um die du dich kümmerst", - "managedBody": "Ihre Akte hat keinen eigenen Login; du betreust sie aus deinem Konto. Zum Anlegen ist ein zweiter Faktor auf deinem Konto nötig.", + "managedBody": "Ihre Akte hat keinen eigenen Login; du betreust sie aus deinem Konto. Zum Anlegen ist ein zweiter Faktor oder ein Passkey auf deinem Konto nötig.", "managedCreate": "Profil anlegen und abschließen", "managedLater": "Du kannst es auch später unter Einstellungen, Geteilter Zugriff anlegen.", "finishWithoutProfile": "Ohne Profil abschließen" @@ -12261,7 +12261,7 @@ "errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.", "errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.", "errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben.", - "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.", + "errorStepUp": "Bestätige deinen zweiten Faktor oder Passkey und versuche es erneut.", "errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier. Prüfe deine Verbindung und versuche es erneut." }, "given": { @@ -12338,7 +12338,7 @@ "create": "Profil anlegen", "creating": "Wird angelegt…", "created": "{name} ist eingerichtet. Über das Kontomenü kannst du in diese Akte wechseln.", - "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.", + "errorStepUp": "Bestätige deinen zweiten Faktor oder Passkey und versuche es erneut.", "errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert. Prüfe deine Verbindung und versuche es erneut.", "errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.", "errorFailed": "Das Profil konnte nicht angelegt werden.", @@ -12377,7 +12377,7 @@ "guardianErrorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen oder dieser E-Mail-Adresse.", "guardianErrorRateLimit": "Zu viele Einladungen in der letzten Stunde. Warte etwas und versuche es erneut.", "guardianErrorFailed": "Die Einladung konnte nicht gesendet werden.", - "errorMfaRequired": "Dafür ist ein zweiter Faktor nötig, und dein Konto hat noch keinen. Richte unter Einstellungen → Sicherheit einen ein und versuche es dann erneut.", + "errorMfaRequired": "Dafür ist ein zweiter Faktor oder ein Passkey nötig, und dein Konto hat noch keins von beiden. Richte unter Einstellungen → Sicherheit eins ein und versuche es dann erneut.", "rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.", "errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut.", "handover": { diff --git a/messages/en.json b/messages/en.json index 799837e13..1026030c2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5160,7 +5160,7 @@ "changeInSettings": "Change these under Settings, Account", "cta": "Confirm and continue", "managedTitle": "The person you look after", - "managedBody": "Their record has no login of its own; you look after it from your account. A second factor on your account is required to create it.", + "managedBody": "Their record has no login of its own; you look after it from your account. A second factor or a passkey on your account is required to create it.", "managedCreate": "Create the profile and finish", "managedLater": "You can also create it later under Settings, Shared access.", "finishWithoutProfile": "Finish without the profile" @@ -12261,7 +12261,7 @@ "errorFailed": "The invitation could not be sent. Please try again.", "errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.", "errorManageBrowserOnly": "Manage access can only be offered in a browser.", - "errorStepUp": "Confirm your second factor, then try again.", + "errorStepUp": "Confirm your second factor or passkey, then try again.", "errorOffline": "That did not reach the server. Your invitation is still here. Check your connection and try again." }, "given": { @@ -12338,7 +12338,7 @@ "create": "Create profile", "creating": "Creating…", "created": "{name} is set up. You can switch into that record from the account menu.", - "errorStepUp": "Confirm your second factor, then try again.", + "errorStepUp": "Confirm your second factor or passkey, then try again.", "errorOffline": "That did not reach the server. Nothing has changed. Check your connection and try again.", "errorInvalid": "The server refused those details. Check the name, the date and the timezone.", "errorFailed": "The profile could not be created.", @@ -12377,7 +12377,7 @@ "guardianErrorNoAccount": "No account on this instance carries that name or e-mail.", "guardianErrorRateLimit": "Too many invitations in the last hour. Wait a while and try again.", "guardianErrorFailed": "The invitation could not be sent.", - "errorMfaRequired": "This needs a second factor, and your account does not have one yet. Set one up under Settings → Security, then try again.", + "errorMfaRequired": "This needs a second factor or a passkey, and your account has neither yet. Set one up under Settings → Security, then try again.", "rosterLoadError": "Could not load who looks after this record.", "errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again.", "handover": { diff --git a/messages/es.json b/messages/es.json index ad66196db..18073a549 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5160,7 +5160,7 @@ "changeInSettings": "Cámbialo en Ajustes, Cuenta", "cta": "Confirmar y continuar", "managedTitle": "La persona a tu cargo", - "managedBody": "Su registro no tiene inicio de sesión propio; lo gestionas desde tu cuenta. Para crearlo hace falta un segundo factor en tu cuenta.", + "managedBody": "Su registro no tiene inicio de sesión propio; lo gestionas desde tu cuenta. Para crearlo hace falta un segundo factor o una llave de acceso en tu cuenta.", "managedCreate": "Crear el perfil y terminar", "managedLater": "También puedes crearlo después en Ajustes, Acceso compartido.", "finishWithoutProfile": "Terminar sin el perfil" @@ -12261,7 +12261,7 @@ "errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.", "errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.", "errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador.", - "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.", + "errorStepUp": "Confirma tu segundo factor o tu llave de acceso y vuelve a intentarlo.", "errorOffline": "Esto no llegó al servidor. Tu invitación sigue aquí: comprueba tu conexión e inténtalo de nuevo." }, "given": { @@ -12338,7 +12338,7 @@ "create": "Crear perfil", "creating": "Creando…", "created": "{name} ya está listo. Puedes entrar en ese historial desde el menú de la cuenta.", - "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.", + "errorStepUp": "Confirma tu segundo factor o tu llave de acceso y vuelve a intentarlo.", "errorOffline": "Esto no llegó al servidor. No ha cambiado nada. Revisa tu conexión y vuelve a intentarlo.", "errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.", "errorFailed": "No se pudo crear el perfil.", @@ -12377,7 +12377,7 @@ "guardianErrorNoAccount": "En esta instancia no hay ninguna cuenta con ese nombre o correo.", "guardianErrorRateLimit": "Demasiadas invitaciones en la última hora. Espera un poco y vuelve a intentarlo.", "guardianErrorFailed": "No se pudo enviar la invitación.", - "errorMfaRequired": "Esto necesita un segundo factor y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.", + "errorMfaRequired": "Esto necesita un segundo factor o una llave de acceso, y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.", "rosterLoadError": "No se pudo cargar quién se ocupa de este historial.", "errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo.", "handover": { diff --git a/messages/fr.json b/messages/fr.json index f3bc886bd..aeb089aff 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5160,7 +5160,7 @@ "changeInSettings": "À modifier dans Réglages, Compte", "cta": "Confirmer et continuer", "managedTitle": "La personne dont vous vous occupez", - "managedBody": "Son dossier n'a pas d'identifiant propre ; vous le gérez depuis votre compte. Un second facteur sur votre compte est nécessaire pour le créer.", + "managedBody": "Son dossier n'a pas d'identifiant propre ; vous le gérez depuis votre compte. Un second facteur ou une clé d'accès sur votre compte est nécessaire pour le créer.", "managedCreate": "Créer le profil et terminer", "managedLater": "Vous pouvez aussi le créer plus tard dans Réglages, Accès partagé.", "finishWithoutProfile": "Terminer sans le profil" @@ -12261,7 +12261,7 @@ "errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.", "errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.", "errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur.", - "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.", + "errorStepUp": "Confirmez votre deuxième facteur ou votre clé d'accès, puis réessayez.", "errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là : vérifiez votre connexion et réessayez." }, "given": { @@ -12338,7 +12338,7 @@ "create": "Créer le profil", "creating": "Création…", "created": "{name} est en place. Vous pouvez entrer dans ce dossier depuis le menu du compte.", - "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.", + "errorStepUp": "Confirmez votre deuxième facteur ou votre clé d'accès, puis réessayez.", "errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé. Vérifiez votre connexion et réessayez.", "errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.", "errorFailed": "Le profil n'a pas pu être créé.", @@ -12377,7 +12377,7 @@ "guardianErrorNoAccount": "Aucun compte de cette instance ne porte ce nom ou cette adresse e-mail.", "guardianErrorRateLimit": "Trop d'invitations dans la dernière heure. Patientez un peu et réessayez.", "guardianErrorFailed": "L'invitation n'a pas pu être envoyée.", - "errorMfaRequired": "Cela demande un deuxième facteur, et votre compte n'en a pas encore. Configurez-en un dans Réglages → Sécurité, puis réessayez.", + "errorMfaRequired": "Cela demande un deuxième facteur ou une clé d'accès, et votre compte n'a encore ni l'un ni l'autre. Configurez-en un dans Réglages → Sécurité, puis réessayez.", "rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.", "errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez.", "handover": { diff --git a/messages/it.json b/messages/it.json index 09c83a377..902146b22 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5160,7 +5160,7 @@ "changeInSettings": "Modificali in Impostazioni, Account", "cta": "Conferma e continua", "managedTitle": "La persona di cui ti prendi cura", - "managedBody": "La sua cartella non ha un accesso proprio; la gestisci dal tuo account. Per crearla serve un secondo fattore sul tuo account.", + "managedBody": "La sua cartella non ha un accesso proprio; la gestisci dal tuo account. Per crearla serve un secondo fattore o una passkey sul tuo account.", "managedCreate": "Crea il profilo e concludi", "managedLater": "Puoi crearlo anche in seguito in Impostazioni, Accesso condiviso.", "finishWithoutProfile": "Concludi senza il profilo" @@ -12261,7 +12261,7 @@ "errorFailed": "Non è stato possibile inviare l'invito. Riprova.", "errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.", "errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser.", - "errorStepUp": "Conferma il tuo secondo fattore e riprova.", + "errorStepUp": "Conferma il tuo secondo fattore o la tua passkey e riprova.", "errorOffline": "La richiesta non ha raggiunto il server. Il tuo invito è ancora qui: controlla la connessione e riprova." }, "given": { @@ -12338,7 +12338,7 @@ "create": "Crea profilo", "creating": "Creazione in corso…", "created": "{name} è pronto. Puoi entrare in quella cartella dal menu dell'account.", - "errorStepUp": "Conferma il tuo secondo fattore e riprova.", + "errorStepUp": "Conferma il tuo secondo fattore o la tua passkey e riprova.", "errorOffline": "Non è arrivato al server. Non è cambiato nulla. Controlla la connessione e riprova.", "errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.", "errorFailed": "Non è stato possibile creare il profilo.", @@ -12377,7 +12377,7 @@ "guardianErrorNoAccount": "Su questa istanza non esiste un account con quel nome o quella e-mail.", "guardianErrorRateLimit": "Troppi inviti nell'ultima ora. Aspetta un po' e riprova.", "guardianErrorFailed": "Non è stato possibile inviare l'invito.", - "errorMfaRequired": "Serve un secondo fattore e il tuo account non ne ha ancora uno. Impostane uno in Impostazioni → Sicurezza e riprova.", + "errorMfaRequired": "Serve un secondo fattore o una passkey e il tuo account non ha ancora né l'uno né l'altra. Impostane uno in Impostazioni → Sicurezza e riprova.", "rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.", "errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova.", "handover": { diff --git a/messages/ko.json b/messages/ko.json index d408e3a2e..fa35ce8b5 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5160,7 +5160,7 @@ "changeInSettings": "설정, 계정에서 변경", "cta": "확인하고 계속", "managedTitle": "내가 돌보는 사람", - "managedBody": "이 기록에는 별도의 로그인이 없으며 내 계정에서 관리합니다. 만들려면 내 계정에 2차 인증이 필요합니다.", + "managedBody": "이 기록에는 별도의 로그인이 없으며 내 계정에서 관리합니다. 만들려면 내 계정에 2차 인증 또는 패스키가 필요합니다.", "managedCreate": "프로필 만들고 마치기", "managedLater": "나중에 설정, 공유 접근에서 만들 수도 있습니다.", "finishWithoutProfile": "프로필 없이 마치기" @@ -12261,7 +12261,7 @@ "errorFailed": "초대를 보내지 못했어요. 다시 시도해 주세요.", "errorScope": "그 섹션들은 그렇게 공유할 수 없어요. 섹션을 하나 이상 고르거나 기록 전체를 공유하세요.", "errorManageBrowserOnly": "관리 권한은 브라우저에서만 줄 수 있어요.", - "errorStepUp": "2단계 인증을 확인한 뒤 다시 시도해 주세요.", + "errorStepUp": "2단계 인증 또는 패스키를 확인한 뒤 다시 시도해 주세요.", "errorOffline": "서버에 닿지 못했어요. 초대 내용은 그대로 있으니 연결을 확인하고 다시 시도해 주세요." }, "given": { @@ -12338,7 +12338,7 @@ "create": "프로필 만들기", "creating": "만드는 중…", "created": "{name} 님 프로필을 만들었어요. 계정 메뉴에서 그 기록으로 전환할 수 있어요.", - "errorStepUp": "2단계 인증을 확인한 뒤 다시 시도해 주세요.", + "errorStepUp": "2단계 인증 또는 패스키를 확인한 뒤 다시 시도해 주세요.", "errorOffline": "서버에 닿지 못했어요. 바뀐 것은 없으니 연결을 확인하고 다시 시도해 주세요.", "errorInvalid": "서버가 그 내용을 거절했어요. 이름과 날짜, 시간대를 확인해 주세요.", "errorFailed": "프로필을 만들지 못했어요.", @@ -12377,7 +12377,7 @@ "guardianErrorNoAccount": "이 인스턴스에 그 이름이나 이메일을 쓰는 계정이 없어요.", "guardianErrorRateLimit": "최근 한 시간 동안 초대가 너무 많았어요. 잠시 기다렸다 다시 시도해 주세요.", "guardianErrorFailed": "초대를 보내지 못했어요.", - "errorMfaRequired": "여기에는 2단계 인증이 필요한데 계정에 아직 없어요. 설정 → 보안에서 등록한 뒤 다시 시도해 주세요.", + "errorMfaRequired": "여기에는 2단계 인증 또는 패스키가 필요한데 계정에 아직 둘 다 없어요. 설정 → 보안에서 하나를 등록한 뒤 다시 시도해 주세요.", "rosterLoadError": "이 기록을 돌보는 사람을 불러오지 못했어요.", "errorRateLimit": "최근 한 시간 동안 만든 프로필이 너무 많아요. 잠시 기다렸다 다시 시도해 주세요.", "handover": { diff --git a/messages/pl.json b/messages/pl.json index 320f8480e..87ff5900e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5160,7 +5160,7 @@ "changeInSettings": "Zmień w Ustawieniach, Konto", "cta": "Potwierdź i kontynuuj", "managedTitle": "Osoba, którą się opiekujesz", - "managedBody": "Jej kartoteka nie ma własnego logowania; zarządzasz nią ze swojego konta. Do jej utworzenia potrzebny jest drugi składnik na Twoim koncie.", + "managedBody": "Jej kartoteka nie ma własnego logowania; zarządzasz nią ze swojego konta. Do jej utworzenia potrzebny jest drugi składnik albo klucz dostępu na Twoim koncie.", "managedCreate": "Utwórz profil i zakończ", "managedLater": "Możesz też utworzyć go później w Ustawieniach, Dostęp współdzielony.", "finishWithoutProfile": "Zakończ bez profilu" @@ -12261,7 +12261,7 @@ "errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.", "errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.", "errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce.", - "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.", + "errorStepUp": "Potwierdź drugi składnik logowania albo klucz dostępu i spróbuj ponownie.", "errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest. Sprawdź połączenie i spróbuj ponownie." }, "given": { @@ -12338,7 +12338,7 @@ "create": "Utwórz profil", "creating": "Tworzenie…", "created": "{name} jest gotowy. Do tej karty przejdziesz z menu konta.", - "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.", + "errorStepUp": "Potwierdź drugi składnik logowania albo klucz dostępu i spróbuj ponownie.", "errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło. Sprawdź połączenie i spróbuj ponownie.", "errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.", "errorFailed": "Nie udało się utworzyć profilu.", @@ -12377,7 +12377,7 @@ "guardianErrorNoAccount": "W tej instancji nie ma konta o takiej nazwie ani takim adresie e-mail.", "guardianErrorRateLimit": "Zbyt wiele zaproszeń w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.", "guardianErrorFailed": "Nie udało się wysłać zaproszenia.", - "errorMfaRequired": "To wymaga drugiego składnika logowania, a Twoje konto jeszcze go nie ma. Skonfiguruj go w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.", + "errorMfaRequired": "To wymaga drugiego składnika logowania albo klucza dostępu, a Twoje konto nie ma jeszcze żadnego z nich. Skonfiguruj jeden w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.", "rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.", "errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.", "handover": { diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index 008ada8b1..c57eb3786 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -600,7 +600,7 @@ describe("ending a managed profile", () => { "p1", ); const html = renderGatedSection(); - expect(html).toContain("Confirm your second factor"); + expect(html).toContain("Confirm your second factor or passkey"); expect(html).not.toContain("The profile could not be deleted"); }); @@ -616,7 +616,7 @@ describe("ending a managed profile", () => { "p1", ); const html = renderGatedSection(); - expect(html).toContain("does not have one yet"); + expect(html).toContain("has neither yet"); expect(html).toContain("Security"); }); diff --git a/src/lib/__tests__/require-fresh-mfa.test.ts b/src/lib/__tests__/require-fresh-mfa.test.ts index 8e119510c..28104c009 100644 --- a/src/lib/__tests__/require-fresh-mfa.test.ts +++ b/src/lib/__tests__/require-fresh-mfa.test.ts @@ -133,9 +133,10 @@ describe("requireFreshMfa", () => { ); }); - it("rejects a user with neither TOTP nor a security key", async () => { + it("rejects a user with neither TOTP nor a security key nor a passkey", async () => { mockSession({ ...MFA_USER, totpConfirmedAt: null }); vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never); await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({ errorCode: "auth.stepup.mfa_not_enrolled", }); @@ -163,21 +164,50 @@ describe("requireFreshMfa", () => { * parameter, and it changes nothing else: the freshness read below is the same * read either way. * - * A primary passkey is deliberately outside the default set and inside - * `"any-possession"`. It DOES stamp `mfaVerifiedAt` on login, so it can clear - * the gate — but counting it by default would pull every passkey-holding + * Since v1.43.3 a primary passkey counts by default: `requireFreshMfa` + * defaults to `"any-possession"`, so a passkey-only account clears the + * managed-profile acts with a passkey re-proof. It DOES stamp `mfaVerifiedAt` + * on login and on re-proof, so it can clear the gate. What stays narrow is + * `hasSecondFactorEnrolled`: widening THAT would pull every passkey-holding * account into `requireFreshMfaIfEnrolled`'s gate on account deletion, the - * data reset, the password change, the encrypted export and key rotation, - * none of which have ever applied to them. The sibling suite - * `require-fresh-mfa-if-enrolled.test.ts` is what catches that if the two - * predicates are ever collapsed into one. + * data reset, the password change, the encrypted export and key rotation. + * The sibling suite `require-fresh-mfa-if-enrolled.test.ts` is what catches + * that if the two predicates are ever collapsed into one. */ + it("admits a passkey-only account by default, with a fresh stamp", async () => { + mockSession({ ...MFA_USER, totpConfirmedAt: null }); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(Date.now() - 60_000), + } as never); + + const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + expect(ctx.user.id).toBe("user-1"); + }); + + it("asks a passkey-only account for its passkey by default when the stamp is stale", async () => { + mockSession({ ...MFA_USER, totpConfirmedAt: null }); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: null, + } as never); + + await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({ + errorCode: "auth.stepup.required", + extraMeta: { methods: ["passkey"] }, + }); + }); + it("refuses a passkey-only account when only a second factor counts", async () => { mockSession({ ...MFA_USER, totpConfirmedAt: null }); vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); - await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({ + await expect( + requireFreshMfa(5 * 60, "second-factor"), + ).rejects.toMatchObject({ errorCode: "auth.stepup.mfa_not_enrolled", }); }); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index f19fa79e0..c827c33aa 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -1253,11 +1253,13 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date }; * the route is protecting. * * `second-factor` — a confirmed TOTP secret or a registered second-factor - * security key. The default, and the right question for the routes that MANAGE - * a second factor: there is nothing there to protect if nothing is enrolled. + * security key. The default of `requireMfaManagementAuth`, and the right + * question for the routes that MANAGE a second factor: there is nothing there + * to protect if nothing is enrolled. * - * `any-possession` — the above, OR a registered PRIMARY passkey. The right - * question for a route protecting the passkey itself, because a passkey is a + * `any-possession` — the above, OR a registered PRIMARY passkey. The default + * of `requireFreshMfa` since v1.43.3, and the right question for a route + * protecting the passkey itself, because a passkey is a * possession factor in its own right and this codebase already treats it as * one in three places: `/api/auth/passkey/login-verify` stamps `mfaVerifiedAt` * on a passkey login (v1.23, M-review M1), `passkey` is in @@ -1270,6 +1272,37 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date }; */ export type FreshFactorProofSource = "second-factor" | "any-possession"; +/* + * A PASSKEY COUNTS AS A SECOND FACTOR FOR THE UNCONDITIONAL STEP-UP GATE. + * + * Maintainer decision, v1.43.3. `requireFreshMfa` defaults to + * `any-possession`, so an account whose only strong credential is a passkey + * is no longer refused `mfa_not_enrolled` on the routes that call it directly + * (creating, editing, deleting and handing over a managed profile, inviting + * and removing a guardian, the admin key rotation). It clears the gate the + * way every other account does: a passkey sign-in or a passkey re-proof at + * `POST /api/auth/reproof` stamps `Session.mfaVerifiedAt`, and the gate reads + * that stamp inside the same five minutes. + * + * The limits, each deliberate: + * + * - `hasSecondFactorEnrolled` keeps its narrow meaning. It decides whether + * the CONDITIONAL gates (`requireFreshMfaIfEnrolled`, + * `requireFreshMfaOrElevationIfEnrolled`, `requireRecentProof`'s branches, + * the enrollment proofs) apply at all. Widening it would newly gate + * account deletion, the data reset, the password change and the export + * for every passkey holder, take the password re-proof away from them, and + * change what the native app's Bearer requests need. None of that is part + * of the decision. + * - Password login is untouched. It asks for a second factor from + * `totpConfirmedAt` and the security-key count only; a passkey-only + * account signing in with its password is not challenged, as before. + * - The MFA-management routes (`requireMfaManagementAuth`) still default to + * `second-factor`: disabling TOTP or rotating recovery codes on an account + * with no second factor has nothing to protect. + * - Cookie only, as ever. The Bearer path is not reached from here. + */ + /** * v1.23 — step-up gate. Passes only for a COOKIE session whose * `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user holds a @@ -1285,12 +1318,13 @@ export type FreshFactorProofSource = "second-factor" | "any-possession"; * waves gate account deletion, key rotation, and passphrase export on it. * * @param proofSource which credentials count as "can prove a factor". Defaults - * to the second-factor set every existing caller means; see - * {@link FreshFactorProofSource}. + * to `any-possession`, so a primary passkey counts (see the decision note + * below {@link FreshFactorProofSource}); `requireMfaManagementAuth` passes + * `second-factor` explicitly. */ export async function requireFreshMfa( maxAgeSeconds: number, - proofSource: FreshFactorProofSource = "second-factor", + proofSource: FreshFactorProofSource = "any-possession", ): Promise { const sessionData = await getSession(); if (!sessionData) diff --git a/src/lib/auth/second-factor.ts b/src/lib/auth/second-factor.ts index 48634c9fa..78d5ea8e6 100644 --- a/src/lib/auth/second-factor.ts +++ b/src/lib/auth/second-factor.ts @@ -21,6 +21,13 @@ import { prisma } from "@/lib/db"; * a behaviour change to five destructive actions, made in passing, for the * benefit of a sixth. `canProveFreshFactor` in `api-handler.ts` is where the wider question * lives. + * + * Since v1.43.3 a passkey DOES count as a second factor where the gate is + * unconditional: `requireFreshMfa` defaults to `any-possession`, so a + * passkey-only account clears the managed-profile acts with a passkey + * re-proof instead of being told to enrol something. That decision was made + * there and not here, on purpose; the note beside `FreshFactorProofSource` + * lists what it does and does not change. */ export async function hasSecondFactorEnrolled(user: { id: string; diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 31b5fbfe0..3e175c73f 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -75,7 +75,7 @@ const accountGrantInvite = inviteGrantSchema.meta({ const createManagedProfileRequest = createManagedProfileSchema.meta({ id: "CreateManagedProfileRequest", description: - "Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.", + "Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. A primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof stamps the session. Rate-limited to ten an hour per caller.", }); const updateManagedProfileRequest = updateManagedProfileSchema.meta({ @@ -1089,7 +1089,7 @@ export const accountSharingPaths: NonNullable = { }, "401": { description: - "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.", + "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.", content: { "application/json": { schema: errorEnvelope } }, }, "429": { diff --git a/tests/integration/managed-profile-passkey-step-up.test.ts b/tests/integration/managed-profile-passkey-step-up.test.ts new file mode 100644 index 000000000..06651bbb8 --- /dev/null +++ b/tests/integration/managed-profile-passkey-step-up.test.ts @@ -0,0 +1,235 @@ +/** + * #959 — a passkey counts as the second factor on the managed-profile acts. + * + * A Guardian whose only strong credential is a primary passkey (a password + * too, but no TOTP and no security key) used to be refused + * `auth.stepup.mfa_not_enrolled` by `requireFreshMfa`, and so could not hand a + * profile over, create one or manage its guardians. Driven against real + * Postgres through the real routes, with only the WebAuthn signature check + * stubbed: + * + * 1. the password login is unchanged: no MFA challenge, a session straight + * away, and that session is not stamped as a fresh factor; + * 2. the handover refuses with `auth.stepup.required` and offers the passkey + * (not the password) as the way through; + * 3. a password re-proof does not open the gate; + * 4. a passkey re-proof, which is what the dialog posts, does, and the + * handover goes through. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { handoverCreateBody } from "@/lib/queries/use-managed-profiles"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-managed-profile-passkey-step-up-32-bytes-min"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: vi.fn(async () => {}), +})); + +// The signature check is the one piece a test cannot produce honestly; the +// challenge lookup, the ownership check and the session stamp all run. +vi.mock("@/lib/auth/passkey", async () => { + const actual = + await vi.importActual( + "@/lib/auth/passkey", + ); + return { ...actual, verifyAuthentication: vi.fn() }; +}); + +const { hashPassword } = await import("@/lib/auth/password"); + +const APP = "http://localhost:3000"; +const PASSWORD = "Correct horse battery staple!42"; +const EMAIL = "guardian@example.test"; +const CREDENTIAL_ID = "guardian-primary-passkey"; + +type RouteFn = (request: NextRequest) => Promise; + +function post(path: string, body?: unknown): NextRequest { + return new NextRequest(`${APP}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); +} + +async function login() { + const { POST } = await import("@/app/api/auth/login/route"); + return (POST as unknown as RouteFn)( + post("/api/auth/login", { email: EMAIL, password: PASSWORD }), + ); +} + +async function reproof(body: unknown) { + const { POST } = await import("@/app/api/auth/reproof/route"); + return (POST as unknown as RouteFn)(post("/api/auth/reproof", body)); +} + +async function handover(profileId: string) { + const { POST } = + await import("@/app/api/managed-profiles/[id]/handover/route"); + return POST( + post( + `/api/managed-profiles/${profileId}/handover`, + handoverCreateBody({ profileId, expiresInDays: 7, proposals: [] }), + ), + { params: Promise.resolve({ id: profileId }) }, + ); +} + +async function body(res: Response) { + return (await res.json()) as { + data?: unknown; + meta?: { errorCode?: string; methods?: string[]; mfaRequired?: boolean }; + }; +} + +async function passkeyProof(userId: string) { + const challenge = await getPrismaClient().authChallenge.create({ + data: { + userId, + challenge: `proof-${Date.now()}`, + type: "authentication", + expiresAt: new Date(Date.now() + 5 * 60_000), + }, + }); + return { + method: "passkey", + challengeId: challenge.id, + credential: { + id: CREDENTIAL_ID, + rawId: CREDENTIAL_ID, + type: "public-key", + response: { + clientDataJSON: "client-data", + authenticatorData: "authenticator-data", + signature: "signature", + }, + clientExtensionResults: {}, + }, + }; +} + +let guardianId = ""; +let profileId = ""; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.authChallenge.deleteMany(); + await prisma.rateLimit.deleteMany(); + cookieJar.clear(); + headerJar.clear(); + process.env.APP_URL = APP; + process.env.NEXT_PUBLIC_APP_URL = APP; + + const guardian = await prisma.user.create({ + data: { + username: "guardian", + email: EMAIL, + passwordHash: await hashPassword(PASSWORD), + onboardingCompletedAt: new Date(), + }, + }); + guardianId = guardian.id; + await prisma.passkey.create({ + data: { + userId: guardian.id, + credentialId: CREDENTIAL_ID, + credentialPublicKey: Buffer.from([4, 3, 2, 1]), + counter: 0, + credentialDeviceType: "multiDevice", + credentialBackedUp: true, + transports: ["internal"], + }, + }); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Robin", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + profileId = profile.id; + + const { verifyAuthentication } = await import("@/lib/auth/passkey"); + vi.mocked(verifyAuthentication).mockResolvedValue({ + verification: { verified: true }, + passkey: { userId: guardian.id }, + } as never); +}); + +describe("a Guardian whose only strong credential is a passkey", () => { + it("signs in with the password exactly as before, without an MFA challenge", async () => { + const res = await login(); + expect(res.status).toBe(200); + expect((await body(res)).meta?.mfaRequired).toBeUndefined(); + expect(cookieJar.get("healthlog_session")).toBeDefined(); + const session = await getPrismaClient().session.findFirstOrThrow({ + where: { userId: guardianId }, + select: { mfaVerifiedAt: true }, + }); + // The password login proves no factor, so it cannot open the gate itself. + expect(session.mfaVerifiedAt).toBeNull(); + expect(await getPrismaClient().mfaChallenge.count()).toBe(0); + }); + + it("is offered the passkey, refused on a password, and hands over after the passkey", async () => { + expect((await login()).status).toBe(200); + + const refused = await handover(profileId); + expect(refused.status).toBe(401); + expect((await body(refused)).meta).toMatchObject({ + errorCode: "auth.stepup.required", + methods: ["passkey"], + }); + + // A password re-proof is accepted by the endpoint (the recent-proof gate + // still takes it on this account) but stamps no factor, so the handover + // stays shut. + const byPassword = await reproof({ + method: "password", + password: PASSWORD, + }); + expect(byPassword.status).toBe(200); + expect((await handover(profileId)).status).toBe(401); + + // What the dialog posts after the passkey ceremony. + const byPasskey = await reproof(await passkeyProof(guardianId)); + expect(byPasskey.status).toBe(200); + + const minted = await handover(profileId); + expect(minted.status).toBe(201); + expect( + await getPrismaClient().managedProfileHandover.count({ + where: { profileId }, + }), + ).toBe(1); + }); +});