Skip to content

Commit cedda0f

Browse files
committed
feat(security): encrypt userId and userHash at rest with AES-256-GCM
Store game credentials encrypted in SQLite so a database leak cannot be directly exploited. A stolen DB file yields only opaque ciphertext. Implementation - src/bot/utils/crypto.ts (new): AES-256-GCM helpers using Node.js crypto. Key loaded from ENCRYPTION_KEY env var (64-char hex / 32 bytes). Ciphertext format: enc1:<iv_hex>:<authTag_hex>:<ciphertext_hex>. Key is validated eagerly at module load time (fail-fast). - src/bot/database/userManager.ts: encrypt on write, decrypt on read. saveCredentials() rejects empty userId/userHash. migratePlaintextCredentials() transparently upgrades existing rows on startup; each field is migrated independently (handles mixed-state rows). - src/bot/bot.ts: calls migratePlaintextCredentials() after DB init. - src/test/setup.ts: sets ENCRYPTION_KEY to a clearly non-secret 'deadbeef'-repeated test fixture to avoid secret-scanner false positives. - src/bot/database/userManager.test.ts: 5 new migration tests; existing tests updated to assert encrypted-at-rest storage. Configuration - .env.example: documents ENCRYPTION_KEY with generation hint. - docker-compose.yml / docker-compose.example.yml: forwards ENCRYPTION_KEY into the container. Docs updated in README, docs/development.md, docs/full-documentation.md, docs/podman.md, docs/github-secrets.md: ENCRYPTION_KEY listed as a required variable with openssl rand -hex 32 generation hint and a warning that the key must be generated once and retained — changing it after credentials have been saved makes those rows unreadable. Signed-off-by: Michael Cramer <michael@bigmichi1.de>
1 parent f65ef4f commit cedda0f

13 files changed

Lines changed: 251 additions & 11 deletions

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,10 @@ DISCORD_CHANNEL_ID=123456789012345679
66
# Database
77
DB_PATH=./data/idle.db
88

9+
# Encryption key for sensitive credentials stored in the database (userId, userHash).
10+
# Must be a 64-character hex string (32 random bytes). Generate with:
11+
# openssl rand -hex 32
12+
ENCRYPTION_KEY=your_64_char_hex_key_here
13+
914
# Environment
1015
NODE_ENV=development

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ mise run install
3636

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

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

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

6671
# 3. Start the bot
6772
docker-compose up -d

docker-compose.example.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ services:
3939
# Find this ID by enabling Developer Mode in Discord → right-click the bot → Copy User ID.
4040
DISCORD_CODE_AUTHOR_ID: ${DISCORD_CODE_AUTHOR_ID:-}
4141

42+
# Required: 64-character hex encryption key for credentials stored in the database.
43+
# Generate with: openssl rand -hex 32
44+
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
45+
4246
# Database location (inside container)
4347
DB_PATH: /app/data/idle.db
4448

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ services:
1111
DISCORD_TOKEN: ${DISCORD_TOKEN}
1212
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-}
1313
DISCORD_CHANNEL_ID: ${DISCORD_CHANNEL_ID:-357247482247380994}
14+
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
1415
DB_PATH: /app/data/idle.db
1516
NODE_ENV: production
1617
volumes:

docs/development.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ DISCORD_TOKEN # Bot token from Discord Developer Portal
302302
DISCORD_GUILD_ID # Server ID (for guild-specific commands)
303303
DISCORD_CHANNEL_ID # Channel ID (for auto code scanning)
304304
DISCORD_CODE_AUTHOR_ID # User/bot ID that posts promo codes (filters backfill to that author only)
305+
ENCRYPTION_KEY # Required — 64-char hex key (32 bytes) for AES-256-GCM credential encryption
306+
# Generate: openssl rand -hex 32
307+
# Note: users.user_id and users.user_hash are stored as AES-256-GCM
308+
# ciphertext (enc1:<iv>:<authTag>:<ct>), not as raw int/token values.
305309
DB_PATH # Database file path (default: ./data/idle.db)
306310
NODE_ENV # development or production
307311
```

docs/full-documentation.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ cp .env.example .env
3939
# - DISCORD_GUILD_ID (your server ID)
4040
# - DISCORD_CHANNEL_ID (where bot scans for codes)
4141
# - DISCORD_CODE_AUTHOR_ID (ID of the bot that posts codes, e.g. Idle Champions #combinations)
42+
# - ENCRYPTION_KEY — generate once with: openssl rand -hex 32
43+
# ⚠️ Store this value safely and never regenerate it for an existing database;
44+
# changing the key makes previously saved credentials unreadable.
4245

4346
# 4. Start the bot
4447
mise run dev
@@ -242,6 +245,9 @@ DISCORD_TOKEN=your_bot_token_here
242245
DISCORD_GUILD_ID=1214259114725605436
243246
DISCORD_CHANNEL_ID=1502624358055809104
244247
DISCORD_CODE_AUTHOR_ID=1502625533236744222
248+
# Required: 64-char hex key for AES-256-GCM credential encryption
249+
# Generate with: openssl rand -hex 32
250+
ENCRYPTION_KEY=your_64_char_hex_key_here
245251
DB_PATH=./data/idle.db
246252
NODE_ENV=development
247253
```

