From a143a8b029bad902fe1075016f384ddfc64f1b55 Mon Sep 17 00:00:00 2001 From: Vaggelis Date: Sat, 22 Aug 2026 21:36:52 +0300 Subject: [PATCH] fix(gemini-titan): use millisecond nonce for WebSocket authentication Gemini's WebSocket API requires the handshake nonce to be a Unix timestamp in milliseconds. buildWsHeaders() divided Date.now() by 1000, sending seconds, which lands outside the server's replay-prevention window and rejects authenticated connections. The REST path already sends milliseconds via GeminiAuth.nonce(), so only the WebSocket handshake was affected. Remove the division, correct the stale doc comment, and add unit tests that freeze Date.now() and assert the nonce header and signed payload carry the millisecond value. Fixes #1933 --- core/src/exchanges/gemini-titan/auth.ts | 4 +- core/test/exchanges/gemini-titan-auth.test.ts | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 core/test/exchanges/gemini-titan-auth.test.ts diff --git a/core/src/exchanges/gemini-titan/auth.ts b/core/src/exchanges/gemini-titan/auth.ts index 6546c134..bb96a1d0 100644 --- a/core/src/exchanges/gemini-titan/auth.ts +++ b/core/src/exchanges/gemini-titan/auth.ts @@ -63,11 +63,11 @@ export class GeminiAuth { /** * Build WebSocket handshake authentication headers. * - * WebSocket auth uses a time-based nonce (seconds since epoch) + * WebSocket auth uses a time-based nonce (milliseconds since epoch) * as the payload, not a JSON object. */ buildWsHeaders(): Record { - const nonce = Math.floor(Date.now() / 1000).toString(); + const nonce = Date.now().toString(); const b64Payload = Buffer.from(nonce).toString('base64'); const signature = crypto .createHmac('sha384', this.apiSecret) diff --git a/core/test/exchanges/gemini-titan-auth.test.ts b/core/test/exchanges/gemini-titan-auth.test.ts new file mode 100644 index 00000000..7a72b2f9 --- /dev/null +++ b/core/test/exchanges/gemini-titan-auth.test.ts @@ -0,0 +1,37 @@ +import crypto from 'crypto'; +import { GeminiAuth } from '../../src/exchanges/gemini-titan/auth'; + +// Milliseconds matter here: Gemini's WebSocket API requires the handshake +// nonce to be a Unix timestamp in milliseconds. +const FROZEN_NOW_MS = 1755878400000; // 2025-08-22T16:00:00.000Z + +describe('GeminiAuth WebSocket headers', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses a millisecond nonce for the WebSocket handshake', () => { + jest.spyOn(Date, 'now').mockReturnValue(FROZEN_NOW_MS); + const auth = new GeminiAuth({ apiKey: 'test-key', apiSecret: 'test-secret' }); + + const headers = auth.buildWsHeaders(); + + expect(headers['X-GEMINI-NONCE']).toBe(String(FROZEN_NOW_MS)); + }); + + it('signs the same millisecond nonce it sends', () => { + jest.spyOn(Date, 'now').mockReturnValue(FROZEN_NOW_MS); + const auth = new GeminiAuth({ apiKey: 'test-key', apiSecret: 'test-secret' }); + + const headers = auth.buildWsHeaders(); + + const payload = Buffer.from(headers['X-GEMINI-PAYLOAD'], 'base64').toString('utf8'); + expect(payload).toBe(String(FROZEN_NOW_MS)); + + const expectedSignature = crypto + .createHmac('sha384', 'test-secret') + .update(headers['X-GEMINI-PAYLOAD']) + .digest('hex'); + expect(headers['X-GEMINI-SIGNATURE']).toBe(expectedSignature); + }); +});