Files
harheimertc/server/utils/members.js
Torsten Schulz (local) 01cf0e58cb
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
Add support for multiple encryption keys in data handling
This commit introduces a mechanism to handle multiple possible encryption keys for data decryption across various modules, including auth.js, members.js, newsletter.js, and encryption.js. It adds functions to retrieve potential old keys for migration purposes and updates the decryption logic to attempt decryption with these keys. Additionally, it includes warnings for users when old keys are used and provides guidance for re-encrypting data. This enhancement improves data migration capabilities and ensures backward compatibility with previously encrypted data.
2026-01-09 09:05:05 +01:00

299 lines
10 KiB
JavaScript

import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
// Handle both dev and production paths
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'members.json'), never user input
const getDataPath = (filename) => {
const cwd = process.cwd()
// In production (.output/server), working dir is .output
if (cwd.endsWith('.output')) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, '../server/data', filename)
}
// In development, working dir is project root
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, 'server/data', filename)
}
const MEMBERS_FILE = getDataPath('members.json')
// Get encryption key from environment or config
function getEncryptionKey() {
return process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
}
// Liste möglicher alter Verschlüsselungsschlüssel (für Migration)
function getPossibleEncryptionKeys() {
const currentKey = getEncryptionKey()
const oldKeys = [
'default-key-change-in-production',
'local_development_encryption_key_change_in_production'
]
// Aktueller Schlüssel zuerst, dann alte Schlüssel
const keys = [currentKey]
for (const oldKey of oldKeys) {
if (oldKey !== currentKey) {
keys.push(oldKey)
}
}
// Optional: Alter Schlüssel aus Environment-Variable
if (process.env.OLD_ENCRYPTION_KEY && process.env.OLD_ENCRYPTION_KEY !== currentKey) {
keys.push(process.env.OLD_ENCRYPTION_KEY)
}
return keys
}
// Check if data is encrypted by checking format indicators
function isEncrypted(data) {
if (!data || typeof data !== 'string') {
return false
}
const trimmed = data.trim()
// Empty or whitespace only
if (!trimmed) {
return false
}
// Check for v2 prefix (v2:base64...)
if (trimmed.startsWith('v2:')) {
return true
}
// Try to parse as JSON - if successful, it's likely unencrypted
try {
const parsed = JSON.parse(trimmed)
// If it's an array (members list) or object with member-like structure, it's unencrypted
if (Array.isArray(parsed)) {
return false // Unencrypted array
}
// If it's an object but not encrypted format, it's unencrypted
if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) {
return false
}
return false
} catch (_e) {
// JSON parsing failed - check if it looks like base64 (encrypted)
// Base64 strings typically don't contain spaces and have specific character set
const base64Pattern = /^[A-Za-z0-9+/=]+$/
if (base64Pattern.test(trimmed) && trimmed.length > 50) {
// Looks like base64 and long enough to be encrypted data
return true
}
// If it's not valid JSON and doesn't look like base64, assume unencrypted but malformed
return false
}
}
// Read manual members from file (with encryption support and migration)
export async function readMembers() {
try {
const data = await fs.readFile(MEMBERS_FILE, 'utf-8')
// Check if file is empty or whitespace only
if (!data || !data.trim()) {
console.warn('Mitgliederdaten-Datei ist leer')
return []
}
// Check if data is encrypted or plain JSON
const encrypted = isEncrypted(data)
if (encrypted) {
// Versuche mit verschiedenen Schlüsseln zu entschlüsseln (für Migration)
const possibleKeys = getPossibleEncryptionKeys()
const encryptionKey = getEncryptionKey()
// Warn if using default encryption key in production
if (process.env.NODE_ENV === 'production' &&
encryptionKey === 'local_development_encryption_key_change_in_production') {
console.error('WARNUNG: Produktionsumgebung verwendet Standard-Verschlüsselungsschlüssel!')
}
let lastError = null
for (let i = 0; i < possibleKeys.length; i++) {
const key = possibleKeys[i]
try {
const decrypted = decryptObject(data, key)
// Wenn mit altem Schlüssel entschlüsselt wurde, warnen und neu verschlüsseln
if (i > 0 && key !== encryptionKey) {
console.warn(`⚠️ Mitgliederdaten wurden mit altem Schlüssel entschlüsselt. Automatische Neuverschlüsselung...`)
try {
await writeMembers(decrypted)
console.log('✅ Mitgliederdaten erfolgreich mit neuem Schlüssel neu verschlüsselt')
} catch (writeError) {
console.error('❌ Fehler beim Neuverschlüsseln:', writeError.message)
console.error(' Bitte führen Sie manuell aus: node scripts/re-encrypt-data.js')
}
}
return decrypted
} catch (decryptError) {
lastError = decryptError
// Versuche nächsten Schlüssel
continue
}
}
// Alle Schlüssel fehlgeschlagen
console.error('Fehler beim Entschlüsseln der Mitgliederdaten:')
console.error(' Versuchte Schlüssel:', possibleKeys.length)
console.error(' Letzter Fehler:', lastError?.message || 'Unbekannter Fehler')
console.error(' Daten-Länge:', data.length)
console.error(' Daten-Präfix (erste 50 Zeichen):', data.substring(0, 50))
console.error('')
console.error('💡 Lösung: Führen Sie das Re-Encrypt-Skript aus:')
console.error(' node scripts/re-encrypt-data.js --old-key="<alter-schlüssel>"')
console.error(' Oder setzen Sie OLD_ENCRYPTION_KEY als Environment-Variable')
// Fallback: try to read as plain JSON (migration scenario)
try {
const plainData = JSON.parse(data.trim())
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
// Don't auto-migrate if decryption failed - might be wrong key
return plainData
} catch (parseError) {
console.error('Konnte Mitgliederdaten weder entschlüsseln noch als JSON lesen')
console.error('JSON-Parse-Fehler:', parseError.message)
// Return empty array to prevent complete failure
return []
}
} else {
// Plain JSON - migrate to encrypted format
const members = JSON.parse(data.trim())
console.log('Migriere unverschlüsselte Mitgliederdaten zu verschlüsselter Speicherung...')
// Write back encrypted
await writeMembers(members)
return members
}
} catch (error) {
if (error.code === 'ENOENT') {
return []
}
console.error('Fehler beim Lesen der Mitgliederdaten:', error)
return []
}
}
// Write manual members to file (always encrypted)
export async function writeMembers(members) {
try {
const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(members, encryptionKey)
await fs.writeFile(MEMBERS_FILE, encryptedData, 'utf-8')
return true
} catch (error) {
console.error('Fehler beim Schreiben der Mitgliederdaten:', error)
return false
}
}
// Get member by ID
export async function getMemberById(id) {
const members = await readMembers()
return members.find(m => m.id === id)
}
// Normalize date string for comparison (handles different date formats)
export function normalizeDate(dateString) {
if (!dateString) return ''
// Try to parse and normalize to ISO format (YYYY-MM-DD)
try {
const date = new Date(dateString)
if (isNaN(date.getTime())) return dateString.trim()
return date.toISOString().split('T')[0]
} catch (_e) {
return dateString.trim()
}
}
// Check for duplicate member based on firstName, lastName, and geburtsdatum
function findDuplicateMember(members, firstName, lastName, geburtsdatum) {
const normalizedFirstName = (firstName || '').trim().toLowerCase()
const normalizedLastName = (lastName || '').trim().toLowerCase()
const normalizedDate = normalizeDate(geburtsdatum)
return members.find(m => {
const mFirstName = (m.firstName || '').trim().toLowerCase()
const mLastName = (m.lastName || '').trim().toLowerCase()
const mDate = normalizeDate(m.geburtsdatum)
return mFirstName === normalizedFirstName &&
mLastName === normalizedLastName &&
mDate === normalizedDate &&
mDate !== '' // Only match if date is provided for both
})
}
// Add or update manual member
export async function saveMember(memberData) {
const members = await readMembers()
if (memberData.id) {
// Update existing
const index = members.findIndex(m => m.id === memberData.id)
if (index !== -1) {
// Check for duplicate (excluding current member)
const duplicate = findDuplicateMember(
members.filter(m => m.id !== memberData.id),
memberData.firstName,
memberData.lastName,
memberData.geburtsdatum
)
if (duplicate) {
throw new Error('Ein Mitglied mit diesem Namen und Geburtsdatum existiert bereits.')
}
members[index] = { ...members[index], ...memberData }
} else {
throw new Error('Mitglied nicht gefunden')
}
} else {
// Add new - check for duplicate first
if (memberData.firstName && memberData.lastName && memberData.geburtsdatum) {
const duplicate = findDuplicateMember(
members,
memberData.firstName,
memberData.lastName,
memberData.geburtsdatum
)
if (duplicate) {
throw new Error('Ein Mitglied mit diesem Namen und Geburtsdatum existiert bereits.')
}
}
// Add new - use UUID for guaranteed uniqueness
const newMember = {
...memberData,
id: randomUUID() // Cryptographically secure unique ID
}
members.push(newMember)
}
await writeMembers(members)
return true
}
// Delete manual member
export async function deleteMember(id) {
const members = await readMembers()
const filtered = members.filter(m => m.id !== id)
await writeMembers(filtered)
return true
}