diff --git a/context/progress-tracker.md b/context/progress-tracker.md index bb527c7..fc29a0a 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -6,6 +6,41 @@ pure chore/docs commits). Direct pushes to main must also be logged here. --- +## 2026-08-28 + +- Fixed TOCTOU nonce reuse in `AuthService.verifySignature()` (src/modules/auth/auth.service.ts:111): + - **Atomic nonce consumption** — replaced `SELECT → verify → UPDATE` with atomic + conditional claim `UPDATE nonces SET used_at = now() WHERE id = ? AND used_at IS NULL` + executed **before** signature verification. Only the winner of the race gets + `count === 1` / `data.length === 1`; losers get `count === 0` and are rejected + with `AUTH_NONCE_NOT_FOUND`. This guarantees a given `(wallet, nonce)` can + produce at most one successful verification ever, even under concurrent + `POST /auth/verify` requests carrying the same stolen pair. + - **Burn-on-failure tradeoff documented in code** — if verification fails + (invalid signature, bad StrKey, or `expires_at` in the past) the nonce stays + burned. The caller must request a fresh nonce; this converts replay attacks + into DoS-on-self (one wasted challenge) versus unlimited session creation. + Chosen over RPC/locking because a single conditional `UPDATE` is natively atomic + in Postgres and fits the existing `SupabaseService.getServiceRoleClient()` + pattern without a new migration. + - **Per-wallet throttling on `POST /auth/verify`** — new `AuthWalletThrottlerGuard` + (src/modules/auth/auth-throttler.guard.ts) keys `@nestjs/throttler` on + `req.body.wallet` (fallback to `req.user.wallet` / IP) and is applied via + `@UseGuards(AuthWalletThrottlerGuard)` alongside the existing global + IP-based `ThrottlerGuard`. Route limit stays `5 req / 60 s` per wallet **and** + per IP, preventing offline-style brute force of the SEP-0043 fallback space + at network speed. `WalletThrottlerGuard` was also hardened to type-check + wallet strings and accept `body.wallet` so the same infrastructure is reused. + - **Tests** — extended `test/unit/modules/auth/auth.service.spec.ts` to prove + atomicity: parallel double-verify → exactly one success, replay after success + fails, replay after failure stays burned (`AUTH_SIGNATURE_INVALID` → `AUTH_NONCE_NOT_FOUND`), + expired nonce rejected and stays burned, atomic race via `count === 0` rejected. + Added `test/unit/modules/auth/auth-throttler.guard.spec.ts` for the wallet-keyed + throttler and updated `auth.controller.spec.ts` to mock the guard. `npm run build` + and `npm test` green (38 suites, 425 tests). + +--- + ## 2026-08-27 - Closed the audit gaps on `POST /transactions/submit` (#117): diff --git a/src/modules/auth/auth-throttler.guard.ts b/src/modules/auth/auth-throttler.guard.ts new file mode 100644 index 0000000..c714a14 --- /dev/null +++ b/src/modules/auth/auth-throttler.guard.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; + +/** + * ThrottlerGuard variant for POST /auth/verify that keys rate limits on the + * wallet address supplied in the request body (unauthenticated) or on the + * authenticated wallet (if present). Prefers body wallet because verify is + * unauthenticated — the wallet is not yet in req.user. + * + * Falls back to the default IP-based tracker when no wallet is present so + * anonymous/probe traffic is still bounded per IP. + * + * Used alongside the global IP-based ThrottlerGuard so POST /auth/verify is + * bounded per wallet AND per IP — preventing brute-force of the SEP-0043 + * fallback space at network speed and limiting stolen-nonce replay attempts. + */ +@Injectable() +export class AuthWalletThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: Record): Promise { + const body = (req as { body?: { wallet?: unknown } }).body; + const user = (req as { user?: { wallet?: unknown } }).user; + const bodyWallet = typeof body?.wallet === 'string' ? body.wallet : undefined; + const userWallet = typeof user?.wallet === 'string' ? user.wallet : undefined; + // Prefer body wallet for unauthenticated verify; fall back to user wallet. + const wallet = bodyWallet ?? userWallet; + if (wallet) { + return `wallet:${wallet}`; + } + return super.getTracker(req); + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 070d532..3d15be1 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,21 +1,23 @@ -import { - Controller, - Post, - Body, - HttpCode, - HttpStatus, +import { + Controller, + Post, + Body, + HttpCode, + HttpStatus, NestInterceptor, ExecutionContext, CallHandler, - UseInterceptors, - UploadedFile, - ParseFilePipe, - MaxFileSizeValidator, - FileTypeValidator + UseInterceptors, + UseGuards, + UploadedFile, + ParseFilePipe, + MaxFileSizeValidator, + FileTypeValidator, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiConsumes, ApiBody } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { AuthService, RegisterResponse } from './auth.service'; +import { AuthWalletThrottlerGuard } from './auth-throttler.guard'; import { UploadedAvatarFile } from '../../database/repositories/users.repository'; import { NonceRequestDto } from './dto/nonce-request.dto'; import { NonceResponseDto } from './dto/nonce-response.dto'; @@ -73,9 +75,11 @@ export class AuthController { @Post('verify') @HttpCode(HttpStatus.OK) @Throttle({ default: { limit: 5, ttl: 60000 } }) + @UseGuards(AuthWalletThrottlerGuard) @ApiOperation({ summary: 'Verify wallet signature and issue JWT tokens' }) @ApiResponse({ status: 200, description: 'Signature verified — JWT tokens issued', type: AuthResponseDto }) @ApiResponse({ status: 401, description: 'Invalid signature or nonce' }) + @ApiResponse({ status: 429, description: 'Too many requests - rate limit exceeded (per wallet or per IP)' }) async verify(@Body() dto: VerifyRequestDto): Promise { await this.authService.verifySignature(dto); return this.authService.generateTokens(dto.wallet); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index d506152..f94b9da 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -190,9 +190,47 @@ export class AuthService { if (nonceError || !nonceRecord) { throw new UnauthorizedException({ code: 'AUTH_NONCE_NOT_FOUND', message: 'Nonce not found or already used.' }); } - if (new Date(nonceRecord.expires_at) < new Date()) { + + // Atomic nonce claim: consume the row BEFORE expensive signature verification. + // The conditional UPDATE ... WHERE id = ? AND used_at IS NULL is a single + // atomic statement in Postgres. Two concurrent verify requests that both + // observed the same unused row above will race here; only one UPDATE will + // affect a row (count === 1). The loser gets count === 0 / empty data and + // is rejected as already consumed. This eliminates the TOCTOU window that + // previously existed between the SELECT and the trailing UPDATE. + // + // SECURITY TRADEOFF: if signature verification subsequently fails (or the + // nonce is expired), the nonce stays burned. Callers must request a fresh + // nonce and re-sign. This converts a replay of a stolen nonce+signature + // into a DoS-on-self (one wasted challenge) which is the correct tradeoff + // versus allowing unlimited session creation from a single intercepted pair. + const claimedAt = new Date().toISOString(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supabase builder count typings for update().select() chain are incomplete; need runtime count check + const claimResult: any = await (client.from('nonces') as any) + .update({ used_at: claimedAt }, { count: 'exact' }) + .eq('id', (nonceRecord as { id: string }).id) + .is('used_at', null) + .select('id'); + const claimError = claimResult?.error as { message: string } | null | undefined; + const claimData = claimResult?.data as unknown[] | null | undefined; + const claimCount = claimResult?.count as number | null | undefined; + if (claimError) { + throw new InternalServerErrorException({ + code: 'DATABASE_NONCE_CLAIM_FAILED', + message: 'Failed to claim nonce.', + }); + } + const claimedCount = typeof claimCount === 'number' ? claimCount : (claimData?.length ?? 0); + if (claimedCount === 0) { + throw new UnauthorizedException({ code: 'AUTH_NONCE_NOT_FOUND', message: 'Nonce not found or already used.' }); + } + + // Nonce is now burned regardless of outcome below. Check expiry AFTER the + // claim so an expired row is still consumed and cannot be retried. + if (new Date((nonceRecord as { expires_at: string }).expires_at) < new Date()) { throw new UnauthorizedException({ code: 'AUTH_NONCE_EXPIRED', message: 'Nonce has expired.' }); } + if (!StrKey.isValidEd25519PublicKey(dto.wallet)) { throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); } @@ -229,7 +267,6 @@ export class AuthService { if (err instanceof UnauthorizedException) throw err; throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); } - await client.from('nonces').update({ used_at: new Date().toISOString() }).eq('id', nonceRecord.id); } /** diff --git a/src/modules/transactions/wallet-throttler.guard.ts b/src/modules/transactions/wallet-throttler.guard.ts index 8fdc2bd..364385f 100644 --- a/src/modules/transactions/wallet-throttler.guard.ts +++ b/src/modules/transactions/wallet-throttler.guard.ts @@ -6,11 +6,19 @@ import { ThrottlerGuard } from '@nestjs/throttler'; * (from the JWT payload) instead of the client IP. Used alongside the global * IP-based guard so POST /transactions/submit is bounded per wallet AND per * IP, preventing a single wallet from being used as an open relay to Horizon. + * + * Also checks req.body.wallet so the same guard can be reused for + * unauthenticated routes like POST /auth/verify where the wallet is in the + * request body. */ @Injectable() export class WalletThrottlerGuard extends ThrottlerGuard { - protected async getTracker(req: { user?: { wallet?: string } }): Promise { - const wallet = req.user?.wallet; - return wallet ? `wallet:${wallet}` : super.getTracker(req); + protected async getTracker(req: Record): Promise { + const user = (req as { user?: { wallet?: unknown } }).user; + const body = (req as { body?: { wallet?: unknown } }).body; + const userWallet = typeof user?.wallet === 'string' ? user.wallet : undefined; + const bodyWallet = typeof body?.wallet === 'string' ? body.wallet : undefined; + const wallet = userWallet ?? bodyWallet; + return wallet ? `wallet:${wallet}` : super.getTracker(req as Record); } } diff --git a/test/unit/modules/auth/auth-throttler.guard.spec.ts b/test/unit/modules/auth/auth-throttler.guard.spec.ts new file mode 100644 index 0000000..49585ce --- /dev/null +++ b/test/unit/modules/auth/auth-throttler.guard.spec.ts @@ -0,0 +1,59 @@ +import { AuthWalletThrottlerGuard } from '../../../../src/modules/auth/auth-throttler.guard'; + +describe('AuthWalletThrottlerGuard', () => { + function createGuard(): AuthWalletThrottlerGuard { + const storageService = { + increment: jest.fn(), + getRecord: jest.fn(), + }; + const options = [{ ttl: 60000, limit: 5 }]; + const reflector = {}; + return new (AuthWalletThrottlerGuard as unknown as new ( + ...args: unknown[] + ) => AuthWalletThrottlerGuard)(options, storageService, reflector); + } + + function getTrackerOf(guard: AuthWalletThrottlerGuard, req: unknown): Promise { + return ( + guard as unknown as { getTracker: (request: unknown) => Promise } + ).getTracker(req); + } + + it('keys the rate limit on the wallet from request body when present (unauthenticated verify)', async () => { + const guard = createGuard(); + const wallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; + + await expect(getTrackerOf(guard, { body: { wallet } })).resolves.toBe(`wallet:${wallet}`); + }); + + it('keys the rate limit on the authenticated wallet when present', async () => { + const guard = createGuard(); + const wallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; + + await expect(getTrackerOf(guard, { user: { wallet } })).resolves.toBe(`wallet:${wallet}`); + }); + + it('prefers body wallet over user wallet when both are present', async () => { + const guard = createGuard(); + const bodyWallet = 'GBODYWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const userWallet = 'GUSERWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + + await expect( + getTrackerOf(guard, { body: { wallet: bodyWallet }, user: { wallet: userWallet } }), + ).resolves.toBe(`wallet:${bodyWallet}`); + }); + + it('falls back to the IP-based tracker when no wallet is present', async () => { + const guard = createGuard(); + + await expect(getTrackerOf(guard, { ip: '203.0.113.7' })).resolves.toBe('203.0.113.7'); + }); + + it('falls back to IP when body wallet is not a string', async () => { + const guard = createGuard(); + + await expect(getTrackerOf(guard, { ip: '198.51.100.9', body: { wallet: 123 } })).resolves.toBe( + '198.51.100.9', + ); + }); +}); diff --git a/test/unit/modules/auth/auth.controller.spec.ts b/test/unit/modules/auth/auth.controller.spec.ts index 258cd8c..5da8918 100644 --- a/test/unit/modules/auth/auth.controller.spec.ts +++ b/test/unit/modules/auth/auth.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from '../../../../src/modules/auth/auth.controller'; import { AuthService } from '../../../../src/modules/auth/auth.service'; +import { AuthWalletThrottlerGuard } from '../../../../src/modules/auth/auth-throttler.guard'; describe('AuthController', () => { let controller: AuthController; @@ -30,7 +31,10 @@ describe('AuthController', () => { useValue: mockAuthService, }, ], - }).compile(); + }) + .overrideGuard(AuthWalletThrottlerGuard) + .useValue({ canActivate: jest.fn().mockReturnValue(true) }) + .compile(); controller = module.get(AuthController); authService = module.get(AuthService); diff --git a/test/unit/modules/auth/auth.service.spec.ts b/test/unit/modules/auth/auth.service.spec.ts index c46740d..1ba0f74 100644 --- a/test/unit/modules/auth/auth.service.spec.ts +++ b/test/unit/modules/auth/auth.service.spec.ts @@ -311,12 +311,12 @@ describe('AuthService', () => { function setupMocks({ nonceResult = { data: defaultNonceRecord, error: null }, - markUsedResult = { error: null }, + claimResult = { data: [{ id: 'nonce-uuid' }], error: null, count: 1 }, signatureValid = true, strKeyValid = true, }: { nonceResult?: NonceResult; - markUsedResult?: { error: unknown }; + claimResult?: { data: unknown[] | null; error: unknown; count: number | null }; signatureValid?: boolean; strKeyValid?: boolean; } = {}) { @@ -326,18 +326,31 @@ describe('AuthService', () => { mockFrom.mockImplementation((table: string) => { if (table === 'nonces') { - const updateChain = { eq: jest.fn().mockResolvedValue(markUsedResult) }; - const chain: Record = { + // Builder that supports both: + // SELECT: select().eq().is().single() -> nonceResult + // CLAIM: update().eq().is().select() -> claimResult + const builder: Record = { select: jest.fn(), eq: jest.fn(), is: jest.fn(), single: jest.fn().mockResolvedValue(nonceResult), - update: jest.fn().mockReturnValue(updateChain), + update: jest.fn(), }; - chain.select.mockReturnValue(chain); - chain.eq.mockReturnValue(chain); - chain.is.mockReturnValue(chain); - return chain; + let operation: 'select' | 'update' = 'select'; + builder.select.mockImplementation((..._args: unknown[]) => { + if (operation === 'update') { + // This is the terminal select after update().eq().is().select() + return Promise.resolve(claimResult); + } + return builder; + }); + builder.update.mockImplementation(() => { + operation = 'update'; + return builder; + }); + builder.eq.mockReturnValue(builder); + builder.is.mockReturnValue(builder); + return builder; } return { insert: mockInsert }; }); @@ -371,10 +384,19 @@ describe('AuthService', () => { }); }); - it('should throw UnauthorizedException (AUTH_NONCE_EXPIRED) when nonce is past expiry', async () => { + it('should throw UnauthorizedException (AUTH_NONCE_NOT_FOUND) when atomic claim loses the race (count === 0)', async () => { + setupMocks({ claimResult: { data: [], error: null, count: 0 } }); + + await expect(service.verifySignature(validDto)).rejects.toMatchObject({ + response: { code: 'AUTH_NONCE_NOT_FOUND' }, + }); + }); + + it('should throw UnauthorizedException (AUTH_NONCE_EXPIRED) when nonce is past expiry (nonce stays burned)', async () => { const expiredDate = new Date(Date.now() - 1000).toISOString(); setupMocks({ nonceResult: { data: { id: 'nonce-uuid', expires_at: expiredDate }, error: null }, + claimResult: { data: [{ id: 'nonce-uuid' }], error: null, count: 1 }, }); await expect(service.verifySignature(validDto)).rejects.toMatchObject({ @@ -383,6 +405,7 @@ describe('AuthService', () => { }); it('should throw UnauthorizedException (AUTH_SIGNATURE_INVALID) when StrKey validation fails', async () => { + // StrKey check happens after claim; claim should have succeeded setupMocks({ strKeyValid: false }); await expect(service.verifySignature(validDto)).rejects.toMatchObject({ @@ -584,12 +607,283 @@ describe('AuthService', () => { }); }); - it('should mark nonce as used after successful verification', async () => { + it('should mark nonce as used via atomic claim before signature verification', async () => { setupMocks(); await service.verifySignature(validDto); expect(mockFrom).toHaveBeenCalledWith('nonces'); }); + + it('should burn the nonce even when signature verification fails (DoS-on-self tradeoff)', async () => { + // Claim succeeds, but signature invalid — nonce stays burned, replay should get NOT_FOUND + const claimCallCount = { count: 0 }; + const mockKeypair = { verify: jest.fn().mockReturnValue(false) }; + (Keypair.fromPublicKey as jest.Mock).mockReturnValue(mockKeypair); + (StrKey.isValidEd25519PublicKey as jest.Mock).mockReturnValue(true); + + mockFrom.mockImplementation((table: string) => { + if (table === 'nonces') { + const builder: Record = { + select: jest.fn(), + eq: jest.fn(), + is: jest.fn(), + single: jest.fn(), + update: jest.fn(), + }; + let operation: 'select' | 'update' = 'select'; + // First verify: SELECT returns the nonce, CLAIM succeeds (count 1) + // Second verify (replay): claim would lose or SELECT already empty + builder.single.mockImplementation(() => { + // For this test we always return the nonce for SELECT, so the second + // call's failure is due to claim burning semantics, not SELECT miss. + // The service should still throw AUTH_NONCE_NOT_FOUND on second call + // because count === 0 (simulated via claimCallCount). + return Promise.resolve({ data: defaultNonceRecord, error: null }); + }); + builder.select.mockImplementation((..._args: unknown[]) => { + if (operation === 'update') { + claimCallCount.count += 1; + if (claimCallCount.count === 1) { + return Promise.resolve({ data: [{ id: 'nonce-uuid' }], error: null, count: 1 }); + } + return Promise.resolve({ data: [], error: null, count: 0 }); + } + return builder; + }); + builder.update.mockImplementation(() => { + operation = 'update'; + return builder; + }); + builder.eq.mockReturnValue(builder); + builder.is.mockReturnValue(builder); + return builder; + } + return { insert: mockInsert }; + }); + + // First call: claim succeeds but signature fails -> AUTH_SIGNATURE_INVALID, nonce burned + await expect(service.verifySignature(validDto)).rejects.toMatchObject({ + response: { code: 'AUTH_SIGNATURE_INVALID' }, + }); + + // Fix signature to be valid, but nonce is already burned -> should get NOT_FOUND, not success + mockKeypair.verify.mockReturnValue(true); + await expect(service.verifySignature(validDto)).rejects.toMatchObject({ + response: { code: 'AUTH_NONCE_NOT_FOUND' }, + }); + }); + }); + + describe('verifySignature — atomicity / concurrency', () => { + const validNonce = 'a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890'; + const validSignature = Buffer.alloc(64).toString('base64'); + const futureExpiry = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + const defaultNonceRecord = { id: 'nonce-uuid', expires_at: futureExpiry }; + const validDto = { wallet: validWallet, nonce: validNonce, signature: validSignature }; + + it('parallel double-verify yields exactly one success (atomic claim)', async () => { + const mockKeypair = { verify: jest.fn().mockReturnValue(true) }; + (Keypair.fromPublicKey as jest.Mock).mockReturnValue(mockKeypair); + (StrKey.isValidEd25519PublicKey as jest.Mock).mockReturnValue(true); + + let claimAttempts = 0; + mockFrom.mockImplementation((table: string) => { + if (table === 'nonces') { + const builder: Record = { + select: jest.fn(), + eq: jest.fn(), + is: jest.fn(), + single: jest.fn().mockResolvedValue({ data: defaultNonceRecord, error: null }), + update: jest.fn(), + }; + let op: 'select' | 'update' = 'select'; + builder.select.mockImplementation((..._args: unknown[]) => { + if (op === 'update') { + claimAttempts += 1; + if (claimAttempts === 1) { + return Promise.resolve({ data: [{ id: 'nonce-uuid' }], error: null, count: 1 }); + } + return Promise.resolve({ data: [], error: null, count: 0 }); + } + return builder; + }); + builder.update.mockImplementation(() => { + op = 'update'; + return builder; + }); + builder.eq.mockReturnValue(builder); + builder.is.mockReturnValue(builder); + return builder; + } + return { insert: mockInsert }; + }); + + const results = await Promise.allSettled([ + service.verifySignature(validDto), + service.verifySignature(validDto), + ]); + + const fulfilled = results.filter((r) => r.status === 'fulfilled'); + const rejected = results.filter((r) => r.status === 'rejected'); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + const rejectedReason = (rejected[0] as PromiseRejectedResult).reason as { response: { code: string } }; + expect(rejectedReason.response.code).toBe('AUTH_NONCE_NOT_FOUND'); + }); + + it('replay after success fails with AUTH_NONCE_NOT_FOUND', async () => { + const mockKeypair = { verify: jest.fn().mockReturnValue(true) }; + (Keypair.fromPublicKey as jest.Mock).mockReturnValue(mockKeypair); + (StrKey.isValidEd25519PublicKey as jest.Mock).mockReturnValue(true); + + // First call: both SELECT and CLAIM succeed + mockFrom.mockImplementation((table: string) => { + if (table === 'nonces') { + const builder: Record = { + select: jest.fn(), + eq: jest.fn(), + is: jest.fn(), + single: jest.fn().mockResolvedValue({ data: defaultNonceRecord, error: null }), + update: jest.fn(), + }; + let op: 'select' | 'update' = 'select'; + builder.select.mockImplementation((..._args: unknown[]) => { + if (op === 'update') return Promise.resolve({ data: [{ id: 'nonce-uuid' }], error: null, count: 1 }); + return builder; + }); + builder.update.mockImplementation(() => { + op = 'update'; + return builder; + }); + builder.eq.mockReturnValue(builder); + builder.is.mockReturnValue(builder); + return builder; + } + return { insert: mockInsert }; + }); + + await expect(service.verifySignature(validDto)).resolves.toBeUndefined(); + + // Second call: nonce already used — SELECT returns no rows (or CLAIM count 0) + mockFrom.mockImplementation((table: string) => { + if (table === 'nonces') { + const builder: Record = { + select: jest.fn(), + eq: jest.fn(), + is: jest.fn(), + single: jest.fn().mockResolvedValue({ data: null, error: { message: 'No rows found' } }), + update: jest.fn(), + }; + builder.select.mockReturnValue(builder); + builder.eq.mockReturnValue(builder); + builder.is.mockReturnValue(builder); + builder.update.mockReturnValue(builder); + return builder; + } + return { insert: mockInsert }; + }); + + await expect(service.verifySignature(validDto)).rejects.toMatchObject({ + response: { code: 'AUTH_NONCE_NOT_FOUND' }, + }); + }); + + it('replay during failure burns the nonce (invalid signature leaves it consumed)', async () => { + const mockKeypair = { verify: jest.fn().mockReturnValue(false) }; + (Keypair.fromPublicKey as jest.Mock).mockReturnValue(mockKeypair); + (StrKey.isValidEd25519PublicKey as jest.Mock).mockReturnValue(true); + + let firstClaim = true; + mockFrom.mockImplementation((table: string) => { + if (table === 'nonces') { + const builder: Record = { + select: jest.fn(), + eq: jest.fn(), + is: jest.fn(), + single: jest.fn().mockResolvedValue({ data: defaultNonceRecord, error: null }), + update: jest.fn(), + }; + let op: 'select' | 'update' = 'select'; + builder.select.mockImplementation((..._args: unknown[]) => { + if (op === 'update') { + if (firstClaim) { + firstClaim = false; + return Promise.resolve({ data: [{ id: 'nonce-uuid' }], error: null, count: 1 }); + } + return Promise.resolve({ data: [], error: null, count: 0 }); + } + return builder; + }); + builder.update.mockImplementation(() => { + op = 'update'; + return builder; + }); + builder.eq.mockReturnValue(builder); + builder.is.mockReturnValue(builder); + return builder; + } + return { insert: mockInsert }; + }); + + await expect(service.verifySignature(validDto)).rejects.toMatchObject({ + response: { code: 'AUTH_SIGNATURE_INVALID' }, + }); + + // Even with a now-valid signature, replay should fail because nonce was burned + mockKeypair.verify.mockReturnValue(true); + await expect(service.verifySignature(validDto)).rejects.toMatchObject({ + response: { code: 'AUTH_NONCE_NOT_FOUND' }, + }); + }); + + it('expired nonce is rejected and stays burned', async () => { + const expiredRecord = { id: 'nonce-uuid', expires_at: new Date(Date.now() - 1000).toISOString() }; + const mockKeypair = { verify: jest.fn().mockReturnValue(true) }; + (Keypair.fromPublicKey as jest.Mock).mockReturnValue(mockKeypair); + (StrKey.isValidEd25519PublicKey as jest.Mock).mockReturnValue(true); + + let claimOnce = true; + mockFrom.mockImplementation((table: string) => { + if (table === 'nonces') { + const builder: Record = { + select: jest.fn(), + eq: jest.fn(), + is: jest.fn(), + single: jest.fn().mockResolvedValue({ data: expiredRecord, error: null }), + update: jest.fn(), + }; + let op: 'select' | 'update' = 'select'; + builder.select.mockImplementation((..._args: unknown[]) => { + if (op === 'update') { + if (claimOnce) { + claimOnce = false; + return Promise.resolve({ data: [{ id: 'nonce-uuid' }], error: null, count: 1 }); + } + return Promise.resolve({ data: [], error: null, count: 0 }); + } + return builder; + }); + builder.update.mockImplementation(() => { + op = 'update'; + return builder; + }); + builder.eq.mockReturnValue(builder); + builder.is.mockReturnValue(builder); + return builder; + } + return { insert: mockInsert }; + }); + + await expect(service.verifySignature(validDto)).rejects.toMatchObject({ + response: { code: 'AUTH_NONCE_EXPIRED' }, + }); + + // Second attempt should be NOT_FOUND because expired nonce was consumed + await expect(service.verifySignature(validDto)).rejects.toMatchObject({ + response: { code: 'AUTH_NONCE_NOT_FOUND' }, + }); + }); }); // ---------------------------------------------------------------------------