Files
kapteins-daagbok/.planning/designs/HYBRID-ELECTRONIC-SIGNATURES.md
T
elpatron dea33e3f00 feat(security): Session-Cookies statt X-User-Id und API-Härtung
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>
2026-05-30 13:47:24 +02:00

17 KiB
Raw Blame History

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

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)

/** 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:

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

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:

{
  "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:

const payload = `${entryId}:${entryHash}:${role}:${randomNonce}`
const challenge = base64url(sha256(payload))

In-Memory-Store (analog activeChallenges in auth.ts):

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:

{
  "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:

{
  "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

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

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)

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):

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)

// 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) ~23 Tage
Phase 2 (Crew Passkey) ~1 Tag
Phase 3 (Audit + Tests) ~12 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. 611612, 13121336)
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.