diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index a481738..9bb6e92 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -17,6 +17,7 @@ import { randomBytes, createHash } from 'crypto'; import { revokeUserSessions } from '../mcp'; import { AuthRequest, User } from '../types'; import { writeAudit, getClientIp } from '../services/auditLog'; +import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto'; authenticator.options = { window: 1 }; @@ -150,6 +151,11 @@ function maskKey(key: string | null | undefined): string | null { return '----' + key.slice(-4); } +function mask_stored_api_key(key: string | null | undefined): string | null { + const plain = decrypt_api_key(key); + return maskKey(plain); +} + function avatarUrl(user: { avatar?: string | null }): string | null { return user.avatar ? `/uploads/avatars/${user.avatar}` : null; } @@ -389,9 +395,9 @@ router.put('/me/maps-key', authenticate, (req: Request, res: Response) => { db.prepare( 'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' - ).run(maps_api_key || null, authReq.user.id); + ).run(maybe_encrypt_api_key(maps_api_key), authReq.user.id); - res.json({ success: true, maps_api_key: maps_api_key || null }); + res.json({ success: true, maps_api_key: mask_stored_api_key(maps_api_key) }); }); router.put('/me/api-keys', authenticate, (req: Request, res: Response) => { @@ -402,8 +408,8 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => { db.prepare( 'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' ).run( - maps_api_key !== undefined ? (maps_api_key || null) : current.maps_api_key, - openweather_api_key !== undefined ? (openweather_api_key || null) : current.openweather_api_key, + maps_api_key !== undefined ? maybe_encrypt_api_key(maps_api_key) : current.maps_api_key, + openweather_api_key !== undefined ? maybe_encrypt_api_key(openweather_api_key) : current.openweather_api_key, authReq.user.id ); @@ -412,7 +418,7 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => { ).get(authReq.user.id) as Pick | undefined; const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; - res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); + res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); }); router.put('/me/settings', authenticate, (req: Request, res: Response) => { @@ -444,8 +450,8 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => { const updates: string[] = []; const params: (string | number | null)[] = []; - if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maps_api_key || null); } - if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(openweather_api_key || null); } + if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); } + if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); } if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); } if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); } @@ -460,7 +466,7 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => { ).get(authReq.user.id) as Pick | undefined; const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; - res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); + res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); }); router.get('/me/settings', authenticate, (req: Request, res: Response) => { @@ -470,7 +476,12 @@ router.get('/me/settings', authenticate, (req: Request, res: Response) => { ).get(authReq.user.id) as Pick | undefined; if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); - res.json({ settings: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } }); + res.json({ + settings: { + maps_api_key: decrypt_api_key(user.maps_api_key), + openweather_api_key: decrypt_api_key(user.openweather_api_key), + } + }); }); router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req: Request, res: Response) => { @@ -515,9 +526,21 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) = const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(authReq.user.id) as Pick | undefined; if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); - const result = { maps: false, weather: false }; + const result: { + maps: boolean; + weather: boolean; + maps_details: null | { + ok: boolean; + status: number | null; + status_text: string | null; + error_message: string | null; + error_status: string | null; + error_raw: string | null; + }; + } = { maps: false, weather: false, maps_details: null }; - if (user.maps_api_key) { + const maps_api_key = decrypt_api_key(user.maps_api_key); + if (maps_api_key) { try { const mapsRes = await fetch( `https://places.googleapis.com/v1/places:searchText`, @@ -525,22 +548,54 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) = method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Goog-Api-Key': user.maps_api_key, + 'X-Goog-Api-Key': maps_api_key, 'X-Goog-FieldMask': 'places.displayName', }, body: JSON.stringify({ textQuery: 'test' }), } ); result.maps = mapsRes.status === 200; + let error_text: string | null = null; + let error_json: any = null; + if (!result.maps) { + try { + error_text = await mapsRes.text(); + try { + error_json = JSON.parse(error_text); + } catch { + error_json = null; + } + } catch { + error_text = null; + error_json = null; + } + } + result.maps_details = { + ok: result.maps, + status: mapsRes.status, + status_text: mapsRes.statusText || null, + error_message: error_json?.error?.message || null, + error_status: error_json?.error?.status || null, + error_raw: error_text, + }; } catch (err: unknown) { result.maps = false; + result.maps_details = { + ok: false, + status: null, + status_text: null, + error_message: err instanceof Error ? err.message : 'Request failed', + error_status: 'FETCH_ERROR', + error_raw: null, + }; } } - if (user.openweather_api_key) { + const openweather_api_key = decrypt_api_key(user.openweather_api_key); + if (openweather_api_key) { try { const weatherRes = await fetch( - `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${user.openweather_api_key}` + `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}` ); result.weather = weatherRes.status === 200; } catch (err: unknown) { diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 76b81bc..58a7330 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -3,6 +3,7 @@ import fetch from 'node-fetch'; import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { AuthRequest } from '../types'; +import { decrypt_api_key } from '../services/apiKeyCrypto'; interface NominatimResult { osm_type: string; @@ -197,9 +198,10 @@ const router = express.Router(); function getMapsKey(userId: number): string | null { const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined; - if (user?.maps_api_key) return user.maps_api_key; + const user_key = decrypt_api_key(user?.maps_api_key); + if (user_key) return user_key; const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined; - return admin?.maps_api_key || null; + return decrypt_api_key(admin?.maps_api_key) || null; } const photoCache = new Map(); diff --git a/server/src/services/apiKeyCrypto.ts b/server/src/services/apiKeyCrypto.ts new file mode 100644 index 0000000..881b840 --- /dev/null +++ b/server/src/services/apiKeyCrypto.ts @@ -0,0 +1,43 @@ +import * as crypto from 'crypto'; +import { JWT_SECRET } from '../config'; + +const ENCRYPTED_PREFIX = 'enc:v1:'; + +function get_key() { + return crypto.createHash('sha256').update(`${JWT_SECRET}:api_keys:v1`).digest(); +} + +export function encrypt_api_key(plain: unknown) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', get_key(), iv); + const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + const blob = Buffer.concat([iv, tag, enc]).toString('base64'); + return `${ENCRYPTED_PREFIX}${blob}`; +} + +export function decrypt_api_key(value: unknown) { + if (!value) return null; + if (typeof value !== 'string') return null; + if (!value.startsWith(ENCRYPTED_PREFIX)) return value; // legacy plaintext + const blob = value.slice(ENCRYPTED_PREFIX.length); + try { + const buf = Buffer.from(blob, 'base64'); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const enc = buf.subarray(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', get_key(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8'); + } catch { + return null; + } +} + +export function maybe_encrypt_api_key(value: unknown) { + const trimmed = String(value || '').trim(); + if (!trimmed) return null; + if (trimmed.startsWith(ENCRYPTED_PREFIX)) return trimmed; + return encrypt_api_key(trimmed); +} +