Checkpoint/src/utils/proof.ts

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