import 'server-only' import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server' import type { AuthenticationResponseJSON, AuthenticatorTransportFuture, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, } from '@simplewebauthn/server' import type { Passkey, PasskeyAnfrageArt, User } from '@prisma/client' import { db } from '@/lib/db' import { env } from '@/lib/env' /** * Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg. * * --------------------------------------------------------------------------- * Warum ein Passkey hier den zweiten Faktor ERSETZEN darf * --------------------------------------------------------------------------- * * Die Zusage dieser Anwendung lautet: zwei Faktoren, nicht abschaltbar. Ein * Passkey sieht auf den ersten Blick nach einem einzigen Klick aus - er ist * aber zwei Faktoren, sofern der Authenticator die Nutzerverifikation * durchfuehrt: * * 1. Besitz - der private Schluessel liegt im Sicherheitsbaustein des * Geraets und laesst sich nicht auslesen. * 2. Wissen - Geraete-PIN * oder Sein - Fingerabdruck, Gesicht. * * Diese Bedingung wird nicht gehofft, sondern erzwungen: bei der Anmeldung * ebenso wie beim Einrichten steht userVerification auf "required", und die * Pruefung laeuft mit requireUserVerification. Ein Authenticator, der nur * Anwesenheit meldet (blosses Antippen), wird abgewiesen. * * Zusaetzlich ist ein Passkey dem Zahlenkomma aus Passwort + TOTP in einem * Punkt ueberlegen: Er ist an die Domain gebunden. Eine nachgebaute * Anmeldeseite bekommt keine Signatur - Phishing, der haeufigste Weg an * einen TOTP-Code zu kommen, laeuft ins Leere. * * --------------------------------------------------------------------------- * Warum TOTP trotzdem zuerst eingerichtet sein muss * --------------------------------------------------------------------------- * * Ein Passkey haengt am Geraet. Geht das Handy verloren und ist es der * einzige Zugangsweg, ist die Akte zu - und in ihr stehen Unterlagen, die * eine betreute Person braucht. Deshalb laesst sich ein Passkey erst * anlegen, wenn der zweite Faktor samt Wiederherstellungscodes steht. Der * Passkey ist die bequeme Tuer, nicht die einzige. */ /** * Wie lange eine begonnene Passkey-Aktion abgeschlossen werden kann. * * Fuenf Minuten sind reichlich fuer "Handy suchen, Finger auflegen" und * kurz genug, dass eine abgefangene Challenge nichts mehr wert ist. */ export const ANFRAGE_GUELTIG_SEKUNDEN = 300 /** * Obergrenze je Benutzer. Nicht aus technischen Gruenden - die Liste soll * uebersichtlich bleiben, damit ein fremder Eintrag auffaellt. */ export const MAX_PASSKEYS_JE_BENUTZER = 10 /** * Obergrenze fuer gleichzeitig offene Anmeldeversuche. * * Eine Anmeldung darf jeder beginnen, auch ohne angemeldet zu sein - anders * ginge es nicht. Damit daraus kein Weg wird, die Tabelle vollzuschreiben, * gilt eine Decke. Fuer eine Handvoll Benutzer ist sie unerreichbar; fuer * ein Skript ist sie schnell erreicht, und dann ist Schluss. */ export const MAX_OFFENE_ANMELDUNGEN = 200 export type PasskeyGrund = | 'nicht-verfuegbar' | 'anfrage-abgelaufen' | 'unbekannt' | 'nicht-verifiziert' | 'geklont' | 'bereits-vorhanden' | 'zu-viele' | 'zweiter-faktor-fehlt' | 'zu-viele-anfragen' | 'ungueltig' /** Traegt einen Grund, damit die Oberflaeche einen brauchbaren Satz daraus macht. */ export class PasskeyFehler extends Error { constructor( readonly grund: PasskeyGrund, nachricht?: string, ) { super(nachricht ?? grund) this.name = 'PasskeyFehler' } } export const PASSKEY_MELDUNG: Record = { 'nicht-verfuegbar': 'Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation läuft nicht über https — ' + 'melde dich mit Passwort und Code an.', 'anfrage-abgelaufen': 'Das hat zu lange gedauert. Bitte klicke noch einmal auf „Mit Passkey anmelden“.', unbekannt: 'Dieser Passkey ist hier nicht hinterlegt. Vielleicht gehört er zu einer anderen Adresse ' + 'oder wurde inzwischen entfernt.', 'nicht-verifiziert': 'Dein Gerät hat den Passkey ohne Fingerabdruck, Gesichtserkennung oder PIN freigegeben. ' + 'Das wäre nur ein Faktor — und hier liegen Gesundheitsdaten. Bitte richte die Gerätesperre ein.', geklont: 'Der Zähler dieses Passkeys ist nicht weitergelaufen. Das kann auf eine Kopie hindeuten. ' + 'Der Passkey wurde nicht akzeptiert — bitte entferne ihn und richte einen neuen ein.', 'bereits-vorhanden': 'Dieser Passkey ist bereits hinterlegt.', 'zu-viele': `Es sind höchstens ${MAX_PASSKEYS_JE_BENUTZER} Passkeys möglich. Entferne zuerst einen alten.`, 'zweiter-faktor-fehlt': 'Richte zuerst die Authenticator-App ein. Ein Passkey hängt an einem Gerät — ohne einen ' + 'zweiten Weg wäre der Zugang bei Verlust zu.', 'zu-viele-anfragen': 'Gerade laufen ungewöhnlich viele Anmeldeversuche. Bitte versuche es in ein paar Minuten ' + 'noch einmal oder melde dich mit Passwort und Code an.', ungueltig: 'Der Passkey konnte nicht geprüft werden. Bitte versuche es noch einmal.', } /** * Kopiert Bytes in ein Uint8Array mit eigenem, nicht geteiltem Puffer. * * Prisma liefert einen Buffer, TextEncoder ein Uint8Array - * die Bibliothek verlangt ausdruecklich Uint8Array. Der * Unterschied ist nicht kosmetisch: ArrayBufferLike schliesst * SharedArrayBuffer ein, und auf geteiltem Speicher darf keine * Signaturpruefung arbeiten, weil sich der Inhalt waehrenddessen aendern * koennte. */ function alsUint8(daten: Uint8Array): Uint8Array { const kopie = new Uint8Array(new ArrayBuffer(daten.byteLength)) kopie.set(daten) return kopie } /** * Holt die Challenge aus der Antwort des Browsers. * * Sie steckt in den clientData, die der Authenticator mitsigniert hat. Wir * lesen sie hier nur, um die passende Zeile zu finden - geprueft wird sie * anschliessend von der Bibliothek gegen genau diesen Wert. * * Exportiert, damit sie sich ohne Datenbank pruefen laesst. */ export function challengeAusAntwort(clientDataJSON: string): string { try { const roh = Buffer.from(clientDataJSON, 'base64url').toString('utf8') const gelesen: unknown = JSON.parse(roh) const challenge = typeof gelesen === 'object' && gelesen !== null && 'challenge' in gelesen ? (gelesen as { challenge: unknown }).challenge : null if (typeof challenge !== 'string' || challenge.length === 0) { throw new PasskeyFehler('ungueltig') } return challenge } catch (fehler) { if (fehler instanceof PasskeyFehler) throw fehler throw new PasskeyFehler('ungueltig') } } /** Legt eine Challenge ab. Nur der Server kennt sie, bis der Browser antwortet. */ async function anfrageMerken( challenge: string, art: PasskeyAnfrageArt, userId: string | null, ): Promise { await db.passkeyAnfrage.create({ data: { challenge, art, userId, ablaufAm: new Date(Date.now() + ANFRAGE_GUELTIG_SEKUNDEN * 1000), }, }) } /** * Verbraucht eine Challenge - und zwar genau einmal. * * Das DELETE ist der eigentliche Schutz gegen das erneute Einspielen einer * mitgeschnittenen Antwort: Die Datenbank entscheidet, wer die Zeile * bekommt. Wurde keine Zeile geloescht, war sie schon verbraucht oder * abgelaufen - beides fuehrt zum Abbruch. */ async function anfrageVerbrauchen( challenge: string, art: PasskeyAnfrageArt, userId?: string, ): Promise { const { count } = await db.passkeyAnfrage.deleteMany({ where: { challenge, art, ablaufAm: { gt: new Date() }, ...(userId ? { userId } : {}), }, }) if (count !== 1) throw new PasskeyFehler('anfrage-abgelaufen') } /** Wirft, wenn die Installation gar nicht ueber https erreichbar ist. */ function verfuegbarkeitPruefen(): void { if (!env.passkey.sicherAufrufbar) throw new PasskeyFehler('nicht-verfuegbar') } /** Ob Passkeys unter der aktuellen Adresse ueberhaupt angeboten werden koennen. */ export function passkeysMoeglich(): boolean { return env.passkey.sicherAufrufbar } // --------------------------------------------------------------------------- // Einrichten // --------------------------------------------------------------------------- export async function registrierungVorbereiten( benutzer: User, ): Promise { verfuegbarkeitPruefen() // Ohne Authenticator-App kein Passkey - siehe Kopfkommentar. if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt') const vorhandene = await db.passkey.findMany({ where: { userId: benutzer.id }, select: { credentialId: true, transports: true }, }) if (vorhandene.length >= MAX_PASSKEYS_JE_BENUTZER) throw new PasskeyFehler('zu-viele') const optionen = await generateRegistrationOptions({ rpName: env.passkey.rpName, rpID: env.passkey.rpId, // Stabil aus der Benutzer-ID. Damit erkennt ein Geraet beim zweiten // Einrichten, dass es derselbe Zugang ist, und ersetzt den Eintrag, // statt einen zweiten anzulegen. userID: alsUint8(new TextEncoder().encode(benutzer.id)), userName: benutzer.email, userDisplayName: benutzer.name, // Keine Attestierung anfordern: Wir wollen nicht wissen, welches // Fabrikat der Benutzer benutzt. Das ist ein Datum, das uns nichts // angeht, und es macht den Ablauf auf manchen Geraeten umstaendlicher. attestationType: 'none', // Schon hinterlegte Schluessel ausschliessen, sonst legt dasselbe // Geraet stillschweigend einen zweiten an. excludeCredentials: vorhandene.map((eintrag) => ({ id: eintrag.credentialId, transports: eintrag.transports as AuthenticatorTransportFuture[], })), authenticatorSelection: { // "required": der Schluessel wird auf dem Geraet auffindbar // gespeichert. Nur dann geht die Anmeldung ohne vorherige Eingabe // der E-Mail-Adresse. residentKey: 'required', requireResidentKey: true, // Der Punkt, an dem aus einem Faktor zwei werden. userVerification: 'required', }, timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000, }) await anfrageMerken(optionen.challenge, 'REGISTRIERUNG', benutzer.id) return optionen } export async function registrierungPruefen( benutzer: User, name: string, antwort: RegistrationResponseJSON, ): Promise { verfuegbarkeitPruefen() if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt') const challenge = challengeAusAntwort(antwort.response.clientDataJSON) await anfrageVerbrauchen(challenge, 'REGISTRIERUNG', benutzer.id) let ergebnis try { ergebnis = await verifyRegistrationResponse({ response: antwort, expectedChallenge: challenge, expectedOrigin: env.passkey.herkunft, expectedRPID: env.passkey.rpId, requireUserVerification: true, }) } catch { throw new PasskeyFehler('ungueltig') } if (!ergebnis.verified) throw new PasskeyFehler('ungueltig') const { credential, credentialDeviceType, credentialBackedUp, userVerified } = ergebnis.registrationInfo // Doppelte Absicherung: die Bibliothek prueft es bereits, aber diese // Zusage ist zu wichtig, um sie an einer Voreinstellung haengen zu lassen. if (!userVerified) throw new PasskeyFehler('nicht-verifiziert') const schonDa = await db.passkey.findUnique({ where: { credentialId: credential.id }, select: { id: true }, }) if (schonDa) throw new PasskeyFehler('bereits-vorhanden') return db.passkey.create({ data: { userId: benutzer.id, credentialId: credential.id, publicKey: Buffer.from(credential.publicKey), counter: BigInt(credential.counter), transports: credential.transports ?? [], name, geraeteArt: credentialDeviceType, gesichert: credentialBackedUp, }, }) } // --------------------------------------------------------------------------- // Anmelden // --------------------------------------------------------------------------- export async function anmeldungVorbereiten(): Promise { verfuegbarkeitPruefen() // Erst aufraeumen, dann die Decke pruefen: Abgelaufenes soll nicht als // laufender Versuch zaehlen. await abgelaufeneAnfragenEntfernen() const offen = await db.passkeyAnfrage.count({ where: { art: 'ANMELDUNG' } }) if (offen >= MAX_OFFENE_ANMELDUNGEN) throw new PasskeyFehler('zu-viele-anfragen') const optionen = await generateAuthenticationOptions({ rpID: env.passkey.rpId, // Bewusst ohne allowCredentials: Der Server soll vor der Anmeldung // nicht verraten, welche Schluessel es zu einer Adresse gibt - und der // Benutzer soll seine E-Mail gar nicht erst eintippen muessen. Das // Geraet weiss selbst, welchen Schluessel es fuer diese Domain hat. userVerification: 'required', timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000, }) await anfrageMerken(optionen.challenge, 'ANMELDUNG', null) return optionen } export type PasskeyAnmeldung = { benutzer: User passkey: Passkey } /** * Prueft eine Anmeldeantwort vollstaendig und meldet, wem sie gehoert. * * Alles, was hier durchgeht, ist kryptografisch belegt: die Signatur passt * zum hinterlegten oeffentlichen Schluessel, die Challenge war frisch und * genau einmal gueltig, die Domain stimmt, und das Geraet hat den Benutzer * verifiziert. * * Rate-Limiting und Audit-Log passieren NICHT hier, sondern beim Aufrufer - * dort ist bekannt, ob es eine Anmeldung oder eine erneute Bestaetigung ist. */ export async function anmeldungPruefen( antwort: AuthenticationResponseJSON, ): Promise { verfuegbarkeitPruefen() const challenge = challengeAusAntwort(antwort.response.clientDataJSON) await anfrageVerbrauchen(challenge, 'ANMELDUNG') const passkey = await db.passkey.findUnique({ where: { credentialId: antwort.id }, include: { user: true }, }) if (!passkey) throw new PasskeyFehler('unbekannt') let ergebnis try { ergebnis = await verifyAuthenticationResponse({ response: antwort, expectedChallenge: challenge, expectedOrigin: env.passkey.herkunft, expectedRPID: env.passkey.rpId, requireUserVerification: true, credential: { id: passkey.credentialId, publicKey: alsUint8(passkey.publicKey), counter: Number(passkey.counter), transports: passkey.transports as AuthenticatorTransportFuture[], }, }) } catch (fehler) { // Die Bibliothek meldet einen nicht weitergelaufenen Zaehler als // gewoehnlichen Fehler. Der verdient eine eigene Meldung: er ist der // einzige Hinweis auf einen kopierten Schluessel, den wir bekommen. const text = fehler instanceof Error ? fehler.message : '' if (text.includes('counter value')) throw new PasskeyFehler('geklont') throw new PasskeyFehler('ungueltig') } if (!ergebnis.verified) throw new PasskeyFehler('ungueltig') if (!ergebnis.authenticationInfo.userVerified) throw new PasskeyFehler('nicht-verifiziert') await db.passkey.update({ where: { id: passkey.id }, data: { counter: BigInt(ergebnis.authenticationInfo.newCounter), zuletztBenutztAm: new Date(), // Beides kann sich nachtraeglich aendern, etwa wenn der Benutzer die // Schluesselbund-Synchronisierung einschaltet. geraeteArt: ergebnis.authenticationInfo.credentialDeviceType, gesichert: ergebnis.authenticationInfo.credentialBackedUp, }, }) return { benutzer: passkey.user, passkey } } // --------------------------------------------------------------------------- // Verwalten und Aufraeumen // --------------------------------------------------------------------------- export type PasskeyUebersicht = { id: string name: string geraeteArt: string | null gesichert: boolean erstelltAm: Date zuletztBenutztAm: Date | null } export async function passkeysDesBenutzers(userId: string): Promise { return db.passkey.findMany({ where: { userId }, orderBy: [{ zuletztBenutztAm: { sort: 'desc', nulls: 'last' } }, { erstelltAm: 'desc' }], select: { id: true, name: true, geraeteArt: true, gesichert: true, erstelltAm: true, zuletztBenutztAm: true, }, }) } /** Raeumt liegengebliebene Challenges weg; wird vom Wartungslauf aufgerufen. */ export async function abgelaufeneAnfragenEntfernen(): Promise { const { count } = await db.passkeyAnfrage.deleteMany({ where: { ablaufAm: { lt: new Date() } }, }) return count }