#!/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() }