20 Commits
Author SHA1 Message Date
florian.wilde 573bd1a426 Merge branch 'phase/036-devlog' 2026-10-12 22:52:53 +02:00
florian.wildeandClaude Opus 5.5 7e4968f2ea docs(devlog): Sitzung 8 mit #035 und #036 (#036)
Zeiten und Entscheidungen der Sitzung festhalten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:52:52 +02:00
florian.wilde 29abbf8095 Merge branch 'phase/036-deploymentkey-abbrechen' 2026-10-12 22:52:36 +02:00
florian.wildeandClaude Opus 5.5 5d03681f3c feat(deploymentkey): Rotation vor dem Umstellen abbrechen (#036)
Eine begonnene Rotation ließ sich nicht zurücknehmen; nahm ein Host den
neuen Key dauerhaft nicht an, blieb nur das Übergehen. Abbrechen
verwirft jetzt den neuen Key samt Private Key, ein Dry-Run nimmt ihn
wieder von den Hosts, und eine neue Rotation kann sofort starten.

Nebenbei: Aktionen der Benutzerverwaltung (#035) bekommen im Audit-Log
eine eigene Gruppe, statt den Filter „Anmeldung“ zu verwässern; der
E2E-Test für lokale Konten hängt nicht mehr von der Reihenfolge ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:52:32 +02:00
florian.wilde 68dbc7cadb Merge branch 'phase/035-lokale-konten' 2026-10-12 22:36:30 +02:00
florian.wildeandClaude Opus 5.5 4219701b29 feat(anmeldung): lokale Benutzerkonten mit Pflicht-TOTP (#035)
Ohne authentik gab es nur den Notfall-Admin – ein geteiltes Konto ohne
Rollen und ohne erkennbare Akteure im Audit-Log. Admins legen jetzt
lokale Konten an; sshkv erzeugt ein Einmal-Passwort, das bei der ersten
Anmeldung ersetzt wird, danach ist TOTP Pflicht.

Rollen, Sperren (beendet Sitzungen sofort) und Zurücksetzen unter
Einstellungen → Benutzer, nur für Admins mit frischer Bestätigung.
SSHKV_LOKALE_KONTEN=0 schaltet lokale Konten ab; der Notfall-Admin
bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:36:25 +02:00
florian.wilde d9eec3d991 Merge branch 'phase/034-devlog' 2026-10-12 20:11:40 +02:00
florian.wildeandClaude Opus 5.5 b3787f514c docs(devlog): Sitzung 7 mit #033 und #034 (#034)
Zeiten und Entscheidungen festhalten, bevor die Abnahme beginnt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:11:39 +02:00
florian.wilde 91c4880e40 Merge branch 'phase/034-abnahme-checkliste' 2026-10-12 20:11:16 +02:00
florian.wildeandClaude Opus 5.5 dc202594d3 docs(betrieb): Abnahme-Checkliste fuer 1.0.0 (#034)
Die automatischen Tests laufen gegen Mock-OIDC und Testcontainer.
Echter authentik, echte Hosts und der Betrieb brauchen eine eigene,
abhakbare Prüfung mit erwartetem Ergebnis je Punkt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:11:15 +02:00
florian.wilde 525eddb091 Merge branch 'phase/033-erneut-rotieren' 2026-10-12 20:09:48 +02:00
florian.wildeandClaude Opus 5.5 c06c051637 feat(rotation): nach Abbruch erneut rotieren (#033)
Bisher war vorgaengerId eindeutig: nach einer abgebrochenen Rotation
passte kein zweiter Nachfolger in die Kette, und der Schlüssel ließ sich
nur über Neuanlage, Zuweisen und Widerruf ersetzen.

Jetzt ist die Relation 1:n. Ein partieller Index erlaubt je Vorgänger
nur einen Nachfolger, der weder widerrufen ist noch gerade widerrufen
wird. Maßgeblich ist der jüngste. Erst wenn der Abbruch abgeschlossen
ist, beginnt die Rotation wieder bei Schritt 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:09:42 +02:00
florian.wilde c91e882159 Merge branch 'phase/032-abschluss-doku' 2026-10-12 18:08:05 +02:00
florian.wildeandClaude Opus 5.5 d34f8b6f54 docs(devlog): Sitzung 6, #031 und #032 nachgetragen (#032)
Zeiten und Entscheidungen der Sitzung festhalten, damit die Abnahme
von 1.0.0 den vollständigen Stand sieht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:08:03 +02:00
florian.wilde 844161dc1b Merge branch 'phase/032-nacharbeiten' 2026-10-12 17:56:33 +02:00
florian.wildeandClaude Opus 5.5 021e415fde docs(issues): #032 abgeschlossen, Entscheidungen notiert (#032)
Haelt fest, was die Nacharbeiten gefunden und entschieden haben:
den Fehler im Massenwiderruf (UUIDs fielen aus der Auswahl), das
Zustandsmodell des Rotationsabbruchs ohne neues Feld, warum es danach
keinen zweiten Nachfolger gibt, und wie die Protokoll-IDs ohne
Umnummerieren lueckenlos werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:55:46 +02:00
florian.wildeandClaude Opus 5.5 f18fd4ec06 feat(rotation): laufende Rotation abbrechen (#032)
Bisher liess sich eine angefangene Rotation nur zu Ende fuehren. Wer
merkt, dass der neue Schluessel nicht passt, braucht einen Weg zurueck,
ohne den alten anzufassen.

"Rotation abbrechen" (Rotationsseite und Seite des Nachfolgers) merkt
den neuen Schluessel ueber den bestehenden Widerruf aus #024 vor; er
kann schon ausgerollt sein, deshalb folgen Dry-Run und Anwenden wie
sonst. Der alte bleibt aktiv und unveraendert. Wo beide denselben
Alias tragen, zeigt die config danach wieder auf die alte Datei, weil
privat.ts nur die verbleibenden Zuweisungen in den Block schreibt.

Zustandsmodell: kein neues Feld. Ein Nachfolger im Widerruf (oder
widerrufen) ist eine abgebrochene Rotation; vorgaengerId bleibt fuer
die Historie, der Abbruch steht als schluessel.rotation.abgebrochen im
Protokoll (in derselben Transaktion wie das Vormerken, dafuer nimmt
widerrufEinleiten optional einen Zusatzschritt an). Abbrechen geht nur,
solange der alte Schluessel noch nicht entfernt wird. widerrufAbschliessen
macht aus dem Widerruf des alten Schluessels kein ROTIERT mehr, wenn
der Nachfolger selbst im Widerruf ist. Weil vorgaengerId eindeutig
ist, gibt es nach einem Abbruch keinen zweiten Nachfolger; Seite und
Fehlermeldung sagen das.

Tests: DB-Tests fuer Abbruch, Idempotenz, Sperren und Status;
Integrationstest gegen echte Ziele (neuer Key ausgerollt, abbrechen,
neuer weg, alter in authorized_keys, ssh zu-b von A geht); E2E fuer
den Ablauf im Browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:45:11 +02:00
florian.wildeandClaude Opus 5.5 fb56fd36c5 fix(widerruf): Massenwiderruf nimmt erzeugte Schluessel an (#032)
Der neue E2E-Test (zwei im Browser erzeugte Schluessel mit Public Key
auf Ziel B, Auswahl in der Liste, frische Bestaetigung ueber den
Mock-OIDC mit auth_time, gemeinsamer Dry-Run, Anwenden) fand zwei
Fehler im Produkt:

- Die Massenwiderruf-Seite filterte die IDs aus ?id= mit
  /^[a-z0-9]{1,64}$/. Vom Worker erzeugte Schluessel tragen aber eine
  UUID mit Bindestrichen, nur Prisma-Defaults sind cuids. Damit fiel
  jeder erzeugte Schluessel still aus der Auswahl, die Seite meldete
  "Keine Schluessel gewaehlt". Der Filter steckt jetzt als
  idsAusSuchparameter in src/lib/schluessel.ts, erlaubt Bindestriche
  und hat einen eigenen Test.
- Der Hinweis nach verweigerter Bestaetigung sagte "erneut Widerruf
  vorbereiten waehlen", der Knopf heisst beim Massenwiderruf aber
  "2 Schluessel widerrufen". Er nennt jetzt den Knopf, den es gibt.

Der Test prueft danach, dass beide Keys aus authorized_keys auf B
verschwunden und WIDERRUFEN sind. datenbank() und oidcBestaetigen()
wandern dafuer aus schluessel.spec.ts nach tests/e2e/hilfen.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:44:46 +02:00
florian.wildeandClaude Opus 5.5 be9837ba50 feat(zuweisungen): Zum Dry-Run springt zur Dry-Run-Karte (#032)
Der Link im Zell-Dialog der Zuweisungsmatrix fuehrte nur auf den Kopf
der Schluesselseite; die Dry-Run-Karte liegt weit unten, und wer aus der
Matrix kommt, will genau dorthin. Die Karte traegt jetzt id="dry-run"
(Karte nimmt dafuer eine optionale id an), der Link zeigt auf
/schluessel/<id>#dry-run. scroll-mt haelt die Karte unter der festen
Kopfleiste der schmalen Ansicht.

E2E: der Matrix-Test klickt den Link und prueft Adresse und Sichtbarkeit
der Karte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:29:49 +02:00
florian.wildeandClaude Opus 5.5 b4804390c4 fix(protokoll): IDs ohne Zweierschritte vergeben (#032)
Spalten-Default (BIGSERIAL) und Trigger protokoll_verketten zogen je
eine Nummer aus der Sequenz, jede Zeile verbrauchte zwei IDs. Die
Nummer aus dem Default darf der Trigger aber nicht einfach
uebernehmen, denn sie wird vor der Advisory-Sperre gezogen: Eine parallele
Transaktion mit groesserer ID koennte zuerst verketten, und die Kette
liesse sich in ID-Reihenfolge nicht mehr pruefen.

Deshalb zieht nur noch der Trigger unter der Sperre; der Default ist
ein Platzhalter 0 (Prisma: @default(0)), damit create ohne ID
funktioniert. Die Sequenz bleibt der Spalte zugeordnet. Bestehende
Zeilen werden nicht umnummeriert, weil die ID in die Hash-Eingabe
eingeht.

Tests: drei Eintraege nacheinander ergeben n, n+1, n+2; eine
mitgegebene ID wird ueberschrieben; 20 parallele Eintraege belegen
genau 20 aufeinanderfolgende IDs bei intakter Kette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:19:28 +02:00
80 changed files with 2603 additions and 247 deletions
+6
View File
@@ -51,6 +51,12 @@ SSHKV_OIDC_GROUP_READER=sshkv-readers
# Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen. # Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen.
# SSHKV_OIDC_HTTP_ERLAUBT=1 # SSHKV_OIDC_HTTP_ERLAUBT=1
# --- Lokale Konten (#035) -------------------------------------------------------
# Admins legen unter /einstellungen/benutzer Konten mit Passwort und Pflicht-TOTP an – neben
# oder statt authentik. 0 schaltet sie ab (vorhandene lokale Konten kommen dann nicht mehr
# hinein); der Notfall-Admin bleibt immer.
SSHKV_LOKALE_KONTEN=1
# --- Notfall-Admin ------------------------------------------------------------- # --- Notfall-Admin -------------------------------------------------------------
# Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet. # Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.
SSHKV_BREAKGLASS_USER=notfall SSHKV_BREAKGLASS_USER=notfall
+9
View File
@@ -9,10 +9,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### Hinzugefügt
- Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031) - Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031)
- Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032)
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
- Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033)
- Rotation des Deployment-Keys lässt sich vor dem Umstellen abbrechen; der neue Key wird verworfen und per Dry-Run wieder entfernt (#036)
- Lokale Konten mit Passwort und Pflicht-TOTP neben oder statt authentik: Admins legen sie unter Einstellungen → Benutzer an (Einmal-Passwort, Rollen, Sperren, Zurücksetzen); abschaltbar mit `SSHKV_LOKALE_KONTEN=0` (#035)
### Geändert ### Geändert
- Protokoll-IDs werden lückenlos vergeben (#032)
### Behoben ### Behoben
- Aktionen der Benutzerverwaltung erscheinen im Audit-Log unter einer eigenen Gruppe „Benutzer“ statt unter „Anmeldung“ (#035)
- Massenwiderruf lehnte im Browser erzeugte Schlüssel ab (IDs mit Bindestrich) (#032)
- Hinweis nach verweigerter Bestätigung nannte beim Massenwiderruf den falschen Knopf (#032)
### Sicherheit ### Sicherheit
+56 -3
View File
@@ -2,8 +2,8 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt (Wandzeit):** 4 h 25 min **Gesamt (Wandzeit):** 6 h 40 min
**Summe der Issue-Zeiten:** 13 h 18 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027 und #028. **Summe der Issue-Zeiten:** 16 h 38 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -37,7 +37,13 @@
| #027 Protokollansicht, CSV, Syslog | 0 h 20 min | | #027 Protokollansicht, CSV, Syslog | 0 h 20 min |
| #028 Benachrichtigungen | 0 h 30 min | | #028 Benachrichtigungen | 0 h 30 min |
| #029 Handbuch und Betriebsdokumentation | 0 h 50 min | | #029 Handbuch und Betriebsdokumentation | 0 h 50 min |
| #030 Gitea-Abgleich (vorbereitet) | 0 h 05 min | | #030 Gitea-Abgleich | 0 h 05 min |
| #031 Rotation des Deployment-Keys | 1 h 10 min |
| #032 Nacharbeiten Phase 4/5 | 0 h 45 min |
| #033 Erneut rotieren nach Abbruch | 0 h 20 min |
| #034 Abnahme-Checkliste | 0 h 10 min |
| #035 Lokale Benutzerkonten | 0 h 35 min |
| #036 Deployment-Key-Rotation abbrechen | 0 h 20 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -46,6 +52,7 @@
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen** | v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen** | v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen** | v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
| v1.0.0 | 2 h 15 min (Wandzeit) | in Arbeit – Abnahme steht aus
--- ---
@@ -149,3 +156,49 @@
- **Master-Key nicht in die Sicherung,** nur ein Prüfwert; Worker verweigert den Start mit falschem Master-Key. - **Master-Key nicht in die Sicherung,** nur ein Prüfwert; Worker verweigert den Start mit falschem Master-Key.
- **Benachrichtigungen lesen ihre Anlässe aus der Datenbank** statt sich in den Abgleich einzuhängen; Doppelmeldungen verhindert ein Versandprotokoll mit Fingerabdruck je Anlass. - **Benachrichtigungen lesen ihre Anlässe aus der Datenbank** statt sich in den Abgleich einzuhängen; Doppelmeldungen verhindert ein Versandprotokoll mit Fingerabdruck je Anlass.
- **Keine 1.0.0 ohne Abnahme:** 0.5.0 schließt die fünf Phasen; authentik, Gitea und ein Lauf auf echten Hosts stehen noch aus. - **Keine 1.0.0 ohne Abnahme:** 0.5.0 schließt die fünf Phasen; authentik, Gitea und ein Lauf auf echten Hosts stehen noch aus.
## 2026-10-12 · Sitzung 6 · 17:12–18:07
**Issue:** #030–#032
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
### Erledigt
- Remote-Repo in Gitea: `main` und Tags v0.1.0–v0.5.0 gepusht, Issues abgeglichen (Basic-Auth, weil die hinterlegten Zugangsdaten ein Passwort sind)
- #031 Geführte Rotation des Deployment-Keys: Service, Worker-Schritte, Seite mit Schrittleiste, Integrationstest gegen beide Testziele, E2E
- #032 Nacharbeiten (Sub-Agent): lückenlose Protokoll-IDs, Rotation abbrechen, Anker „Zum Dry-Run“, Massenwiderruf-E2E – der fand, dass der Massenwiderruf UUIDs mit Bindestrich abwies
- Stand: 876 Unit-/DB-Tests, 46 Integrationstests, 27 E2E-Abläufe grün
### Entscheidungen
- **Der Nachfolger des Deployment-Keys ist ein eigener Schlüssel,** der während der Rotation über `sollFuer()` in Block und Pflichtliste steht – so verteilt ihn der normale Dry-Run, und der Aussperrschutz deckt beide Keys.
- **Umstellen erst nach Prüfung** auf allen Hosts; ein Host lässt sich nur bewusst und protokolliert übergehen.
- **Rotation abbrechen nutzt den vorhandenen Widerruf** statt eines neuen Zustands (#032).
- **Protokoll-ID nur noch aus dem Trigger** unter der Advisory-Sperre – keine Lücken mehr durch verworfene Sequenzwerte.
## 2026-10-12 · Sitzung 7 · 19:48–20:18
**Issue:** #033, #034
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
### Erledigt
- #033 Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (1:n-Relation, partieller Index)
- #034 Abnahme-Checkliste `docs/betrieb/abnahme.md` für authentik, echte Hosts und Betrieb
- Stand: 877 Unit-/DB-Tests, 46 Integrationstests grün; E2E Rotation, Widerruf, Schlüssel grün
### Entscheidungen
- **Je Vorgänger höchstens ein offener Nachfolger** statt genau einem: abgebrochene bleiben in der Historie, maßgeblich ist der jüngste.
- **Neustart erst nach abgeschlossenem Abbruch** – sonst stünden zwei neue Schlüssel gleichzeitig auf den Hosts.
- **Bestätigung „Rotation abgebrochen“ bleibt sichtbar** über dem Neustart, statt kommentarlos auf Schritt 1 zu springen (fand der E2E-Test).
## 2026-10-12 · Sitzung 8 · 22:05–22:55
**Issue:** #035, #036
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
### Erledigt
- #035 Lokale Benutzerkonten mit Pflicht-TOTP: Einmal-Passwort, eigenes Passwort bei der ersten Anmeldung, Benutzerverwaltung unter Einstellungen, Schalter `SSHKV_LOKALE_KONTEN`
- #036 Rotation des Deployment-Keys vor dem Umstellen abbrechen
- Stand: 911 Unit-/DB-Tests, 47 Integrationstests, 29 E2E-Abläufe grün
### Entscheidungen
- **TOTP auch für lokale Konten Pflicht; lokale Konten dauerhaft, aber abschaltbar** (Florian).
- **Admins wählen keine Passwörter für andere** – sshkv erzeugt ein Einmal-Passwort, das genau einmal angezeigt wird.
- **`SSHKV_LOKALE_KONTEN` muss in `docker-compose.yml` stehen:** Compose reicht nur ausdrücklich genannte Variablen an den Web-Container weiter – sonst hätte der Schalter im Betrieb nicht gewirkt.
- **Testreste machen Produktlücken sichtbar:** Eine hängengebliebene Rotation auf verwaisten Testhosts zeigte, dass es keinen Weg zurück gab (#036). Die E2E-Tests räumen solche Reste jetzt selbst ab.
+1
View File
@@ -27,6 +27,7 @@ Notfallplan) und [`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md).
- [Installation und Betrieb](docs/betrieb/installation.md) – Docker Compose, authentik, Reverse Proxy - [Installation und Betrieb](docs/betrieb/installation.md) – Docker Compose, authentik, Reverse Proxy
- [Sicherung und Wiederherstellung](docs/betrieb/sicherung.md) – `deploy/sichern.sh`, `deploy/wiederherstellen.sh` - [Sicherung und Wiederherstellung](docs/betrieb/sicherung.md) – `deploy/sichern.sh`, `deploy/wiederherstellen.sh`
- [Sicherheit](docs/betrieb/sicherheit.md) – Aufbau, Bedrohungsanalyse, Notfallplan - [Sicherheit](docs/betrieb/sicherheit.md) – Aufbau, Bedrohungsanalyse, Notfallplan
- [Abnahme vor 1.0.0](docs/betrieb/abnahme.md) – Prüfliste für authentik, echte Hosts und Betrieb
## Installation (Docker Compose) ## Installation (Docker Compose)
+2
View File
@@ -64,6 +64,8 @@ services:
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins} SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators} SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers} SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
# Lokale Konten mit Passwort und TOTP (#035); 0 schaltet sie ab, der Notfall-Admin bleibt.
SSHKV_LOKALE_KONTEN: ${SSHKV_LOKALE_KONTEN:-1}
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus. # Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-} SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514} SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
+19 -4
View File
@@ -40,9 +40,13 @@ in die Verwaltung.
- **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den - **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den
authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt. authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt.
- **Notfallzugang** – nur, wenn authentik ausfällt. Anmeldename, Passwort und ein Code aus einer - **Lokales Konto** – ohne authentik oder zusätzlich dazu. Ein Admin legt das Konto an und gibt
Authenticator-App. Bei der ersten Anmeldung richtet der Notfall-Admin den zweiten Faktor ein Ihnen ein **Einmal-Passwort**. Bei der ersten Anmeldung (Abschnitt „Lokales Konto oder
(QR-Code scannen, Code bestätigen). Notfallzugang“) setzen Sie ein eigenes Passwort (mindestens 12 Zeichen) und richten den zweiten
Faktor ein: QR-Code mit einer Authenticator-App scannen, Code bestätigen. Danach immer
Anmeldename, Passwort und Code.
- **Notfallzugang** – nur, wenn authentik ausfällt. Gleiches Formular; der Notfall-Admin richtet
bei der ersten Anmeldung ebenfalls den zweiten Faktor ein.
Kritische Aktionen (Private Key anzeigen, Benutzer mit sudo anlegen, Massenwiderruf, Host-Key Kritische Aktionen (Private Key anzeigen, Benutzer mit sudo anlegen, Massenwiderruf, Host-Key
ersetzen) verlangen eine **erneute Bestätigung**; sie gilt fünf Minuten. ersetzen) verlangen eine **erneute Bestätigung**; sie gilt fünf Minuten.
@@ -174,7 +178,8 @@ auswählen (nur Admin).
**erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung **erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung
mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert
die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach
einem neuen Bootstrap. einem neuen Bootstrap. Solange noch nicht umgestellt ist, lässt sich die
Rotation **abbrechen**: Der neue Key wird verworfen, ein Dry-Run nimmt ihn wieder von den Hosts.
![Deployment-Key rotieren](bilder/deployment-key-desktop.png) ![Deployment-Key rotieren](bilder/deployment-key-desktop.png)
@@ -225,6 +230,16 @@ weder ändern noch löschen; jeder Eintrag ist per Hash mit seinem Vorgänger ve
## 12. Einstellungen und Benachrichtigungen ## 12. Einstellungen und Benachrichtigungen
**Benutzer** (Einstellungen → Instanz → „Konten verwalten“, nur Admin): lokale Konten anlegen –
sshkv erzeugt das Einmal-Passwort und zeigt es **genau einmal** –, Rollen ändern, Konten sperren
(beendet laufende Sitzungen sofort) und zurücksetzen (neues Einmal-Passwort, TOTP neu einrichten,
etwa nach verlorenem Telefon). Konten aus authentik erscheinen nach ihrer ersten Anmeldung; ihre
Rolle kommt aus den Gruppen, sie lassen sich hier nur sperren. Der Notfall-Admin ist
unveränderlich.
![Benutzer](bilder/benutzer-desktop.png)
Unter **Einstellungen** (ändern nur Admins) richten Sie Benachrichtigungen ein: Unter **Einstellungen** (ändern nur Admins) richten Sie Benachrichtigungen ein:
- **E-Mail** über SMTP – für Microsoft 365 vorbelegt (`smtp.office365.com:587`, STARTTLS). - **E-Mail** über SMTP – für Microsoft 365 vorbelegt (`smtp.office365.com:587`, STARTTLS).
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+143
View File
@@ -0,0 +1,143 @@
# Abnahme vor Version 1.0.0
Was die automatischen Tests **nicht** abdecken können: der echte Anbieter (authentik), echte Hosts
und der Betrieb auf dem Zielserver. Diese Liste geht genau das durch. Jeder Punkt nennt das
erwartete Ergebnis; was abweicht, kommt als Issue in `docs/issues/` (oder direkt als Notiz hier
darunter).
Voraussetzung: Installation nach [installation.md](installation.md) ist durch, `docker compose ps`
zeigt `db`, `web` und `worker` als *healthy*.
**Abnahme durch:** ____________________ **Datum:** __________ **Version/Commit:** __________
---
## A · Installation und Grundschutz
- [ ] `https://<sshkv>/api/gesundheit` antwortet mit `{"status":"ok","version":…}`; über `http://` gibt es keine Anmeldung
(Secure-Cookie).
- [ ] Von außerhalb des internen Netzes ist die Oberfläche **nicht** erreichbar (Reverse Proxy /
Firewall liefert 403 oder Timeout).
- [ ] Der Web-Container sieht den Master-Key nicht:
`docker compose exec web ls /run/secrets` → leer bzw. Fehler.
- [ ] Worker-Log beim Start: Master-Key passt, Deployment-Key vorhanden
(`docker compose logs worker | head`).
- [ ] Antwort-Header prüfen (`curl -sI https://<sshkv>/anmelden`): `Content-Security-Policy`,
`Strict-Transport-Security`, `X-Frame-Options`/`frame-ancestors`, `Referrer-Policy`.
## B · Anmeldung mit authentik
- [ ] Anmelden mit einem Konto in `sshkv-admins` → Rolle **Admin** (unten links in der Navigation).
- [ ] Konto in `sshkv-operators` → **Operator**; Konto in `sshkv-readers` → **Leser**.
- [ ] Konto in **keiner** dieser Gruppen → kein Zugang, verständliche Meldung, Eintrag
`anmeldung.verweigert` im Audit-Log.
- [ ] Gruppe in authentik entziehen → bei der nächsten Anfrage gilt die neue Rolle bzw. kein
Zugang (die Rolle kommt aus der Datenbank, nicht aus dem alten Token – spätestens nach
erneuter Anmeldung).
- [ ] **Erneute Bestätigung:** als Admin „Private Key anzeigen“ wählen → authentik fragt das
Passwort (und ggf. MFA) **wirklich neu** ab; danach geht die Aktion fünf Minuten lang ohne
erneute Frage. *Kritischer Punkt:* Kommt authentik ohne neue Eingabe zurück, meldet sshkv
„auth_time fehlt oder ist zu alt“ – dann die Flow-Einstellung in authentik prüfen.
- [ ] Abmelden beendet die Sitzung (Zurück-Knopf zeigt keine Daten mehr).
## B2 · Lokale Konten (wenn ohne oder neben authentik)
- [ ] Einstellungen → „Konten verwalten“ → Konto anlegen (frische Bestätigung nötig) → das
Einmal-Passwort erscheint genau einmal; nach Neuladen ist es weg.
- [ ] Erste Anmeldung im privaten Browserfenster: eigenes Passwort setzen (unter 12 Zeichen wird
abgelehnt), TOTP einrichten, Rolle stimmt (unten links „über lokales Konto“).
- [ ] Sperren → die Sitzung im anderen Fenster endet beim nächsten Klick; Entsperren → wieder
anmeldbar.
- [ ] Zurücksetzen → neues Einmal-Passwort, TOTP wird neu eingerichtet.
- [ ] Audit-Log enthält Anlegen, Passwort gesetzt, Sperren – **kein** Passwort im Klartext.
- [ ] Mit `SSHKV_LOKALE_KONTEN=0` und `docker compose up -d`: lokales Konto kommt nicht mehr
hinein, der Notfall-Admin schon.
## C · Notfall-Admin
- [ ] `/anmelden` → „Notfallzugang“: Benutzer `notfall` + Passwort aus `.env` → TOTP-Einrichtung
erzwungen (QR-Code mit Authenticator-App scannen).
- [ ] Erneute Anmeldung verlangt Passwort **und** TOTP-Code; falscher Code wird abgelehnt.
- [ ] Nach fünf Fehlversuchen in 15 Minuten → „Zu viele Fehlversuche“, auch mit richtigem Passwort.
- [ ] `SSHKV_BREAKGLASS_PASSWORD` danach aus der `.env` entfernen (der Hash steht in der
Datenbank) und das Passwort im Passwortmanager ablegen.
## D · Echte Hosts aufnehmen
Mindestens zwei Hosts, möglichst unterschiedlich (z. B. Debian 12 und Ubuntu 24.04), einer mit
Anmeldeart *Dienstbenutzer*, einer mit *root*.
- [ ] Host anlegen → „Host-Key abrufen“ → Fingerprint mit dem Host vergleichen
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` **auf dem Host**) → vertrauen.
- [ ] Bootstrap per Einmal-Passwort: danach existiert `sshkv` mit `/etc/sudoers.d/sshkv`, das
Passwort steht **nirgends** (Audit-Log, `docker compose logs`, Datenbank:
`grep -r` im Dump findet es nicht).
- [ ] Bootstrap per Kopier-Befehl auf dem zweiten Host: Befehl in die Root-Shell einfügen, danach
„Einrichtung erneut prüfen“ → eingerichtet.
- [ ] „Verbindung prüfen“ → erreichbar; sshd-Befund wird angezeigt (z. B. PasswordAuthentication).
- [ ] Host-Key auf einem Host absichtlich tauschen (`ssh-keygen -A` nach Löschen der Keys,
sshd neu starten) → nächster Abgleich meldet **Host-Key geändert**, sshkv verbindet sich
nicht mehr, bis ein Admin den neuen Key bestätigt. Danach zurückdrehen.
## E · Schlüssel ausrollen (Kern-Anwendungsfall)
- [ ] Vorhandene `authorized_keys` eines Hosts mit eigenen Zeilen (Kommentare, Optionen, leere
Zeilen) vorher sichern: `cp ~/.ssh/authorized_keys /tmp/vorher`.
- [ ] Public Key eines Admins auf beide Hosts ausrollen → Dry-Run zeigt **nur** den verwalteten
Block; nach „Anwenden“ ist alles außerhalb des Blocks **byte-gleich**
(`diff` gegen `/tmp/vorher` zeigt nur den Block).
- [ ] Backup-Datei `authorized_keys.sshkv.bak.*` liegt daneben; Rechte 0600, Verzeichnis 0700.
- [ ] Host-zu-Host-Assistent: Vorlage „Deployment-Zugang“ von Host A nach Host B → Verbindungstest
grün; auf A: `ssh <alias> id -un` funktioniert ohne Rückfrage (known_hosts gesetzt).
- [ ] Datei `~/.ssh/config` auf A: eigener Inhalt außerhalb des sshkv-Blocks unverändert.
- [ ] Zielkonto anlegen (z. B. `deploy` mit sudo) → Benutzer und sudoers-Datei entstehen,
`visudo -c` auf dem Host ist sauber.
## F · Drift, Rotation, Widerruf
- [ ] Im verwalteten Block auf einem Host von Hand eine Zeile ändern → Abgleich meldet
Abweichung; Übersicht zeigt den Host; Dry-Run stellt den Soll-Stand wieder her.
- [ ] Fremden Key außerhalb des Blocks eintragen → Abgleich meldet **unbekannten Schlüssel**;
„übernehmen“ funktioniert.
- [ ] Geführte Rotation des Assistenten-Schlüssels: Alias auf A funktioniert vorher, während und
nachher; alte Datei ist weg.
- [ ] Rotation einmal **abbrechen** und danach erneut rotieren (#032, #033).
- [ ] Widerruf eines Schlüssels mit einem nicht erreichbaren Host (Host kurz stoppen) →
„Widerruf unvollständig“; nach dem Wiederanlauf abschließbar.
- [ ] **Deployment-Key rotieren** (Einstellungen → Instanz) über alle echten Hosts – danach
klappen Abgleich und Ausrollen weiter; der alte Fingerprint steht auf keinem Host mehr.
- [ ] Aussperrschutz: Den Deployment-Key auf einem Host per Hand aus dem Block löschen und
ausrollen → sshkv schreibt ihn zurück bzw. verweigert eine Änderung, die ihn entfernen würde.
## G · Audit-Log und Benachrichtigungen
- [ ] Audit-Log zeigt alle Aktionen aus D–F mit Akteur; „Kette prüfen“ → intakt.
- [ ] CSV-Export öffnet sich sauber in LibreOffice/Excel (Umlaute, Trennzeichen).
- [ ] Syslog-Kopie kommt beim Zielserver an (falls `SSHKV_SYSLOG_*` gesetzt).
- [ ] Testversand E-Mail (Microsoft 365) und Webhook aus den Einstellungen kommt an.
- [ ] Am nächsten Morgen: tägliche Zusammenfassung ist da und nennt die offenen Befunde.
## H · Sicherung und Wiederherstellung
- [ ] `sudo bash deploy/sichern.sh` läuft durch; Sicherung landet am vorgesehenen Ort.
- [ ] Master-Key **getrennt** gesichert (Passwortmanager/Tresor), Wiederauffinden geprobt.
- [ ] Wiederherstellung auf einer **Wegwerf-Instanz** nach [sicherung.md](sicherung.md): danach
Anmeldung, Private Key anzeigen und ein Abgleich funktionieren.
- [ ] Mit falschem Master-Key verweigert der Worker den Start.
## I · Oberfläche
- [ ] Smartphone (schmale Ansicht): Navigation, Übersicht, Hostliste und Dry-Run bedienbar.
- [ ] Hell/dunkel: Die App ist bewusst dunkel (Tresor-Design); Kontrast in heller Umgebung
ausreichend lesbar.
- [ ] Handbuch (`docs/anleitung/README.md`) passt zu dem, was die Oberfläche zeigt.
---
## Ergebnis
- [ ] Alle Punkte erledigt oder als Issue erfasst und als *nicht blockierend* eingestuft.
- [ ] **Freigabe für 1.0.0** – danach: Version in `package.json`, CHANGELOG-Abschnitt `[1.0.0]`,
Tag `v1.0.0`, Push.
### Notizen
+14
View File
@@ -71,6 +71,20 @@ Erneute Bestätigung vor kritischen Aktionen schickt `prompt=login` und `max_age
| Operator | zusätzlich Hosts verwalten, Host-Keys bestätigen, Schlüssel erzeugen, Public Keys ausrollen, abgleichen | | Operator | zusätzlich Hosts verwalten, Host-Keys bestätigen, Schlüssel erzeugen, Public Keys ausrollen, abgleichen |
| Admin | zusätzlich Private Keys ausrollen/anzeigen/löschen, Zielkonten mit sudo, Massenwiderruf, Einstellungen, Vorlagen | | Admin | zusätzlich Private Keys ausrollen/anzeigen/löschen, Zielkonten mit sudo, Massenwiderruf, Einstellungen, Vorlagen |
## Ohne authentik starten: lokale Konten
authentik ist nicht Pflicht. Ohne `SSHKV_OIDC_*` zeigt die Anmeldeseite nur das Formular für
lokale Konten und den Notfallzugang:
1. Als **Notfall-Admin** anmelden (TOTP einrichten).
2. Einstellungen → „Konten verwalten“ → für jede Person ein Konto mit passender Rolle anlegen und
das Einmal-Passwort auf sicherem Weg weitergeben.
3. Für den Alltag die eigenen Konten nutzen, den Notfall-Admin nur im Notfall – so bleibt im
Audit-Log erkennbar, wer was getan hat.
Später lässt sich authentik dazuschalten; lokale Konten und OIDC laufen nebeneinander.
`SSHKV_LOKALE_KONTEN=0` schaltet die lokalen Konten danach ab.
## Reverse Proxy (Beispiel Caddy) ## Reverse Proxy (Beispiel Caddy)
```caddyfile ```caddyfile
+5 -1
View File
@@ -37,7 +37,11 @@ Private Key steht. Bootstrap-Passwörter werden nie gespeichert.
- **Rollen:** Private Keys anzeigen, ausrollen und löschen, Deployment-Key, Zielkonten mit sudo und - **Rollen:** Private Keys anzeigen, ausrollen und löschen, Deployment-Key, Zielkonten mit sudo und
Massenwiderruf nur für Admins; kritische Aktionen zusätzlich mit erneuter Anmeldung (5 Minuten). Massenwiderruf nur für Admins; kritische Aktionen zusätzlich mit erneuter Anmeldung (5 Minuten).
Ohne passende authentik-Gruppe gibt es keinen Zugang, auch keinen lesenden. Ohne passende authentik-Gruppe gibt es keinen Zugang, auch keinen lesenden.
- **Notfall-Admin** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten. - **Notfall-Admin und lokale Konten** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten
(5 Fehlversuche je Anmeldename in 15 Minuten). Lokale Konten bekommen ein vom System erzeugtes
Einmal-Passwort, das bei der ersten Anmeldung ersetzt werden muss – Admins kennen die Passwörter
anderer nie. Sperren beendet Sitzungen sofort; `SSHKV_LOKALE_KONTEN=0` schaltet lokale Konten
ganz ab.
- **Weboberfläche:** Content-Security-Policy mit Nonce, `X-Frame-Options: DENY`, `Referrer-Policy: - **Weboberfläche:** Content-Security-Policy mit Nonce, `X-Frame-Options: DENY`, `Referrer-Policy:
no-referrer`, HttpOnly-/SameSite-Cookies, Secure hinter HTTPS; Server Actions mit Origin-Prüfung. no-referrer`, HttpOnly-/SameSite-Cookies, Secure hinter HTTPS; Server Actions mit Origin-Prüfung.
+38 -7
View File
@@ -1,22 +1,53 @@
# #032 Nacharbeiten aus Phase 4/5 # #032 Nacharbeiten aus Phase 4/5
**Status:** offen **Status:** erledigt
**Meilenstein:** v1.0.0 **Meilenstein:** v1.0.0
**Labels:** test, chore **Labels:** test, chore
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0,75 h
## Ziel ## Ziel
Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen. Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
## Aufgaben ## Aufgaben
- [ ] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC) - [x] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
- [ ] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv) - [x] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
- [ ] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`) - [x] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
- [ ] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker) - [x] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Alle Suiten grün - [x] Alle Suiten grün
## Abhängigkeiten ## Abhängigkeiten
#023, #024, #025, #027 #023, #024, #025, #027
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Massenwiderruf:** Der neue E2E-Test (`widerruf.spec.ts`) fand einen echten Fehler: Die Seite
`/schluessel/widerruf` filterte `?id=` mit `/^[a-z0-9]{1,64}$/`. Vom Worker erzeugte Schlüssel tragen
eine UUID mit Bindestrichen – jeder erzeugte Schlüssel fiel still heraus („Keine Schlüssel gewählt“).
Jetzt `idsAusSuchparameter` in `src/lib/schluessel.ts` (mit Test). Außerdem nannte der Hinweis nach
verweigerter Bestätigung den falschen Knopf („Widerruf vorbereiten“ statt „2 Schlüssel widerrufen“).
`datenbank()` und `oidcBestaetigen()` liegen jetzt in `tests/e2e/hilfen.ts`.
- **Rotation abbrechen:** kein neues Feld. Abbrechen = Widerruf des Nachfolgers über den Ablauf aus #024
(Dry-Run, er kann schon ausgerollt sein); ein Nachfolger im Widerruf bzw. widerrufen *ist* die
abgebrochene Rotation (`rotationAbgebrochen`). `vorgaengerId` bleibt für die Historie, der Abbruch
steht als `schluessel.rotation.abgebrochen` im Protokoll – in derselben Transaktion wie das Vormerken
(`widerrufEinleiten` nimmt dafür optional `zusaetzlich` an). Der alte Schlüssel bleibt unverändert; bei
gleichem Alias zeigt die config nach dem Anwenden wieder auf die alte Datei (nur verbleibende
Zuweisungen landen im Block).
- Abbrechen geht nur, solange der alte Schlüssel noch nicht entfernt wird (Schritt 5) – sonst wären am
Ende beide halb entfernt. Auf dem Nachfolger ersetzt „Rotation abbrechen“ den normalen
„Widerrufen“-Knopf, damit der Abbruch immer im Protokoll steht.
- `widerrufAbschliessen` setzt `ROTIERT` nur noch bei einem aktiven Nachfolger, der nicht selbst im
Widerruf ist; die Seite schreibt „Ersetzt durch …“ nur bei `ROTIERT`.
- **Kein zweiter Nachfolger:** `vorgaengerId` ist eindeutig (1:1-Relation). Nach einem Abbruch lässt sich
derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das
(neuen Schlüssel anlegen, zuweisen, alten widerrufen). Eine erneute Rotation bräuchte eine
1:n-Relation mit eindeutigem Teilindex „ein nicht widerrufener Nachfolger je Vorgänger“.
→ Behoben in #033 (partieller Index statt 1:1).
- **Protokoll-IDs:** Migration `20261012173000_protokoll_id_lueckenlos` setzt den Spalten-Default auf den
Platzhalter `0` (Prisma: `@default(0)`, `create` ohne ID geht weiter); die ID zieht nur noch der
Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben
unverändert (die ID steckt in der Hash-Eingabe), alte Lücken bleiben also stehen. Eine mitgegebene ID
überschreibt der Trigger weiterhin.
- **Anker:** Die Dry-Run-Karte trägt `id="dry-run"` (`Karte` nimmt eine optionale `id`) und
`scroll-mt-20` wegen der festen Kopfleiste der schmalen Ansicht; „Zum Dry-Run“ zeigt auf
`/schluessel/<id>#dry-run`.
+27
View File
@@ -0,0 +1,27 @@
# #033 Erneut rotieren nach abgebrochener Rotation
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** backend, datenbank
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
## Ziel
Nach einer abgebrochenen Rotation (#032) lässt sich derselbe Schlüssel erneut rotieren, statt dass ein neuer Schlüssel angelegt, zugewiesen und der alte widerrufen werden muss.
## Aufgaben
- [x] `vorgaengerId` nicht mehr eindeutig; stattdessen partieller Index: je Vorgänger höchstens ein Nachfolger, der weder widerrufen ist noch gerade widerrufen wird
- [x] Relation 1:n (`nachfolgerListe`); maßgeblich ist der jüngste Nachfolger (`JUENGSTER`, `mitNachfolger`)
- [x] Neue Rotation erst, wenn der Abbruch abgeschlossen ist (Nachfolger WIDERRUFEN)
- [x] Schlüsselseite: „Rotieren“ wieder anbieten, Hinweistext angepasst
## Akzeptanzkriterien
- [x] DB-Test: Abbruch → während des Abbruchs keine neue Rotation → danach zweiter Nachfolger, Kette zeigt ihn
- [x] DB-Test: die Datenbank lehnt einen zweiten offenen Nachfolger ab
- [x] E2E: nach dem Abbruch führt „Rotieren“ wieder zu Schritt 1
## Abhängigkeiten
#023, #032
## Notizen / Entscheidungen
- **Abgebrochene Nachfolger bleiben** mit `vorgaengerId` in der Historie; die Rotationskette folgt dem jüngsten Nachfolger.
- **Kein neuer Rotations-Zustand:** Ist der jüngste Nachfolger vollständig widerrufen und der alte nicht ROTIERT, steht die Rotation wieder bei Schritt 0.
- Prisma kennt partielle Indizes nicht; `migrate diff` bleibt trotzdem leer, weil der Index nur in der Migration steht (wie beim Instanz-Standard).
+23
View File
@@ -0,0 +1,23 @@
# #034 Abnahme-Checkliste für 1.0.0
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** docs
**Geschätzt:** 0,5 h **Tatsächlich:** 0 h 10 min
## Ziel
Was die automatischen Tests nicht abdecken – echter authentik, echte Hosts, Betrieb auf dem Zielserver – als abhakbare Liste mit erwartetem Ergebnis je Punkt, damit die Abnahme von 1.0.0 vollständig und nachvollziehbar ist.
## Aufgaben
- [x] `docs/betrieb/abnahme.md`: Installation, authentik, Notfall-Admin, Hosts, Ausrollen, Drift/Rotation/Widerruf, Audit-Log/Benachrichtigungen, Sicherung, Oberfläche
- [x] Aussagen gegen den Code geprüft (Knopfnamen, Healthcheck, Ratenbegrenzung, Header)
- [x] Verlinkt im README
## Akzeptanzkriterien
- [x] Jeder Punkt nennt ein prüfbares Ergebnis
- [x] Die kritischen offenen Punkte (authentik-Neuanmeldung mit `auth_time`, Byte-Gleichheit außerhalb des Blocks auf echten Hosts) sind ausdrücklich enthalten
## Abhängigkeiten
#029
## Notizen / Entscheidungen
- Die Abnahme selbst macht der Betreiber; 1.0.0 wird erst danach getaggt.
+32
View File
@@ -0,0 +1,32 @@
# #035 Lokale Benutzerkonten
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** sicherheit, backend, ui
**Geschätzt:** 2 h **Tatsächlich:** 0 h 35 min
## Ziel
sshkv ohne authentik in Betrieb nehmen können – mit eigenen Konten je Person statt eines geteilten Notfall-Admins, damit Rollen und Audit-Log stimmen. Später laufen lokale Konten und OIDC nebeneinander, oder die lokalen werden abgeschaltet.
## Aufgaben
- [x] Anmeldung mit Anmeldename, Passwort und Pflicht-TOTP für lokale Konten (gleicher Ablauf wie der Notfall-Admin)
- [x] Einmal-Passwort vom System, Pflichtwechsel bei der ersten Anmeldung (`passwortWechselNoetig`, Seite `/anmelden/passwort`)
- [x] Benutzerverwaltung `/einstellungen/benutzer`: anlegen, Rolle ändern, sperren/entsperren, zurücksetzen – Recht `benutzerVerwalten` (Admin + frische Bestätigung)
- [x] OIDC-Konten nur sperrbar; Notfall-Admin und eigenes Konto unveränderlich
- [x] Schalter `SSHKV_LOKALE_KONTEN` (Standard an), auch in `docker-compose.yml`
- [x] Protokoll für alle Schritte, nie mit Passwort
- [x] Doku: Handbuch, Installation („Ohne authentik starten“), Sicherheit, Abnahme-Liste
## Akzeptanzkriterien
- [x] DB-Tests: erste Anmeldung (Einmal-Passwort → eigenes → TOTP → angemeldet), schwache/unveränderte Passwörter, doppelte Namen, Rollen- und Sperrregeln, Zurücksetzen, abgeschaltet
- [x] E2E: Admin legt an, Person meldet sich zum ersten Mal an, Operator kommt nicht in die Benutzerverwaltung, Sperren beendet die Sitzung sofort
## Abhängigkeiten
#004
## Notizen / Entscheidungen
- **TOTP ist Pflicht** auch für lokale Konten (Entscheidung Florian, 12.10.2026).
- **Dauerhaft, aber abschaltbar** per `SSHKV_LOKALE_KONTEN=0` (Entscheidung Florian, 12.10.2026).
- **Admins wählen keine Passwörter für andere:** sshkv erzeugt ein Einmal-Passwort (4 × 5 Zeichen ohne verwechselbare Zeichen, gut 115 Bit), das genau einmal angezeigt wird.
- **12 Zeichen Mindestlänge** für eigene Passwörter – mit Pflicht-TOTP ausreichend; der Notfall-Admin behält 16.
- **Sperren beendet Sitzungen sofort** (`sitzungenGueltigAb`); eine Rollenänderung verwirft die gespeicherte Bestätigung.
- Lokale Konten bekommen keine E-Mail-Adresse, damit die OIDC-Verknüpfung per E-Mail nie ein lokales Konto übernehmen kann.
@@ -0,0 +1,25 @@
# #036 Rotation des Deployment-Keys abbrechen
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** sicherheit, backend
**Geschätzt:** 0,5 h **Tatsächlich:** 0 h 20 min
## Ziel
Eine begonnene Rotation des Deployment-Keys (#031) zurücknehmen können, etwa wenn ein Host den neuen Key dauerhaft nicht annimmt – bisher blieb nur, Hosts beim Umstellen zu übergehen.
## Aufgaben
- [x] Worker-Schritt `abbrechen`: Nachfolger WIDERRUFEN, Private Key sofort verwerfen, protokollieren
- [x] Nur vor dem Umstellen; danach meldet sich sshkv schon mit dem neuen Key an
- [x] Seite: Knopf „Rotation abbrechen“, danach Karte „Abgebrochene Rotation aufräumen“ mit Dry-Run
- [x] Neue Rotation sofort möglich (partieller Index aus #033)
## Akzeptanzkriterien
- [x] Integrationstest: verteilen → abbrechen → Dry-Run entfernt den neuen Key, alter arbeitet weiter → neue Rotation startet
- [x] E2E: abbrechen und aufräumen im Browser; nimmt auch eine liegengebliebene Rotation mit
## Abhängigkeiten
#031, #033
## Notizen / Entscheidungen
- Gefunden, weil ein fehlgeschlagener E2E-Lauf eine Rotation auf verwaisten Testhosts hängen ließ: Das Umstellen verweigerte sich zu Recht, aber es gab keinen Weg zurück.
- Der verworfene Key bleibt bis zum Dry-Run in den Blöcken stehen (harmlos – sein Private Key existiert nicht mehr); die Aufräum-Karte zeigt das.
@@ -0,0 +1,15 @@
-- #032: Protokoll-IDs ohne Zweierschritte.
--
-- Bisher zog der Spalten-Default (BIGSERIAL) eine Nummer aus der Sequenz und der Trigger
-- `protokoll_verketten` gleich noch eine – jede Zeile verbrauchte zwei IDs. Die ID muss aber
-- im Trigger unter der Advisory-Sperre vergeben werden: Eine vorher gezogene Nummer koennte
-- kleiner sein als die einer parallelen Transaktion, die zuerst verkettet, und die Kette liesse
-- sich in ID-Reihenfolge nicht mehr pruefen. Deshalb verzichtet der Default auf die Sequenz.
--
-- 0 ist nur ein Platzhalter, damit `prisma.protokoll.create` ohne ID auskommt; der Trigger
-- ueberschreibt jede ID (auch eine mitgegebene). Die Sequenz bleibt der Spalte zugeordnet
-- (OWNED BY), deshalb findet `pg_get_serial_sequence` sie im Trigger weiterhin.
--
-- Bestehende Zeilen bleiben, wie sie sind: Die ID ist Teil der Hash-Eingabe, Umnummerieren
-- wuerde die Kette brechen. Die alten Luecken bleiben also stehen.
ALTER TABLE "Protokoll" ALTER COLUMN "id" SET DEFAULT 0;
@@ -0,0 +1,11 @@
-- Nach einer abgebrochenen Rotation darf derselbe Schluessel erneut rotiert werden (#033).
-- Die Kette bleibt eindeutig: je Vorgaenger hoechstens ein Nachfolger, der nicht widerrufen
-- ist oder gerade widerrufen wird.
DROP INDEX "Schluessel_vorgaengerId_key";
CREATE INDEX "Schluessel_vorgaengerId_idx" ON "Schluessel"("vorgaengerId");
CREATE UNIQUE INDEX "schluessel_ein_offener_nachfolger" ON "Schluessel" ("vorgaengerId")
WHERE "vorgaengerId" IS NOT NULL
AND "widerrufEingeleitetAm" IS NULL
AND "status" <> 'WIDERRUFEN';
@@ -0,0 +1,2 @@
-- Lokale Konten (#035): ein vom Admin vergebenes Einmal-Passwort muss zuerst ersetzt werden.
ALTER TABLE "Benutzer" ADD COLUMN "passwortWechselNoetig" BOOLEAN NOT NULL DEFAULT false;
+14 -5
View File
@@ -155,13 +155,15 @@ model Benutzer {
/// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der /// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der
/// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013). /// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013).
emailBestaetigt Boolean @default(false) emailBestaetigt Boolean @default(false)
/// Anmeldename des Notfall-Admins. /// Anmeldename lokaler Konten: Notfall-Admin und von Admins angelegte Konten (#035).
anmeldename String? @unique anmeldename String? @unique
name String name String
rolle Rolle @default(LESER) rolle Rolle @default(LESER)
aktiv Boolean @default(true) aktiv Boolean @default(true)
istNotfall Boolean @default(false) istNotfall Boolean @default(false)
passwortHash String? passwortHash String?
/// Einmal-Passwort vom Admin: vor allem anderen muss ein eigenes gesetzt werden (#035).
passwortWechselNoetig Boolean @default(false)
/// TOTP-Geheimnis, verschluesselt mit dem aus SSHKV_APP_SECRET abgeleiteten Schluessel /// TOTP-Geheimnis, verschluesselt mit dem aus SSHKV_APP_SECRET abgeleiteten Schluessel
/// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web). /// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web).
totpGeheimnis Bytes? totpGeheimnis Bytes?
@@ -318,10 +320,13 @@ model Schluessel {
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst /// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren. /// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
widerrufEingeleitetAm DateTime? widerrufEingeleitetAm DateTime?
/// Rotationskette: der Schluessel, den dieser abgeloest hat. /// Rotationskette: der Schluessel, den dieser abgeloest hat. Nach einer abgebrochenen Rotation
vorgaengerId String? @unique /// darf ein weiterer Nachfolger folgen; je Vorgaenger hoechstens ein nicht widerrufener
/// (partieller Index, #033).
vorgaengerId String?
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull) vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
nachfolger Schluessel? @relation("Rotation") /// Alle Nachfolger, auch abgebrochene. Massgeblich ist der juengste (`nachfolgerVon`).
nachfolgerListe Schluessel[] @relation("Rotation")
erstelltVonId String? erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service). /// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
@@ -334,6 +339,8 @@ model Schluessel {
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel") hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel") gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
vorgaenge Vorgang[] vorgaenge Vorgang[]
@@index([vorgaengerId])
} }
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe. /// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
@@ -483,7 +490,9 @@ model Vorgang {
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile /// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet. /// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
model Protokoll { model Protokoll {
id BigInt @id @default(autoincrement()) /// Vergibt der Trigger `protokoll_verketten` unter der Advisory-Sperre aus der Sequenz; der
/// Default 0 ist nur ein Platzhalter, damit `create` ohne ID auskommt (#032).
id BigInt @id @default(0)
zeitpunkt DateTime @default(now()) zeitpunkt DateTime @default(now())
akteurId String? akteurId String?
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird. /// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
+23 -1
View File
@@ -1,4 +1,4 @@
import { CheckCheck, KeyRound, ShieldCheck } from "lucide-react"; import { CheckCheck, KeyRound, ShieldCheck, Undo2 } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
@@ -81,6 +81,16 @@ export default async function DeploymentKeySeite() {
)} )}
</Karte> </Karte>
{stand.phase === "bereit" && stand.abgebrochen && (
<Karte
titel="Abgebrochene Rotation aufräumen"
untertitel="Der verworfene Key kann noch in den Blöcken stehen. Ein Dry-Run zeigt es; steht alles auf Soll, ist nichts mehr zu tun."
>
<p className="text-muted mb-4 font-mono text-[13px] break-all">{stand.abgebrochen.fingerprint}</p>
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={0} />
</Karte>
)}
{stand.phase === "bereit" && stand.aktuell && ( {stand.phase === "bereit" && stand.aktuell && (
<Karte <Karte
titel="1 · Neuen Key erzeugen" titel="1 · Neuen Key erzeugen"
@@ -136,6 +146,18 @@ export default async function DeploymentKeySeite() {
<Umstellen fehlgeschlagen={fehlgeschlagen} /> <Umstellen fehlgeschlagen={fehlgeschlagen} />
</Karte> </Karte>
)} )}
<Karte
titel="Rotation abbrechen"
untertitel="Solange nicht umgestellt ist: der neue Key wird verworfen, der alte bleibt unverändert. Danach per Dry-Run den neuen wieder von den Hosts entfernen."
className="border-danger-border"
>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "abbrechen")}
beschriftung="Rotation abbrechen"
variante="gefahr"
icon={<Undo2 className="size-4" />}
/>
</Karte>
</> </>
)} )}
@@ -0,0 +1,101 @@
import type { Metadata } from "next";
import Link from "next/link";
import { KontoAktionen } from "@/components/benutzer/KontoAktionen";
import { KontoAnlegen } from "@/components/benutzer/KontoAnlegen";
import { Badge, Breadcrumb, Karte, Seitenkopf, Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui";
import { datumZeit } from "@/lib/format";
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { benutzerListe, type KontoHerkunft } from "@/server/queries/benutzer";
export const metadata: Metadata = { title: "Benutzer" };
const ROLLE: Record<string, string> = { ADMIN: "Admin", OPERATOR: "Operator", LESER: "Leser" };
const HERKUNFT: Record<KontoHerkunft, string> = {
notfall: "Notfall-Admin",
lokal: "lokal",
oidc: process.env["SSHKV_OIDC_NAME"] ?? "OIDC",
};
/**
* Benutzerverwaltung (#035): lokale Konten anlegen, Rollen vergeben, sperren, zuruecksetzen.
* OIDC-Konten erscheinen nach ihrer ersten Anmeldung; ihre Rolle kommt aus dem Anbieter.
*/
export default async function BenutzerSeite() {
const { benutzer } = await sitzungErforderlich("benutzerVerwalten", "/einstellungen/benutzer");
const konten = await benutzerListe();
const lokal = lokaleKontenAktiv();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Benutzer" }]} />
}
titel="Benutzer"
untertitel="Lokale Konten mit Passwort und TOTP – neben oder statt des Identitätsanbieters."
/>
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
<p className="text-accent text-[13.5px]">
Änderungen an Konten verlangen eine frische Bestätigung Ihrer Anmeldung.{" "}
<Link href="/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer" className="underline">
Jetzt bestätigen
</Link>
</p>
)}
{lokal ? (
<Karte
titel="Lokales Konto anlegen"
untertitel="sshkv erzeugt ein Einmal-Passwort; bei der ersten Anmeldung setzt die Person ein eigenes und richtet TOTP ein."
>
<KontoAnlegen />
</Karte>
) : (
<p className="text-muted text-[14px]">
Lokale Konten sind abgeschaltet (<code className="font-mono">SSHKV_LOKALE_KONTEN=0</code>).
Vorhandene lokale Konten können sich nicht anmelden.
</p>
)}
<Karte titel="Konten" untertitel={konten.length === 1 ? "1 Konto" : `${konten.length} Konten`}>
<Tabelle
beschriftung="Konten"
spalten={[
{ titel: "Name" },
{ titel: "Anmeldung" },
{ titel: "Rolle" },
{ titel: "Zuletzt angemeldet", versteckt: true },
{ titel: "Aktionen", rechts: true },
]}
>
{konten.map((k) => (
<TabellenZeile key={k.id}>
<TabellenZelle>
<span className="block font-medium">{k.name}</span>
<span className="text-muted block font-mono text-[12.5px]">
{k.anmeldename ?? k.email ?? "–"}
</span>
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<span className="flex flex-wrap gap-1.5">
<Badge>{HERKUNFT[k.herkunft]}</Badge>
{!k.aktiv && <Badge variante="gefahr">gesperrt</Badge>}
{k.aktiv && k.einrichtungOffen && <Badge variante="messing">Einrichtung offen</Badge>}
</span>
</TabellenZelle>
<TabellenZelle>{ROLLE[k.rolle]}</TabellenZelle>
<TabellenZelle className="text-muted text-[13px] whitespace-nowrap">
{k.letzteAnmeldungAm ? datumZeit(k.letzteAnmeldungAm) : "noch nie"}
</TabellenZelle>
<TabellenZelle rechts>
<KontoAktionen konto={k} selbst={k.id === benutzer.id} />
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
</Karte>
</div>
);
}
+12
View File
@@ -188,6 +188,18 @@ export default async function Einstellungen() {
<span className="text-muted">nicht eingerichtet</span> <span className="text-muted">nicht eingerichtet</span>
)} )}
</Info> </Info>
<Info titel="Benutzer">
{darf(benutzer.rolle, "benutzerVerwalten") ? (
<Link
href="/einstellungen/benutzer"
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
>
Konten verwalten
</Link>
) : (
<span className="text-muted">nur für Admins</span>
)}
</Info>
<Info titel="Notfall-Admin"> <Info titel="Notfall-Admin">
{!instanz.notfall.vorhanden ? ( {!instanz.notfall.vorhanden ? (
<span className="text-muted">nicht angelegt</span> <span className="text-muted">nicht angelegt</span>
+5 -1
View File
@@ -11,6 +11,10 @@ export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
name: benutzer.name, name: benutzer.name,
rolle: benutzer.rolle, rolle: benutzer.rolle,
herkunft: herkunft:
sitzung.herkunft === "notfall" ? "Notfallzugang" : (process.env["SSHKV_OIDC_NAME"] ?? "authentik"), sitzung.herkunft === "notfall"
? "Notfallzugang"
: sitzung.herkunft === "lokal"
? "lokales Konto"
: (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
}; };
} }
+65 -11
View File
@@ -23,7 +23,8 @@ import { widerrufUebersicht } from "@/server/queries/widerruf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf"; import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
import { ablaufZustand } from "@/lib/schluessel"; import { ablaufZustand } from "@/lib/schluessel";
import { rotationGesperrt } from "@/server/services/rotation"; import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
@@ -216,9 +217,17 @@ export default async function SchluesselSeite({
const jetzt = new Date(); const jetzt = new Date();
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen"; const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten"); const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
const rotationDesVorgaengers =
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
? s.vorgaenger
: null;
const widerrufbar = const widerrufbar =
!imWiderruf && !imWiderruf &&
!(s.nachfolger?.status === "AKTIV") && !(s.nachfolger?.status === "AKTIV") &&
// Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) –
// so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand.
!rotationDesVorgaengers &&
s.kategorie !== "DEPLOYMENT" && s.kategorie !== "DEPLOYMENT" &&
(s.status === "AKTIV" || s.status === "ABGELAUFEN") && (s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
rechte.public && rechte.public &&
@@ -235,13 +244,22 @@ export default async function SchluesselSeite({
privatZuweisungen: aktivePrivat, privatZuweisungen: aktivePrivat,
}); });
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT"; const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat); const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf)); // Nach einem abgeschlossenen Abbruch (#032) darf erneut rotiert werden (#033); solange der
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger. // abgebrochene Nachfolger noch entfernt wird, nicht.
const rotationDesVorgaengers = const rotierbar =
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN") darfRotieren &&
? s.vorgaenger (rotationLaeuft ||
: null; (!rotationsSperre && !imWiderruf && (!s.nachfolger || s.nachfolger.status === "WIDERRUFEN")));
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
const darfAbbrechen =
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
!imWiderruf &&
s.status === "AKTIV" &&
verwaltenRecht &&
rechte.public &&
(s.privateVerwendungen.length === 0 || rechte.privat);
const vorauswahl = ( const vorauswahl = (
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "") Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
) )
@@ -308,7 +326,19 @@ export default async function SchluesselSeite({
} }
/> />
{rotationDesVorgaengers && ( {rotationDesVorgaengers &&
(imWiderruf || s.status === "WIDERRUFEN" ? (
<Karte titel="Rotation abgebrochen">
<p className="text-muted text-[14px]">
Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen.
{imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "}
<Link href={`/schluessel/${rotationDesVorgaengers.id}`} className="text-accent hover:underline">
{rotationDesVorgaengers.name}
</Link>{" "}
bleibt aktiv.
</p>
</Karte>
) : (
<Karte titel="Rotation läuft" className="border-accent"> <Karte titel="Rotation läuft" className="border-accent">
<p className="text-muted text-[14px]"> <p className="text-muted text-[14px]">
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
@@ -320,8 +350,17 @@ export default async function SchluesselSeite({
Rotation fortsetzen Rotation fortsetzen
</Link> </Link>
</p> </p>
</Karte> {darfAbbrechen && (
<div className="mt-4">
<RotationAbbrechen
altId={rotationDesVorgaengers.id}
altName={rotationDesVorgaengers.name}
neuName={s.name}
/>
</div>
)} )}
</Karte>
))}
{imWiderruf && widerruf && ( {imWiderruf && widerruf && (
<Karte <Karte
@@ -336,7 +375,7 @@ export default async function SchluesselSeite({
vorauswahl={vorauswahl} vorauswahl={vorauswahl}
ruecksprung={`/schluessel/${s.id}`} ruecksprung={`/schluessel/${s.id}`}
darfPrivat={rechte.privat} darfPrivat={rechte.privat}
ohneZielkonten={rotationLaeuft} ohneZielkonten={rotationLaeuft || Boolean(rotationDesVorgaengers)}
/> />
</Karte> </Karte>
)} )}
@@ -367,7 +406,8 @@ export default async function SchluesselSeite({
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}> <Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
<p className="text-muted text-[14px]"> <p className="text-muted text-[14px]">
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen. Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
{s.nachfolger && ( {/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */}
{s.nachfolger && s.status === "ROTIERT" && (
<> <>
{" "} {" "}
Ersetzt durch{" "} Ersetzt durch{" "}
@@ -457,6 +497,10 @@ export default async function SchluesselSeite({
!imWiderruf && !imWiderruf &&
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && ( (s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
<Karte <Karte
// Sprungziel fuer „Zum Dry-Run“ aus der Zuweisungsmatrix; scroll-mt haelt die Karte
// unter der festen Kopfleiste der schmalen Ansicht (#032).
id="dry-run"
className="scroll-mt-20"
titel="Dry-Run-Vorschau" titel="Dry-Run-Vorschau"
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts" untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
> >
@@ -498,6 +542,16 @@ export default async function SchluesselSeite({
))} ))}
</ol> </ol>
)} )}
{abgebrocheneRotation && s.nachfolger && (
<p className="text-muted mt-3 text-[14px]">
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
dieser Schlüssel bleibt aktiv
{s.nachfolger.status === "WIDERRUFEN"
? " und lässt sich erneut rotieren."
: ". Danach lässt er sich erneut rotieren."}
</p>
)}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && ( {rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}> <p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
{rotationsSperre.grund} {rotationsSperre.grund}
@@ -59,9 +59,14 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
} }
const widerruf = const widerruf =
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT" stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
? await offeneEntfernungen([alt.id]) ? await offeneEntfernungen([alt.id])
: null; : null;
// Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter.
const abbruch =
stand.neu && stand.abgebrochen
? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) }
: null;
return ( return (
<div className="flex flex-col gap-6 sm:gap-7"> <div className="flex flex-col gap-6 sm:gap-7">
@@ -74,6 +79,11 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
neuOffen: stand.neu ? stand.neuOffen : 0, neuOffen: stand.neu ? stand.neuOffen : 0,
tests: stand.neu ? stand.tests : [], tests: stand.neu ? stand.tests : [],
widerruf, widerruf,
abbruch,
letzterAbbruch:
!stand.neu && stand.alt.nachfolger?.status === "WIDERRUFEN"
? { id: stand.alt.nachfolger.id, name: stand.alt.nachfolger.name }
: null,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"), darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}} }}
/> />
+3 -10
View File
@@ -9,6 +9,7 @@ import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf"; import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular"; import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
import { idsAusSuchparameter } from "@/lib/schluessel";
import { darf } from "@/server/auth/rechte"; import { darf } from "@/server/auth/rechte";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { widerrufUebersicht } from "@/server/queries/widerruf"; import { widerrufUebersicht } from "@/server/queries/widerruf";
@@ -17,14 +18,6 @@ export const metadata: Metadata = { title: "Massenwiderruf" };
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>; type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
function liste(wert: string | string[] | undefined): string[] {
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice(
0,
200,
);
}
/** /**
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem * Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische * Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
@@ -32,7 +25,7 @@ function liste(wert: string | string[] | undefined): string[] {
*/ */
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) { export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
const parameter = await searchParams; const parameter = await searchParams;
const ids = liste(parameter.id); const ids = idsAusSuchparameter(parameter.id);
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`; const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst); const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
@@ -61,7 +54,7 @@ export default async function MassenwiderrufSeite({ searchParams }: { searchPara
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN"; const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet); const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet); const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
const vorauswahl = liste(parameter.zielkonten); const vorauswahl = idsAusSuchparameter(parameter.zielkonten);
return ( return (
<div className="flex flex-col gap-6 sm:gap-7"> <div className="flex flex-col gap-6 sm:gap-7">
+8 -4
View File
@@ -1,8 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen"; import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { NotfallFormular } from "@/components/anmeldung/NotfallFormular"; import { KontoFormular } from "@/components/anmeldung/KontoFormular";
import { buttonKlassen, Formularfehler } from "@/components/ui"; import { buttonKlassen, Formularfehler } from "@/components/ui";
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
import { sicheresZiel } from "@/server/auth/anmeldung"; import { sicheresZiel } from "@/server/auth/anmeldung";
import { oidcAktiv, oidcName } from "@/server/auth/oidc"; import { oidcAktiv, oidcName } from "@/server/auth/oidc";
import { angemeldet } from "@/server/auth/sitzungspflicht"; import { angemeldet } from "@/server/auth/sitzungspflicht";
@@ -33,6 +34,7 @@ export default async function AnmeldenSeite({
if (await angemeldet()) redirect(ziel); if (await angemeldet()) redirect(ziel);
const oidc = oidcAktiv(); const oidc = oidcAktiv();
const lokal = lokaleKontenAktiv();
const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined; const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined;
return ( return (
@@ -53,15 +55,17 @@ export default async function AnmeldenSeite({
</a> </a>
) : ( ) : (
<p className="text-muted text-[14px]"> <p className="text-muted text-[14px]">
Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv. {lokal
? "Kein Identitätsanbieter eingerichtet – Anmeldung mit lokalem Konto."
: "Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv."}
</p> </p>
)} )}
<details className="group border-border rounded-box border" open={!oidc}> <details className="group border-border rounded-box border" open={!oidc}>
<summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium"> <summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium">
Notfallzugang {lokal ? "Lokales Konto oder Notfallzugang" : "Notfallzugang"}
</summary> </summary>
<div className="border-border border-t p-4"> <div className="border-border border-t p-4">
<NotfallFormular ziel={ziel} /> <KontoFormular ziel={ziel} lokal={lokal} />
</div> </div>
</details> </details>
</div> </div>
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { PasswortSetzenFormular } from "@/components/anmeldung/PasswortSetzenFormular";
import { KONTO_PASSWORT_MINDESTLAENGE } from "@/lib/lokale-konten";
import { passwortSetzenLesen } from "@/server/auth/totp-einrichtung";
export const metadata: Metadata = { title: "Eigenes Passwort setzen" };
/**
* Erste Anmeldung eines lokalen Kontos (#035): das Einmal-Passwort vom Admin wird durch ein
* eigenes ersetzt. Nur mit dem Cookie erreichbar, das ein richtiges Einmal-Passwort ausstellt.
*/
export default async function PasswortSetzenSeite() {
if (!(await passwortSetzenLesen())) redirect("/anmelden");
return (
<AnmeldeRahmen
titel="Eigenes Passwort setzen"
text={`Das Einmal-Passwort gilt nur für diese erste Anmeldung. Bitte ein eigenes mit mindestens ${KONTO_PASSWORT_MINDESTLAENGE} Zeichen wählen – danach wird der zweite Faktor eingerichtet.`}
>
<PasswortSetzenFormular />
</AnmeldeRahmen>
);
}
+13 -3
View File
@@ -4,20 +4,30 @@ import QRCode from "qrcode";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen"; import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { TotpFormular } from "@/components/anmeldung/TotpFormular"; import { TotpFormular } from "@/components/anmeldung/TotpFormular";
import { MonoKopieren } from "@/components/ui"; import { MonoKopieren } from "@/components/ui";
import { prisma } from "@/lib/db";
import { totpAdresse } from "@/lib/totp"; import { totpAdresse } from "@/lib/totp";
import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung"; import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung";
export const metadata: Metadata = { title: "TOTP einrichten" }; export const metadata: Metadata = { title: "TOTP einrichten" };
/** /**
* Erste Anmeldung des Notfall-Admins: TOTP ist Pflicht (Freigabe Frage 5). Die Seite ist * Erste Anmeldung eines Passwort-Kontos (Notfall-Admin, lokale Konten #035): TOTP ist Pflicht
* nur mit dem Einrichtungs-Cookie erreichbar, das ein richtiges Passwort ausgestellt hat. * (Freigabe Frage 5). Die Seite ist nur mit dem Einrichtungs-Cookie erreichbar, das ein
* richtiges Passwort ausgestellt hat.
*/ */
export default async function TotpSeite() { export default async function TotpSeite() {
const einrichtung = await totpEinrichtungLesen(); const einrichtung = await totpEinrichtungLesen();
if (!einrichtung) redirect("/anmelden"); if (!einrichtung) redirect("/anmelden");
const adresse = totpAdresse(einrichtung.geheimnis, "Notfall-Admin"); const konto = await prisma.benutzer.findUnique({
where: { id: einrichtung.benutzerId },
select: { anmeldename: true, istNotfall: true },
});
// In der Authenticator-App soll stehen, welches Konto das ist.
const adresse = totpAdresse(
einrichtung.geheimnis,
konto?.istNotfall ? "Notfall-Admin" : (konto?.anmeldename ?? "Konto"),
);
// Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser. // Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser.
const svg = await QRCode.toString(adresse, { const svg = await QRCode.toString(adresse, {
type: "svg", type: "svg",
+1 -1
View File
@@ -22,7 +22,7 @@ export default async function BestaetigenSeite({
titel="Anmeldung bestätigen" titel="Anmeldung bestätigen"
text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten." text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten."
> >
{benutzer.istNotfall ? ( {benutzer.passwortHash && !benutzer.oidcSub ? (
<BestaetigenFormular ziel={ziel} /> <BestaetigenFormular ziel={ziel} />
) : ( ) : (
<a <a
@@ -2,11 +2,11 @@
import { useActionState } from "react"; import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui"; import { Button, Feld, Formularfehler } from "@/components/ui";
import { notfallBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung"; import { kontoBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */ /** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */
export function BestaetigenFormular({ ziel }: { ziel: string }) { export function BestaetigenFormular({ ziel }: { ziel: string }) {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallBestaetigenAktion, {}); const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoBestaetigenAktion, {});
return ( return (
<form action={aktion} className="flex flex-col gap-4"> <form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="ziel" value={ziel} /> <input type="hidden" name="ziel" value={ziel} />
@@ -2,11 +2,14 @@
import { useActionState } from "react"; import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui"; import { Button, Feld, Formularfehler } from "@/components/ui";
import { notfallAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung"; import { kontoAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
/** Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code (#004). */ /**
export function NotfallFormular({ ziel }: { ziel: string }) { * Lokale Konten und Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallAnmeldenAktion, {}); * (#004, #035).
*/
export function KontoFormular({ ziel, lokal }: { ziel: string; lokal: boolean }) {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoAnmeldenAktion, {});
return ( return (
<form action={aktion} className="flex flex-col gap-4"> <form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="ziel" value={ziel} /> <input type="hidden" name="ziel" value={ziel} />
@@ -29,7 +32,7 @@ export function NotfallFormular({ ziel }: { ziel: string }) {
hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet." hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet."
/> />
<Button type="submit" variante="sekundaer" disabled={laeuft}> <Button type="submit" variante="sekundaer" disabled={laeuft}>
{laeuft ? "Prüfe …" : "Mit Notfallzugang anmelden"} {laeuft ? "Prüfe …" : lokal ? "Mit Benutzername anmelden" : "Mit Notfallzugang anmelden"}
</Button> </Button>
</form> </form>
); );
@@ -0,0 +1,32 @@
"use client";
import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui";
import { passwortSetzenAktion, type FormularZustand } from "@/server/actions/anmeldung";
export function PasswortSetzenFormular() {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(passwortSetzenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<Formularfehler text={zustand.fehler} />
<Feld
beschriftung="Neues Passwort"
name="passwort"
type="password"
autoComplete="new-password"
required
autoFocus
/>
<Feld
beschriftung="Neues Passwort wiederholen"
name="wiederholung"
type="password"
autoComplete="new-password"
required
/>
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : "Passwort setzen"}
</Button>
</form>
);
}
@@ -0,0 +1,18 @@
import { MonoKopieren } from "@/components/ui";
/** Einmal-Passwort fuer ein neues oder zurueckgesetztes Konto (#035) – wird nur jetzt gezeigt. */
export function EinmalPasswort({ anmeldename, passwort }: { anmeldename: string; passwort: string }) {
return (
<section
aria-label={`Einmal-Passwort für ${anmeldename}`}
className="border-accent bg-raised rounded-box flex flex-col gap-2 border p-4"
>
<p className="text-[14px]">
Einmal-Passwort für <span className="font-mono">{anmeldename}</span> –{" "}
<strong>nur jetzt sichtbar</strong>. Bitte auf sicherem Weg weitergeben; bei der ersten Anmeldung wird
ein eigenes Passwort und TOTP eingerichtet.
</p>
<MonoKopieren wert={passwort} beschriftung="Einmal-Passwort" />
</section>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { EinmalPasswort } from "@/components/benutzer/EinmalPasswort";
import { Button } from "@/components/ui";
import type { KontoHerkunft } from "@/server/queries/benutzer";
import { aktivSetzenAktion, rolleAendernAktion, zuruecksetzenAktion } from "@/server/actions/benutzer";
const ROLLEN = [
{ wert: "LESER", text: "Leser" },
{ wert: "OPERATOR", text: "Operator" },
{ wert: "ADMIN", text: "Admin" },
] as const;
/**
* Aktionen je Konto (#035): Rolle (nur lokal), sperren/entsperren, zuruecksetzen (nur lokal).
* Der Notfall-Admin und das eigene Konto bekommen keine Knoepfe – der Server lehnt es ohnehin ab.
*/
export function KontoAktionen({
konto,
selbst,
}: {
konto: {
id: string;
name: string;
anmeldename: string | null;
rolle: string;
aktiv: boolean;
herkunft: KontoHerkunft;
};
selbst: boolean;
}) {
const router = useRouter();
const [fehler, setFehler] = useState<string>();
const [einmal, setEinmal] = useState<string>();
const [rueckfrage, setRueckfrage] = useState(false);
const [laeuft, starten] = useTransition();
if (konto.herkunft === "notfall" || selbst) {
return <span className="text-muted text-[13px]">{selbst ? "eigenes Konto" : "unveränderlich"}</span>;
}
const ausfuehren = (aktion: () => Promise<{ fehler?: string; einmalPasswort?: string }>) =>
starten(async () => {
setFehler(undefined);
const antwort = await aktion();
if (antwort.fehler) setFehler(antwort.fehler);
if (antwort.einmalPasswort) setEinmal(antwort.einmalPasswort);
router.refresh();
});
return (
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap justify-end gap-2">
{konto.herkunft === "lokal" && (
<label className="sr-only" htmlFor={`rolle-${konto.id}`}>
Rolle von {konto.name}
</label>
)}
{konto.herkunft === "lokal" && (
<select
id={`rolle-${konto.id}`}
className="bg-bg border-border-strong rounded-feld min-h-11 border px-2 text-[14px]"
value={konto.rolle}
disabled={laeuft}
onChange={(e) => ausfuehren(() => rolleAendernAktion(konto.id, e.target.value))}
>
{ROLLEN.map((r) => (
<option key={r.wert} value={r.wert}>
{r.text}
</option>
))}
</select>
)}
<Button
klein
disabled={laeuft}
onClick={() => ausfuehren(() => aktivSetzenAktion(konto.id, !konto.aktiv))}
>
{konto.aktiv ? "Sperren" : "Entsperren"}
<span className="sr-only"> – {konto.name}</span>
</Button>
{konto.herkunft === "lokal" &&
(rueckfrage ? (
<Button
klein
variante="gefahr"
disabled={laeuft}
onClick={() => {
setRueckfrage(false);
ausfuehren(() => zuruecksetzenAktion(konto.id));
}}
>
Wirklich zurücksetzen?
</Button>
) : (
<Button klein disabled={laeuft} onClick={() => setRueckfrage(true)}>
Zurücksetzen<span className="sr-only"> – {konto.name}</span>
</Button>
))}
</div>
{fehler && (
<p role="alert" className="text-danger text-[13px]">
{fehler}
</p>
)}
{einmal && konto.anmeldename && <EinmalPasswort anmeldename={konto.anmeldename} passwort={einmal} />}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { UserPlus } from "lucide-react";
import { useActionState } from "react";
import { EinmalPasswort } from "@/components/benutzer/EinmalPasswort";
import { Auswahlliste, Button, Feld, Formularfehler } from "@/components/ui";
import { kontoAnlegenAktion, type KontoZustand } from "@/server/actions/benutzer";
/** Lokales Konto anlegen (#035). Das Passwort waehlt nicht der Admin, sshkv erzeugt es. */
export function KontoAnlegen() {
const [zustand, aktion, laeuft] = useActionState<KontoZustand, FormData>(kontoAnlegenAktion, {});
return (
<div className="flex flex-col gap-4">
{zustand.einmalPasswort && zustand.anmeldename && (
<EinmalPasswort anmeldename={zustand.anmeldename} passwort={zustand.einmalPasswort} />
)}
<form action={aktion} className="grid gap-4 sm:grid-cols-[1fr_1fr_12rem_auto] sm:items-end">
<div className="sm:col-span-4 empty:hidden">
<Formularfehler text={zustand.fehler} />
</div>
<Feld
beschriftung="Anmeldename"
name="anmeldename"
autoComplete="off"
required
mono
fehler={zustand.feldfehler?.["anmeldename"]}
/>
<Feld
beschriftung="Name"
name="name"
autoComplete="off"
required
fehler={zustand.feldfehler?.["name"]}
/>
<Auswahlliste beschriftung="Rolle" name="rolle" defaultValue="LESER">
<option value="LESER">Leser</option>
<option value="OPERATOR">Operator</option>
<option value="ADMIN">Admin</option>
</Auswahlliste>
<Button type="submit" variante="primaer" disabled={laeuft} icon={<UserPlus className="size-4" />}>
{laeuft ? "Lege an …" : "Konto anlegen"}
</Button>
</form>
</div>
);
}
+48
View File
@@ -9,6 +9,7 @@ import { Button, Formularfehler, Karte } from "@/components/ui";
import { Schrittleiste } from "@/components/ui/Schrittleiste"; import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { warten } from "@/components/vorgang/warten"; import { warten } from "@/components/vorgang/warten";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { RotationAbbrechen } from "./RotationAbbrechen";
import { import {
rotationAltWiderrufenAktion, rotationAltWiderrufenAktion,
rotationStartenAktion, rotationStartenAktion,
@@ -47,6 +48,13 @@ export type RotationsDaten = {
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */ /** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
widerruf: OffeneEntfernung[] | null; widerruf: OffeneEntfernung[] | null;
darfPrivat: boolean; darfPrivat: boolean;
/**
* Abgebrochene Rotation (#032): `offen` sind die offenen Entfernungen des neuen Schluessels,
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
*/
abbruch: { offen: OffeneEntfernung[] | null } | null;
/** Zuletzt abgebrochener Nachfolger, wenn die Rotation danach wieder am Anfang steht (#033). */
letzterAbbruch: { id: string; name: string } | null;
}; };
export function Rotation({ daten }: { daten: RotationsDaten }) { export function Rotation({ daten }: { daten: RotationsDaten }) {
@@ -109,8 +117,43 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</> </>
); );
if (daten.abbruch?.offen && neu) {
return (
<Karte
titel="Rotation wird abgebrochen"
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={[neu.id]}
offen={daten.abbruch.offen}
zielkonten={[]}
vorauswahl={[]}
ruecksprung={`/schluessel/${alt.id}/rotieren`}
darfPrivat={daten.darfPrivat}
ohneZielkonten
/>
</Karte>
);
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{daten.letzterAbbruch && schritt === 0 && (
<Karte titel="Rotation abgebrochen">
<p className="text-[14px]">
Der neue Schlüssel{" "}
<Link href={`/schluessel/${daten.letzterAbbruch.id}`} className="text-accent hover:underline">
{daten.letzterAbbruch.name}
</Link>{" "}
ist widerrufen und von allen Hosts entfernt.{" "}
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
{alt.name}
</Link>{" "}
bleibt aktiv und lässt sich erneut rotieren.
</p>
</Karte>
)}
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" /> <Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
{schritt <= 1 && ( {schritt <= 1 && (
@@ -225,6 +268,11 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</Karte> </Karte>
)} )}
{/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */}
{neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && (
<RotationAbbrechen altId={alt.id} altName={alt.name} neuName={neu.name} />
)}
{schritt === 5 && neu && ( {schritt === 5 && neu && (
<Karte titel="Rotation abgeschlossen"> <Karte titel="Rotation abgeschlossen">
<p className="text-[14px]"> <p className="text-[14px]">
@@ -0,0 +1,80 @@
"use client";
import { Undo2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button, Formularfehler } from "@/components/ui";
import { rotationAbbrechenAktion } from "@/server/actions/rotation";
/**
* „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der
* Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und
* „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt.
*/
export function RotationAbbrechen({
altId,
altName,
neuName,
}: {
altId: string;
altName: string;
neuName: string;
}) {
const router = useRouter();
const [offen, setOffen] = useState(false);
const [fehler, setFehler] = useState<string>();
const [laeuft, starten] = useTransition();
if (!offen) {
return (
<div>
<Button icon={<Undo2 className="size-4" />} onClick={() => setOffen(true)}>
Rotation abbrechen
</Button>
</div>
);
}
return (
<div
role="group"
aria-label="Rotation abbrechen?"
className="border-danger-border bg-bg rounded-box flex flex-col gap-3 border px-4 py-3 text-[14px]"
>
<p className="font-medium">Rotation abbrechen?</p>
<ul className="text-muted list-disc space-y-1 pl-5 text-[13.5px]">
<li>
Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was
entfernt wird.
</li>
<li>„{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.</li>
<li>Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.</li>
</ul>
<Formularfehler text={fehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button disabled={laeuft} onClick={() => setOffen(false)}>
Zurück
</Button>
<Button
variante="gefahr"
icon={<Undo2 className="size-4" />}
disabled={laeuft}
onClick={() =>
starten(async () => {
setFehler(undefined);
const antwort = await rotationAbbrechenAktion(altId);
if (antwort.fehler) {
setFehler(antwort.fehler);
return;
}
setOffen(false);
router.refresh();
})
}
>
{laeuft ? "Wird vorgemerkt …" : "Neuen Schlüssel widerrufen"}
</Button>
</div>
</div>
);
}
+4
View File
@@ -13,6 +13,7 @@ export function Karte({
className, className,
innen = "normal", innen = "normal",
as: Element = "section", as: Element = "section",
id,
}: { }: {
titel?: ReactNode; titel?: ReactNode;
untertitel?: ReactNode; untertitel?: ReactNode;
@@ -22,9 +23,12 @@ export function Karte({
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */ /** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
innen?: "normal" | "ohne"; innen?: "normal" | "ohne";
as?: "section" | "div" | "article"; as?: "section" | "div" | "article";
/** Sprungziel für Links von anderen Seiten (z. B. `#dry-run`, #032). */
id?: string;
}) { }) {
return ( return (
<Element <Element
id={id}
className={clsx( className={clsx(
"border-border bg-surface min-w-0 rounded-karte border", "border-border bg-surface min-w-0 rounded-karte border",
innen === "normal" && "p-5 sm:p-6", innen === "normal" && "p-5 sm:p-6",
+4 -6
View File
@@ -32,6 +32,7 @@ export function WiderrufFormular({
const [laeuft, starten] = useTransition(); const [laeuft, starten] = useTransition();
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt); const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
const mehrere = uebersicht.schluessel.length > 1; const mehrere = uebersicht.schluessel.length > 1;
const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten";
const ziel = (() => { const ziel = (() => {
const url = new URL(ruecksprung, "http://x"); const url = new URL(ruecksprung, "http://x");
@@ -100,7 +101,8 @@ export function WiderrufFormular({
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`} href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium" className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
> >
Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen {/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */}
Anmeldung bestätigen und danach erneut „{knopfText}“ wählen
</a> </a>
)} )}
<div className="flex flex-wrap justify-end gap-2.5"> <div className="flex flex-wrap justify-end gap-2.5">
@@ -124,11 +126,7 @@ export function WiderrufFormular({
}) })
} }
> >
{laeuft {laeuft ? "Wird vorgemerkt …" : knopfText}
? "Wird vorgemerkt …"
: mehrere
? `${uebersicht.schluessel.length} Schlüssel widerrufen`
: "Widerruf vorbereiten"}
</Button> </Button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -175,7 +175,7 @@ export function ZellenDialog({
zeigt und schreibt die Änderung. zeigt und schreibt die Änderung.
</p> </p>
<Button <Button
href={`/schluessel/${schluessel.id}`} href={`/schluessel/${schluessel.id}#dry-run`}
klein klein
className="self-start" className="self-start"
icon={<ArrowRight className="size-4" />} icon={<ArrowRight className="size-4" />}
+55
View File
@@ -0,0 +1,55 @@
import { randomInt } from "node:crypto";
import { z } from "@/lib/validierung";
/**
* Regeln fuer lokale Konten (#035): Anmeldename, Passwortlaenge, Einmal-Passwort. Rein, damit
* testbar. Lokale Konten melden sich wie der Notfall-Admin mit Passwort und Pflicht-TOTP an.
*/
/** Mit Pflicht-TOTP genuegen 12 Zeichen; der Notfall-Admin behaelt seine 16 (#004). */
export const KONTO_PASSWORT_MINDESTLAENGE = 12;
export const KONTO_PASSWORT_HOECHSTLAENGE = 256;
export const anmeldenameSchema = z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z][a-z0-9._-]{1,63}$/, {
message:
"2–64 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich; mit einem Buchstaben beginnen.",
});
export const kontoNameSchema = z.string().trim().min(1).max(120);
/** Welche Rollen ein lokales Konto haben kann – dieselben wie ueber OIDC. */
export const kontoRolleSchema = z.enum(["ADMIN", "OPERATOR", "LESER"]);
export function passwortFehler(passwort: string, anmeldename?: string | null): string | null {
if (passwort.length < KONTO_PASSWORT_MINDESTLAENGE)
return `Das Passwort braucht mindestens ${KONTO_PASSWORT_MINDESTLAENGE} Zeichen.`;
if (passwort.length > KONTO_PASSWORT_HOECHSTLAENGE) return "Das Passwort ist zu lang.";
if (anmeldename && passwort.toLowerCase().includes(anmeldename.toLowerCase()))
return "Das Passwort darf den Anmeldenamen nicht enthalten.";
return null;
}
/** Ohne leicht verwechselbare Zeichen (0/O, 1/l/I) – das Passwort wird abgetippt. */
const ZEICHEN = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/**
* Einmal-Passwort fuer ein neues oder zurueckgesetztes Konto: 4 × 5 Zeichen mit Bindestrichen,
* gut 115 Bit. Es wird genau einmal angezeigt und muss bei der ersten Anmeldung ersetzt werden.
*/
export function einmalPasswortErzeugen(): string {
return Array.from({ length: 4 }, () =>
Array.from({ length: 5 }, () => ZEICHEN[randomInt(ZEICHEN.length)]).join(""),
).join("-");
}
/** Lokale Konten lassen sich per `SSHKV_LOKALE_KONTEN=0` abschalten; der Notfall-Admin bleibt. */
export function lokaleKontenAktiv(
umgebung: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
const wert = umgebung["SSHKV_LOKALE_KONTEN"]?.trim().toLowerCase();
return !(wert === "0" || wert === "false" || wert === "nein" || wert === "aus");
}
+14
View File
@@ -13,6 +13,7 @@
export const AKTIONSGRUPPEN = { export const AKTIONSGRUPPEN = {
anmeldung: "Anmeldung", anmeldung: "Anmeldung",
benutzer: "Benutzer",
hostkey: "Host-Key", hostkey: "Host-Key",
ausrollen: "Ausrollen", ausrollen: "Ausrollen",
schluessel: "Schlüssel", schluessel: "Schlüssel",
@@ -44,6 +45,17 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
"anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"), "anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"),
"anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false), "anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false),
"anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false), "anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false),
"anmeldung.lokal": a("Anmeldung (lokales Konto)", "anmeldung", false),
"anmeldung.lokal.fehlgeschlagen": a("Anmeldung (lokal) fehlgeschlagen", "anmeldung", false),
"anmeldung.lokal.gesperrt": a("Anmeldung (lokal) gesperrt – zu viele Fehlversuche", "anmeldung", false),
"anmeldung.lokal.totp_falsch": a("Anmeldung (lokal): falscher TOTP-Code", "anmeldung", false),
"konto.passwort_gesetzt": a("Eigenes Passwort gesetzt", "benutzer"),
"konto.totp_eingerichtet": a("TOTP eingerichtet", "anmeldung"),
"benutzer.angelegt": a("Lokales Konto angelegt", "benutzer"),
"benutzer.rolle_geaendert": a("Rolle geändert", "benutzer"),
"benutzer.gesperrt": a("Konto gesperrt", "benutzer"),
"benutzer.entsperrt": a("Konto entsperrt", "benutzer"),
"benutzer.zurueckgesetzt": a("Konto zurückgesetzt (Passwort und TOTP)", "benutzer"),
"anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false), "anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false),
"anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false), "anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false),
"anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false), "anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false),
@@ -79,6 +91,7 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"), "deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
"deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false), "deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false),
"deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"), "deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"),
"deploymentkey.abgebrochen": a("Rotation des Deployment-Keys abgebrochen", "widerruf"),
"deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"), "deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"),
// Widerruf und Rotation (#023, #024) // Widerruf und Rotation (#023, #024)
@@ -86,6 +99,7 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"), "schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"), "schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"), "schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
"schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"),
// Zielkonten (#018) // Zielkonten (#018)
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"), "zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
+17
View File
@@ -35,3 +35,20 @@ export function rotationsPfad(pfad: string, datumKompakt: string, belegt: Readon
for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`; for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`;
return kandidat; return kandidat;
} }
/** Laedt nur den juengsten Nachfolger – nach einem Abbruch kann es mehrere geben (#033). */
export const JUENGSTER = { orderBy: { angelegtAm: "desc" }, take: 1 } as const;
/**
* Macht aus `nachfolgerListe` (hoechstens ein Element, siehe JUENGSTER) wieder `nachfolger`.
* Massgeblich ist immer der juengste: ein laufender oder – wenn keiner laeuft – der zuletzt
* abgebrochene.
*/
export type MitNachfolger<T extends { nachfolgerListe: readonly unknown[] }> = Omit<T, "nachfolgerListe"> & {
nachfolger: T["nachfolgerListe"][number] | null;
};
export function mitNachfolger<T extends { nachfolgerListe: readonly unknown[] }>(s: T): MitNachfolger<T> {
const { nachfolgerListe, ...rest } = s;
return { ...rest, nachfolger: nachfolgerListe[0] ?? null };
}
+11
View File
@@ -293,3 +293,14 @@ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): st
const [typ, base64] = zeile.trim().split(/\s+/); const [typ, base64] = zeile.trim().split(/\s+/);
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`; return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
} }
/**
* IDs aus einem Suchparameter (`?id=a&id=b` oder `?id=a,b`), etwa fuer den Massenwiderruf (#024).
* Erlaubt Bindestriche: Vom Worker erzeugte Schluessel tragen eine UUID, nur Prisma-Defaults sind
* cuids – ohne Bindestrich fielen alle erzeugten Schluessel still aus der Auswahl (#032).
*/
export function idsAusSuchparameter(wert: string | string[] | undefined, hoechstens = 200): string[] {
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
const gueltig = roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9-]{1,64}$/i.test(w));
return [...new Set(gueltig)].slice(0, hoechstens);
}
+2 -2
View File
@@ -21,7 +21,7 @@ export const SITZUNG_COOKIE = "sshkv_sitzung";
*/ */
export const SITZUNG_LAUFZEIT_STUNDEN = 8; export const SITZUNG_LAUFZEIT_STUNDEN = 8;
export type SitzungsHerkunft = "notfall" | "oidc"; export type SitzungsHerkunft = "notfall" | "lokal" | "oidc";
export type Sitzung = { export type Sitzung = {
benutzerId: string; benutzerId: string;
@@ -45,7 +45,7 @@ const ALGORITHMUS = "HS256";
const MINDESTLAENGE = 32; const MINDESTLAENGE = 32;
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "oidc"]; const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "lokal", "oidc"];
export class SitzungsFehler extends Error { export class SitzungsFehler extends Error {
constructor(nachricht: string, optionen?: ErrorOptions) { constructor(nachricht: string, optionen?: ErrorOptions) {
+85 -31
View File
@@ -7,16 +7,21 @@ import { totpGeheimnisErzeugen } from "@/lib/totp";
import { z } from "@/lib/validierung"; import { z } from "@/lib/validierung";
import { sicheresZiel, sitzungSetzen, ueberHttps } from "@/server/auth/anmeldung"; import { sicheresZiel, sitzungSetzen, ueberHttps } from "@/server/auth/anmeldung";
import { angemeldet } from "@/server/auth/sitzungspflicht"; import { angemeldet } from "@/server/auth/sitzungspflicht";
import type { Benutzer } from "@/generated/prisma/client";
import { import {
EINRICHTUNG_SEKUNDEN, EINRICHTUNG_SEKUNDEN,
EINRICHTUNG_ZWECK, EINRICHTUNG_ZWECK,
PASSWORT_SETZEN_COOKIE,
PASSWORT_SETZEN_ZWECK,
TOTP_EINRICHTUNG_COOKIE, TOTP_EINRICHTUNG_COOKIE,
passwortSetzenLesen,
totpEinrichtungLesen, totpEinrichtungLesen,
} from "@/server/auth/totp-einrichtung"; } from "@/server/auth/totp-einrichtung";
import { import {
AnmeldeFehler, AnmeldeFehler,
notfallAnmelden, eigenesPasswortSetzen,
notfallBestaetigen, kontoAnmelden,
kontoBestaetigen,
totpEinrichtungAbschliessen, totpEinrichtungAbschliessen,
} from "@/server/services/anmeldung"; } from "@/server/services/anmeldung";
@@ -27,48 +32,97 @@ import {
*/ */
export type FormularZustand = { readonly fehler?: string }; export type FormularZustand = { readonly fehler?: string };
const notfallSchema = z.object({ const kontoSchema = z.object({
anmeldename: z.string().trim().min(1).max(64), anmeldename: z.string().trim().min(1).max(64),
passwort: z.string().min(1).max(256), passwort: z.string().min(1).max(256),
code: z.string().trim().max(10).default(""), code: z.string().trim().max(10).default(""),
ziel: z.string().max(512).optional(), ziel: z.string().max(512).optional(),
}); });
export async function notfallAnmeldenAktion( const herkunft = (benutzer: Benutzer) => (benutzer.istNotfall ? "notfall" : "lokal");
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const eingabe = notfallSchema.safeParse(Object.fromEntries(formular));
if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." };
const ziel = sicheresZiel(eingabe.data.ziel);
let ergebnis; /** Kurzlebige httpOnly-Cookies fuer die Einrichtungsschritte – nur auf /anmelden gueltig. */
try { async function einrichtungsCookie(name: string, token: string) {
ergebnis = await notfallAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code); (await cookies()).set(name, token, {
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
throw fehler;
}
if (ergebnis.art === "totpEinrichten") {
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
const token = await kurzzeitTokenErzeugen(
EINRICHTUNG_ZWECK,
{ benutzerId: ergebnis.benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
EINRICHTUNG_SEKUNDEN,
);
(await cookies()).set(TOTP_EINRICHTUNG_COOKIE, token, {
httpOnly: true, httpOnly: true,
sameSite: "strict", sameSite: "strict",
secure: ueberHttps(), secure: ueberHttps(),
path: "/anmelden", path: "/anmelden",
maxAge: EINRICHTUNG_SEKUNDEN, maxAge: EINRICHTUNG_SEKUNDEN,
}); });
}
async function totpEinrichtungStarten(benutzer: Benutzer, ziel: string): Promise<never> {
// Das neue Geheimnis lebt bis zur Bestaetigung nur in einem signierten, kurzlebigen
// httpOnly-Cookie dieses Browsers – gespeichert wird es erst, wenn ein Code passt.
const token = await kurzzeitTokenErzeugen(
EINRICHTUNG_ZWECK,
{ benutzerId: benutzer.id, geheimnis: totpGeheimnisErzeugen(), ziel },
EINRICHTUNG_SEKUNDEN,
);
await einrichtungsCookie(TOTP_EINRICHTUNG_COOKIE, token);
redirect("/anmelden/totp"); redirect("/anmelden/totp");
}
export async function kontoAnmeldenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const eingabe = kontoSchema.safeParse(Object.fromEntries(formular));
if (!eingabe.success) return { fehler: "Bitte Anmeldename und Passwort angeben." };
const ziel = sicheresZiel(eingabe.data.ziel);
let ergebnis;
try {
ergebnis = await kontoAnmelden(eingabe.data.anmeldename, eingabe.data.passwort, eingabe.data.code);
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
throw fehler;
} }
await sitzungSetzen(ergebnis.benutzer, "notfall"); if (ergebnis.art === "passwortSetzen") {
const token = await kurzzeitTokenErzeugen(
PASSWORT_SETZEN_ZWECK,
{ benutzerId: ergebnis.benutzer.id, ziel },
EINRICHTUNG_SEKUNDEN,
);
await einrichtungsCookie(PASSWORT_SETZEN_COOKIE, token);
redirect("/anmelden/passwort");
}
if (ergebnis.art === "totpEinrichten") await totpEinrichtungStarten(ergebnis.benutzer, ziel);
await sitzungSetzen(ergebnis.benutzer, herkunft(ergebnis.benutzer));
redirect(ziel);
}
const passwortSchema = z.object({
passwort: z.string().max(256),
wiederholung: z.string().max(256),
});
/** Einmal-Passwort ersetzen (#035); danach geht es zur TOTP-Einrichtung. */
export async function passwortSetzenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const ausstehend = await passwortSetzenLesen();
if (!ausstehend)
return { fehler: "Die Sitzung zum Passwortwechsel ist abgelaufen. Bitte erneut anmelden." };
const eingabe = passwortSchema.safeParse(Object.fromEntries(formular));
if (!eingabe.success) return { fehler: "Bitte das neue Passwort zweimal eingeben." };
if (eingabe.data.passwort !== eingabe.data.wiederholung)
return { fehler: "Die beiden Eingaben stimmen nicht überein." };
let benutzer;
try {
benutzer = await eigenesPasswortSetzen(ausstehend.benutzerId, eingabe.data.passwort);
} catch (fehler) {
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
throw fehler;
}
(await cookies()).delete({ name: PASSWORT_SETZEN_COOKIE, path: "/anmelden" });
const ziel = sicheresZiel(ausstehend.ziel);
if (!benutzer.totpEingerichtetAm) await totpEinrichtungStarten(benutzer, ziel);
await sitzungSetzen(benutzer, herkunft(benutzer));
redirect(ziel); redirect(ziel);
} }
@@ -88,7 +142,7 @@ export async function totpEinrichtenAktion(
} }
const laden = await cookies(); const laden = await cookies();
laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" }); laden.delete({ name: TOTP_EINRICHTUNG_COOKIE, path: "/anmelden" });
await sitzungSetzen(benutzer, "notfall"); await sitzungSetzen(benutzer, herkunft(benutzer));
redirect(sicheresZiel(einrichtung.ziel)); redirect(sicheresZiel(einrichtung.ziel));
} }
@@ -98,7 +152,7 @@ const bestaetigenSchema = z.object({
ziel: z.string().max(512).optional(), ziel: z.string().max(512).optional(),
}); });
export async function notfallBestaetigenAktion( export async function kontoBestaetigenAktion(
_vorher: FormularZustand, _vorher: FormularZustand,
formular: FormData, formular: FormData,
): Promise<FormularZustand> { ): Promise<FormularZustand> {
@@ -107,7 +161,7 @@ export async function notfallBestaetigenAktion(
const eingabe = bestaetigenSchema.safeParse(Object.fromEntries(formular)); const eingabe = bestaetigenSchema.safeParse(Object.fromEntries(formular));
if (!eingabe.success) return { fehler: "Bitte Passwort und Code angeben." }; if (!eingabe.success) return { fehler: "Bitte Passwort und Code angeben." };
try { try {
await notfallBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code); await kontoBestaetigen(vorhanden.benutzer, eingabe.data.passwort, eingabe.data.code);
} catch (fehler) { } catch (fehler) {
if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message }; if (fehler instanceof AnmeldeFehler) return { fehler: fehler.message };
throw fehler; throw fehler;
+82
View File
@@ -0,0 +1,82 @@
"use server";
import { revalidatePath } from "next/cache";
import { anmeldenameSchema, kontoNameSchema, kontoRolleSchema } from "@/lib/lokale-konten";
import { z } from "@/lib/validierung";
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
kontoAktivSetzen,
kontoZuruecksetzen,
lokalesKontoAnlegen,
rolleAendern,
} from "@/server/services/benutzer";
/** Benutzerverwaltung (#035). Duenn: Recht mit Re-Auth, Eingabe, Service. */
const PFAD = "/einstellungen/benutzer";
const id = z.string().min(1).max(64);
/** Das Einmal-Passwort geht genau einmal an den Browser des Admins – nie ins Protokoll. */
export type KontoZustand = FormularZustand & {
readonly einmalPasswort?: string;
readonly anmeldename?: string;
};
export async function kontoAnlegenAktion(_vorher: KontoZustand, formular: FormData): Promise<KontoZustand> {
try {
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
const eingabe = z
.object({ anmeldename: anmeldenameSchema, name: kontoNameSchema, rolle: kontoRolleSchema })
.safeParse({
anmeldename: textfeld(formular, "anmeldename") ?? "",
name: textfeld(formular, "name") ?? "",
rolle: textfeld(formular, "rolle") ?? "LESER",
});
if (!eingabe.success) {
const feldfehler: Record<string, string> = {};
for (const fehler of eingabe.error.issues) feldfehler[String(fehler.path[0])] ??= fehler.message;
return { feldfehler };
}
const { einmalPasswort } = await lokalesKontoAnlegen(eingabe.data, akteurVon(benutzer));
revalidatePath(PFAD);
return { einmalPasswort, anmeldename: eingabe.data.anmeldename };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function rolleAendernAktion(kontoId: string, rolle: string): Promise<{ fehler?: string }> {
try {
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
await rolleAendern(id.parse(kontoId), kontoRolleSchema.parse(rolle), akteurVon(benutzer));
revalidatePath(PFAD);
return {};
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function aktivSetzenAktion(kontoId: string, aktiv: boolean): Promise<{ fehler?: string }> {
try {
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
await kontoAktivSetzen(id.parse(kontoId), z.boolean().parse(aktiv), akteurVon(benutzer));
revalidatePath(PFAD);
return {};
} catch (fehler) {
return fehlerZustand(fehler);
}
}
export async function zuruecksetzenAktion(
kontoId: string,
): Promise<{ fehler?: string; einmalPasswort?: string }> {
try {
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
const { einmalPasswort } = await kontoZuruecksetzen(id.parse(kontoId), akteurVon(benutzer));
revalidatePath(PFAD);
return { einmalPasswort };
} catch (fehler) {
return fehlerZustand(fehler);
}
}
+2 -2
View File
@@ -21,11 +21,11 @@ function alsAntwort(fehler: unknown): { fehler: string } {
} }
export async function rotationsSchrittAktion( export async function rotationsSchrittAktion(
schritt: "erzeugen" | "pruefen" | "abschliessen", schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen",
): Promise<VorgangsAntwort> { ): Promise<VorgangsAntwort> {
try { try {
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten"); const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
const geprueft = z.enum(["erzeugen", "pruefen", "abschliessen"]).parse(schritt); const geprueft = z.enum(["erzeugen", "pruefen", "abbrechen", "abschliessen"]).parse(schritt);
return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) }; return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) };
} catch (fehler) { } catch (fehler) {
return alsAntwort(fehler); return alsAntwort(fehler);
+25 -1
View File
@@ -6,7 +6,12 @@ import { akteurVon } from "@/server/actions/formular";
import { darf } from "@/server/auth/rechte"; import { darf } from "@/server/auth/rechte";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { FachFehler } from "@/server/services/fachfehler"; import { FachFehler } from "@/server/services/fachfehler";
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation"; import {
rotationAbbrechen,
rotationAltWiderrufen,
rotationStarten,
rotationUebernehmen,
} from "@/server/services/rotation";
import { VerwendungsFehler } from "@/server/services/verwendungen"; import { VerwendungsFehler } from "@/server/services/verwendungen";
/** /**
@@ -69,3 +74,22 @@ export async function rotationAltWiderrufenAktion(schluesselId: string): Promise
return alsAntwort(fehler); return alsAntwort(fehler);
} }
} }
/**
* Laufende Rotation abbrechen (#032): merkt den neuen Schluessel zum Widerruf vor. Gleiche Rechte
* wie die Rotation selbst; Private Keys entfernt nur, wer sie ausrollen darf (Service).
*/
export async function rotationAbbrechenAktion(schluesselId: string): Promise<RotationsAntwort> {
try {
const { benutzer } = await berechtigt();
const altId = id.parse(schluesselId);
await rotationAbbrechen(altId, akteurVon(benutzer), {
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
});
revalidatePath(`/schluessel/${altId}`);
revalidatePath(`/schluessel/${altId}/rotieren`);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
+6 -1
View File
@@ -20,7 +20,8 @@ export type Recht =
| "deploymentKeyVerwalten" | "deploymentKeyVerwalten"
| "massenwiderruf" | "massenwiderruf"
| "zielkontenVerwalten" | "zielkontenVerwalten"
| "einstellungenVerwalten"; | "einstellungenVerwalten"
| "benutzerVerwalten";
export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = { export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
lesen: ["ADMIN", "OPERATOR", "LESER"], lesen: ["ADMIN", "OPERATOR", "LESER"],
@@ -40,6 +41,9 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
massenwiderruf: ["ADMIN"], massenwiderruf: ["ADMIN"],
zielkontenVerwalten: ["ADMIN"], zielkontenVerwalten: ["ADMIN"],
einstellungenVerwalten: ["ADMIN"], einstellungenVerwalten: ["ADMIN"],
// Lokale Konten anlegen, Rollen vergeben, zuruecksetzen (#035) – wer das darf, kann sich jede
// Rolle verschaffen; deshalb nur Admins und nur mit Re-Auth.
benutzerVerwalten: ["ADMIN"],
}; };
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */ /** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
@@ -49,6 +53,7 @@ export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
"deploymentKeyVerwalten", "deploymentKeyVerwalten",
"massenwiderruf", "massenwiderruf",
"zielkontenVerwalten", "zielkontenVerwalten",
"benutzerVerwalten",
]); ]);
export function darf(rolle: Rolle, recht: Recht): boolean { export function darf(rolle: Rolle, recht: Recht): boolean {
+8 -3
View File
@@ -5,6 +5,7 @@ import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung"; import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
import { BESTAETIGUNG_NOETIG, darf, type Recht } from "@/server/auth/rechte"; import { BESTAETIGUNG_NOETIG, darf, type Recht } from "@/server/auth/rechte";
import { passwortKonto } from "@/server/services/anmeldung";
/** /**
* Wer ist angemeldet und was darf er (#004, Muster aus Laufzettel). * Wer ist angemeldet und was darf er (#004, Muster aus Laufzettel).
@@ -74,9 +75,13 @@ export const angemeldet = cache(async (): Promise<Angemeldet | undefined> => {
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } }); const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
const urteil = sitzungBewerten(sitzung, benutzer); const urteil = sitzungBewerten(sitzung, benutzer);
if (!urteil.gueltig || !benutzer) return undefined; if (!urteil.gueltig || !benutzer) return undefined;
// Ein Notfall-Admin ohne eingerichtetes TOTP ist nur halb angemeldet und kommt nicht // Ein Passwort-Konto ohne eigenes Passwort oder ohne TOTP ist nur halb angemeldet und kommt
// ueber die Einrichtung hinaus – siehe /anmelden/totp. // nicht ueber die Einrichtung hinaus (/anmelden/passwort, /anmelden/totp). Abgeschaltete lokale
if (benutzer.istNotfall && !benutzer.totpEingerichtetAm) return undefined; // Konten verlieren ihre Sitzung sofort (#035).
if (benutzer.passwortHash && !benutzer.oidcSub) {
if (!passwortKonto(benutzer) || benutzer.passwortWechselNoetig || !benutzer.totpEingerichtetAm)
return undefined;
}
return { benutzer, sitzung }; return { benutzer, sitzung };
}); });
+14
View File
@@ -18,3 +18,17 @@ export async function totpEinrichtungLesen(): Promise<
if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined; if (!werte?.["benutzerId"] || !werte["geheimnis"]) return undefined;
return { benutzerId: werte["benutzerId"], geheimnis: werte["geheimnis"], ziel: werte["ziel"] ?? "/" }; return { benutzerId: werte["benutzerId"], geheimnis: werte["geheimnis"], ziel: werte["ziel"] ?? "/" };
} }
/**
* Ausstehender Passwortwechsel eines lokalen Kontos (#035): ausgestellt nur nach einem
* richtigen Einmal-Passwort, gilt so lange wie die TOTP-Einrichtung.
*/
export const PASSWORT_SETZEN_COOKIE = "sshkv_passwort_setzen";
export const PASSWORT_SETZEN_ZWECK = "passwort-setzen";
export async function passwortSetzenLesen(): Promise<{ benutzerId: string; ziel: string } | undefined> {
const token = (await cookies()).get(PASSWORT_SETZEN_COOKIE)?.value;
const werte = await kurzzeitTokenLesen(PASSWORT_SETZEN_ZWECK, token);
if (!werte?.["benutzerId"]) return undefined;
return { benutzerId: werte["benutzerId"], ziel: werte["ziel"] ?? "/" };
}
+35
View File
@@ -0,0 +1,35 @@
import { prisma } from "@/lib/db";
/** Lesemodell der Benutzerverwaltung (#035). Nie Hashes oder TOTP-Geheimnisse – nur Zustaende. */
export type KontoHerkunft = "notfall" | "lokal" | "oidc";
export async function benutzerListe() {
const konten = await prisma.benutzer.findMany({
orderBy: [{ aktiv: "desc" }, { name: "asc" }],
select: {
id: true,
name: true,
anmeldename: true,
email: true,
rolle: true,
aktiv: true,
istNotfall: true,
oidcSub: true,
passwortWechselNoetig: true,
totpEingerichtetAm: true,
letzteAnmeldungAm: true,
angelegtAm: true,
},
});
return konten.map(({ oidcSub, istNotfall, totpEingerichtetAm, passwortWechselNoetig, ...k }) => {
const herkunft: KontoHerkunft = istNotfall ? "notfall" : oidcSub ? "oidc" : "lokal";
return {
...k,
herkunft,
// Nur fuer Passwort-Konten von Bedeutung: noch nicht fertig eingerichtet.
einrichtungOffen: herkunft !== "oidc" && (passwortWechselNoetig || !totpEingerichtetAm),
};
});
}
export type BenutzerZeile = Awaited<ReturnType<typeof benutzerListe>>[number];
+11 -6
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums"; import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { JUENGSTER, mitNachfolger } from "@/lib/rotation";
/** /**
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen. * Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
@@ -58,8 +59,12 @@ export const SCHLUESSEL_DETAIL_SELECT = {
person: { select: { id: true, name: true } }, person: { select: { id: true, name: true } },
vorlage: { select: { id: true, name: true } }, vorlage: { select: { id: true, name: true } },
erstelltVon: { select: { name: true } }, erstelltVon: { select: { name: true } },
vorgaenger: { select: { id: true, name: true, status: true } }, vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
nachfolger: { select: { id: true, name: true, status: true } }, // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032).
nachfolgerListe: {
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
...JUENGSTER,
},
publicVerwendungen: { publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } }, where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" }, orderBy: { angelegtAm: "asc" },
@@ -163,7 +168,7 @@ export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>
export async function schluesselDetail(id: string) { export async function schluesselDetail(id: string) {
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT }); const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
if (!schluessel) return null; if (!schluessel) return null;
const { masterKeyVersion, ...rest } = schluessel; const { masterKeyVersion, ...rest } = mitNachfolger(schluessel);
return { ...rest, privatGespeichert: masterKeyVersion !== null }; return { ...rest, privatGespeichert: masterKeyVersion !== null };
} }
@@ -209,7 +214,7 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
name: true, name: true,
status: true, status: true,
vorgaengerId: true, vorgaengerId: true,
nachfolger: { select: { id: true } }, nachfolgerListe: { select: { id: true }, ...JUENGSTER },
}, },
}); });
const start = await glied(id); const start = await glied(id);
@@ -223,12 +228,12 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
kette.unshift({ id: g.id, name: g.name, status: g.status }); kette.unshift({ id: g.id, name: g.name, status: g.status });
davor = g.vorgaengerId; davor = g.vorgaengerId;
} }
for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) { for (let danach = start.nachfolgerListe[0]?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
const g = await glied(danach); const g = await glied(danach);
if (!g) break; if (!g) break;
gesehen.add(g.id); gesehen.add(g.id);
kette.push({ id: g.id, name: g.name, status: g.status }); kette.push({ id: g.id, name: g.name, status: g.status });
danach = g.nachfolger?.id; danach = g.nachfolgerListe[0]?.id;
} }
return kette; return kette;
} }
+75 -29
View File
@@ -1,7 +1,8 @@
import type { Benutzer } from "@/generated/prisma/client"; import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { passwortPruefen } from "@/lib/passwort-hash"; import { lokaleKontenAktiv, passwortFehler } from "@/lib/lokale-konten";
import { passwortHashen, passwortPruefen } from "@/lib/passwort-hash";
import { totpPruefen } from "@/lib/totp"; import { totpPruefen } from "@/lib/totp";
import type { OidcAnspruecke } from "@/server/auth/oidc"; import type { OidcAnspruecke } from "@/server/auth/oidc";
import { rolleAusGruppen } from "@/server/auth/oidc"; import { rolleAusGruppen } from "@/server/auth/oidc";
@@ -9,7 +10,7 @@ import { protokollieren } from "@/server/services/protokoll";
import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung"; import { versuchZaehlen, zaehlerLoeschen } from "@/server/services/ratenbegrenzung";
/** /**
* Anmeldelogik ohne Next (#004): OIDC-Konto uebernehmen, Notfall-Admin pruefen, TOTP * Anmeldelogik ohne Next (#004, #035): OIDC-Konto uebernehmen, Passwort-Konten pruefen, TOTP
* einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie. * einrichten, erneute Bestaetigung. Die Next-Schicht setzt danach nur noch das Cookie.
*/ */
@@ -27,8 +28,8 @@ export class AnmeldeFehler extends Error {
} }
/** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */ /** Fuenf Fehlversuche je Anmeldename in 15 Minuten. */
const NOTFALL_VERSUCHE = 5; const KONTO_VERSUCHE = 5;
const NOTFALL_FENSTER_SEKUNDEN = 15 * 60; const KONTO_FENSTER_SEKUNDEN = 15 * 60;
/** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */ /** Allgemeine Meldung – verraet nicht, ob der Name, das Passwort oder der Code falsch war. */
const FALSCHE_ANGABEN = "Anmeldung fehlgeschlagen. Bitte Angaben prüfen."; const FALSCHE_ANGABEN = "Anmeldung fehlgeschlagen. Bitte Angaben prüfen.";
@@ -115,53 +116,67 @@ export async function oidcBestaetigungVermerken(
}); });
} }
// ---------------------------------------------------------------- Notfall-Admin // ---------------------------------------------------------------- Lokale Konten
export type NotfallErgebnis = /**
* Passwort-Konten: der Notfall-Admin (#004) und von Admins angelegte lokale Konten (#035).
* Beide melden sich mit Anmeldename, Passwort und Pflicht-TOTP an.
*/
export type KontoErgebnis =
| { readonly art: "angemeldet"; readonly benutzer: Benutzer } | { readonly art: "angemeldet"; readonly benutzer: Benutzer }
| { readonly art: "passwortSetzen"; readonly benutzer: Benutzer }
| { readonly art: "totpEinrichten"; readonly benutzer: Benutzer }; | { readonly art: "totpEinrichten"; readonly benutzer: Benutzer };
/** Darf sich dieses Konto mit Passwort anmelden? OIDC-Konten nie, lokale nur wenn eingeschaltet. */
export function passwortKonto(benutzer: Pick<Benutzer, "passwortHash" | "oidcSub" | "istNotfall">): boolean {
return Boolean(benutzer.passwortHash) && !benutzer.oidcSub && (benutzer.istNotfall || lokaleKontenAktiv());
}
/** Protokoll-Praefix: der Notfall-Admin bleibt im Audit-Log als solcher erkennbar. */
const praefix = (benutzer: Pick<Benutzer, "istNotfall"> | null) =>
benutzer?.istNotfall ? "anmeldung.notfall" : "anmeldung.lokal";
/** /**
* Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code. * Prueft Anmeldename, Passwort und – sobald eingerichtet – den TOTP-Code.
* *
* Ohne eingerichtetes TOTP fuehrt ein richtiges Passwort nur zur Einrichtung, nie direkt * Ein Einmal-Passwort fuehrt nur zum Setzen eines eigenen, ein Konto ohne TOTP nur zur
* in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht). * Einrichtung – nie direkt in die Anwendung (Freigabe Frage 5: TOTP ist Pflicht).
*/ */
export async function notfallAnmelden( export async function kontoAnmelden(
anmeldename: string, anmeldename: string,
passwort: string, passwort: string,
code: string, code: string,
jetzt = new Date(), jetzt = new Date(),
): Promise<NotfallErgebnis> { ): Promise<KontoErgebnis> {
const zaehler = `notfall:${anmeldename.toLowerCase()}`; const name = anmeldename.trim().toLowerCase();
const versuch = await versuchZaehlen(zaehler, NOTFALL_VERSUCHE, NOTFALL_FENSTER_SEKUNDEN); const zaehler = `konto:${name}`;
const versuch = await versuchZaehlen(zaehler, KONTO_VERSUCHE, KONTO_FENSTER_SEKUNDEN);
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename: name } });
if (!versuch.erlaubt) { if (!versuch.erlaubt) {
await protokollieren({ await protokollieren({
akteur: "System", akteur: "System",
aktion: "anmeldung.notfall.gesperrt", aktion: `${praefix(benutzer)}.gesperrt`,
ergebnis: "VERWEIGERT", ergebnis: "VERWEIGERT",
details: { anmeldename }, details: { anmeldename: name },
}); });
throw new AnmeldeFehler( throw new AnmeldeFehler(
`Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`, `Zu viele Fehlversuche. Bitte in ${Math.ceil(versuch.wiederInSekunden / 60)} Minuten erneut versuchen.`,
); );
} }
const benutzer = await prisma.benutzer.findUnique({ where: { anmeldename } });
const passwortStimmt = const passwortStimmt =
benutzer?.istNotfall && benutzer.passwortHash benutzer && passwortKonto(benutzer) ? await passwortPruefen(benutzer.passwortHash!, passwort) : false;
? await passwortPruefen(benutzer.passwortHash, passwort)
: false;
if (!benutzer || !passwortStimmt || !benutzer.aktiv) { if (!benutzer || !passwortStimmt || !benutzer.aktiv) {
await protokollieren({ await protokollieren({
akteur: "System", akteur: "System",
aktion: "anmeldung.notfall.fehlgeschlagen", aktion: `${praefix(benutzer)}.fehlgeschlagen`,
ergebnis: "VERWEIGERT", ergebnis: "VERWEIGERT",
details: { anmeldename }, details: { anmeldename: name },
}); });
throw new AnmeldeFehler(FALSCHE_ANGABEN); throw new AnmeldeFehler(FALSCHE_ANGABEN);
} }
if (benutzer.passwortWechselNoetig) return { art: "passwortSetzen", benutzer };
if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) { if (!benutzer.totpGeheimnis || !benutzer.totpEingerichtetAm) {
return { art: "totpEinrichten", benutzer }; return { art: "totpEinrichten", benutzer };
} }
@@ -175,7 +190,7 @@ export async function notfallAnmelden(
if (schritt === undefined) { if (schritt === undefined) {
await protokollieren({ await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name }, akteur: { id: benutzer.id, name: benutzer.name },
aktion: "anmeldung.notfall.totp_falsch", aktion: `${praefix(benutzer)}.totp_falsch`,
ergebnis: "VERWEIGERT", ergebnis: "VERWEIGERT",
}); });
throw new AnmeldeFehler(FALSCHE_ANGABEN); throw new AnmeldeFehler(FALSCHE_ANGABEN);
@@ -188,16 +203,42 @@ export async function notfallAnmelden(
}); });
await protokollieren({ await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name }, akteur: { id: benutzer.id, name: benutzer.name },
aktion: "anmeldung.notfall", aktion: praefix(benutzer),
ergebnis: "ERFOLG", ergebnis: "ERFOLG",
}); });
return { art: "angemeldet", benutzer: aktualisiert }; return { art: "angemeldet", benutzer: aktualisiert };
} }
/**
* Ersetzt das Einmal-Passwort durch ein eigenes (#035). Nur moeglich, solange der Wechsel
* aussteht – das Kurzzeit-Token dafuer stellt allein ein richtiges Einmal-Passwort aus.
*/
export async function eigenesPasswortSetzen(benutzerId: string, neu: string): Promise<Benutzer> {
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
if (!benutzer || !passwortKonto(benutzer) || !benutzer.passwortWechselNoetig || !benutzer.aktiv) {
throw new AnmeldeFehler("Für dieses Konto steht kein Passwortwechsel aus.");
}
const fehler = passwortFehler(neu, benutzer.anmeldename);
if (fehler) throw new AnmeldeFehler(fehler);
if (await passwortPruefen(benutzer.passwortHash!, neu)) {
throw new AnmeldeFehler("Bitte ein anderes Passwort als das Einmal-Passwort wählen.");
}
const aktualisiert = await prisma.benutzer.update({
where: { id: benutzerId },
data: { passwortHash: await passwortHashen(neu), passwortWechselNoetig: false },
});
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "konto.passwort_gesetzt",
ergebnis: "ERFOLG",
});
return aktualisiert;
}
/** /**
* Schliesst die TOTP-Einrichtung ab. Das Geheimnis kommt aus dem kurzlebigen * Schliesst die TOTP-Einrichtung ab. Das Geheimnis kommt aus dem kurzlebigen
* Einrichtungs-Token und wird erst gespeichert, wenn ein Code dazu passt – sonst saesse * Einrichtungs-Token und wird erst gespeichert, wenn ein Code dazu passt – sonst saesse
* der Notfall-Admin mit einem Geheimnis fest, das in keiner App steht. * das Konto mit einem Geheimnis fest, das in keiner App steht.
*/ */
export async function totpEinrichtungAbschliessen( export async function totpEinrichtungAbschliessen(
benutzerId: string, benutzerId: string,
@@ -206,7 +247,12 @@ export async function totpEinrichtungAbschliessen(
jetzt = new Date(), jetzt = new Date(),
): Promise<Benutzer> { ): Promise<Benutzer> {
const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } }); const benutzer = await prisma.benutzer.findUnique({ where: { id: benutzerId } });
if (!benutzer?.istNotfall || benutzer.totpEingerichtetAm) { if (
!benutzer ||
!passwortKonto(benutzer) ||
benutzer.totpEingerichtetAm ||
benutzer.passwortWechselNoetig
) {
throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet."); throw new AnmeldeFehler("TOTP ist für dieses Konto bereits eingerichtet.");
} }
const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime()); const schritt = totpPruefen(geheimnisBase32, code, jetzt.getTime());
@@ -227,18 +273,18 @@ export async function totpEinrichtungAbschliessen(
}); });
await protokollieren({ await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name }, akteur: { id: benutzer.id, name: benutzer.name },
aktion: "notfall.totp_eingerichtet", aktion: benutzer.istNotfall ? "notfall.totp_eingerichtet" : "konto.totp_eingerichtet",
ergebnis: "ERFOLG", ergebnis: "ERFOLG",
}); });
return aktualisiert; return aktualisiert;
} }
/** Erneute Bestaetigung des Notfall-Admins: Passwort und TOTP noch einmal. */ /** Erneute Bestaetigung eines Passwort-Kontos: Passwort und TOTP noch einmal. */
export async function notfallBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> { export async function kontoBestaetigen(benutzer: Benutzer, passwort: string, code: string): Promise<void> {
if (!benutzer.istNotfall || !benutzer.anmeldename) { if (!passwortKonto(benutzer) || !benutzer.anmeldename) {
throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter."); throw new AnmeldeFehler("Dieses Konto bestätigt sich über den Identitätsanbieter.");
} }
const ergebnis = await notfallAnmelden(benutzer.anmeldename, passwort, code); const ergebnis = await kontoAnmelden(benutzer.anmeldename, passwort, code);
if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) { if (ergebnis.art !== "angemeldet" || ergebnis.benutzer.id !== benutzer.id) {
throw new AnmeldeFehler(FALSCHE_ANGABEN); throw new AnmeldeFehler(FALSCHE_ANGABEN);
} }
+152
View File
@@ -0,0 +1,152 @@
import type { Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { einmalPasswortErzeugen, lokaleKontenAktiv } from "@/lib/lokale-konten";
import { passwortHashen } from "@/lib/passwort-hash";
import { FachFehler } from "@/server/services/fachfehler";
import { protokollieren } from "@/server/services/protokoll";
/**
* Benutzerverwaltung (#035). Lokale Konten legt ein Admin an; sshkv erzeugt ein Einmal-Passwort,
* das genau einmal angezeigt wird – Admins waehlen also nie Passwoerter fuer andere. Bei der
* ersten Anmeldung setzt der Benutzer ein eigenes Passwort und richtet TOTP ein.
*
* OIDC-Konten bekommen ihre Rolle aus den Gruppen des Anbieters; hier lassen sie sich nur
* sperren und entsperren. Der Notfall-Admin ist unantastbar – er ist der Weg zurueck, wenn
* alles andere versagt.
*/
type Akteur = { readonly id: string; readonly name: string };
const KONTO = {
id: true,
name: true,
anmeldename: true,
rolle: true,
aktiv: true,
istNotfall: true,
oidcSub: true,
passwortHash: true,
} as const;
async function kontoLaden(id: string) {
const konto = await prisma.benutzer.findUnique({ where: { id }, select: KONTO });
if (!konto) throw new FachFehler("Dieses Konto gibt es nicht.");
return konto;
}
function lokal(konto: { istNotfall: boolean; oidcSub: string | null; passwortHash: string | null }) {
return !konto.istNotfall && !konto.oidcSub && Boolean(konto.passwortHash);
}
function nichtSelbst(konto: { id: string }, akteur: Akteur, was: string) {
if (konto.id === akteur.id) throw new FachFehler(`Das eigene Konto lässt sich nicht ${was}.`);
}
function kontenErlaubt() {
if (!lokaleKontenAktiv()) {
throw new FachFehler("Lokale Konten sind abgeschaltet (SSHKV_LOKALE_KONTEN=0).");
}
}
export async function lokalesKontoAnlegen(
eingabe: { anmeldename: string; name: string; rolle: Rolle },
akteur: Akteur,
): Promise<{ id: string; einmalPasswort: string }> {
kontenErlaubt();
if (
await prisma.benutzer.findUnique({ where: { anmeldename: eingabe.anmeldename }, select: { id: true } })
) {
throw new FachFehler("Diesen Anmeldenamen gibt es schon.", { anmeldename: "Bereits vergeben." });
}
const einmalPasswort = einmalPasswortErzeugen();
const konto = await prisma.benutzer.create({
data: {
anmeldename: eingabe.anmeldename,
name: eingabe.name,
rolle: eingabe.rolle,
passwortHash: await passwortHashen(einmalPasswort),
passwortWechselNoetig: true,
},
select: { id: true },
});
await protokollieren({
akteur,
aktion: "benutzer.angelegt",
ergebnis: "ERFOLG",
details: { anmeldename: eingabe.anmeldename, name: eingabe.name, rolle: eingabe.rolle },
});
return { id: konto.id, einmalPasswort };
}
/** Rolle nur fuer lokale Konten – bei OIDC entscheidet die Gruppe im Anbieter. */
export async function rolleAendern(id: string, rolle: Rolle, akteur: Akteur): Promise<void> {
const konto = await kontoLaden(id);
if (!lokal(konto)) {
throw new FachFehler(
konto.istNotfall
? "Der Notfall-Admin bleibt Admin."
: "Die Rolle dieses Kontos kommt aus den Gruppen des Identitätsanbieters.",
);
}
nichtSelbst(konto, akteur, "herabstufen");
if (konto.rolle === rolle) return;
await prisma.benutzer.update({
where: { id },
// Neue Rolle gilt sofort; alte Sitzungen sollen nicht mit gespeicherter Bestaetigung weiterlaufen.
data: { rolle, bestaetigtAm: null },
});
await protokollieren({
akteur,
aktion: "benutzer.rolle_geaendert",
ergebnis: "ERFOLG",
details: { anmeldename: konto.anmeldename, name: konto.name, vorher: konto.rolle, nachher: rolle },
});
}
/** Sperren beendet alle Sitzungen sofort; gilt auch fuer OIDC-Konten. */
export async function kontoAktivSetzen(id: string, aktiv: boolean, akteur: Akteur): Promise<void> {
const konto = await kontoLaden(id);
if (konto.istNotfall) throw new FachFehler("Der Notfall-Admin lässt sich nicht sperren.");
nichtSelbst(konto, akteur, "sperren");
if (konto.aktiv === aktiv) return;
await prisma.benutzer.update({
where: { id },
data: aktiv ? { aktiv } : { aktiv, sitzungenGueltigAb: new Date(), bestaetigtAm: null },
});
await protokollieren({
akteur,
aktion: aktiv ? "benutzer.entsperrt" : "benutzer.gesperrt",
ergebnis: "ERFOLG",
details: { anmeldename: konto.anmeldename, name: konto.name },
});
}
/**
* Passwort und TOTP eines lokalen Kontos zuruecksetzen – etwa nach verlorenem Telefon. Es gibt
* ein neues Einmal-Passwort; alle Sitzungen enden.
*/
export async function kontoZuruecksetzen(id: string, akteur: Akteur): Promise<{ einmalPasswort: string }> {
kontenErlaubt();
const konto = await kontoLaden(id);
if (!lokal(konto)) throw new FachFehler("Nur lokale Konten lassen sich zurücksetzen.");
nichtSelbst(konto, akteur, "zurücksetzen");
const einmalPasswort = einmalPasswortErzeugen();
await prisma.benutzer.update({
where: { id },
data: {
passwortHash: await passwortHashen(einmalPasswort),
passwortWechselNoetig: true,
totpGeheimnis: null,
totpEingerichtetAm: null,
totpLetzterSchritt: null,
sitzungenGueltigAb: new Date(),
bestaetigtAm: null,
},
});
await protokollieren({
akteur,
aktion: "benutzer.zurueckgesetzt",
ergebnis: "ERFOLG",
details: { anmeldename: konto.anmeldename, name: konto.name },
});
return { einmalPasswort };
}
+15 -2
View File
@@ -85,11 +85,21 @@ export async function rotationsStand() {
: vorgaenger : vorgaenger
? "aufraeumen" ? "aufraeumen"
: "bereit"; : "bereit";
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung }; // Zuletzt abgebrochener Nachfolger des aktuellen Keys (#036): kann noch in Bloecken stehen,
// bis ein Dry-Run ihn entfernt.
const abgebrochen =
phase === "bereit" && aktuell
? await prisma.schluessel.findFirst({
where: { kategorie: "DEPLOYMENT", vorgaengerId: aktuell.id, status: "WIDERRUFEN" },
orderBy: { angelegtAm: "desc" },
select: { id: true, fingerprint: true },
})
: null;
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung, abgebrochen };
} }
export async function rotationsSchrittAnfordern( export async function rotationsSchrittAnfordern(
schritt: "erzeugen" | "pruefen" | "abschliessen", schritt: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen",
akteur: Akteur, akteur: Akteur,
): Promise<string> { ): Promise<string> {
const stand = await rotationsStand(); const stand = await rotationsStand();
@@ -98,6 +108,9 @@ export async function rotationsSchrittAnfordern(
} }
if (schritt === "pruefen" && !stand.nachfolger) if (schritt === "pruefen" && !stand.nachfolger)
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen."); throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
// Abbrechen nur vor dem Umstellen – danach meldet sich sshkv schon mit dem neuen Key an.
if (schritt === "abbrechen" && !stand.nachfolger)
throw new DeploymentKeyFehler("Es läuft keine Rotation, die sich abbrechen ließe.");
if (schritt === "abschliessen" && !stand.vorgaenger) if (schritt === "abschliessen" && !stand.vorgaenger)
throw new DeploymentKeyFehler("Es gibt nichts abzuschließen."); throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } }); return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
+84 -8
View File
@@ -1,6 +1,13 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation"; import {
JUENGSTER,
type MitNachfolger,
mitNachfolger,
rotationsDatum,
rotationsName,
rotationsPfad,
} from "@/lib/rotation";
import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel"; import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel";
import { eindeutigerSlug } from "@/server/services/schluessel"; import { eindeutigerSlug } from "@/server/services/schluessel";
import { protokollieren } from "@/server/services/protokoll"; import { protokollieren } from "@/server/services/protokoll";
@@ -45,11 +52,15 @@ const ALT_SELECT = {
vorlageId: true, vorlageId: true,
masterKeyVersion: true, masterKeyVersion: true,
widerrufEingeleitetAm: true, widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, name: true, status: true } }, nachfolgerListe: {
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
...JUENGSTER,
},
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } }, _count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
} satisfies Prisma.SchluesselSelect; } satisfies Prisma.SchluesselSelect;
type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>; type AltRoh = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
type Alt = MitNachfolger<AltRoh>;
/** /**
* Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder * Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder
@@ -77,7 +88,7 @@ export function rotationGesperrt(
async function altLaden(id: string): Promise<Alt> { async function altLaden(id: string): Promise<Alt> {
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT }); const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht."); if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
return alt; return mitNachfolger(alt);
} }
function gesperrtPruefen(alt: Alt) { function gesperrtPruefen(alt: Alt) {
@@ -92,7 +103,15 @@ function gesperrtPruefen(alt: Alt) {
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> { export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
const alt = await altLaden(altId); const alt = await altLaden(altId);
gesperrtPruefen(alt); gesperrtPruefen(alt);
if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger."); // Ein vollstaendig abgebrochener (widerrufener) Nachfolger bleibt in der Historie; danach darf
// ein neuer folgen (#033). Der partielle Index haelt die Kette trotzdem eindeutig.
if (alt.nachfolger && alt.nachfolger.status !== "WIDERRUFEN") {
throw new RotationsFehler(
rotationAbgebrochen(alt.nachfolger)
? "Der Abbruch der letzten Rotation läuft noch – bitte erst den neuen Schlüssel fertig entfernen (Dry-Run und Anwenden)."
: "Für diesen Schlüssel gibt es schon einen Nachfolger.",
);
}
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX); const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
const slug = await eindeutigerSlug(null, name); const slug = await eindeutigerSlug(null, name);
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen. // Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
@@ -111,8 +130,19 @@ export async function rotationStarten(altId: string, akteur: Akteur): Promise<st
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe }); return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
} }
/**
* Ein Nachfolger im Widerruf (oder widerrufen) heisst: die Rotation wurde abgebrochen (#032).
* Ein eigenes Feld braucht es dafuer nicht – der Widerruf des Nachfolgers ist der Abbruch.
*/
export function rotationAbgebrochen(
neu: { readonly status: string; readonly widerrufEingeleitetAm: Date | null } | null,
): boolean {
return neu !== null && (neu.status === "WIDERRUFEN" || neu.widerrufEingeleitetAm !== null);
}
async function nachfolgerVon(alt: Alt) { async function nachfolgerVon(alt: Alt) {
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen."); if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
if (rotationAbgebrochen(alt.nachfolger)) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv."); if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
return alt.nachfolger; return alt.nachfolger;
} }
@@ -254,8 +284,10 @@ export type RotationsStand = Awaited<ReturnType<typeof rotationsStand>>;
export async function rotationsStand(altId: string) { export async function rotationsStand(altId: string) {
const alt = await altLaden(altId); const alt = await altLaden(altId);
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen }); const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
const neu = alt.nachfolger; // Nach einem abgeschlossenen Abbruch beginnt die Rotation von vorn (#033).
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const }; const neu = alt.nachfolger?.status === "WIDERRUFEN" && alt.status !== "ROTIERT" ? null : alt.nachfolger;
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false };
const abgebrochen = rotationAbgebrochen(neu);
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([ const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }), prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
@@ -287,7 +319,7 @@ export async function rotationsStand(altId: string) {
: !testsGruen : !testsGruen
? (3 as const) ? (3 as const)
: (4 as const); : (4 as const);
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen }; return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen, abgebrochen };
} }
/** /**
@@ -302,6 +334,7 @@ export async function rotationAltWiderrufen(
const stand = await rotationsStand(altId); const stand = await rotationsStand(altId);
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen."); if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
if (stand.schritt === 5) return; if (stand.schritt === 5) return;
if (stand.abgebrochen) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
if (stand.schritt < 4) { if (stand.schritt < 4) {
throw new RotationsFehler( throw new RotationsFehler(
stand.schritt === 3 stand.schritt === 3
@@ -311,3 +344,46 @@ export async function rotationAltWiderrufen(
} }
await widerrufEinleiten([altId], akteur, optionen); await widerrufEinleiten([altId], akteur, optionen);
} }
/**
* Laufende Rotation abbrechen (#032): der neue Schluessel wird ueber den normalen Widerruf (#024)
* entfernt – mit Dry-Run, denn er kann schon ausgerollt sein. Der alte bleibt aktiv und
* unveraendert; wo beide denselben Alias tragen, zeigt die config nach dem Anwenden wieder auf
* die alte Datei (juengste *verbleibende* Zuweisung gewinnt, src/worker/ablaeufe/privat.ts).
*
* `vorgaengerId` des neuen Schluessels bleibt fuer die Historie stehen; dass abgebrochen wurde,
* erkennt man am Widerruf des Nachfolgers und am Protokolleintrag. Ist der alte Schluessel schon
* im Widerruf (Schritt 5), gibt es kein Zurueck mehr – sonst waeren am Ende beide halb entfernt.
*/
export async function rotationAbbrechen(
altId: string,
akteur: Akteur,
optionen: { readonly privatErlaubt: boolean },
): Promise<void> {
const alt = await altLaden(altId);
const neu = alt.nachfolger;
if (!neu) throw new RotationsFehler("Für diesen Schlüssel läuft keine Rotation.");
if (alt.status === "ROTIERT") throw new RotationsFehler("Die Rotation ist bereits abgeschlossen.");
// Idempotent: ein zweiter Klick (oder ein Neuladen) setzt den laufenden Abbruch nur fort.
if (rotationAbgebrochen(neu)) return;
if (alt.widerrufEingeleitetAm) {
throw new RotationsFehler(
"Der alte Schlüssel wird bereits entfernt – die Rotation lässt sich nicht mehr abbrechen.",
);
}
await widerrufEinleiten([neu.id], akteur, {
privatErlaubt: optionen.privatErlaubt,
zusaetzlich: async (tx) => {
await protokollieren(
{
akteur,
aktion: "schluessel.rotation.abgebrochen",
ergebnis: "ERFOLG",
fingerprint: alt.fingerprint,
details: { name: alt.name, nachfolgerId: neu.id, nachfolger: neu.name },
},
tx,
);
},
});
}
+14 -4
View File
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll"; import { protokollieren } from "@/server/services/protokoll";
import { VerwendungsFehler } from "@/server/services/verwendungen"; import { VerwendungsFehler } from "@/server/services/verwendungen";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
import { JUENGSTER, type MitNachfolger, mitNachfolger } from "@/lib/rotation";
/** /**
* Widerruf und Massenwiderruf (#024). * Widerruf und Massenwiderruf (#024).
@@ -37,10 +38,11 @@ const SCHLUESSEL_KOPF = {
status: true, status: true,
fingerprint: true, fingerprint: true,
widerrufEingeleitetAm: true, widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, status: true } }, nachfolgerListe: { select: { id: true, status: true, widerrufEingeleitetAm: true }, ...JUENGSTER },
} satisfies Prisma.SchluesselSelect; } satisfies Prisma.SchluesselSelect;
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>; type KopfRoh = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
type SchluesselKopf = MitNachfolger<KopfRoh>;
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */ /** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null { export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null {
@@ -61,7 +63,7 @@ async function schluesselLaden(ids: readonly string[]): Promise<SchluesselKopf[]
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht."); if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
return liste; return liste.map(mitNachfolger);
} }
// ---------------------------------------------------------------- Zielkonten // ---------------------------------------------------------------- Zielkonten
@@ -167,6 +169,11 @@ export async function zielkontoKandidaten(schluesselIds: readonly string[]): Pro
export type EinleitenOptionen = { export type EinleitenOptionen = {
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */ /** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
readonly privatErlaubt: boolean; readonly privatErlaubt: boolean;
/**
* Laeuft in derselben Transaktion wie das Vormerken – etwa der Protokolleintrag eines
* Rotationsabbruchs (#032), damit es keinen Abbruch ohne Eintrag gibt.
*/
readonly zusaetzlich?: (tx: Prisma.TransactionClient) => Promise<void>;
}; };
/** /**
@@ -264,6 +271,7 @@ export async function widerrufEinleiten(
tx, tx,
); );
} }
await optionen.zusaetzlich?.(tx);
}); });
} }
@@ -406,7 +414,9 @@ export async function widerrufAbschliessen(
continue; continue;
} }
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023). // Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN"; // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032) – ersetzt ist dann nichts.
const status =
s.nachfolger?.status === "AKTIV" && !s.nachfolger.widerrufEingeleitetAm ? "ROTIERT" : "WIDERRUFEN";
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
const geaendert = await tx.schluessel.updateMany({ const geaendert = await tx.schluessel.updateMany({
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } }, where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
+25
View File
@@ -95,6 +95,30 @@ async function pruefen(angefordertVonId: string | null) {
return { ergebnis: { hosts } }; return { ergebnis: { hosts } };
} }
/**
* Rotation abbrechen (#036), nur vor dem Umstellen: der Nachfolger wird widerrufen und sein
* Private Key sofort verworfen – sshkv hat sich mit ihm nie produktiv angemeldet. Aus den
* Bloecken verschwindet er beim naechsten Dry-Run, weil `sollFuer()` nur aktive Nachfolger kennt.
*/
async function abbrechen(angefordertVonId: string | null) {
const stand = await rotationsStand();
if (!stand.nachfolger) throw new VorgangsFehler("Es läuft keine Rotation, die sich abbrechen ließe.");
await prisma.schluessel.update({
where: { id: stand.nachfolger.id },
data: { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() },
select: { id: true },
});
await privateKeyVerwerfen(stand.nachfolger.id);
await protokollieren({
akteur: await akteurVon(angefordertVonId),
aktion: "deploymentkey.abgebrochen",
ergebnis: "ERFOLG",
fingerprint: stand.nachfolger.fingerprint,
details: { aktuell: stand.aktuell?.fingerprint ?? null },
});
return { ergebnis: { fingerprint: stand.nachfolger.fingerprint } };
}
/** /**
* Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft * Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft
* erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie * erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie
@@ -143,6 +167,7 @@ export const deploymentRotationHandler: Handler = async ({ vorgang }) => {
const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt; const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt;
if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId); if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId);
if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId); if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId);
if (schritt === "abbrechen") return abbrechen(vorgang.angefordertVonId);
if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId); if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId);
throw new VorgangsFehler("Unbekannter Schritt der Rotation."); throw new VorgangsFehler("Unbekannter Schritt der Rotation.");
}; };
+2 -1
View File
@@ -110,7 +110,8 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
}); });
} catch (fehler) { } catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) { if (istEindeutigkeitsFehler(fehler)) {
// vorgaengerId ist eindeutig: zwei Rotationen desselben Schluessels gleichzeitig (#023). // Je Vorgaenger nur ein offener Nachfolger (partieller Index, #023/#033): zwei Rotationen
// desselben Schluessels gleichzeitig.
if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation."); if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation.");
throw new VorgangsFehler( throw new VorgangsFehler(
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`, `Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
+1
View File
@@ -74,6 +74,7 @@ describe("Rechte", () => {
it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => { it("verlangt fuer kritische Aktionen eine Bestaetigung, und die nur fuer Admin-Rechte", () => {
expect([...BESTAETIGUNG_NOETIG].sort()).toEqual( expect([...BESTAETIGUNG_NOETIG].sort()).toEqual(
[ [
"benutzerVerwalten",
"deploymentKeyVerwalten", "deploymentKeyVerwalten",
"massenwiderruf", "massenwiderruf",
"privateKeyAnzeigen", "privateKeyAnzeigen",
+24 -1
View File
@@ -26,6 +26,7 @@ const REGELN = [
const INDIZES = [ const INDIZES = [
"schluessel_ein_instanzstandard", "schluessel_ein_instanzstandard",
"schluessel_ein_offener_nachfolger",
"publicverwendung_eindeutig_host", "publicverwendung_eindeutig_host",
"publicverwendung_eindeutig_gruppe", "publicverwendung_eindeutig_gruppe",
"privateverwendung_eindeutig", "privateverwendung_eindeutig",
@@ -236,8 +237,30 @@ describe("Protokoll", () => {
expect(pruefung?.ergebnis).toBe(zweiter.id); expect(pruefung?.ergebnis).toBe(zweiter.id);
}); });
it("vergibt IDs ohne Zweierschritte (#032)", async () => {
// Frueher zogen Spalten-Default und Trigger je eine Nummer aus der Sequenz.
const [a, b, c] = [await eintragen("eins"), await eintragen("zwei"), await eintragen("drei")];
expect(b.id).toBe(a.id + 1n);
expect(c.id).toBe(a.id + 2n);
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
expect(pruefung?.ergebnis).toBeNull();
});
it("ueberschreibt eine mitgegebene ID unter der Sperre (#032)", async () => {
const erster = await eintragen("eins");
const zweiter = await prisma.protokoll.create({
data: { id: 999n, akteurName: "Test", aktion: "zwei", ergebnis: "ERFOLG" },
});
expect(zweiter.id).toBe(erster.id + 1n);
expect(zweiter.vorgaengerHash).toBe(erster.hash);
});
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => { it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`))); const zeilen = await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
const ids = zeilen.map((z) => z.id).sort((x, y) => (x < y ? -1 : 1));
expect(ids.at(-1)! - ids[0]!).toBe(19n);
const [pruefung] = await prisma.$queryRaw< const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[] { ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`; >`SELECT protokoll_kette_pruefen() AS ergebnis`;
+2 -2
View File
@@ -66,10 +66,10 @@ test("OIDC ohne sshkv-Gruppe wird abgewiesen", async ({ page }) => {
test("Notfall-Admin richtet TOTP ein und bestätigt sich erneut", async ({ page }) => { test("Notfall-Admin richtet TOTP ein und bestätigt sich erneut", async ({ page }) => {
test.skip(NOTFALL_PASSWORT.length < 16, "SSHKV_BREAKGLASS_PASSWORD fehlt in .env"); test.skip(NOTFALL_PASSWORT.length < 16, "SSHKV_BREAKGLASS_PASSWORD fehlt in .env");
await page.goto("/anmelden"); await page.goto("/anmelden");
await page.getByText("Notfallzugang", { exact: true }).click(); await page.getByText("Lokales Konto oder Notfallzugang", { exact: true }).click();
await page.getByLabel("Anmeldename").fill("notfall"); await page.getByLabel("Anmeldename").fill("notfall");
await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT); await page.getByLabel("Passwort").fill(NOTFALL_PASSWORT);
await page.getByRole("button", { name: "Mit Notfallzugang anmelden" }).click(); await page.getByRole("button", { name: "Mit Benutzername anmelden" }).click();
await expect(page).toHaveURL(/\/anmelden\/totp/); await expect(page).toHaveURL(/\/anmelden\/totp/);
await expect(page.getByRole("img", { name: /QR-Code/ })).toBeVisible(); await expect(page.getByRole("img", { name: /QR-Code/ })).toBeVisible();
+33
View File
@@ -19,6 +19,39 @@ async function bestaetigen(page: Page) {
await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible();
} }
test("Rotation des Deployment-Keys abbrechen und aufräumen (#036)", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await page.goto("/deployment-key");
await bestaetigen(page);
// Eine liegengebliebene Rotation (etwa aus einem abgebrochenen Testlauf) wird mit abgebrochen.
const erzeugen = page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" });
if (await erzeugen.isVisible()) {
await erzeugen.click();
await expect(page.getByText("Neuer Key (noch nicht Standard)")).toBeVisible({ timeout: 30_000 });
}
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("2");
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
await expect(page.getByRole("heading", { name: "Abgebrochene Rotation aufräumen" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("1");
// Ein neuer Anlauf ist sofort moeglich.
await expect(page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" })).toBeVisible();
await archivieren(page, hostA);
});
test("Deployment-Key rotieren", async ({ page }) => { test("Deployment-Key rotieren", async ({ page }) => {
const kennung = Date.now().toString(36); const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`; const hostA = `zz-e2e-a-${kennung}`;
+25
View File
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import pg from "pg";
import "dotenv/config"; import "dotenv/config";
/** /**
@@ -35,6 +36,30 @@ export async function anmelden(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
} }
/** Direkter Zugriff auf die Datenbank des gebauten Stands – fuer Vorbedingungen und Pruefungen. */
export async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
await client.connect();
try {
return await arbeit(client);
} finally {
await client.end();
}
}
/**
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
*/
export async function oidcBestaetigen(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
await page.getByRole("link", { name: /erneut anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
}
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> { export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
await page.goto("/hosts/neu"); await page.goto("/hosts/neu");
await page.getByLabel("Name").fill(name); await page.getByLabel("Name").fill(name);
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from "@playwright/test";
import { base32Dekodieren, totpCode, totpSchritt } from "../../src/lib/totp";
import { anmelden, oidcBestaetigen } from "./hilfen";
/**
* Lokale Konten (#035) im gebauten Stand: Admin legt ein Konto an, die Person meldet sich zum
* ersten Mal an (eigenes Passwort, TOTP), der Admin sperrt sie – die Sitzung endet sofort.
*/
test.setTimeout(120_000);
test("lokales Konto anlegen, erste Anmeldung, sperren", async ({ page, browser }) => {
const anmeldename = `zz-e2e-${Date.now().toString(36)}`;
const passwort = "ein-eigenes-e2e-passwort";
await anmelden(page);
await page.goto("/einstellungen");
await page.getByRole("link", { name: "Konten verwalten" }).click();
await expect(page.getByRole("heading", { name: "Benutzer", exact: true })).toBeVisible();
// Frische Bestaetigung immer selbst holen – ob ein frueherer Test sie schon gesetzt hat,
// haengt von der Reihenfolge ab. Dass das Recht sie verlangt, pruefen die Unit-Tests.
await page.goto("/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer");
await oidcBestaetigen(page);
await expect(page).toHaveURL(/\/einstellungen\/benutzer$/);
await page.getByLabel("Anmeldename").fill(anmeldename);
await page.getByLabel("Name", { exact: true }).fill("E2E Lokal");
await page.getByLabel("Rolle", { exact: true }).selectOption("OPERATOR");
await page.getByRole("button", { name: "Konto anlegen" }).click();
const anzeige = page.getByRole("region", { name: `Einmal-Passwort für ${anmeldename}` });
const einmal = ((await anzeige.locator("code").textContent()) ?? "").trim();
expect(einmal).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
await page.screenshot({ path: "docs/anleitung/bilder/benutzer-desktop.png", fullPage: true });
// Zweiter Browser: die Person meldet sich zum ersten Mal an.
const kontext = await browser.newContext();
const person = await kontext.newPage();
await person.goto("/anmelden");
await person.getByText("Lokales Konto oder Notfallzugang", { exact: true }).click();
await person.getByLabel("Anmeldename").fill(anmeldename);
await person.getByLabel("Passwort").fill(einmal);
await person.getByRole("button", { name: "Mit Benutzername anmelden" }).click();
await expect(person).toHaveURL(/\/anmelden\/passwort/);
await person.getByLabel("Neues Passwort", { exact: true }).fill("zu-kurz");
await person.getByLabel("Neues Passwort wiederholen").fill("zu-kurz");
await person.getByRole("button", { name: "Passwort setzen" }).click();
await expect(person.getByText(/mindestens 12 Zeichen/).last()).toBeVisible();
await person.getByLabel("Neues Passwort", { exact: true }).fill(passwort);
await person.getByLabel("Neues Passwort wiederholen").fill(passwort);
await person.getByRole("button", { name: "Passwort setzen" }).click();
await expect(person).toHaveURL(/\/anmelden\/totp/);
const geheimnis = ((await person.locator("code").first().textContent()) ?? "").trim();
await person
.getByLabel("Code zur Bestätigung")
.fill(totpCode(base32Dekodieren(geheimnis), totpSchritt(Date.now())));
await person.getByRole("button", { name: /TOTP einrichten/ }).click();
await expect(person.getByRole("heading", { name: "Übersicht" })).toBeVisible();
await expect(person.getByText("Operator · über lokales Konto")).toBeVisible();
// Ein Operator verwaltet keine Benutzer.
await person.goto("/einstellungen/benutzer");
await expect(person).toHaveURL(/\/kein-zugriff/);
// Admin sperrt das Konto – die Sitzung der Person endet sofort.
await page.reload();
const zeile = page.getByRole("row").filter({ hasText: anmeldename });
await expect(zeile.getByText("Einrichtung offen")).toHaveCount(0);
await zeile.getByRole("button", { name: /Sperren/ }).click();
await expect(zeile.getByText("gesperrt", { exact: true })).toBeVisible();
await person.goto("/");
await expect(person).toHaveURL(/\/anmelden/);
await kontext.close();
});
+50
View File
@@ -69,3 +69,53 @@ test("Rotation mit Schrittanzeige", async ({ page }) => {
await archivieren(page, hostA); await archivieren(page, hostA);
await archivieren(page, hostB); await archivieren(page, hostB);
}); });
test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
const zweck = `zz-e2e Abbruch ${kennung}`;
zieleZuruecksetzen();
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
const schluesselSeite = await kernAufbauen(page, zweck, hostA, hostB);
await page.reload();
await page.getByRole("link", { name: "Rotieren" }).click();
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 60_000 });
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Verbindungen testen" })).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).toMatch(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/m);
// Abbrechen: Rueckfrage, dann Widerruf des neuen Schluessels mit Dry-Run.
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
const rueckfrage = page.getByRole("group", { name: "Rotation abbrechen?" });
await expect(rueckfrage.getByText(`„${zweck}“ bleibt aktiv und unverändert`)).toBeVisible();
await rueckfrage.getByRole("button", { name: "Neuen Schlüssel widerrufen" }).click();
await expect(page.getByRole("heading", { name: "Rotation wird abgebrochen" })).toBeVisible();
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByRole("heading", { name: "Rotation abgebrochen" })).toBeVisible({ timeout: 60_000 });
// Auf den Hosts: wie vorher. Der Alias geht wieder mit dem alten Schluessel.
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toMatch(/-r\d{8}/);
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).not.toMatch(/-r\d{8}/);
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
// Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger – und er laesst sich
// erneut rotieren (#033).
await page.goto(schluesselSeite);
const kette = page.getByRole("list", { name: "Rotationskette" });
await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible();
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen.*erneut rotieren/)).toBeVisible();
await page.getByRole("link", { name: "Rotieren" }).click();
await expect(
page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }),
).toBeVisible();
await archivieren(page, hostA);
await archivieren(page, hostB);
});
+1 -24
View File
@@ -1,7 +1,7 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import pg from "pg";
import "dotenv/config"; import "dotenv/config";
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh"; import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
import { datenbank, oidcBestaetigen } from "./hilfen";
/** /**
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-… * Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
@@ -17,16 +17,6 @@ async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader")
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
} }
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
await client.connect();
try {
return await arbeit(client);
} finally {
await client.end();
}
}
function eindeutig(): string { function eindeutig(): string {
return `zz-e2e-${Date.now().toString(36)}`; return `zz-e2e-${Date.now().toString(36)}`;
} }
@@ -104,19 +94,6 @@ test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async
await expect(page.locator("li", { hasText: name })).toHaveCount(0); await expect(page.locator("li", { hasText: name })).toHaveCount(0);
}); });
/**
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
*/
async function oidcBestaetigen(page: Page) {
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
await page.getByRole("link", { name: /erneut anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
}
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({ test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
page, page,
}) => { }) => {
+90 -1
View File
@@ -1,10 +1,12 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { import {
anmelden, anmelden,
archivieren, archivieren,
aufZiel, aufZiel,
datenbank,
hostEinrichten, hostEinrichten,
kernAufbauen, kernAufbauen,
oidcBestaetigen,
PORT, PORT,
zieleZuruecksetzen, zieleZuruecksetzen,
} from "./hilfen"; } from "./hilfen";
@@ -59,3 +61,90 @@ test("Widerruf mit einem Klick", async ({ page }) => {
await archivieren(page, hostA); await archivieren(page, hostA);
await archivieren(page, hostB); await archivieren(page, hostB);
}); });
/** Schluessel erzeugen, Public Key auf `host` zuweisen und ausrollen. Liefert den Key-Teil. */
async function publicKeyAusrollen(page: Page, zweck: string, host: string): Promise<string> {
await page.goto("/schluessel/neu");
await page.getByLabel("Zweck").fill(zweck);
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
const zeile = (await page.getByLabel("Public Key", { exact: true }).textContent()) ?? "";
const keyTeil = zeile.trim().split(/\s+/)[1] ?? "";
expect(keyTeil).toMatch(/^AAAA/);
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: host });
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
await expect(oeffentlich).toBeHidden();
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
return keyTeil;
}
test("Massenwiderruf mit frischer Bestätigung (#032)", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostB = `zz-e2e-mb-${kennung}`;
const praefix = `zz-e2e Massen ${kennung}`;
const zwecke = [`${praefix} eins`, `${praefix} zwei`];
await anmelden(page);
// Eine Bestaetigung aus der Anmeldung eben oder einem frueheren Lauf soll nicht mitzaehlen.
await datenbank((client) =>
client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
);
await hostEinrichten(page, hostB, PORT.b);
const keys = [];
for (const zweck of zwecke) keys.push(await publicKeyAusrollen(page, zweck, hostB));
const vorher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
for (const key of keys) expect(vorher).toContain(key);
// Auswahl in der Liste – ein GET-Formular auf die Massenwiderruf-Seite.
await page.goto(`/schluessel?q=${encodeURIComponent(praefix)}`);
for (const zweck of zwecke)
await page.getByRole("checkbox", { name: `${zweck} für den Massenwiderruf auswählen` }).check();
await page.getByRole("button", { name: "Ausgewählte widerrufen" }).click();
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
await expect(page.getByRole("heading", { name: "2 Schlüssel widerrufen?" })).toBeVisible();
for (const zweck of zwecke)
await expect(
page.getByRole("region", { name: `Widerruf von ${zweck}` }).getByText(`root@${hostB}`),
).toBeVisible();
// Ohne frische Bestaetigung merkt sshkv nichts vor und schickt zur erneuten Anmeldung.
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
await page.getByRole("link", { name: /Anmeldung bestätigen/ }).click();
await oidcBestaetigen(page);
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
const eingeleitet = await datenbank((client) =>
client.query(
`SELECT count(*)::int AS n FROM "Schluessel" WHERE "name" = ANY($1) AND "widerrufEingeleitetAm" IS NOT NULL`,
[zwecke],
),
);
expect(eingeleitet.rows[0]).toEqual({ n: 0 });
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible();
// Ein gemeinsamer Dry-Run fuer beide Schluessel, dann anwenden; der Abschluss laeuft danach.
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeHidden({ timeout: 60_000 });
const gewaehlt = page.locator("section", {
has: page.getByRole("heading", { name: "Gewählte Schlüssel" }),
});
await expect(gewaehlt.getByText("widerrufen", { exact: true })).toHaveCount(2);
await expect(gewaehlt.getByText("Widerruf unvollständig")).toHaveCount(0);
const nachher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
for (const key of keys) expect(nachher).not.toContain(key);
const status = await datenbank((client) =>
client.query(`SELECT "status" FROM "Schluessel" WHERE "name" = ANY($1)`, [zwecke]),
);
expect(status.rows).toEqual([{ status: "WIDERRUFEN" }, { status: "WIDERRUFEN" }]);
await archivieren(page, hostB);
});
+8
View File
@@ -128,6 +128,14 @@ test("Matrix zeigt Gruppen-Zuweisung, filtert, weist zu und exportiert", async (
`"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`, `"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`,
); );
expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`); expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`);
// „Zum Dry-Run“ springt direkt zur Dry-Run-Karte der Schluesselseite (#032).
await matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: Public Key direkt`) }).click();
await dialog.getByRole("link", { name: "Zum Dry-Run" }).click();
await expect(page).toHaveURL(new RegExp(`/schluessel/${ids.schluessel}#dry-run$`));
const karte = page.locator("#dry-run");
await expect(karte).toContainText("Dry-Run-Vorschau");
await expect(karte).toBeInViewport();
}); });
test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => { test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => {
+30 -1
View File
@@ -41,7 +41,7 @@ beforeEach(async () => {
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id; AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
}); });
const schritt = (s: "erzeugen" | "pruefen" | "abschliessen") => const schritt = (s: "erzeugen" | "pruefen" | "abbrechen" | "abschliessen") =>
vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id }); vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
async function trockenlaufUndAusrollen() { async function trockenlaufUndAusrollen() {
@@ -136,6 +136,35 @@ describe("Rotation des Deployment-Keys", () => {
expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0); expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
}); });
it("bricht vor dem Umstellen ab und laesst danach eine neue Rotation zu (#036)", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
const neu = (await rotationsStand()).nachfolger!;
await trockenlaufUndAusrollen();
expect(await fingerprintsAuf(a)).toContain(neu.fingerprint);
expect((await schritt("abbrechen")).status).toBe("ERLEDIGT");
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: neu.id } });
expect(verworfen).toMatchObject({ status: "WIDERRUFEN", privatVerschluesselt: null });
expect(await rotationsStand()).toMatchObject({ phase: "bereit", abgebrochen: { id: neu.id } });
// Ohne laufende Rotation gibt es nichts mehr abzubrechen.
expect((await schritt("abbrechen")).status).toBe("FEHLGESCHLAGEN");
// Der Dry-Run nimmt den verworfenen Key wieder heraus; der alte arbeitet unveraendert.
await trockenlaufUndAusrollen();
const fingerprints = await fingerprintsAuf(a);
expect(fingerprints).toContain(alt.fingerprint);
expect(fingerprints).not.toContain(neu.fingerprint);
expect(await anmeldungKlappt(a, alt.id)).toBe(true);
// Eine neue Rotation beginnt von vorn.
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
const zweiter = (await rotationsStand()).nachfolger!;
expect(zweiter.id).not.toBe(neu.id);
expect(zweiter.vorgaengerId).toBe(alt.id);
});
it("laesst Hosts mit eigenem Deployment-Key aus", async () => { it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
const a = await eingerichteterHost(zielA, "ziel-a"); const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b"); const b = await eingerichteterHost(zielB, "ziel-b");
+61 -7
View File
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { rotationsDatum } from "@/lib/rotation"; import { rotationsDatum } from "@/lib/rotation";
import { import {
rotationAbbrechen,
rotationAltWiderrufen, rotationAltWiderrufen,
rotationStarten, rotationStarten,
rotationUebernehmen, rotationUebernehmen,
@@ -55,10 +56,8 @@ async function trockenlaufUndAusrollen(schluesselId: string) {
expect(ausrollen.fehler).toBeNull(); expect(ausrollen.fehler).toBeNull();
} }
describe("Rotation", () => { /** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => { async function alterSchluesselAusgerollt(aId: string, bId: string) {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup"); const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
const alt = await prisma.schluessel.create({ const alt = await prisma.schluessel.create({
data: { data: {
@@ -77,16 +76,24 @@ describe("Rotation", () => {
await privateVerwendungAnlegen( await privateVerwendungAnlegen(
{ {
schluesselId: alt.id, schluesselId: alt.id,
hostId: a.id, hostId: aId,
zielbenutzer: "root", zielbenutzer: "root",
pfad: "~/.ssh/id_backup", pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [b.id], knownHostsFuer: [bId],
}, },
AKTEUR, AKTEUR,
); );
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR); await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id); await trockenlaufUndAusrollen(alt.id);
return alt;
}
describe("Rotation", () => {
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const alt = await alterSchluesselAusgerollt(a.id, b.id);
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen. // 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR)); await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
@@ -123,4 +130,51 @@ describe("Rotation", () => {
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root"); expect(ssh.stdout.trim()).toBe("root");
}); });
it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const alt = await alterSchluesselAusgerollt(a.id, b.id);
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: alt.id },
select: { id: true, publicKey: true },
});
await trockenlaufUndAusrollen(neu.id);
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain(
`IdentityFile ~/.ssh/${neuerPfad}`,
);
// Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden.
await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true });
await trockenlaufUndAusrollen(neu.id);
expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN");
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!);
expect(authorized).toContain(alt.publicKey.split(" ")[1]!);
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
expect(dateien).toContain("id_backup");
expect(dateien).not.toContain(neuerPfad);
// Der Alias zeigt wieder auf die alte Datei – und funktioniert.
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
expect(config).toContain("IdentityFile ~/.ssh/id_backup\n");
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root");
// Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt.
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher);
const zustaende = [
...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })),
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
].map((v) => v.zustand);
expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
// Abbruch abgeschlossen: die Rotation steht wieder am Anfang (#033).
expect(await rotationsStand(alt.id)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
});
}); });
+192
View File
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import {
anmeldenameSchema,
einmalPasswortErzeugen,
lokaleKontenAktiv,
passwortFehler,
} from "@/lib/lokale-konten";
import { passwortHashen } from "@/lib/passwort-hash";
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
import {
AnmeldeFehler,
eigenesPasswortSetzen,
kontoAnmelden,
totpEinrichtungAbschliessen,
} from "@/server/services/anmeldung";
import {
kontoAktivSetzen,
kontoZuruecksetzen,
lokalesKontoAnlegen,
rolleAendern,
} from "@/server/services/benutzer";
import { FachFehler } from "@/server/services/fachfehler";
import { datenbankLeeren } from "./hilfen/db";
/** Lokale Konten (#035): Regeln, erste Anmeldung, Verwaltung, Abschalten. */
const EIGENES = "ein-eigenes-langes-passwort";
let admin: { id: string; name: string };
beforeEach(async () => {
await datenbankLeeren();
delete process.env["SSHKV_LOKALE_KONTEN"];
admin = await prisma.benutzer.create({
data: { name: "Ada Admin", rolle: "ADMIN", oidcSub: "ada" },
select: { id: true, name: true },
});
});
afterEach(() => {
delete process.env["SSHKV_LOKALE_KONTEN"];
});
const code = (geheimnis: string, zeit: Date) =>
totpCode(base32Dekodieren(geheimnis), totpSchritt(zeit.getTime()));
/** Anlegen, Einmal-Passwort ersetzen, TOTP einrichten – wie bei der ersten Anmeldung. */
async function eingerichtetesKonto(anmeldename = "anna") {
const { id, einmalPasswort } = await lokalesKontoAnlegen(
{ anmeldename, name: "Anna", rolle: "OPERATOR" },
admin,
);
expect((await kontoAnmelden(anmeldename, einmalPasswort, "")).art).toBe("passwortSetzen");
await eigenesPasswortSetzen(id, EIGENES);
expect((await kontoAnmelden(anmeldename, EIGENES, "")).art).toBe("totpEinrichten");
const geheimnis = totpGeheimnisErzeugen();
const t0 = new Date("2026-10-12T10:00:00Z");
await totpEinrichtungAbschliessen(id, geheimnis, code(geheimnis, t0), t0);
return { id, geheimnis, einmalPasswort };
}
describe("Regeln", () => {
it("erzeugt gut abtippbare, verschiedene Einmal-Passwoerter", () => {
const pw = einmalPasswortErzeugen();
expect(pw).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
expect(pw).not.toMatch(/[01OlI]/);
expect(new Set(Array.from({ length: 50 }, einmalPasswortErzeugen)).size).toBe(50);
});
it("prueft Anmeldename und Passwort", () => {
expect(anmeldenameSchema.parse(" Anna.Muster ")).toBe("anna.muster");
expect(anmeldenameSchema.safeParse("1anna").success).toBe(false);
expect(anmeldenameSchema.safeParse("a").success).toBe(false);
expect(anmeldenameSchema.safeParse("anna muster").success).toBe(false);
expect(passwortFehler("kurz")).toMatch(/12 Zeichen/);
expect(passwortFehler("xx-anna-xx-geheim", "Anna")).toMatch(/Anmeldenamen/);
expect(passwortFehler(EIGENES, "anna")).toBeNull();
});
it("schaltet lokale Konten nur mit ausdruecklichem Aus ab", () => {
expect(lokaleKontenAktiv({})).toBe(true);
expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: "1" })).toBe(true);
for (const aus of ["0", "false", "nein", "AUS"])
expect(lokaleKontenAktiv({ SSHKV_LOKALE_KONTEN: aus })).toBe(false);
});
});
describe("Erste Anmeldung", () => {
it("verlangt erst ein eigenes Passwort, dann TOTP, dann geht es hinein", async () => {
const { id, geheimnis, einmalPasswort } = await eingerichtetesKonto();
const t1 = new Date("2026-10-12T10:01:00Z");
// Das Einmal-Passwort gilt nicht mehr, Gross-/Kleinschreibung des Namens egal.
await expect(kontoAnmelden("anna", einmalPasswort, code(geheimnis, t1), t1)).rejects.toThrow(
AnmeldeFehler,
);
const ergebnis = await kontoAnmelden("Anna", EIGENES, code(geheimnis, t1), t1);
expect(ergebnis).toMatchObject({ art: "angemeldet", benutzer: { id, rolle: "OPERATOR" } });
expect(await prisma.protokoll.count({ where: { aktion: "anmeldung.lokal" } })).toBe(1);
});
it("lehnt schwache und unveraenderte Passwoerter ab und laesst nichts im Protokoll", async () => {
const { id, einmalPasswort } = await lokalesKontoAnlegen(
{ anmeldename: "bert", name: "Bert", rolle: "LESER" },
admin,
);
await expect(eigenesPasswortSetzen(id, "zu-kurz")).rejects.toThrow(/12 Zeichen/);
await expect(eigenesPasswortSetzen(id, einmalPasswort)).rejects.toThrow(/anderes Passwort/);
await eigenesPasswortSetzen(id, EIGENES);
// Kein zweites Mal ohne Zuruecksetzen.
await expect(eigenesPasswortSetzen(id, `${EIGENES}-2`)).rejects.toThrow(/kein Passwortwechsel/);
const protokoll = JSON.stringify(await prisma.protokoll.findMany(), (_k, v: unknown) =>
typeof v === "bigint" ? v.toString() : v,
);
expect(protokoll).not.toContain(einmalPasswort);
expect(protokoll).not.toContain(EIGENES);
});
it("kennt keinen doppelten Anmeldenamen – auch nicht den des Notfall-Admins", async () => {
await prisma.benutzer.create({
data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" },
});
await lokalesKontoAnlegen({ anmeldename: "anna", name: "Anna", rolle: "LESER" }, admin);
for (const name of ["anna", "notfall"]) {
await expect(
lokalesKontoAnlegen({ anmeldename: name, name: "X", rolle: "LESER" }, admin),
).rejects.toThrow(FachFehler);
}
});
});
describe("Verwaltung", () => {
it("aendert Rollen nur bei lokalen Konten und nie am eigenen", async () => {
const { id } = await eingerichtetesKonto();
await rolleAendern(id, "ADMIN", admin);
expect((await prisma.benutzer.findUniqueOrThrow({ where: { id } })).rolle).toBe("ADMIN");
// OIDC-Konto: Rolle kommt aus den Gruppen.
await expect(rolleAendern(admin.id, "LESER", { id: id, name: "Anna" })).rejects.toThrow(/Gruppen/);
await expect(rolleAendern(id, "LESER", { id, name: "Anna" })).rejects.toThrow(/eigene Konto/);
const notfall = await prisma.benutzer.create({
data: { anmeldename: "notfall", name: "Notfall", rolle: "ADMIN", istNotfall: true, passwortHash: "x" },
});
await expect(rolleAendern(notfall.id, "LESER", admin)).rejects.toThrow(/Notfall-Admin/);
await expect(kontoAktivSetzen(notfall.id, false, admin)).rejects.toThrow(/Notfall-Admin/);
});
it("sperrt sofort: Sitzungen enden, Anmeldung scheitert", async () => {
const { id, geheimnis } = await eingerichtetesKonto();
const vorher = (await prisma.benutzer.findUniqueOrThrow({ where: { id } })).sitzungenGueltigAb;
await kontoAktivSetzen(id, false, admin);
const gesperrt = await prisma.benutzer.findUniqueOrThrow({ where: { id } });
expect(gesperrt.aktiv).toBe(false);
expect(gesperrt.sitzungenGueltigAb.getTime()).toBeGreaterThan(vorher.getTime());
const t = new Date("2026-10-12T10:02:00Z");
await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler);
await kontoAktivSetzen(id, true, admin);
expect((await kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).art).toBe("angemeldet");
});
it("setzt Passwort und TOTP zurueck", async () => {
const { id } = await eingerichtetesKonto();
const { einmalPasswort } = await kontoZuruecksetzen(id, admin);
const konto = await prisma.benutzer.findUniqueOrThrow({ where: { id } });
expect(konto).toMatchObject({
passwortWechselNoetig: true,
totpGeheimnis: null,
totpEingerichtetAm: null,
});
expect((await kontoAnmelden("anna", einmalPasswort, "")).art).toBe("passwortSetzen");
await expect(kontoZuruecksetzen(admin.id, { id, name: "Anna" })).rejects.toThrow(/lokale Konten/);
});
});
describe("Abgeschaltet (SSHKV_LOKALE_KONTEN=0)", () => {
it("laesst lokale Konten nicht mehr hinein, den Notfall-Admin schon", async () => {
const { geheimnis } = await eingerichtetesKonto();
await prisma.benutzer.create({
data: {
anmeldename: "notfall",
name: "Notfall",
rolle: "ADMIN",
istNotfall: true,
passwortHash: await passwortHashen("notfall-test-passwort-123"),
},
});
process.env["SSHKV_LOKALE_KONTEN"] = "0";
const t = new Date("2026-10-12T10:03:00Z");
await expect(kontoAnmelden("anna", EIGENES, code(geheimnis, t), t)).rejects.toThrow(AnmeldeFehler);
expect((await kontoAnmelden("notfall", "notfall-test-passwort-123", "")).art).toBe("totpEinrichten");
await expect(
lokalesKontoAnlegen({ anmeldename: "carl", name: "Carl", rolle: "LESER" }, admin),
).rejects.toThrow(/abgeschaltet/);
});
});
+10 -10
View File
@@ -4,7 +4,7 @@ import { passwortHashen } from "@/lib/passwort-hash";
import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp"; import { base32Dekodieren, totpCode, totpGeheimnisErzeugen, totpSchritt } from "@/lib/totp";
import { import {
AnmeldeFehler, AnmeldeFehler,
notfallAnmelden, kontoAnmelden,
oidcBenutzerUebernehmen, oidcBenutzerUebernehmen,
totpEinrichtungAbschliessen, totpEinrichtungAbschliessen,
} from "@/server/services/anmeldung"; } from "@/server/services/anmeldung";
@@ -37,7 +37,7 @@ beforeEach(async () => {
describe("Notfall-Admin", () => { describe("Notfall-Admin", () => {
it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => { it("fuehrt beim ersten Mal zur TOTP-Einrichtung, nicht in die Anwendung", async () => {
await notfallAnlegen(); await notfallAnlegen();
const ergebnis = await notfallAnmelden("notfall", PASSWORT, ""); const ergebnis = await kontoAnmelden("notfall", PASSWORT, "");
expect(ergebnis.art).toBe("totpEinrichten"); expect(ergebnis.art).toBe("totpEinrichten");
}); });
@@ -56,33 +56,33 @@ describe("Notfall-Admin", () => {
expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis); expect(Buffer.from(gespeichert.totpGeheimnis ?? []).toString("utf8")).not.toContain(geheimnis);
const t1 = new Date(t0.getTime() + 60_000); const t1 = new Date(t0.getTime() + 60_000);
await expect(notfallAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler); await expect(kontoAnmelden("notfall", PASSWORT, "123456", t1)).rejects.toThrow(AnmeldeFehler);
const ergebnis = await notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1); const ergebnis = await kontoAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1);
expect(ergebnis.art).toBe("angemeldet"); expect(ergebnis.art).toBe("angemeldet");
// Derselbe Code ein zweites Mal: abgelehnt. // Derselbe Code ein zweites Mal: abgelehnt.
await expect(notfallAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow( await expect(kontoAnmelden("notfall", PASSWORT, codeZu(geheimnis, t1), t1)).rejects.toThrow(
AnmeldeFehler, AnmeldeFehler,
); );
}); });
it("verraet nicht, ob Name oder Passwort falsch war", async () => { it("verraet nicht, ob Name oder Passwort falsch war", async () => {
await notfallAnlegen(); await notfallAnlegen();
const a = await notfallAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message); const a = await kontoAnmelden("gibtsnicht", PASSWORT, "").catch((f: Error) => f.message);
const b = await notfallAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message); const b = await kontoAnmelden("notfall", "falsch-falsch-falsch", "").catch((f: Error) => f.message);
expect(a).toBe(b); expect(a).toBe(b);
}); });
it("sperrt nach fuenf Fehlversuchen, auch fuer das richtige Passwort", async () => { it("sperrt nach fuenf Fehlversuchen, auch fuer das richtige Passwort", async () => {
await notfallAnlegen(); await notfallAnlegen();
for (let i = 0; i < 5; i += 1) { for (let i = 0; i < 5; i += 1) {
await expect(notfallAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow(); await expect(kontoAnmelden("notfall", "falsch-falsch-falsch", "")).rejects.toThrow();
} }
await expect(notfallAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/); await expect(kontoAnmelden("notfall", PASSWORT, "")).rejects.toThrow(/Zu viele Fehlversuche/);
}); });
it("protokolliert Fehlversuche ohne Passwort", async () => { it("protokolliert Fehlversuche ohne Passwort", async () => {
await notfallAnlegen(); await notfallAnlegen();
await notfallAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined); await kontoAnmelden("notfall", "geheimes-falsches-pw", "").catch(() => undefined);
const eintraege = await prisma.protokoll.findMany(); const eintraege = await prisma.protokoll.findMany();
expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen"); expect(eintraege.map((e) => e.aktion)).toContain("anmeldung.notfall.fehlgeschlagen");
expect( expect(
+1 -1
View File
@@ -75,7 +75,7 @@ beforeEach(async () => {
describe("protokollSeite", () => { describe("protokollSeite", () => {
it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => { it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => {
// IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt. // IDs koennen Luecken haben (Rollbacks, alte Zeilen vor #032) – nur die Ordnung zaehlt.
const ids: string[] = []; const ids: string[] = [];
for (let i = 0; i < SEITENGROESSE + 7; i += 1) for (let i = 0; i < SEITENGROESSE + 7; i += 1)
ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id)); ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));
+152 -1
View File
@@ -8,13 +8,14 @@ import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
import { rotationsKette } from "@/server/queries/schluessel"; import { rotationsKette } from "@/server/queries/schluessel";
import { import {
RotationsFehler, RotationsFehler,
rotationAbbrechen,
rotationAltWiderrufen, rotationAltWiderrufen,
rotationGesperrt, rotationGesperrt,
rotationStarten, rotationStarten,
rotationUebernehmen, rotationUebernehmen,
rotationsStand, rotationsStand,
} from "@/server/services/rotation"; } from "@/server/services/rotation";
import { widerrufAbschliessen } from "@/server/services/widerruf"; import { widerrufAbschliessen, widerrufEinleiten } from "@/server/services/widerruf";
import { masterKeyVergessen } from "@/worker/geheimnisse"; import { masterKeyVergessen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "./hilfen/db"; import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang"; import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
@@ -96,8 +97,10 @@ async function alterSchluessel() {
async function nachfolgerErzeugen(altId: string): Promise<string> { async function nachfolgerErzeugen(altId: string): Promise<string> {
const vorgangId = await rotationStarten(altId, admin); const vorgangId = await rotationStarten(altId, admin);
await vorgangImWorker(vorgangId); await vorgangImWorker(vorgangId);
// Nach einem Abbruch gibt es mehrere Nachfolger; der juengste ist der gerade erzeugte (#033).
const neu = await prisma.schluessel.findFirstOrThrow({ const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: altId }, where: { vorgaengerId: altId },
orderBy: { angelegtAm: "desc" },
select: { id: true }, select: { id: true },
}); });
return neu.id; return neu.id;
@@ -258,3 +261,151 @@ describe("Rotation Schritt fuer Schritt", () => {
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/); await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
}); });
}); });
describe("Rotation abbrechen (#032)", () => {
/** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */
async function laufendeRotation() {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
const jetzt = new Date();
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
return { altId, neuId };
}
async function verwendungen(schluesselId: string) {
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
]);
return [...oeffentlich, ...privat].map((v) => v.zustand).sort();
}
it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => {
const { altId, neuId } = await laufendeRotation();
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
// Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg.
expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]);
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher);
const stand = await rotationsStand(altId);
expect(stand.abgebrochen).toBe(true);
// Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts.
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.rotation.abgebrochen" },
select: { fingerprint: true, details: true },
});
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
// Solange der Abbruch laeuft, keine neue Rotation (#033).
await expect(rotationStarten(altId, admin)).rejects.toThrow(/läuft noch/);
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([
[altId, "AKTIV"],
[neuId, "WIDERRUFEN"],
]);
expect(
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
.vorgaengerId,
).toBe(altId);
// Danach laesst sich derselbe Schluessel erneut rotieren (#033).
expect(await rotationsStand(altId)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
const neu2 = await nachfolgerErzeugen(altId);
expect(neu2).not.toBe(neuId);
expect(await rotationsStand(altId)).toMatchObject({ neu: { id: neu2 }, schritt: 1, abgebrochen: false });
expect((await rotationsKette(altId)).map((g) => g.id)).toEqual([altId, neu2]);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
});
it("erlaubt in der Datenbank nur einen offenen Nachfolger je Schluessel (#033)", async () => {
const altId = await alterSchluessel();
const nachfolger = (n: string, daten: object = {}) =>
prisma.schluessel.create({
data: {
name: n,
slug: n,
typ: "ED25519",
kategorie: "DIENST",
publicKey: zeile("ssh-ed25519", ed25519Blob(), n),
fingerprint: `SHA256:${n}`,
kommentar: n,
vorgaengerId: altId,
...daten,
},
select: { id: true },
});
await nachfolger("abgebrochen", { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() });
await nachfolger("im-abbruch", { widerrufEingeleitetAm: new Date() });
await nachfolger("offen");
await expect(nachfolger("zweiter-offen")).rejects.toThrow();
});
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]);
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
});
it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => {
const { altId } = await laufendeRotation();
const privat = await prisma.privateVerwendung.findFirstOrThrow({
where: { schluessel: { vorgaengerId: altId } },
select: { id: true },
});
await prisma.vorgang.create({
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
});
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
/nicht mehr abbrechen/,
);
await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/);
});
it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => {
const { altId, neuId } = await laufendeRotation();
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
await widerrufEinleiten([altId], admin, { privatErlaubt: true });
await prisma.publicVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("WIDERRUFEN");
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV");
});
});
+10
View File
@@ -9,6 +9,7 @@ import {
ablaufZustand, ablaufZustand,
fingerprintKurz, fingerprintKurz,
freierSlug, freierSlug,
idsAusSuchparameter,
slugAus, slugAus,
typAnzeige, typAnzeige,
erzeugenSchema, erzeugenSchema,
@@ -66,6 +67,15 @@ function kern(privateKey: string): string {
} }
describe("Regeln (rein)", () => { describe("Regeln (rein)", () => {
it("liest IDs aus dem Suchparameter, auch UUIDs vom Worker (#032)", () => {
const uuid = "5f703ff1-3877-4af4-af08-84338237fcd8";
expect(idsAusSuchparameter([uuid, "cm1abc"])).toEqual([uuid, "cm1abc"]);
expect(idsAusSuchparameter(`a,b,a`)).toEqual(["a", "b"]);
expect(idsAusSuchparameter(["x y", "../etc", ""])).toEqual([]);
expect(idsAusSuchparameter(undefined)).toEqual([]);
expect(idsAusSuchparameter(["a", "b", "c"], 2)).toEqual(["a", "b"]);
});
it.each([ it.each([
["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"], ["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"], ["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],
+1
View File
@@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest";
const AUSNAHMEN: Record<string, string> = { const AUSNAHMEN: Record<string, string> = {
"src/app/anmelden/page.tsx": "Anmeldeseite selbst", "src/app/anmelden/page.tsx": "Anmeldeseite selbst",
"src/app/anmelden/totp/page.tsx": "nur mit Einrichtungs-Cookie aus richtigem Passwort erreichbar", "src/app/anmelden/totp/page.tsx": "nur mit Einrichtungs-Cookie aus richtigem Passwort erreichbar",
"src/app/anmelden/passwort/page.tsx": "nur mit Cookie aus richtigem Einmal-Passwort erreichbar (#035)",
"src/app/api/auth/abmelden/route.ts": "Abmelden braucht keine Pruefung", "src/app/api/auth/abmelden/route.ts": "Abmelden braucht keine Pruefung",
"src/app/api/auth/oidc/start/route.ts": "Beginn der Anmeldung", "src/app/api/auth/oidc/start/route.ts": "Beginn der Anmeldung",
"src/app/api/auth/oidc/rueckruf/route.ts": "Ende der Anmeldung; prueft state, nonce und PKCE", "src/app/api/auth/oidc/rueckruf/route.ts": "Ende der Anmeldung; prueft state, nonce und PKCE",