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:
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user