← back to Wine Finder Next

lib/requestSigning.ts

483 lines

// Request Signing for Sensitive Operations
// HMAC-based request signing for high-value transactions

import crypto from 'crypto';
import { logAudit } from './auth';
import { logSecurityEvent } from './securityMonitoring';

interface SignedRequest {
  signature: string;
  timestamp: number;
  nonce: string;
  apiKey: string;
  method: string;
  path: string;
  body?: string;
}

interface APIKey {
  id: string;
  secret: string;
  userId: string;
  name: string;
  permissions: string[];
  createdAt: Date;
  lastUsed?: Date;
  expiresAt?: Date;
  requestCount: number;
  rateLimit: number; // requests per minute
}

// Storage - in production use database
const apiKeys = new Map<string, APIKey>();
const usedNonces = new Map<string, Set<string>>(); // apiKey -> nonces
const requestRateLimits = new Map<string, { count: number; resetAt: number }>();

// Configuration
const SIGNING_CONFIG = {
  algorithm: 'sha256',
  timestampTolerance: 5 * 60 * 1000, // 5 minutes
  nonceLifetime: 10 * 60 * 1000, // 10 minutes
  signatureHeader: 'x-signature',
  timestampHeader: 'x-timestamp',
  nonceHeader: 'x-nonce',
  apiKeyHeader: 'x-api-key',
  requiredForPaths: [
    '/api/membership/marketplace/*/purchase',
    '/api/membership/votes/*/execute',
    '/api/admin/*',
    '/api/security/*'
  ],
  highValueThreshold: 1000, // USD
  cleanupInterval: 60 * 60 * 1000 // 1 hour
};

// Generate API key pair
export function generateAPIKey(
  userId: string,
  name: string,
  permissions: string[] = [],
  expiresInDays?: number
): { apiKey: string; apiSecret: string } {
  const apiKey = `wdao_${crypto.randomBytes(16).toString('hex')}`;
  const apiSecret = `secret_${crypto.randomBytes(32).toString('hex')}`;

  const key: APIKey = {
    id: apiKey,
    secret: crypto.createHash('sha256').update(apiSecret).digest('hex'),
    userId,
    name,
    permissions,
    createdAt: new Date(),
    requestCount: 0,
    rateLimit: 100, // Default 100 requests per minute
    expiresAt: expiresInDays
      ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
      : undefined
  };

  apiKeys.set(apiKey, key);
  usedNonces.set(apiKey, new Set());

  logAudit({
    userId,
    action: 'API_KEY_CREATE',
    resource: `apikey/${apiKey}`,
    ipAddress: 'system',
    userAgent: 'system',
    success: true,
    metadata: { name, permissions, expiresInDays }
  });

  return { apiKey, apiSecret };
}

// Sign request
export function signRequest(
  apiSecret: string,
  method: string,
  path: string,
  body?: any,
  timestamp?: number,
  nonce?: string
): SignedRequest {
  const ts = timestamp || Date.now();
  const requestNonce = nonce || crypto.randomBytes(16).toString('hex');

  // Create signing string
  const bodyString = body ? JSON.stringify(body) : '';
  const signingString = [
    method.toUpperCase(),
    path,
    ts.toString(),
    requestNonce,
    bodyString
  ].join('\n');

  // Generate signature
  const signature = crypto
    .createHmac(SIGNING_CONFIG.algorithm, apiSecret)
    .update(signingString)
    .digest('hex');

  return {
    signature,
    timestamp: ts,
    nonce: requestNonce,
    apiKey: '', // Will be set by caller
    method,
    path,
    body: bodyString
  };
}

