Files
kapteins-daagbok/server/src/routes/sign.ts
T
elpatron 4acb9b1290 fix(logs): Crew-Unterschrift mit Benutzerzuordnung und Owner-Crew-Signatur
Klassische Crew-Signaturen speichern Unterzeichner und Datum; Export und UI zeigen die Zuordnung. Eigner ohne WRITE-Collaborators dürfen wieder als Crew per Passkey signieren.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 19:24:46 +02:00

299 lines
8.4 KiB
TypeScript

import { Router } from 'express'
import crypto from 'crypto'
import {
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server'
import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
const router = Router()
const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
const CHALLENGE_TTL_MS = 5 * 60 * 1000
interface SigningContext {
userId: string
logbookId: string
entryId: string
entryHash: string
role: 'skipper' | 'crew'
expiresAt: number
}
const signingChallenges = new Map<string, SigningContext>()
function pruneExpiredChallenges() {
const now = Date.now()
for (const [key, ctx] of signingChallenges) {
if (ctx.expiresAt <= now) signingChallenges.delete(key)
}
}
router.use(requireUser)
async function getLogbookWithAccess(logbookId: string, userId: string) {
const logbook = await prisma.logbook.findUnique({
where: { id: logbookId },
include: {
collaborators: {
where: { userId }
}
}
})
if (!logbook) return null
const isOwner = logbook.userId === userId
const collaboration = logbook.collaborators[0]
if (!isOwner && !collaboration) return null
return { logbook, isOwner, collaboration }
}
function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: string } | null }) {
// Intentional (HYBRID-ELECTRONIC-SIGNATURES.md §2.1): owner OR WRITE collaborator may sign entries.
return access.isOwner || access.collaboration?.role === 'WRITE'
}
async function hasWriteCollaborators(logbookId: string): Promise<boolean> {
const count = await prisma.collaboration.count({
where: { logbookId, role: 'WRITE' }
})
return count > 0
}
async function getAllowCredentialsForRole(
logbookId: string,
role: 'skipper' | 'crew',
requestingUserId: string
) {
if (role === 'skipper') {
const credentials = await prisma.credential.findMany({
where: { userId: requestingUserId }
})
return credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
}
const collaborations = await prisma.collaboration.findMany({
where: { logbookId, role: 'WRITE' },
select: { userId: true }
})
const userIds = collaborations.map((c) => c.userId)
if (userIds.length === 0) {
const credentials = await prisma.credential.findMany({
where: { userId: requestingUserId }
})
return credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
}
const credentials = await prisma.credential.findMany({
where: { userId: { in: userIds } }
})
return credentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key' as const,
transports: cred.transports as any[]
}))
}
async function isAuthorizedSigner(
logbookId: string,
ownerUserId: string,
signerUserId: string,
role: 'skipper' | 'crew'
): Promise<boolean> {
if (role === 'skipper') {
return signerUserId === ownerUserId
}
const collaboration = await prisma.collaboration.findUnique({
where: {
logbookId_userId: { logbookId, userId: signerUserId }
}
})
if (collaboration?.role === 'WRITE') return true
if (signerUserId === ownerUserId) {
return !(await hasWriteCollaborators(logbookId))
}
return false
}
router.post('/options', async (req: any, res) => {
try {
pruneExpiredChallenges()
const { logbookId, entryId, entryHash, role } = req.body
if (!logbookId || !entryId || !entryHash || !role) {
return res.status(400).json({ error: 'logbookId, entryId, entryHash and role are required' })
}
if (role !== 'skipper' && role !== 'crew') {
return res.status(400).json({ error: 'role must be skipper or crew' })
}
const access = await getLogbookWithAccess(logbookId, req.userId)
if (!access) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
if (!hasWriteAccess(access)) {
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
}
const authorized = await isAuthorizedSigner(
logbookId,
access.logbook.userId,
req.userId,
role
)
if (!authorized) {
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
}
const allowCredentials = await getAllowCredentialsForRole(
logbookId,
role,
req.userId
)
if (allowCredentials.length === 0) {
return res.status(400).json({
error: 'No passkey credentials found for signer'
})
}
const nonce = crypto.randomBytes(16).toString('hex')
const challengePayload = `${entryId}:${entryHash}:${role}:${nonce}`
const challengeBytes = crypto
.createHash('sha256')
.update(challengePayload)
.digest()
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,
entryHash,
role,
expiresAt: Date.now() + CHALLENGE_TTL_MS
})
return res.json(options)
} catch (error: any) {
console.error('Error generating sign options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
router.post('/verify', async (req: any, res) => {
try {
pruneExpiredChallenges()
const { credentialResponse, challenge, logbookId, entryId, entryHash, role } = req.body
if (!credentialResponse || !challenge || !logbookId || !entryId || !entryHash || !role) {
return res.status(400).json({ error: 'Missing required parameters' })
}
const context = signingChallenges.get(challenge)
if (!context || context.expiresAt <= Date.now()) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
if (
context.logbookId !== logbookId ||
context.entryId !== entryId ||
context.entryHash !== entryHash ||
context.role !== role
) {
return res.status(400).json({ error: 'Signing context mismatch' })
}
const access = await getLogbookWithAccess(logbookId, req.userId)
if (!access) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
if (!hasWriteAccess(access)) {
return res.status(403).json({ error: 'Forbidden: WRITE access required to sign entries' })
}
const dbCred = await prisma.credential.findUnique({
where: { credentialId: credentialResponse.id },
include: { user: true }
})
if (!dbCred) {
return res.status(400).json({ error: 'Credential not recognized' })
}
const authorized = await isAuthorizedSigner(
logbookId,
access.logbook.userId,
dbCred.userId,
role
)
if (!authorized) {
return res.status(403).json({ error: 'Forbidden: Signer not authorized for this role' })
}
const verification = await verifyAuthenticationResponse({
response: credentialResponse,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: Buffer.from(dbCred.credentialId, 'base64url'),
credentialPublicKey: dbCred.publicKey,
counter: Number(dbCred.counter)
}
})
if (!verification.verified || !verification.authenticationInfo) {
return res.status(400).json({ error: 'Signature verification failed' })
}
signingChallenges.delete(challenge)
await prisma.credential.update({
where: { id: dbCred.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
})
return res.json({
verified: true,
userId: dbCred.user.id,
username: dbCred.user.username,
credentialId: dbCred.credentialId,
signedAt: new Date().toISOString()
})
} catch (error: any) {
console.error('Error verifying signature:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router