diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index edcf8f2..f0eacf8 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,11 +1,23 @@ import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { WalletLoginDto } from './auth.dto'; +import { WalletLoginDto, RequestChallengeDto, VerifyChallengeDto } from './auth.dto'; @Controller('auth') export class AuthController { constructor(private readonly auth: AuthService) {} + @Post('challenge') + @HttpCode(HttpStatus.OK) + requestChallenge(@Body() dto: RequestChallengeDto) { + return this.auth.requestChallenge(dto); + } + + @Post('verify') + @HttpCode(HttpStatus.OK) + verifyChallenge(@Body() dto: VerifyChallengeDto) { + return this.auth.verifyChallenge(dto); + } + @Post('login') @HttpCode(HttpStatus.OK) login(@Body() dto: WalletLoginDto) { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index e882665..9e51536 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -4,6 +4,7 @@ import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; +import { RedisModule } from '../redis/redis.module'; @Module({ imports: [ @@ -12,6 +13,7 @@ import { JwtStrategy } from './jwt.strategy'; secret: process.env.JWT_SECRET ?? 'dev-secret', signOptions: { expiresIn: process.env.JWT_EXPIRES_IN ?? '7d' }, }), + RedisModule, ], controllers: [AuthController], providers: [AuthService, JwtStrategy], diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 95e04e8..5fcc8af 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,13 +1,55 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { BadRequestException } from '@nestjs/common'; +import { UnauthorizedException, ServiceUnavailableException } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtService } from '@nestjs/jwt'; +import { RedisService } from '../redis/redis.service'; +import * as StellarSdk from '@stellar/stellar-sdk'; +import axios from 'axios'; + +jest.mock('axios', () => { + const mockAxios: any = jest.fn(); + mockAxios.get = jest.fn(); + mockAxios.post = jest.fn(); + mockAxios.create = jest.fn(() => mockAxios); + mockAxios.interceptors = { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }; + mockAxios.defaults = { headers: { common: {} } }; + return mockAxios; +}); +const mockedAxios = axios as jest.Mocked; describe('AuthService', () => { let service: AuthService; - let jwtService: JwtService; + + const mockWallet = StellarSdk.Keypair.random(); + const mockServerKeypair = StellarSdk.Keypair.random(); + const mockNonce = 'a'.repeat(64); + + const mockRedisClient = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + incr: jest.fn(), + expire: jest.fn(), + }; + + beforeAll(() => { + process.env.STELLAR_PLATFORM_SECRET_KEY = mockServerKeypair.secret(); + process.env.CHALLENGE_TTL_SECONDS = '300'; + }); + + afterAll(() => { + delete process.env.STELLAR_PLATFORM_SECRET_KEY; + delete process.env.CHALLENGE_TTL_SECONDS; + }); beforeEach(async () => { + jest.clearAllMocks(); + + mockRedisClient.incr.mockResolvedValue(1); // rate limit + const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, @@ -15,30 +57,330 @@ describe('AuthService', () => { provide: JwtService, useValue: { sign: jest.fn().mockReturnValue('mock-token') }, }, + { + provide: RedisService, + useValue: { getClient: () => mockRedisClient }, + }, ], }).compile(); service = module.get(AuthService); - jwtService = module.get(JwtService); + + // Reset circuit breaker + (service as any).horizonFailureCount = 0; + (service as any).horizonCircuitOpenUntil = 0; }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('should request a challenge successfully', async () => { + const result = await service.requestChallenge({ walletAddress: mockWallet.publicKey() }); + expect(result).toHaveProperty('transaction'); + expect(result).toHaveProperty('passphrase'); + expect(result).toHaveProperty('expiresAt'); + expect(mockRedisClient.set).toHaveBeenCalled(); }); - it('should return token for valid wallet address via walletLogin', async () => { - const result = await service.walletLogin({ - walletAddress: 'G' + 'A'.repeat(55), + it('should verify a valid challenge successfully', async () => { + mockRedisClient.get.mockResolvedValue(mockNonce); + mockedAxios.get.mockResolvedValue({ + data: { signers: [{ key: mockWallet.publicKey(), weight: 1 }] }, }); - expect(result).toHaveProperty('access_token'); - expect(result).toHaveProperty('wallet'); + + // Create a mock valid challenge tx + const timebounds = { + minTime: (Math.floor(Date.now() / 1000) - 100).toString(), + maxTime: (Math.floor(Date.now() / 1000) + 100).toString(), + }; + + const tx = new StellarSdk.TransactionBuilder( + new StellarSdk.Account(mockServerKeypair.publicKey(), '0'), + { + fee: StellarSdk.BASE_FEE, + networkPassphrase: StellarSdk.Networks.TESTNET, + timebounds, + }, + ) + .addOperation( + StellarSdk.Operation.manageData({ + source: mockWallet.publicKey(), + name: `${mockServerKeypair.publicKey()} auth`, + value: Buffer.from(mockNonce, 'hex'), + }), + ) + .build(); + + tx.sign(mockServerKeypair); // Server signs + tx.sign(mockWallet); // Client signs + + const result = await service.verifyChallenge({ + walletAddress: mockWallet.publicKey(), + transaction: { + tx: tx.toEnvelope().toXDR('base64'), + passphrase: StellarSdk.Networks.TESTNET, + }, + }); + expect(result.access_token).toBe('mock-token'); - expect(jest.spyOn(jwtService, 'sign')).toHaveBeenCalled(); + expect(mockRedisClient.del).toHaveBeenCalled(); // single-use nonce + }); + + it('should reject if nonce is not in redis (expired/used)', async () => { + mockRedisClient.get.mockResolvedValue(null); + await expect( + service.verifyChallenge({ + walletAddress: mockWallet.publicKey(), + transaction: { tx: 'base64xdr', passphrase: StellarSdk.Networks.TESTNET }, + }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should reject expired timebounds (clock skew > 60s)', async () => { + mockRedisClient.get.mockResolvedValue(mockNonce); + + const timebounds = { + minTime: (Math.floor(Date.now() / 1000) - 400).toString(), // 400s in past + maxTime: (Math.floor(Date.now() / 1000) - 100).toString(), // 100s in past + }; + + const tx = new StellarSdk.TransactionBuilder( + new StellarSdk.Account(mockServerKeypair.publicKey(), '0'), + { + fee: StellarSdk.BASE_FEE, + networkPassphrase: StellarSdk.Networks.TESTNET, + timebounds, + }, + ) + .addOperation( + StellarSdk.Operation.manageData({ + source: mockWallet.publicKey(), + name: `${mockServerKeypair.publicKey()} auth`, + value: Buffer.from(mockNonce, 'hex'), + }), + ) + .build(); + + tx.sign(mockServerKeypair); + tx.sign(mockWallet); + + await expect( + service.verifyChallenge({ + walletAddress: mockWallet.publicKey(), + transaction: { + tx: tx.toEnvelope().toXDR('base64'), + passphrase: StellarSdk.Networks.TESTNET, + }, + }), + ).rejects.toThrow(/expired/i); }); - it('should throw BadRequestException when requestChallenge has no server configured', async () => { - await expect(service.requestChallenge({ walletAddress: 'G' + 'A'.repeat(55) })).rejects.toThrow( - BadRequestException, - ); + it('should retry horizon and then fail, opening circuit breaker', async () => { + mockRedisClient.get.mockResolvedValue(mockNonce); + + // Simulate Horizon 503 error + mockedAxios.get.mockRejectedValue({ + response: { status: 503 }, + }); + + const timebounds = { + minTime: (Math.floor(Date.now() / 1000) - 100).toString(), + maxTime: (Math.floor(Date.now() / 1000) + 100).toString(), + }; + + const tx = new StellarSdk.TransactionBuilder( + new StellarSdk.Account(mockServerKeypair.publicKey(), '0'), + { fee: StellarSdk.BASE_FEE, networkPassphrase: StellarSdk.Networks.TESTNET, timebounds }, + ) + .addOperation( + StellarSdk.Operation.manageData({ + source: mockWallet.publicKey(), + name: `${mockServerKeypair.publicKey()} auth`, + value: Buffer.from(mockNonce, 'hex'), + }), + ) + .build(); + + tx.sign(mockServerKeypair); + tx.sign(mockWallet); + + // Trigger the circuit breaker by failing 3 times + for (let i = 0; i < 3; i++) { + await expect( + service.verifyChallenge({ + walletAddress: mockWallet.publicKey(), + transaction: { + tx: tx.toEnvelope().toXDR('base64'), + passphrase: StellarSdk.Networks.TESTNET, + }, + }), + ).rejects.toThrow(ServiceUnavailableException); + } + + expect(mockedAxios.get).toHaveBeenCalledTimes(9); // 3 calls * (Initial + 2 retries) + expect((service as any).horizonFailureCount).toBe(3); + expect((service as any).horizonCircuitOpenUntil).toBeGreaterThan(Date.now()); + }, 30000); + + it('should enforce rate limiting', async () => { + mockRedisClient.get.mockResolvedValue('11'); // Over limit + await expect( + service.verifyChallenge({ + walletAddress: mockWallet.publicKey(), + transaction: { tx: 'base64xdr', passphrase: StellarSdk.Networks.TESTNET }, + }), + ).rejects.toThrow(/Too many failed/i); + }); + + it('should reject wrong network passphrase', async () => { + mockRedisClient.get.mockResolvedValue(mockNonce); + + const timebounds = { + minTime: (Math.floor(Date.now() / 1000) - 100).toString(), + maxTime: (Math.floor(Date.now() / 1000) + 100).toString(), + }; + + const tx = new StellarSdk.TransactionBuilder( + new StellarSdk.Account(mockServerKeypair.publicKey(), '0'), + { fee: StellarSdk.BASE_FEE, networkPassphrase: StellarSdk.Networks.PUBLIC, timebounds }, // Wrong passphrase + ) + .addOperation( + StellarSdk.Operation.manageData({ + source: mockWallet.publicKey(), + name: `${mockServerKeypair.publicKey()} auth`, + value: Buffer.from(mockNonce, 'hex'), + }), + ) + .build(); + + tx.sign(mockServerKeypair); + tx.sign(mockWallet); + + await expect( + service.verifyChallenge({ + walletAddress: mockWallet.publicKey(), + transaction: { + tx: tx.toEnvelope().toXDR('base64'), + passphrase: StellarSdk.Networks.TESTNET, + }, + }), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should reject forged signature', async () => { + mockRedisClient.get.mockResolvedValue(mockNonce); + + const timebounds = { + minTime: (Math.floor(Date.now() / 1000) - 100).toString(), + maxTime: (Math.floor(Date.now() / 1000) + 100).toString(), + }; + + const tx = new StellarSdk.TransactionBuilder( + new StellarSdk.Account(mockServerKeypair.publicKey(), '0'), + { fee: StellarSdk.BASE_FEE, networkPassphrase: StellarSdk.Networks.TESTNET, timebounds }, + ) + .addOperation( + StellarSdk.Operation.manageData({ + source: mockWallet.publicKey(), + name: `${mockServerKeypair.publicKey()} auth`, + value: Buffer.from(mockNonce, 'hex'), + }), + ) + .build(); + + tx.sign(mockServerKeypair); + // Missing client signature or using wrong keypair + const fakeClient = StellarSdk.Keypair.random(); + tx.sign(fakeClient); + + mockedAxios.get.mockResolvedValue({ + data: { signers: [{ key: mockWallet.publicKey(), weight: 1 }] }, + }); + + await expect( + service.verifyChallenge({ + walletAddress: mockWallet.publicKey(), + transaction: { + tx: tx.toEnvelope().toXDR('base64'), + passphrase: StellarSdk.Networks.TESTNET, + }, + }), + ).rejects.toThrow(/signature is invalid/i); + }); + + it('should handle Horizon timeout and trigger 503', async () => { + // Reset circuit breaker to ensure a fresh start + (service as any).horizonFailureCount = 0; + (service as any).horizonCircuitOpenUntil = 0; + + mockRedisClient.get.mockResolvedValue(mockNonce); + // Simulate Horizon timeout + mockedAxios.get.mockRejectedValue({ code: 'ECONNABORTED' }); + + const timebounds = { + minTime: (Math.floor(Date.now() / 1000) - 100).toString(), + maxTime: (Math.floor(Date.now() / 1000) + 100).toString(), + }; + + const tx = new StellarSdk.TransactionBuilder( + new StellarSdk.Account(mockServerKeypair.publicKey(), '0'), + { fee: StellarSdk.BASE_FEE, networkPassphrase: StellarSdk.Networks.TESTNET, timebounds }, + ) + .addOperation( + StellarSdk.Operation.manageData({ + source: mockWallet.publicKey(), + name: `${mockServerKeypair.publicKey()} auth`, + value: Buffer.from(mockNonce, 'hex'), + }), + ) + .build(); + + tx.sign(mockServerKeypair); + tx.sign(mockWallet); + + // After 3 calls, circuit breaker opens + for (let i = 0; i < 3; i++) { + await expect( + service.verifyChallenge({ + walletAddress: mockWallet.publicKey(), + transaction: { + tx: tx.toEnvelope().toXDR('base64'), + passphrase: StellarSdk.Networks.TESTNET, + }, + }), + ).rejects.toThrow(ServiceUnavailableException); + } + }, 30000); + + it('should reject clock skew edge cases (just outside)', async () => { + mockRedisClient.get.mockResolvedValue(mockNonce); + + const timebounds = { + minTime: (Math.floor(Date.now() / 1000) - 100).toString(), + maxTime: (Math.floor(Date.now() / 1000) - 61).toString(), // 61s in past (skew limit is 60) + }; + + const tx = new StellarSdk.TransactionBuilder( + new StellarSdk.Account(mockServerKeypair.publicKey(), '0'), + { fee: StellarSdk.BASE_FEE, networkPassphrase: StellarSdk.Networks.TESTNET, timebounds }, + ) + .addOperation( + StellarSdk.Operation.manageData({ + source: mockWallet.publicKey(), + name: `${mockServerKeypair.publicKey()} auth`, + value: Buffer.from(mockNonce, 'hex'), + }), + ) + .build(); + + tx.sign(mockServerKeypair); + tx.sign(mockWallet); + + await expect( + service.verifyChallenge({ + walletAddress: mockWallet.publicKey(), + transaction: { + tx: tx.toEnvelope().toXDR('base64'), + passphrase: StellarSdk.Networks.TESTNET, + }, + }), + ).rejects.toThrow(/expired/i); }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 76713fa..20efeaa 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,188 +1,299 @@ -import { Injectable, UnauthorizedException, BadRequestException, Logger } from '@nestjs/common'; +import { + Injectable, + UnauthorizedException, + BadRequestException, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { RequestChallengeDto, VerifyChallengeDto } from './auth.dto'; +import { RedisService } from '../redis/redis.service'; import * as StellarSdk from '@stellar/stellar-sdk'; import * as crypto from 'crypto'; +import axios from 'axios'; -const CHALLENGE_TTL_MS = 5 * 60 * 1000; -const NONCE_BYTES = 48; -const SERVER_ACCOUNT = - process.env.STELLAR_PLATFORM_ACCOUNT || process.env.PLATFORM_RECEIVING_ACCOUNT; - -interface PendingChallenge { - nonce: string; - serverAccountId: string; - createdAt: number; -} +const CHALLENGE_TTL_SECONDS = parseInt(process.env.CHALLENGE_TTL_SECONDS || '300', 10); +const NONCE_BYTES = 32; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); - private readonly pendingChallenges = new Map(); - private serverKeypair: StellarSdk.Keypair | null = null; - constructor(private readonly jwt: JwtService) { - this.initializeServerKeypair(); - } + // Circuit breaker state + private horizonFailureCount = 0; + private horizonCircuitOpenUntil = 0; - private initializeServerKeypair() { - const secret = process.env.STELLAR_PLATFORM_SECRET_KEY; - if (secret) { - try { - this.serverKeypair = StellarSdk.Keypair.fromSecret(secret); - this.logger.log('SEP-10 server keypair initialized from STELLAR_PLATFORM_SECRET_KEY'); - } catch { - this.logger.error('Invalid STELLAR_PLATFORM_SECRET_KEY — SEP-10 challenges will fail'); - } + constructor( + private readonly jwt: JwtService, + private readonly redisService: RedisService, + ) {} + + private getNetworkConfig() { + const network = (process.env.STELLAR_NETWORK || 'TESTNET').toUpperCase(); + if (network === 'MAINNET') { + return { + passphrase: StellarSdk.Networks.PUBLIC, + horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org', + secret: process.env.MAINNET_AUTH_SECRET_KEY || process.env.STELLAR_PLATFORM_SECRET_KEY, + }; } + return { + passphrase: StellarSdk.Networks.TESTNET, + horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org', + secret: process.env.TESTNET_AUTH_SECRET_KEY || process.env.STELLAR_PLATFORM_SECRET_KEY, + }; } - private getServerAccountId(): string { - if (this.serverKeypair) { - return this.serverKeypair.publicKey(); + private cachedServerKeypair: StellarSdk.Keypair | null = null; + + private getServerKeypair(): StellarSdk.Keypair { + if (this.cachedServerKeypair) { + return this.cachedServerKeypair; } - if (SERVER_ACCOUNT) { - return SERVER_ACCOUNT; + const config = this.getNetworkConfig(); + if (!config.secret) { + throw new BadRequestException('Auth server secret key not configured for the active network'); } - throw new BadRequestException( - 'SEP-10 not configured: set STELLAR_PLATFORM_SECRET_KEY or PLATFORM_RECEIVING_ACCOUNT', - ); + this.cachedServerKeypair = StellarSdk.Keypair.fromSecret(config.secret); + return this.cachedServerKeypair; } - private getServerKeypair(): StellarSdk.Keypair { - if (!this.serverKeypair) { - throw new BadRequestException( - 'SEP-10 not configured: set STELLAR_PLATFORM_SECRET_KEY environment variable', - ); + private async fetchAccountSignersWithResilience(accountId: string, horizonUrl: string) { + if (Date.now() < this.horizonCircuitOpenUntil) { + throw new ServiceUnavailableException('Horizon API temporarily unavailable'); } - return this.serverKeypair; - } - private getNetworkPassphrase(): string { - const network = process.env.STELLAR_NETWORK?.toUpperCase(); - if (network === 'MAINNET') { - return StellarSdk.Networks.PUBLIC; + const maxRetries = 2; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await axios.get(`${horizonUrl}/accounts/${accountId}`, { + timeout: 5000, + }); + this.horizonFailureCount = 0; // reset on success + return response.data.signers || []; + } catch (error: any) { + if (error.response?.status === 404) { + // Account not found on ledger, not an API failure. Master key is the only signer. + return [{ key: accountId, weight: 1 }]; + } + + const isServerError = error.response?.status >= 500; + const isTimeout = + error.code === 'ECONNABORTED' || (error.message && error.message.includes('timeout')); + + if (isServerError || isTimeout) { + if (attempt < maxRetries) { + const delay = attempt === 0 ? 1000 : 2000; + await new Promise((res) => setTimeout(res, delay)); + continue; + } else { + this.horizonFailureCount++; + if (this.horizonFailureCount >= 3) { + this.horizonCircuitOpenUntil = Date.now() + 30000; // 30 seconds + this.logger.error('Horizon circuit breaker opened for 30s'); + } + throw new ServiceUnavailableException('Horizon API is unreachable'); + } + } + + throw error; + } } - return StellarSdk.Networks.TESTNET; } - private cleanExpiredChallenges() { - const now = Date.now(); - for (const [key, challenge] of this.pendingChallenges) { - if (now - challenge.createdAt > CHALLENGE_TTL_MS) { - this.pendingChallenges.delete(key); - } + private verifyTxSignedBy(transaction: StellarSdk.Transaction, publicKey: string): boolean { + try { + const hash = transaction.hash(); + const keypair = StellarSdk.Keypair.fromPublicKey(publicKey); + const expectedHint = keypair.signatureHint(); + return transaction.signatures.some((sig) => { + if (sig.hint().equals(expectedHint)) { + return keypair.verify(hash, sig.signature()); + } + return false; + }); + } catch { + return false; } } async requestChallenge(dto: RequestChallengeDto) { const { walletAddress } = dto; - this.cleanExpiredChallenges(); + const config = this.getNetworkConfig(); + const serverKeypair = this.getServerKeypair(); + const serverAccountId = serverKeypair.publicKey(); const nonce = crypto.randomBytes(NONCE_BYTES).toString('hex'); - const serverAccountId = this.getServerAccountId(); - try { - const serverAccount = await new StellarSdk.Horizon.Server( - process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org', - ).loadAccount(serverAccountId); - - const transaction = new StellarSdk.TransactionBuilder(serverAccount, { - fee: StellarSdk.BASE_FEE, - networkPassphrase: this.getNetworkPassphrase(), - }) - .addOperation( - StellarSdk.Operation.manageData({ - source: walletAddress, - name: `${serverAccountId} auth`, - value: Buffer.from(nonce, 'hex'), - }), - ) - .setTimeout(CHALLENGE_TTL_MS / 1000) - .build(); - - const txEnvelope = transaction.toEnvelope().toXDR('base64'); - - this.pendingChallenges.set(walletAddress, { - nonce, - serverAccountId, - createdAt: Date.now(), - }); + // We mock the sequence number for the challenge transaction. Standard SEP-10 uses "0". + const serverAccount = new StellarSdk.Account(serverAccountId, '0'); - return { - transaction: txEnvelope, - passphrase: this.getNetworkPassphrase(), - expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS).toISOString(), - }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Failed to create SEP-10 challenge: ${message}`); - throw new BadRequestException('Failed to create authentication challenge'); - } + const nowSeconds = Math.floor(Date.now() / 1000); + const timebounds = { + minTime: (nowSeconds - CHALLENGE_TTL_SECONDS).toString(), + maxTime: (nowSeconds + CHALLENGE_TTL_SECONDS).toString(), + }; + + const transaction = new StellarSdk.TransactionBuilder(serverAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: config.passphrase, + timebounds, + }) + .addOperation( + StellarSdk.Operation.manageData({ + source: walletAddress, + name: `${serverAccountId} auth`, + value: Buffer.from(nonce, 'hex'), + }), + ) + .build(); + + transaction.sign(serverKeypair); + + const txEnvelope = transaction.toEnvelope().toXDR('base64'); + + const redis = this.redisService.getClient(); + await redis.set(`challenge:${walletAddress}`, nonce, 'EX', CHALLENGE_TTL_SECONDS); + + return { + transaction: txEnvelope, + passphrase: config.passphrase, + expiresAt: new Date((nowSeconds + CHALLENGE_TTL_SECONDS) * 1000).toISOString(), + }; } async verifyChallenge(dto: VerifyChallengeDto) { - const { walletAddress, transaction: txData } = dto; - const pending = this.pendingChallenges.get(walletAddress); + const { walletAddress } = dto; + const redis = this.redisService.getClient(); + const rateLimitKey = `rate_limit:verify:${walletAddress}`; - if (!pending) { - throw new UnauthorizedException('No pending challenge for this wallet. Request a new one.'); + const attemptsStr = await redis.get(rateLimitKey); + const attempts = attemptsStr ? parseInt(attemptsStr, 10) : 0; + if (attempts >= 10) { + throw new UnauthorizedException('Too many failed verification attempts. Try again later.'); } - if (Date.now() - pending.createdAt > CHALLENGE_TTL_MS) { - this.pendingChallenges.delete(walletAddress); - throw new UnauthorizedException('Challenge expired. Request a new one.'); + try { + return await this.verifyChallengeCore(dto, redis); + } catch (error) { + if (error instanceof UnauthorizedException) { + const newAttempts = await redis.incr(rateLimitKey); + if (newAttempts === 1) { + await redis.expire(rateLimitKey, 60); + } + } + throw error; } + } + private async verifyChallengeCore(dto: VerifyChallengeDto, redis: any) { + const { walletAddress, transaction: txData } = dto; + const storedNonce = await redis.get(`challenge:${walletAddress}`); + + if (!storedNonce) { + throw new UnauthorizedException('Challenge expired or not found. Request a new one.'); + } + + let transaction: StellarSdk.Transaction; try { - const transaction = StellarSdk.TransactionBuilder.fromXDR(txData.tx, txData.passphrase); + transaction = StellarSdk.TransactionBuilder.fromXDR( + txData.tx, + txData.passphrase, + ) as StellarSdk.Transaction; + } catch { + throw new UnauthorizedException('Invalid transaction XDR'); + } - const txSource = (transaction as StellarSdk.Transaction).source; - if (txSource !== walletAddress) { - throw new UnauthorizedException('Transaction source does not match wallet address'); - } + const config = this.getNetworkConfig(); + const serverKeypair = this.getServerKeypair(); - const serverKeypair = this.getServerKeypair(); + const txSource = transaction.source; + if (txSource !== serverKeypair.publicKey()) { + throw new UnauthorizedException('Transaction source does not match server account'); + } - const signatureHint = transaction.signatures[0].hint(); - const serverHint = serverKeypair.signatureHint(); + // Check operations + const operations = transaction.operations; + if (operations.length !== 1) { + throw new UnauthorizedException('Challenge transaction must have exactly one operation'); + } - if (Buffer.compare(signatureHint, serverHint) !== 0) { - throw new UnauthorizedException('Transaction not signed by server account'); - } + const op = operations[0] as unknown as StellarSdk.Operation.ManageData; + if (op.source !== walletAddress) { + throw new UnauthorizedException('Operation source does not match wallet address'); + } - const operations = transaction.operations; - if (operations.length !== 1) { - throw new UnauthorizedException('Challenge transaction must have exactly one operation'); - } + const opName = op.name; + if (opName !== `${serverKeypair.publicKey()} auth`) { + throw new UnauthorizedException('Invalid manageData operation name'); + } - const op = operations[0] as unknown as StellarSdk.Operation.ManageData; - const opName = (op as unknown as { name: string }).name; - if (opName !== `${pending.serverAccountId} auth`) { - throw new UnauthorizedException('Invalid manageData operation name'); - } + const opValue = op.value; + const opNonce = Buffer.from(opValue).toString('hex'); + if (opNonce !== storedNonce) { + throw new UnauthorizedException('Nonce mismatch'); + } - const opValue = (op as unknown as { value: Buffer }).value; - const opNonce = Buffer.from(opValue).toString('hex'); - if (opNonce !== pending.nonce) { - throw new UnauthorizedException('Nonce mismatch'); - } + // Validate Timebounds + const timebounds = transaction.timeBounds; + if (!timebounds) { + throw new UnauthorizedException('Transaction must have timebounds'); + } - this.pendingChallenges.delete(walletAddress); + const nowSeconds = Math.floor(Date.now() / 1000); + const minTime = parseInt(timebounds.minTime, 10); + const maxTime = parseInt(timebounds.maxTime, 10); - const payload = { sub: walletAddress, walletAddress, authMethod: 'sep10' }; - return { - access_token: this.jwt.sign(payload), - wallet: walletAddress, - }; - } catch (err: unknown) { - if (err instanceof UnauthorizedException || err instanceof BadRequestException) { - throw err; + // Clock skew tolerance + const skewLimit = 60; + if (nowSeconds < minTime - skewLimit || nowSeconds > maxTime + skewLimit) { + throw new UnauthorizedException('Challenge transaction expired (timebounds)'); + } + + const minSkew = Math.max(0, minTime - nowSeconds); + const maxSkew = Math.max(0, nowSeconds - maxTime); + const actualSkew = Math.max(minSkew, maxSkew); + + if (actualSkew > 30) { + this.logger.warn(`Significant clock skew detected: ${actualSkew} seconds`); + } + + // Server should have signed the challenge initially + const serverSigned = this.verifyTxSignedBy(transaction, serverKeypair.publicKey()); + if (!serverSigned) { + throw new UnauthorizedException('Transaction not signed by server account'); + } + + // Check client signatures + let signers: Array<{ key: string; weight: number }> = []; + try { + signers = await this.fetchAccountSignersWithResilience(walletAddress, config.horizonUrl); + } catch (error) { + if (error instanceof ServiceUnavailableException) { + throw error; } - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`SEP-10 verification failed: ${message}`); - throw new UnauthorizedException('Transaction verification failed'); + this.logger.error(`Failed to fetch account for verification: ${error}`); + throw new UnauthorizedException('Verification failed during network request'); + } + + // Check if the client signed it directly + const clientSigned = this.verifyTxSignedBy(transaction, walletAddress); + + // To support multi-sig, we check if ANY of the signers' public keys have signed the tx + const hasValidSigner = signers.some((signer) => this.verifyTxSignedBy(transaction, signer.key)); + + if (!clientSigned && !hasValidSigner) { + throw new UnauthorizedException('Transaction signature is invalid or insufficient'); } + + // Success! Delete nonce + await redis.del(`challenge:${walletAddress}`); + + const payload = { sub: walletAddress, walletAddress, authMethod: 'sep10' }; + return { + access_token: this.jwt.sign(payload), + wallet: walletAddress, + }; } async walletLogin(dto: { walletAddress: string }) {