fix(server): encrypt api keys
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
43
server/src/services/apiKeyCrypto.ts
Normal file
43
server/src/services/apiKeyCrypto.ts
Normal 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user