// Verify signed request
export function verifySignedRequest(
  headers: Record<string, string>,
  method: string,
  path: string,
  body?: any,
  ipAddress?: string
): { valid: boolean; userId?: string; error?: string } {
  // Extract signing headers
  const signature = headers[SIGNING_CONFIG.signatureHeader];
  const timestamp = headers[SIGNING_CONFIG.timestampHeader];
  const nonce = headers[SIGNING_CONFIG.nonceHeader];
  const apiKeyId = headers[SIGNING_CONFIG.apiKeyHeader];

  if (!signature || !timestamp || !nonce || !apiKeyId) {
    return { valid: false, error: 'Missing required signing headers' };
  }

  // Get API key
  const apiKey = apiKeys.get(apiKeyId);

  if (!apiKey) {
    logSecurityEvent({
      type: 'INVALID_TOKEN',
      severity: 'medium',
      ipAddress: ipAddress || 'unknown',
      userAgent: headers['user-agent'] || 'unknown',
      details: { reason: 'Invalid API key', apiKey: apiKeyId }
    });
    return { valid: false, error: 'Invalid API key' };
  }

  // Check expiration
  if (apiKey.expiresAt && new Date() > apiKey.expiresAt) {
    return { valid: false, error: 'API key expired' };
  }

  // Check timestamp
  const ts = parseInt(timestamp);
  const now = Date.now();

  if (isNaN(ts) || Math.abs(now - ts) > SIGNING_CONFIG.timestampTolerance) {
    logSecurityEvent({
      type: 'SUSPICIOUS_ACTIVITY',
      severity: 'medium',
      userId: apiKey.userId,
      ipAddress: ipAddress || 'unknown',
      userAgent: headers['user-agent'] || 'unknown',
      details: {
        reason: 'Invalid timestamp in signed request',
        timestamp: ts,
        current: now
      }
    });
    return { valid: false, error: 'Request timestamp out of valid range' };
  }

  // Check nonce
  const apiNonces = usedNonces.get(apiKeyId) || new Set();
  if (apiNonces.has(nonce)) {
    logSecurityEvent({
      type: 'SUSPICIOUS_ACTIVITY',
      severity: 'high',
      userId: apiKey.userId,
      ipAddress: ipAddress || 'unknown',
      userAgent: headers['user-agent'] || 'unknown',
      details: { reason: 'Nonce replay detected', nonce }
    });
    return { valid: false, error: 'Nonce already used' };
  }

  // Check rate limit
  if (!checkAPIKeyRateLimit(apiKeyId, apiKey.rateLimit)) {
    return { valid: false, error: 'API rate limit exceeded' };
  }

  // Recreate signing string
  const bodyString = body ? JSON.stringify(body) : '';
  const signingString = [
    method.toUpperCase(),
    path,
    timestamp,
    nonce,
    bodyString
  ].join('\n');

  // Verify signature (use the stored hash of the secret)
  // In real implementation, we'd need the original secret
  // This is a simplified version for demonstration
  const expectedSignature = crypto
    .createHmac(SIGNING_CONFIG.algorithm, apiKey.secret)
    .update(signingString)
    .digest('hex');

  if (signature !== expectedSignature) {
    logSecurityEvent({
      type: 'INVALID_TOKEN',
      severity: 'high',
      userId: apiKey.userId,
      ipAddress: ipAddress || 'unknown',
      userAgent: headers['user-agent'] || 'unknown',
      details: { reason: 'Invalid request signature' }
    });
    return { valid: false, error: 'Invalid request signature' };
  }

  // Store nonce
  apiNonces.add(nonce);
  usedNonces.set(apiKeyId, apiNonces);

  // Update usage
  apiKey.lastUsed = new Date();
  apiKey.requestCount++;

  logAudit({
    userId: apiKey.userId,
    action: 'SIGNED_REQUEST',
    resource: path,
    ipAddress: ipAddress || 'unknown',
    userAgent: headers['user-agent'] || 'unknown',
    success: true,
    metadata: { apiKey: apiKeyId, method }
  });

  return { valid: true, userId: apiKey.userId };
}

// Check if request requires signing
export function requiresSigning(path: string, value?: number): boolean {
  // Check if path matches any required patterns
  for (const pattern of SIGNING_CONFIG.requiredForPaths) {
    const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
    if (regex.test(path)) {
      return true;
    }
  }

  // Check if high-value transaction
  if (value && value >= SIGNING_CONFIG.highValueThreshold) {
    return true;
  }

  return false;
}

// Check API key rate limit
function checkAPIKeyRateLimit(apiKeyId: string, limit: number): boolean {
  const now = Date.now();
  const rateLimit = requestRateLimits.get(apiKeyId);

  if (!rateLimit || now > rateLimit.resetAt) {
    requestRateLimits.set(apiKeyId, {
      count: 1,
      resetAt: now + 60000 // 1 minute
    });
    return true;
  }

  if (rateLimit.count >= limit) {
    return false;
  }

  rateLimit.count++;
  return true;
}

// Revoke API key
export function revokeAPIKey(apiKeyId: string, reason: string): boolean {
  const apiKey = apiKeys.get(apiKeyId);

  if (!apiKey) {
    return false;
  }

  apiKeys.delete(apiKeyId);
  usedNonces.delete(apiKeyId);
  requestRateLimits.delete(apiKeyId);

  logAudit({
    userId: apiKey.userId,
    action: 'API_KEY_REVOKE',
    resource: `apikey/${apiKeyId}`,
    ipAddress: 'system',
    userAgent: 'system',
    success: true,
    metadata: { reason }
  });

  return true;
}

