feat(security): Session-Cookies statt X-User-Id und API-Härtung

Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach
WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und
ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 13:47:24 +02:00
co-authored by Cursor
parent 4f3f530f1f
commit dea33e3f00
33 changed files with 657 additions and 397 deletions
+38
View File
@@ -0,0 +1,38 @@
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
export async function apiFetch(
input: string,
init: RequestInit = {}
): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return fetch(input, {
...init,
headers,
credentials: 'include'
})
}
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
const res = await apiFetch(input, init)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const message =
typeof data === 'object' && data && 'error' in data && typeof data.error === 'string'
? data.error
: `Request failed (${res.status})`
throw new ApiError(message, res.status)
}
return data as T
}
+63 -75
View File
@@ -6,27 +6,22 @@ import {
deriveKeyFromPin,
encryptBuffer,
decryptBuffer,
generateRecoveryPhrase,
base64ToBuffer,
bufferToBase64
generateRecoveryPhrase
} from './crypto.js'
import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
import { apiFetch, apiJson } from './api.js'
const API_BASE = '/api/auth'
// Shared in-memory container for the active user's session master key
// Master key lives in memory only (never localStorage — XSS-resistant).
let activeMasterKey: ArrayBuffer | null = null
// Restore key from localStorage on load if present (survives reload/restart)
try {
const savedKey = localStorage.getItem('active_master_key')
if (savedKey) {
activeMasterKey = base64ToBuffer(savedKey)
}
} catch (e) {
console.error('Failed to restore active master key:', e)
localStorage.removeItem('active_master_key')
} catch {
/* ignore */
}
export function getActiveMasterKey(): ArrayBuffer | null {
@@ -35,17 +30,34 @@ export function getActiveMasterKey(): ArrayBuffer | null {
export function setActiveMasterKey(key: ArrayBuffer | null) {
activeMasterKey = key
if (key) {
try {
localStorage.setItem('active_master_key', bufferToBase64(key))
} catch (e) {
console.error('Failed to save master key to localStorage:', e)
}
} else {
localStorage.removeItem('active_master_key')
}
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
try {
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`)
} catch {
return { authenticated: false }
}
}
export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST'
})
const credentialResponse = await startAuthentication({ optionsJSON: options })
await apiJson(`${API_BASE}/reauth-verify`, {
method: 'POST',
body: JSON.stringify({
credentialResponse,
challenge: options.challenge
})
})
return true
}
// PIN fallback mechanism functions
export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise<void> {
const pinKey = await deriveKeyFromPin(pin, username)
@@ -152,19 +164,11 @@ export interface RegistrationResult {
export async function registerUser(username: string): Promise<RegistrationResult> {
// 1. Get registration options
const optionsRes = await fetch(`${API_BASE}/register-options`, {
const options = await apiJson<any>(`${API_BASE}/register-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch registration options')
}
const options = await optionsRes.json()
// Request the PRF extension WITH an evaluation salt. This must match the
// salt used during login (PRF_SALT), otherwise the PRF-derived key produced
// at login would never match what was stored here and every login would fall
@@ -229,9 +233,8 @@ export async function registerUser(username: string): Promise<RegistrationResult
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
// 4. Verify registration on the server
const verifyRes = await fetch(`${API_BASE}/register-verify`, {
const result = await apiJson<{ verified: boolean; userId: string }>(`${API_BASE}/register-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credentialResponse,
@@ -243,13 +246,6 @@ export async function registerUser(username: string): Promise<RegistrationResult
encryptedMasterKeyRecTag: encryptedRecovery.tag
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify registration response')
}
const result = await verifyRes.json()
if (result.verified) {
setActiveMasterKey(masterKey)
localStorage.setItem('active_username', username)
@@ -292,19 +288,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
}
// 1. Get authentication options
const optionsRes = await fetch(`${API_BASE}/login-options`, {
const options = await apiJson<any>(`${API_BASE}/login-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch login options')
}
const options = await optionsRes.json()
// Add PRF extension evaluation input.
// When the server returned a concrete allowCredentials list we use
// `evalByCredential` (keyed by the base64url credential id), which is the
@@ -366,21 +354,23 @@ export async function loginUser(username?: string): Promise<LoginResult> {
}
// 3. Verify assertion on the server
const verifyRes = await fetch(`${API_BASE}/login-verify`, {
const result = await apiJson<{
verified: boolean
userId: string
username: string
encryptedMasterKeyPrf: string | null
encryptedMasterKeyPrfIv: string | null
encryptedMasterKeyPrfTag: string | null
encryptedMasterKeyRec: string
encryptedMasterKeyRecIv: string
encryptedMasterKeyRecTag: string
}>(`${API_BASE}/login-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialResponse,
challenge: options.challenge
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify login response')
}
const result = await verifyRes.json()
if (!result.verified) {
return { verified: false, prfSuccess: false }
}
@@ -407,7 +397,12 @@ export async function loginUser(username?: string): Promise<LoginResult> {
console.log('PRF extension results first present:', !!prfResults.results?.first)
}
if (prfResults?.results?.first && result.encryptedMasterKeyPrf) {
if (
prfResults?.results?.first &&
result.encryptedMasterKeyPrf &&
result.encryptedMasterKeyPrfIv &&
result.encryptedMasterKeyPrfTag
) {
try {
const firstBuffer = typeof prfResults.results.first === 'string'
? base64urlToBuffer(prfResults.results.first)
@@ -475,22 +470,14 @@ export async function completeLoginWithRecovery(
const prfKey = await deriveKeyFromPrf(firstBuffer)
const encryptedPrf = await encryptBuffer(decryptedMaster, prfKey)
console.log('Sending PRF credentials to server...')
const enrollRes = await fetch(`${API_BASE}/enroll-prf`, {
await apiJson(`${API_BASE}/enroll-prf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': encryptedPayloads.userId
},
body: JSON.stringify({
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
encryptedMasterKeyPrfIv: encryptedPrf.iv,
encryptedMasterKeyPrfTag: encryptedPrf.tag
})
})
console.log('Enrollment response status:', enrollRes.status)
if (!enrollRes.ok) {
console.warn('Server rejected PRF enrollment')
}
} catch (err) {
console.error('Failed to encrypt/enroll master key with PRF key:', err)
}
@@ -508,25 +495,26 @@ export async function completeLoginWithRecovery(
}
}
export function logoutUser() {
export async function logoutUser() {
setActiveMasterKey(null)
clearLogbookKeysCache()
localStorage.removeItem('active_username')
localStorage.removeItem('active_userid')
try {
await apiFetch(`${API_BASE}/logout`, { method: 'POST' })
} catch {
/* ignore network errors on logout */
}
}
export async function deleteAccount(): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
const username = localStorage.getItem('active_username')
if (!userId) return false
if (!localStorage.getItem('active_userid')) return false
try {
const res = await fetch(`${API_BASE}/delete-account`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
await reauthWithPasskey()
const res = await apiFetch(`${API_BASE}/delete-account`, { method: 'DELETE' })
if (res.ok) {
if (username) {
@@ -546,7 +534,7 @@ export async function deleteAccount(): Promise<boolean> {
])
// Wipe localStorage and session variables
logoutUser()
await logoutUser()
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
return true
}
+9 -25
View File
@@ -1,5 +1,6 @@
import { startAuthentication } from '@simplewebauthn/browser'
import type { PasskeySignature } from '../types/signatures.js'
import { apiJson } from './api.js'
export async function signLogEntry(params: {
logbookId: string
@@ -7,32 +8,22 @@ export async function signLogEntry(params: {
entryHash: string
role: 'skipper' | 'crew'
}): Promise<PasskeySignature> {
const userId = localStorage.getItem('active_userid')
if (!userId) throw new Error('User not authenticated')
if (!localStorage.getItem('active_userid')) throw new Error('User not authenticated')
const optionsRes = await fetch('/api/sign/options', {
const options = await apiJson<any>('/api/sign/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify(params)
})
if (!optionsRes.ok) {
const err = await optionsRes.json().catch(() => ({}))
throw new Error(err.error || 'Failed to start passkey signing')
}
const options = await optionsRes.json()
const credentialResponse = await startAuthentication({ optionsJSON: options })
const verifyRes = await fetch('/api/sign/verify', {
const result = await apiJson<{
userId: string
username: string
credentialId: string
signedAt: string
}>('/api/sign/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
credentialResponse,
challenge: options.challenge,
@@ -43,13 +34,6 @@ export async function signLogEntry(params: {
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json().catch(() => ({}))
throw new Error(err.error || 'Passkey signature verification failed')
}
const result = await verifyRes.json()
return {
kind: 'passkey',
version: 1,
+3 -11
View File
@@ -1,3 +1,5 @@
import { apiFetch } from './api.js'
export type FeedbackCategory = 'bug' | 'feature' | 'general'
export class FeedbackApiError extends Error {
@@ -19,15 +21,6 @@ export function isValidFeedbackEmail(email: string): boolean {
return EMAIL_PATTERN.test(email.trim())
}
function buildFeedbackHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
const userId = localStorage.getItem('active_userid')
if (userId) headers['X-User-Id'] = userId
return headers
}
export async function sendFeedback(payload: {
category: FeedbackCategory
message: string
@@ -40,9 +33,8 @@ export async function sendFeedback(payload: {
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
}
const res = await fetch('/api/feedback', {
const res = await apiFetch('/api/feedback', {
method: 'POST',
headers: buildFeedbackHeaders(),
body: JSON.stringify({
category: payload.category,
message: payload.message,
+4 -18
View File
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { apiFetch } from './api.js'
const API_BASE = '/api/logbooks'
@@ -66,13 +67,7 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
}
})
const response = await apiFetch(API_BASE, { method: 'GET' })
if (response.ok) {
const serverLogbooks = await response.json()
@@ -208,12 +203,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (navigator.onLine) {
try {
const response = await fetch(API_BASE, {
const response = await apiFetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
id: localId,
...payloadData
@@ -301,12 +292,7 @@ export async function deleteLogbook(id: string): Promise<void> {
if (navigator.onLine) {
try {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE',
headers: {
'X-User-Id': userId
}
})
const response = await apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' })
if (!response.ok) {
console.warn('Server deletion failed or was rejected')
}
+4 -7
View File
@@ -1,3 +1,5 @@
import { apiJson } from './api.js'
export interface LogbookAccess {
isOwner: boolean
role: 'OWNER' | 'READ' | 'WRITE'
@@ -5,15 +7,10 @@ export interface LogbookAccess {
}
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !navigator.onLine) return null
if (!localStorage.getItem('active_userid') || !navigator.onLine) return null
try {
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
headers: { 'X-User-Id': userId }
})
if (!res.ok) return null
return res.json()
return await apiJson<LogbookAccess>(`/api/logbooks/${logbookId}/access`)
} catch {
return null
}
+12 -40
View File
@@ -1,8 +1,6 @@
const API_BASE = '/api/push'
import { apiFetch, apiJson } from './api.js'
function getUserId(): string | null {
return localStorage.getItem('active_userid')
}
const API_BASE = '/api/push'
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
@@ -46,38 +44,24 @@ async function fetchVapidPublicKey(): Promise<string | null> {
}
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
const userId = getUserId()
if (!userId) return { collaboratorChangesEnabled: false }
const res = await fetch(`${API_BASE}/prefs`, {
headers: { 'X-User-Id': userId }
})
if (!res.ok) {
throw new Error('Failed to load push notification preferences')
if (!localStorage.getItem('active_userid')) {
return { collaboratorChangesEnabled: false }
}
return res.json()
return apiJson<{ collaboratorChangesEnabled: boolean }>(`${API_BASE}/prefs`)
}
export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise<void> {
const userId = getUserId()
if (!userId) throw new Error('Not authenticated')
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const res = await fetch(`${API_BASE}/prefs`, {
await apiJson(`${API_BASE}/prefs`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ collaboratorChangesEnabled })
})
if (!res.ok) {
throw new Error('Failed to save push notification preferences')
}
}
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
const userId = getUserId()
if (!userId) throw new Error('Not authenticated')
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
@@ -86,12 +70,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
const locale = document.documentElement.lang?.startsWith('en') ? 'en' : 'de'
const res = await fetch(`${API_BASE}/subscription`, {
await apiJson(`${API_BASE}/subscription`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({
endpoint: json.endpoint,
keys: json.keys,
@@ -99,9 +79,6 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
userAgent: navigator.userAgent
})
})
if (!res.ok) {
throw new Error('Failed to register push subscription on server')
}
}
export async function subscribeToPush(): Promise<void> {
@@ -137,7 +114,6 @@ export async function subscribeToPush(): Promise<void> {
export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return
const userId = getUserId()
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
@@ -145,13 +121,9 @@ export async function unsubscribeFromPush(): Promise<void> {
const endpoint = subscription.endpoint
await subscription.unsubscribe()
if (userId && endpoint) {
await fetch(`${API_BASE}/subscription`, {
if (localStorage.getItem('active_userid') && endpoint) {
await apiFetch(`${API_BASE}/subscription`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ endpoint })
}).catch(() => {})
}
+10 -14
View File
@@ -1,5 +1,7 @@
import { db, type SyncQueueItem } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
import { getLogbookAccess } from './logbookAccess.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
@@ -126,19 +128,17 @@ function scheduleResync(logbookId: string) {
// Push local sync queue items to the server
async function pushChanges(logbookId: string): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
if (!userId) return false
if (!getActiveMasterKey() || !localStorage.getItem('active_userid')) return false
const access = await getLogbookAccess(logbookId)
if (access && access.role === 'READ') return true
const pending = await coalesceSyncQueue(logbookId)
if (pending.length === 0) return true
try {
const response = await fetch(`${API_BASE}/push`, {
const response = await apiFetch(`${API_BASE}/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-User-Id': userId
},
body: JSON.stringify({ items: pending })
})
@@ -187,15 +187,11 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
// Pull updates from the server and apply last-write-wins
async function pullChanges(logbookId: string): Promise<boolean> {
const userId = localStorage.getItem('active_userid')
if (!userId) return false
if (!localStorage.getItem('active_userid')) return false
try {
const response = await fetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
method: 'GET',
headers: {
'X-User-Id': userId
}
const response = await apiFetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
method: 'GET'
})
if (!response.ok) {
+7 -14
View File
@@ -1,3 +1,5 @@
import { apiFetch } from './api.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
@@ -8,17 +10,6 @@ export class WeatherApiError extends Error {
}
}
function buildWeatherHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
const userId = localStorage.getItem('active_userid')
const userKey = localStorage.getItem('owm_api_key')?.trim()
if (userId) headers['X-User-Id'] = userId
if (userKey) headers['X-OWM-Api-Key'] = userKey
return headers
}
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
@@ -35,9 +26,11 @@ export async function fetchOpenWeatherCurrent(params: {
throw new WeatherApiError('lat/lon or location query required')
}
const res = await fetch(`/api/weather/current?${searchParams.toString()}`, {
headers: buildWeatherHeaders()
})
const userKey = localStorage.getItem('owm_api_key')?.trim()
const headers: Record<string, string> = {}
if (userKey) headers['X-OWM-Api-Key'] = userKey
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')