dea33e3f00
Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen. Co-authored-by: Cursor <cursoragent@cursor.com>
480 lines
17 KiB
Markdown
480 lines
17 KiB
Markdown
# Implementierungsvorschlag: Hybride elektronische Signatur (Variante C)
|
||
|
||
**Status:** Entwurf
|
||
**Datum:** 2026-05-29
|
||
**Scope:** Skipper-Freigabe per Passkey, Crew-Freigabe per Passkey *oder* klassische Unterschrift (Pad/Text)
|
||
|
||
---
|
||
|
||
## 1. Ziel
|
||
|
||
Die bestehenden Felder `signSkipper` und `signCrew` sollen um eine **identitätsgebundene Passkey-Freigabe** ergänzt werden, ohne die Papier-Tradition vollständig zu ersetzen:
|
||
|
||
| Rolle | Primär | Fallback |
|
||
|-------|--------|----------|
|
||
| **Skipper** | Passkey (WebAuthn-Assertion, an Eintrags-Hash gebunden) | SignaturePad / getippter Name (Offline, Gastgerät ohne Passkey) |
|
||
| **Crew** | Passkey (nur für eingeladene Collaborators mit WRITE) | SignaturePad / getippter Name (Gäste ohne Konto) |
|
||
|
||
**Nicht-Ziel (v1):** Qualifizierte elektronische Signatur (QES/eIDAS), serverseitiges Audit-Log (optional Phase 2), Multi-Device-Signatur-Workflow für Crew auf separatem Gerät.
|
||
|
||
---
|
||
|
||
## 2. Produktverhalten (UX)
|
||
|
||
### 2.1 Skipper-Bereich
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ Skipper-Freigabe │
|
||
│ ┌─────────────────────────────────────────┐ │
|
||
│ │ ✓ Signiert von max@see 29.05.26 14:32 │ │ ← Passkey-Signatur vorhanden
|
||
│ │ [Erneut freigeben] [Klassisch …] │ │
|
||
│ └─────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Standard-Flow beim Speichern:**
|
||
|
||
1. Nutzer füllt Logbuchseite aus und tippt auf „Speichern“.
|
||
2. Wenn `signSkipper` leer ist und Netzwerk verfügbar → **Passkey-Dialog** (User Verification).
|
||
3. Nach erfolgreicher Assertion wird der Eintrag verschlüsselt gespeichert.
|
||
4. Wenn Offline oder Passkey fehlschlägt → Dialog: *„Offline / Passkey nicht verfügbar — klassische Unterschrift verwenden?“* → SignaturePad.
|
||
|
||
**Regeln:**
|
||
|
||
- Passkey-Skipper-Signatur ist an den **Eintragsinhalt ohne Signaturfelder** gebunden. Änderungen an Datum, Route, Tankständen etc. **invalidieren** die Signatur (Badge „Signatur ungültig — erneut freigeben“).
|
||
- Der Logbuch-**Owner** oder ein Collaborator mit **WRITE** darf Skipper-Freigabe leisten (konfigurierbar: v1 = jeder WRITE-Nutzer auf dem Gerät).
|
||
- Bereits signierte Einträge im **readOnly**-Modus: nur Anzeige, kein erneutes Signieren.
|
||
|
||
### 2.2 Crew-Bereich
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ Crew-Freigabe │
|
||
│ ○ Passkey (empfohlen) ○ Klassisch │ ← Toggle nur wenn Collaborators existieren
|
||
│ [Mit Passkey freigeben] │
|
||
│ — oder — │
|
||
│ [SignaturePad wie bisher] │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Crew-Passkey:**
|
||
|
||
- Button „Mit Passkey freigeben“ startet WebAuthn mit `allowCredentials` **nur für Collaborators dieses Logbuchs** (Server liefert Credential-IDs).
|
||
- Auf einem gemeinsamen Tablet kann die Crew-Mitperson ihren Passkey wählen, ohne das Kapitäns-Konto zu verlassen.
|
||
- Ohne Collaborators: nur SignaturePad (wie heute).
|
||
|
||
**Crew ohne Konto:** unverändert Pad oder Text — kein Passkey-Zwang.
|
||
|
||
### 2.3 Zusammenfassung der Flows
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as Nutzer
|
||
participant E as LogEntryEditor
|
||
participant S as entrySigning.ts
|
||
participant A as /api/sign/*
|
||
participant DB as IndexedDB (E2E)
|
||
|
||
U->>E: Speichern
|
||
E->>E: entryHash = hash(canonicalEntry ohne Signaturen)
|
||
alt Skipper Passkey (online)
|
||
E->>S: signEntry(logbookId, entryId, hash, role=skipper)
|
||
S->>A: POST /sign/options
|
||
A-->>S: WebAuthn options
|
||
S->>U: Passkey-Dialog
|
||
U-->>S: assertion
|
||
S->>A: POST /sign/verify
|
||
A-->>S: verified + Metadaten
|
||
S-->>E: PasskeySignature
|
||
else Offline / abgebrochen
|
||
E->>U: Fallback SignaturePad
|
||
end
|
||
E->>DB: encryptJson(entry inkl. signSkipper)
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Datenmodell
|
||
|
||
### 3.1 Neue Typen (`client/src/types/signatures.ts`)
|
||
|
||
```typescript
|
||
/** Passkey-Freigabe v1 — rein in E2E-Payload, kein Klartext auf dem Server */
|
||
export interface PasskeySignature {
|
||
kind: 'passkey'
|
||
version: 1
|
||
role: 'skipper' | 'crew'
|
||
userId: string
|
||
username: string
|
||
credentialId: string // base64url
|
||
signedAt: string // ISO-8601 UTC
|
||
entryHash: string // base64url SHA-256
|
||
/** Client-seitig gespeichert für Offline-Anzeige; Server verifiziert bei Erstellung */
|
||
clientVerified: boolean
|
||
}
|
||
|
||
/** Legacy: string = PNG data URL oder getippter Name */
|
||
export type SignatureValue = string | PasskeySignature
|
||
|
||
export interface LogEntrySignatures {
|
||
signSkipper?: SignatureValue
|
||
signCrew?: SignatureValue
|
||
}
|
||
```
|
||
|
||
### 3.2 Abwärtskompatibilität
|
||
|
||
Bestehende Einträge speichern `signSkipper`/`signCrew` als `string`. Keine Migration nötig.
|
||
|
||
Hilfsfunktionen in `client/src/utils/signatures.ts` erweitern:
|
||
|
||
```typescript
|
||
export function isPasskeySignature(v: unknown): v is PasskeySignature
|
||
export function normalizeSignature(v: unknown): SignatureValue | undefined
|
||
export function formatSignatureForExport(v: SignatureValue | undefined, labels: ExportLabels): string
|
||
export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean
|
||
```
|
||
|
||
**Export-Texte (i18n):**
|
||
|
||
| Key | DE | EN |
|
||
|-----|----|----|
|
||
| `logs.sign_passkey_export` | `Passkey: {{username}} ({{date}})` | `Passkey: {{username}} ({{date}})` |
|
||
| `logs.sign_invalid` | Signatur ungültig | Signature invalid |
|
||
| `logs.sign_with_passkey` | Mit Passkey freigeben | Sign with Passkey |
|
||
| `logs.sign_classic_fallback` | Klassische Unterschrift | Classic signature |
|
||
| `logs.sign_offline_hint` | Passkey-Freigabe erfordert Internet | Passkey signing requires internet |
|
||
|
||
### 3.3 Kanonischer Eintrags-Hash
|
||
|
||
Neue Datei: `client/src/utils/entryCanonicalHash.ts`
|
||
|
||
```typescript
|
||
const SIGNATURE_KEYS = ['signSkipper', 'signCrew'] as const
|
||
|
||
/** Stabil sortiertes JSON → SHA-256 → base64url */
|
||
export async function hashEntryForSigning(entry: Record<string, unknown>): Promise<string>
|
||
```
|
||
|
||
**In Hash einbeziehen:** alle Felder außer `signSkipper`, `signCrew` und transienten UI-Feldern.
|
||
**Reihenfolge:** Keys alphabetisch sortieren, Arrays in definierter Reihenfolge (events nach `time`), Zahlen normalisiert.
|
||
|
||
Beim Laden eines Eintrags: `computedHash !== sig.entryHash` → UI-Warnung.
|
||
|
||
---
|
||
|
||
## 4. Server-API
|
||
|
||
Neuer Router: `server/src/routes/sign.ts` → Mount unter `/api/sign`
|
||
|
||
Auth wie bestehend: HttpOnly-Session-Cookie `daagbok_session` nach WebAuthn (`server/src/middleware/auth.ts`, Client `apiFetch` mit `credentials: 'include'`).
|
||
|
||
### 4.1 `POST /api/sign/options`
|
||
|
||
**Request:**
|
||
|
||
```json
|
||
{
|
||
"logbookId": "uuid",
|
||
"entryId": "uuid",
|
||
"entryHash": "base64url-sha256",
|
||
"role": "skipper" | "crew"
|
||
}
|
||
```
|
||
|
||
**Autorisierung:**
|
||
|
||
- Nutzer ist Owner **oder** Collaborator mit WRITE auf `logbookId`.
|
||
- Für `role: "crew"`: optional prüfen, dass mindestens ein anderer Collaborator existiert (Skipper darf auch Crew signieren in v1).
|
||
|
||
**Challenge-Konstruktion:**
|
||
|
||
```typescript
|
||
const payload = `${entryId}:${entryHash}:${role}:${randomNonce}`
|
||
const challenge = base64url(sha256(payload))
|
||
```
|
||
|
||
In-Memory-Store (analog `activeChallenges` in `auth.ts`):
|
||
|
||
```typescript
|
||
signingChallenges.set(challenge, {
|
||
userId, logbookId, entryId, entryHash, role, expiresAt
|
||
})
|
||
```
|
||
|
||
**Response:** WebAuthn `PublicKeyCredentialRequestOptions`
|
||
|
||
- `allowCredentials`: Credentials des **anfragenden** Users (Skipper) **oder** bei `role: crew` alle Credentials der Logbook-Collaborators (Query über `Collaboration` + `Credential`).
|
||
- **Kein PRF-Extension** — reine User-Verifikation, nicht Key-Derivation.
|
||
- `userVerification: 'required'`
|
||
|
||
### 4.2 `POST /api/sign/verify`
|
||
|
||
**Request:**
|
||
|
||
```json
|
||
{
|
||
"credentialResponse": { /* WebAuthn */ },
|
||
"challenge": "...",
|
||
"logbookId": "...",
|
||
"entryId": "...",
|
||
"entryHash": "...",
|
||
"role": "skipper" | "crew"
|
||
}
|
||
```
|
||
|
||
**Verifikation:**
|
||
|
||
1. Challenge aus Store (TTL 5 Min, one-time).
|
||
2. `entryHash` und Metadaten müssen mit gespeichertem Kontext übereinstimmen.
|
||
3. `verifyAuthenticationResponse` wie in `auth.ts` `/login-verify`.
|
||
4. Credential-User muss berechtigt sein (Owner/Collaborator WRITE; bei Crew-Signatur: Credential gehört zu einem Collaborator des Logbuchs).
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"verified": true,
|
||
"userId": "...",
|
||
"username": "...",
|
||
"credentialId": "...",
|
||
"signedAt": "2026-05-29T12:00:00.000Z"
|
||
}
|
||
```
|
||
|
||
**Wichtig:** Server speichert in v1 **keinen** Eintragsinhalt und keine Assertion — nur verifiziert und gibt Metadaten zurück. Der Client packt `PasskeySignature` ins E2E-Blob.
|
||
|
||
### 4.3 Optional Phase 2: Audit-Tabelle
|
||
|
||
```prisma
|
||
model EntrySignatureAudit {
|
||
id String @id @default(uuid())
|
||
logbookId String
|
||
entryId String
|
||
userId String
|
||
role String // skipper | crew
|
||
entryHash String
|
||
credentialId String
|
||
signedAt DateTime @default(now())
|
||
|
||
@@index([logbookId, entryId])
|
||
}
|
||
```
|
||
|
||
Ermöglicht spätere Prüfung ohne Entschlüsselung des Eintrags. Bewusst **ohne** Klartext-Inhalt.
|
||
|
||
---
|
||
|
||
## 5. Client-Implementierung
|
||
|
||
### 5.1 Neuer Service: `client/src/services/entrySigning.ts`
|
||
|
||
```typescript
|
||
export async function signLogEntry(params: {
|
||
logbookId: string
|
||
entryId: string
|
||
entryHash: string
|
||
role: 'skipper' | 'crew'
|
||
}): Promise<PasskeySignature>
|
||
|
||
export async function listCollaboratorCredentialIds(logbookId: string): Promise<string[]>
|
||
// Wrapper für /api/sign/options + startAuthentication + /api/sign/verify
|
||
// Kein PRF — separates Flow von loginUser()
|
||
```
|
||
|
||
Wiederverwendung: `@simplewebauthn/browser` `startAuthentication`, Muster aus `auth.ts` (Retry ohne PRF entfällt hier).
|
||
|
||
### 5.2 UI-Komponenten
|
||
|
||
| Datei | Aufgabe |
|
||
|-------|---------|
|
||
| `client/src/components/SignatureSection.tsx` | Container für Skipper + Crew, Modus-Toggle |
|
||
| `client/src/components/PasskeySignButton.tsx` | Button, Loading, Fehler, Erfolgs-Badge |
|
||
| `client/src/components/SignaturePad.tsx` | unverändert für Fallback |
|
||
|
||
**`LogEntryEditor.tsx` Änderungen:**
|
||
|
||
- State: `signSkipper: SignatureValue | ''`, `signCrew: SignatureValue | ''`
|
||
- Beim Submit: zuerst `entryData` ohne Signaturen hashen, dann Skipper-Passkey anstoßen (wenn gewählt/leer+online).
|
||
- `readOnly`: Passkey-Badge + „Signiert von … am …“; Pad disabled.
|
||
|
||
**`LogEntriesList.tsx`:** Default für neue Einträge `signSkipper: undefined` (nicht leerer String erzwingen).
|
||
|
||
### 5.3 Speichern-Logik (Pseudocode)
|
||
|
||
```typescript
|
||
async function handleSubmit() {
|
||
const entryData = buildEntryPayload({ signSkipper: undefined, signCrew: undefined })
|
||
const entryHash = await hashEntryForSigning(entryData)
|
||
|
||
let skipperSig = signSkipper
|
||
if (skipperSignMode === 'passkey' && !isPasskeySignature(skipperSig)) {
|
||
skipperSig = await signLogEntry({ logbookId, entryId, entryHash, role: 'skipper' })
|
||
}
|
||
|
||
const finalEntry = { ...entryData, signSkipper: skipperSig, signCrew: signCrew }
|
||
await encryptAndSave(finalEntry)
|
||
}
|
||
```
|
||
|
||
Crew-Passkey: separater Button (nicht zwingend beim Speichern), damit Crew-Mitglied nach dem Skipper signieren kann.
|
||
|
||
### 5.4 Export-Anpassungen
|
||
|
||
**CSV** (`csvExport.ts`):
|
||
|
||
```typescript
|
||
formatSignatureForExport(value, {
|
||
imagePlaceholder: t('logs.sign_export_image'),
|
||
passkeyTemplate: (sig) => i18n.t('logs.sign_passkey_export', { username: sig.username, date: format(sig.signedAt) })
|
||
})
|
||
```
|
||
|
||
**PDF** (`pdfExport.ts`):
|
||
|
||
- Passkey: zweizeilig — `SIGNIERT / SIGNED` + Username + Datum (kein Bild).
|
||
- Bild/Text: bestehende Logik.
|
||
|
||
---
|
||
|
||
## 6. Offline-Verhalten
|
||
|
||
| Situation | Verhalten |
|
||
|-----------|-----------|
|
||
| Online + Passkey | Standard Skipper-Flow |
|
||
| Offline | Passkey deaktiviert; Hinweis + SignaturePad |
|
||
| Eintrag offline gespeichert, später online | Kein Auto-Nachsignieren; Nutzer tippt „Mit Passkey freigeben“ |
|
||
| Passkey-Signatur vorhanden, Inhalt geändert | Signatur als ungültig markieren, erneute Freigabe nötig |
|
||
|
||
Kein Offline-Queue für WebAuthn in v1 — zu komplex (Challenge-Ablauf, Counter-Sync).
|
||
|
||
---
|
||
|
||
## 7. Sicherheit
|
||
|
||
| Risiko | Mitigation |
|
||
|--------|------------|
|
||
| Signatur ohne Inhaltsbindung | `entryHash` in Challenge + im `PasskeySignature`-Objekt |
|
||
| Fremder signiert Skipper-Feld | Server prüft WRITE auf Logbook + Credential-Zugehörigkeit |
|
||
| Replay der Assertion | Challenge one-time, 5 Min TTL |
|
||
| Manipulation nach Signatur | Client prüft Hash bei Anzeige; Export zeigt „invalid“ |
|
||
| E2E vs. Audit | v1 nur E2E-Metadaten; Audit optional Phase 2 |
|
||
| Login-Session vs. Signatur | Separater Endpoint, `userVerification: required`, kein PRF |
|
||
|
||
**Hinweis:** Gespeicherte `PasskeySignature` im E2E-Blob ist **selbst nicht kryptografisch signiert** durch den Server (Server sieht Payload nicht). Vertrauen basiert auf: (a) erfolgreiche Server-Verifikation zum Zeitpunkt der Erstellung, (b) Hash-Bindung, (c) optional Audit-Log in Phase 2. Für stärkere Non-Repudiation: Assertion-Response oder Server-Signatur über Hash in Audit speichern.
|
||
|
||
---
|
||
|
||
## 8. Implementierungsphasen
|
||
|
||
### Phase 1 — Fundament (MVP)
|
||
|
||
**Ziel:** Skipper Passkey + Crew Pad, Hash, Export, Fallback.
|
||
|
||
| # | Task | Dateien |
|
||
|---|------|---------|
|
||
| 1.1 | Typen + Signature-Utils | `types/signatures.ts`, `utils/signatures.ts`, `utils/entryCanonicalHash.ts` |
|
||
| 1.2 | Server `/api/sign/*` | `server/src/routes/sign.ts`, `server/src/index.ts` (mount) |
|
||
| 1.3 | Client `entrySigning.ts` | `client/src/services/entrySigning.ts` |
|
||
| 1.4 | UI Skipper Passkey + Pad-Fallback | `PasskeySignButton.tsx`, `SignatureSection.tsx`, `LogEntryEditor.tsx` |
|
||
| 1.5 | Crew nur Pad (unverändert) | `SignatureSection.tsx` |
|
||
| 1.6 | Export CSV/PDF | `csvExport.ts`, `pdfExport.ts` |
|
||
| 1.7 | i18n DE/EN | `locales/de.json`, `locales/en.json` |
|
||
|
||
**Akzeptanzkriterien:**
|
||
|
||
1. Skipper kann Eintrag online per Passkey freigeben; PDF/CSV zeigen Username + Datum.
|
||
2. Offline → Pad-Fallback funktioniert.
|
||
3. Alte Einträge (String-Signaturen) laden und exportieren unverändert.
|
||
4. Geänderte Felder nach Passkey-Signatur → Warnung „Signatur ungültig“.
|
||
|
||
### Phase 2 — Crew Passkey
|
||
|
||
| # | Task | Dateien |
|
||
|---|------|---------|
|
||
| 2.1 | Collaborator-Credentials in `/sign/options` | `sign.ts`, ggf. `collaboration.ts` |
|
||
| 2.2 | Crew-Toggle Passkey vs. Pad | `SignatureSection.tsx` |
|
||
| 2.3 | Collaborator-Liste für UI | `SettingsForm` / neuer Hook `useLogbookCollaborators` |
|
||
|
||
**Akzeptanzkriterien:**
|
||
|
||
1. Eingeladener WRITE-Collaborator kann Crew-Feld per eigenem Passkey signieren.
|
||
2. Gäste ohne Konto nutzen weiterhin Pad.
|
||
|
||
### Phase 3 — Härtung (optional)
|
||
|
||
- Prisma `EntrySignatureAudit`
|
||
- „Signatur prüfen“-Button (Re-Verify gegen Server, wenn online)
|
||
- Einstellung im Logbook: „Skipper-Freigabe nur Passkey“ (Pad-Fallback deaktivieren)
|
||
- Tests: Unit-Tests für `hashEntryForSigning`, Integrationstest `/sign/verify`
|
||
|
||
---
|
||
|
||
## 9. Testplan
|
||
|
||
### Manuell
|
||
|
||
- [ ] DE/EN: Export-Texte für Passkey, Pad, leer
|
||
- [ ] Skipper Passkey → Speichern → Reload → Badge sichtbar
|
||
- [ ] Eintrag ändern → ungültige Signatur
|
||
- [ ] Offline speichern mit Pad
|
||
- [ ] Legacy-Eintrag mit PNG-Signatur lädt korrekt
|
||
- [ ] Crew-Collaborator Passkey auf zweitem Account
|
||
- [ ] READ-only Collaborator darf nicht signieren (403)
|
||
|
||
### Automatisiert (empfohlen)
|
||
|
||
```typescript
|
||
// entryCanonicalHash.test.ts
|
||
test('stable hash ignores signature fields')
|
||
test('different tank values produce different hash')
|
||
|
||
// signatures.test.ts
|
||
test('formatSignatureForExport passkey vs image vs text')
|
||
test('isSignatureValidForEntry')
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Aufwandsschätzung
|
||
|
||
| Phase | Aufwand |
|
||
|-------|---------|
|
||
| Phase 1 (MVP) | ~2–3 Tage |
|
||
| Phase 2 (Crew Passkey) | ~1 Tag |
|
||
| Phase 3 (Audit + Tests) | ~1–2 Tage |
|
||
|
||
---
|
||
|
||
## 11. Offene Entscheidungen (vor Implementierung klären)
|
||
|
||
1. **Skipper-Pflicht:** Muss jeder Eintrag Passkey-signiert sein, oder optional wie heute?
|
||
- *Empfehlung v1:* Optional; Passkey wird beim Speichern **angeboten**, Pad bei Offline.
|
||
|
||
2. **Wer darf Skipper signieren?** Nur Owner oder jeder WRITE-Nutzer?
|
||
- *Empfehlung v1:* Jeder WRITE-Nutzer (typisch: Kapitän auf eigenem Gerät).
|
||
|
||
3. **Pad-Fallback dauerhaft erlauben?**
|
||
- *Empfehlung:* Ja (Variante C); später Logbook-Setting zum Erzwingen von Passkey.
|
||
|
||
4. **Crew-Passkey beim Speichern oder separater Schritt?**
|
||
- *Empfehlung:* Separater Button — Crew signiert oft nach dem Skipper.
|
||
|
||
---
|
||
|
||
## 12. Referenzen im Code
|
||
|
||
| Bereich | Pfad |
|
||
|---------|------|
|
||
| Aktuelle Signaturen | `client/src/components/LogEntryEditor.tsx` (ca. Z. 611–612, 1312–1336) |
|
||
| Signature-Utils | `client/src/utils/signatures.ts` |
|
||
| WebAuthn Login | `client/src/services/auth.ts`, `server/src/routes/auth.ts` |
|
||
| Collaborators | `server/src/routes/collaboration.ts`, `SettingsForm.tsx` |
|
||
| E2E-Einträge | `EntryPayload` in `server/prisma/schema.prisma` |
|
||
| API-Auth | Session-Cookie via `requireUser` in `server/src/middleware/auth.ts` |
|
||
|
||
---
|
||
|
||
*Entwurf für Variante C — Hybrid elektronische Signatur im Kapteins Daagbok.*
|