Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@ DISCORD_CHANNEL_ID=123456789012345679
# Database
DB_PATH=./data/idle.db

# Encryption key for sensitive credentials stored in the database (userId, userHash).
Comment thread
BigMichi1 marked this conversation as resolved.
# Must be a 64-character hex string (32 random bytes). Generate with:
# openssl rand -hex 32
ENCRYPTION_KEY=your_64_char_hex_key_here
Comment thread
BigMichi1 marked this conversation as resolved.
Comment thread
BigMichi1 marked this conversation as resolved.
Comment thread
BigMichi1 marked this conversation as resolved.

# Environment
NODE_ENV=development
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ mise run install

# 2. Configure environment
cp .env.example .env
# Edit .env with your DISCORD_TOKEN and server IDs
# Edit .env with your DISCORD_TOKEN, server IDs, and generate an encryption key:
# openssl rand -hex 32 → paste result as ENCRYPTION_KEY

# 3. Start the bot
mise run dev
Expand All @@ -60,8 +61,12 @@ Deploy the bot using the pre-built Docker image:
# 1. Copy the example compose file
cp docker-compose.example.yml docker-compose.yml

# 2. Set your Discord token
# 2. Set required environment variables
export DISCORD_TOKEN=your_bot_token_here
# Generate ENCRYPTION_KEY once and store it safely (e.g. in a .env file or secret store).
# ⚠️ Never regenerate this value for an existing database — previously saved credentials
# will become unreadable if the key changes.
export ENCRYPTION_KEY=$(openssl rand -hex 32)

# 3. Start the bot
docker-compose up -d
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ services:
# Find this ID by enabling Developer Mode in Discord → right-click the bot → Copy User ID.
DISCORD_CODE_AUTHOR_ID: ${DISCORD_CODE_AUTHOR_ID:-}

# Required: 64-character hex encryption key for credentials stored in the database.
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY: ${ENCRYPTION_KEY}

# Database location (inside container)
DB_PATH: /app/data/idle.db

Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
DISCORD_TOKEN: ${DISCORD_TOKEN}
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-}
DISCORD_CHANNEL_ID: ${DISCORD_CHANNEL_ID:-357247482247380994}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
DB_PATH: /app/data/idle.db
NODE_ENV: production
volumes:
Expand Down
4 changes: 4 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ DISCORD_TOKEN # Bot token from Discord Developer Portal
DISCORD_GUILD_ID # Server ID (for guild-specific commands)
DISCORD_CHANNEL_ID # Channel ID (for auto code scanning)
DISCORD_CODE_AUTHOR_ID # User/bot ID that posts promo codes (filters backfill to that author only)
ENCRYPTION_KEY # Required — 64-char hex key (32 bytes) for AES-256-GCM credential encryption
# Generate: openssl rand -hex 32
Comment thread
BigMichi1 marked this conversation as resolved.
# Note: users.user_id and users.user_hash are stored as AES-256-GCM
# ciphertext (enc1:<iv>:<authTag>:<ct>), not as raw int/token values.
DB_PATH # Database file path (default: ./data/idle.db)
NODE_ENV # development or production
```
6 changes: 6 additions & 0 deletions docs/full-documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ cp .env.example .env
# - DISCORD_GUILD_ID (your server ID)
# - DISCORD_CHANNEL_ID (where bot scans for codes)
# - DISCORD_CODE_AUTHOR_ID (ID of the bot that posts codes, e.g. Idle Champions #combinations)
# - ENCRYPTION_KEY — generate once with: openssl rand -hex 32
# ⚠️ Store this value safely and never regenerate it for an existing database;
# changing the key makes previously saved credentials unreadable.

# 4. Start the bot
mise run dev
Expand Down Expand Up @@ -242,6 +245,9 @@ DISCORD_TOKEN=your_bot_token_here
DISCORD_GUILD_ID=1214259114725605436
DISCORD_CHANNEL_ID=1502624358055809104
DISCORD_CODE_AUTHOR_ID=1502625533236744222
# Required: 64-char hex key for AES-256-GCM credential encryption
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=your_64_char_hex_key_here
Comment thread
BigMichi1 marked this conversation as resolved.
DB_PATH=./data/idle.db
NODE_ENV=development
```
Expand Down
2 changes: 2 additions & 0 deletions docs/github-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ DISCORD_TOKEN=your_discord_bot_token_here
DISCORD_GUILD_ID=your_guild_id_here
DISCORD_CHANNEL_ID=your_channel_id_here
DISCORD_CODE_AUTHOR_ID=your_code_author_bot_id_here
# Required: generate with: openssl rand -hex 32
ENCRYPTION_KEY=your_64_char_hex_key_here
DB_PATH=/app/data/idle.db
NODE_ENV=development
```
Expand Down
8 changes: 8 additions & 0 deletions docs/podman.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ sudo pip3 install podman-compose
# Copy and configure
cp docker-compose.example.yml docker-compose.yml
export DISCORD_TOKEN=your_bot_token_here
# Generate ENCRYPTION_KEY once, then persist the value (e.g. in a .env file).
# ⚠️ Never regenerate this for an existing database — previously saved credentials
# will become unreadable if the key changes.
export ENCRYPTION_KEY=$(openssl rand -hex 32)

# Start the bot
podman-compose up -d
Expand Down Expand Up @@ -103,6 +107,10 @@ export DISCORD_TOKEN=your_bot_token_here
export DISCORD_GUILD_ID=optional_guild_id
export DISCORD_CHANNEL_ID=optional_channel_id
export DISCORD_CODE_AUTHOR_ID=optional_code_author_bot_id
# Generate ENCRYPTION_KEY once and persist it (e.g. in a .env file or secret store).
# ⚠️ Never regenerate this for an existing database — previously saved credentials
# will become unreadable if the key changes.
export ENCRYPTION_KEY=$(openssl rand -hex 32)
```

