diff --git a/.planning/designs/HYBRID-ELECTRONIC-SIGNATURES.md b/.planning/designs/HYBRID-ELECTRONIC-SIGNATURES.md new file mode 100644 index 0000000..4f26fc9 --- /dev/null +++ b/.planning/designs/HYBRID-ELECTRONIC-SIGNATURES.md @@ -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): Promise +``` + +**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 + +export async function listCollaboratorCredentialIds(logbookId: string): Promise +// 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.* diff --git a/client/src/App.css b/client/src/App.css index 04c651b..853256a 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -2177,6 +2177,85 @@ body:has(.theme-cupertino) { 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-banner { position: fixed; diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 267c4fa..3fb4048 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -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 { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' @@ -8,10 +8,20 @@ import { syncLogbook } from '../services/sync.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react' import PhotoCapture from './PhotoCapture.tsx' -import SignaturePad from './SignaturePad.tsx' +import SignatureSection from './SignatureSection.tsx' import TrackMap from './TrackMap.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 { getDecryptedTrack, saveUploadedTrack, @@ -85,8 +95,12 @@ export default function LogEntryEditor({ const [fuelConsumption, setFuelConsumption] = useState('0') // Signatures - const [signSkipper, setSignSkipper] = useState('') - const [signCrew, setSignCrew] = useState('') + const [signSkipper, setSignSkipper] = useState('') + const [signCrew, setSignCrew] = useState('') + 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) 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 useEffect(() => { const morning = parseFloat(fwMorning) || 0 @@ -215,8 +314,8 @@ export default function LogEntryEditor({ setFuelEvening(String(preloadedEntry.fuel.evening || 0)) } - setSignSkipper(preloadedEntry.signSkipper || '') - setSignCrew(preloadedEntry.signCrew || '') + setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') + setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') loadTrackStatsFromEntry(preloadedEntry) setEvents(preloadedEntry.events || []) return @@ -245,8 +344,8 @@ export default function LogEntryEditor({ setFuelEvening(String(decrypted.fuel.evening || 0)) } - setSignSkipper(decrypted.signSkipper || '') - setSignCrew(decrypted.signCrew || '') + setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') + setSignCrew(normalizeSignature(decrypted.signCrew) || '') loadTrackStatsFromEntry(decrypted) setEvents(decrypted.events || []) } @@ -591,29 +690,11 @@ export default function LogEntryEditor({ const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') + const entryPayload = buildPayloadForSigning() const entryData = { - date, - dayOfTravel: dayOfTravel.trim(), - departure: departure.trim(), - 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 + ...entryPayload, + signSkipper: serializeSignature(signSkipper), + signCrew: serializeSignature(signCrew) } // E2E encrypt @@ -1309,32 +1390,21 @@ export default function LogEntryEditor({ - {/* Section 4: Sign-Off Signatures */} -
-
- -

{t('logs.signatures')}

-
-
- - - -
-
+ {/* Save Controls */} {!readOnly && ( diff --git a/client/src/components/PasskeySignButton.tsx b/client/src/components/PasskeySignButton.tsx new file mode 100644 index 0000000..874780a --- /dev/null +++ b/client/src/components/PasskeySignButton.tsx @@ -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 + 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(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 ( +
+
{label}
+ + {signature ? ( +
+ +
+ {t('logs.sign_passkey_signed', { username: signature.username })} + {formattedDate} +
+ {!signatureValid && ( + + + {t('logs.sign_invalid')} + + )} +
+ ) : null} + + {canSign && !disabled && ( + + )} + + {signature && onClear && !disabled && ( + + )} + + {error &&

{error}

} +
+ ) +} diff --git a/client/src/components/SignatureSection.tsx b/client/src/components/SignatureSection.tsx new file mode 100644 index 0000000..3777df8 --- /dev/null +++ b/client/src/components/SignatureSection.tsx @@ -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 + onPasskeySignCrew: () => Promise +} + +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 ( +
+
+ +

{t('logs.signatures')}

+
+ +
+
+ {showSkipperPasskey && ( + onSignSkipperChange('') : undefined} + /> + )} + + {!skipperPasskey && ( + + )} + + {showSkipperPasskey && !skipperPasskey && !readOnly && ( +

{t('logs.sign_classic_or_passkey')}

+ )} + + {!isOnline && isOwner && !readOnly && ( +

{t('logs.sign_offline_hint')}

+ )} +
+ +
+ {showCrewPasskey && ( + onSignCrewChange('') : undefined} + /> + )} + + {!crewPasskey && ( + + )} + + {showCrewPasskey && !crewPasskey && !readOnly && ( +

{t('logs.sign_crew_passkey_hint')}

+ )} +
+
+
+ ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index b0e8f35..2d26d1c 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -118,6 +118,17 @@ "sign_hint": "Mit Finger, Stift oder Maus unterschreiben", "sign_clear": "Löschen", "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!", "back_to_list": "Zurück zur Journal-Liste", "save": "Logbuchseite speichern", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 1a232d8..93376f2 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -118,6 +118,17 @@ "sign_hint": "Sign with finger, stylus, or mouse", "sign_clear": "Clear", "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!", "back_to_list": "Back to Journal List", "save": "Save Logbook Page", diff --git a/client/src/services/csvExport.ts b/client/src/services/csvExport.ts index 2bb8ef5..7a569bf 100644 --- a/client/src/services/csvExport.ts +++ b/client/src/services/csvExport.ts @@ -2,7 +2,7 @@ import { db } from './db.js' import { getActiveMasterKey } from './auth.js' import { getLogbookKey } from './logbookKeys.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' 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 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) { const dateVal = entry.date || ''; const travelDay = entry.dayOfTravel || ''; const dep = entry.departure || ''; const dest = entry.destination || ''; - const signS = formatSignatureForExport(entry.signSkipper, signaturePlaceholder); - const signC = formatSignatureForExport(entry.signCrew, signaturePlaceholder); + const signS = formatSignatureForExport(normalizeSignature(entry.signSkipper), exportLabels); + const signC = formatSignatureForExport(normalizeSignature(entry.signCrew), exportLabels); const trackDist = entry.trackDistanceNm ?? ''; const trackMax = entry.trackSpeedMaxKn ?? ''; const trackAvg = entry.trackSpeedAvgKn ?? ''; diff --git a/client/src/services/entrySigning.ts b/client/src/services/entrySigning.ts new file mode 100644 index 0000000..915da34 --- /dev/null +++ b/client/src/services/entrySigning.ts @@ -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 { + 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 + } +} diff --git a/client/src/services/logbookAccess.ts b/client/src/services/logbookAccess.ts new file mode 100644 index 0000000..1ca8790 --- /dev/null +++ b/client/src/services/logbookAccess.ts @@ -0,0 +1,20 @@ +export interface LogbookAccess { + isOwner: boolean + role: 'OWNER' | 'READ' | 'WRITE' + writeCollaboratorCount: number +} + +export async function getLogbookAccess(logbookId: string): Promise { + 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 + } +} diff --git a/client/src/services/pdfExport.ts b/client/src/services/pdfExport.ts index 70c80a2..77abd22 100644 --- a/client/src/services/pdfExport.ts +++ b/client/src/services/pdfExport.ts @@ -3,7 +3,7 @@ import { db } from './db.js' import { getActiveMasterKey } from './auth.js' import { getLogbookKey } from './logbookKeys.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 { 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.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) } else { doc.setFont('Helvetica', 'normal'); @@ -239,7 +244,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string, doc.setFont('Helvetica', 'bold'); 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) } else { doc.setFont('Helvetica', 'normal'); diff --git a/client/src/types/signatures.ts b/client/src/types/signatures.ts new file mode 100644 index 0000000..8190a91 --- /dev/null +++ b/client/src/types/signatures.ts @@ -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 diff --git a/client/src/utils/entryCanonicalHash.ts b/client/src/utils/entryCanonicalHash.ts new file mode 100644 index 0000000..8997dfb --- /dev/null +++ b/client/src/utils/entryCanonicalHash.ts @@ -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 + const sorted: Record = {} + 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): Promise { + const canonical = JSON.stringify(sortValue(entry)) + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)) + return bufferToBase64url(digest) +} diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts new file mode 100644 index 0000000..e3b221a --- /dev/null +++ b/client/src/utils/logEntryPayload.ts @@ -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 { + const payload: Record = { + 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 +} diff --git a/client/src/utils/signatures.ts b/client/src/utils/signatures.ts index ff8a44b..1e2e53a 100644 --- a/client/src/utils/signatures.ts +++ b/client/src/utils/signatures.ts @@ -1,12 +1,50 @@ +import type { PasskeySignature, SignatureValue } from '../types/signatures.js' + export function isSignatureImage(value: string | undefined | null): boolean { return typeof value === 'string' && value.startsWith('data:image/') } -export function formatSignatureForExport( - value: string | undefined | null, +export function isPasskeySignature(value: unknown): value is PasskeySignature { + 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 + passkeyLabel: (username: string, signedAt: string) => string +} + +export function formatSignatureForExport( + value: SignatureValue | undefined | null, + labels: SignatureExportLabels ): string { 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 } + +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 +} diff --git a/server/src/index.ts b/server/src/index.ts index 253cc9d..9615f89 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ import authRouter from './routes/auth.js' import logbooksRouter from './routes/logbooks.js' import syncRouter from './routes/sync.js' import collaborationRouter from './routes/collaboration.js' +import signRouter from './routes/sign.js' import { prisma } from './db.js' dotenv.config() @@ -20,6 +21,7 @@ app.use('/api/auth', authRouter) app.use('/api/logbooks', logbooksRouter) app.use('/api/sync', syncRouter) app.use('/api/collaboration', collaborationRouter) +app.use('/api/sign', signRouter) // Health check endpoint app.get('/api/health', async (req, res) => { diff --git a/server/src/routes/logbooks.ts b/server/src/routes/logbooks.ts index 727cb52..0af051e 100644 --- a/server/src/routes/logbooks.ts +++ b/server/src/routes/logbooks.ts @@ -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) => { try { const { id } = req.params diff --git a/server/src/routes/sign.ts b/server/src/routes/sign.ts new file mode 100644 index 0000000..45243dd --- /dev/null +++ b/server/src/routes/sign.ts @@ -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() + +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 { + 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