172 lines
6.4 KiB
JavaScript
172 lines
6.4 KiB
JavaScript
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;
|
|
}
|