From 4790403882faef954191a469e704228fb0305c45 Mon Sep 17 00:00:00 2001 From: Ajuwonlo Date: Mon, 1 Jun 2026 03:04:06 +0200 Subject: [PATCH] feat: login timing attack --- src/auth/auth.service.spec.ts | 121 +++++++++++++++++++++------------- src/auth/auth.service.ts | 28 ++++---- 2 files changed, 91 insertions(+), 58 deletions(-) diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 4115c20..0815fb0 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,6 +1,5 @@ +import { UnauthorizedException, InternalServerErrorException } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { JwtService } from '@nestjs/jwt'; -import { PrismaService } from '../prisma/prisma.service'; jest.mock('ethers', () => ({ verifyMessage: jest.fn(), @@ -8,10 +7,11 @@ jest.mock('ethers', () => ({ import { verifyMessage } from 'ethers'; -describe('AuthService Nonce behaviour', () => { +describe('AuthService', () => { let authService: AuthService; - let jwtService: Partial; - let prisma: Partial; + let jwtService: any; + let prisma: any; + let redisService: any; beforeEach(() => { jwtService = { @@ -22,63 +22,96 @@ describe('AuthService Nonce behaviour', () => { wallet: { findFirst: jest.fn().mockResolvedValue(null), } as any, - } as Partial; + }; + + redisService = { + set: jest.fn().mockResolvedValue(true), + get: jest.fn().mockResolvedValue(null), + del: jest.fn().mockResolvedValue(true), + }; - authService = new AuthService(prisma as PrismaService, jwtService as JwtService); + authService = new AuthService(prisma, jwtService, redisService); }); afterEach(() => { jest.clearAllMocks(); }); - it('generates a challenge and stores NonceRecord with camelCase fields', () => { + it('generates a fixed-format challenge and persists the nonce with the configured TTL', async () => { const address = '0xAbCd'; - const message = authService.generateChallenge(address); - expect(typeof message).toBe('string'); - - const record = (authService as any).nonces.get(address.toLowerCase()); - expect(record).toBeDefined(); - expect(record.nonce).toBeDefined(); - expect(record.createdAt).toBeDefined(); - expect(typeof record.nonce).toBe('string'); - expect(typeof record.createdAt).toBe('number'); - }); - it('cleans up expired nonces (cleanupNonces removes old createdAt entries)', () => { - const address = '0xdead'; - authService.generateChallenge(address); - const map = (authService as any).nonces; - const record = map.get(address.toLowerCase()); - // simulate expiry by setting createdAt far in the past - record.createdAt = Date.now() - ((authService as any).NONCE_TTL + 1000); - - // call private cleanup - (authService as any).cleanupNonces(); - expect(map.has(address.toLowerCase())).toBe(false); + const message = await authService.generateChallenge(address); + + expect(message).toMatch(/^Sign in to TruthBounty: [A-Za-z0-9]{32}$/); + expect(redisService.set).toHaveBeenCalledWith( + 'auth:nonce:0xabcd', + expect.stringMatching(/^[A-Za-z0-9]{32}$/), + 300, + ); }); - it('allows login and deletes nonce (single-use) and is case-insensitive', async () => { + it('rejects a challenge response when the signed message does not exactly match the stored nonce', async () => { const address = '0xAaBbCc'; const lower = address.toLowerCase(); + const storedNonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; + const tamperedNonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123457'; - // generate challenge - const challenge = authService.generateChallenge(address); - const record = (authService as any).nonces.get(lower); - const nonce = record.nonce; + redisService.get.mockResolvedValueOnce(storedNonce); + (verifyMessage as jest.Mock).mockReturnValue(address); - // Prepare login DTO - const message = `Sign in to TruthBounty: ${nonce}`; - const signature = '0xsig'; + await expect( + authService.login({ + address, + signature: '0xsig', + message: `Sign in to TruthBounty: ${tamperedNonce}`, + } as any), + ).rejects.toBeInstanceOf(UnauthorizedException); + + expect(redisService.del).not.toHaveBeenCalled(); + expect(prisma.wallet.findFirst).not.toHaveBeenCalled(); + expect(jwtService.sign).not.toHaveBeenCalled(); + expect(redisService.get).toHaveBeenCalledWith(`auth:nonce:${lower}`); + }); - // mock verifyMessage to return mixed-case recovered address + it('logs in with an exact challenge message, deletes the nonce, and issues a JWT', async () => { + const address = '0xAaBbCc'; + const lower = address.toLowerCase(); + const storedNonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; + const challengeMessage = `Sign in to TruthBounty: ${storedNonce}`; + + redisService.get.mockResolvedValueOnce(storedNonce); + prisma.wallet.findFirst.mockResolvedValueOnce({ + address: lower, + user: { id: 'user-123' }, + } as any); (verifyMessage as jest.Mock).mockReturnValue(address); - // call login - const result = await authService.login({ address: lower, signature, message } as any); - expect(result).toBeDefined(); - expect(result.accessToken).toBe('signed-token'); + const result = await authService.login({ + address, + signature: '0xsig', + message: challengeMessage, + } as any); + + expect(result).toEqual({ + accessToken: 'signed-token', + user: { + id: 'user-123', + address: lower, + }, + }); + expect(redisService.del).toHaveBeenCalledWith(`auth:nonce:${lower}`); + expect(jwtService.sign).toHaveBeenCalledWith({ + address: lower, + userId: 'user-123', + sub: 'user-123', + }); + }); + + it('fails challenge generation when Redis rejects the nonce write', async () => { + redisService.set.mockResolvedValueOnce(false); - // ensure nonce deleted (single-use) - expect((authService as any).nonces.has(lower)).toBe(false); + await expect(authService.generateChallenge('0xAbCd')).rejects.toBeInstanceOf( + InternalServerErrorException, + ); }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 976fb03..821f9f3 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,13 +1,9 @@ import { Injectable, BadRequestException, UnauthorizedException, InternalServerErrorException, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { verifyMessage } from 'ethers'; +import { timingSafeEqual } from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; import { LoginDto } from './dto/login.dto'; - -export interface NonceRecord { - nonce: string; - createdAt: number; -} import { RedisService } from '../redis/redis.service'; @Injectable() @@ -70,8 +66,10 @@ export class AuthService { throw new UnauthorizedException('No challenge found or challenge expired. Please request a challenge first.'); } - // Verify the message contains the correct nonce - if (!message.includes(stored)) { + const expectedMessage = `Sign in to TruthBounty: ${stored}`; + + // Compare the full challenge message in constant time to avoid timing attacks. + if (!this.constantTimeEquals(message, expectedMessage)) { throw new UnauthorizedException('Invalid nonce in message.'); } @@ -154,14 +152,16 @@ export class AuthService { } /** - * Clean up expired nonces + * Constant-time string comparison for challenge messages. */ - private cleanupNonces(): void { - const now = Date.now(); - for (const [address, record] of this.nonces.entries()) { - if (now - record.createdAt > this.NONCE_TTL) { - this.nonces.delete(address); - } + private constantTimeEquals(a: string, b: string): boolean { + const aBuffer = Buffer.from(a, 'utf8'); + const bBuffer = Buffer.from(b, 'utf8'); + + if (aBuffer.length !== bBuffer.length) { + return false; } + + return timingSafeEqual(aBuffer, bBuffer); } }