diff --git a/.env.local.example b/.env.local.example index 11c825911..842412623 100644 --- a/.env.local.example +++ b/.env.local.example @@ -13,6 +13,10 @@ MONGODB_URI=mongodb+srv://:@cluster0.mongodb.net/commitpulse # Generate at: https://github.com/settings/tokens (No scopes required) GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# Required for encrypting stored third-party API tokens. +# Use a unique random secret with at least 32 characters. +ENCRYPTION_KEY= + # Vercel KV / Upstash Redis URL for distributed rate limiting. # Leave blank to operate with degraded local-memory rate limits. KV_REST_API_URL= diff --git a/app/api/student/resume/confirm/route.theme-contrast.test.ts b/app/api/student/resume/confirm/route.theme-contrast.test.ts index 243571d11..9168b857d 100644 --- a/app/api/student/resume/confirm/route.theme-contrast.test.ts +++ b/app/api/student/resume/confirm/route.theme-contrast.test.ts @@ -33,6 +33,10 @@ vi.mock('@/utils/getClientIp', () => ({ getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })); +vi.mock('@/lib/github-owner-verification', () => ({ + verifyGitHubOwner: vi.fn().mockResolvedValue({ verified: true }), +})); + // Set up prefers-color-scheme via window.matchMedia mock let prefersColorScheme: 'dark' | 'light' = 'dark'; @@ -54,6 +58,7 @@ function makeRequest(body: string | Record): Request { method: 'POST', headers: { 'Content-Type': 'application/json', + Authorization: 'Bearer test-owner-token', }, body: typeof body === 'string' ? body : JSON.stringify(body), }); diff --git a/utils/encryption.test.ts b/utils/encryption.test.ts new file mode 100644 index 000000000..4d07cd782 --- /dev/null +++ b/utils/encryption.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { decryptToken, encryptToken } from './encryption'; + +describe('token encryption', () => { + afterEach(() => { + delete process.env.ENCRYPTION_KEY; + }); + + it('fails closed when ENCRYPTION_KEY is missing', () => { + expect(() => encryptToken('github-token')).toThrow(/ENCRYPTION_KEY must be configured/); + }); + + it('rejects weak encryption keys', () => { + process.env.ENCRYPTION_KEY = 'too-short'; + + expect(() => encryptToken('github-token')).toThrow(/at least 32 characters/); + }); + + it('encrypts and decrypts tokens using a versioned authenticated format', () => { + process.env.ENCRYPTION_KEY = 'a-secure-test-key-with-at-least-32-characters'; + + const encrypted = encryptToken('github-token'); + + expect(encrypted).toMatch(/^v1:[0-9a-f]{32}:[0-9a-f]{32}:[0-9a-f]+$/); + expect(encrypted).not.toContain('github-token'); + expect(decryptToken(encrypted)).toBe('github-token'); + }); + + it('never silently accepts plaintext as encrypted token data', () => { + process.env.ENCRYPTION_KEY = 'a-secure-test-key-with-at-least-32-characters'; + + expect(() => decryptToken('github-token')).toThrow('Invalid encrypted token format'); + }); + + it('rejects malformed IVs, tags, and ciphertext before decryption', () => { + process.env.ENCRYPTION_KEY = 'a-secure-test-key-with-at-least-32-characters'; + + expect(() => decryptToken('v1:00:00:00')).toThrow('Invalid encrypted token format'); + expect(() => decryptToken(`v1:${'z'.repeat(32)}:${'0'.repeat(32)}:${'0'.repeat(2)}`)).toThrow( + 'Invalid encrypted token format' + ); + }); + + it('rejects ciphertext encrypted with a different key', () => { + process.env.ENCRYPTION_KEY = 'first-secure-test-key-with-at-least-32-characters'; + const encrypted = encryptToken('github-token'); + process.env.ENCRYPTION_KEY = 'second-secure-test-key-with-at-least-32-characters'; + + expect(() => decryptToken(encrypted)).toThrow('Failed to decrypt token securely'); + }); +}); diff --git a/utils/encryption.ts b/utils/encryption.ts index 47b5130cf..3bd4ee4fc 100644 --- a/utils/encryption.ts +++ b/utils/encryption.ts @@ -4,26 +4,30 @@ const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 16; const TAG_LENGTH = 16; const KEY_LEN = 32; +const ENCRYPTED_TOKEN_VERSION = 'v1'; +const MIN_KEY_LENGTH = 32; -// The encryption key should be exactly 32 bytes for AES-256 -// In production, ensure ENCRYPTION_KEY is securely set in environment variables const getEncryptionKey = (): Buffer => { - const key = process.env.ENCRYPTION_KEY || 'default_commitpulse_secret_key_32'; - // Use scrypt to securely derive a 32-byte key from the environment variable + const key = process.env.ENCRYPTION_KEY; + if (!key || key.length < MIN_KEY_LENGTH) { + throw new Error(`ENCRYPTION_KEY must be configured with at least ${MIN_KEY_LENGTH} characters`); + } + return crypto.scryptSync(key, 'commitpulse_salt', KEY_LEN); }; /** * Securely encrypts a third-party API token using AES-256-GCM. * @param plaintextToken The plaintext API token (e.g., GitHub PAT) - * @returns The encrypted token string in the format iv:tag:encryptedData + * @returns The encrypted token string in the format v1:iv:tag:encryptedData */ export function encryptToken(plaintextToken: string): string { if (!plaintextToken) return plaintextToken; + const key = getEncryptionKey(); + try { const iv = crypto.randomBytes(IV_LENGTH); - const key = getEncryptionKey(); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); @@ -32,7 +36,7 @@ export function encryptToken(plaintextToken: string): string { const tag = cipher.getAuthTag(); - return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted}`; + return `${ENCRYPTED_TOKEN_VERSION}:${iv.toString('hex')}:${tag.toString('hex')}:${encrypted}`; } catch (error) { console.error('Encryption failed:', error); throw new Error('Failed to encrypt token securely'); @@ -48,18 +52,29 @@ export function decryptToken(encryptedString: string): string { if (!encryptedString) return encryptedString; const parts = encryptedString.split(':'); - if (parts.length !== 3) { - // Return original string if it doesn't match the encrypted format - // This allows graceful fallback for any legacy plaintext tokens - return encryptedString; + if (parts.length !== 4 || parts[0] !== ENCRYPTED_TOKEN_VERSION) { + throw new Error('Invalid encrypted token format'); + } + + const [, ivHex, tagHex, encrypted] = parts; + const isHex = (value: string) => /^[0-9a-f]+$/i.test(value); + if ( + ivHex.length !== IV_LENGTH * 2 || + tagHex.length !== TAG_LENGTH * 2 || + encrypted.length === 0 || + encrypted.length % 2 !== 0 || + !isHex(ivHex) || + !isHex(tagHex) || + !isHex(encrypted) + ) { + throw new Error('Invalid encrypted token format'); } - const [ivHex, tagHex, encrypted] = parts; + const key = getEncryptionKey(); try { const iv = Buffer.from(ivHex, 'hex'); const tag = Buffer.from(tagHex, 'hex'); - const key = getEncryptionKey(); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(tag);