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
This commit is contained in:
@@ -347,6 +347,19 @@ abends musst du nichts nacharbeiten.
|
|||||||
|
|
||||||
## Wenn etwas schiefgeht
|
## 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.**
|
**Ich habe mein Handy mit der Authenticator-App verloren.**
|
||||||
Melde dich mit einem deiner Wiederherstellungscodes an — statt der sechs
|
Melde dich mit einem deiner Wiederherstellungscodes an — statt der sechs
|
||||||
Ziffern gibst du den Code im Format `XXXX-XXXX-XXXX` ein. Jeder
|
Ziffern gibst du den Code im Format `XXXX-XXXX-XXXX` ein. Jeder
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user