Skip to content

Commit 1027e09

Browse files
committed
Encrypt Nano-GPT API Keys
1 parent 603a1ec commit 1027e09

14 files changed

Lines changed: 719 additions & 11 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ BETTER_AUTH_URL=http://localhost:3432
99
# Trusted Origins (comma-separated list of URLs)
1010
BETTER_AUTH_TRUSTED_ORIGINS=
1111

12+
# Encryption key for API keys in the database
13+
# Generate a secure random string (at least 32 characters)
14+
# You can generate one with: openssl rand -base64 32
15+
# IMPORTANT: Keep this key secure and never change it, or all encrypted API keys will be lost
16+
ENCRYPTION_KEY=
17+
1218
NANOGPT_API_KEY=
1319

1420
# Artificial Analysis API key for model benchmarks (optional)

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,16 @@ Generate videos using NanoGPT's video models:
175175
| `BETTER_AUTH_SECRET` | Authentication secret |
176176
| `BETTER_AUTH_URL` | Base URL for authentication |
177177
| `ARTIFICIAL_ANALYSIS_API_KEY`| (Optional) API key for model benchmarks from artificialanalysis.ai |
178+
| `ENCRYPTION_KEY` | (Optional) Encryption key for API keys at rest. Generate with `openssl rand -base64 32` |
179+
180+
### API Key Encryption
181+
182+
The application supports encrypting API keys stored in the database using AES-256-GCM:
183+
184+
- **Optional**: The app works without `ENCRYPTION_KEY` (keys stored in plain text)
185+
- **Recommended**: Set `ENCRYPTION_KEY` to encrypt all API keys at rest
186+
- **Migration**: Run `bun run scripts/migrate-encrypt-api-keys.ts` to encrypt existing keys
187+
- **Details**: See [`scripts/README-API-KEY-ENCRYPTION.md`](scripts/README-API-KEY-ENCRYPTION.md)
178188

179189
---
180190

api-docs.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ List active API keys for the current user.
169169
]
170170
}
171171
```
172+
**Note**: The actual key value is never returned in list responses.
172173

173174
#### POST `/api/api-keys`
174175
Create a new API key.
@@ -191,6 +192,7 @@ Create a new API key.
191192
"createdAt": "date"
192193
}
193194
```
195+
**Note**: The key is returned only during creation. Save it securely - it cannot be retrieved again. Keys are stored encrypted in the database using AES-256-GCM.
194196

