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
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; |
|
} |
|
}
|
|
|