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>
17 KiB
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:
- Nutzer füllt Logbuchseite aus und tippt auf „Speichern“.
- Wenn
signSkipperleer ist und Netzwerk verfügbar → Passkey-Dialog (User Verification). - Nach erfolgreicher Assertion wird der Eintrag verschlüsselt gespeichert.
- 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
allowCredentialsnur 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 beirole: crewalle Credentials der Logbook-Collaborators (Query überCollaboration+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:
- Challenge aus Store (TTL 5 Min, one-time).
entryHashund Metadaten müssen mit gespeichertem Kontext übereinstimmen.verifyAuthenticationResponsewie inauth.ts/login-verify.- 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
entryDataohne 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:
- Skipper kann Eintrag online per Passkey freigeben; PDF/CSV zeigen Username + Datum.
- Offline → Pad-Fallback funktioniert.
- Alte Einträge (String-Signaturen) laden und exportieren unverändert.
- 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:
- Eingeladener WRITE-Collaborator kann Crew-Feld per eigenem Passkey signieren.
- 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) | ~2–3 Tage |
| Phase 2 (Crew Passkey) | ~1 Tag |
| Phase 3 (Audit + Tests) | ~1–2 Tage |
11. Offene Entscheidungen (vor Implementierung klären)
-
Skipper-Pflicht: Muss jeder Eintrag Passkey-signiert sein, oder optional wie heute?
- Empfehlung v1: Optional; Passkey wird beim Speichern angeboten, Pad bei Offline.
-
Wer darf Skipper signieren? Nur Owner oder jeder WRITE-Nutzer?
- Empfehlung v1: Jeder WRITE-Nutzer (typisch: Kapitän auf eigenem Gerät).
-
Pad-Fallback dauerhaft erlauben?
- Empfehlung: Ja (Variante C); später Logbook-Setting zum Erzwingen von Passkey.
-
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.