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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user