From 878a18e9f79a00354dea4c3c57ea42f542a4993c Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 16:40:01 +0200 Subject: [PATCH] fix: Passkey-Sign Challenge und Signatur-Moduswechsel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebAuthn-Challenge wird als Bytes übergeben und unter options.challenge gespeichert. Passkey/Klassisch-Toggle erlaubt Wechsel zwischen Freigabearten. Co-authored-by: Cursor --- client/src/App.css | 30 +++ client/src/components/SignatureSection.tsx | 248 +++++++++++++++------ client/src/i18n/locales/de.json | 2 + client/src/i18n/locales/en.json | 2 + server/src/routes/sign.ts | 23 +- 5 files changed, 231 insertions(+), 74 deletions(-) diff --git a/client/src/App.css b/client/src/App.css index 853256a..03cc54b 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -2190,6 +2190,36 @@ body:has(.theme-cupertino) { line-height: 1.4; } +.signature-mode-toggle { + display: inline-flex; + gap: 4px; + padding: 3px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); +} + +.signature-mode-btn { + border: none; + border-radius: 7px; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + color: rgba(226, 232, 240, 0.75); + background: transparent; +} + +.signature-mode-btn.active { + color: #0f172a; + background: #e2e8f0; +} + +.signature-mode-btn:hover:not(.active) { + color: #f8fafc; + background: rgba(255, 255, 255, 0.06); +} + .passkey-sign-block { display: flex; flex-direction: column; diff --git a/client/src/components/SignatureSection.tsx b/client/src/components/SignatureSection.tsx index 3777df8..ed7842c 100644 --- a/client/src/components/SignatureSection.tsx +++ b/client/src/components/SignatureSection.tsx @@ -1,10 +1,13 @@ +import { useEffect, useState } from 'react' 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 type { PasskeySignature, SignatureValue } from '../types/signatures.js' import { isPasskeySignature } from '../utils/signatures.js' +type SignatureMode = 'passkey' | 'classic' + interface SignatureSectionProps { readOnly?: boolean disabled?: boolean @@ -26,6 +29,157 @@ function padValue(value: SignatureValue | ''): string { return value } +function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode { + if (isPasskeySignature(value)) return 'passkey' + if (value) return 'classic' + return passkeyAvailable ? 'passkey' : 'classic' +} + +interface RoleSignatureBlockProps { + roleLabel: string + passkeyLabel: string + padId: string + value: SignatureValue | '' + passkeySignature?: PasskeySignature + signatureValid: boolean + showPasskey: boolean + readOnly: boolean + disabled: boolean + classicHint?: string + offlineHint?: string + onChange: (value: SignatureValue | '') => void + onPasskeySign: () => Promise +} + +function RoleSignatureBlock({ + roleLabel, + passkeyLabel, + padId, + value, + passkeySignature, + signatureValid, + showPasskey, + readOnly, + disabled, + classicHint, + offlineHint, + onChange, + onPasskeySign +}: RoleSignatureBlockProps) { + const { t } = useTranslation() + const [mode, setMode] = useState(() => modeFromValue(value, showPasskey)) + + useEffect(() => { + setMode(modeFromValue(value, showPasskey)) + }, [value, showPasskey]) + + const switchToClassic = () => { + setMode('classic') + if (isPasskeySignature(value)) onChange('') + } + + const switchToPasskey = () => { + setMode('passkey') + if (value && !isPasskeySignature(value)) onChange('') + } + + const handlePadChange = (next: string) => { + setMode('classic') + onChange(next) + } + + if (readOnly) { + if (isPasskeySignature(value)) { + return ( +
+ +
+ ) + } + return ( +
+ {}} + disabled={disabled} + readOnly + /> +
+ ) + } + + const showPasskeyPanel = showPasskey && mode === 'passkey' + const showClassicPanel = !showPasskey || mode === 'classic' + + return ( +
+ {showPasskey && ( +
+ + +
+ )} + + {showPasskeyPanel && ( + + )} + + {showClassicPanel && ( + <> + + {classicHint && !passkeySignature && ( +

{classicHint}

+ )} + + )} + + {offlineHint && !showPasskey && ( +

{offlineHint}

+ )} +
+ ) +} + export default function SignatureSection({ readOnly = false, disabled = false, @@ -43,9 +197,6 @@ export default function SignatureSection({ }: SignatureSectionProps) { const { t } = useTranslation() - const skipperPasskey = isPasskeySignature(signSkipper) ? signSkipper : undefined - const crewPasskey = isPasskeySignature(signCrew) ? signCrew : undefined - const showSkipperPasskey = isOwner && isOnline const showCrewPasskey = hasWriteCollaborators && isOnline @@ -57,67 +208,36 @@ export default function SignatureSection({
-
- {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 2d26d1c..2d49a93 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -123,6 +123,8 @@ "sign_passkey_signed": "Freigegeben von {{username}}", "sign_passkey_export": "Passkey: {{username}} ({{date}})", "sign_passkey_clear": "Passkey-Freigabe entfernen", + "sign_mode_passkey": "Passkey", + "sign_mode_classic": "Klassisch", "sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen", "sign_passkey_cancelled": "Passkey-Freigabe abgebrochen", "sign_invalid": "Signatur ungültig — Inhalt wurde geändert", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 93376f2..6d49237 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -123,6 +123,8 @@ "sign_passkey_signed": "Signed by {{username}}", "sign_passkey_export": "Passkey: {{username}} ({{date}})", "sign_passkey_clear": "Remove Passkey signature", + "sign_mode_passkey": "Passkey", + "sign_mode_classic": "Classic", "sign_passkey_failed": "Passkey signing failed", "sign_passkey_cancelled": "Passkey signing cancelled", "sign_invalid": "Signature invalid — entry content changed", diff --git a/server/src/routes/sign.ts b/server/src/routes/sign.ts index 45243dd..142fdc8 100644 --- a/server/src/routes/sign.ts +++ b/server/src/routes/sign.ts @@ -150,12 +150,22 @@ router.post('/options', async (req: any, res) => { const nonce = crypto.randomBytes(16).toString('hex') const challengePayload = `${entryId}:${entryHash}:${role}:${nonce}` - const derivedChallenge = crypto + const challengeBytes = crypto .createHash('sha256') .update(challengePayload) - .digest('base64url') + .digest() - signingChallenges.set(derivedChallenge, { + const options = await generateAuthenticationOptions({ + rpID, + challenge: challengeBytes, + allowCredentials, + userVerification: 'required' + }) + + // Must key by options.challenge — the base64url value returned to the client. + // Passing a string challenge would be UTF-8 re-encoded by simplewebauthn, so the + // client challenge would not match a map key derived from our pre-encoded string. + signingChallenges.set(options.challenge, { userId: req.userId, logbookId, entryId, @@ -164,13 +174,6 @@ router.post('/options', async (req: any, res) => { 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)