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;
|
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 {
|
.passkey-sign-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Check } from 'lucide-react'
|
import { Check } from 'lucide-react'
|
||||||
import SignaturePad from './SignaturePad.tsx'
|
import SignaturePad from './SignaturePad.tsx'
|
||||||
import PasskeySignButton from './PasskeySignButton.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'
|
import { isPasskeySignature } from '../utils/signatures.js'
|
||||||
|
|
||||||
|
type SignatureMode = 'passkey' | 'classic'
|
||||||
|
|
||||||
interface SignatureSectionProps {
|
interface SignatureSectionProps {
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -26,6 +29,157 @@ function padValue(value: SignatureValue | ''): string {
|
|||||||
return value
|
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({
|
export default function SignatureSection({
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -43,9 +197,6 @@ export default function SignatureSection({
|
|||||||
}: SignatureSectionProps) {
|
}: SignatureSectionProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const skipperPasskey = isPasskeySignature(signSkipper) ? signSkipper : undefined
|
|
||||||
const crewPasskey = isPasskeySignature(signCrew) ? signCrew : undefined
|
|
||||||
|
|
||||||
const showSkipperPasskey = isOwner && isOnline
|
const showSkipperPasskey = isOwner && isOnline
|
||||||
const showCrewPasskey = hasWriteCollaborators && isOnline
|
const showCrewPasskey = hasWriteCollaborators && isOnline
|
||||||
|
|
||||||
@@ -57,67 +208,36 @@ export default function SignatureSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-grid signature-grid">
|
<div className="form-grid signature-grid">
|
||||||
<div className="signature-role-block">
|
<RoleSignatureBlock
|
||||||
{showSkipperPasskey && (
|
roleLabel={t('logs.sign_skipper')}
|
||||||
<PasskeySignButton
|
passkeyLabel={t('logs.sign_skipper')}
|
||||||
label={t('logs.sign_skipper')}
|
padId="sign-skipper"
|
||||||
signature={skipperPasskey}
|
value={signSkipper}
|
||||||
|
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
|
||||||
signatureValid={skipperSignatureValid}
|
signatureValid={skipperSignatureValid}
|
||||||
|
showPasskey={showSkipperPasskey}
|
||||||
|
readOnly={readOnly}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
canSign={!readOnly}
|
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
|
||||||
onSign={onPasskeySignSkipper}
|
offlineHint={!isOnline && isOwner ? t('logs.sign_offline_hint') : undefined}
|
||||||
onClear={skipperPasskey ? () => onSignSkipperChange('') : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!skipperPasskey && (
|
|
||||||
<SignaturePad
|
|
||||||
id="sign-skipper"
|
|
||||||
label={t('logs.sign_skipper')}
|
|
||||||
value={padValue(signSkipper)}
|
|
||||||
onChange={onSignSkipperChange}
|
onChange={onSignSkipperChange}
|
||||||
disabled={disabled}
|
onPasskeySign={onPasskeySignSkipper}
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{showSkipperPasskey && !skipperPasskey && !readOnly && (
|
<RoleSignatureBlock
|
||||||
<p className="signature-hint">{t('logs.sign_classic_or_passkey')}</p>
|
roleLabel={t('logs.sign_crew')}
|
||||||
)}
|
passkeyLabel={t('logs.sign_crew')}
|
||||||
|
padId="sign-crew"
|
||||||
{!isOnline && isOwner && !readOnly && (
|
value={signCrew}
|
||||||
<p className="signature-hint">{t('logs.sign_offline_hint')}</p>
|
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="signature-role-block">
|
|
||||||
{showCrewPasskey && (
|
|
||||||
<PasskeySignButton
|
|
||||||
label={t('logs.sign_crew')}
|
|
||||||
signature={crewPasskey}
|
|
||||||
signatureValid={crewSignatureValid}
|
signatureValid={crewSignatureValid}
|
||||||
disabled={disabled}
|
showPasskey={showCrewPasskey}
|
||||||
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}
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"sign_passkey_signed": "Freigegeben von {{username}}",
|
"sign_passkey_signed": "Freigegeben von {{username}}",
|
||||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||||
"sign_passkey_clear": "Passkey-Freigabe entfernen",
|
"sign_passkey_clear": "Passkey-Freigabe entfernen",
|
||||||
|
"sign_mode_passkey": "Passkey",
|
||||||
|
"sign_mode_classic": "Klassisch",
|
||||||
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
|
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
|
||||||
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
|
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
|
||||||
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
|
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"sign_passkey_signed": "Signed by {{username}}",
|
"sign_passkey_signed": "Signed by {{username}}",
|
||||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||||
"sign_passkey_clear": "Remove Passkey signature",
|
"sign_passkey_clear": "Remove Passkey signature",
|
||||||
|
"sign_mode_passkey": "Passkey",
|
||||||
|
"sign_mode_classic": "Classic",
|
||||||
"sign_passkey_failed": "Passkey signing failed",
|
"sign_passkey_failed": "Passkey signing failed",
|
||||||
"sign_passkey_cancelled": "Passkey signing cancelled",
|
"sign_passkey_cancelled": "Passkey signing cancelled",
|
||||||
"sign_invalid": "Signature invalid — entry content changed",
|
"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 nonce = crypto.randomBytes(16).toString('hex')
|
||||||
const challengePayload = `${entryId}:${entryHash}:${role}:${nonce}`
|
const challengePayload = `${entryId}:${entryHash}:${role}:${nonce}`
|
||||||
const derivedChallenge = crypto
|
const challengeBytes = crypto
|
||||||
.createHash('sha256')
|
.createHash('sha256')
|
||||||
.update(challengePayload)
|
.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,
|
userId: req.userId,
|
||||||
logbookId,
|
logbookId,
|
||||||
entryId,
|
entryId,
|
||||||
@@ -164,13 +174,6 @@ router.post('/options', async (req: any, res) => {
|
|||||||
expiresAt: Date.now() + CHALLENGE_TTL_MS
|
expiresAt: Date.now() + CHALLENGE_TTL_MS
|
||||||
})
|
})
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
|
||||||
rpID,
|
|
||||||
challenge: derivedChallenge,
|
|
||||||
allowCredentials,
|
|
||||||
userVerification: 'required'
|
|
||||||
})
|
|
||||||
|
|
||||||
return res.json(options)
|
return res.json(options)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error generating sign options:', error)
|
console.error('Error generating sign options:', error)
|
||||||
|
|||||||
Reference in New Issue
Block a user