← 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 };