Skip to content
markpaper

src/auth/auth.ts

v0.1.0 · 5.7 KB

Download file
import crypto from 'node:crypto';
export interface QfexKeyPair {
  publicKey: string;
  secret: string;
}
export const QFEX_NONCE_WINDOW_MS = 15 * 60_000;
export const QFEX_NONCE_MAX_LEN = 100;
const NONCE_RE = /^[0-9a-fA-F]{1,100}$/;
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
function checkAccountId(accountId: string | null): void {
  if (accountId !== null && !isQfexAccountId(accountId)) throw new TypeError('Account id must be a UUID or null');
}
export function unixSeconds(nowMs: number): number {
  return Math.floor(nowMs / 1000);
}
export function hmacSignature(secret: string, nonce: string, unixTs: number): string {
  if (!secret) throw new Error('HMAC secret is required');
  if (!NONCE_RE.test(nonce)) throw new Error(`Nonce must contain 1 to ${QFEX_NONCE_MAX_LEN} hexadecimal characters`);
  if (!Number.isSafeInteger(unixTs) || unixTs < 1000000000 || unixTs >= 100000000000) {
    throw new Error(`Invalid unix timestamp in seconds: ${unixTs}`);
  }
  return crypto.createHmac('sha256', secret).update(`${nonce}:${unixTs}`, 'utf8').digest('hex');
}
export function isQfexAccountId(s: unknown): s is string {
  return typeof s === 'string' && UUID_RE.test(s);
}
export function buildRestAuthHeaders(
  keys: QfexKeyPair,
  accountId: string | null,
  nowMs: number,
  nonce: string,
): Record<string, string> {
  checkAccountId(accountId);
  const ts = unixSeconds(nowMs);
  const h: Record<string, string> = {
    'x-qfex-public-key': keys.publicKey,
    'x-qfex-hmac-signature': hmacSignature(keys.secret, nonce, ts),
    'x-qfex-nonce': nonce,
    'x-qfex-timestamp': String(ts),
  };
  if (accountId !== null) h['x-qfex-requested-account-id'] = accountId;
  return h;
}
export function buildWsAuthFrame(keys: QfexKeyPair, accountId: string | null, nowMs: number, nonce: string): string {
  checkAccountId(accountId);
  const ts = unixSeconds(nowMs);
  const params: {
    hmac: {
      public_key: string;
      nonce: string;
      unix_ts: number;
      signature: string;
    };
    account_id?: string;
  } = {
    hmac: { public_key: keys.publicKey, nonce, unix_ts: ts, signature: hmacSignature(keys.secret, nonce, ts) },
  };
  if (accountId !== null) params.account_id = accountId;
  return JSON.stringify({ type: 'auth', params });
}
export function wsQueryAuthUrl(baseUrl: string, publicKey: string): string {
  const u = new URL(baseUrl);
  u.searchParams.set('api_key', publicKey);
  return u.toString();
}
export type QfexAuthFailureKind = 'bad_signature' | 'no_credentials' | 'unknown_key' | 'forbidden';
export interface QfexAuthFailure {
  kind: QfexAuthFailureKind;
  retryFreshNonce: boolean;
  keyRejected: boolean;
}
export function classifyQfexAuthFailure(status: number, body: string | null | undefined): QfexAuthFailure | null {
  const t = String(body ?? '').toLowerCase();
  if (status === 401) {
    if (t.includes('no authentication credentials'))
      return { kind: 'no_credentials', retryFreshNonce: false, keyRejected: false };
    return { kind: 'bad_signature', retryFreshNonce: true, keyRejected: false };
  }
  if (status === 403) {
    if (t.includes('invalid credentials')) return { kind: 'unknown_key', retryFreshNonce: false, keyRejected: true };
    return { kind: 'forbidden', retryFreshNonce: false, keyRejected: false };
  }
  return null;
}
export const QFEX_WS_READ_FRAME_TYPES: ReadonlySet<string> = new Set([
  'auth',
  'subscribe',
  'unsubscribe',
  'get_user_orders',
  'get_user_leverage',
  'get_order',
  'get_user_trades',
  'get_available_leverage_levels',
]);
export function qfexWsFrameIsWrite(
  frame:
    | string
    | {
        type?: unknown;
      },
): boolean {
  let type: unknown;
  if (typeof frame === 'string') {
    try {
      type = (
        JSON.parse(frame) as {
          type?: unknown;
        } | null
      )?.type;
    } catch {
      return true;
    }
  } else {
    type = frame?.type;
  }
  return typeof type !== 'string' || !QFEX_WS_READ_FRAME_TYPES.has(type);
}
export function qfexRestIsWrite(method: string): boolean {
  const m = String(method).toUpperCase();
  return m !== 'GET' && m !== 'HEAD';
}
export class QfexDryRunWriteError extends Error {}
export function assertQfexWriteAllowed(what: string, dryRun: boolean): void {
  if (dryRun) throw new QfexDryRunWriteError(`Read-only mode blocks ${what}`);
}
export function createNonceSource(
  options: { windowMs?: number; randomBytes?: (n: number) => Buffer; now?: () => number } = {},
) {
  const recent = new Map<string, number>();
  const windowMs = options.windowMs ?? QFEX_NONCE_WINDOW_MS;
  if (!Number.isFinite(windowMs) || windowMs < QFEX_NONCE_WINDOW_MS)
    throw new TypeError('Nonce memory must cover the server window');
  return {
    next(now = (options.now ?? Date.now)()) {
      for (const [value, at] of recent) if (now - at >= windowMs) recent.delete(value);
      for (let attempt = 0; attempt < 8; attempt++) {
        const nonce = (options.randomBytes ?? crypto.randomBytes)(16).toString('hex');
        if (!/^[0-9a-f]{32}$/.test(nonce)) throw new TypeError('Invalid nonce entropy');
        if (recent.has(nonce)) continue;
        recent.set(nonce, now);
        return nonce;
      }
      throw new Error('Nonce generator repeatedly returned a duplicate');
    },
    size: () => recent.size,
  };
}
export function maskSecrets(text: string, options: { secret?: string } = {}): string {
  let result = String(text);
  if (options.secret) result = result.split(options.secret).join('[redacted]');
  return result
    .replace(/qfex_(?:secret|pub)_[A-Za-z0-9_-]+/g, '[redacted]')
    .replace(/(api_key=)[^&\s]+/gi, '$1[redacted]')
    .replace(/(signature["'\s:=]+)[A-Za-z0-9]+/gi, '$1[redacted]')
    .replace(/\b[0-9a-fA-F]{64}\b/g, '[redacted]');
}
All files