// List user API keys
export function getUserAPIKeys(userId: string): Array<{
  id: string;
  name: string;
  permissions: string[];
  createdAt: Date;
  lastUsed?: Date;
  expiresAt?: Date;
  requestCount: number;
}> {
  const userKeys: Array<any> = [];

  for (const apiKey of apiKeys.values()) {
    if (apiKey.userId === userId) {
      userKeys.push({
        id: apiKey.id,
        name: apiKey.name,
        permissions: apiKey.permissions,
        createdAt: apiKey.createdAt,
        lastUsed: apiKey.lastUsed,
        expiresAt: apiKey.expiresAt,
        requestCount: apiKey.requestCount
      });
    }
  }

  return userKeys;
}

// Cleanup expired nonces
function cleanupExpiredNonces(): void {
  const now = Date.now();

  for (const [apiKeyId, nonces] of usedNonces.entries()) {
    // In production, store nonce timestamps and clean old ones
    // For now, clear if too many
    if (nonces.size > 10000) {
      nonces.clear();
    }
  }

  // Clean expired API keys
  for (const [id, key] of apiKeys.entries()) {
    if (key.expiresAt && new Date() > key.expiresAt) {
      revokeAPIKey(id, 'Expired');
    }
  }
}

// Generate signing example for documentation
export function generateSigningExample(
  apiKey: string,
  apiSecret: string
): {
  curl: string;
  javascript: string;
  python: string;
} {
  const method = 'POST';
  const path = '/api/membership/marketplace/1/purchase';
  const body = { quantity: 1, shipping: { /* ... */ } };
  const timestamp = Date.now();
  const nonce = crypto.randomBytes(16).toString('hex');

  const signed = signRequest(apiSecret, method, path, body, timestamp, nonce);

  return {
    curl: `curl -X ${method} \\
  -H "${SIGNING_CONFIG.apiKeyHeader}: ${apiKey}" \\
  -H "${SIGNING_CONFIG.signatureHeader}: ${signed.signature}" \\
  -H "${SIGNING_CONFIG.timestampHeader}: ${timestamp}" \\
  -H "${SIGNING_CONFIG.nonceHeader}: ${nonce}" \\
  -H "Content-Type: application/json" \\
  -d '${JSON.stringify(body)}' \\
  https://winedao.com${path}`,

    javascript: `const crypto = require('crypto');

const apiKey = '${apiKey}';
const apiSecret = '${apiSecret}';
const method = '${method}';
const path = '${path}';
const body = ${JSON.stringify(body, null, 2)};
const timestamp = Date.now();
const nonce = crypto.randomBytes(16).toString('hex');

const signingString = [
  method,
  path,
  timestamp,
  nonce,
  JSON.stringify(body)
].join('\\n');

const signature = crypto
  .createHmac('sha256', apiSecret)
  .update(signingString)
  .digest('hex');

fetch('https://winedao.com' + path, {
  method,
  headers: {
    '${SIGNING_CONFIG.apiKeyHeader}': apiKey,
    '${SIGNING_CONFIG.signatureHeader}': signature,
    '${SIGNING_CONFIG.timestampHeader}': timestamp,
    '${SIGNING_CONFIG.nonceHeader}': nonce,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(body)
});`,

    python: `import hashlib
import hmac
import json
import time
import secrets
import requests

api_key = '${apiKey}'
api_secret = '${apiSecret}'
method = '${method}'
path = '${path}'
body = ${JSON.stringify(body, null, 2)}
timestamp = int(time.time() * 1000)
nonce = secrets.token_hex(16)

signing_string = '\\n'.join([
    method,
    path,
    str(timestamp),
    nonce,
    json.dumps(body)
])

signature = hmac.new(
    api_secret.encode(),
    signing_string.encode(),
    hashlib.sha256
).hexdigest()

response = requests.${method.toLowerCase()}(
    f'https://winedao.com{path}',
    headers={
        '${SIGNING_CONFIG.apiKeyHeader}': api_key,
        '${SIGNING_CONFIG.signatureHeader}': signature,
        '${SIGNING_CONFIG.timestampHeader}': str(timestamp),
        '${SIGNING_CONFIG.nonceHeader}': nonce,
        'Content-Type': 'application/json'
    },
    json=body
)`
  };
}

// Run cleanup periodically
setInterval(cleanupExpiredNonces, SIGNING_CONFIG.cleanupInterval);

// Export configuration for testing
export { SIGNING_CONFIG };