src/auth/auth.ts
v0.1.0 · 5.7 KB
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]');
}