fix: Passkey-Sign Challenge und Signatur-Moduswechsel

WebAuthn-Challenge wird als Bytes übergeben und unter options.challenge
gespeichert. Passkey/Klassisch-Toggle erlaubt Wechsel zwischen Freigabearten.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:40:01 +02:00
parent ce47fe5fdc
commit 878a18e9f7
5 changed files with 231 additions and 74 deletions
+30
View File
@@ -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;
+177 -57
View File
@@ -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<void>
}
function RoleSignatureBlock({
roleLabel,
passkeyLabel,
padId,
value,
passkeySignature,
signatureValid,
showPasskey,
readOnly,
disabled,
classicHint,
offlineHint,
onChange,
onPasskeySign
}: RoleSignatureBlockProps) {
const { t } = useTranslation()
const [mode, setMode] = useState<SignatureMode>(() => 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 (
<div className="signature-role-block">
<PasskeySignButton
label={passkeyLabel}
signature={value}
signatureValid={signatureValid}
disabled={disabled}
canSign={false}
onSign={onPasskeySign}
/>
</div>
)
}
return (
<div className="signature-role-block">
<SignaturePad
id={padId}
label={roleLabel}
value={padValue(value)}
onChange={() => {}}
disabled={disabled}
readOnly
/>
</div>
)
}
const showPasskeyPanel = showPasskey && mode === 'passkey'
const showClassicPanel = !showPasskey || mode === 'classic'
return (
<div className="signature-role-block">
{showPasskey && (
<div className="signature-mode-toggle" role="tablist" aria-label={passkeyLabel}>
<button
type="button"
role="tab"
aria-selected={mode === 'passkey'}
className={`signature-mode-btn ${mode === 'passkey' ? 'active' : ''}`}
onClick={switchToPasskey}
>
{t('logs.sign_mode_passkey')}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'classic'}
className={`signature-mode-btn ${mode === 'classic' ? 'active' : ''}`}
onClick={switchToClassic}
>
{t('logs.sign_mode_classic')}
</button>
</div>
)}
{showPasskeyPanel && (
<PasskeySignButton
label={passkeyLabel}
signature={passkeySignature}
signatureValid={signatureValid}
disabled={disabled}
canSign
onSign={onPasskeySign}
onClear={passkeySignature ? switchToClassic : undefined}
/>
)}
{showClassicPanel && (
<>
<SignaturePad
id={padId}
label={roleLabel}
value={padValue(value)}
onChange={handlePadChange}
disabled={disabled}
readOnly={false}
/>
{classicHint && !passkeySignature && (
<p className="signature-hint">{classicHint}</p>
)}
</>
)}
{offlineHint && !showPasskey && (
<p className="signature-hint">{offlineHint}</p>
)}
</div>
)
}
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({
</div>
<div className="form-grid signature-grid">
<div className="signature-role-block">
{showSkipperPasskey && (
<PasskeySignButton
label={t('logs.sign_skipper')}
signature={skipperPasskey}
<RoleSignatureBlock
roleLabel={t('logs.sign_skipper')}
passkeyLabel={t('logs.sign_skipper')}
padId="sign-skipper"
value={signSkipper}
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
signatureValid={skipperSignatureValid}
showPasskey={showSkipperPasskey}
readOnly={readOnly}
disabled={disabled}
canSign={!readOnly}
onSign={onPasskeySignSkipper}
onClear={skipperPasskey ? () => onSignSkipperChange('') : undefined}
/>
)}
{!skipperPasskey && (
<SignaturePad
id="sign-skipper"
label={t('logs.sign_skipper')}
value={padValue(signSkipper)}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
offlineHint={!isOnline && isOwner ? t('logs.sign_offline_hint') : undefined}
onChange={onSignSkipperChange}
disabled={disabled}
readOnly={readOnly}
onPasskeySign={onPasskeySignSkipper}
/>
)}
{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}
<RoleSignatureBlock
roleLabel={t('logs.sign_crew')}
passkeyLabel={t('logs.sign_crew')}
padId="sign-crew"
value={signCrew}
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
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}
showPasskey={showCrewPasskey}
readOnly={readOnly}
disabled={disabled}
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
onChange={onSignCrewChange}
onPasskeySign={onPasskeySignCrew}
/>
)}
{showCrewPasskey && !crewPasskey && !readOnly && (
<p className="signature-hint">{t('logs.sign_crew_passkey_hint')}</p>
)}
</div>
</div>
</div>
)
+2
View File
@@ -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",
+2
View File
@@ -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",
+13 -10
View File
@@ -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)