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
20 changes: 17 additions & 3 deletions app/backend/src/config/app-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
16 changes: 16 additions & 0 deletions app/backend/src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});

/**
Expand Down Expand Up @@ -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;
}
5 changes: 5 additions & 0 deletions app/backend/src/marketplace/marketplace.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
BadRequestException,
ForbiddenException,
ConflictException,
UseGuards,
} from '@nestjs/common';
import {
ApiBody,
Expand All @@ -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) {}

Expand Down
5 changes: 4 additions & 1 deletion app/backend/src/refunds/refunds.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
94 changes: 94 additions & 0 deletions app/backend/src/signed-payload/guards/signed-payload.guard.ts
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 17 in app/backend/src/signed-payload/guards/signed-payload.guard.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'SignedPayload' is defined but never used

@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<boolean> {
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;
}
}
8 changes: 8 additions & 0 deletions app/backend/src/signed-payload/index.ts
Original file line number Diff line number Diff line change
@@ -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';
24 changes: 24 additions & 0 deletions app/backend/src/signed-payload/replay-protection.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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 {

Check failure on line 21 in app/backend/src/signed-payload/replay-protection.service.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'oldestAllowedMs' is defined but never used
this.logger.debug(`Replay protection cache size before: ${this.usedSignatures.size}`);
}
}
46 changes: 46 additions & 0 deletions app/backend/src/signed-payload/signed-payload.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator';

Check failure on line 2 in app/backend/src/signed-payload/signed-payload.dto.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'Max' is defined but never used

Check failure on line 2 in app/backend/src/signed-payload/signed-payload.dto.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'IsDateString' is defined but never used

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;
}
19 changes: 19 additions & 0 deletions app/backend/src/signed-payload/signed-payload.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading