fix(server): encrypt api keys

This commit is contained in:
David Moll
2026-03-31 09:00:35 +02:00
parent 069fd99341
commit 990e804bd3
3 changed files with 116 additions and 16 deletions

View File

@@ -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<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | 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<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | 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<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | 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<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | 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) {

View File

@@ -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<string, { photoUrl: string; attribution: string | null; fetchedAt: number }>();

View File

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