Files
betreuungs-cockpit/scripts/totp-geheimnis.mjs
T
Florian Wilde 9df6419a68 feat: TOTP-Geheimnis auslesen, um ein zweites Geraet einzurichten
Die Anwendung zeigt das Geheimnis genau einmal an, beim Einrichten - das
ist richtig so. Wer den Zugang aber auf ein zweites Geraet legen will,
Handy und Tablet oder ein Ersatzgeraet im Schrank, braucht es noch
einmal. Bisher blieb dafuer nur, den zweiten Faktor zurueckzusetzen und
alle Wiederherstellungscodes neu zu erzeugen.

Das Skript laeuft nur auf dem Server und nur mit Zugriff auf die
Datenbank UND auf ENC_KEY_V1. Wer beides hat, kann ohnehin alles lesen -
genau deshalb liegt der Schluessel nicht in der Datenbank. Es ist also
keine Hintertuer, sondern erspart eine unnoetige Neueinrichtung.

Es gibt den otpauth-URI mit aus, aber mit dem ausdruecklichen Hinweis,
ihn in einen QR-Erzeuger auf demselben Rechner zu geben und nicht in
einen Dienst im Netz - sonst haette man das Geheimnis gerade dorthin
gegeben, wovor der zweite Faktor schuetzen soll.

Geprueft gegen den Entwicklungszugang: der ausgelesene Wert stimmt mit
SEED_DEV_TOTP_SECRET ueberein.

Etappe: 8
Phase: Haertung
2026-08-22 11:44:29 +02:00

123 lines
3.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Das TOTP-Geheimnis eines Zugangs auslesen.
*
* node --env-file=.env scripts/totp-geheimnis.mjs <e-mail>
*
* 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 <e-mail>')
process.exit(1)
}
/**
* Entschluesselt einen Umschlag aus src/lib/crypto/feld.ts.
* Format: bc1.<version>.<iv>.<tag>.<geheimtext>, 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()
}