Ablauf: Web legt einen Vorgang PRIVATE_KEY_ABHOLEN an (Protokoll privatekey.angefordert)
→ der Worker öffnet den Umschlag und legt den Key mit SSHKV_APP_SECRET verschlüsselt in Vorgang.ergebnis ab ({ abholung: "v1.…" }) → das Web holt ihn genau einmal ab: ein
einziges UPDATE … FROM (SELECT … FOR UPDATE) RETURNING liest das alte Ergebnis und setzt { abgeholt: true }; fünf gleichzeitige Abholungen ergeben genau eine (Test). Abholen darf
nur, wer angefordert hat. Protokoll privatekey.angezeigt / privatekey.heruntergeladen mit
Fingerprint, nie mit Key.
Die Status-Schnittstelle/api/vorgaenge/[id] zeigt für eine bereitliegende Abholung nur { bereit: true }, nie das Chiffrat.
Verfall nach 2 Minuten (ABHOLFRIST_MS in src/lib/schluessel.ts), dreifach abgesichert:
der Worker räumt alle 30 s und vor jeder neuen Abholung auf (abholungenAufraeumen, ein
einziges UPDATE → { verfallen: true }), und das Web gibt eine ältere Abholung nicht mehr
heraus (überschreibt sie aber trotzdem). Ein Worker-Takt statt pg-boss-Zeitplan: dessen Raster
ist eine Minute.
Anzeige im Dialog (Monospace, Kopier-Knopf, „Wird nicht erneut angezeigt“); der Key lebt
nur im Zustand des Dialogs und wird beim Schließen verworfen. Download als Datei <slug>
über POST /api/private-key/[vorgangId] (Formular, kein GET – ein Link oder Vorabruf würde die
einmalige Abholung verbrauchen), Content-Disposition: attachment, Cache-Control: no-store. Der Route Handler prüft selbst Recht, Bestätigung und Herkunft:
wegen Referrer-Policy: no-referrer senden Browser bei Formular-POSTs Origin: null,
maßgeblich ist deshalb Sec-Fetch-Site: same-origin (oder ein passender Origin).
Re-Auth: Fehlt die frische Bestätigung, leitet die Oberfläche nach /bestaetigen?ziel=/schluessel/<id>?privatekey=1 und öffnet danach den Dialog wieder. Der
Mock-OIDC liefert auth_time nur, wenn man es in die Claims schreibt – die E2E tut das.
Löschen („Private Key aus SSHKeyVault löschen“): neues Recht privateKeyLoeschen (nur
Admin, mit Bestätigung), ausgeführt als Worker-Vorgang PRIVATE_KEY_LOESCHEN
(privateKeyVerwerfen in src/worker/geheimnisse.ts), damit nur diese Datei die
Chiffrat-Felder anfasst – auch zum Leeren. Setzt Chiffrat, DEK und Version auf null und privatGeloeschtAm; Protokoll privatekey.geloescht. CHECK schluessel_geloescht_ohne_private_key (Migration aus #012).
Deployment-Keys gibt sshkv weder heraus noch löscht es deren Private Key: der Key ist root
auf allen Hosts, und ohne ihn käme der Worker nirgends mehr hin. Im Notfall wird rotiert.
Quelle: docs/issues/014-private-key-anzeigen.md – dort wird dieses Issue gepflegt.
## Ziel
Nur Admin, nur nach Re-Auth, immer protokolliert; Entschlüsselung im Worker.
## Aufgaben
- [x] Einmal-Abholung
- [x] Option „Private Key nach Ausrollen löschen“
## Akzeptanzkriterien
- [x] Kein Caching, kein Logging
## Abhängigkeiten
#012, #004
## Notizen / Entscheidungen
- **Ablauf:** Web legt einen Vorgang `PRIVATE_KEY_ABHOLEN` an (Protokoll `privatekey.angefordert`)
→ der Worker öffnet den Umschlag und legt den Key mit `SSHKV_APP_SECRET` verschlüsselt in
`Vorgang.ergebnis` ab (`{ abholung: "v1.…" }`) → das Web holt ihn **genau einmal** ab: ein
einziges `UPDATE … FROM (SELECT … FOR UPDATE) RETURNING` liest das alte Ergebnis und setzt
`{ abgeholt: true }`; fünf gleichzeitige Abholungen ergeben genau eine (Test). Abholen darf
nur, wer angefordert hat. Protokoll `privatekey.angezeigt` / `privatekey.heruntergeladen` mit
Fingerprint, nie mit Key.
- **Die Status-Schnittstelle** `/api/vorgaenge/[id]` zeigt für eine bereitliegende Abholung nur
`{ bereit: true }`, nie das Chiffrat.
- **Verfall nach 2 Minuten** (`ABHOLFRIST_MS` in `src/lib/schluessel.ts`), dreifach abgesichert:
der Worker räumt alle 30 s und vor jeder neuen Abholung auf (`abholungenAufraeumen`, ein
einziges UPDATE → `{ verfallen: true }`), und das Web gibt eine ältere Abholung nicht mehr
heraus (überschreibt sie aber trotzdem). Ein Worker-Takt statt pg-boss-Zeitplan: dessen Raster
ist eine Minute.
- **Anzeige** im Dialog (Monospace, Kopier-Knopf, „Wird nicht erneut angezeigt“); der Key lebt
nur im Zustand des Dialogs und wird beim Schließen verworfen. **Download** als Datei `<slug>`
über `POST /api/private-key/[vorgangId]` (Formular, kein GET – ein Link oder Vorabruf würde die
einmalige Abholung verbrauchen), `Content-Disposition: attachment`,
`Cache-Control: no-store`. Der Route Handler prüft selbst Recht, Bestätigung und Herkunft:
wegen `Referrer-Policy: no-referrer` senden Browser bei Formular-POSTs `Origin: null`,
maßgeblich ist deshalb `Sec-Fetch-Site: same-origin` (oder ein passender Origin).
- **Re-Auth:** Fehlt die frische Bestätigung, leitet die Oberfläche nach
`/bestaetigen?ziel=/schluessel/<id>?privatekey=1` und öffnet danach den Dialog wieder. Der
Mock-OIDC liefert `auth_time` nur, wenn man es in die Claims schreibt – die E2E tut das.
- **Löschen** („Private Key aus SSHKeyVault löschen“): neues Recht `privateKeyLoeschen` (nur
Admin, mit Bestätigung), ausgeführt als Worker-Vorgang `PRIVATE_KEY_LOESCHEN`
(`privateKeyVerwerfen` in `src/worker/geheimnisse.ts`), damit nur diese Datei die
Chiffrat-Felder anfasst – auch zum Leeren. Setzt Chiffrat, DEK und Version auf `null` und
`privatGeloeschtAm`; Protokoll `privatekey.geloescht`. CHECK
`schluessel_geloescht_ohne_private_key` (Migration aus #012).
- **Deployment-Keys** gibt sshkv weder heraus noch löscht es deren Private Key: der Key ist root
auf allen Hosts, und ohne ihn käme der Worker nirgends mehr hin. Im Notfall wird rotiert.
---
Quelle: `docs/issues/014-private-key-anzeigen.md` – dort wird dieses Issue gepflegt.
florian.wilde
added this to the v0.2.0 milestone 2026-10-12 15:12:40 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Ziel
Nur Admin, nur nach Re-Auth, immer protokolliert; Entschlüsselung im Worker.
Aufgaben
Akzeptanzkriterien
Abhängigkeiten
#012, #004
Notizen / Entscheidungen
PRIVATE_KEY_ABHOLENan (Protokollprivatekey.angefordert)→ der Worker öffnet den Umschlag und legt den Key mit
SSHKV_APP_SECRETverschlüsselt inVorgang.ergebnisab ({ abholung: "v1.…" }) → das Web holt ihn genau einmal ab: eineinziges
UPDATE … FROM (SELECT … FOR UPDATE) RETURNINGliest das alte Ergebnis und setzt{ abgeholt: true }; fünf gleichzeitige Abholungen ergeben genau eine (Test). Abholen darfnur, wer angefordert hat. Protokoll
privatekey.angezeigt/privatekey.heruntergeladenmitFingerprint, nie mit Key.
/api/vorgaenge/[id]zeigt für eine bereitliegende Abholung nur{ bereit: true }, nie das Chiffrat.ABHOLFRIST_MSinsrc/lib/schluessel.ts), dreifach abgesichert:der Worker räumt alle 30 s und vor jeder neuen Abholung auf (
abholungenAufraeumen, eineinziges UPDATE →
{ verfallen: true }), und das Web gibt eine ältere Abholung nicht mehrheraus (überschreibt sie aber trotzdem). Ein Worker-Takt statt pg-boss-Zeitplan: dessen Raster
ist eine Minute.
nur im Zustand des Dialogs und wird beim Schließen verworfen. Download als Datei
<slug>über
POST /api/private-key/[vorgangId](Formular, kein GET – ein Link oder Vorabruf würde dieeinmalige Abholung verbrauchen),
Content-Disposition: attachment,Cache-Control: no-store. Der Route Handler prüft selbst Recht, Bestätigung und Herkunft:wegen
Referrer-Policy: no-referrersenden Browser bei Formular-POSTsOrigin: null,maßgeblich ist deshalb
Sec-Fetch-Site: same-origin(oder ein passender Origin)./bestaetigen?ziel=/schluessel/<id>?privatekey=1und öffnet danach den Dialog wieder. DerMock-OIDC liefert
auth_timenur, wenn man es in die Claims schreibt – die E2E tut das.privateKeyLoeschen(nurAdmin, mit Bestätigung), ausgeführt als Worker-Vorgang
PRIVATE_KEY_LOESCHEN(
privateKeyVerwerfeninsrc/worker/geheimnisse.ts), damit nur diese Datei dieChiffrat-Felder anfasst – auch zum Leeren. Setzt Chiffrat, DEK und Version auf
nullundprivatGeloeschtAm; Protokollprivatekey.geloescht. CHECKschluessel_geloescht_ohne_private_key(Migration aus #012).auf allen Hosts, und ohne ihn käme der Worker nirgends mehr hin. Im Notfall wird rotiert.
Quelle:
docs/issues/014-private-key-anzeigen.md– dort wird dieses Issue gepflegt.