Or add them to your `docker-compose.yml` (or `.env` file if using podman-compose).
2 changes: 2 additions & 0 deletions src/bot/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { backfillChannelHistory } from './handlers/backfillHandler';
import { enqueueAutoRedeem, setDiscordClient } from './handlers/autoRedeemer';
import { codeManager } from './database/codeManager';
import { backfillManager } from './database/backfillManager';
import { userManager } from './database/userManager';
import { initDebugLogger } from './utils/debugLogger';
import logger from './utils/logger';
import { apiRequestLogger } from './utils/apiRequestLogger';
Expand Down Expand Up @@ -78,6 +79,7 @@ client.on(Events.ClientReady, async () => {

try {
initializeDatabase();
await userManager.migratePlaintextCredentials();

// Check if startup backfill should run
const shouldBackfill = await backfillManager.shouldRunStartupBackfill();
Expand Down
93 changes: 89 additions & 4 deletions src/bot/database/userManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, test, expect, beforeAll, beforeEach } from 'bun:test';
import { db, initializeDatabase } from './db';
import { userManager } from './userManager';
import { users, redeemedCodes, pendingCodes } from './schema/index';
import { isEncrypted, encrypt } from '../utils/crypto';

beforeAll(() => {
initializeDatabase();
Expand All @@ -25,17 +26,24 @@ describe('saveCredentials', () => {
const rows = db.select().from(users).all();
expect(rows).toHaveLength(1);
expect(rows[0].discordId).toBe('user-1');
expect(rows[0].userId).toBe('111');
expect(rows[0].userHash).toBe('hash-a');
// Credentials are encrypted at rest — raw DB values must not be plaintext
expect(rows[0].userId).not.toBe('111');
expect(rows[0].userHash).not.toBe('hash-a');
// Decrypted values match originals
const creds = await userManager.getCredentials('user-1');
expect(creds?.userId).toBe('111');
expect(creds?.userHash).toBe('hash-a');
});

test('upserts existing user credentials', async () => {
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
await userManager.saveCredentials({ discordId: 'user-1', userId: '222', userHash: 'hash-b' });
const rows = db.select().from(users).all();
expect(rows).toHaveLength(1);
expect(rows[0].userId).toBe('222');
expect(rows[0].userHash).toBe('hash-b');
// Decrypted values reflect the latest upsert
const creds = await userManager.getCredentials('user-1');
expect(creds?.userId).toBe('222');
expect(creds?.userHash).toBe('hash-b');
});

test('stores optional server field', async () => {
Expand Down Expand Up @@ -215,3 +223,80 @@ describe('getAllUsersWithAutoRedeem', () => {
expect(await userManager.getAllUsersWithAutoRedeem()).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// migratePlaintextCredentials
// ---------------------------------------------------------------------------
describe('migratePlaintextCredentials', () => {
test('is a no-op when the table is empty', async () => {
await userManager.migratePlaintextCredentials();
expect(db.select().from(users).all()).toHaveLength(0);
});

test('encrypts plaintext userId and userHash, preserving decrypted values', async () => {
// Seed a row with plaintext credentials directly (bypassing saveCredentials)
db.insert(users).values({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }).run();

await userManager.migratePlaintextCredentials();

const row = db.select().from(users).get();
// Both fields must now be in encrypted format
expect(isEncrypted(row!.userId)).toBe(true);
expect(isEncrypted(row!.userHash)).toBe(true);
// Decrypted values must match the original plaintext
const creds = await userManager.getCredentials('user-1');
expect(creds?.userId).toBe('111');
expect(creds?.userHash).toBe('hash-a');
});

test('migrates multiple plaintext rows independently', async () => {
db.insert(users).values([
{ discordId: 'user-1', userId: '111', userHash: 'hash-a' },
{ discordId: 'user-2', userId: '222', userHash: 'hash-b' },
]).run();

await userManager.migratePlaintextCredentials();

const creds1 = await userManager.getCredentials('user-1');
const creds2 = await userManager.getCredentials('user-2');
expect(creds1?.userId).toBe('111');
expect(creds1?.userHash).toBe('hash-a');
expect(creds2?.userId).toBe('222');
expect(creds2?.userHash).toBe('hash-b');
});

test('leaves already-encrypted rows unchanged (no double-encryption)', async () => {
// Seed via saveCredentials so the row is already encrypted
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
const before = db.select().from(users).get();

await userManager.migratePlaintextCredentials();

const after = db.select().from(users).get();
// Raw stored values must be identical — no re-encryption occurred
expect(after!.userId).toBe(before!.userId);
expect(after!.userHash).toBe(before!.userHash);
// Decrypted values still correct
const creds = await userManager.getCredentials('user-1');
expect(creds?.userId).toBe('111');
expect(creds?.userHash).toBe('hash-a');
});

test('encrypts only the plaintext field in a mixed-state row', async () => {
// Seed a row where userId is already encrypted but userHash is still plaintext
const encryptedUserId = encrypt('111');
db.insert(users).values({ discordId: 'user-1', userId: encryptedUserId, userHash: 'hash-a' }).run();

await userManager.migratePlaintextCredentials();

const row = db.select().from(users).get();
// userId must be the same encrypted value (not double-encrypted)
expect(row!.userId).toBe(encryptedUserId);
// userHash must now be encrypted
expect(isEncrypted(row!.userHash)).toBe(true);
// Both decrypt correctly
const creds = await userManager.getCredentials('user-1');
expect(creds?.userId).toBe('111');
expect(creds?.userHash).toBe('hash-a');
});
});
54 changes: 49 additions & 5 deletions src/bot/database/userManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { eq, sql } from 'drizzle-orm';
import { db } from './db';
import { users } from './schema/index';
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
import logger from '../utils/logger';

export interface UserCredentials {
discordId: string;
Expand All @@ -11,11 +13,15 @@ export interface UserCredentials {
autoRedeem?: boolean;
}

function decryptField(value: string): string {
return isEncrypted(value) ? decrypt(value) : value;
Comment thread
BigMichi1 marked this conversation as resolved.
}

function rowToCredentials(user: typeof users.$inferSelect): UserCredentials {
return {
discordId: user.discordId,
userId: user.userId,
userHash: user.userHash,
userId: decryptField(user.userId),
userHash: decryptField(user.userHash),
server: user.server ?? undefined,
instanceId: user.instanceId ?? undefined,
autoRedeem: user.autoRedeem ?? true,
Expand All @@ -25,14 +31,25 @@ function rowToCredentials(user: typeof users.$inferSelect): UserCredentials {
class UserManager {
async saveCredentials(credentials: UserCredentials): Promise<void> {
const { discordId, userId, userHash, server, instanceId } = credentials;
if (!userId || !userHash) {
throw new Error('userId and userHash must not be empty');
}
const encryptedUserId = encrypt(userId);
const encryptedUserHash = encrypt(userHash);

db.insert(users)
.values({ discordId, userId, userHash, server: server ?? null, instanceId: instanceId ?? null })
.values({
discordId,
userId: encryptedUserId,
userHash: encryptedUserHash,
server: server ?? null,
instanceId: instanceId ?? null,
})
.onConflictDoUpdate({
target: users.discordId,
set: {
userId,
userHash,
userId: encryptedUserId,
userHash: encryptedUserHash,
server: server ?? null,
instanceId: instanceId ?? null,
updatedAt: sql`CURRENT_TIMESTAMP`,
Expand Down Expand Up @@ -85,6 +102,33 @@ class UserManager {
const rows = db.select().from(users).orderBy(sql`${users.createdAt} DESC`).all();
return rows.map(rowToCredentials);
}

/**
* One-time migration: re-encrypts any rows whose userId/userHash were stored
* as plaintext before encryption was introduced. Safe to call on every startup.
*/
async migratePlaintextCredentials(): Promise<void> {
Comment thread
BigMichi1 marked this conversation as resolved.
Comment thread
BigMichi1 marked this conversation as resolved.
const rows = db.select().from(users).all();
let migrated = 0;
for (const row of rows) {
const userIdNeedsEncryption = !isEncrypted(row.userId);
const userHashNeedsEncryption = !isEncrypted(row.userHash);
Comment thread
BigMichi1 marked this conversation as resolved.
if (userIdNeedsEncryption || userHashNeedsEncryption) {
db.update(users)
.set({
userId: userIdNeedsEncryption ? encrypt(row.userId) : row.userId,
userHash: userHashNeedsEncryption ? encrypt(row.userHash) : row.userHash,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(users.discordId, row.discordId))
.run();
migrated++;
}
}
if (migrated > 0) {
logger.info(`[USER MANAGER] Migrated ${migrated} plaintext credential row(s) to encrypted storage`);
}
}
}

export const userManager = new UserManager();
Loading
Loading