fix: Skipper-Signatur für WRITE-Collaborators und Events-Hash

WRITE-Collaborators dürfen Skipper-Freigaben leisten; der Eintrags-Hash sortiert events nach time, damit Umordnungen die Passkey-Signatur invalidieren.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:57:00 +02:00
parent 241b2fdf63
commit 1710007efe
4 changed files with 53 additions and 21 deletions
+3 -3
View File
@@ -98,7 +98,7 @@ export default function LogEntryEditor({
// Signatures // Signatures
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('') const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('') const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
const [isOwner, setIsOwner] = useState(false) const [canSignSkipper, setCanSignSkipper] = useState(false)
const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false) const [hasWriteCollaborators, setHasWriteCollaborators] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine) const [isOnline, setIsOnline] = useState(navigator.onLine)
const [entryHash, setEntryHash] = useState('') const [entryHash, setEntryHash] = useState('')
@@ -211,7 +211,7 @@ export default function LogEntryEditor({
useEffect(() => { useEffect(() => {
getLogbookAccess(logbookId).then((access) => { getLogbookAccess(logbookId).then((access) => {
if (!access) return if (!access) return
setIsOwner(access.isOwner) setCanSignSkipper(access.isOwner || access.role === 'WRITE')
setHasWriteCollaborators(access.writeCollaboratorCount > 0) setHasWriteCollaborators(access.writeCollaboratorCount > 0)
}) })
}, [logbookId]) }, [logbookId])
@@ -1454,7 +1454,7 @@ export default function LogEntryEditor({
readOnly={readOnly} readOnly={readOnly}
disabled={saving} disabled={saving}
isOnline={isOnline} isOnline={isOnline}
isOwner={isOwner} canSignSkipper={canSignSkipper}
hasWriteCollaborators={hasWriteCollaborators} hasWriteCollaborators={hasWriteCollaborators}
signSkipper={signSkipper} signSkipper={signSkipper}
signCrew={signCrew} signCrew={signCrew}
+4 -4
View File
@@ -12,7 +12,7 @@ interface SignatureSectionProps {
readOnly?: boolean readOnly?: boolean
disabled?: boolean disabled?: boolean
isOnline: boolean isOnline: boolean
isOwner: boolean canSignSkipper: boolean
hasWriteCollaborators: boolean hasWriteCollaborators: boolean
signSkipper: SignatureValue | '' signSkipper: SignatureValue | ''
signCrew: SignatureValue | '' signCrew: SignatureValue | ''
@@ -188,7 +188,7 @@ export default function SignatureSection({
readOnly = false, readOnly = false,
disabled = false, disabled = false,
isOnline, isOnline,
isOwner, canSignSkipper,
hasWriteCollaborators, hasWriteCollaborators,
signSkipper, signSkipper,
signCrew, signCrew,
@@ -202,7 +202,7 @@ export default function SignatureSection({
}: SignatureSectionProps) { }: SignatureSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const showSkipperPasskey = isOwner && isOnline const showSkipperPasskey = canSignSkipper && isOnline
const showCrewPasskey = hasWriteCollaborators && isOnline const showCrewPasskey = hasWriteCollaborators && isOnline
const hasSignature = !!(signSkipper || signCrew) const hasSignature = !!(signSkipper || signCrew)
@@ -231,7 +231,7 @@ export default function SignatureSection({
readOnly={readOnly} readOnly={readOnly}
disabled={disabled} disabled={disabled}
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined} 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} onChange={onSignSkipperChange}
onPasskeySign={onPasskeySignSkipper} onPasskeySign={onPasskeySignSkipper}
onBeforeSign={onBeforeSign} onBeforeSign={onBeforeSign}
+25 -3
View File
@@ -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<string, unknown>).time)
: ''
const timeB =
typeof b === 'object' && b !== null && 'time' in b
? String((b as Record<string, unknown>).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 (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<string, unknown> const obj = value as Record<string, unknown>
const sorted: Record<string, unknown> = {} const sorted: Record<string, unknown> = {}
for (const key of Object.keys(obj).sort()) { 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 return sorted
} }
+21 -11
View File
@@ -60,14 +60,18 @@ async function getLogbookWithAccess(logbookId: string, userId: string) {
return { logbook, isOwner, collaboration } return { logbook, isOwner, collaboration }
} }
function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: string } | null }) {
return access.isOwner || access.collaboration?.role === 'WRITE'
}
async function getAllowCredentialsForRole( async function getAllowCredentialsForRole(
logbookId: string, logbookId: string,
ownerUserId: string, role: 'skipper' | 'crew',
role: 'skipper' | 'crew' requestingUserId: string
) { ) {
if (role === 'skipper') { if (role === 'skipper') {
const credentials = await prisma.credential.findMany({ const credentials = await prisma.credential.findMany({
where: { userId: ownerUserId } where: { userId: requestingUserId }
}) })
return credentials.map((cred) => ({ return credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'), id: Buffer.from(cred.credentialId, 'base64url'),
@@ -102,7 +106,13 @@ async function isAuthorizedSigner(
role: 'skipper' | 'crew' role: 'skipper' | 'crew'
): Promise<boolean> { ): Promise<boolean> {
if (role === 'skipper') { 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({ 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' }) return res.status(403).json({ error: 'Forbidden: Access denied' })
} }
if (role === 'skipper' && !access.isOwner) { if (!hasWriteAccess(access)) {
return res.status(403).json({ error: 'Forbidden: Only the logbook owner can sign as skipper' }) return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
} }
const allowCredentials = await getAllowCredentialsForRole( const allowCredentials = await getAllowCredentialsForRole(
logbookId, logbookId,
access.logbook.userId, role,
role req.userId
) )
if (allowCredentials.length === 0) { if (allowCredentials.length === 0) {
return res.status(400).json({ return res.status(400).json({
error: role === 'crew' error: role === 'crew'
? 'No write collaborators with passkeys found' ? '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' }) return res.status(403).json({ error: 'Forbidden: Access denied' })
} }
if (role === 'skipper' && !access.isOwner) { if (!hasWriteAccess(access)) {
return res.status(403).json({ error: 'Forbidden: Only the logbook owner can sign as skipper' }) return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
} }
const dbCred = await prisma.credential.findUnique({ const dbCred = await prisma.credential.findUnique({