feat: Hybride Passkey-Freigabe für Skipper und Crew
Skipper (nur Owner) und Crew (WRITE-Collaborators) können Logbuchseiten optional per WebAuthn freigeben; klassische Unterschrift bleibt als Fallback. Signatur ist an den Eintrags-Hash gebunden, Export in CSV/PDF angepasst. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,479 @@
|
|||||||
|
# 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: Header `X-User-Id` (siehe `sync.ts`).
|
||||||
|
|
||||||
|
### 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` |
|
||||||
|
| Auth-Header | `X-User-Id` in `server/src/routes/sync.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Entwurf für Variante C — Hybrid elektronische Signatur im Kapteins Daagbok.*
|
||||||
@@ -2177,6 +2177,85 @@ body:has(.theme-cupertino) {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.signature-role-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(226, 232, 240, 0.65);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-badge {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.35);
|
||||||
|
background: rgba(34, 197, 94, 0.08);
|
||||||
|
color: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-badge.invalid {
|
||||||
|
border-color: rgba(251, 191, 36, 0.45);
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
color: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-badge-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-date {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-invalid-hint {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-clear {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey-sign-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
/* PWA install prompt */
|
/* PWA install prompt */
|
||||||
.pwa-install-banner {
|
.pwa-install-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
@@ -8,10 +8,20 @@ import { syncLogbook } from '../services/sync.js'
|
|||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
|
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
|
||||||
import PhotoCapture from './PhotoCapture.tsx'
|
import PhotoCapture from './PhotoCapture.tsx'
|
||||||
import SignaturePad from './SignaturePad.tsx'
|
import SignatureSection from './SignatureSection.tsx'
|
||||||
import TrackMap from './TrackMap.tsx'
|
import TrackMap from './TrackMap.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { isSignatureImage } from '../utils/signatures.js'
|
import {
|
||||||
|
normalizeSignature,
|
||||||
|
serializeSignature,
|
||||||
|
isPasskeySignature,
|
||||||
|
isSignatureValidForEntry
|
||||||
|
} from '../utils/signatures.js'
|
||||||
|
import type { SignatureValue } from '../types/signatures.js'
|
||||||
|
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||||
|
import { signLogEntry } from '../services/entrySigning.js'
|
||||||
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
import {
|
import {
|
||||||
getDecryptedTrack,
|
getDecryptedTrack,
|
||||||
saveUploadedTrack,
|
saveUploadedTrack,
|
||||||
@@ -85,8 +95,12 @@ export default function LogEntryEditor({
|
|||||||
const [fuelConsumption, setFuelConsumption] = useState('0')
|
const [fuelConsumption, setFuelConsumption] = useState('0')
|
||||||
|
|
||||||
// Signatures
|
// Signatures
|
||||||
const [signSkipper, setSignSkipper] = useState('')
|
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||||
const [signCrew, setSignCrew] = useState('')
|
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||||
|
const [isOwner, setIsOwner] = useState(false)
|
||||||
|
const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false)
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
|
const [entryHash, setEntryHash] = useState('')
|
||||||
|
|
||||||
// GPS track stats (from uploaded track)
|
// GPS track stats (from uploaded track)
|
||||||
const [trackDistanceNm, setTrackDistanceNm] = useState('')
|
const [trackDistanceNm, setTrackDistanceNm] = useState('')
|
||||||
@@ -149,6 +163,91 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildPayloadForSigning = useCallback(() => {
|
||||||
|
return buildLogEntryPayload({
|
||||||
|
date,
|
||||||
|
dayOfTravel,
|
||||||
|
departure,
|
||||||
|
destination,
|
||||||
|
freshwater: {
|
||||||
|
morning: parseFloat(fwMorning) || 0,
|
||||||
|
refilled: parseFloat(fwRefilled) || 0,
|
||||||
|
evening: parseFloat(fwEvening) || 0,
|
||||||
|
consumption: parseFloat(fwConsumption) || 0
|
||||||
|
},
|
||||||
|
fuel: {
|
||||||
|
morning: parseFloat(fuelMorning) || 0,
|
||||||
|
refilled: parseFloat(fuelRefilled) || 0,
|
||||||
|
evening: parseFloat(fuelEvening) || 0,
|
||||||
|
consumption: parseFloat(fuelConsumption) || 0
|
||||||
|
},
|
||||||
|
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||||
|
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||||
|
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||||
|
events
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
date, dayOfTravel, departure, destination,
|
||||||
|
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||||
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||||
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn,
|
||||||
|
events
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true)
|
||||||
|
const handleOffline = () => setIsOnline(false)
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getLogbookAccess(logbookId).then((access) => {
|
||||||
|
if (!access) return
|
||||||
|
setIsOwner(access.isOwner)
|
||||||
|
setHasWriteCollaborators(access.writeCollaboratorCount > 0)
|
||||||
|
})
|
||||||
|
}, [logbookId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
||||||
|
if (!cancelled) setEntryHash(hash)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [buildPayloadForSigning])
|
||||||
|
|
||||||
|
const skipperSignatureValid = !isPasskeySignature(signSkipper) || isSignatureValidForEntry(signSkipper, entryHash)
|
||||||
|
const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash)
|
||||||
|
|
||||||
|
const handlePasskeySignSkipper = async () => {
|
||||||
|
const hash = await hashEntryForSigning(buildPayloadForSigning())
|
||||||
|
const signature = await signLogEntry({
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
entryHash: hash,
|
||||||
|
role: 'skipper'
|
||||||
|
})
|
||||||
|
setSignSkipper(signature)
|
||||||
|
setEntryHash(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasskeySignCrew = async () => {
|
||||||
|
const hash = await hashEntryForSigning(buildPayloadForSigning())
|
||||||
|
const signature = await signLogEntry({
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
entryHash: hash,
|
||||||
|
role: 'crew'
|
||||||
|
})
|
||||||
|
setSignCrew(signature)
|
||||||
|
setEntryHash(hash)
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-calculate Freshwater Consumption
|
// Auto-calculate Freshwater Consumption
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const morning = parseFloat(fwMorning) || 0
|
const morning = parseFloat(fwMorning) || 0
|
||||||
@@ -215,8 +314,8 @@ export default function LogEntryEditor({
|
|||||||
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
setSignSkipper(preloadedEntry.signSkipper || '')
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||||
setSignCrew(preloadedEntry.signCrew || '')
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||||
loadTrackStatsFromEntry(preloadedEntry)
|
loadTrackStatsFromEntry(preloadedEntry)
|
||||||
setEvents(preloadedEntry.events || [])
|
setEvents(preloadedEntry.events || [])
|
||||||
return
|
return
|
||||||
@@ -245,8 +344,8 @@ export default function LogEntryEditor({
|
|||||||
setFuelEvening(String(decrypted.fuel.evening || 0))
|
setFuelEvening(String(decrypted.fuel.evening || 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
setSignSkipper(decrypted.signSkipper || '')
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||||
setSignCrew(decrypted.signCrew || '')
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||||
loadTrackStatsFromEntry(decrypted)
|
loadTrackStatsFromEntry(decrypted)
|
||||||
setEvents(decrypted.events || [])
|
setEvents(decrypted.events || [])
|
||||||
}
|
}
|
||||||
@@ -591,29 +690,11 @@ export default function LogEntryEditor({
|
|||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
|
const entryPayload = buildPayloadForSigning()
|
||||||
const entryData = {
|
const entryData = {
|
||||||
date,
|
...entryPayload,
|
||||||
dayOfTravel: dayOfTravel.trim(),
|
signSkipper: serializeSignature(signSkipper),
|
||||||
departure: departure.trim(),
|
signCrew: serializeSignature(signCrew)
|
||||||
destination: destination.trim(),
|
|
||||||
freshwater: {
|
|
||||||
morning: parseFloat(fwMorning) || 0,
|
|
||||||
refilled: parseFloat(fwRefilled) || 0,
|
|
||||||
evening: parseFloat(fwEvening) || 0,
|
|
||||||
consumption: parseFloat(fwConsumption) || 0
|
|
||||||
},
|
|
||||||
fuel: {
|
|
||||||
morning: parseFloat(fuelMorning) || 0,
|
|
||||||
refilled: parseFloat(fuelRefilled) || 0,
|
|
||||||
evening: parseFloat(fuelEvening) || 0,
|
|
||||||
consumption: parseFloat(fuelConsumption) || 0
|
|
||||||
},
|
|
||||||
signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
|
|
||||||
signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(),
|
|
||||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
|
||||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
|
||||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
|
||||||
events
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// E2E encrypt
|
// E2E encrypt
|
||||||
@@ -1309,32 +1390,21 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
||||||
|
|
||||||
{/* Section 4: Sign-Off Signatures */}
|
<SignatureSection
|
||||||
<div className="form-card">
|
readOnly={readOnly}
|
||||||
<div className="form-header">
|
disabled={saving}
|
||||||
<Check size={20} className="form-icon" />
|
isOnline={isOnline}
|
||||||
<h3>{t('logs.signatures')}</h3>
|
isOwner={isOwner}
|
||||||
</div>
|
hasWriteCollaborators={hasWriteCollaborators}
|
||||||
<div className="form-grid signature-grid">
|
signSkipper={signSkipper}
|
||||||
<SignaturePad
|
signCrew={signCrew}
|
||||||
id="sign-skipper"
|
skipperSignatureValid={skipperSignatureValid}
|
||||||
label={t('logs.sign_skipper')}
|
crewSignatureValid={crewSignatureValid}
|
||||||
value={signSkipper}
|
onSignSkipperChange={setSignSkipper}
|
||||||
onChange={setSignSkipper}
|
onSignCrewChange={setSignCrew}
|
||||||
disabled={saving}
|
onPasskeySignSkipper={handlePasskeySignSkipper}
|
||||||
readOnly={readOnly}
|
onPasskeySignCrew={handlePasskeySignCrew}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SignaturePad
|
|
||||||
id="sign-crew"
|
|
||||||
label={t('logs.sign_crew')}
|
|
||||||
value={signCrew}
|
|
||||||
onChange={setSignCrew}
|
|
||||||
disabled={saving}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Controls */}
|
{/* Save Controls */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
|
||||||
|
import type { PasskeySignature } from '../types/signatures.js'
|
||||||
|
|
||||||
|
interface PasskeySignButtonProps {
|
||||||
|
label: string
|
||||||
|
signature?: PasskeySignature
|
||||||
|
signatureValid?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
canSign: boolean
|
||||||
|
onSign: () => Promise<void>
|
||||||
|
onClear?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasskeySignButton({
|
||||||
|
label,
|
||||||
|
signature,
|
||||||
|
signatureValid = true,
|
||||||
|
disabled = false,
|
||||||
|
canSign,
|
||||||
|
onSign,
|
||||||
|
onClear
|
||||||
|
}: PasskeySignButtonProps) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const [signing, setSigning] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSign = async () => {
|
||||||
|
setSigning(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await onSign()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === 'NotAllowedError') {
|
||||||
|
setError(t('logs.sign_passkey_cancelled'))
|
||||||
|
} else {
|
||||||
|
setError(err?.message || t('logs.sign_passkey_failed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSigning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDate = signature
|
||||||
|
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="passkey-sign-block">
|
||||||
|
<div className="passkey-sign-label">{label}</div>
|
||||||
|
|
||||||
|
{signature ? (
|
||||||
|
<div className={`passkey-sign-badge ${signatureValid ? 'valid' : 'invalid'}`}>
|
||||||
|
<Fingerprint size={16} />
|
||||||
|
<div className="passkey-sign-badge-text">
|
||||||
|
<span>{t('logs.sign_passkey_signed', { username: signature.username })}</span>
|
||||||
|
<span className="passkey-sign-date">{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
{!signatureValid && (
|
||||||
|
<span className="passkey-sign-invalid-hint">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{t('logs.sign_invalid')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canSign && !disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary passkey-sign-btn"
|
||||||
|
onClick={handleSign}
|
||||||
|
disabled={signing}
|
||||||
|
>
|
||||||
|
{signing ? <Loader2 size={16} className="spin" /> : <Fingerprint size={16} />}
|
||||||
|
{signing ? t('logs.sign_passkey_signing') : t('logs.sign_with_passkey')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{signature && onClear && !disabled && (
|
||||||
|
<button type="button" className="btn text-btn passkey-sign-clear" onClick={onClear}>
|
||||||
|
{t('logs.sign_passkey_clear')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="passkey-sign-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Check } from 'lucide-react'
|
||||||
|
import SignaturePad from './SignaturePad.tsx'
|
||||||
|
import PasskeySignButton from './PasskeySignButton.tsx'
|
||||||
|
import type { SignatureValue } from '../types/signatures.js'
|
||||||
|
import { isPasskeySignature } from '../utils/signatures.js'
|
||||||
|
|
||||||
|
interface SignatureSectionProps {
|
||||||
|
readOnly?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
isOnline: boolean
|
||||||
|
isOwner: boolean
|
||||||
|
hasWriteCollaborators: boolean
|
||||||
|
signSkipper: SignatureValue | ''
|
||||||
|
signCrew: SignatureValue | ''
|
||||||
|
skipperSignatureValid: boolean
|
||||||
|
crewSignatureValid: boolean
|
||||||
|
onSignSkipperChange: (value: SignatureValue | '') => void
|
||||||
|
onSignCrewChange: (value: SignatureValue | '') => void
|
||||||
|
onPasskeySignSkipper: () => Promise<void>
|
||||||
|
onPasskeySignCrew: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function padValue(value: SignatureValue | ''): string {
|
||||||
|
if (!value || isPasskeySignature(value)) return ''
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignatureSection({
|
||||||
|
readOnly = false,
|
||||||
|
disabled = false,
|
||||||
|
isOnline,
|
||||||
|
isOwner,
|
||||||
|
hasWriteCollaborators,
|
||||||
|
signSkipper,
|
||||||
|
signCrew,
|
||||||
|
skipperSignatureValid,
|
||||||
|
crewSignatureValid,
|
||||||
|
onSignSkipperChange,
|
||||||
|
onSignCrewChange,
|
||||||
|
onPasskeySignSkipper,
|
||||||
|
onPasskeySignCrew
|
||||||
|
}: SignatureSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const skipperPasskey = isPasskeySignature(signSkipper) ? signSkipper : undefined
|
||||||
|
const crewPasskey = isPasskeySignature(signCrew) ? signCrew : undefined
|
||||||
|
|
||||||
|
const showSkipperPasskey = isOwner && isOnline
|
||||||
|
const showCrewPasskey = hasWriteCollaborators && isOnline
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<Check size={20} className="form-icon" />
|
||||||
|
<h3>{t('logs.signatures')}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid signature-grid">
|
||||||
|
<div className="signature-role-block">
|
||||||
|
{showSkipperPasskey && (
|
||||||
|
<PasskeySignButton
|
||||||
|
label={t('logs.sign_skipper')}
|
||||||
|
signature={skipperPasskey}
|
||||||
|
signatureValid={skipperSignatureValid}
|
||||||
|
disabled={disabled}
|
||||||
|
canSign={!readOnly}
|
||||||
|
onSign={onPasskeySignSkipper}
|
||||||
|
onClear={skipperPasskey ? () => onSignSkipperChange('') : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!skipperPasskey && (
|
||||||
|
<SignaturePad
|
||||||
|
id="sign-skipper"
|
||||||
|
label={t('logs.sign_skipper')}
|
||||||
|
value={padValue(signSkipper)}
|
||||||
|
onChange={onSignSkipperChange}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSkipperPasskey && !skipperPasskey && !readOnly && (
|
||||||
|
<p className="signature-hint">{t('logs.sign_classic_or_passkey')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOnline && isOwner && !readOnly && (
|
||||||
|
<p className="signature-hint">{t('logs.sign_offline_hint')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="signature-role-block">
|
||||||
|
{showCrewPasskey && (
|
||||||
|
<PasskeySignButton
|
||||||
|
label={t('logs.sign_crew')}
|
||||||
|
signature={crewPasskey}
|
||||||
|
signatureValid={crewSignatureValid}
|
||||||
|
disabled={disabled}
|
||||||
|
canSign={!readOnly}
|
||||||
|
onSign={onPasskeySignCrew}
|
||||||
|
onClear={crewPasskey ? () => onSignCrewChange('') : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!crewPasskey && (
|
||||||
|
<SignaturePad
|
||||||
|
id="sign-crew"
|
||||||
|
label={t('logs.sign_crew')}
|
||||||
|
value={padValue(signCrew)}
|
||||||
|
onChange={onSignCrewChange}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCrewPasskey && !crewPasskey && !readOnly && (
|
||||||
|
<p className="signature-hint">{t('logs.sign_crew_passkey_hint')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -118,6 +118,17 @@
|
|||||||
"sign_hint": "Mit Finger, Stift oder Maus unterschreiben",
|
"sign_hint": "Mit Finger, Stift oder Maus unterschreiben",
|
||||||
"sign_clear": "Löschen",
|
"sign_clear": "Löschen",
|
||||||
"sign_export_image": "[Unterschrift]",
|
"sign_export_image": "[Unterschrift]",
|
||||||
|
"sign_with_passkey": "Mit Passkey freigeben",
|
||||||
|
"sign_passkey_signing": "Passkey wird angefordert…",
|
||||||
|
"sign_passkey_signed": "Freigegeben von {{username}}",
|
||||||
|
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||||
|
"sign_passkey_clear": "Passkey-Freigabe entfernen",
|
||||||
|
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
|
||||||
|
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
|
||||||
|
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
|
||||||
|
"sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben",
|
||||||
|
"sign_crew_passkey_hint": "Crew-Mitglieder mit Schreibzugriff können per Passkey freigeben",
|
||||||
|
"sign_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich",
|
||||||
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
|
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
|
||||||
"back_to_list": "Zurück zur Journal-Liste",
|
"back_to_list": "Zurück zur Journal-Liste",
|
||||||
"save": "Logbuchseite speichern",
|
"save": "Logbuchseite speichern",
|
||||||
|
|||||||
@@ -118,6 +118,17 @@
|
|||||||
"sign_hint": "Sign with finger, stylus, or mouse",
|
"sign_hint": "Sign with finger, stylus, or mouse",
|
||||||
"sign_clear": "Clear",
|
"sign_clear": "Clear",
|
||||||
"sign_export_image": "[Signature]",
|
"sign_export_image": "[Signature]",
|
||||||
|
"sign_with_passkey": "Sign with Passkey",
|
||||||
|
"sign_passkey_signing": "Requesting Passkey…",
|
||||||
|
"sign_passkey_signed": "Signed by {{username}}",
|
||||||
|
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||||
|
"sign_passkey_clear": "Remove Passkey signature",
|
||||||
|
"sign_passkey_failed": "Passkey signing failed",
|
||||||
|
"sign_passkey_cancelled": "Passkey signing cancelled",
|
||||||
|
"sign_invalid": "Signature invalid — entry content changed",
|
||||||
|
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
|
||||||
|
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
|
||||||
|
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
|
||||||
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
|
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
|
||||||
"back_to_list": "Back to Journal List",
|
"back_to_list": "Back to Journal List",
|
||||||
"save": "Save Logbook Page",
|
"save": "Save Logbook Page",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { db } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { decryptJson } from './crypto.js'
|
import { decryptJson } from './crypto.js'
|
||||||
import { formatSignatureForExport } from '../utils/signatures.js'
|
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
|
||||||
import i18n from '../i18n/index.js'
|
import i18n from '../i18n/index.js'
|
||||||
|
|
||||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||||
@@ -90,15 +90,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
];
|
];
|
||||||
|
|
||||||
const rows: string[][] = [headers];
|
const rows: string[][] = [headers];
|
||||||
const signaturePlaceholder = i18n.t('logs.sign_export_image');
|
const exportLabels = {
|
||||||
|
imagePlaceholder: i18n.t('logs.sign_export_image'),
|
||||||
|
passkeyLabel: (username: string, signedAt: string) => {
|
||||||
|
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||||
|
return i18n.t('logs.sign_passkey_export', { username, date })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (const entry of decryptedEntries) {
|
for (const entry of decryptedEntries) {
|
||||||
const dateVal = entry.date || '';
|
const dateVal = entry.date || '';
|
||||||
const travelDay = entry.dayOfTravel || '';
|
const travelDay = entry.dayOfTravel || '';
|
||||||
const dep = entry.departure || '';
|
const dep = entry.departure || '';
|
||||||
const dest = entry.destination || '';
|
const dest = entry.destination || '';
|
||||||
const signS = formatSignatureForExport(entry.signSkipper, signaturePlaceholder);
|
const signS = formatSignatureForExport(normalizeSignature(entry.signSkipper), exportLabels);
|
||||||
const signC = formatSignatureForExport(entry.signCrew, signaturePlaceholder);
|
const signC = formatSignatureForExport(normalizeSignature(entry.signCrew), exportLabels);
|
||||||
const trackDist = entry.trackDistanceNm ?? '';
|
const trackDist = entry.trackDistanceNm ?? '';
|
||||||
const trackMax = entry.trackSpeedMaxKn ?? '';
|
const trackMax = entry.trackSpeedMaxKn ?? '';
|
||||||
const trackAvg = entry.trackSpeedAvgKn ?? '';
|
const trackAvg = entry.trackSpeedAvgKn ?? '';
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
|
import type { PasskeySignature } from '../types/signatures.js'
|
||||||
|
|
||||||
|
export async function signLogEntry(params: {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
entryHash: string
|
||||||
|
role: 'skipper' | 'crew'
|
||||||
|
}): Promise<PasskeySignature> {
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId) throw new Error('User not authenticated')
|
||||||
|
|
||||||
|
const optionsRes = await fetch('/api/sign/options', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-User-Id': userId
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!optionsRes.ok) {
|
||||||
|
const err = await optionsRes.json().catch(() => ({}))
|
||||||
|
throw new Error(err.error || 'Failed to start passkey signing')
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = await optionsRes.json()
|
||||||
|
const credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||||
|
|
||||||
|
const verifyRes = await fetch('/api/sign/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-User-Id': userId
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
credentialResponse,
|
||||||
|
challenge: options.challenge,
|
||||||
|
logbookId: params.logbookId,
|
||||||
|
entryId: params.entryId,
|
||||||
|
entryHash: params.entryHash,
|
||||||
|
role: params.role
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!verifyRes.ok) {
|
||||||
|
const err = await verifyRes.json().catch(() => ({}))
|
||||||
|
throw new Error(err.error || 'Passkey signature verification failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verifyRes.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'passkey',
|
||||||
|
version: 1,
|
||||||
|
role: params.role,
|
||||||
|
userId: result.userId,
|
||||||
|
username: result.username,
|
||||||
|
credentialId: result.credentialId,
|
||||||
|
signedAt: result.signedAt,
|
||||||
|
entryHash: params.entryHash,
|
||||||
|
clientVerified: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface LogbookAccess {
|
||||||
|
isOwner: boolean
|
||||||
|
role: 'OWNER' | 'READ' | 'WRITE'
|
||||||
|
writeCollaboratorCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId || !navigator.onLine) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
|
||||||
|
headers: { 'X-User-Id': userId }
|
||||||
|
})
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { db } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { decryptJson } from './crypto.js'
|
import { decryptJson } from './crypto.js'
|
||||||
import { isSignatureImage } from '../utils/signatures.js'
|
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.js'
|
||||||
|
|
||||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||||
@@ -230,7 +230,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
|
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
|
||||||
|
|
||||||
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
|
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
|
||||||
if (isSignatureImage(entry.signSkipper)) {
|
if (isPasskeySignature(entry.signSkipper)) {
|
||||||
|
doc.setFont('Helvetica', 'normal');
|
||||||
|
const skipperDate = new Date(entry.signSkipper.signedAt).toLocaleString('de-DE');
|
||||||
|
doc.text(`Passkey: ${entry.signSkipper.username}`, sigX + 2, sigY + 9);
|
||||||
|
doc.text(skipperDate, sigX + 2, sigY + 13.5);
|
||||||
|
} else if (isSignatureImage(entry.signSkipper)) {
|
||||||
doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
|
doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
|
||||||
} else {
|
} else {
|
||||||
doc.setFont('Helvetica', 'normal');
|
doc.setFont('Helvetica', 'normal');
|
||||||
@@ -239,7 +244,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
|
|
||||||
doc.setFont('Helvetica', 'bold');
|
doc.setFont('Helvetica', 'bold');
|
||||||
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
|
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
|
||||||
if (isSignatureImage(entry.signCrew)) {
|
if (isPasskeySignature(entry.signCrew)) {
|
||||||
|
doc.setFont('Helvetica', 'normal');
|
||||||
|
const crewDate = new Date(entry.signCrew.signedAt).toLocaleString('de-DE');
|
||||||
|
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
|
||||||
|
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
|
||||||
|
} else if (isSignatureImage(entry.signCrew)) {
|
||||||
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
||||||
} else {
|
} else {
|
||||||
doc.setFont('Helvetica', 'normal');
|
doc.setFont('Helvetica', 'normal');
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/** Passkey-Freigabe — gespeichert im E2E-verschlüsselten Eintrag */
|
||||||
|
export interface PasskeySignature {
|
||||||
|
kind: 'passkey'
|
||||||
|
version: 1
|
||||||
|
role: 'skipper' | 'crew'
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
credentialId: string
|
||||||
|
signedAt: string
|
||||||
|
entryHash: string
|
||||||
|
clientVerified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy: PNG data URL oder getippter Name */
|
||||||
|
export type SignatureValue = string | PasskeySignature
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
function sortValue(value: unknown): unknown {
|
||||||
|
if (value === null || typeof value !== 'object') return value
|
||||||
|
if (Array.isArray(value)) return value.map(sortValue)
|
||||||
|
const obj = value as Record<string, unknown>
|
||||||
|
const sorted: Record<string, unknown> = {}
|
||||||
|
for (const key of Object.keys(obj).sort()) {
|
||||||
|
sorted[key] = sortValue(obj[key])
|
||||||
|
}
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToBase64url(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ''
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b)
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stabil sortiertes JSON → SHA-256 → base64url */
|
||||||
|
export async function hashEntryForSigning(entry: Record<string, unknown>): Promise<string> {
|
||||||
|
const canonical = JSON.stringify(sortValue(entry))
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical))
|
||||||
|
return bufferToBase64url(digest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export interface LogEventPayload {
|
||||||
|
time: string
|
||||||
|
mgk: string
|
||||||
|
rwk: string
|
||||||
|
windPressure: string
|
||||||
|
windDirection: string
|
||||||
|
windStrength: string
|
||||||
|
seaState: string
|
||||||
|
weatherIcon: string
|
||||||
|
current: string
|
||||||
|
heel: string
|
||||||
|
sailsOrMotor: string
|
||||||
|
logReading: string
|
||||||
|
distance: string
|
||||||
|
gpsLat: string
|
||||||
|
gpsLng: string
|
||||||
|
remarks: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntryPayloadInput {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
trackDistanceNm?: number
|
||||||
|
trackSpeedMaxKn?: number
|
||||||
|
trackSpeedAvgKn?: number
|
||||||
|
events: LogEventPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
date: input.date,
|
||||||
|
dayOfTravel: input.dayOfTravel.trim(),
|
||||||
|
departure: input.departure.trim(),
|
||||||
|
destination: input.destination.trim(),
|
||||||
|
freshwater: { ...input.freshwater },
|
||||||
|
fuel: { ...input.fuel },
|
||||||
|
events: input.events.map((e) => ({ ...e }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
||||||
|
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
|
||||||
|
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -1,12 +1,50 @@
|
|||||||
|
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||||
|
|
||||||
export function isSignatureImage(value: string | undefined | null): boolean {
|
export function isSignatureImage(value: string | undefined | null): boolean {
|
||||||
return typeof value === 'string' && value.startsWith('data:image/')
|
return typeof value === 'string' && value.startsWith('data:image/')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSignatureForExport(
|
export function isPasskeySignature(value: unknown): value is PasskeySignature {
|
||||||
value: string | undefined | null,
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
(value as PasskeySignature).kind === 'passkey' &&
|
||||||
|
(value as PasskeySignature).version === 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSignature(value: unknown): SignatureValue | undefined {
|
||||||
|
if (value === null || value === undefined || value === '') return undefined
|
||||||
|
if (isPasskeySignature(value)) return value
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: string): boolean {
|
||||||
|
return sig.entryHash === entryHash
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureExportLabels {
|
||||||
imagePlaceholder: string
|
imagePlaceholder: string
|
||||||
|
passkeyLabel: (username: string, signedAt: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSignatureForExport(
|
||||||
|
value: SignatureValue | undefined | null,
|
||||||
|
labels: SignatureExportLabels
|
||||||
): string {
|
): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
if (isSignatureImage(value)) return imagePlaceholder
|
if (isPasskeySignature(value)) {
|
||||||
|
return labels.passkeyLabel(value.username, value.signedAt)
|
||||||
|
}
|
||||||
|
if (isSignatureImage(value)) return labels.imagePlaceholder
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
|
||||||
|
if (!value) return undefined
|
||||||
|
if (isPasskeySignature(value)) return value
|
||||||
|
if (isSignatureImage(value)) return value
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed || undefined
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import authRouter from './routes/auth.js'
|
|||||||
import logbooksRouter from './routes/logbooks.js'
|
import logbooksRouter from './routes/logbooks.js'
|
||||||
import syncRouter from './routes/sync.js'
|
import syncRouter from './routes/sync.js'
|
||||||
import collaborationRouter from './routes/collaboration.js'
|
import collaborationRouter from './routes/collaboration.js'
|
||||||
|
import signRouter from './routes/sign.js'
|
||||||
import { prisma } from './db.js'
|
import { prisma } from './db.js'
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
@@ -20,6 +21,7 @@ app.use('/api/auth', authRouter)
|
|||||||
app.use('/api/logbooks', logbooksRouter)
|
app.use('/api/logbooks', logbooksRouter)
|
||||||
app.use('/api/sync', syncRouter)
|
app.use('/api/sync', syncRouter)
|
||||||
app.use('/api/collaboration', collaborationRouter)
|
app.use('/api/collaboration', collaborationRouter)
|
||||||
|
app.use('/api/sign', signRouter)
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/api/health', async (req, res) => {
|
app.get('/api/health', async (req, res) => {
|
||||||
|
|||||||
@@ -69,7 +69,50 @@ router.post('/', async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. Delete a logbook
|
// 3. Access metadata for a logbook (owner / collaborator)
|
||||||
|
router.get('/:id/access', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const logbook = await prisma.logbook.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
collaborators: {
|
||||||
|
where: { userId: req.userId }
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
collaborators: {
|
||||||
|
where: { role: 'WRITE' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!logbook) {
|
||||||
|
return res.status(404).json({ error: 'Logbook not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = logbook.userId === req.userId
|
||||||
|
const collaboration = logbook.collaborators[0]
|
||||||
|
|
||||||
|
if (!isOwner && !collaboration) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
isOwner,
|
||||||
|
role: isOwner ? 'OWNER' : collaboration!.role,
|
||||||
|
writeCollaboratorCount: logbook._count.collaborators
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching logbook access:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Delete a logbook
|
||||||
router.delete('/:id', async (req: any, res) => {
|
router.delete('/:id', async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import {
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse
|
||||||
|
} from '@simplewebauthn/server'
|
||||||
|
import { prisma } from '../db.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const rpID = process.env.RP_ID || 'localhost'
|
||||||
|
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||||
|
|
||||||
|
const CHALLENGE_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
interface SigningContext {
|
||||||
|
userId: string
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
entryHash: string
|
||||||
|
role: 'skipper' | 'crew'
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingChallenges = new Map<string, SigningContext>()
|
||||||
|
|
||||||
|
function pruneExpiredChallenges() {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, ctx] of signingChallenges) {
|
||||||
|
if (ctx.expiresAt <= now) signingChallenges.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireUser = (req: any, res: any, next: any) => {
|
||||||
|
const userId = req.headers['x-user-id']
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||||
|
}
|
||||||
|
req.userId = userId
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(requireUser)
|
||||||
|
|
||||||
|
async function getLogbookWithAccess(logbookId: string, userId: string) {
|
||||||
|
const logbook = await prisma.logbook.findUnique({
|
||||||
|
where: { id: logbookId },
|
||||||
|
include: {
|
||||||
|
collaborators: {
|
||||||
|
where: { userId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!logbook) return null
|
||||||
|
|
||||||
|
const isOwner = logbook.userId === userId
|
||||||
|
const collaboration = logbook.collaborators[0]
|
||||||
|
if (!isOwner && !collaboration) return null
|
||||||
|
|
||||||
|
return { logbook, isOwner, collaboration }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllowCredentialsForRole(
|
||||||
|
logbookId: string,
|
||||||
|
ownerUserId: string,
|
||||||
|
role: 'skipper' | 'crew'
|
||||||
|
) {
|
||||||
|
if (role === 'skipper') {
|
||||||
|
const credentials = await prisma.credential.findMany({
|
||||||
|
where: { userId: ownerUserId }
|
||||||
|
})
|
||||||
|
return credentials.map((cred) => ({
|
||||||
|
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: cred.transports as any[]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const collaborations = await prisma.collaboration.findMany({
|
||||||
|
where: { logbookId, role: 'WRITE' },
|
||||||
|
select: { userId: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const userIds = collaborations.map((c) => c.userId)
|
||||||
|
if (userIds.length === 0) return []
|
||||||
|
|
||||||
|
const credentials = await prisma.credential.findMany({
|
||||||
|
where: { userId: { in: userIds } }
|
||||||
|
})
|
||||||
|
|
||||||
|
return credentials.map((cred) => ({
|
||||||
|
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||||
|
type: 'public-key' as const,
|
||||||
|
transports: cred.transports as any[]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isAuthorizedSigner(
|
||||||
|
logbookId: string,
|
||||||
|
ownerUserId: string,
|
||||||
|
signerUserId: string,
|
||||||
|
role: 'skipper' | 'crew'
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (role === 'skipper') {
|
||||||
|
return signerUserId === ownerUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
const collaboration = await prisma.collaboration.findUnique({
|
||||||
|
where: {
|
||||||
|
logbookId_userId: { logbookId, userId: signerUserId }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return collaboration?.role === 'WRITE'
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/options', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
pruneExpiredChallenges()
|
||||||
|
|
||||||
|
const { logbookId, entryId, entryHash, role } = req.body
|
||||||
|
if (!logbookId || !entryId || !entryHash || !role) {
|
||||||
|
return res.status(400).json({ error: 'logbookId, entryId, entryHash and role are required' })
|
||||||
|
}
|
||||||
|
if (role !== 'skipper' && role !== 'crew') {
|
||||||
|
return res.status(400).json({ error: 'role must be skipper or crew' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await getLogbookWithAccess(logbookId, req.userId)
|
||||||
|
if (!access) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'skipper' && !access.isOwner) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Only the logbook owner can sign as skipper' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowCredentials = await getAllowCredentialsForRole(
|
||||||
|
logbookId,
|
||||||
|
access.logbook.userId,
|
||||||
|
role
|
||||||
|
)
|
||||||
|
|
||||||
|
if (allowCredentials.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: role === 'crew'
|
||||||
|
? 'No write collaborators with passkeys found'
|
||||||
|
: 'No passkey credentials found for owner'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonce = crypto.randomBytes(16).toString('hex')
|
||||||
|
const challengePayload = `${entryId}:${entryHash}:${role}:${nonce}`
|
||||||
|
const derivedChallenge = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(challengePayload)
|
||||||
|
.digest('base64url')
|
||||||
|
|
||||||
|
signingChallenges.set(derivedChallenge, {
|
||||||
|
userId: req.userId,
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
entryHash,
|
||||||
|
role,
|
||||||
|
expiresAt: Date.now() + CHALLENGE_TTL_MS
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID,
|
||||||
|
challenge: derivedChallenge,
|
||||||
|
allowCredentials,
|
||||||
|
userVerification: 'required'
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json(options)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error generating sign options:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/verify', async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
pruneExpiredChallenges()
|
||||||
|
|
||||||
|
const { credentialResponse, challenge, logbookId, entryId, entryHash, role } = req.body
|
||||||
|
if (!credentialResponse || !challenge || !logbookId || !entryId || !entryHash || !role) {
|
||||||
|
return res.status(400).json({ error: 'Missing required parameters' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = signingChallenges.get(challenge)
|
||||||
|
if (!context || context.expiresAt <= Date.now()) {
|
||||||
|
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
context.logbookId !== logbookId ||
|
||||||
|
context.entryId !== entryId ||
|
||||||
|
context.entryHash !== entryHash ||
|
||||||
|
context.role !== role
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'Signing context mismatch' })
|
||||||
|
}
|
||||||
|
|
||||||
|
signingChallenges.delete(challenge)
|
||||||
|
|
||||||
|
const access = await getLogbookWithAccess(logbookId, req.userId)
|
||||||
|
if (!access) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'skipper' && !access.isOwner) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Only the logbook owner can sign as skipper' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbCred = await prisma.credential.findUnique({
|
||||||
|
where: { credentialId: credentialResponse.id },
|
||||||
|
include: { user: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!dbCred) {
|
||||||
|
return res.status(400).json({ error: 'Credential not recognized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorized = await isAuthorizedSigner(
|
||||||
|
logbookId,
|
||||||
|
access.logbook.userId,
|
||||||
|
dbCred.userId,
|
||||||
|
role
|
||||||
|
)
|
||||||
|
if (!authorized) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response: credentialResponse,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpID,
|
||||||
|
authenticator: {
|
||||||
|
credentialID: Buffer.from(dbCred.credentialId, 'base64url'),
|
||||||
|
credentialPublicKey: dbCred.publicKey,
|
||||||
|
counter: Number(dbCred.counter)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!verification.verified || !verification.authenticationInfo) {
|
||||||
|
return res.status(400).json({ error: 'Signature verification failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.credential.update({
|
||||||
|
where: { id: dbCred.id },
|
||||||
|
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
verified: true,
|
||||||
|
userId: dbCred.user.id,
|
||||||
|
username: dbCred.user.username,
|
||||||
|
credentialId: dbCred.credentialId,
|
||||||
|
signedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error verifying signature:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
Reference in New Issue
Block a user