diff --git a/.github/SECURITY-INSIGHTS.yml b/.github/SECURITY-INSIGHTS.yml new file mode 100644 index 00000000..50aff104 --- /dev/null +++ b/.github/SECURITY-INSIGHTS.yml @@ -0,0 +1,19 @@ +name: ChainForge Security Insights + +contact: + email: security@chainforge.app + disclosure: https://github.com/ChainForgee/ChainForge/security/advisories/new + +policy: + url: https://github.com/ChainForgee/ChainForge/blob/main/SECURITY.md + +supported_versions: + - version: latest + status: supported + - version: "1.0" + status: unsupported + +languages: + - rust + - typescript + - python diff --git a/README.md b/README.md index 922ec3d2..6fbb641d 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,12 @@ For component-specific contribution guidelines, refer to the README in each `app --- +## Security + +To report a vulnerability, email [security@chainforge.app](mailto:security@chainforge.app) or use [GitHub's private vulnerability reporting](https://github.com/ChainForgee/ChainForge/security/advisories/new). Please do not open public issues for security reports. See [SECURITY.md](SECURITY.md) for details. + +--- + ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/app/mobile/src/__tests__/BulkScannerScreen.test.tsx b/app/mobile/src/__tests__/BulkScannerScreen.test.tsx new file mode 100644 index 00000000..2068ed23 --- /dev/null +++ b/app/mobile/src/__tests__/BulkScannerScreen.test.tsx @@ -0,0 +1,123 @@ +import { parseQRCode } from '../screens/BulkScannerScreen'; + +describe('BulkScannerScreen QR parsing', () => { + it('extracts aidId from chainforge deep link', () => { + expect(parseQRCode('chainforge://package/aid-001')).toBe('aid-001'); + }); + + it('returns null for non-chainforge URLs', () => { + expect(parseQRCode('https://example.com/qr')).toBeNull(); + }); + + it('returns null for malformed input', () => { + expect(parseQRCode('')).toBeNull(); + expect(parseQRCode('chainforge://')).toBeNull(); + expect(parseQRCode('chainforge://package/')).toBeNull(); + }); +}); + +describe('BulkScannerScreen concurrency', () => { + const MAX_CONCURRENT = 4; + + function createMockQueue(): { + queue: string[]; + process: () => Promise; + } { + const state = { queue: [] as string[], processing: 0, completed: 0 }; + return { + queue: state.queue, + process: async () => { + state.queue.push('pending'); + }, + }; + } + + it('allows up to MAX_CONCURRENT in-flight scans', () => { + let inFlight = 0; + let maxObserved = 0; + + for (let i = 0; i < 50; i++) { + if (inFlight < MAX_CONCURRENT) { + inFlight++; + maxObserved = Math.max(maxObserved, inFlight); + // Simulate async completion + inFlight--; + } + } + + expect(maxObserved).toBe(MAX_CONCURRENT); + }); + + it('throughput with concurrency ≥ 2× sequential on 50 items', async () => { + const ITEM_COUNT = 50; + const SIMULATED_LATENCY_MS = 10; + + async function processSequential(ids: string[]): Promise { + const start = performance.now(); + for (const id of ids) { + await new Promise(r => setTimeout(r, SIMULATED_LATENCY_MS)); + } + return performance.now() - start; + } + + async function processConcurrent(ids: string[]): Promise { + const start = performance.now(); + const chunks: string[][] = []; + for (let i = 0; i < ids.length; i += MAX_CONCURRENT) { + chunks.push(ids.slice(i, i + MAX_CONCURRENT)); + } + for (const chunk of chunks) { + await Promise.all(chunk.map(() => new Promise(r => setTimeout(r, SIMULATED_LATENCY_MS)))); + } + return performance.now() - start; + } + + const ids = Array.from({ length: ITEM_COUNT }, (_, i) => `aid-${i}`); + + const sequentialMs = await processSequential(ids); + const concurrentMs = await processConcurrent(ids); + + const speedup = sequentialMs / concurrentMs; + expect(speedup).toBeGreaterThanOrEqual(2); + }); + + it('skips duplicate aidIds within dedup window', () => { + const seen = new Map(); + const DEDUP_TTL_MS = 5000; + const results: string[] = []; + + const scans = ['aid-1', 'aid-2', 'aid-1', 'aid-3', 'aid-2']; + + for (const aidId of scans) { + const now = Date.now(); + const lastSeen = seen.get(aidId); + if (lastSeen && now - lastSeen < DEDUP_TTL_MS) { + results.push('skipped'); + } else { + seen.set(aidId, now); + results.push('processed'); + } + } + + expect(results).toEqual(['processed', 'processed', 'skipped', 'processed', 'skipped']); + }); + + it('rate-limits scans within RATE_LIMIT_MS window', () => { + const RATE_LIMIT_MS = 300; + const seen = new Map(); + const results: string[] = []; + + const now = Date.now(); + seen.set('aid-1', now); + + // Immediate re-scan of same aidId + const elapsed = Date.now() - (seen.get('aid-1') ?? 0); + if (elapsed < RATE_LIMIT_MS) { + results.push('rate-limited'); + } else { + results.push('allowed'); + } + + expect(results).toEqual(['rate-limited']); + }); +}); diff --git a/app/mobile/src/screens/BulkScannerScreen.tsx b/app/mobile/src/screens/BulkScannerScreen.tsx index b7177ec3..906a9d59 100644 --- a/app/mobile/src/screens/BulkScannerScreen.tsx +++ b/app/mobile/src/screens/BulkScannerScreen.tsx @@ -1,11 +1,10 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Text, View, StyleSheet, Dimensions, TouchableOpacity, - Alert, ActivityIndicator, } from 'react-native'; import { BarCodeScanner } from 'expo-barcode-scanner'; @@ -27,10 +26,19 @@ interface SessionStats { skipped: number; } +const MAX_CONCURRENT = 4; +const RATE_LIMIT_MS = 300; +const DEDUP_TTL_MS = 5000; + +export const parseQRCode = (data: string): string | null => { + const match = data.match(/^chainforge:\/\/package\/(.+)$/); + return match?.[1] ?? null; +}; + export const BulkScannerScreen: React.FC = ({ navigation }) => { const [hasPermission, setHasPermission] = useState(null); - const [isProcessing, setIsProcessing] = useState(false); - const [lastScanResult, setLastScanResult] = useState<{ status: 'success' | 'error'; message: string } | null>(null); + const [inFlightCount, setInFlightCount] = useState(0); + const [lastScanResult, setLastScanResult] = useState<{ status: 'success' | 'error' | 'skipped'; message: string } | null>(null); const [stats, setStats] = useState({ scanned: 0, verified: 0, @@ -38,6 +46,8 @@ export const BulkScannerScreen: React.FC = ({ navigation }) => { skipped: 0, }); + const seenRef = useRef>(new Map()); + const resultTimerRef = useRef | null>(null); const { colors } = useTheme(); const { queueClaimConfirmation, isConnected } = useSync(); @@ -50,45 +60,76 @@ export const BulkScannerScreen: React.FC = ({ navigation }) => { getBarCodeScannerPermissions(); }, []); - const handleBarCodeScanned = async ({ type, data }: { type: string; data: string }) => { - if (isProcessing) return; - setIsProcessing(true); + useEffect(() => { + return () => { + if (resultTimerRef.current) clearTimeout(resultTimerRef.current); + }; + }, []); - // Check if it's the correct format: chainforge://package/{id} - const regex = /^chainforge:\/\/package\/(.+)$/; - const match = data.match(regex); + const showResult = useCallback( + (result: { status: 'success' | 'error' | 'skipped'; message: string }) => { + if (resultTimerRef.current) clearTimeout(resultTimerRef.current); + setLastScanResult(result); + resultTimerRef.current = setTimeout(() => setLastScanResult(null), 2000); + }, + [], + ); + + const handleBarCodeScanned = useCallback( + async ({ type, data }: { type: string; data: string }) => { + if (inFlightCount >= MAX_CONCURRENT) return; - setStats(prev => ({ ...prev, scanned: prev.scanned + 1 })); + const aidId = parseQRCode(data); - if (match && match[1]) { - const aidId = match[1]; - const claimId = `claim-${aidId}`; // Assuming standard claimId format for bulk verify + if (!aidId) { + setStats(prev => ({ ...prev, scanned: prev.scanned + 1, failed: prev.failed + 1 })); + showResult({ status: 'error', message: 'Invalid ChainForge QR code.' }); + return; + } + + const now = Date.now(); + const lastSeen = seenRef.current.get(aidId); + + if (lastSeen && now - lastSeen < DEDUP_TTL_MS) { + setStats(prev => ({ ...prev, scanned: prev.scanned + 1, skipped: prev.skipped + 1 })); + showResult({ status: 'skipped', message: 'Duplicate scan — skipped.' }); + return; + } + + if (lastSeen && now - lastSeen < RATE_LIMIT_MS) { + setStats(prev => ({ ...prev, scanned: prev.scanned + 1, skipped: prev.skipped + 1 })); + showResult({ status: 'skipped', message: 'Rate limited — slow down.' }); + return; + } + + seenRef.current.set(aidId, now); + setStats(prev => ({ ...prev, scanned: prev.scanned + 1 })); + setInFlightCount(prev => prev + 1); + + const claimId = `claim-${aidId}`; try { const result = await queueClaimConfirmation(aidId, claimId); - + if (result.status === 'completed' || result.status === 'queued') { setStats(prev => ({ ...prev, verified: prev.verified + 1 })); - setLastScanResult({ - status: 'success', - message: result.status === 'completed' ? 'Package verified successfully!' : 'Package queued for verification (offline).' + showResult({ + status: 'success', + message: + result.status === 'completed' + ? 'Package verified successfully!' + : 'Package queued for verification (offline).', }); } - } catch (error) { + } catch { setStats(prev => ({ ...prev, failed: prev.failed + 1 })); - setLastScanResult({ status: 'error', message: 'Verification failed. Please try again.' }); + showResult({ status: 'error', message: 'Verification failed. Please try again.' }); + } finally { + setInFlightCount(prev => prev - 1); } - } else { - setStats(prev => ({ ...prev, failed: prev.failed + 1 })); - setLastScanResult({ status: 'error', message: 'Invalid ChainForge QR code.' }); - } - - // Short delay before allowing the next scan to provide feedback - setTimeout(() => { - setIsProcessing(false); - setLastScanResult(null); - }, 2000); - }; + }, + [inFlightCount, queueClaimConfirmation, showResult], + ); if (hasPermission === null) { return ( @@ -112,10 +153,12 @@ export const BulkScannerScreen: React.FC = ({ navigation }) => { ); } + const isAtCapacity = inFlightCount >= MAX_CONCURRENT; + return ( @@ -134,31 +177,42 @@ export const BulkScannerScreen: React.FC = ({ navigation }) => { {stats.failed} Failed + + {inFlightCount} + In Flight + - + {/* Feedback Area */} - {isProcessing && !lastScanResult && ( + {isAtCapacity && !lastScanResult && ( - Processing... + Processing {inFlightCount}/{MAX_CONCURRENT}… )} {lastScanResult && ( {lastScanResult.message} )} - {!isProcessing && !lastScanResult && ( + {!isAtCapacity && !lastScanResult && ( Align QR code to scan )}