diff --git a/app/backend/src/config/app-config.service.ts b/app/backend/src/config/app-config.service.ts index adf08875..c67fefe5 100644 --- a/app/backend/src/config/app-config.service.ts +++ b/app/backend/src/config/app-config.service.ts @@ -152,10 +152,24 @@ export class AppConfigService { return this.configService.get('STELLAR_PUBLIC_KEY', { infer: true }); } - /** - * Check if payment signing is configured (has secret key) - */ +/** + * Check if payment signing is configured (has secret key) + */ get isPaymentSigningConfigured(): boolean { return !!this.stellarSecretKey; } + + /** + * Check if signed payload verification is enabled + */ + get signedPayloadEnabled(): boolean { + return this.configService.get('SIGNED_PAYLOAD_ENABLED', { infer: true }); + } + + /** + * Get signed payload replay window in milliseconds + */ + get signedPayloadReplayWindowMs(): number { + return this.configService.get('SIGNED_PAYLOAD_REPLAY_WINDOW_MS', { infer: true }); + } } diff --git a/app/backend/src/config/env.schema.ts b/app/backend/src/config/env.schema.ts index bc173c0d..69cf43c6 100644 --- a/app/backend/src/config/env.schema.ts +++ b/app/backend/src/config/env.schema.ts @@ -243,6 +243,20 @@ export const envSchema = Joi.object({ .optional() .default(1.0) .description("Sentry profiling sample rate (0.0 to 1.0). Default: 1.0"), + + // --------------------------------------------------------------------------- + // Signed Payload Verification (optional; omit to disable) + // --------------------------------------------------------------------------- + + SIGNED_PAYLOAD_ENABLED: Joi.boolean() + .default(true) + .description('Enable signed payload verification for sensitive endpoints'), + + SIGNED_PAYLOAD_REPLAY_WINDOW_MS: Joi.number() + .integer() + .min(1000) + .default(5 * 60 * 1000) + .description('Replay protection window in milliseconds'), }); /** @@ -287,4 +301,6 @@ export interface EnvConfig { SENTRY_RELEASE?: string; SENTRY_TRACES_SAMPLE_RATE: number; SENTRY_PROFILES_SAMPLE_RATE: number; + SIGNED_PAYLOAD_ENABLED: boolean; + SIGNED_PAYLOAD_REPLAY_WINDOW_MS: number; } diff --git a/app/backend/src/marketplace/marketplace.controller.ts b/app/backend/src/marketplace/marketplace.controller.ts index 7d0098cc..d270c7e7 100644 --- a/app/backend/src/marketplace/marketplace.controller.ts +++ b/app/backend/src/marketplace/marketplace.controller.ts @@ -10,6 +10,7 @@ import { BadRequestException, ForbiddenException, ConflictException, + UseGuards, } from '@nestjs/common'; import { ApiBody, @@ -23,9 +24,13 @@ import { import { MarketplaceService } from './marketplace.service'; import { ListUsernameDto, PlaceBidDto, AcceptBidDto, CancelListingDto } from './dto'; import { MarketplaceError, MarketplaceErrorCode } from './errors'; +import { SignedPayload } from '../signed-payload/decorators/require-signed-payload.decorator'; +import { SignedPayloadGuard } from '../signed-payload/guards/signed-payload.guard'; @ApiTags('marketplace') @Controller('marketplace') +@UseGuards(SignedPayloadGuard) +@SignedPayload(true) export class MarketplaceController { constructor(private readonly marketplaceService: MarketplaceService) {} diff --git a/app/backend/src/refunds/refunds.controller.ts b/app/backend/src/refunds/refunds.controller.ts index bef622e0..3f51f454 100644 --- a/app/backend/src/refunds/refunds.controller.ts +++ b/app/backend/src/refunds/refunds.controller.ts @@ -22,6 +22,8 @@ import { RefundsService } from './refunds.service'; import { InitiateRefundDto } from './dto/initiate-refund.dto'; import { ApiKeyGuard } from '../auth/guards/api-key.guard'; import { RequireScopes } from '../auth/decorators/require-scopes.decorator'; +import { SignedPayload } from '../signed-payload/decorators/require-signed-payload.decorator'; +import { SignedPayloadGuard } from '../signed-payload/guards/signed-payload.guard'; interface ApiKeyRequest extends Request { apiKey: { id: string }; @@ -33,8 +35,9 @@ interface ApiKeyRequest extends Request { description: 'Admin API key with refunds:write scope', required: true, }) -@UseGuards(ApiKeyGuard) +@UseGuards(ApiKeyGuard, SignedPayloadGuard) @RequireScopes('refunds:write') +@SignedPayload(true) @Controller('admin/refunds') export class RefundsController { constructor(private readonly refundsService: RefundsService) {} diff --git a/app/backend/src/signed-payload/decorators/require-signed-payload.decorator.ts b/app/backend/src/signed-payload/decorators/require-signed-payload.decorator.ts new file mode 100644 index 00000000..dabf3b37 --- /dev/null +++ b/app/backend/src/signed-payload/decorators/require-signed-payload.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SIGNED_PAYLOAD_KEY = 'signedPayloadRequired'; + +export const SignedPayload = (required: boolean = true) => + SetMetadata(SIGNED_PAYLOAD_KEY, required); + +export const requireSignedPayload = (required: boolean) => SetMetadata(SIGNED_PAYLOAD_KEY, required); \ No newline at end of file diff --git a/app/backend/src/signed-payload/guards/signed-payload.guard.ts b/app/backend/src/signed-payload/guards/signed-payload.guard.ts new file mode 100644 index 00000000..71f74a66 --- /dev/null +++ b/app/backend/src/signed-payload/guards/signed-payload.guard.ts @@ -0,0 +1,94 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + BadRequestException, + Logger, +} from '@nestjs/common'; + +import { + SIGNED_PAYLOAD_HEADER, + SIGNED_PAYLOAD_ERROR_CODES, + SignedPayloadData, +} from '../signed-payload.types'; +import { SignedPayloadService } from '../signed-payload.service'; +import { ReplayProtectionService } from '../replay-protection.service'; +import { SignedPayload } from '../decorators/require-signed-payload.decorator'; + +@Injectable() +export class SignedPayloadGuard implements CanActivate { + private readonly logger = new Logger(SignedPayloadGuard.name); + + constructor( + private readonly signedPayloadService: SignedPayloadService, + private readonly replayProtection: ReplayProtectionService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const requiresSignedPayload = this.getMetadata(context); + + if (!requiresSignedPayload) { + return true; + } + + const signedPayloadHeader = request.headers[SIGNED_PAYLOAD_HEADER]; + if (!signedPayloadHeader) { + throw new UnauthorizedException({ + code: SIGNED_PAYLOAD_ERROR_CODES.MISSING_SIGNED_PAYLOAD, + message: 'Signed payload header required for this endpoint', + }); + } + + let parsed: SignedPayloadData; + try { + parsed = JSON.parse(signedPayloadHeader); + } catch { + throw new BadRequestException({ + code: SIGNED_PAYLOAD_ERROR_CODES.MISSING_SIGNED_PAYLOAD, + message: 'Invalid signed payload header format', + }); + } + + const { timestamp, method, path, signature } = parsed; + const body = request.body ? JSON.stringify(request.body) : ''; + + if (!timestamp || !method || !path || !signature) { + throw new BadRequestException({ + code: SIGNED_PAYLOAD_ERROR_CODES.MISSING_SIGNED_PAYLOAD, + message: 'Missing required signed payload fields', + }); + } + + const data: SignedPayloadData = { + timestamp, + method, + path, + body, + signature, + }; + + const signerPublicKey = request.headers['x-signer-public-key'] as string; + if (!signerPublicKey) { + throw new UnauthorizedException({ + code: SIGNED_PAYLOAD_ERROR_CODES.MISSING_SIGNED_PAYLOAD, + message: 'Signer public key header required (x-signer-public-key)', + }); + } + + await this.signedPayloadService.verify(data, signerPublicKey); + + return true; + } + + private getMetadata(context: ExecutionContext): boolean { + const handler = context.getHandler(); + const controller = context.getClass(); + + const handlerMeta = Reflect.getMetadata('signedPayloadRequired', handler); + const controllerMeta = Reflect.getMetadata('signedPayloadRequired', controller); + + return handlerMeta ?? controllerMeta ?? false; + } +} \ No newline at end of file diff --git a/app/backend/src/signed-payload/index.ts b/app/backend/src/signed-payload/index.ts new file mode 100644 index 00000000..f0ce5d9e --- /dev/null +++ b/app/backend/src/signed-payload/index.ts @@ -0,0 +1,8 @@ +export * from './signed-payload.types'; +export * from './signed-payload.dto'; +export * from './signed-payload.service'; +export * from './replay-protection.service'; +export * from './signed-payload.module'; +export * from './guards/signed-payload.guard'; +export * from './decorators/require-signed-payload.decorator'; +export { SIGNED_PAYLOAD_HEADER } from './signed-payload.types'; \ No newline at end of file diff --git a/app/backend/src/signed-payload/replay-protection.service.ts b/app/backend/src/signed-payload/replay-protection.service.ts new file mode 100644 index 00000000..e3c24e51 --- /dev/null +++ b/app/backend/src/signed-payload/replay-protection.service.ts @@ -0,0 +1,24 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class ReplayProtectionService { + private readonly logger = new Logger(ReplayProtectionService.name); + private readonly usedSignatures = new Set(); + private readonly maxEntries = 10000; + + addSignature(signature: string): void { + if (this.usedSignatures.size >= this.maxEntries) { + this.usedSignatures.clear(); + this.logger.warn('Replay cache cleared due to size limit'); + } + this.usedSignatures.add(signature); + } + + isReplay(signature: string): boolean { + return this.usedSignatures.has(signature); + } + + clearExpired(oldestAllowedMs: number): void { + this.logger.debug(`Replay protection cache size before: ${this.usedSignatures.size}`); + } +} \ No newline at end of file diff --git a/app/backend/src/signed-payload/signed-payload.dto.ts b/app/backend/src/signed-payload/signed-payload.dto.ts new file mode 100644 index 00000000..a1e1640f --- /dev/null +++ b/app/backend/src/signed-payload/signed-payload.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator'; + +export const SIGNED_PAYLOAD_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; + +export class SignedPayloadDto { + @ApiProperty({ + description: 'Unix timestamp in milliseconds', + example: 1714324800000, + }) + @IsNumber() + @Min(0) + timestamp!: number; + + @ApiProperty({ + description: 'HTTP method', + example: 'POST', + }) + @IsString() + @IsNotEmpty() + method!: string; + + @ApiProperty({ + description: 'Full request path including query params', + example: '/marketplace/listing123/bid', + }) + @IsString() + @IsNotEmpty() + path!: string; + + @ApiProperty({ + description: 'JSON stringified request body', + example: '{"listingId":"listing123","bidAmount":100}', + }) + @IsString() + @IsNotEmpty() + body!: string; + + @ApiProperty({ + description: 'Stellar address signature (base64)', + example: 'abcd1234...', + }) + @IsString() + @IsNotEmpty() + signature!: string; +} \ No newline at end of file diff --git a/app/backend/src/signed-payload/signed-payload.module.ts b/app/backend/src/signed-payload/signed-payload.module.ts new file mode 100644 index 00000000..1c4209ec --- /dev/null +++ b/app/backend/src/signed-payload/signed-payload.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +import { SignedPayloadService } from './signed-payload.service'; +import { ReplayProtectionService } from './replay-protection.service'; +import { SignedPayloadGuard } from './guards/signed-payload.guard'; + +@Module({ + providers: [ + SignedPayloadService, + ReplayProtectionService, + SignedPayloadGuard, + ], + exports: [ + SignedPayloadService, + ReplayProtectionService, + SignedPayloadGuard, + ], +}) +export class SignedPayloadModule {} \ No newline at end of file diff --git a/app/backend/src/signed-payload/signed-payload.service.ts b/app/backend/src/signed-payload/signed-payload.service.ts new file mode 100644 index 00000000..f97d33e6 --- /dev/null +++ b/app/backend/src/signed-payload/signed-payload.service.ts @@ -0,0 +1,111 @@ +import { + Injectable, + UnauthorizedException, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { verify, Keypair } from '@stellar/stellar-sdk'; + +import { + SIGNED_PAYLOAD_ERROR_CODES, + SignedPayloadErrorCode, + SignedPayloadData, + SignedPayloadConfig, + DEFAULT_SIGNED_PAYLOAD_CONFIG, +} from './signed-payload.types'; +import { ReplayProtectionService } from './replay-protection.service'; + +@Injectable() +export class SignedPayloadService { + private readonly logger = new Logger(SignedPayloadService.name); + private readonly config: SignedPayloadConfig; + + constructor( + private readonly replayProtection: ReplayProtectionService, + private readonly configService: ConfigService, + ) { + const enabled = this.configService.get('SIGNED_PAYLOAD_ENABLED'); + const replayWindowMs = this.configService.get('SIGNED_PAYLOAD_REPLAY_WINDOW_MS'); + + this.config = { + enabled: enabled ?? DEFAULT_SIGNED_PAYLOAD_CONFIG.enabled, + replayWindowMs: replayWindowMs ?? DEFAULT_SIGNED_PAYLOAD_CONFIG.replayWindowMs, + }; + } + + async verify( + data: SignedPayloadData, + signerPublicKey: string, + ): Promise { + if (!this.config.enabled) { + this.logger.debug('Signed payload verification is disabled'); + return; + } + + const now = Date.now(); + const timeDiff = Math.abs(now - data.timestamp); + + if (timeDiff > this.config.replayWindowMs) { + throw new UnauthorizedException({ + code: SIGNED_PAYLOAD_ERROR_CODES.INVALID_TIMESTAMP, + message: `Timestamp outside acceptable window (${this.config.replayWindowMs}ms)`, + }); + } + + const payload = `${data.timestamp}:${data.method}:${data.path}:${data.body}`; + const signatureBuffer = Buffer.from(data.signature, 'base64'); + + try { + const isValid = verify( + signerPublicKey, + payload, + signatureBuffer, + ); + + if (!isValid) { + throw new UnauthorizedException({ + code: SIGNED_PAYLOAD_ERROR_CODES.INVALID_SIGNATURE, + message: 'Signature verification failed', + }); + } + + if (this.replayProtection.isReplay(data.signature)) { + throw new UnauthorizedException({ + code: SIGNED_PAYLOAD_ERROR_CODES.REPLAY_DETECTED, + message: 'Signature has already been used', + }); + } + + this.replayProtection.addSignature(data.signature); + } catch (err) { + if ( + err instanceof UnauthorizedException && + err.getResponse() && + typeof err.getResponse() === 'object' && + 'code' in err.getResponse() + ) { + throw err; + } + + this.logger.error(`Signature verification error: ${err}`); + throw new UnauthorizedException({ + code: SIGNED_PAYLOAD_ERROR_CODES.INVALID_SIGNATURE, + message: 'Signature verification failed', + }); + } + } + + buildPayload( + timestamp: number, + method: string, + path: string, + body: string, + ): string { + return `${timestamp}:${method}:${path}:${body}`; + } + + sign(payload: string, keypair: Keypair): string { + return keypair.sign(Buffer.from(payload)).toString('base64'); + } +} \ No newline at end of file diff --git a/app/backend/src/signed-payload/signed-payload.service.unit.spec.ts b/app/backend/src/signed-payload/signed-payload.service.unit.spec.ts new file mode 100644 index 00000000..f50d5b5d --- /dev/null +++ b/app/backend/src/signed-payload/signed-payload.service.unit.spec.ts @@ -0,0 +1,142 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import { Keypair } from '@stellar/stellar-sdk'; + +import { SignedPayloadService } from './signed-payload.service'; +import { ReplayProtectionService } from './replay-protection.service'; +import { SIGNED_PAYLOAD_ERROR_CODES } from './signed-payload.types'; + +describe('SignedPayloadService', () => { + let service: SignedPayloadService; + let replayProtection: ReplayProtectionService; + + const testKeypair = Keypair.fromSecret( + 'SCZANGBA5YHTNYVVCR4EBHFIYSVJ5TAEJKGKIGXRJBBAY3DZDSZ4BMTG6XY', + ); + const testPublicKey = testKeypair.publicKey(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SignedPayloadService, + ReplayProtectionService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'SIGNED_PAYLOAD_ENABLED') return true; + if (key === 'SIGNED_PAYLOAD_REPLAY_WINDOW_MS') return 300000; + return undefined; + }), + }, + }, + ], + }).compile(); + + service = module.get(SignedPayloadService); + replayProtection = module.get(ReplayProtectionService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('verify', () => { + it('should reject timestamp outside window', async () => { + const oldTimestamp = Date.now() - 10 * 60 * 1000; + const payload = service.buildPayload(oldTimestamp, 'POST', '/test', '{"foo":"bar"}'); + const signature = service.sign(payload, testKeypair); + + const data = { + timestamp: oldTimestamp, + method: 'POST', + path: '/test', + body: '{"foo":"bar"}', + signature, + }; + + await expect(service.verify(data, testPublicKey)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should reject invalid signature', async () => { + const timestamp = Date.now(); + const payload = service.buildPayload(timestamp, 'POST', '/test', '{"foo":"bar"}'); + const invalidSignature = 'INVALID_SIGNATURE_BASE64=='; + + const data = { + timestamp, + method: 'POST', + path: '/test', + body: '{"foo":"bar"}', + signature: invalidSignature, + }; + + await expect(service.verify(data, testPublicKey)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should accept valid signature within window', async () => { + const timestamp = Date.now(); + const payload = service.buildPayload(timestamp, 'POST', '/test', '{"foo":"bar"}'); + const signature = service.sign(payload, testKeypair); + + const data = { + timestamp, + method: 'POST', + path: '/test', + body: '{"foo":"bar"}', + signature, + }; + + await expect(service.verify(data, testPublicKey)).resolves.not.toThrow(); + }); + }); + + describe('buildPayload', () => { + it('should build correct payload string', () => { + const result = service.buildPayload(12345, 'POST', '/test', '{"foo":"bar"}'); + expect(result).toBe('12345:POST:/test:{"foo":"bar"}'); + }); + }); + + describe('sign', () => { + it('should produce base64 signature', () => { + const payload = '12345:POST:/test:{"foo":"bar"}'; + const signature = service.sign(payload, testKeypair); + expect(typeof signature).toBe('string'); + expect(signature.length).toBeGreaterThan(0); + }); + }); +}); + +describe('ReplayProtectionService', () => { + let service: ReplayProtectionService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ReplayProtectionService], + }).compile(); + + service = module.get(ReplayProtectionService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('addSignature / isReplay', () => { + it('should detect replayed signature', () => { + const sig = 'test-signature'; + service.addSignature(sig); + expect(service.isReplay(sig)).toBe(true); + }); + + it('should return false for new signature', () => { + expect(service.isReplay('new-signature')).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/app/backend/src/signed-payload/signed-payload.types.ts b/app/backend/src/signed-payload/signed-payload.types.ts new file mode 100644 index 00000000..2e079c7d --- /dev/null +++ b/app/backend/src/signed-payload/signed-payload.types.ts @@ -0,0 +1,30 @@ +export const SIGNED_PAYLOAD_HEADER = 'x-signed-payload'; + +export const SIGNED_PAYLOAD_ERROR_CODES = { + MISSING_SIGNED_PAYLOAD: 'MISSING_SIGNED_PAYLOAD', + INVALID_TIMESTAMP: 'INVALID_TIMESTAMP', + INVALID_SIGNATURE: 'INVALID_SIGNATURE', + REPLAY_DETECTED: 'REPLAY_DETECTED', + BODY_TAMPERED: 'BODY_TAMPERED', +} as const; + +export type SignedPayloadErrorCode = + (typeof SIGNED_PAYLOAD_ERROR_CODES)[keyof typeof SIGNED_PAYLOAD_ERROR_CODES]; + +export interface SignedPayloadData { + timestamp: number; + method: string; + path: string; + body: string; + signature: string; +} + +export interface SignedPayloadConfig { + enabled: boolean; + replayWindowMs: number; +} + +export const DEFAULT_SIGNED_PAYLOAD_CONFIG: SignedPayloadConfig = { + enabled: true, + replayWindowMs: 5 * 60 * 1000, +}; \ No newline at end of file