You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

404 lines
12 KiB

/**
* Secure messaging preferences storage
*
* SECURITY FEATURES:
* - Encrypted salt storage (separate key)
* - HMAC-based lookup keys (pubkey never directly used as DB key)
* - Rate limiting on decryption attempts
* - Per-user key derivation (master key + pubkey + salt)
* - AES-256-GCM authenticated encryption
* - Random IV per encryption
*
* NOTE: This module uses Node.js crypto and should only be used server-side.
* It will throw an error if imported in browser/client code.
*/
// This file uses .server.ts suffix so SvelteKit automatically excludes it from client bundles
// The runtime check below is a safety measure
if (typeof window !== 'undefined') {
throw new Error('preferences-storage.server.ts uses Node.js crypto and cannot be imported in browser code. Use API endpoints instead.');
}
import {
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
createHash,
createHmac
} from 'crypto';
import logger from '../logger.js';
import { getCachedUserLevel } from '../security/user-level-cache.js';
import type { MessagingPreferences } from './preferences-types.js';
// Re-export the type for convenience
export type { MessagingPreferences } from './preferences-types.js';
// Encryption keys from environment (NEVER commit these!)
// These are optional - if not set, messaging preferences will be disabled
const ENCRYPTION_KEY = process.env.MESSAGING_PREFS_ENCRYPTION_KEY;
const SALT_ENCRYPTION_KEY = process.env.MESSAGING_SALT_ENCRYPTION_KEY;
const LOOKUP_SECRET = process.env.MESSAGING_LOOKUP_SECRET;
// Check if messaging preferences are configured
const isMessagingConfigured = !!(ENCRYPTION_KEY && SALT_ENCRYPTION_KEY && LOOKUP_SECRET);
if (!isMessagingConfigured) {
logger.info('Messaging preferences storage is disabled (optional feature). To enable, set environment variables: MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET');
}
interface StoredPreferences {
encryptedSalt: string; // Salt encrypted with SALT_ENCRYPTION_KEY
encrypted: string; // Preferences encrypted with derived key
}
// Rate limiting: track decryption attempts per pubkey
interface DecryptionAttempt {
count: number;
resetAt: number;
}
const decryptionAttempts = new Map<string, DecryptionAttempt>();
const MAX_DECRYPTION_ATTEMPTS = 10; // Max attempts per window
const DECRYPTION_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
// Cleanup expired rate limit entries
setInterval(() => {
const now = Date.now();
for (const [key, attempt] of decryptionAttempts.entries()) {
if (attempt.resetAt < now) {
decryptionAttempts.delete(key);
}
}
}, 60 * 1000); // Cleanup every minute
/**
* Generate HMAC-based lookup key from pubkey
* Prevents pubkey from being directly used as database key
*/
function getLookupKey(userPubkeyHex: string): string {
if (!LOOKUP_SECRET) {
throw new Error('Messaging preferences are not configured. LOOKUP_SECRET environment variable is missing.');
}
return createHmac('sha256', LOOKUP_SECRET)
.update(userPubkeyHex)
.digest('hex');
}
function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: number } {
// If not configured, allow all (no rate limiting)
if (!isMessagingConfigured) {
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS };
}
const lookupKey = getLookupKey(userPubkeyHex);
const now = Date.now();
const attempt = decryptionAttempts.get(lookupKey);
if (!attempt || attempt.resetAt < now) {
// New window or expired
decryptionAttempts.set(lookupKey, {
count: 1,
resetAt: now + DECRYPTION_WINDOW_MS
});
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - 1 };
}
if (attempt.count >= MAX_DECRYPTION_ATTEMPTS) {
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
'Decryption rate limit exceeded');
return { allowed: false, remaining: 0 };
}
attempt.count++;
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - attempt.count };
}
/**
* Encrypt data with AES-256-GCM
*/
function encryptAES256GCM(key: Buffer, plaintext: string): string {
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return JSON.stringify({
iv: iv.toString('hex'),
tag: authTag.toString('hex'),
data: encrypted
});
}
/**
* Decrypt data with AES-256-GCM
*/
function decryptAES256GCM(key: Buffer, encryptedData: string): string {
const { iv, tag, data } = JSON.parse(encryptedData);
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'));
decipher.setAuthTag(Buffer.from(tag, 'hex'));
let decrypted = decipher.update(data, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Encrypt salt with SALT_ENCRYPTION_KEY
*/
function encryptSalt(salt: string): string {
if (!SALT_ENCRYPTION_KEY) {
throw new Error('SALT_ENCRYPTION_KEY not configured');
}
const key = createHash('sha256').update(SALT_ENCRYPTION_KEY).digest();
return encryptAES256GCM(key, salt);
}
/**
* Decrypt salt with SALT_ENCRYPTION_KEY
*/
function decryptSalt(encryptedSalt: string): string {
if (!SALT_ENCRYPTION_KEY) {
throw new Error('SALT_ENCRYPTION_KEY not configured');
}
const key = createHash('sha256').update(SALT_ENCRYPTION_KEY).digest();
return decryptAES256GCM(key, encryptedSalt);
}
/**
* Derive per-user encryption key
* Uses: master key + user pubkey + salt
*/
function deriveUserKey(userPubkeyHex: string, salt: string): Buffer {
if (!ENCRYPTION_KEY) {
throw new Error('ENCRYPTION_KEY not configured');
}
// Combine pubkey and salt for key derivation
// Attacker needs: master key + pubkey + salt
const combinedSalt = `${userPubkeyHex}:${salt}`;
return scryptSync(
ENCRYPTION_KEY,
combinedSalt,
32, // 256-bit key
{ N: 16384, r: 8, p: 1 } // scrypt parameters
);
}
/**
* In-memory storage (in production, use Redis or database)
* Key: HMAC(pubkey), Value: {encryptedSalt, encrypted}
*/
const preferencesStore = new Map<string, string>();
/**
* Store user messaging preferences securely
*
* @param userPubkeyHex - User's public key (hex)
* @param preferences - Messaging preferences to store
* @throws Error if user doesn't have unlimited access
*/
export async function storePreferences(
userPubkeyHex: string,
preferences: MessagingPreferences
): Promise<void> {
if (!isMessagingConfigured) {
throw new Error('Messaging preferences are not configured. Please set MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, and MESSAGING_LOOKUP_SECRET environment variables.');
}
// Verify user has unlimited access
const cached = getCachedUserLevel(userPubkeyHex);
const { hasUnlimitedAccess } = await import('../../utils/user-access.js');
if (!hasUnlimitedAccess(cached?.level)) {
throw new Error('Messaging forwarding requires unlimited access');
}
// Generate random salt (unique per user, per save)
const salt = randomBytes(32).toString('hex');
// Encrypt salt with separate key
const encryptedSalt = encryptSalt(salt);
// Derive user-specific encryption key
const userKey = deriveUserKey(userPubkeyHex, salt);
// Encrypt preferences
const encrypted = encryptAES256GCM(userKey, JSON.stringify(preferences));
// Store using HMAC lookup key (not raw pubkey)
const lookupKey = getLookupKey(userPubkeyHex);
const stored: StoredPreferences = {
encryptedSalt,
encrypted
};
preferencesStore.set(lookupKey, JSON.stringify(stored));
logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
'Stored messaging preferences');
}
/**
* Retrieve and decrypt user messaging preferences
*
* @param userPubkeyHex - User's public key (hex)
* @returns Decrypted preferences or null if not found
* @throws Error if rate limit exceeded or decryption fails
*/
export async function getPreferences(
userPubkeyHex: string
): Promise<MessagingPreferences | null> {
if (!isMessagingConfigured) {
// If not configured, return null (no preferences stored)
return null;
}
// Check rate limit
const rateLimit = checkRateLimit(userPubkeyHex);
if (!rateLimit.allowed) {
throw new Error(
`Decryption rate limit exceeded. Try again in ${Math.ceil(
(decryptionAttempts.get(getLookupKey(userPubkeyHex))!.resetAt - Date.now()) / 1000 / 60
)} minutes.`
);
}
// Get stored data using HMAC lookup key
const lookupKey = getLookupKey(userPubkeyHex);
const storedJson = preferencesStore.get(lookupKey);
if (!storedJson) {
return null;
}
try {
const stored: StoredPreferences = JSON.parse(storedJson);
// Decrypt salt
const salt = decryptSalt(stored.encryptedSalt);
// Derive same encryption key
const userKey = deriveUserKey(userPubkeyHex, salt);
// Decrypt preferences
const decrypted = decryptAES256GCM(userKey, stored.encrypted);
const preferences: MessagingPreferences = JSON.parse(decrypted);
// Reset rate limit on successful decryption
decryptionAttempts.delete(lookupKey);
return preferences;
} catch (error) {
logger.error({
error,
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...'
}, 'Failed to decrypt preferences');
throw new Error('Failed to decrypt preferences. Data may be corrupted.');
}
}
/**
* Delete user messaging preferences
*/
export async function deletePreferences(userPubkeyHex: string): Promise<void> {
const lookupKey = getLookupKey(userPubkeyHex);
preferencesStore.delete(lookupKey);
decryptionAttempts.delete(lookupKey);
logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
'Deleted messaging preferences');
}
/**
* Check if user has preferences configured
*/
export async function hasPreferences(userPubkeyHex: string): Promise<boolean> {
const lookupKey = getLookupKey(userPubkeyHex);
return preferencesStore.has(lookupKey);
}
/**
* Get rate limit status for a user
*/
export function getRateLimitStatus(userPubkeyHex: string): {
remaining: number;
resetAt: number | null;
} {
const lookupKey = getLookupKey(userPubkeyHex);
const attempt = decryptionAttempts.get(lookupKey);
if (!attempt) {
return { remaining: MAX_DECRYPTION_ATTEMPTS, resetAt: null };
}
if (attempt.resetAt < Date.now()) {
return { remaining: MAX_DECRYPTION_ATTEMPTS, resetAt: null };
}
return {
remaining: Math.max(0, MAX_DECRYPTION_ATTEMPTS - attempt.count),
resetAt: attempt.resetAt
};
}
/**
* Get a safe summary of user preferences (without sensitive tokens)
* This decrypts preferences but only returns safe information
*/
export async function getPreferencesSummary(userPubkeyHex: string): Promise<{
configured: boolean;
enabled: boolean;
platforms: {
telegram?: boolean;
simplex?: boolean;
email?: boolean;
gitPlatforms?: Array<{
platform: string;
owner: string;
repo: string;
apiUrl?: string;
}>;
};
notifyOn?: string[];
} | null> {
try {
// If not configured, return null (not configured)
if (!isMessagingConfigured) {
return null;
}
const preferences = await getPreferences(userPubkeyHex);
if (!preferences) {
return null;
}
return {
configured: true,
enabled: preferences.enabled,
platforms: {
telegram: !!preferences.telegram,
simplex: !!preferences.simplex,
email: !!preferences.email,
gitPlatforms: preferences.gitPlatforms?.map(gp => ({
platform: gp.platform,
owner: gp.owner,
repo: gp.repo,
apiUrl: gp.apiUrl
// token is intentionally omitted
}))
},
notifyOn: preferences.notifyOn
};
} catch (err) {
// If any error occurs (e.g., decryption fails, not configured, etc.), return null
logger.warn({ error: err, userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
'Failed to get preferences summary');
return null;
}
}