import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; const PROFILE_VERSION = 1; const ALLOWED_RISKS = new Set(['weather', 'conflict', 'infrastructure', 'cyber', 'travel', 'health', 'crime', 'economic']); const ALLOWED_DEPENDENCIES = new Set(['electricity', 'internet', 'mobile_network', 'public_transport', 'private_vehicle', 'medical_power', 'mobility_support']); export class SecurityProfileStore { constructor(filePath, encryptionSecret) { this.filePath = filePath; this.encryptionSecret = String(encryptionSecret || ''); this.profile = null; this.configured = this.encryptionSecret.length >= 32; this.available = this.configured; this.reason = this.available ? null : 'SECURITY_PROFILE_ENCRYPTION_KEY must contain at least 32 characters'; } init() { mkdirSync(dirname(this.filePath), { recursive: true }); if (!this.available || !existsSync(this.filePath)) return this; try { this.profile = decryptEnvelope(readFileSync(this.filePath, 'utf8'), this.encryptionSecret); this.profile = sanitizeSecurityProfile(this.profile); } catch (error) { this.profile = null; this.available = false; this.reason = `Encrypted profile could not be read: ${error.message}`; } return this; } get exists() { return Boolean(this.profile?.completedAt); } getProfile() { return this.profile ? structuredClone(this.profile) : null; } getAgentProfile() { const profile = this.getProfile(); if (!profile) return null; return { language: profile.language, preferredName: profile.preferredName || null, location: profile.location, timezone: profile.timezone || null, household: profile.household, mobility: profile.mobility, travelPattern: profile.travelPattern || null, riskPriorities: profile.riskPriorities, criticalDependencies: profile.criticalDependencies, alertPreference: profile.alertPreference, quietHours: profile.quietHours || null, consentedAt: profile.consentedAt, updatedAt: profile.updatedAt, }; } save(input) { if (!this.available) throw new Error(this.reason); const now = new Date().toISOString(); const profile = sanitizeSecurityProfile({ ...input, version: PROFILE_VERSION, completedAt: input.completedAt || now, updatedAt: now, }); const envelope = encryptEnvelope(profile, this.encryptionSecret); const temporaryPath = `${this.filePath}.tmp`; writeFileSync(temporaryPath, envelope, { encoding: 'utf8', mode: 0o600 }); renameSync(temporaryPath, this.filePath); this.profile = profile; this.reason = null; return this.getProfile(); } delete() { if (existsSync(this.filePath)) unlinkSync(this.filePath); this.profile = null; this.available = this.configured; this.reason = this.available ? null : 'SECURITY_PROFILE_ENCRYPTION_KEY must contain at least 32 characters'; } status() { return { available: this.available, configured: this.configured, exists: this.exists, encrypted: true, reason: this.reason, updatedAt: this.profile?.updatedAt || null, }; } } export function sanitizeSecurityProfile(input = {}) { return { version: PROFILE_VERSION, language: ['de', 'en'].includes(input.language) ? input.language : 'en', preferredName: clean(input.preferredName, 80) || null, location: { country: clean(input.location?.country, 80) || null, region: clean(input.location?.region, 100) || null, city: clean(input.location?.city, 100) || null, }, timezone: clean(input.timezone, 80) || null, household: { adults: boundedInt(input.household?.adults, 0, 20, 1), children: boundedInt(input.household?.children, 0, 20, 0), pets: boundedInt(input.household?.pets, 0, 20, 0), }, mobility: cleanList(input.mobility, 8, 40), travelPattern: clean(input.travelPattern, 120) || null, riskPriorities: cleanList(input.riskPriorities, 8, 40).filter(item => ALLOWED_RISKS.has(item)), criticalDependencies: cleanList(input.criticalDependencies, 8, 40).filter(item => ALLOWED_DEPENDENCIES.has(item)), alertPreference: ['critical_only', 'important', 'all'].includes(input.alertPreference) ? input.alertPreference : 'important', quietHours: clean(input.quietHours, 40) || null, consentedAt: validIso(input.consentedAt), completedAt: validIso(input.completedAt), updatedAt: validIso(input.updatedAt), }; } function encryptEnvelope(profile, secret) { const iv = randomBytes(12); const key = deriveKey(secret); const cipher = createCipheriv('aes-256-gcm', key, iv); const ciphertext = Buffer.concat([cipher.update(JSON.stringify(profile), 'utf8'), cipher.final()]); return JSON.stringify({ version: PROFILE_VERSION, algorithm: 'aes-256-gcm', iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64'), }); } function decryptEnvelope(raw, secret) { const envelope = JSON.parse(raw); if (envelope.algorithm !== 'aes-256-gcm') throw new Error('unsupported encryption envelope'); const decipher = createDecipheriv('aes-256-gcm', deriveKey(secret), Buffer.from(envelope.iv, 'base64')); decipher.setAuthTag(Buffer.from(envelope.tag, 'base64')); const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64')), decipher.final()]); return JSON.parse(plaintext.toString('utf8')); } function deriveKey(secret) { return createHash('sha256').update(`intelligence-terminal-security-profile\0${secret}`).digest(); } function clean(value, maxLength) { return String(value || '').replace(/[\u0000-\u001f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, maxLength); } function cleanList(value, maxItems, maxLength) { const list = Array.isArray(value) ? value : String(value || '').split(','); return [...new Set(list.map(item => clean(item, maxLength).toLowerCase()).filter(Boolean))].slice(0, maxItems); } function boundedInt(value, min, max, fallback) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback; } function validIso(value) { if (!value) return null; const date = new Date(value); return Number.isFinite(date.getTime()) ? date.toISOString() : null; }