From 1710007efe410d6a419154715f0637742c1f0f7b Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 16:57:00 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Skipper-Signatur=20f=C3=BCr=20WRITE-Coll?= =?UTF-8?q?aborators=20und=20Events-Hash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WRITE-Collaborators dürfen Skipper-Freigaben leisten; der Eintrags-Hash sortiert events nach time, damit Umordnungen die Passkey-Signatur invalidieren. Co-authored-by: Cursor --- client/src/components/LogEntryEditor.tsx | 6 ++-- client/src/components/SignatureSection.tsx | 8 +++--- client/src/utils/entryCanonicalHash.ts | 28 +++++++++++++++++-- server/src/routes/sign.ts | 32 ++++++++++++++-------- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index e099428..329e993 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -98,7 +98,7 @@ export default function LogEntryEditor({ // Signatures const [signSkipper, setSignSkipper] = useState('') const [signCrew, setSignCrew] = useState('') - const [isOwner, setIsOwner] = useState(false) + const [canSignSkipper, setCanSignSkipper] = useState(false) const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false) const [isOnline, setIsOnline] = useState(navigator.onLine) const [entryHash, setEntryHash] = useState('') @@ -211,7 +211,7 @@ export default function LogEntryEditor({ useEffect(() => { getLogbookAccess(logbookId).then((access) => { if (!access) return - setIsOwner(access.isOwner) + setCanSignSkipper(access.isOwner || access.role === 'WRITE') setHasWriteCollaborators(access.writeCollaboratorCount > 0) }) }, [logbookId]) @@ -1454,7 +1454,7 @@ export default function LogEntryEditor({ readOnly={readOnly} disabled={saving} isOnline={isOnline} - isOwner={isOwner} + canSignSkipper={canSignSkipper} hasWriteCollaborators={hasWriteCollaborators} signSkipper={signSkipper} signCrew={signCrew} diff --git a/client/src/components/SignatureSection.tsx b/client/src/components/SignatureSection.tsx index 98d3cf4..b64aa2e 100644 --- a/client/src/components/SignatureSection.tsx +++ b/client/src/components/SignatureSection.tsx @@ -12,7 +12,7 @@ interface SignatureSectionProps { readOnly?: boolean disabled?: boolean isOnline: boolean - isOwner: boolean + canSignSkipper: boolean hasWriteCollaborators: boolean signSkipper: SignatureValue | '' signCrew: SignatureValue | '' @@ -188,7 +188,7 @@ export default function SignatureSection({ readOnly = false, disabled = false, isOnline, - isOwner, + canSignSkipper, hasWriteCollaborators, signSkipper, signCrew, @@ -202,7 +202,7 @@ export default function SignatureSection({ }: SignatureSectionProps) { const { t } = useTranslation() - const showSkipperPasskey = isOwner && isOnline + const showSkipperPasskey = canSignSkipper && isOnline const showCrewPasskey = hasWriteCollaborators && isOnline const hasSignature = !!(signSkipper || signCrew) @@ -231,7 +231,7 @@ export default function SignatureSection({ readOnly={readOnly} disabled={disabled} classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined} - offlineHint={!isOnline && isOwner ? t('logs.sign_offline_hint') : undefined} + offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined} onChange={onSignSkipperChange} onPasskeySign={onPasskeySignSkipper} onBeforeSign={onBeforeSign} diff --git a/client/src/utils/entryCanonicalHash.ts b/client/src/utils/entryCanonicalHash.ts index 8997dfb..251b17c 100644 --- a/client/src/utils/entryCanonicalHash.ts +++ b/client/src/utils/entryCanonicalHash.ts @@ -1,10 +1,32 @@ -function sortValue(value: unknown): unknown { +const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew']) + +function sortEventsByTime(items: unknown[]): unknown[] { + return [...items] + .sort((a, b) => { + const timeA = + typeof a === 'object' && a !== null && 'time' in a + ? String((a as Record).time) + : '' + const timeB = + typeof b === 'object' && b !== null && 'time' in b + ? String((b as Record).time) + : '' + return timeA.localeCompare(timeB) + }) + .map((item) => sortValue(item)) +} + +function sortValue(value: unknown, parentKey?: string): unknown { if (value === null || typeof value !== 'object') return value - if (Array.isArray(value)) return value.map(sortValue) + if (Array.isArray(value)) { + if (parentKey === 'events') return sortEventsByTime(value) + return value.map((item) => sortValue(item)) + } const obj = value as Record const sorted: Record = {} for (const key of Object.keys(obj).sort()) { - sorted[key] = sortValue(obj[key]) + if (SIGNATURE_KEYS.has(key)) continue + sorted[key] = sortValue(obj[key], key) } return sorted } diff --git a/server/src/routes/sign.ts b/server/src/routes/sign.ts index 225d6d7..18f49a8 100644 --- a/server/src/routes/sign.ts +++ b/server/src/routes/sign.ts @@ -60,14 +60,18 @@ async function getLogbookWithAccess(logbookId: string, userId: string) { return { logbook, isOwner, collaboration } } +function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: string } | null }) { + return access.isOwner || access.collaboration?.role === 'WRITE' +} + async function getAllowCredentialsForRole( logbookId: string, - ownerUserId: string, - role: 'skipper' | 'crew' + role: 'skipper' | 'crew', + requestingUserId: string ) { if (role === 'skipper') { const credentials = await prisma.credential.findMany({ - where: { userId: ownerUserId } + where: { userId: requestingUserId } }) return credentials.map((cred) => ({ id: Buffer.from(cred.credentialId, 'base64url'), @@ -102,7 +106,13 @@ async function isAuthorizedSigner( role: 'skipper' | 'crew' ): Promise { if (role === 'skipper') { - return signerUserId === ownerUserId + if (signerUserId === ownerUserId) return true + const collaboration = await prisma.collaboration.findUnique({ + where: { + logbookId_userId: { logbookId, userId: signerUserId } + } + }) + return collaboration?.role === 'WRITE' } const collaboration = await prisma.collaboration.findUnique({ @@ -130,21 +140,21 @@ router.post('/options', async (req: any, res) => { 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' }) + if (!hasWriteAccess(access)) { + return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' }) } const allowCredentials = await getAllowCredentialsForRole( logbookId, - access.logbook.userId, - role + role, + req.userId ) if (allowCredentials.length === 0) { return res.status(400).json({ error: role === 'crew' ? 'No write collaborators with passkeys found' - : 'No passkey credentials found for owner' + : 'No passkey credentials found for signer' }) } @@ -209,8 +219,8 @@ router.post('/verify', async (req: any, res) => { 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' }) + if (!hasWriteAccess(access)) { + return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' }) } const dbCred = await prisma.credential.findUnique({