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
49 changes: 49 additions & 0 deletions src/lib/auth/__tests__/email-verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { constantTimeEqual } from '../jwt';
import {
__resetVerificationStoreForTests,
__setVerificationStorePathForTests,
Expand Down Expand Up @@ -146,6 +147,54 @@ describe('email verification store', () => {
expect(bySession?.verificationId).toBe(created.record.verificationId);
});

it('rejects a wrong verification token (constant-time comparison)', async () => {
const created = await createOrRestoreVerification({
email: 'wrong-token@teachlink.com',
name: 'Wrong Token Tester',
});

if (!('verificationToken' in created)) throw new Error('Expected verification token');

// A random wrong token should not match
const wrongResult = await verifyEmailToken('0'.repeat(64));
expect(wrongResult.status).toBe('not_found');

// The correct token should still work
const correctResult = await verifyEmailToken(created.verificationToken);
expect(correctResult.status).toBe('verified');
});

it('rejects a wrong backup code during restore (constant-time comparison)', async () => {
const created = await createOrRestoreVerification({
email: 'wrong-backup@teachlink.com',
name: 'Wrong Backup Tester',
});

if (!('verificationToken' in created)) throw new Error('Expected verification token');

// Expire the verification token
const store = await loadStore();
store.records[0].expiresAt = new Date(Date.now() - 60_000).toISOString();
store.records[0].status = 'pending';
store.records[0].backupCodeExpiresAt = new Date(Date.now() + 60_000).toISOString();
await saveStore(store);
await __resetVerificationStoreForTests();

// Wrong backup code should fail
const wrongResult = await restoreVerificationEmail({
email: 'wrong-backup@teachlink.com',
backupCode: 'WRONGCODE',
});
expect(wrongResult.status).toBe('not_found');

// Correct backup code should succeed
const correctResult = await restoreVerificationEmail({
email: 'wrong-backup@teachlink.com',
backupCode: created.backupCode,
});
expect('verificationToken' in correctResult).toBe(true);
});

it('builds verification links using the public site URL', async () => {
const created = await createOrRestoreVerification({
email: 'links@teachlink.com',
Expand Down
54 changes: 53 additions & 1 deletion src/lib/auth/__tests__/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { verifyToken, verifyTokenDetailed } from '../jwt';
import { constantTimeEqual, verifyToken, verifyTokenDetailed } from '../jwt';

const SECRET = 'test-jwt-secret';
const USER_ROLE = 'STUDENT' as const;
Expand Down Expand Up @@ -27,6 +27,58 @@ async function signTokenWithSecret(payload: Record<string, unknown>): Promise<st
return `${unsigned}.${signatureB64}`;
}

describe('constantTimeEqual', () => {
it('returns true for identical strings', () => {
expect(constantTimeEqual('hello', 'hello')).toBe(true);
});

it('returns false for different strings of the same length', () => {
expect(constantTimeEqual('hello', 'world')).toBe(false);
});

it('returns false for strings of different lengths', () => {
expect(constantTimeEqual('hello', 'helloo')).toBe(false);
expect(constantTimeEqual('hello', 'hell')).toBe(false);
});

it('returns true for empty strings', () => {
expect(constantTimeEqual('', '')).toBe(true);
});

it('returns false when one string is empty', () => {
expect(constantTimeEqual('', 'a')).toBe(false);
expect(constantTimeEqual('a', '')).toBe(false);
});

it('works with hex-encoded hash strings', () => {
const hash1 = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2';
const hash2 = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2';
const hash3 = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f3';

expect(constantTimeEqual(hash1, hash2)).toBe(true);
expect(constantTimeEqual(hash1, hash3)).toBe(false);
});

it('returns false for strings that differ only in the last character', () => {
expect(constantTimeEqual('aaaa', 'aaab')).toBe(false);
});

it('returns false for strings that differ only in the first character', () => {
expect(constantTimeEqual('aaaa', 'baaa')).toBe(false);
});

it('handles strings with special characters', () => {
expect(constantTimeEqual('hello world!', 'hello world!')).toBe(true);
expect(constantTimeEqual('hello world!', 'hello world?')).toBe(false);
});

it('handles strings with different byte lengths (unicode)', () => {
// héllo has a multi-byte UTF-8 char, making it different byte-length than hello
expect(constantTimeEqual('héllo', 'héllo')).toBe(true);
expect(constantTimeEqual('héllo', 'hello')).toBe(false);
});
});

describe('JWT clock skew tolerance', () => {
beforeEach(() => {
process.env.JWT_SECRET = SECRET;
Expand Down
5 changes: 3 additions & 2 deletions src/lib/auth/email-verification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash, randomBytes, randomUUID } from 'crypto';
import { mkdir, readFile, rename, writeFile } from 'fs/promises';
import path from 'path';
import { constantTimeEqual } from '@/lib/auth/jwt';
import type { EmailVerificationInput } from '@/services/notifications';

export type EmailVerificationStatus = 'pending' | 'verified' | 'expired' | 'already_verified';
Expand Down Expand Up @@ -265,7 +266,7 @@ export async function verifyEmailToken(token: string): Promise<VerificationLooku

return withStore(async (store) => {
sweepExpiredRecords(store);
const record = store.records.find((item) => item.verificationTokenHash === tokenHash);
const record = store.records.find((item) => constantTimeEqual(item.verificationTokenHash, tokenHash));

if (!record) {
return { status: 'not_found' };
Expand Down Expand Up @@ -345,7 +346,7 @@ export async function restoreVerificationEmail(params: {
return { status: 'expired', record: existing };
}

if (hashSecret(params.backupCode.trim()) !== existing.backupCodeHash) {
if (!constantTimeEqual(hashSecret(params.backupCode.trim()), existing.backupCodeHash)) {
return { status: 'not_found', record: existing };
}

Expand Down
14 changes: 14 additions & 0 deletions src/lib/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { timingSafeEqual } from 'crypto';
import { SignJWT } from 'jose';
import { getJWTConfig } from '@/config/environment';
import { UserRole } from '@/types/api';
Expand Down Expand Up @@ -114,6 +115,19 @@ function base64UrlDecode(str: string): Uint8Array {
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}

/**
* Performs a constant-time comparison of two strings to prevent timing attacks.
* Both inputs are compared byte-by-byte regardless of where they differ.
*/
export function constantTimeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) {
return false;
}
return timingSafeEqual(bufA, bufB);
}

/**
* Decodes a JWT's payload **without verifying its signature**. Safe to call on
* the client (no secret required) — used only to read non-sensitive claims such
Expand Down
Loading