# 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.*
