diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 5ad204e..82e61dc 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -347,6 +347,19 @@ abends musst du nichts nacharbeiten. ## Wenn etwas schiefgeht +**Ich will den Zugang auf ein zweites Gerät legen.** +Das Geheimnis wird nur einmal angezeigt, beim Einrichten. Wer es später +noch einmal braucht — Handy und Tablet, oder ein Ersatzgerät im Schrank — +liest es auf dem Server aus: + +```bash +docker compose exec app node scripts/totp-geheimnis.mjs deine@adresse.de +``` + +Das ist keine Hintertür: Es läuft nur mit Zugriff auf die Datenbank **und** +auf `ENC_KEY_V1`. Wer beides hat, kommt ohnehin an alles. Die Ausgabe +gehört trotzdem in kein Protokoll und in keine E-Mail. + **Ich habe mein Handy mit der Authenticator-App verloren.** Melde dich mit einem deiner Wiederherstellungscodes an — statt der sechs Ziffern gibst du den Code im Format `XXXX-XXXX-XXXX` ein. Jeder diff --git a/scripts/totp-geheimnis.mjs b/scripts/totp-geheimnis.mjs new file mode 100644 index 0000000..d90a429 --- /dev/null +++ b/scripts/totp-geheimnis.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * Das TOTP-Geheimnis eines Zugangs auslesen. + * + * node --env-file=.env scripts/totp-geheimnis.mjs + * + * Wofuer das gut ist: Die Anwendung zeigt das Geheimnis genau einmal an, + * beim Einrichten. Wer den Zugang auf ein zweites Geraet legen will - + * Handy und Tablet, oder ein Ersatzgeraet im Schrank - braucht es noch + * einmal. Ohne dieses Skript bliebe nur, den zweiten Faktor zurueckzusetzen + * und alle Wiederherstellungscodes neu zu erzeugen. + * + * Was es NICHT ist: eine Hintertuer. Es laeuft nur auf dem Server, nur mit + * Zugriff auf die Datenbank UND auf ENC_KEY_V1 aus der Umgebung. Wer beides + * hat, kann ohnehin alles lesen. Genau deshalb liegt der Schluessel nicht + * in der Datenbank. + * + * Im Docker-Betrieb: + * + * docker compose exec app node scripts/totp-geheimnis.mjs fw@example.de + * + * Die Ausgabe gehoert nicht in ein Protokoll und nicht in eine E-Mail. + */ +import { PrismaClient } from '@prisma/client' +import { createDecipheriv } from 'node:crypto' + +const email = (process.argv[2] ?? '').trim().toLowerCase() + +if (!email) { + console.error('Aufruf: node --env-file=.env scripts/totp-geheimnis.mjs ') + process.exit(1) +} + +/** + * Entschluesselt einen Umschlag aus src/lib/crypto/feld.ts. + * Format: bc1...., alles base64url. + */ +function entschluesseln(umschlag, kontext) { + const teile = umschlag.split('.') + if (teile.length !== 5 || teile[0] !== 'bc1') { + throw new Error('Der gespeicherte Wert hat nicht das erwartete Format.') + } + + const version = Number.parseInt(teile[1], 10) + const roh = process.env[`ENC_KEY_V${version}`] + if (!roh) { + throw new Error( + `ENC_KEY_V${version} ist nicht gesetzt. Ohne diesen Schluessel laesst ` + + `sich der Wert nicht lesen - auch nicht aus einem Backup.`, + ) + } + + const decipher = createDecipheriv( + 'aes-256-gcm', + Buffer.from(roh, 'base64'), + Buffer.from(teile[2], 'base64url'), + { authTagLength: 16 }, + ) + // Der Feldname wird mitauthentifiziert - er muss exakt stimmen. + decipher.setAAD(Buffer.from(`bc1:${version}:${kontext}`, 'utf8')) + decipher.setAuthTag(Buffer.from(teile[3], 'base64url')) + + return Buffer.concat([ + decipher.update(Buffer.from(teile[4], 'base64url')), + decipher.final(), + ]).toString('utf8') +} + +/** Base32 in Vierergruppen - so tippt es sich von Hand deutlich sicherer. */ +function gruppiert(text) { + return text.replace(/(.{4})/g, '$1 ').trim() +} + +const db = new PrismaClient() + +try { + const benutzer = await db.user.findUnique({ + where: { email }, + select: { + email: true, + name: true, + totpSecretVerschluesselt: true, + totpAktiviertAm: true, + wiederherstellungscodes: true, + }, + }) + + if (!benutzer) { + console.error(`Kein Zugang mit der Adresse ${email}.`) + process.exit(1) + } + + if (!benutzer.totpSecretVerschluesselt) { + console.error( + `Fuer ${email} ist noch kein zweiter Faktor eingerichtet. ` + + `Das passiert beim ersten Anmelden.`, + ) + process.exit(1) + } + + const secret = entschluesseln(benutzer.totpSecretVerschluesselt, 'totpSecretVerschluesselt') + const uri = + `otpauth://totp/Betreuungs-Cockpit:${encodeURIComponent(benutzer.email)}` + + `?secret=${secret}&issuer=Betreuungs-Cockpit&algorithm=SHA1&digits=6&period=30` + + console.log(` +Zugang: ${benutzer.name} <${benutzer.email}> +Aktiv seit: ${benutzer.totpAktiviertAm?.toLocaleString('de-DE') ?? 'noch nicht bestaetigt'} +Codes uebrig: ${benutzer.wiederherstellungscodes.length} + +Geheimnis zum Abtippen: + ${gruppiert(secret)} + +Zum Einscannen (in einen QR-Code-Erzeuger auf DIESEM Rechner geben, +niemals in einen Dienst im Netz): + ${uri} + +Diese Ausgabe gehoert nicht in ein Protokoll und nicht in eine E-Mail. +`) +} finally { + await db.$disconnect() +}