195197
#### DELETE `/api/api-keys`
196198
Revoke an API key.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# API Key Encryption Migration
2+
3+
This guide explains how to encrypt all API keys in your database using AES-256-GCM encryption.
4+
5+
## Overview
6+
7+
The application now encrypts all API keys (both user-added provider keys and application-generated API keys) before storing them in the database. This provides an additional layer of security in case your database is compromised.
8+
9+
### What Gets Encrypted?
10+
11+
1. **User Provider Keys** (`user_keys` table): NanoGPT, OpenAI, Anthropic, and HuggingFace API keys that users add in their account settings
12+
2. **Application API Keys** (`api_keys` table): Developer API keys generated for programmatic access (format: `nc_...`)
13+
14+
### Encryption Details
15+
16+
- **Algorithm**: AES-256-GCM (industry standard, widely supported, hardware accelerated)
17+
- **Key Derivation**: scrypt with N=16384, r=8, p=1 (~64MB memory)
18+
- **Key Source**: `ENCRYPTION_KEY` environment variable
19+
- **Format**: VERSION | SALT | IV | ENCRYPTED_DATA | AUTH_TAG (base64-encoded)
20+
21+
## Prerequisites
22+
23+
1. **Backup your database** - This is critical!
24+
```bash
25+
cp data/nanochat.db data/nanochat.db.backup
26+
```
27+
28+
2. **Generate an encryption key** (if you haven't already):
29+
```bash
30+
openssl rand -base64 32
31+
```
32+
33+
3. **Set the ENCRYPTION_KEY environment variable** in your `.env` file:
34+
```bash
35+
ENCRYPTION_KEY=your-generated-key-here
36+
```
37+
38+
⚠️ **IMPORTANT**: Keep this key safe and never change it, or all encrypted API keys will be permanently lost!
39+
40+
## Migration Steps
41+
42+
### Option 1: Run the Migration Script (Recommended)
43+
44+
The migration script will encrypt all existing API keys in your database:
45+
46+
1. **Ensure the application is NOT being used** (stop the server)
47+
2. **Set the ENCRYPTION_KEY** environment variable
48+
3. **Run the migration script**:
49+
```bash
50+
bun run scripts/migrate-encrypt-api-keys.ts
51+
```
52+
53+
The script will:
54+
- Show you all keys it's about to encrypt
55+
- Wait 5 seconds (press Ctrl+C to cancel)
56+
- Encrypt all unencrypted keys
57+
- Skip keys that are already encrypted
58+
- Show a summary when complete
59+
60+
### Option 2: Let New Keys Be Encrypted (No Migration)
61+
62+
If you prefer not to encrypt existing keys:
63+
- New keys will automatically be encrypted when added
64+
- Existing unencrypted keys will continue to work (the app detects and handles both)
65+
- You can run the migration script later if desired
66+
67+
### Option 3: Skip Encryption Entirely (Graceful Degradation)
68+
69+
The application supports running without encryption for compatibility:
70+
71+
- **Without `ENCRYPTION_KEY` set**: API keys are stored in plain text (like before)
72+
- A warning is logged on startup: `⚠️ ENCRYPTION_KEY not set. API keys will be stored in plain text.`
73+
- The application continues to work normally
74+
- You can enable encryption later by setting `ENCRYPTION_KEY` and running the migration script
75+
76+
## Verification
77+
78+
After migration, you can verify encryption worked by checking the database:
79+
80+
```bash
81+
# For SQLite
82+
sqlite3 data/nanochat.db "SELECT key FROM user_keys LIMIT 5;"
83+
sqlite3 data/nanochat.db "SELECT key FROM api_keys LIMIT 5;"
84+
```
85+
86+
Encrypted keys will be long base64 strings (150+ characters), while unencrypted keys are shorter.
87+
88+
## How It Works
89+
90+
### Key Storage Flow
91+
92+
1. **When a user adds an API key**:
93+
- The key is received via the API
94+
- Immediately encrypted using `encryptApiKey()`
95+
- Stored in the database in encrypted form
96+
97+
2. **When an API key is needed**:
98+
- Retrieved from database (encrypted)
99+
- Decrypted using `decryptApiKey()`
100+
- Used for API calls
101+
- Never returned to the client (masked instead)
102+
103+
3. **For legacy unencrypted keys**:
104+
- The `isEncrypted()` helper detects encryption
105+
- Unencrypted keys are used as-is
106+
- This allows gradual migration
107+
108+
### Security Considerations
109+
110+
**What encryption protects against**:
111+
- Database dumps/file exposure
112+
- SQL injection attacks that expose the database
113+
- Backup database access
114+
115+
**What encryption does NOT protect against**:
116+
- Application server compromise (the key is in memory during use)
117+
- Environment variable exposure on the server
118+
- Process debugging/memory dumps
119+
120+
### Best Practices
121+
122+
1. **Never commit the ENCRYPTION_KEY to version control**
123+
2. **Rotate the encryption key** (requires special handling - not yet implemented)
124+
3. **Use strong environment variable security** in production
125+
4. **Keep backups** before any migration
126+
5. **Document the key location** for disaster recovery
127+
128+
## Troubleshooting
129+
130+
### "ENCRYPTION_KEY environment variable is not set"
131+
132+
**Solution**: Add the ENCRYPTION_KEY to your `.env` file:
133+
```bash
134+
ENCRYPTION_KEY=$(openssl rand -base64 32)
135+
```
136+
137+
### "ENCRYPTION_KEY must be at least 32 characters"
138+
139+
**Solution**: Generate a longer key:
140+
```bash
141+
openssl rand -base64 32
142+
```
143+
144+
### Keys stopped working after migration
145+
146+
**Solution**: This likely means the ENCRYPTION_KEY used during migration is different from the one the application is using. Either:
147+
- Restore from backup and migrate again with the correct key
148+
- Ensure the same ENCRYPTION_KEY is set in all environments
149+
150+
## Rollback
151+
152+
If you need to rollback:
153+
154+
1. Stop the application
155+
2. Restore your database backup:
156+
```bash
157+
cp data/nanochat.db.backup data/nanochat.db
158+
```
159+
3. Remove or comment out the `ENCRYPTION_KEY` from your `.env` file
160+
4. Restart the application
161+
162+
Note: This will revert to storing API keys in plain text.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env tsx
2+
/**
3+
* Migration script to encrypt all existing API keys in the database
4+
*
5+
* This script will:
6+
* 1. Read all unencrypted API keys from user_keys and api_keys tables
7+
* 2. Encrypt them using the new encryption utilities
8+
* 3. Update the database with the encrypted values
9+
*
10+
* IMPORTANT:
11+
* - This script should be run AFTER setting the ENCRYPTION_KEY environment variable
12+
* - This should be run when the application is NOT being used
13+
* - BACKUP YOUR DATABASE BEFORE RUNNING THIS SCRIPT
14+
* - This operation is NOT reversible (unless you have a backup)
15+
*/
16+
17+
import { db } from '../src/lib/db/index.js';
18+
import { userKeys, apiKeys } from '../src/lib/db/schema.js';
19+
import { eq } from 'drizzle-orm';
20+
import { encryptApiKey, isEncrypted } from '../src/lib/encryption.js';
21+
22+
async function migrateUserKeys() {
23+
console.log('\n🔐 Migrating user_keys table...');
24+
25+
try {
26+
const keys = await db.select().from(userKeys).all();
27+
28+
console.log(` Found ${keys.length} user keys`);
29+
30+
let encryptedCount = 0;
31+
let skippedCount = 0;
32+
33+
for (const key of keys) {
34+
// Skip if already encrypted
35+
if (isEncrypted(key.key)) {
36+
skippedCount++;
37+
console.log(` ⏭️ Skipping key ${key.id} (already encrypted)`);
38+
continue;
39+
}
40+
41+
try {
42+
const encrypted = encryptApiKey(key.key);
43+
44+
await db
45+
.update(userKeys)
46+
.set({ key: encrypted })
47+
.where(eq(userKeys.id, key.id))
48+
.run();
49+
50+
encryptedCount++;
51+
console.log(` ✅ Encrypted key ${key.id} for provider ${key.provider}`);
52+
} catch (error) {
53+
console.error(` ❌ Failed to encrypt key ${key.id}:`, error);
54+
}
55+
}
56+
57+
console.log(` ✅ User keys migration complete: ${encryptedCount} encrypted, ${skippedCount} skipped`);
58+
} catch (error) {
59+
console.error(' ❌ Failed to migrate user keys:', error);
60+
throw error;
61+
}
62+
}
63+
64+
async function migrateApiKeys() {
65+
console.log('\n🔐 Migrating api_keys table...');
66+
67+
try {
68+
const keys = await db.select().from(apiKeys).all();
69+
70+
console.log(` Found ${keys.length} API keys`);
71+
72+
let encryptedCount = 0;
73+
let skippedCount = 0;
74+
75+
for (const key of keys) {
76+
// Skip if already encrypted
77+
if (isEncrypted(key.key)) {
78+
skippedCount++;
79+
console.log(` ⏭️ Skipping key ${key.id} (already encrypted)`);
80+
continue;
81+
}
82+
83+
try {
84+
const encrypted = encryptApiKey(key.key);
85+
86+
await db
87+
.update(apiKeys)
88+
.set({ key: encrypted })
89+
.where(eq(apiKeys.id, key.id))
90+
.run();
91+
92+
encryptedCount++;
93+
console.log(` ✅ Encrypted key ${key.id} (${key.name})`);
94+
} catch (error) {
95+
console.error(` ❌ Failed to encrypt key ${key.id}:`, error);
96+
}
97+
}
98+
99+
console.log(` ✅ API keys migration complete: ${encryptedCount} encrypted, ${skippedCount} skipped`);
100+
} catch (error) {
101+
console.error(' ❌ Failed to migrate API keys:', error);
102+
throw error;
103+
}
104+
}
105+
106+
async function main() {
107+
console.log('==================================================');
108+
console.log(' API Key Encryption Migration Script');
109+
console.log('==================================================');
110+
111+
// Check if ENCRYPTION_KEY is set
112+
if (!process.env.ENCRYPTION_KEY) {
113+
console.error('\n❌ ERROR: ENCRYPTION_KEY environment variable is not set!');
114+
console.error('Please set it before running this script:\n');
115+
console.error(' export ENCRYPTION_KEY="$(openssl rand -base64 32)"\n');
116+
process.exit(1);
117+
}
118+
119+
if (process.env.ENCRYPTION_KEY.length < 32) {
120+
console.error('\n❌ ERROR: ENCRYPTION_KEY must be at least 32 characters long!');
121+
process.exit(1);
122+
}
123+
124+
console.log('\n⚠️ WARNING: This will encrypt all API keys in your database.');
125+
console.log('⚠️ Make sure you have backed up your database before proceeding!');
126+
127+
// Give user time to read and cancel
128+
console.log('\n⏳ Starting migration in 5 seconds... Press Ctrl+C to cancel.\n');
129+
await new Promise((resolve) => setTimeout(resolve, 5000));
130+
131+
try {
132+
await migrateUserKeys();
133+
await migrateApiKeys();
134+
135+
console.log('\n✨ Migration completed successfully!');
136+
console.log('\n📝 Summary:');
137+
console.log(' - All API keys have been encrypted in the database');
138+
console.log(' - The application will automatically decrypt keys when needed');
139+
console.log(' - Keep your ENCRYPTION_KEY safe and secure!');
140+
console.log(' - DO NOT lose or change the ENCRYPTION_KEY!\n');
141+
142+
process.exit(0);
143+
} catch (error) {
144+
console.error('\n❌ Migration failed:', error);
145+
console.error('\n⚠️ Some keys may have been encrypted while others were not.');
146+
console.error('⚠️ Please restore from backup and investigate the error before retrying.\n');
147+
process.exit(1);
148+
}
149+
}
150+
151+
// Run the migration
152+
main();

0 commit comments

Comments
 (0)