diff --git a/src/app/page.tsx b/src/app/page.tsx index a036721a..2a5e70f6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,6 +9,7 @@ import { structuredLogger } from "@/utils/structuredLogger"; import { errorMonitoring } from "@/utils/errorMonitoringService"; import { ErrorCategory, ErrorSeverity } from "@/types/errors"; import { logger } from "@/utils/logger"; +import { generateErrorId } from "@/utils/secureId"; import { WalletConnector } from "@/components/WalletConnector"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { @@ -48,7 +49,7 @@ function HomeContent() { error.stack = event.error?.stack; const appError = { - id: `error_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + id: generateErrorId(), category: ErrorCategory.UI, severity: ErrorSeverity.HIGH, message: event.message, @@ -71,7 +72,7 @@ function HomeContent() { const error = new Error(event.reason?.message || 'Unhandled promise rejection'); const appError = { - id: `error_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + id: generateErrorId(), category: ErrorCategory.NETWORK, severity: ErrorSeverity.MEDIUM, message: error.message, diff --git a/src/components/DomainWarningBanner.tsx b/src/components/DomainWarningBanner.tsx index c88a669c..659c9376 100644 --- a/src/components/DomainWarningBanner.tsx +++ b/src/components/DomainWarningBanner.tsx @@ -1,129 +1,133 @@ -"use client"; - -import React, { useEffect, useState } from 'react'; -import { AlertTriangle, ShieldAlert, X } from 'lucide-react'; -import { PhishingProtection } from '@/utils/security/phishingProtection'; -import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -export const DomainWarningBanner = () => { - const [warning, setWarning] = useState<{ - show: boolean; - type: 'phishing' | 'unofficial'; - message: string; - riskScore: number; - }>({ - show: false, - type: 'unofficial', - message: '', - riskScore: 0, - }); - - const [isDismissed, setIsDismissed] = useState(false); +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { AlertTriangle, Shield, ExternalLink } from 'lucide-react'; + +const TRUSTED_DOMAINS = new Set([ + 'propchain.io', + 'app.propchain.io', + 'localhost', + '127.0.0.1', +]); + +const OFFICIAL_URL = 'https://propchain.io'; + +interface DomainWarningBannerProps { + className?: string; +} + +/** + * DomainWarningBanner + * + * Renders a non-navigating warning panel when the current host is not a + * trusted domain. The user must explicitly click "Proceed only if you trust + * this site" to dismiss the banner. Includes an aria-live region for screen + * reader announcements. + * + * NEVER auto-redirects. All navigation is gated behind explicit user consent. + */ +export const DomainWarningBanner: React.FC = ({ className = '' }) => { + const [isSuspicious, setIsSuspicious] = useState(false); + const [dismissed, setDismissed] = useState(false); + const [hostname, setHostname] = useState(''); + const [announcement, setAnnouncement] = useState(''); useEffect(() => { - const checkDomain = async () => { - if (typeof window === 'undefined') return; - - const url = window.location.href; - const domain = window.location.hostname; - const result = PhishingProtection.detectPhishing(url); - - if (result.isPhishing) { - setWarning({ - show: true, - type: 'phishing', - message: 'This domain is flagged as a known phishing site. Your funds may be at risk.', - riskScore: result.riskScore, - }); - // Auto-report phishing domains - PhishingProtection.reportSuspiciousDomain(domain, 'Known phishing domain'); - } else if (result.warnings.includes('Unofficial domain detected')) { - setWarning({ - show: true, - type: 'unofficial', - message: 'You are accessing PropChain from an unofficial domain. Please ensure you are on propchain.io.', - riskScore: result.riskScore, - }); - // Report unofficial domains for investigation - PhishingProtection.reportSuspiciousDomain(domain, 'Unofficial domain'); - } - }; - - checkDomain(); + if (typeof window === 'undefined') return; + + const host = window.location.hostname; + const isTrusted = + TRUSTED_DOMAINS.has(host) || + host.endsWith('.propchain.io') || + host.endsWith('.vercel.app') || + host.endsWith('.netlify.app'); + + if (!isTrusted) { + setHostname(host); + setIsSuspicious(true); + setAnnouncement( + `Warning: You are viewing PropChain on an untrusted domain: ${host}. Please verify the site is legitimate before proceeding.` + ); + } + }, []); + + const handleDismiss = useCallback(() => { + setDismissed(true); + setAnnouncement('Domain warning dismissed by user. Proceeding on untrusted domain.'); }, []); - if (!warning.show || isDismissed) return null; + const handleGoToOfficial = useCallback(() => { + setAnnouncement('Navigating to official PropChain website.'); + window.location.href = OFFICIAL_URL; + }, []); - const isPhishing = warning.type === 'phishing'; + if (!isSuspicious || dismissed) return null; return ( -
-
- - {isPhishing ? ( - - ) : ( - - )} -
-
- - {isPhishing ? "Security Alert: Phishing Detected" : "Security Warning: Unofficial Domain"} - - - {warning.message} - + <> + {/* Screen-reader live region for announcements */} +
+ {announcement} +
+ +
+
+
+ {/* Warning message */} +
+
-
- - {!isPhishing && ( - - )} + + + +
- - +
-
+ ); }; + +export default DomainWarningBanner; diff --git a/src/components/TransactionProgress.tsx b/src/components/TransactionProgress.tsx index 525a48a6..0339355a 100644 --- a/src/components/TransactionProgress.tsx +++ b/src/components/TransactionProgress.tsx @@ -8,6 +8,12 @@ import { Progress } from '@/components/ui/progress'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Web3Tooltip } from '@/components/ui/Web3Tooltip'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; interface TransactionStep { id: string; @@ -202,60 +208,30 @@ export const TransactionProgress: React.FC = memo(({ if (!isOpen) return null; return ( - - - e.stopPropagation()} - role="dialog" - aria-modal="true" - aria-labelledby="transaction-progress-title" - aria-describedby="transaction-progress-description" - > - - - {/* Header */} -
-
-

- Transaction in Progress -

- {transactionHash && ( -

- Transaction hash: {transactionHash.slice(0, 10)}...{transactionHash.slice(-8)} -

- )} -
- -
- - {/* Overall Progress */} + { if (!open) onClose(); }}> + + + + Transaction in Progress + + + {transactionHash && ( +

+ {transactionHash.slice(0, 10)}...{transactionHash.slice(-8)} +

+ )} +
+ + + + {/* Overall Progress */}
Overall Progress @@ -372,13 +348,12 @@ export const TransactionProgress: React.FC = memo(({ {steps[steps.length - 1].status === 'completed' ? 'Close' : 'Processing...'}
- - - - - + + + +
); -}; +}); // Hook to use transaction progress export const useTransactionProgress = () => { diff --git a/src/components/WalletModal.tsx b/src/components/WalletModal.tsx index bbe9f1df..f330d1ca 100644 --- a/src/components/WalletModal.tsx +++ b/src/components/WalletModal.tsx @@ -7,8 +7,12 @@ import { toChainId } from '@/config/chains'; import { useSecurity } from '@/hooks/useSecurity'; import { useWalletConnector } from '@/hooks/useWalletConnector'; import { AlertTriangle, Shield, X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { ModalTransition } from './PageTransition'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; interface WalletModalProps { isOpen: boolean; @@ -236,35 +240,14 @@ export const WalletModal: React.FC = ({ isOpen, onClose }) => return 0; }); - if (!isOpen) return null; - return ( - - {isOpen && ( -
- - - -
-

- Connect Wallet -

- -
+ { if (!open) onClose(); }}> + + + Connect Wallet + -
+
{renderLoadingStep()} {renderSecurityStatus()} @@ -368,9 +351,8 @@ export const WalletModal: React.FC = ({ isOpen, onClose }) =>
-
- -
- +
+ + ); }; diff --git a/src/components/dashboard/PropertyCard.tsx b/src/components/dashboard/PropertyCard.tsx index 3f6e04aa..f563076f 100644 --- a/src/components/dashboard/PropertyCard.tsx +++ b/src/components/dashboard/PropertyCard.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { useTransaction } from "@/hooks/useTransaction"; import { GasEstimator } from "@/components/GasEstimator"; import { useState } from "react"; +import { generateMockTxHash } from "@/utils/secureId"; interface Property { id: string; @@ -32,7 +33,7 @@ export const PropertyCard = ({ property, index }: PropertyCardProps) => { const handlePurchase = () => { // Simulate a transaction hash for demo purposes - const mockTxHash = `0x${Math.random().toString(16).substr(2, 64)}`; + const mockTxHash = generateMockTxHash(); addTransactionToQueue({ hash: mockTxHash, diff --git a/src/hooks/useRewardDistribution.ts b/src/hooks/useRewardDistribution.ts index 02f65078..da988907 100644 --- a/src/hooks/useRewardDistribution.ts +++ b/src/hooks/useRewardDistribution.ts @@ -3,10 +3,11 @@ * Handles smart contract interactions for distributing referral rewards */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'; import { parseUnits } from 'viem'; import { useReferralStore } from '@/store/referralStore'; +import { generateMockTxHash } from '@/utils/secureId'; /** * Reward distribution hook interface @@ -198,7 +199,7 @@ export function useClaimRewards() { // In a real scenario, this would call a smart contract // For now, we'll simulate the transaction - const mockTxHash = `0x${Math.random().toString(16).slice(2)}`; + const mockTxHash = generateMockTxHash(); return { transactionHash: mockTxHash, @@ -302,6 +303,3 @@ export function useRewardDistributionValidator() { return { validateRewardClaim }; } - -// Import useEffect for the status tracking hook -import { useEffect } from 'react'; diff --git a/src/hooks/useSearchHistory.ts b/src/hooks/useSearchHistory.ts index 6806c9be..e6c19a72 100644 --- a/src/hooks/useSearchHistory.ts +++ b/src/hooks/useSearchHistory.ts @@ -2,6 +2,8 @@ import { logger } from '@/utils/logger'; import { useState, useEffect } from 'react'; +import { generateSecureId } from '@/utils/secureId'; +import { safeLocalStorage } from '@/utils/safeLocalStorage'; interface SearchHistoryItem { id: string; @@ -18,16 +20,9 @@ export const useSearchHistory = () => { // Load search history from localStorage on mount useEffect(() => { - try { - const saved = localStorage.getItem(SEARCH_HISTORY_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (Array.isArray(parsed)) { - setSearchHistory(parsed); - } - } - } catch (error) { - logger.error('Error loading search history:', error); + const saved = safeLocalStorage.getJSON(SEARCH_HISTORY_KEY, []); + if (Array.isArray(saved)) { + setSearchHistory(saved); } }, []); @@ -35,7 +30,7 @@ export const useSearchHistory = () => { if (!query.trim()) return; const newItem: SearchHistoryItem = { - id: `${Date.now()}-${Math.random()}`, + id: generateSecureId('search'), query: query.trim(), timestamp: new Date().toISOString(), type, @@ -52,11 +47,7 @@ export const useSearchHistory = () => { const limited = updated.slice(0, MAX_HISTORY_ITEMS); // Save to localStorage - try { - localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(limited)); - } catch (error) { - logger.error('Error saving search history:', error); - } + safeLocalStorage.setJSON(SEARCH_HISTORY_KEY, limited); return limited; }); @@ -65,22 +56,14 @@ export const useSearchHistory = () => { const removeFromHistory = (id: string) => { setSearchHistory(prev => { const updated = prev.filter(item => item.id !== id); - try { - localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated)); - } catch (error) { - logger.error('Error updating search history:', error); - } + safeLocalStorage.setJSON(SEARCH_HISTORY_KEY, updated); return updated; }); }; const clearHistory = () => { setSearchHistory([]); - try { - localStorage.removeItem(SEARCH_HISTORY_KEY); - } catch (error) { - logger.error('Error clearing search history:', error); - } + safeLocalStorage.remove(SEARCH_HISTORY_KEY); }; const getRecentSearches = (limit: number = 5) => { diff --git a/src/lib/batchTransaction.ts b/src/lib/batchTransaction.ts index 9b66376d..1ae7cee2 100644 --- a/src/lib/batchTransaction.ts +++ b/src/lib/batchTransaction.ts @@ -1,6 +1,7 @@ import type { CartItem } from '@/types/cart'; import type { BatchTransactionResult } from '@/types/cart'; import { logger } from '@/utils/logger'; +import { generateMockTxHash } from '@/utils/secureId'; // Mock multicall implementation - in production, this would use actual smart contracts export class BatchTransactionService { @@ -33,9 +34,8 @@ export class BatchTransactionService { // Simulate blockchain transaction delay await new Promise(resolve => setTimeout(resolve, 2000)); - // Generate mock transaction hash - const transactionHash = `0x${Array.from({length: 64}, () => - Math.floor(Math.random() * 16).toString(16)).join('')}`; + // Generate mock transaction hash using secure random values + const transactionHash = generateMockTxHash(); // Simulate individual transaction results const results = items.map(item => ({ diff --git a/src/lib/cacheManager.ts b/src/lib/cacheManager.ts index 7b712f8f..2b632467 100644 --- a/src/lib/cacheManager.ts +++ b/src/lib/cacheManager.ts @@ -26,6 +26,8 @@ import { isCacheAvailable, initPropertyCache, } from './propertyCache'; +import { generateSecureId } from '@/utils/secureId'; +import { safeLocalStorage } from '@/utils/safeLocalStorage'; // Version migration handlers type VersionMigration = (data: unknown) => unknown; @@ -69,10 +71,7 @@ export const initCacheManager = async (): Promise => { // Load last sync time if (typeof window !== 'undefined') { - const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.LAST_SYNC); - if (stored) { - lastSyncTime = parseInt(stored, 10); - } + lastSyncTime = safeLocalStorage.getJSON(LOCAL_STORAGE_KEYS.LAST_SYNC, 0); } // Perform initial sync if online @@ -159,10 +158,7 @@ export const performBackgroundSync = async (): Promise => { // Update last sync time lastSyncTime = Date.now(); if (typeof window !== 'undefined') { - localStorage.setItem( - LOCAL_STORAGE_KEYS.LAST_SYNC, - lastSyncTime.toString() - ); + safeLocalStorage.setJSON(LOCAL_STORAGE_KEYS.LAST_SYNC, lastSyncTime); } logger.info('Background sync completed'); @@ -182,10 +178,7 @@ const processSyncQueue = async (): Promise => { if (typeof window === 'undefined') return; try { - const queueJson = localStorage.getItem(LOCAL_STORAGE_KEYS.SYNC_QUEUE); - if (!queueJson) return; - - const queue: SyncQueueItem[] = JSON.parse(queueJson); + const queue = safeLocalStorage.getJSON(LOCAL_STORAGE_KEYS.SYNC_QUEUE, []); if (queue.length === 0) return; logger.info(`Processing ${queue.length} sync queue items`); @@ -217,10 +210,7 @@ const processSyncQueue = async (): Promise => { (item) => !processedIds.includes(item.id) || failedItems.some((f) => f.id === item.id) ); - localStorage.setItem( - LOCAL_STORAGE_KEYS.SYNC_QUEUE, - JSON.stringify(remainingQueue) - ); + safeLocalStorage.setJSON(LOCAL_STORAGE_KEYS.SYNC_QUEUE, remainingQueue); logger.info(`Sync queue processed: ${processedIds.length} succeeded, ${failedItems.length} failed`); } catch (error) { @@ -261,12 +251,10 @@ export const addToSyncQueue = ( if (typeof window === 'undefined') return; try { - const queueJson = localStorage.getItem(LOCAL_STORAGE_KEYS.SYNC_QUEUE); - const queue: SyncQueueItem[] = queueJson ? JSON.parse(queueJson) : []; + const queue = safeLocalStorage.getJSON(LOCAL_STORAGE_KEYS.SYNC_QUEUE, []); const newItem: SyncQueueItem = { - // Combine timestamp with random base-36 string for a unique, sortable ID - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + id: generateSecureId('sync'), type, payload, timestamp: Date.now(), @@ -274,7 +262,7 @@ export const addToSyncQueue = ( }; queue.push(newItem); - localStorage.setItem(LOCAL_STORAGE_KEYS.SYNC_QUEUE, JSON.stringify(queue)); + safeLocalStorage.setJSON(LOCAL_STORAGE_KEYS.SYNC_QUEUE, queue); logger.info(`Added item to sync queue: ${newItem.id}`); } catch (error) { @@ -289,9 +277,7 @@ export const getSyncQueueLength = (): number => { if (typeof window === 'undefined') return 0; try { - const queueJson = localStorage.getItem(LOCAL_STORAGE_KEYS.SYNC_QUEUE); - if (!queueJson) return 0; - const queue: SyncQueueItem[] = JSON.parse(queueJson); + const queue = safeLocalStorage.getJSON(LOCAL_STORAGE_KEYS.SYNC_QUEUE, []); return queue.length; } catch { return 0; @@ -303,7 +289,7 @@ export const getSyncQueueLength = (): number => { */ export const clearSyncQueue = (): void => { if (typeof window === 'undefined') return; - localStorage.removeItem(LOCAL_STORAGE_KEYS.SYNC_QUEUE); + safeLocalStorage.remove(LOCAL_STORAGE_KEYS.SYNC_QUEUE); logger.info('Sync queue cleared'); }; diff --git a/src/lib/kyc.ts b/src/lib/kyc.ts index ed9e4386..73a12bae 100644 --- a/src/lib/kyc.ts +++ b/src/lib/kyc.ts @@ -1,3 +1,5 @@ +import { generateTimestampedId } from '@/utils/secureId'; + export const DEFAULT_KYC_THRESHOLD_ETH = 10; const WEI_IN_ETH = BigInt('1000000000000000000'); @@ -23,5 +25,5 @@ export function shouldRequireKyc(valueWei: string | undefined, thresholdEth: num } export function createComplianceId(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + return generateTimestampedId(prefix); } diff --git a/src/lib/notificationService.ts b/src/lib/notificationService.ts index baf18d15..a2b06f3f 100644 --- a/src/lib/notificationService.ts +++ b/src/lib/notificationService.ts @@ -1,6 +1,7 @@ import { logger } from '@/utils/logger'; import type { SavedSearch, Property, PropertyAlert, NotificationFrequency } from '@/types/property'; import { propertyService } from './propertyService'; +import { generateSecureId } from '@/utils/secureId'; /** * Notification Service @@ -179,7 +180,7 @@ class NotificationService { * Generate unique ID */ private generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + return generateSecureId(); } /** diff --git a/src/lib/offlineTransactionQueue.ts b/src/lib/offlineTransactionQueue.ts index e2b2aea7..dbdaac04 100644 --- a/src/lib/offlineTransactionQueue.ts +++ b/src/lib/offlineTransactionQueue.ts @@ -8,6 +8,7 @@ */ import { logger } from "@/utils/logger"; +import { generateSecureId } from '@/utils/secureId'; export type QueuedTransactionStatus = "pending" | "retrying" | "failed"; @@ -104,7 +105,7 @@ export const enqueueTransaction = async ( > & { id?: string; maxAttempts?: number } ): Promise => { const item: QueuedTransaction = { - id: input.id ?? `tx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: input.id ?? generateSecureId('tx'), type: input.type, payload: input.payload, description: input.description, diff --git a/src/lib/propertyCache.ts b/src/lib/propertyCache.ts index 8a07cfaf..03155ccc 100644 --- a/src/lib/propertyCache.ts +++ b/src/lib/propertyCache.ts @@ -39,6 +39,7 @@ import { getCacheEntryStatus, calculateEntrySize, } from '@/types/cache'; +import { safeLocalStorage } from '@/utils/safeLocalStorage'; // Event listeners const eventListeners: Set = new Set(); @@ -128,16 +129,8 @@ const updateMetadataOnAccess = (metadata: CacheMetadata): CacheMetadata => ({ export const getCacheConfig = (): CacheConfig => { if (typeof window === 'undefined') return DEFAULT_CACHE_CONFIG; - try { - const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.CACHE_CONFIG); - if (stored) { - const parsed = JSON.parse(stored); - return { ...DEFAULT_CACHE_CONFIG, ...parsed }; - } - } catch (error) { - logger.error('Error reading cache config:', error); - } - return DEFAULT_CACHE_CONFIG; + const stored = safeLocalStorage.getJSON(LOCAL_STORAGE_KEYS.CACHE_CONFIG, DEFAULT_CACHE_CONFIG); + return { ...DEFAULT_CACHE_CONFIG, ...stored }; }; /** @@ -146,13 +139,9 @@ export const getCacheConfig = (): CacheConfig => { export const setCacheConfig = (config: Partial): void => { if (typeof window === 'undefined') return; - try { - const current = getCacheConfig(); - const updated = { ...current, ...config }; - localStorage.setItem(LOCAL_STORAGE_KEYS.CACHE_CONFIG, JSON.stringify(updated)); - } catch (error) { - logger.error('Error saving cache config:', error); - } + const current = getCacheConfig(); + const updated = { ...current, ...config }; + safeLocalStorage.setJSON(LOCAL_STORAGE_KEYS.CACHE_CONFIG, updated); }; /** @@ -509,11 +498,9 @@ export const cacheSearchResult = async ( }; if (typeof window !== 'undefined') { - const searches = JSON.parse( - localStorage.getItem('propchain-search-cache') || '{}' - ); + const searches = safeLocalStorage.getJSON>('propchain-search-cache', {}); searches[key] = searchCache; - localStorage.setItem('propchain-search-cache', JSON.stringify(searches)); + safeLocalStorage.setJSON('propchain-search-cache', searches); } emitEvent({ type: 'set', key, timestamp: Date.now() }); @@ -535,9 +522,7 @@ export const getCachedSearchResult = async ( try { if (typeof window === 'undefined') return null; - const searches = JSON.parse( - localStorage.getItem('propchain-search-cache') || '{}' - ); + const searches = safeLocalStorage.getJSON>('propchain-search-cache', {}); const cached = searches[key]; if (!cached) { @@ -550,7 +535,7 @@ export const getCachedSearchResult = async ( if (Date.now() - cached.cachedAt > config.ttl) { cacheMisses++; delete searches[key]; - localStorage.setItem('propchain-search-cache', JSON.stringify(searches)); + safeLocalStorage.setJSON('propchain-search-cache', searches); return null; } @@ -642,7 +627,7 @@ export const clearAllCachedProperties = async (): Promise => { await dbClear(CACHE_STORE_NAMES.MOBILE_PROPERTIES); if (typeof window !== 'undefined') { - localStorage.removeItem('propchain-search-cache'); + safeLocalStorage.remove('propchain-search-cache'); } cacheHits = 0; @@ -722,10 +707,7 @@ export const updateCacheStats = async (): Promise => { // Persist stats if (typeof window !== 'undefined') { - localStorage.setItem( - LOCAL_STORAGE_KEYS.CACHE_STATS, - JSON.stringify(cacheStats) - ); + safeLocalStorage.setJSON(LOCAL_STORAGE_KEYS.CACHE_STATS, cacheStats); } return cacheStats; @@ -809,7 +791,7 @@ export const cleanupExpiredEntries = async (): Promise => { } if (modified) { - localStorage.setItem('propchain-search-cache', JSON.stringify(searches)); + safeLocalStorage.setJSON('propchain-search-cache', searches); } } @@ -842,10 +824,8 @@ export const initPropertyCache = async (): Promise => { try { // Load cached stats if (typeof window !== 'undefined') { - const storedStats = localStorage.getItem(LOCAL_STORAGE_KEYS.CACHE_STATS); - if (storedStats) { - cacheStats = { ...cacheStats, ...JSON.parse(storedStats) }; - } + const storedStats = safeLocalStorage.getJSON>(LOCAL_STORAGE_KEYS.CACHE_STATS, {}); + cacheStats = { ...cacheStats, ...storedStats }; } // Clean up expired entries on init diff --git a/src/lib/propertyService.ts b/src/lib/propertyService.ts index 805aef94..d5067869 100644 --- a/src/lib/propertyService.ts +++ b/src/lib/propertyService.ts @@ -27,7 +27,7 @@ import { cacheSearchResult, } from './propertyCache'; import { isNetworkOnline } from './cacheManager'; -import { redisCacheService } from './redisCache'; +import { generateSecureId } from '@/utils/secureId'; /** * Property Service @@ -460,7 +460,7 @@ class PropertyService { * Generate unique ID */ private generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + return generateSecureId(); } } diff --git a/src/lib/secondaryMarketService.ts b/src/lib/secondaryMarketService.ts index 8de85a65..9bcfd12c 100644 --- a/src/lib/secondaryMarketService.ts +++ b/src/lib/secondaryMarketService.ts @@ -1,4 +1,5 @@ import type { SecondaryMarketListing, OrderBookEntry, BlockchainNetwork } from '@/types/property'; +import { generateSecureId, generateMockTxHash } from '@/utils/secureId'; /** * Secondary Market Service @@ -82,7 +83,7 @@ export const secondaryMarketService = { const newListing: SecondaryMarketListing = { ...data, - id: `sec-${Math.random().toString(36).substr(2, 9)}`, + id: generateSecureId('sec'), listedDate: new Date().toISOString(), }; @@ -97,7 +98,7 @@ export const secondaryMarketService = { await new Promise(resolve => setTimeout(resolve, 2000)); return { - transactionHash: `0x${Math.random().toString(16).substr(2, 64)}` + transactionHash: generateMockTxHash() }; } }; diff --git a/src/store/comparisonHistoryStore.ts b/src/store/comparisonHistoryStore.ts index b1268575..d18cc8e3 100644 --- a/src/store/comparisonHistoryStore.ts +++ b/src/store/comparisonHistoryStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { generateSecureId } from '@/utils/secureId'; export interface ComparisonHistory { id: string; @@ -28,7 +29,7 @@ export const useComparisonHistoryStore = create()( addComparison: (propertyIds: string[]) => { if (propertyIds.length === 0) return; - const id = `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + const id = generateSecureId('comp'); const shareUrl = `/compare?ids=${propertyIds.join(',')}`; const newComparison: ComparisonHistory = { diff --git a/src/store/paperTradingStore.ts b/src/store/paperTradingStore.ts index e0194fb3..75317926 100644 --- a/src/store/paperTradingStore.ts +++ b/src/store/paperTradingStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { generateSecureId } from '@/utils/secureId'; export interface PaperPosition { propertyId: string; @@ -80,7 +81,7 @@ export const usePaperTradingStore = create()( } const tx: PaperTransaction = { - id: `pt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + id: generateSecureId('pt'), type: 'buy', propertyId, propertyName, @@ -127,7 +128,7 @@ export const usePaperTradingStore = create()( const proceeds = tokens * pricePerToken; const tx: PaperTransaction = { - id: `pt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + id: generateSecureId('pt'), type: 'sell', propertyId, propertyName: position.propertyName, diff --git a/src/utils/errorMonitoringService.ts b/src/utils/errorMonitoringService.ts index 422ed056..63663832 100644 --- a/src/utils/errorMonitoringService.ts +++ b/src/utils/errorMonitoringService.ts @@ -4,6 +4,7 @@ import { structuredLogger } from './structuredLogger'; import { errorReporting } from './errorReporting'; import { logger } from './logger'; import { ErrorCategory, ErrorSeverity, type AppError } from '@/types/errors'; +import { generateAlertId } from './secureId'; // ============================================================================ // Error Monitoring Service @@ -165,7 +166,7 @@ class ErrorMonitoringService { private createAlert(error: AppError): void { const alert: ErrorAlert = { - id: `alert_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + id: generateAlertId(), error, timestamp: new Date(), severity: error.severity, diff --git a/src/utils/errorReporting.ts b/src/utils/errorReporting.ts index e9b58e17..f1b85405 100644 --- a/src/utils/errorReporting.ts +++ b/src/utils/errorReporting.ts @@ -1,5 +1,6 @@ import { type AppError, ErrorCategory, ErrorSeverity, type ErrorReportingData, type ErrorMetrics } from '@/types/errors'; import { logger } from './logger'; +import { generateSessionId } from './secureId'; class ErrorReportingService { private static instance: ErrorReportingService; @@ -27,7 +28,7 @@ class ErrorReportingService { } private generateSessionId(): string { - return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + return generateSessionId(); } private initializeMetrics(): void { diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 25b79d14..d45d4622 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,6 +1,7 @@ 'use client'; import { getErrorMessage } from './typeGuards'; +import { generateCorrelationId, generateChildId } from './secureId'; // ============================================================================ // Log Levels @@ -102,9 +103,6 @@ let globalConfig = getDefaultConfig(); // Correlation ID // ============================================================================ -const generateCorrelationId = (): string => - `corr-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 15)}`; - class CorrelationIdManager { private static instance: CorrelationIdManager; private id: string = generateCorrelationId(); @@ -116,11 +114,27 @@ class CorrelationIdManager { return CorrelationIdManager.instance; } - getId(): string { return this.id; } - setId(id: string): void { this.id = id; } - reset(): void { this.id = generateCorrelationId(); } - fork(): string { return generateCorrelationId(); } - createChild(): string { return `${this.id}-${Math.random().toString(36).substring(2, 8)}`; } + getId(): string { + return this.correlationId; + } + + setId(id: string): void { + this.correlationStack.push(this.correlationId); + this.correlationId = id; + } + + reset(): void { + this.correlationId = generateCorrelationId(); + } + + createChild(): string { + return generateChildId(this.correlationId); + } + + // For async operations - returns a new correlation ID + fork(): string { + return generateCorrelationId(); + } } // ============================================================================ diff --git a/src/utils/safeLocalStorage.ts b/src/utils/safeLocalStorage.ts new file mode 100644 index 00000000..0fad37bb --- /dev/null +++ b/src/utils/safeLocalStorage.ts @@ -0,0 +1,312 @@ +/** + * Safe LocalStorage Utility + * + * Centralized localStorage wrapper that safely handles: + * - BigInt values (converts to string representation) + * - Circular references (detects and breaks cycles) + * - Undefined values (converts to null) + * - Functions (strips them out) + * - Special types (Date, RegExp, Map, Set) + * + * All writes are wrapped in try/catch with structured logging on failure. + */ + +import { logger } from './logger'; + +// ============================================================================ +// Type definitions +// ============================================================================ + +/** Shape of the search cache used in propertyCache */ +export interface SearchCacheEntry { + key: string; + propertyIds: string[]; + total: number; + page: number; + totalPages: number; + cachedAt: number; +} + +/** Shape of sync queue items in cacheManager */ +export interface SyncQueueItem { + id: string; + type: 'property-update' | 'property-delete' | 'search-update'; + payload: unknown; + timestamp: number; + retries: number; +} + +// ============================================================================ +// Serialization helpers +// ============================================================================ + +/** + * JSON replacer that handles BigInt, Date, RegExp, Map, Set, and circular references. + */ +function safeReplacer(): (key: string, value: unknown) => unknown { + const seen = new WeakSet(); + + return (_key: string, value: unknown): unknown => { + // Handle BigInt + if (typeof value === 'bigint') { + return { __type: 'BigInt', __value: value.toString() }; + } + + // Handle Date + if (value instanceof Date) { + return { __type: 'Date', __value: value.toISOString() }; + } + + // Handle RegExp + if (value instanceof RegExp) { + return { __type: 'RegExp', __value: value.toString() }; + } + + // Handle Map + if (value instanceof Map) { + return { + __type: 'Map', + __value: Array.from(value.entries()), + }; + } + + // Handle Set + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value.values()), + }; + } + + // Handle circular references + if (typeof value === 'object' && value !== null) { + if (seen.has(value as object)) { + return { __type: 'CircularRef', __value: '[Circular]' }; + } + seen.add(value as object); + } + + // Handle undefined + if (value === undefined) { + return null; + } + + // Handle functions (strip them out) + if (typeof value === 'function') { + return undefined; + } + + return value; + }; +} + +/** + * JSON reviver that restores BigInt, Date, RegExp, Map, and Set. + */ +function safeReviver(_key: string, value: unknown): unknown { + if (value && typeof value === 'object' && '__type' in value) { + const typed = value as { __type: string; __value: unknown }; + + switch (typed.__type) { + case 'BigInt': + return BigInt(typed.__value as string); + case 'Date': + return new Date(typed.__value as string); + case 'RegExp': { + const str = typed.__value as string; + const match = str.match(/^\/(.+)\/([gimsuy]*)$/); + if (match) { + return new RegExp(match[1], match[2]); + } + return new RegExp(str); + } + case 'Map': + return new Map(typed.__value as [unknown, unknown][]); + case 'Set': + return new Set(typed.__value as unknown[]); + case 'CircularRef': + return '[Circular Reference]'; + default: + return typed.__value; + } + } + return value; +} + +// ============================================================================ +// Safe serialization +// ============================================================================ + +/** + * Safely stringify a value, handling BigInt, circular references, and other + * non-JSON-safe types. Never throws - returns null on failure. + * + * @param value - The value to stringify + * @param space - Optional indentation for pretty-printing + * @returns JSON string or null on failure + */ +export function safeStringify(value: unknown, space?: number): string | null { + try { + return JSON.stringify(value, safeReplacer(), space); + } catch (error) { + logger.error('safeStringify failed', { + metadata: { + error: error instanceof Error ? error.message : String(error), + valueType: typeof value, + }, + }); + return null; + } +} + +/** + * Safely parse a JSON string, restoring special types. + * Never throws - returns null on failure. + * + * @param json - The JSON string to parse + * @returns Parsed value or null on failure + */ +export function safeParse(json: string): T | null { + try { + return JSON.parse(json, safeReviver) as T; + } catch (error) { + logger.error('safeParse failed', { + metadata: { + error: error instanceof Error ? error.message : String(error), + jsonPreview: json.slice(0, 100), + }, + }); + return null; + } +} + +// ============================================================================ +// Safe localStorage API +// ============================================================================ + +/** + * Safely set a value in localStorage with JSON serialization. + * Handles BigInt, circular refs, and non-serializable values automatically. + * Never throws. + * + * @param key - localStorage key + * @param value - Value to store + * @returns true if successful, false otherwise + */ +export function safeSetItem(key: string, value: unknown): boolean { + if (typeof window === 'undefined') return false; + + try { + const json = safeStringify(value); + if (json === null) return false; + localStorage.setItem(key, json); + return true; + } catch (error) { + logger.error(`Failed to write to localStorage key "${key}"`, { + metadata: { + error: error instanceof Error ? error.message : String(error), + key, + }, + }); + return false; + } +} + +/** + * Safely get and parse a value from localStorage. + * Handles type restoration (BigInt, Date, etc.). + * Never throws. + * + * @param key - localStorage key + * @param defaultValue - Fallback value if key doesn't exist or parsing fails + * @returns Parsed value or defaultValue + */ +export function safeGetItem(key: string, defaultValue: T | null = null): T | null { + if (typeof window === 'undefined') return defaultValue; + + try { + const raw = localStorage.getItem(key); + if (raw === null) return defaultValue; + + const parsed = safeParse(raw); + if (parsed === null) return defaultValue; + + return parsed; + } catch (error) { + logger.error(`Failed to read from localStorage key "${key}"`, { + metadata: { + error: error instanceof Error ? error.message : String(error), + key, + }, + }); + return defaultValue; + } +} + +/** + * Remove a key from localStorage. Never throws. + * + * @param key - localStorage key to remove + * @returns true if successful, false otherwise + */ +export function safeRemoveItem(key: string): boolean { + if (typeof window === 'undefined') return false; + + try { + localStorage.removeItem(key); + return true; + } catch (error) { + logger.error(`Failed to remove localStorage key "${key}"`, { + metadata: { + error: error instanceof Error ? error.message : String(error), + key, + }, + }); + return false; + } +} + +/** + * Check if a key exists in localStorage. + * + * @param key - localStorage key to check + * @returns true if the key exists + */ +export function safeHasItem(key: string): boolean { + if (typeof window === 'undefined') return false; + + try { + return localStorage.getItem(key) !== null; + } catch { + return false; + } +} + +// ============================================================================ +// Namespaced helpers for common app storage patterns +// ============================================================================ + +/** + * Cache config storage helper. + */ +export const safeLocalStorage = { + get: safeGetItem, + set: safeSetItem, + remove: safeRemoveItem, + has: safeHasItem, + + /** Get with type assertion */ + getJSON(key: string, defaultValue: T): T { + const result = safeGetItem(key); + return result !== null ? result : defaultValue; + }, + + /** Set object, logging key on failure */ + setJSON(key: string, value: unknown): boolean { + const result = safeSetItem(key, value); + if (!result) { + logger.warn(`safeLocalStorage.setJSON failed for key: ${key}`); + } + return result; + }, +}; diff --git a/src/utils/secureId.ts b/src/utils/secureId.ts new file mode 100644 index 00000000..9449817a --- /dev/null +++ b/src/utils/secureId.ts @@ -0,0 +1,123 @@ +/** + * Secure ID Generation Utility + * + * Replaces all Math.random-based ID/hash generation with cryptographically + * secure alternatives. Uses crypto.randomUUID() when available (all modern + * browsers), falling back to crypto.getRandomValues(). + */ + +/** + * Generate a cryptographically secure random ID string. + * Uses crypto.randomUUID() when available, falls back to getRandomValues. + * + * @param prefix - Optional prefix for the ID + * @param length - Length of the random part when using fallback (default: 16) + * @returns A secure random ID string + */ +export function generateSecureId(prefix?: string, length = 16): string { + // Try crypto.randomUUID() first (available in all modern browsers and Node 19+) + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + const uuid = crypto.randomUUID(); + return prefix ? `${prefix}_${uuid}` : uuid; + } + + // Fallback to crypto.getRandomValues() + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + const randomPart = Array.from(bytes, (byte) => + byte.toString(36).padStart(2, '0') + ).join(''); + return prefix ? `${prefix}_${randomPart}` : randomPart; + } + + // Ultimate fallback (should never be reached in practice, but prevents crash) + const randomPart = Date.now().toString(36); + return prefix ? `${prefix}_${randomPart}` : randomPart; +} + +/** + * Generate a timestamped secure ID with optional prefix. + * Format: `prefix_timestamp_randomPart` + * + * @param prefix - Prefix for the ID (e.g., 'error', 'tx', 'session') + * @returns A timestamped secure ID string + */ +export function generateTimestampedId(prefix: string): string { + const timestamp = Date.now().toString(36); + const randomPart = generateSecureRandomHex(8); + return `${prefix}_${timestamp}_${randomPart}`; +} + +/** + * Generate a secure random hex string of specified length. + * + * @param length - Number of hex characters (default: 16) + * @returns A hex string from crypto.getRandomValues() + */ +export function generateSecureRandomHex(length = 16): string { + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + const bytes = new Uint8Array(Math.ceil(length / 2)); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => + byte.toString(16).padStart(2, '0') + ).join('').slice(0, length); + } + + // Fallback + return Date.now().toString(36); +} + +/** + * Generate a secure session ID. + * Format: `sess_timestamp_randomPart` + */ +export function generateSessionId(): string { + return generateTimestampedId('sess'); +} + +/** + * Generate a secure correlation ID for logging. + * Format: `corr_timestamp_randomPart` + */ +export function generateCorrelationId(): string { + return generateTimestampedId('corr'); +} + +/** + * Generate a secure error ID. + * Format: `error_timestamp_randomPart` + */ +export function generateErrorId(): string { + return generateTimestampedId('error'); +} + +/** + * Generate a secure alert ID. + * Format: `alert_timestamp_randomPart` + */ +export function generateAlertId(): string { + return generateTimestampedId('alert'); +} + +/** + * Generate a secure transaction hash (for demo/mock purposes). + * Uses crypto.getRandomValues for unpredictability. + * + * @returns A 64-character hex string prefixed with 0x + */ +export function generateMockTxHash(): string { + const hex = generateSecureRandomHex(64); + return `0x${hex}`; +} + +/** + * Generate a secure child ID from a parent ID. + * + * @param parentId - The parent correlation ID + * @returns A child correlation ID + */ +export function generateChildId(parentId: string): string { + const suffix = generateSecureRandomHex(6); + return `${parentId}-${suffix}`; +} diff --git a/src/utils/security/auditLogger.ts b/src/utils/security/auditLogger.ts index af8c799d..9b8edf84 100644 --- a/src/utils/security/auditLogger.ts +++ b/src/utils/security/auditLogger.ts @@ -24,6 +24,8 @@ export interface SecurityAlert { walletAddress?: string; } +import { generateSecureId } from '@/utils/secureId'; + export class SecurityAuditLogger { private static instance: SecurityAuditLogger; private logs: AuditLogEntry[] = []; @@ -584,14 +586,14 @@ export class SecurityAuditLogger { * Generates a unique ID */ private generateId(): string { - return Math.random().toString(36).substr(2, 9) + Date.now().toString(36); + return generateSecureId(); } /** * Generates a session ID */ private generateSessionId(): string { - return 'session_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); + return generateSecureId('session'); } } diff --git a/src/utils/security/transactionMonitor.ts b/src/utils/security/transactionMonitor.ts index 2c819652..2219c27a 100644 --- a/src/utils/security/transactionMonitor.ts +++ b/src/utils/security/transactionMonitor.ts @@ -28,6 +28,8 @@ export interface TransactionMetrics { transactionFrequency: number; // transactions per hour } +import { generateSecureId } from '@/utils/secureId'; + export class TransactionMonitor { private static instance: TransactionMonitor; private transactionHistory: Map = new Map(); // wallet -> transactions @@ -515,7 +517,7 @@ export class TransactionMonitor { * Generates a unique ID */ private generateId(): string { - return Math.random().toString(36).substr(2, 9) + Date.now().toString(36); + return generateSecureId(); } } diff --git a/src/utils/structuredLogger.ts b/src/utils/structuredLogger.ts index 0e48cd9f..b4a9c1dd 100644 --- a/src/utils/structuredLogger.ts +++ b/src/utils/structuredLogger.ts @@ -28,6 +28,7 @@ import { logger, createLogger, LogLevel } from './logger'; import { errorReporting } from './errorReporting'; import { ErrorCategory, ErrorSeverity } from '@/types/errors'; import type { AppError } from '@/types/errors'; +import { generateSessionId, generateErrorId } from './secureId'; // ============================================================================ // Extended entry shape (superset of LogEntry) @@ -87,6 +88,10 @@ class StructuredLogger { this.startFlushTimer(); } + private generateSessionId(): string { + return generateSessionId(); + } + private startFlushTimer(): void { if (this.flushTimer) clearInterval(this.flushTimer); this.flushTimer = setInterval(() => this.flushLogs(), this.cfg.flushInterval); @@ -135,11 +140,11 @@ class StructuredLogger { private reportError(entry: StructuredLogEntry): void { if (!entry.error) return; const appError: AppError = { - id: `error_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - message: entry.error.message, - category: entry.category ?? ErrorCategory.UI, - severity: entry.severity ?? ErrorSeverity.MEDIUM, - timestamp: new Date(entry.timestamp), + id: generateErrorId(), + message: log.error.message, + category: log.category || ErrorCategory.UI, + severity: log.severity || ErrorSeverity.MEDIUM, + timestamp: new Date(log.timestamp), isRecoverable: false, shouldReport: true, context: {