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
4 changes: 4 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ MONGODB_URI=mongodb+srv://<username>:<password>@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=
Expand Down
5 changes: 5 additions & 0 deletions app/api/student/resume/confirm/route.theme-contrast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -54,6 +58,7 @@ function makeRequest(body: string | Record<string, unknown>): Request {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-owner-token',
},
body: typeof body === 'string' ? body : JSON.stringify(body),
});
Expand Down
51 changes: 51 additions & 0 deletions utils/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
41 changes: 28 additions & 13 deletions utils/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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');
Expand All @@ -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');
Comment thread
Krishnx21 marked this conversation as resolved.
}

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);
Expand Down
Loading