feat: add privacy-aware security manager onboarding
This commit is contained in:
171
lib/security/security-profile-store.mjs
Normal file
171
lib/security/security-profile-store.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user