docs/github-secrets.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ DISCORD_TOKEN=your_discord_bot_token_here
4646
DISCORD_GUILD_ID=your_guild_id_here
4747
DISCORD_CHANNEL_ID=your_channel_id_here
4848
DISCORD_CODE_AUTHOR_ID=your_code_author_bot_id_here
49+
# Required: generate with: openssl rand -hex 32
50+
ENCRYPTION_KEY=your_64_char_hex_key_here
4951
DB_PATH=/app/data/idle.db
5052
NODE_ENV=development
5153
```

docs/podman.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ sudo pip3 install podman-compose
5252
# Copy and configure
5353
cp docker-compose.example.yml docker-compose.yml
5454
export DISCORD_TOKEN=your_bot_token_here
55+
# Generate ENCRYPTION_KEY once, then persist the value (e.g. in a .env file).
56+
# ⚠️ Never regenerate this for an existing database — previously saved credentials
57+
# will become unreadable if the key changes.
58+
export ENCRYPTION_KEY=$(openssl rand -hex 32)
5559

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

108116
Or add them to your `docker-compose.yml` (or `.env` file if using podman-compose).

src/bot/bot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { backfillChannelHistory } from './handlers/backfillHandler';
55
import { enqueueAutoRedeem, setDiscordClient } from './handlers/autoRedeemer';
66
import { codeManager } from './database/codeManager';
77
import { backfillManager } from './database/backfillManager';
8+
import { userManager } from './database/userManager';
89
import { initDebugLogger } from './utils/debugLogger';
910
import logger from './utils/logger';
1011
import { apiRequestLogger } from './utils/apiRequestLogger';
@@ -78,6 +79,7 @@ client.on(Events.ClientReady, async () => {
7879

7980
try {
8081
initializeDatabase();
82+
await userManager.migratePlaintextCredentials();
8183

8284
// Check if startup backfill should run
8385
const shouldBackfill = await backfillManager.shouldRunStartupBackfill();

src/bot/database/userManager.test.ts

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeAll, beforeEach } from 'bun:test';
22
import { db, initializeDatabase } from './db';
33
import { userManager } from './userManager';
44
import { users, redeemedCodes, pendingCodes } from './schema/index';
5+
import { isEncrypted, encrypt } from '../utils/crypto';
56

67
beforeAll(() => {
78
initializeDatabase();
@@ -25,17 +26,24 @@ describe('saveCredentials', () => {
2526
const rows = db.select().from(users).all();
2627
expect(rows).toHaveLength(1);
2728
expect(rows[0].discordId).toBe('user-1');
28-
expect(rows[0].userId).toBe('111');
29-
expect(rows[0].userHash).toBe('hash-a');
29+
// Credentials are encrypted at rest — raw DB values must not be plaintext
30+
expect(rows[0].userId).not.toBe('111');
31+
expect(rows[0].userHash).not.toBe('hash-a');
32+
// Decrypted values match originals
33+
const creds = await userManager.getCredentials('user-1');
34+
expect(creds?.userId).toBe('111');
35+
expect(creds?.userHash).toBe('hash-a');
3036
});
3137

3238
test('upserts existing user credentials', async () => {
3339
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
3440
await userManager.saveCredentials({ discordId: 'user-1', userId: '222', userHash: 'hash-b' });
3541
const rows = db.select().from(users).all();
3642
expect(rows).toHaveLength(1);
37-
expect(rows[0].userId).toBe('222');
38-
expect(rows[0].userHash).toBe('hash-b');
43+
// Decrypted values reflect the latest upsert
44+
const creds = await userManager.getCredentials('user-1');
45+
expect(creds?.userId).toBe('222');
46+
expect(creds?.userHash).toBe('hash-b');
3947
});
4048

4149
test('stores optional server field', async () => {
@@ -215,3 +223,80 @@ describe('getAllUsersWithAutoRedeem', () => {
215223
expect(await userManager.getAllUsersWithAutoRedeem()).toEqual([]);
216224
});
217225
});
226+
227+
// ---------------------------------------------------------------------------
228+
// migratePlaintextCredentials
229+
// ---------------------------------------------------------------------------
230+
describe('migratePlaintextCredentials', () => {
231+
test('is a no-op when the table is empty', async () => {
232+
await userManager.migratePlaintextCredentials();
233+
expect(db.select().from(users).all()).toHaveLength(0);
234+
});
235+
236+
test('encrypts plaintext userId and userHash, preserving decrypted values', async () => {
237+
// Seed a row with plaintext credentials directly (bypassing saveCredentials)
238+
db.insert(users).values({ discordId: 'user-1', userId: '111', userHash: 'hash-a' }).run();
239+
240+
await userManager.migratePlaintextCredentials();
241+
242+
const row = db.select().from(users).get();
243+
// Both fields must now be in encrypted format
244+
expect(isEncrypted(row!.userId)).toBe(true);
245+
expect(isEncrypted(row!.userHash)).toBe(true);
246+
// Decrypted values must match the original plaintext
247+
const creds = await userManager.getCredentials('user-1');
248+
expect(creds?.userId).toBe('111');
249+
expect(creds?.userHash).toBe('hash-a');
250+
});
251+
252+
test('migrates multiple plaintext rows independently', async () => {
253+
db.insert(users).values([
254+
{ discordId: 'user-1', userId: '111', userHash: 'hash-a' },
255+
{ discordId: 'user-2', userId: '222', userHash: 'hash-b' },
256+
]).run();
257+
258+
await userManager.migratePlaintextCredentials();
259+
260+
const creds1 = await userManager.getCredentials('user-1');
261+
const creds2 = await userManager.getCredentials('user-2');
262+
expect(creds1?.userId).toBe('111');
263+
expect(creds1?.userHash).toBe('hash-a');
264+
expect(creds2?.userId).toBe('222');
265+
expect(creds2?.userHash).toBe('hash-b');
266+
});
267+
268+
test('leaves already-encrypted rows unchanged (no double-encryption)', async () => {
269+
// Seed via saveCredentials so the row is already encrypted
270+
await userManager.saveCredentials({ discordId: 'user-1', userId: '111', userHash: 'hash-a' });
271+
const before = db.select().from(users).get();
272+
273+
await userManager.migratePlaintextCredentials();
274+
275+
const after = db.select().from(users).get();
276+
// Raw stored values must be identical — no re-encryption occurred
277+
expect(after!.userId).toBe(before!.userId);
278+
expect(after!.userHash).toBe(before!.userHash);
279+
// Decrypted values still correct
280+
const creds = await userManager.getCredentials('user-1');
281+
expect(creds?.userId).toBe('111');
282+
expect(creds?.userHash).toBe('hash-a');
283+
});
284+
285+
test('encrypts only the plaintext field in a mixed-state row', async () => {
286+
// Seed a row where userId is already encrypted but userHash is still plaintext
287+
const encryptedUserId = encrypt('111');
288+
db.insert(users).values({ discordId: 'user-1', userId: encryptedUserId, userHash: 'hash-a' }).run();
289+
290+
await userManager.migratePlaintextCredentials();
291+
292+
const row = db.select().from(users).get();
293+
// userId must be the same encrypted value (not double-encrypted)
294+
expect(row!.userId).toBe(encryptedUserId);
295+
// userHash must now be encrypted
296+
expect(isEncrypted(row!.userHash)).toBe(true);
297+
// Both decrypt correctly
298+
const creds = await userManager.getCredentials('user-1');
299+
expect(creds?.userId).toBe('111');
300+
expect(creds?.userHash).toBe('hash-a');
301+
});
302+
});

0 commit comments

Comments
 (0)