'use strict'; const crypto = require('crypto'); const SIGNATURE_TOLERANCE_SECONDS = 5 * 60; const usedNonces = new Map(); function normalizeIp(value) { return String(value || '') .trim() .replace(/^::ffff:/i, '') .replace(/^\[|\]$/g, ''); } function resolveRequestIp(req = {}) { const socketIp = normalizeIp(req.socket?.remoteAddress || ''); const forwarded = String(req.headers?.['x-forwarded-for'] || '') .split(',')[0] .trim(); const requestIp = normalizeIp(req.ip || forwarded); return ['127.0.0.1', '::1'].includes(socketIp) ? (requestIp || normalizeIp(forwarded) || socketIp) : socketIp; } function isRequestIpAllowed(requestIp, allowedIps = []) { const normalizedRequestIp = normalizeIp(requestIp); const normalizedAllowedIps = allowedIps.map(normalizeIp).filter(Boolean); return normalizedAllowedIps.length === 0 || normalizedAllowedIps.includes(normalizedRequestIp); } function hashBody(rawBody) { return crypto.createHash('sha256').update(String(rawBody || ''), 'utf8').digest('hex'); } function buildCanonicalRequest({ timestamp, nonce, method, originalUrl, rawBody }) { return [ String(timestamp || '').trim(), String(nonce || '').trim(), String(method || 'GET').toUpperCase(), String(originalUrl || ''), hashBody(rawBody), ].join('\n'); } function signCanonicalRequest(apiKey, canonicalRequest) { return crypto .createHmac('sha256', String(apiKey || '')) .update(String(canonicalRequest || ''), 'utf8') .digest('hex'); } function safeEqual(left, right) { const leftBuffer = Buffer.from(String(left || ''), 'utf8'); const rightBuffer = Buffer.from(String(right || ''), 'utf8'); return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); } function cleanupExpiredNonces(nowMs) { for (const [key, expiresAt] of usedNonces.entries()) { if (expiresAt <= nowMs) usedNonces.delete(key); } } function verifyHmacRequest(req, apiKey, keyId, options = {}) { const timestamp = String(req.get('x-timestamp') || '').trim(); const nonce = String(req.get('x-nonce') || '').trim(); const providedSignature = String(req.get('x-signature') || '') .trim() .replace(/^sha256=/i, '') .toLowerCase(); if (!timestamp || !nonce || !providedSignature) { return { valid: false, reason: '缺少 X-Timestamp、X-Nonce 或 X-Signature' }; } const timestampSeconds = Number(timestamp); const nowMs = Number(options.nowMs || Date.now()); const toleranceSeconds = Number(options.toleranceSeconds || SIGNATURE_TOLERANCE_SECONDS); if (!Number.isFinite(timestampSeconds)) { return { valid: false, reason: 'X-Timestamp 必须是 Unix 秒级时间戳' }; } if (Math.abs(Math.floor(nowMs / 1000) - timestampSeconds) > toleranceSeconds) { return { valid: false, reason: '请求时间戳已过期' }; } if (!/^[A-Za-z0-9_-]{8,160}$/.test(nonce)) { return { valid: false, reason: 'X-Nonce 格式不正确' }; } const rawBody = typeof req.rawBody === 'string' ? req.rawBody : (req.body && Object.keys(req.body).length > 0 ? JSON.stringify(req.body) : ''); const canonicalRequest = buildCanonicalRequest({ timestamp, nonce, method: req.method, originalUrl: req.originalUrl, rawBody, }); const expectedSignature = signCanonicalRequest(apiKey, canonicalRequest); if (!safeEqual(providedSignature, expectedSignature)) { return { valid: false, reason: 'HMAC 签名不匹配' }; } cleanupExpiredNonces(nowMs); const nonceKey = `${keyId || 'key'}:${nonce}`; if (usedNonces.has(nonceKey)) { return { valid: false, reason: 'X-Nonce 已使用,请勿重放请求' }; } usedNonces.set(nonceKey, nowMs + toleranceSeconds * 1000); return { valid: true, canonicalRequest }; } module.exports = { buildCanonicalRequest, hashBody, isRequestIpAllowed, normalizeIp, resolveRequestIp, signCanonicalRequest, verifyHmacRequest, };