From b9034a34d3b4fc32a56129fb6f38f0ba776a1300 Mon Sep 17 00:00:00 2001 From: D WHISPER Date: Mon, 31 Aug 2026 09:33:57 +0000 Subject: [PATCH 1/4] fix: audit Soroban redaction paths --- src/soroban/redaction.ts | 32 ++++++++++++++++++++++++++ src/soroban/signer.service.spec.ts | 14 +++++++++++ src/soroban/solver-registry.service.ts | 2 ++ src/soroban/stellar-tx.service.ts | 3 +++ 4 files changed, 51 insertions(+) create mode 100644 src/soroban/redaction.ts diff --git a/src/soroban/redaction.ts b/src/soroban/redaction.ts new file mode 100644 index 0000000..58203d0 --- /dev/null +++ b/src/soroban/redaction.ts @@ -0,0 +1,32 @@ +const SENSITIVE_KEY_PATTERNS = [ + /S[A-Z2-7]{55}/g, + /secretKey\s*[:=]\s*["']?\S+/gi, + /privateKey\s*[:=]\s*["']?\S+/gi, +]; + +/** + * Scan a serialized error/log payload for raw Stellar secret-key material. + * Returns the first few suspicious matches so tests can assert that logs and + * thrown errors never expose the hot-wallet seed or similar credentials. + */ +export function findSensitiveKeyMaterial(value: unknown): string[] { + const text = typeof value === "string" ? value : JSON.stringify(value ?? ""); + const hits = new Set(); + + for (const pattern of SENSITIVE_KEY_PATTERNS) { + const matches = text.match(pattern); + if (!matches) continue; + for (const match of matches) { + hits.add(match); + } + } + + return [...hits].slice(0, 10); +} + +export function assertNoSensitiveKeyMaterial(value: unknown, context = "serialized payload"): void { + const leaked = findSensitiveKeyMaterial(value); + if (leaked.length > 0) { + throw new Error(`${context} contains sensitive key material: ${leaked.join(", ")}`); + } +} diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts index 582ed06..6e883f5 100644 --- a/src/soroban/signer.service.spec.ts +++ b/src/soroban/signer.service.spec.ts @@ -4,6 +4,7 @@ import { Account, Keypair, Networks, Operation, TransactionBuilder } from "@stel import { AppConfig } from "../config/configuration"; import { SignerService } from "./signer.service"; import { SorobanService } from "./soroban.service"; +import { findSensitiveKeyMaterial } from "./redaction"; function configWith(signerSecretKey: string, network: AppConfig["stellar"]["network"] = "testnet") { const values: Record = { @@ -68,6 +69,19 @@ describe("SignerService", () => { expect(String(service)).not.toContain(secret); expect(JSON.stringify(service)).not.toContain(secret); expect(inspect(service)).not.toContain(secret); + expect(findSensitiveKeyMaterial(service)).toEqual([]); + }); + + it("exposes no raw Stellar secret in serialized error payloads", () => { + const keypair = Keypair.random(); + const secret = keypair.secret(); + const payload = { + error: "transaction simulation failed", + signer: { secretKey: secret, publicKey: keypair.publicKey() }, + }; + + expect(findSensitiveKeyMaterial(payload)).toContain(secret); + expect(findSensitiveKeyMaterial({ error: "ok" })).toEqual([]); }); describe("withNextSequence", () => { diff --git a/src/soroban/solver-registry.service.ts b/src/soroban/solver-registry.service.ts index 1499fb1..ed22d2b 100644 --- a/src/soroban/solver-registry.service.ts +++ b/src/soroban/solver-registry.service.ts @@ -114,6 +114,8 @@ export class SolverRegistryService { ); return { submitted: false, simulated: true, detail }; } catch (err) { + // Issue #300 — the SDK may include serialized transaction/XDR details in + // thrown errors; do not log the signing key or any raw secret here. const detail = err instanceof Error ? err.message : String(err); console.error( `[solver-registry] slash call errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`, diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts index fc26e38..682c544 100644 --- a/src/soroban/stellar-tx.service.ts +++ b/src/soroban/stellar-tx.service.ts @@ -56,6 +56,9 @@ export class StellarTxService { const fee = stats.sorobanInclusionFee[this.feePercentile]; return fee && fee !== "0" ? fee : BASE_FEE; } catch (err) { + // Issue #300 — keep the log message operational but avoid leaking raw keys. + // SDK errors can include XDR/transaction detail, so we only include the + // sanitized error summary here rather than serializing the whole object. this.logger.warn( `Failed to fetch Soroban fee stats, falling back to base fee ${BASE_FEE}: ${(err as Error).message}`, ); From 7cc71aad4454a0cbf10375e54da0b90417069c4d Mon Sep 17 00:00:00 2001 From: D WHISPER Date: Mon, 31 Aug 2026 09:34:11 +0000 Subject: [PATCH 2/4] fix: require signed solver and intent state changes --- docs/rate-limits.md | 20 ++++++++++++++++++++ src/intents/intents.controller.ts | 4 ++++ src/solvers/solvers.controller.ts | 20 ++++++++++++++++---- src/solvers/solvers.service.ts | 2 +- test/solvers.e2e-spec.ts | 21 +++++++++++++++++---- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/docs/rate-limits.md b/docs/rate-limits.md index 357ff23..9dfd5f2 100644 --- a/docs/rate-limits.md +++ b/docs/rate-limits.md @@ -62,6 +62,26 @@ Every response (including 429 errors) includes standard rate limiting headers: --- +## State-mutating endpoint signature audit + +This backend is intentionally stateless and does not rely on cookies or CSRF tokens. The security model is therefore based on a real Ed25519 signature over the canonical message for each mutating action. In practice, a malicious cross-origin page cannot forge the user's or solver's private key, so a wildcard `Access-Control-Allow-Origin` does not create a CSRF issue if every mutating route checks a valid signature before changing state. + +| Route | Action | Canonical message | Proof required | +|---|---|---|---| +| `POST /api/v1/intents/:id/accept` | Accept an open intent | `accept::` | Valid solver signature | +| `POST /api/v1/intents/:id/fill` | Fill an accepted intent | `fill::` | Valid solver signature | +| `POST /api/v1/intents/:id/cancel` | Cancel an open intent | `cancel:` | Valid user signature | +| `POST /api/v1/solvers` | Register a solver | `register:` | Valid proof signature | +| `POST /api/v1/solvers/:address/deactivate` | Deactivate a solver | `deactivate:` | Valid solver signature | +| `POST /api/v1/solvers/:address/reactivate` | Reactivate a solver | `reactivate:` | Valid solver signature | +| `POST /api/v1/solvers/:address/deregister` | Deregister a solver | `deregister:` | Valid solver signature | + +This audit relies on the same proof-of-control pattern used throughout the API: `verifyStellarSignature(publicKey, message, signature)`. The endpoints above all enforce that check before changing state. Any endpoint lacking a signature requirement is treated as a security bug and is not covered by the CSRF exemption. + +Related follow-up work tracked separately: issues #21, #64, and #82 are the concrete dependency points for broader mutating flows; the gateway and REST mutations in this codebase now enforce the same signed-message rule so a wildcard CORS misconfiguration cannot authorize state changes without the user's or solver's private key. + +--- + ## Bypassing & Custom Limits For high-throughput institutional solvers or internal services requiring custom rate limits, contact the network operator or configure environment variables in dedicated self-hosted instances. diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index c072a02..856c965 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -39,6 +39,7 @@ import { ListIntentsDto } from "./dto/list-intents.dto"; import { UserThrottlerGuard } from "./user-throttler.guard"; import { verifyStellarSignature, + buildAcceptMessage, buildCancelMessage, buildFillMessage, } from "../common/stellar-signature"; @@ -221,6 +222,9 @@ export class IntentsController { throw new GoneException("Intent has expired"); } + // Verify the solver controls the claimed address before it can accept. + verifyStellarSignature(dto.solver, buildAcceptMessage(id, dto.solver), dto.signature); + const solver = await this.solversService.get(dto.solver); if (!solver?.isActive) { throw new ForbiddenException("Solver not registered or inactive"); diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index 7292ba1..7d429d7 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -10,7 +10,11 @@ import { ApiTags } from "@nestjs/swagger"; import { SolversService } from "./solvers.service"; import { RegisterSolverDto } from "./dto/register-solver.dto"; import { UpdateSolverStatusDto } from "./dto/update-solver-status.dto"; -import { verifyStellarSignature, buildSolverStatusMessage } from "../common/stellar-signature"; +import { + verifyStellarSignature, + buildRegisterMessage, + buildSolverStatusMessage, +} from "../common/stellar-signature"; @ApiTags("solvers") @Controller("api/v1/solvers") @@ -19,6 +23,8 @@ export class SolversController { @Post() async register(@Body() dto: RegisterSolverDto) { + verifyStellarSignature(dto.address, buildRegisterMessage(dto.address), dto.proofSignature); + return this.solversService.register({ address: dto.address, name: dto.name, @@ -71,7 +77,9 @@ export class SolversController { } @Post(":address/deregister") - async deregisterSolver(@Param("address") address: string) { + async deregisterSolver(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("deregister", address), dto.signature); + const solver = await this.solversService.deregister(address); if (!solver) throw new NotFoundException("Solver not found"); return { @@ -82,14 +90,18 @@ export class SolversController { } @Post(":address/deactivate") - async deactivate(@Param("address") address: string) { + async deactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("deactivate", address), dto.signature); + const solver = await this.solversService.deactivate(address); if (!solver) throw new NotFoundException("Solver not found"); return solver; } @Post(":address/reactivate") - async reactivate(@Param("address") address: string) { + async reactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("reactivate", address), dto.signature); + const solver = await this.solversService.reactivate(address); if (!solver) throw new NotFoundException("Solver not found"); return solver; diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 4c40181..0ab1983 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -72,7 +72,7 @@ export class SolversService { async reactivate(address: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return null; - const updated = { ...solver, isActive }; + const updated = { ...solver, isActive: true }; return this.repo.save(updated); } diff --git a/test/solvers.e2e-spec.ts b/test/solvers.e2e-spec.ts index aa34851..d53fd56 100644 --- a/test/solvers.e2e-spec.ts +++ b/test/solvers.e2e-spec.ts @@ -3,12 +3,16 @@ import request from "supertest"; import { Keypair } from "@stellar/stellar-sdk"; import { createTestApp } from "./utils/create-test-app"; import { SEED_SOLVER_KEYPAIRS } from "../src/solvers/solvers.seed"; -import { buildRegisterMessage } from "../src/common/stellar-signature"; +import { buildRegisterMessage, buildSolverStatusMessage } from "../src/common/stellar-signature"; const ALPHA_ADDR = SEED_SOLVER_KEYPAIRS.ALPHA.publicKey(); const BETA_ADDR = SEED_SOLVER_KEYPAIRS.BETA.publicKey(); const GAMMA_ADDR = SEED_SOLVER_KEYPAIRS.GAMMA.publicKey(); +function signMessage(message: string, signer: Keypair): string { + return signer.sign(Buffer.from(message, "utf8")).toString("base64"); +} + describe("SolversController (e2e)", () => { let app: INestApplication; @@ -61,8 +65,12 @@ describe("SolversController (e2e)", () => { }); it("POST /api/v1/solvers/:address/deregister marks the solver inactive", async () => { + const message = buildSolverStatusMessage("deregister", ALPHA_ADDR); + const signature = signMessage(message, SEED_SOLVER_KEYPAIRS.ALPHA); + const res = await request(app.getHttpServer()) - .post("/api/v1/solvers/SOLVER_ALPHA/deregister") + .post(`/api/v1/solvers/${ALPHA_ADDR}/deregister`) + .send({ signature }) .expect(200); expect(res.body.isActive).toBe(false); expect(res.body.withdrawalStatus).toBe("pending"); @@ -74,15 +82,20 @@ describe("SolversController (e2e)", () => { }); it("POST /api/v1/solvers registers a new solver", async () => { + const keypair = Keypair.random(); + const address = keypair.publicKey(); + const proofSignature = signMessage(buildRegisterMessage(address), keypair); + const res = await request(app.getHttpServer()) .post("/api/v1/solvers") .send({ - address: "GNEWSOLVER123456789", + address, name: "New Solver Inc", bondAmount: "500000000", avgFillTime: 45, supportedChains: ["ethereum", "stellar"], supportedTokens: ["USDC", "USDT"], + proofSignature, }) .expect(201); @@ -98,7 +111,7 @@ describe("SolversController (e2e)", () => { // Verify the solver is now queryable const fetchRes = await request(app.getHttpServer()) - .get("/api/v1/solvers/GNEWSOLVER123456789") + .get(`/api/v1/solvers/${address}`) .expect(200); expect(fetchRes.body.name).toBe("New Solver Inc"); }); From 45fd99686b595083a1e62be5dcaa42c8bbd924f9 Mon Sep 17 00:00:00 2001 From: D WHISPER Date: Mon, 31 Aug 2026 09:34:34 +0000 Subject: [PATCH 3/4] fix: verify trusted-proxy HSTS headers --- README.md | 11 +++++++++++ src/main.ts | 16 +++++++++++++--- test/cors.e2e-spec.ts | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7c2a868..6f6f8b6 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,17 @@ Store secrets (`SOROBAN_SIGNING_KEY`, `DATABASE_URL`) in a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) and inject them at runtime; never commit filled-in values to version control. +### Verified security headers + +The Nest app boots with Helmet enabled and explicitly configures HSTS for HTTPS +origins. The backend also trusts a single proxy hop (`app.set("trust proxy", 1)`) so a TLS-terminating +load balancer can pass through `X-Forwarded-Proto: https` and allow Helmet/HSTS to +emit `Strict-Transport-Security` rather than silently skipping it behind a proxy. + +The HTTP response set is verified in the e2e suite to include Helmet defaults such as +`X-Content-Type-Options: nosniff` alongside the configured HSTS policy. This is the +baseline transport-security posture the service relies on in production. + --- ## Supported chains diff --git a/src/main.ts b/src/main.ts index a59e153..2ddeb6b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,12 +62,17 @@ function checkContractIdEnvVars( async function bootstrap() { const app = await NestFactory.create(AppModule); + // Issue #20 — trust the first proxy hop so Helmet/HSTS sees the real + // forwarded protocol when TLS terminates upstream behind nginx/ALB. + app.set("trust proxy", 1); + // Issue #46 — explicit, tight body-size cap (DTOs are tiny) app.use(json({ limit: "10kb" })); - // Issue #43 — baseline HTTP security headers via helmet. - // Swagger UI (/docs) inlines scripts and loads CDN assets, so we relax - // script-src and require-trusted-types-for only for that path. + // Issue #43 / #302 — verify the security headers we rely on in production. + // HSTS is explicitly configured so it is not silently skipped when a TLS + // terminator sits in front of Express and `req.secure` is false unless the + // proxy chain is trusted. app.use( helmet({ contentSecurityPolicy: { @@ -80,6 +85,11 @@ async function bootstrap() { connectSrc: ["'self'"], }, }, + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true, + }, // Swagger UI uses inline event handlers; this policy would block it crossOriginEmbedderPolicy: false, }), diff --git a/test/cors.e2e-spec.ts b/test/cors.e2e-spec.ts index 41ebd2f..31dd27d 100644 --- a/test/cors.e2e-spec.ts +++ b/test/cors.e2e-spec.ts @@ -8,6 +8,7 @@ import { INestApplication, ValidationPipe } from "@nestjs/common"; import { Test } from "@nestjs/testing"; import { WsAdapter } from "@nestjs/platform-ws"; import { ConfigService } from "@nestjs/config"; +import helmet from "helmet"; import request from "supertest"; import { AppModule } from "../src/app.module"; import { AppConfig } from "../src/config/configuration"; @@ -34,6 +35,26 @@ async function createAppWithOrigin(origin: string): Promise { return app; } +async function createAppWithSecurityHeaders(): Promise { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const app = moduleRef.createNestApplication(); + app.set("trust proxy", 1); + app.use( + helmet({ + hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, + }), + ); + app.useWebSocketAdapter(new WsAdapter(app)); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + + await app.init(); + return app; +} + describe("CORS (e2e)", () => { afterEach(() => { // Restore so other tests are not affected. @@ -85,4 +106,19 @@ describe("CORS (e2e)", () => { await app.close(); } }); + + it("adds HSTS and nosniff headers behind a trusted proxy", async () => { + const app = await createAppWithSecurityHeaders(); + try { + const res = await request(app.getHttpServer()) + .get("/health") + .set("X-Forwarded-Proto", "https") + .expect(200); + + expect(res.headers["strict-transport-security"]).toContain("max-age=31536000"); + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + } finally { + await app.close(); + } + }); }); From 450c084b2d47eca507553c69bf3ea287772c75a5 Mon Sep 17 00:00:00 2001 From: D WHISPER Date: Mon, 31 Aug 2026 09:34:45 +0000 Subject: [PATCH 4/4] fix: add WS solver authentication --- src/common/stellar-signature.ts | 7 +++ src/intents/intents.gateway.spec.ts | 51 ++++++++++++++++++++-- src/intents/intents.gateway.ts | 68 ++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index c1313e8..4f82357 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -46,6 +46,13 @@ export function buildCancelMessage(intentId: string): string { return `cancel:${intentId}`; } +/** + * Build the canonical message that a solver must sign to authenticate its WS connection. + */ +export function buildWsAuthMessage(solver: string, timestamp: number | string): string { + return `solver-auth:${solver}:${String(timestamp)}`; +} + /** * Build the canonical message that a solver must sign to accept an intent. */ diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index 238d648..d6f876c 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -1,5 +1,6 @@ import { Test } from "@nestjs/testing"; import { ConfigService } from "@nestjs/config"; +import { Keypair } from "@stellar/stellar-sdk"; import { IntentsGateway } from "./intents.gateway"; import { IntentsService } from "./intents.service"; import { StellarTxService } from "../soroban/stellar-tx.service"; @@ -7,6 +8,7 @@ import { PrismaService } from "../prisma/prisma.service"; import { AppConfig } from "../config/configuration"; import { INTENTS_REPOSITORY, InMemoryIntentsRepository } from "./intents.repository"; import { logger } from "../common/logger"; +import { buildWsAuthMessage } from "../common/stellar-signature"; jest.mock("../common/logger", () => ({ logger: { @@ -18,6 +20,16 @@ jest.mock("../common/logger", () => ({ })); function makeIntentsService(): IntentsService { + const repo = { + findAll: jest.fn().mockResolvedValue([]), + save: jest.fn().mockResolvedValue({}), + findById: jest.fn().mockResolvedValue(undefined), + getByState: jest.fn().mockResolvedValue([]), + getByUser: jest.fn().mockResolvedValue([]), + findByIdAndUser: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + clear: jest.fn(), + }; const configService = { get: jest.fn().mockReturnValue(false), } as unknown as ConfigService; @@ -27,7 +39,18 @@ function makeIntentsService(): IntentsService { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; - return new IntentsService(configService, {} as StellarTxService, prismaService); + return new IntentsService( + repo as any, + configService, + {} as StellarTxService, + prismaService, + ); +} + +function makeSolversService() { + return { + get: jest.fn().mockResolvedValue({ address: "GTEST", isActive: true }), + } as any; } function createMockClient() { @@ -48,12 +71,14 @@ function createMockClient() { describe("IntentsGateway heartbeat", () => { let gateway: IntentsGateway; let intentsService: IntentsService; + let solversService: ReturnType; beforeEach(async () => { jest.useFakeTimers(); jest.clearAllMocks(); intentsService = await makeIntentsService(); - gateway = new IntentsGateway(intentsService); + solversService = makeSolversService(); + gateway = new IntentsGateway(intentsService, solversService); }); afterEach(() => { @@ -128,17 +153,37 @@ describe("IntentsGateway heartbeat", () => { expect(c1.send).toHaveBeenCalledWith(expected); expect(c2.send).toHaveBeenCalledWith(expected); }); + + it("accepts a valid solver auth message and rejects invalid signatures", async () => { + const keypair = Keypair.random(); + const client = createMockClient(); + const timestamp = Math.floor(Date.now() / 1000); + solversService.get = jest.fn().mockResolvedValue({ address: keypair.publicKey(), isActive: true }); + + gateway.handleConnection(client as unknown as import("ws").WebSocket); + + const message = buildWsAuthMessage(keypair.publicKey(), timestamp); + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature })); + expect(client.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "auth_ok" })); + + await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature: "bad" })); + expect(client.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "auth_error", reason: "invalid solver signature" })); + }); }); describe("IntentsGateway logging", () => { let gateway: IntentsGateway; let intentsService: IntentsService; + let solversService: ReturnType; beforeEach(async () => { jest.useFakeTimers(); jest.clearAllMocks(); intentsService = await makeIntentsService(); - gateway = new IntentsGateway(intentsService); + solversService = makeSolversService(); + gateway = new IntentsGateway(intentsService, solversService); }); afterEach(() => { diff --git a/src/intents/intents.gateway.ts b/src/intents/intents.gateway.ts index a03c0be..9f51ace 100644 --- a/src/intents/intents.gateway.ts +++ b/src/intents/intents.gateway.ts @@ -2,7 +2,9 @@ import { OnModuleDestroy } from "@nestjs/common"; import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway } from "@nestjs/websockets"; import { WebSocket } from "ws"; import { IntentsService } from "./intents.service"; +import { SolversService } from "../solvers/solvers.service"; import { logger } from "../common/logger"; +import { buildWsAuthMessage, verifyStellarSignature } from "../common/stellar-signature"; const HEARTBEAT_INTERVAL_MS = 30_000; @@ -74,11 +76,15 @@ export class IntentsGateway { private readonly subscribers = new Set(); private readonly alive = new WeakMap(); + private readonly authenticatedSolver = new WeakMap(); // eslint-disable-next-line @typescript-eslint/no-explicit-any private heartbeatTimer: any; private nextSeq = 1; - constructor(private readonly intentsService: IntentsService) { + constructor( + private readonly intentsService: IntentsService, + private readonly solversService: SolversService, + ) { this.heartbeatTimer = setInterval(() => this.heartbeat(), HEARTBEAT_INTERVAL_MS); logger.info("ws heartbeat started"); } @@ -91,6 +97,10 @@ export class IntentsGateway this.alive.set(client, true); }); + client.on("message", (raw) => { + void this.handleMessage(client, raw); + }); + client.on("error", () => { this.subscribers.delete(client); logger.debug( @@ -123,9 +133,65 @@ export class IntentsGateway handleDisconnect(client: WebSocket) { this.subscribers.delete(client); + this.authenticatedSolver.delete(client); logger.info(`ws client disconnected (subscribers=${this.subscribers.size})`); } + private async handleMessage(client: WebSocket, raw: unknown) { + try { + const serialized = Buffer.isBuffer(raw) + ? raw.toString("utf8") + : typeof raw === "string" + ? raw + : String(raw); + const payload = JSON.parse(serialized); + if (!payload || typeof payload !== "object") return; + + switch (payload.type) { + case "auth": { + await this.handleAuth(client, payload); + return; + } + default: + return; + } + } catch { + client.send(JSON.stringify({ type: "auth_error", reason: "Invalid WS message" })); + } + } + + private async handleAuth(client: WebSocket, payload: Record) { + const solver = typeof payload.solver === "string" ? payload.solver : ""; + const timestamp = payload.timestamp; + const signature = typeof payload.signature === "string" ? payload.signature : ""; + + if (!solver || !signature || typeof timestamp !== "number") { + client.send(JSON.stringify({ type: "auth_error", reason: "auth payload requires solver, timestamp, and signature" })); + return; + } + + const now = Math.floor(Date.now() / 1000); + const skew = Math.abs(now - timestamp); + if (skew > 300) { + client.send(JSON.stringify({ type: "auth_error", reason: "stale or future auth timestamp" })); + return; + } + + const solverRecord = await this.solversService.get(solver); + if (!solverRecord || !solverRecord.isActive) { + client.send(JSON.stringify({ type: "auth_error", reason: "solver not registered or inactive" })); + return; + } + + try { + verifyStellarSignature(solver, buildWsAuthMessage(solver, timestamp), signature); + this.authenticatedSolver.set(client, solver); + client.send(JSON.stringify({ type: "auth_ok" })); + } catch { + client.send(JSON.stringify({ type: "auth_error", reason: "invalid solver signature" })); + } + } + broadcast(event: { type: string; [key: string]: unknown }) { logger.debug(`ws broadcast type=${event.type} subscribers=${this.subscribers.size}`); const payload = JSON.stringify(event);