Skip to content
Open
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
43 changes: 43 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,49 @@ 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).

- Hardened `ApiKeyGuard` hot path (`src/auth/guards/api-key.guard.ts:29`):
- **Cache key records by hash** β€” `CACHE_MANAGER` (Redis via `cache-manager` + `ioredis`, same pattern as `src/modules/liquidity/liquidity.service.ts:54` and `src/modules/transactions/transactions.service.ts:121`) stores `ApiKeyRecord` under `apikey:record:<keyHash>` (never the raw key) with `60s` TTL. Steady-state vendor traffic now causes ≀1 `SELECT` per TTL per key instead of 2 DB round-trips per request (lookup + unconditional `last_used_at` update). Negative lookups are not cached to avoid polluting the store; enumeration is handled by unified errors.
- **Collapsed `last_used_at` writes** β€” cache-guarded dirty flag `apikey:last_used:<keyId>` with `300s` TTL ensures at-most-once-per-5-minutes-per-key DB `UPDATE`, eliminating 1:1 write amplification. Fire-and-forget `maybeUpdateLastUsed()` logs but never blocks the request.
- **Normalized failure responses** β€” `API_KEY_INVALID`, `API_KEY_INACTIVE`, `API_KEY_EXPIRED`, and missing/malformed headers all map to a single `API_KEY_UNAUTHORIZED` (401) with `message: 'Invalid API key.'`. Server-side `Logger.warn` retains distinct reasons (`hash 8-char prefix`, `keyId`) for forensics, preventing enumeration of revoked vs expired vs nonexistent keys. `API_KEY_INSUFFICIENT_PERMISSIONS` (403) and `API_KEY_RATE_LIMITED` (429) remain distinct.
- **Per-key sliding-window rate limiting** β€” cache-backed counter `apikey:rate:<keyId>` with `60s` window and `60` req limit (structured `429` `API_KEY_RATE_LIMITED` via `HttpException`). Wired through the repo's established `CACHE_MANAGER` guard pattern (not a new BullMQ queue), consistent with `ThrottlerGuard` per-wallet limits. Trips and resets with TTL are tested.
- **Revocation invalidation** β€” `VendorsService.revokeApiKey()` (`src/modules/vendors/vendors.service.ts:636`) now selects `key_hash` alongside `id`, performs the `is_active=false` update, then `await cacheManager.del` for `apikey:record:<hash>`, `apikey:rate:<keyId>`, and `apikey:last_used:<keyId>`, guaranteeing visibility within one TTL. `VendorsService` now injects `CACHE_MANAGER` (`@Inject(CACHE_MANAGER)`) and `src/app.module.ts:13` registers a global `CacheModule` (`isGlobal: true`) via `getRedisConfig` so `ApiKeyGuard` and `VendorsService` share the same Redis/in-memory store.
- **Tests** β€” rewrote `test/unit/modules/auth/api-key.guard.spec.ts:7` to assert cache hit avoids DB (mock `select` call counts and `never store full keys`), revocation invalidation (manual `del` then DB re-check), rate-limit trips (`60` β†’ `429`) and resets after TTL, and enumeration uniformity (missing/invalid/inactive/expired all `API_KEY_UNAUTHORIZED`). Updated `test/unit/modules/vendors/vendors.service.spec.ts:14` to provide `CACHE_MANAGER` mock and verify `revokeApiKey` deletes the three cache keys and tolerates cache failures. `npm run build` and `npm test` green (38 suites, 434 tests).

---

## 2026-08-27

- Closed the audit gaps on `POST /transactions/submit` (#117):
Expand Down
10 changes: 9 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { MiddlewareConsumer, Module, NestModule, OnModuleInit } from '@nestjs/common';
import { APP_GUARD, APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheModule } from '@nestjs/cache-manager';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { getRedisConfig } from './config/redis.config';
import { SentryModule, SentryGlobalFilter } from '@sentry/nestjs/setup';
import { AuthModule } from './modules/auth/auth.module';
import { HealthModule } from './modules/health/health.module';
Expand Down Expand Up @@ -36,6 +38,12 @@ import { AuditInterceptor } from './common/interceptors/audit.interceptor';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: getRedisConfig,
}),
SentryModule.forRoot(),
ScheduleModule.forRoot(),
LoggerModule,
Expand Down
149 changes: 123 additions & 26 deletions src/auth/guards/api-key.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import {
ExecutionContext,
UnauthorizedException,
ForbiddenException,
HttpException,
HttpStatus,
Inject,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { createHash } from 'crypto';
import { SupabaseService } from '../../database/supabase.client';
import { API_KEY_PERMISSIONS_KEY } from './api-key-permissions.decorator';
Expand All @@ -25,9 +30,36 @@ interface ApiKeyRecord {
updated_at: string;
}

/**
* Cache and rate-limit constants.
*
* - API_KEY_CACHE_TTL: short TTL for key records (≀1 DB lookup per TTL per key in steady state).
* - API_KEY_LAST_USED_TTL: collapse last_used_at writes to at-most-once-per-N-minutes-per-key.
* - API_KEY_RATE_LIMIT_* : per-key sliding-window (counts live in cache-manager, not DB).
*/
const API_KEY_CACHE_TTL_SECONDS = 60;
const API_KEY_LAST_USED_TTL_SECONDS = 300;
const API_KEY_RATE_LIMIT_WINDOW_SECONDS = 60;
const API_KEY_RATE_LIMIT_MAX_REQUESTS = 60;

function getRecordCacheKey(keyHash: string): string {
return `apikey:record:${keyHash}`;
}

function getLastUsedCacheKey(keyId: string): string {
return `apikey:last_used:${keyId}`;
}

function getRateLimitCacheKey(keyId: string): string {
return `apikey:rate:${keyId}`;
}

@Injectable()
export class ApiKeyGuard implements CanActivate {
private readonly logger = new Logger(ApiKeyGuard.name);

constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly supabaseService: SupabaseService,
private readonly reflector: Reflector,
) {}
Expand All @@ -41,44 +73,75 @@ export class ApiKeyGuard implements CanActivate {
const apiKeyHeader = request.headers['x-api-key'];

if (!apiKeyHeader || typeof apiKeyHeader !== 'string') {
this.logger.warn('API key missing or malformed header');
throw new UnauthorizedException({
code: 'API_KEY_MISSING',
message: 'X-API-Key header is required.',
code: 'API_KEY_UNAUTHORIZED',
message: 'Invalid API key.',
});
}

const keyHash = createHash('sha256').update(apiKeyHeader).digest('hex');
const recordCacheKey = getRecordCacheKey(keyHash);

const client = this.supabaseService.getServiceRoleClient();
const { data, error } = await client
.from('api_keys')
.select('*')
.eq('key_hash', keyHash)
.single();
let keyRecord: ApiKeyRecord | undefined;

if (error || !data) {
throw new UnauthorizedException({
code: 'API_KEY_INVALID',
message: 'Invalid API key.',
});
try {
keyRecord = await this.cacheManager.get<ApiKeyRecord>(recordCacheKey);
} catch (error) {
this.logger.warn(`API key cache read failed for ${keyHash.slice(0, 8)}...: ${(error as Error).message}`);
}

const keyRecord = data as unknown as ApiKeyRecord;
if (!keyRecord) {
const client = this.supabaseService.getServiceRoleClient();
const { data, error } = await client.from('api_keys').select('*').eq('key_hash', keyHash).single();

if (error || !data) {
this.logger.warn(
`API key lookup failed for hash ${keyHash.slice(0, 8)}...: ${error?.message ?? 'not found'}`,
);
throw new UnauthorizedException({
code: 'API_KEY_UNAUTHORIZED',
message: 'Invalid API key.',
});
}

keyRecord = data as unknown as ApiKeyRecord;
}

// Unified validation: is_active and expires_at both map to the same
// API_KEY_UNAUTHORIZED response to prevent enumeration of revoked vs
// expired vs nonexistent keys. Details are logged server-side only.
if (!keyRecord.is_active) {
this.logger.warn(`API key inactive: ${keyRecord.id} (hash ${keyHash.slice(0, 8)}...)`);
throw new UnauthorizedException({
code: 'API_KEY_INACTIVE',
message: 'API key has been revoked.',
code: 'API_KEY_UNAUTHORIZED',
message: 'Invalid API key.',
});
}

if (keyRecord.expires_at && new Date(keyRecord.expires_at) < new Date()) {
this.logger.warn(`API key expired: ${keyRecord.id} (hash ${keyHash.slice(0, 8)}...)`);
throw new UnauthorizedException({
code: 'API_KEY_EXPIRED',
message: 'API key has expired.',
code: 'API_KEY_UNAUTHORIZED',
message: 'Invalid API key.',
});
}

// Cache the validated record for steady-state traffic (≀1 lookup per TTL per key).
// Only cache after successful validation so inactive/expired records are not
// served from cache; revocation explicitly invalidates via VendorsService.
try {
const cached = await this.cacheManager.get<ApiKeyRecord>(recordCacheKey);
if (!cached) {
await this.cacheManager.set(recordCacheKey, keyRecord, API_KEY_CACHE_TTL_SECONDS);
}
} catch (error) {
this.logger.warn(`API key cache write failed for ${keyRecord.id}: ${(error as Error).message}`);
}

// Per-key sliding-window rate limit (cache-backed, not DB).
await this.enforceRateLimit(keyRecord.id, keyHash);

const requiredPermissions = this.reflector.get<string[]>(
API_KEY_PERMISSIONS_KEY,
context.getHandler(),
Expand All @@ -95,21 +158,55 @@ export class ApiKeyGuard implements CanActivate {
}
}

this.updateLastUsed(keyRecord.id);
// Throttled last_used_at: at-most-once-per-N-minutes-per-key.
// Fire-and-forget is intentionally not awaited to avoid adding latency to
// the hot path; errors are logged.
void this.maybeUpdateLastUsed(keyRecord.id);

request.apiKey = keyRecord;
return true;
}

private async updateLastUsed(keyId: string): Promise<void> {
private async enforceRateLimit(keyId: string, keyHash: string): Promise<void> {
const rateKey = getRateLimitCacheKey(keyId);
try {
const current = (await this.cacheManager.get<number>(rateKey)) ?? 0;
if (current >= API_KEY_RATE_LIMIT_MAX_REQUESTS) {
this.logger.warn(
`API key rate limited: ${keyId} (hash ${keyHash.slice(0, 8)}...) β€” ${current}/${API_KEY_RATE_LIMIT_MAX_REQUESTS} per ${API_KEY_RATE_LIMIT_WINDOW_SECONDS}s`,
);
throw new HttpException(
{
code: 'API_KEY_RATE_LIMITED',
message: 'Too many requests for this API key. Please retry after a short delay.',
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
const next = current + 1;
// Sliding window: each increment resets TTL to full window. For a fixed
// window we would preserve the original TTL, but sliding is simpler and
// matches the per-key burst protection needed here.
await this.cacheManager.set(rateKey, next, API_KEY_RATE_LIMIT_WINDOW_SECONDS);
} catch (error) {
if (error instanceof HttpException) throw error;
// Cache failures should not block legitimate traffic; log and allow.
this.logger.warn(`API key rate-limit cache error for ${keyId}: ${(error as Error).message}`);
}
}

private async maybeUpdateLastUsed(keyId: string): Promise<void> {
const lastUsedKey = getLastUsedCacheKey(keyId);
try {
const flagged = await this.cacheManager.get<boolean>(lastUsedKey);
if (flagged) {
return;
}
const client = this.supabaseService.getServiceRoleClient();
await client
.from('api_keys')
.update({ last_used_at: new Date().toISOString() })
.eq('id', keyId);
} catch {
// Fire-and-forget β€” failure to update last_used_at should not block the request
await client.from('api_keys').update({ last_used_at: new Date().toISOString() }).eq('id', keyId);
await this.cacheManager.set(lastUsedKey, true, API_KEY_LAST_USED_TTL_SECONDS);
} catch (error) {
this.logger.warn(`Failed to update last_used_at for ${keyId}: ${(error as Error).message}`);
}
}
}
31 changes: 31 additions & 0 deletions src/modules/auth/auth-throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<string> {
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);
}
}
26 changes: 15 additions & 11 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<AuthResponseDto> {
await this.authService.verifySignature(dto);
return this.authService.generateTokens(dto.wallet);
Expand Down
Loading
Loading