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

@ -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');
@ -32,7 +39,83 @@ const runMigrations = () => {
db.exec("ALTER TABLE users ADD COLUMN default_game_config TEXT");
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;
}
}