306 lines
No EOL
11 KiB
TypeScript
306 lines
No EOL
11 KiB
TypeScript
import * as crypto from 'crypto';
|
|
import { getRealIP, type NetworkRequest } from './network.js';
|
|
import { parseDuration } from './time.js';
|
|
|
|
// Type definitions for secure proof operations
|
|
export interface ChallengeData {
|
|
readonly challenge: string;
|
|
readonly salt: string;
|
|
}
|
|
|
|
export interface ChallengeParams {
|
|
readonly Challenge: string;
|
|
readonly Salt: string;
|
|
readonly Difficulty: number;
|
|
readonly ExpiresAt: number;
|
|
readonly CreatedAt: number;
|
|
readonly ClientIP: string;
|
|
readonly PoSSeed: string;
|
|
}
|
|
|
|
export interface CheckpointConfig {
|
|
readonly SaltLength: number;
|
|
readonly Difficulty: number;
|
|
readonly ChallengeExpiration: number;
|
|
readonly CheckPoSTimes: boolean;
|
|
readonly PoSTimeConsistencyRatio: number;
|
|
}
|
|
|
|
// Security constants - prevent DoS attacks while respecting user config
|
|
const ABSOLUTE_MAX_SALT_LENGTH = 1024; // 1KB - prevents memory exhaustion
|
|
const ABSOLUTE_MAX_DIFFICULTY = 64; // Reasonable upper bound for crypto safety
|
|
const ABSOLUTE_MIN_DIFFICULTY = 1; // Must be at least 1
|
|
const ABSOLUTE_MAX_DURATION = parseDuration('365d'); // 1 year - prevents overflow
|
|
const EXPECTED_POS_TIMES_LENGTH = 3; // Protocol requirement
|
|
const EXPECTED_POS_HASHES_LENGTH = 3; // Protocol requirement
|
|
const EXPECTED_HASH_LENGTH = 64; // SHA-256 hex length
|
|
const ABSOLUTE_MAX_INPUT_LENGTH = 100000; // 100KB - prevents DoS
|
|
const ABSOLUTE_MAX_REQUEST_ID_LENGTH = 64; // Reasonable hex string limit
|
|
|
|
// Input validation functions - zero trust approach
|
|
function validateHexString(value: unknown, paramName: string, maxLength: number): string {
|
|
if (typeof value !== 'string') {
|
|
throw new Error(`${paramName} must be a string`);
|
|
}
|
|
if (value.length === 0) {
|
|
throw new Error(`${paramName} cannot be empty`);
|
|
}
|
|
if (value.length > maxLength) {
|
|
throw new Error(`${paramName} exceeds maximum length of ${maxLength}`);
|
|
}
|
|
if (!/^[0-9a-fA-F]+$/.test(value)) {
|
|
throw new Error(`${paramName} must be a valid hexadecimal string`);
|
|
}
|
|
return value.toLowerCase();
|
|
}
|
|
|
|
function validatePositiveInteger(value: unknown, paramName: string, min: number, max: number): number {
|
|
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
throw new Error(`${paramName} must be an integer`);
|
|
}
|
|
if (value < min || value > max) {
|
|
throw new Error(`${paramName} must be between ${min} and ${max}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateTimesArray(value: unknown, paramName: string): number[] {
|
|
if (!Array.isArray(value)) {
|
|
throw new Error(`${paramName} must be an array`);
|
|
}
|
|
if (value.length !== EXPECTED_POS_TIMES_LENGTH) {
|
|
throw new Error(`${paramName} must have exactly ${EXPECTED_POS_TIMES_LENGTH} elements`);
|
|
}
|
|
|
|
const validatedTimes: number[] = [];
|
|
for (let i = 0; i < value.length; i++) {
|
|
const time = value[i];
|
|
if (typeof time !== 'number' || !Number.isFinite(time) || time < 0) {
|
|
throw new Error(`${paramName}[${i}] must be a non-negative finite number`);
|
|
}
|
|
if (time > 10000000) { // 10M ms = ~3 hours - generous but prevents DoS
|
|
throw new Error(`${paramName}[${i}] exceeds maximum allowed value`);
|
|
}
|
|
validatedTimes.push(time);
|
|
}
|
|
return validatedTimes;
|
|
}
|
|
|
|
function validateHashesArray(value: unknown, paramName: string): string[] {
|
|
if (!Array.isArray(value)) {
|
|
throw new Error(`${paramName} must be an array`);
|
|
}
|
|
if (value.length !== EXPECTED_POS_HASHES_LENGTH) {
|
|
throw new Error(`${paramName} must have exactly ${EXPECTED_POS_HASHES_LENGTH} elements`);
|
|
}
|
|
|
|
const validatedHashes: string[] = [];
|
|
for (let i = 0; i < value.length; i++) {
|
|
const hash = validateHexString(value[i], `${paramName}[${i}]`, EXPECTED_HASH_LENGTH);
|
|
if (hash.length !== EXPECTED_HASH_LENGTH) {
|
|
throw new Error(`${paramName}[${i}] must be exactly ${EXPECTED_HASH_LENGTH} characters`);
|
|
}
|
|
validatedHashes.push(hash);
|
|
}
|
|
return validatedHashes;
|
|
}
|
|
|
|
function validateCheckpointConfig(config: unknown): CheckpointConfig {
|
|
if (!config || typeof config !== 'object') {
|
|
throw new Error('CheckpointConfig must be an object');
|
|
}
|
|
|
|
const cfg = config as Record<string, unknown>;
|
|
|
|
// Validate user's salt length - allow generous range but prevent memory exhaustion
|
|
const saltLength = validatePositiveInteger(cfg.SaltLength, 'SaltLength', 1, ABSOLUTE_MAX_SALT_LENGTH);
|
|
|
|
// Respect user's difficulty settings completely - they know their security needs
|
|
const difficulty = validatePositiveInteger(cfg.Difficulty, 'Difficulty', ABSOLUTE_MIN_DIFFICULTY, ABSOLUTE_MAX_DIFFICULTY);
|
|
|
|
// Respect user's expiration settings - they control their own security/usability balance
|
|
const challengeExpiration = validatePositiveInteger(cfg.ChallengeExpiration, 'ChallengeExpiration', 1000, ABSOLUTE_MAX_DURATION);
|
|
|
|
// Validate consistency ratio - prevent divide by zero but allow user control
|
|
const consistencyRatio = typeof cfg.PoSTimeConsistencyRatio === 'number' && cfg.PoSTimeConsistencyRatio > 0 && cfg.PoSTimeConsistencyRatio <= 1000
|
|
? cfg.PoSTimeConsistencyRatio : 2.0;
|
|
|
|
return {
|
|
SaltLength: saltLength,
|
|
Difficulty: difficulty,
|
|
ChallengeExpiration: challengeExpiration,
|
|
CheckPoSTimes: typeof cfg.CheckPoSTimes === 'boolean' ? cfg.CheckPoSTimes : false,
|
|
PoSTimeConsistencyRatio: consistencyRatio
|
|
};
|
|
}
|
|
|
|
function validateNetworkRequest(request: unknown): NetworkRequest {
|
|
if (!request || typeof request !== 'object') {
|
|
throw new Error('Request must be an object');
|
|
}
|
|
|
|
const req = request as Record<string, unknown>;
|
|
|
|
// Validate headers object exists
|
|
if (!req.headers || typeof req.headers !== 'object') {
|
|
throw new Error('Request must have headers object');
|
|
}
|
|
|
|
// Basic validation - ensure it has the minimal structure for a NetworkRequest
|
|
return request as NetworkRequest;
|
|
}
|
|
|
|
function generateChallenge(checkpointConfig: unknown): ChallengeData {
|
|
const validatedConfig = validateCheckpointConfig(checkpointConfig);
|
|
|
|
const challenge = crypto.randomBytes(16).toString('hex');
|
|
const salt = crypto.randomBytes(validatedConfig.SaltLength).toString('hex');
|
|
|
|
return { challenge, salt };
|
|
}
|
|
|
|
function calculateHash(input: unknown): string {
|
|
if (typeof input !== 'string') {
|
|
throw new Error('Hash input must be a string');
|
|
}
|
|
if (input.length === 0) {
|
|
throw new Error('Hash input cannot be empty');
|
|
}
|
|
if (input.length > ABSOLUTE_MAX_INPUT_LENGTH) { // Prevent DoS via massive strings
|
|
throw new Error(`Hash input exceeds maximum length of ${ABSOLUTE_MAX_INPUT_LENGTH}`);
|
|
}
|
|
|
|
return crypto.createHash('sha256').update(input).digest('hex');
|
|
}
|
|
|
|
export function verifyPoW(
|
|
challenge: unknown,
|
|
salt: unknown,
|
|
nonce: unknown,
|
|
difficulty: unknown
|
|
): boolean {
|
|
// Validate all user-provided inputs with zero trust
|
|
const validatedChallenge = validateHexString(challenge, 'challenge', ABSOLUTE_MAX_INPUT_LENGTH);
|
|
const validatedSalt = validateHexString(salt, 'salt', ABSOLUTE_MAX_INPUT_LENGTH);
|
|
const validatedNonce = validateHexString(nonce, 'nonce', ABSOLUTE_MAX_INPUT_LENGTH);
|
|
const validatedDifficulty = validatePositiveInteger(difficulty, 'difficulty', ABSOLUTE_MIN_DIFFICULTY, ABSOLUTE_MAX_DIFFICULTY);
|
|
|
|
// Perform cryptographic operation with validated inputs
|
|
const hash = calculateHash(validatedChallenge + validatedSalt + validatedNonce);
|
|
const requiredPrefix = '0'.repeat(validatedDifficulty);
|
|
|
|
return hash.startsWith(requiredPrefix);
|
|
}
|
|
|
|
export function checkPoSTimes(times: unknown, enableCheck: unknown, ratio: unknown): void {
|
|
const validatedTimes = validateTimesArray(times, 'times');
|
|
const validatedEnableCheck = typeof enableCheck === 'boolean' ? enableCheck : false;
|
|
const validatedRatio = typeof ratio === 'number' && ratio > 0 ? ratio : 2.0;
|
|
|
|
if (!validatedEnableCheck) {
|
|
return; // Skip check if disabled
|
|
}
|
|
|
|
const minTime = Math.min(...validatedTimes);
|
|
const maxTime = Math.max(...validatedTimes);
|
|
|
|
if (minTime === 0) {
|
|
throw new Error('PoS run times cannot be zero');
|
|
}
|
|
|
|
const actualRatio = maxTime / minTime;
|
|
if (actualRatio > validatedRatio) {
|
|
throw new Error(`PoS run times inconsistent (ratio ${actualRatio.toFixed(2)} > ${validatedRatio})`);
|
|
}
|
|
}
|
|
|
|
// Secure in-memory storage with automatic cleanup
|
|
export const challengeStore = new Map<string, ChallengeParams>();
|
|
|
|
// Cleanup expired challenges to prevent memory exhaustion
|
|
function cleanupExpiredChallenges(): void {
|
|
const now = Date.now();
|
|
for (const [requestId, params] of Array.from(challengeStore.entries())) {
|
|
if (params.ExpiresAt < now) {
|
|
challengeStore.delete(requestId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run cleanup every 5 minutes
|
|
setInterval(cleanupExpiredChallenges, parseDuration('5m'));
|
|
|
|
export function generateRequestID(request: unknown, checkpointConfig: unknown): string {
|
|
const validatedConfig = validateCheckpointConfig(checkpointConfig);
|
|
const validatedRequest = validateNetworkRequest(request);
|
|
const { challenge, salt } = generateChallenge(validatedConfig);
|
|
|
|
const posSeed = crypto.randomBytes(32).toString('hex');
|
|
const requestId = crypto.randomBytes(16).toString('hex');
|
|
|
|
const params: ChallengeParams = {
|
|
Challenge: challenge,
|
|
Salt: salt,
|
|
Difficulty: validatedConfig.Difficulty,
|
|
ExpiresAt: Date.now() + validatedConfig.ChallengeExpiration,
|
|
CreatedAt: Date.now(),
|
|
ClientIP: getRealIP(validatedRequest),
|
|
PoSSeed: posSeed,
|
|
};
|
|
|
|
challengeStore.set(requestId, params);
|
|
return requestId;
|
|
}
|
|
|
|
export function getChallengeParams(requestId: unknown): ChallengeParams | undefined {
|
|
if (typeof requestId !== 'string') {
|
|
throw new Error('Request ID must be a string');
|
|
}
|
|
if (requestId.length > ABSOLUTE_MAX_REQUEST_ID_LENGTH) {
|
|
throw new Error(`Request ID exceeds maximum length of ${ABSOLUTE_MAX_REQUEST_ID_LENGTH}`);
|
|
}
|
|
if (requestId.length !== 32) { // Expected length for hex-encoded 16 bytes
|
|
throw new Error('Invalid request ID format');
|
|
}
|
|
if (!/^[0-9a-fA-F]+$/.test(requestId)) {
|
|
throw new Error('Request ID must be hexadecimal');
|
|
}
|
|
|
|
return challengeStore.get(requestId);
|
|
}
|
|
|
|
export function deleteChallenge(requestId: unknown): boolean {
|
|
if (typeof requestId !== 'string') {
|
|
throw new Error('Request ID must be a string');
|
|
}
|
|
|
|
return challengeStore.delete(requestId);
|
|
}
|
|
|
|
export function verifyPoS(
|
|
hashes: unknown,
|
|
times: unknown,
|
|
checkpointConfig: unknown
|
|
): void {
|
|
// Validate all user inputs with zero trust
|
|
const validatedHashes = validateHashesArray(hashes, 'hashes');
|
|
const validatedTimes = validateTimesArray(times, 'times');
|
|
const validatedConfig = validateCheckpointConfig(checkpointConfig);
|
|
|
|
// Verify hash consistency - all must match
|
|
const firstHash = validatedHashes[0];
|
|
for (let i = 1; i < validatedHashes.length; i++) {
|
|
if (validatedHashes[i] !== firstHash) {
|
|
throw new Error('PoS hashes do not match');
|
|
}
|
|
}
|
|
|
|
// Validate timing consistency
|
|
checkPoSTimes(validatedTimes, validatedConfig.CheckPoSTimes, validatedConfig.PoSTimeConsistencyRatio);
|
|
}
|
|
|
|
// Export for testing
|
|
export {
|
|
calculateHash,
|
|
generateChallenge
|
|
};
|