Remove decryption and api key storage

This commit is contained in:
Joey Yakimowich-Payne 2026-01-22 07:32:11 -07:00
commit 15b76f330b
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
8 changed files with 326 additions and 233 deletions

View file

@ -4,6 +4,56 @@ import { useAuthenticatedFetch } from './useAuthenticatedFetch';
import type { UserPreferences } from '../types';
import { COLOR_SCHEMES } from '../types';
const LOCAL_API_KEYS_STORAGE = 'kaboot.apiKeys';
type LocalApiKeys = Pick<UserPreferences, 'geminiApiKey' | 'openRouterApiKey' | 'openAIApiKey'>;
const normalizeApiKey = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
};
const readLocalApiKeys = (): LocalApiKeys => {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(LOCAL_API_KEYS_STORAGE);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
geminiApiKey: normalizeApiKey(parsed.geminiApiKey),
openRouterApiKey: normalizeApiKey(parsed.openRouterApiKey),
openAIApiKey: normalizeApiKey(parsed.openAIApiKey),
};
} catch {
return {};
}
};
const writeLocalApiKeys = (keys: LocalApiKeys) => {
if (typeof window === 'undefined') return;
const normalized = {
geminiApiKey: normalizeApiKey(keys.geminiApiKey),
openRouterApiKey: normalizeApiKey(keys.openRouterApiKey),
openAIApiKey: normalizeApiKey(keys.openAIApiKey),
};
const hasAny = Boolean(
normalized.geminiApiKey || normalized.openRouterApiKey || normalized.openAIApiKey
);
if (!hasAny) {
window.localStorage.removeItem(LOCAL_API_KEYS_STORAGE);
return;
}
window.localStorage.setItem(LOCAL_API_KEYS_STORAGE, JSON.stringify(normalized));
};
const mergePreferencesWithLocalKeys = (prefs: UserPreferences, localKeys: LocalApiKeys): UserPreferences => ({
...prefs,
geminiApiKey: localKeys.geminiApiKey ?? prefs.geminiApiKey,
openRouterApiKey: localKeys.openRouterApiKey ?? prefs.openRouterApiKey,
openAIApiKey: localKeys.openAIApiKey ?? prefs.openAIApiKey,
});
const DEFAULT_PREFERENCES: UserPreferences = {
colorScheme: 'blue',
aiProvider: 'gemini',
@ -51,7 +101,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
const response = await authFetch('/api/users/me/preferences');
if (response.ok) {
const data = await response.json();
const prefs: UserPreferences = {
const serverPrefs: UserPreferences = {
colorScheme: data.colorScheme || 'blue',
aiProvider: data.aiProvider || 'gemini',
geminiApiKey: data.geminiApiKey || undefined,
@ -61,9 +111,17 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
openAIApiKey: data.openAIApiKey || undefined,
openAIModel: data.openAIModel || undefined,
};
setPreferences(prefs);
const localKeys = readLocalApiKeys();
const mergedPrefs = mergePreferencesWithLocalKeys(serverPrefs, localKeys);
const mergedLocalKeys: LocalApiKeys = {
geminiApiKey: localKeys.geminiApiKey ?? serverPrefs.geminiApiKey,
openRouterApiKey: localKeys.openRouterApiKey ?? serverPrefs.openRouterApiKey,
openAIApiKey: localKeys.openAIApiKey ?? serverPrefs.openAIApiKey,
};
writeLocalApiKeys(mergedLocalKeys);
setPreferences(mergedPrefs);
setHasAIAccess(data.hasAIAccess || false);
applyColorScheme(prefs.colorScheme);
applyColorScheme(mergedPrefs.colorScheme);
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
try {
@ -91,17 +149,34 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
const savePreferences = useCallback(async (prefs: UserPreferences) => {
setSaving(true);
try {
const nextPrefs: UserPreferences = { ...preferences, ...prefs };
const localKeys: LocalApiKeys = {
geminiApiKey: nextPrefs.geminiApiKey,
openRouterApiKey: nextPrefs.openRouterApiKey,
openAIApiKey: nextPrefs.openAIApiKey,
};
writeLocalApiKeys(localKeys);
const serverPrefs = {
...nextPrefs,
geminiApiKey: undefined,
openRouterApiKey: undefined,
openAIApiKey: undefined,
};
const response = await authFetch('/api/users/me/preferences', {
method: 'PUT',
body: JSON.stringify(prefs),
body: JSON.stringify(serverPrefs),
});
if (!response.ok) {
throw new Error('Failed to save preferences');
}
setPreferences(prefs);
applyColorScheme(prefs.colorScheme);
const normalizedLocalKeys = readLocalApiKeys();
const mergedPrefs = mergePreferencesWithLocalKeys(nextPrefs, normalizedLocalKeys);
setPreferences(mergedPrefs);
applyColorScheme(mergedPrefs.colorScheme);
toast.success('Preferences saved!');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
@ -110,7 +185,7 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
} finally {
setSaving(false);
}
}, [authFetch]);
}, [authFetch, preferences]);
useEffect(() => {
fetchPreferences();

View file

@ -0,0 +1,100 @@
import Database from 'better-sqlite3';
import { createDecipheriv, createHash, hkdfSync } from 'crypto';
import { join } from 'path';
const DB_PATH = process.env.DATABASE_PATH || join(process.cwd(), 'data', 'kaboot.db');
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 16;
if (!ENCRYPTION_KEY) {
console.error('ENCRYPTION_KEY is required to migrate encrypted default_game_config values.');
process.exit(1);
}
const masterKey = createHash('sha256').update(ENCRYPTION_KEY).digest();
const isJsonString = (value) => {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
};
const decryptForUser = (ciphertext, userSub) => {
if (!ciphertext) return null;
try {
const combined = Buffer.from(ciphertext, 'base64');
if (combined.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1) {
return null;
}
const salt = combined.subarray(0, SALT_LENGTH);
const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const authTag = combined.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
const key = Buffer.from(
hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32)
);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString('utf8');
} catch {
return null;
}
};
const db = new Database(DB_PATH);
const rows = db.prepare(`
SELECT id, default_game_config as defaultGameConfig
FROM users
WHERE default_game_config IS NOT NULL
`).all();
const update = db.prepare('UPDATE users SET default_game_config = ? WHERE id = ?');
let migrated = 0;
let alreadyPlaintext = 0;
let failed = 0;
const migrate = db.transaction(() => {
for (const row of rows) {
const value = row.defaultGameConfig;
if (!value) continue;
if (isJsonString(value)) {
alreadyPlaintext += 1;
continue;
}
const decrypted = decryptForUser(value, row.id);
if (decrypted && isJsonString(decrypted)) {
update.run(decrypted, row.id);
migrated += 1;
} else {
failed += 1;
}
}
});
migrate();
console.log(`Default config migration complete.`);
console.log(`- migrated: ${migrated}`);
console.log(`- already plaintext: ${alreadyPlaintext}`);
console.log(`- failed: ${failed}`);
if (failed > 0) {
console.warn('Some entries could not be decrypted. Check ENCRYPTION_KEY and retry if needed.');
}

View file

@ -16,6 +16,13 @@ db.pragma('foreign_keys = ON');
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
db.exec(schema);
const logOnce = (metaKey: string, message: string) => {
const logged = db.prepare('SELECT 1 FROM meta WHERE meta_key = ?').get(metaKey);
if (logged) return;
db.prepare('INSERT INTO meta (meta_key, value) VALUES (?, ?)').run(metaKey, new Date().toISOString());
console.log(message);
};
const runMigrations = () => {
const tableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[];
const hasGameConfig = tableInfo.some(col => col.name === 'game_config');
@ -33,6 +40,82 @@ const runMigrations = () => {
console.log("Migration: Added default_game_config to users");
}
let userTableInfo2 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
const deprecatedUserColumns = [
'gemini_api_key',
'openrouter_api_key',
'openai_api_key',
'username',
'email',
'display_name',
];
const hasDeprecatedColumns = userTableInfo2.some(col => deprecatedUserColumns.includes(col.name));
let didDropApiKeyColumns = false;
if (hasDeprecatedColumns) {
didDropApiKeyColumns = true;
const userColumns = [
{ name: 'id', definition: 'TEXT PRIMARY KEY' },
{ name: 'created_at', definition: 'DATETIME DEFAULT CURRENT_TIMESTAMP' },
{ name: 'last_login', definition: 'DATETIME' },
{ name: 'default_game_config', definition: 'TEXT' },
{ name: 'color_scheme', definition: "TEXT DEFAULT 'blue'" },
{ name: 'gemini_model', definition: 'TEXT' },
{ name: 'ai_provider', definition: "TEXT DEFAULT 'gemini'" },
{ name: 'openrouter_model', definition: 'TEXT' },
{ name: 'openai_model', definition: 'TEXT' },
{ name: 'stripe_customer_id', definition: 'TEXT UNIQUE' },
{ name: 'subscription_status', definition: "TEXT DEFAULT 'none'" },
{ name: 'subscription_id', definition: 'TEXT' },
{ name: 'subscription_current_period_end', definition: 'DATETIME' },
{ name: 'generation_count', definition: 'INTEGER DEFAULT 0' },
{ name: 'generation_reset_date', definition: 'DATETIME' },
];
const existingColumns = new Set(userTableInfo2.map(col => col.name));
const columnDefaults: Record<string, string> = {
color_scheme: "'blue'",
ai_provider: "'gemini'",
subscription_status: "'none'",
generation_count: '0',
created_at: 'CURRENT_TIMESTAMP',
};
const createColumnsSql = userColumns
.map(col => `${col.name} ${col.definition}`)
.join(',\n ');
const insertColumnsSql = userColumns.map(col => col.name).join(', ');
const selectColumnsSql = userColumns
.map(col => (
existingColumns.has(col.name)
? col.name
: `${columnDefaults[col.name] ?? 'NULL'} AS ${col.name}`
))
.join(', ');
db.exec('BEGIN');
db.exec('ALTER TABLE users RENAME TO users_old');
db.exec(`
CREATE TABLE users (
${createColumnsSql}
);
`);
db.exec(`
INSERT INTO users (${insertColumnsSql})
SELECT ${selectColumnsSql}
FROM users_old;
`);
db.exec('DROP TABLE users_old');
db.exec('COMMIT');
userTableInfo2 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
}
if (didDropApiKeyColumns) {
logOnce(
'migration.users_drop_api_keys',
'Migration: Removed deprecated user columns (API keys and identity fields) from users.'
);
}
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='game_sessions'").get();
if (!tables) {
db.exec(`
@ -61,17 +144,11 @@ const runMigrations = () => {
console.log("Migration: Added first_correct_player_id to game_sessions");
}
const userTableInfo2 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme");
if (!hasColorScheme) {
db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'");
console.log("Migration: Added color_scheme to users");
}
const hasGeminiKey = userTableInfo2.some(col => col.name === "gemini_api_key");
if (!hasGeminiKey) {
db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT");
console.log("Migration: Added gemini_api_key to users");
}
const hasAiProvider = userTableInfo2.some(col => col.name === "ai_provider");
if (!hasAiProvider) {
@ -79,24 +156,12 @@ const runMigrations = () => {
console.log("Migration: Added ai_provider to users");
}
const hasOpenRouterKey = userTableInfo2.some(col => col.name === "openrouter_api_key");
if (!hasOpenRouterKey) {
db.exec("ALTER TABLE users ADD COLUMN openrouter_api_key TEXT");
console.log("Migration: Added openrouter_api_key to users");
}
const hasOpenRouterModel = userTableInfo2.some(col => col.name === "openrouter_model");
if (!hasOpenRouterModel) {
db.exec("ALTER TABLE users ADD COLUMN openrouter_model TEXT");
console.log("Migration: Added openrouter_model to users");
}
const hasOpenAIKey = userTableInfo2.some(col => col.name === "openai_api_key");
if (!hasOpenAIKey) {
db.exec("ALTER TABLE users ADD COLUMN openai_api_key TEXT");
console.log("Migration: Added openai_api_key to users");
}
const hasOpenAIModel = userTableInfo2.some(col => col.name === "openai_model");
if (!hasOpenAIModel) {
db.exec("ALTER TABLE users ADD COLUMN openai_model TEXT");
@ -110,6 +175,11 @@ const runMigrations = () => {
}
const quizTableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[];
const hasSharedBy = quizTableInfo.some(col => col.name === "shared_by");
if (!hasSharedBy) {
db.exec("ALTER TABLE quizzes ADD COLUMN shared_by TEXT");
console.log("Migration: Added shared_by to quizzes");
}
const hasShareToken = quizTableInfo.some(col => col.name === "share_token");
if (!hasShareToken) {
db.exec("ALTER TABLE quizzes ADD COLUMN share_token TEXT");

View file

@ -1,13 +1,13 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
default_game_config TEXT,
color_scheme TEXT DEFAULT 'blue',
gemini_api_key TEXT,
gemini_model TEXT,
ai_provider TEXT DEFAULT 'gemini',
openrouter_model TEXT,
openai_model TEXT,
-- Stripe subscription fields
stripe_customer_id TEXT UNIQUE,
subscription_status TEXT DEFAULT 'none',
@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS quizzes (
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
ai_topic TEXT,
game_config TEXT,
shared_by TEXT,
share_token TEXT UNIQUE,
is_shared INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@ -85,3 +86,8 @@ CREATE TABLE IF NOT EXISTS payments (
CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id);
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_id);
CREATE TABLE IF NOT EXISTS meta (
meta_key TEXT PRIMARY KEY,
value TEXT
);

View file

@ -158,12 +158,10 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
const quizId = uuidv4();
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, last_login)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO users (id, last_login)
VALUES (?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
last_login = CURRENT_TIMESTAMP,
email = COALESCE(excluded.email, users.email),
display_name = COALESCE(excluded.display_name, users.display_name)
last_login = CURRENT_TIMESTAMP
`);
const insertQuiz = db.prepare(`
@ -183,10 +181,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
const transaction = db.transaction(() => {
upsertUser.run(
req.user!.sub,
req.user!.preferred_username,
req.user!.email || null,
req.user!.name || null
req.user!.sub
);
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null, gameConfig ? JSON.stringify(gameConfig) : null);
@ -336,10 +331,13 @@ router.post('/:id/share', (req: AuthenticatedRequest, res: Response) => {
}
const shareToken = randomBytes(16).toString('base64url');
const sharedBy = req.user!.name || req.user!.preferred_username || null;
db.prepare(`
UPDATE quizzes SET share_token = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`).run(shareToken, quizId);
UPDATE quizzes
SET share_token = ?, shared_by = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(shareToken, sharedBy, quizId);
res.json({ shareToken });
});

View file

@ -13,8 +13,7 @@ interface QuizRow {
gameConfig: string | null;
createdAt: string;
updatedAt: string;
sharedByUsername: string | null;
sharedByDisplayName: string | null;
sharedBy: string | null;
}
interface QuestionRow {
@ -41,9 +40,8 @@ router.get('/:token', (req: Request, res: Response) => {
SELECT
q.id, q.title, q.source, q.ai_topic as aiTopic, q.game_config as gameConfig,
q.created_at as createdAt, q.updated_at as updatedAt,
u.username as sharedByUsername, u.display_name as sharedByDisplayName
q.shared_by as sharedBy
FROM quizzes q
LEFT JOIN users u ON q.user_id = u.id
WHERE q.share_token = ? AND q.is_shared = 1
`).get(token) as QuizRow | undefined;
@ -92,7 +90,7 @@ router.get('/:token', (req: Request, res: Response) => {
gameConfig: parsedConfig,
questions: questionsWithOptions,
questionCount: questions.length,
sharedBy: quiz.sharedByDisplayName || quiz.sharedByUsername || null,
sharedBy: quiz.sharedBy || null,
});
});
@ -126,12 +124,10 @@ router.post('/:token/copy', requireAuth, (req: AuthenticatedRequest, res: Respon
const newQuizId = uuidv4();
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, last_login)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO users (id, last_login)
VALUES (?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
last_login = CURRENT_TIMESTAMP,
email = COALESCE(excluded.email, users.email),
display_name = COALESCE(excluded.display_name, users.display_name)
last_login = CURRENT_TIMESTAMP
`);
const insertQuiz = db.prepare(`
@ -151,10 +147,7 @@ router.post('/:token/copy', requireAuth, (req: AuthenticatedRequest, res: Respon
const transaction = db.transaction(() => {
upsertUser.run(
req.user!.sub,
req.user!.preferred_username,
req.user!.email || null,
req.user!.name || null
req.user!.sub
);
insertQuiz.run(newQuizId, req.user!.sub, newTitle, sourceQuiz.gameConfig);

View file

@ -1,7 +1,14 @@
import { Router, Response } from 'express';
import { db } from '../db/connection.js';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
import { encryptForUser, decryptForUser, encryptJsonForUser, decryptJsonForUser } from '../services/encryption.js';
const parseDefaultConfig = (value: string | null): unknown | null => {
if (!value) return null;
try {
return JSON.parse(value);
} catch {
return null;
}
};
const router = Router();
@ -9,25 +16,17 @@ router.use(requireAuth);
interface UserRow {
id: string;
username: string;
email: string | null;
displayName: string | null;
defaultGameConfig: string | null;
colorScheme: string | null;
geminiApiKey: string | null;
createdAt: string | null;
lastLogin: string | null;
}
function decryptUserData(row: UserRow, userSub: string) {
function decryptUserData(row: UserRow) {
return {
id: row.id,
username: row.username,
email: decryptForUser(row.email, userSub),
displayName: decryptForUser(row.displayName, userSub),
defaultGameConfig: decryptJsonForUser(row.defaultGameConfig, userSub),
defaultGameConfig: parseDefaultConfig(row.defaultGameConfig),
colorScheme: row.colorScheme,
geminiApiKey: decryptForUser(row.geminiApiKey, userSub),
createdAt: row.createdAt,
lastLogin: row.lastLogin,
};
@ -37,8 +36,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const row = db.prepare(`
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig,
color_scheme as colorScheme, gemini_api_key as geminiApiKey,
SELECT id, default_game_config as defaultGameConfig, color_scheme as colorScheme,
created_at as createdAt, last_login as lastLogin
FROM users
WHERE id = ?
@ -55,7 +53,6 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
displayName: req.user!.name,
defaultGameConfig: null,
colorScheme: 'blue',
geminiApiKey: null,
hasAIAccess,
createdAt: null,
lastLogin: null,
@ -64,10 +61,13 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
return;
}
const decrypted = decryptUserData(row, userSub);
const decrypted = decryptUserData(row);
res.json({
...decrypted,
username: req.user!.preferred_username,
email: req.user!.email,
displayName: req.user!.name,
hasAIAccess,
isNew: false
});
@ -77,13 +77,11 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const { defaultGameConfig } = req.body;
const encryptedConfig = encryptJsonForUser(defaultGameConfig, userSub);
const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
const configValue = defaultGameConfig ? JSON.stringify(defaultGameConfig) : null;
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, default_game_config, last_login)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO users (id, default_game_config, last_login)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
default_game_config = ?,
last_login = CURRENT_TIMESTAMP
@ -91,11 +89,8 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
upsertUser.run(
userSub,
req.user!.preferred_username,
encryptedEmail,
encryptedDisplayName,
encryptedConfig,
encryptedConfig
configValue,
configValue
);
res.json({ success: true });
@ -105,20 +100,17 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const user = db.prepare(`
SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey,
SELECT color_scheme as colorScheme,
gemini_model as geminiModel, ai_provider as aiProvider,
openrouter_api_key as openRouterApiKey, openrouter_model as openRouterModel,
openai_api_key as openAIApiKey, openai_model as openAIModel
openrouter_model as openRouterModel,
openai_model as openAIModel
FROM users
WHERE id = ?
`).get(userSub) as {
colorScheme: string | null;
geminiApiKey: string | null;
geminiModel: string | null;
aiProvider: string | null;
openRouterApiKey: string | null;
openRouterModel: string | null;
openAIApiKey: string | null;
openAIModel: string | null;
} | undefined;
@ -128,11 +120,11 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
res.json({
colorScheme: user?.colorScheme || 'blue',
aiProvider: user?.aiProvider || 'gemini',
geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub),
geminiApiKey: null,
geminiModel: user?.geminiModel || null,
openRouterApiKey: decryptForUser(user?.openRouterApiKey || null, userSub),
openRouterApiKey: null,
openRouterModel: user?.openRouterModel || null,
openAIApiKey: decryptForUser(user?.openAIApiKey || null, userSub),
openAIApiKey: null,
openAIModel: user?.openAIModel || null,
hasAIAccess,
});
@ -140,49 +132,31 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const { colorScheme, geminiApiKey, geminiModel, aiProvider, openRouterApiKey, openRouterModel, openAIApiKey, openAIModel } = req.body;
const encryptedGeminiKey = encryptForUser(geminiApiKey || null, userSub);
const encryptedOpenRouterKey = encryptForUser(openRouterApiKey || null, userSub);
const encryptedOpenAIKey = encryptForUser(openAIApiKey || null, userSub);
const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
const { colorScheme, geminiModel, aiProvider, openRouterModel, openAIModel } = req.body;
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, gemini_model, ai_provider, openrouter_api_key, openrouter_model, openai_api_key, openai_model, last_login)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO users (id, color_scheme, gemini_model, ai_provider, openrouter_model, openai_model, last_login)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
color_scheme = ?,
gemini_api_key = ?,
gemini_model = ?,
ai_provider = ?,
openrouter_api_key = ?,
openrouter_model = ?,
openai_api_key = ?,
openai_model = ?,
last_login = CURRENT_TIMESTAMP
`);
upsertUser.run(
userSub,
req.user!.preferred_username,
encryptedEmail,
encryptedDisplayName,
colorScheme || 'blue',
encryptedGeminiKey,
geminiModel || null,
aiProvider || 'gemini',
encryptedOpenRouterKey,
openRouterModel || null,
encryptedOpenAIKey,
openAIModel || null,
colorScheme || 'blue',
encryptedGeminiKey,
geminiModel || null,
aiProvider || 'gemini',
encryptedOpenRouterKey,
openRouterModel || null,
encryptedOpenAIKey,
openAIModel || null
);

View file

@ -1,123 +0,0 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash, hkdfSync } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 16;
let encryptionWarningShown = false;
function getMasterKey(): Buffer | null {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
if (!encryptionWarningShown) {
console.warn('[SECURITY WARNING] ENCRYPTION_KEY not set. User data will NOT be encrypted.');
console.warn('Generate one with: openssl rand -base64 36');
encryptionWarningShown = true;
}
return null;
}
return createHash('sha256').update(key).digest();
}
function deriveUserKey(userSub: string, salt: Buffer): Buffer | null {
const masterKey = getMasterKey();
if (!masterKey) return null;
return Buffer.from(
hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32)
);
}
export function encryptForUser(plaintext: string | null | undefined, userSub: string): string | null {
if (plaintext === null || plaintext === undefined || plaintext === '') {
return null;
}
const salt = randomBytes(SALT_LENGTH);
const key = deriveUserKey(userSub, salt);
if (!key) {
return plaintext;
}
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag();
const combined = Buffer.concat([salt, iv, authTag, encrypted]);
return combined.toString('base64');
}
export function decryptForUser(ciphertext: string | null | undefined, userSub: string): string | null {
if (ciphertext === null || ciphertext === undefined || ciphertext === '') {
return null;
}
const masterKey = getMasterKey();
if (!masterKey) {
return ciphertext;
}
try {
const combined = Buffer.from(ciphertext, 'base64');
if (combined.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1) {
return ciphertext;
}
const salt = combined.subarray(0, SALT_LENGTH);
const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const authTag = combined.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
const key = Buffer.from(
hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32)
);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
return decrypted.toString('utf8');
} catch {
return ciphertext;
}
}
export function isEncrypted(value: string | null | undefined): boolean {
if (!value) return false;
try {
const decoded = Buffer.from(value, 'base64');
return decoded.length >= SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1;
} catch {
return false;
}
}
export function encryptJsonForUser(data: object | null | undefined, userSub: string): string | null {
if (data === null || data === undefined) {
return null;
}
return encryptForUser(JSON.stringify(data), userSub);
}
export function decryptJsonForUser<T>(ciphertext: string | null | undefined, userSub: string): T | null {
const plaintext = decryptForUser(ciphertext, userSub);
if (!plaintext) return null;
try {
return JSON.parse(plaintext) as T;
} catch {
return null;
}
}