diff --git a/app/(auth)/wifi-setup.tsx b/app/(auth)/wifi-setup.tsx index af0db4a..68eb9d3 100644 --- a/app/(auth)/wifi-setup.tsx +++ b/app/(auth)/wifi-setup.tsx @@ -16,7 +16,6 @@ import { ActivityIndicator, Alert, FlatList, - Platform, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router, useLocalSearchParams } from 'expo-router'; @@ -26,8 +25,12 @@ import { espProvisioning, type WellNuoDevice, type WifiNetwork, - type ESPDevice, } from '@/services/espProvisioning'; +import { + validateWiFiCredentials, + getValidationErrorMessage, + sanitizeWiFiCredentials, +} from '@/utils/wifiValidation'; type Step = 'scan' | 'connect' | 'wifi-select' | 'wifi-password' | 'provisioning' | 'complete'; @@ -139,12 +142,56 @@ export default function WifiSetupScreen() { const handleProvision = async () => { if (!selectedWifi) return; + // Validate credentials before provisioning + const validation = validateWiFiCredentials( + selectedWifi.ssid, + wifiPassword, + selectedWifi.auth + ); + + if (!validation.valid) { + const errorMsg = getValidationErrorMessage(validation); + setError(errorMsg); + return; + } + + // Show warning if present (but allow to continue) + if (validation.warnings.length > 0) { + const warningMsg = validation.warnings.join('\n'); + Alert.alert( + 'Warning', + `${warningMsg}\n\nDo you want to continue?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Continue', + onPress: () => provisionDevice(), + }, + ] + ); + return; + } + + // No warnings, proceed directly + await provisionDevice(); + }; + + // Actual provisioning logic + const provisionDevice = async () => { + if (!selectedWifi) return; + setStep('provisioning'); setIsLoading(true); setError(null); try { - await espProvisioning.provisionWifi(selectedWifi.ssid, wifiPassword); + // Sanitize credentials before sending + const sanitized = sanitizeWiFiCredentials({ + ssid: selectedWifi.ssid, + password: wifiPassword, + }); + + await espProvisioning.provisionWifi(sanitized.ssid, sanitized.password); setStep('complete'); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; @@ -414,9 +461,13 @@ export default function WifiSetupScreen() { {/* Connect button */} {step === 'provisioning' ? ( <> @@ -434,6 +485,13 @@ export default function WifiSetupScreen() { This is an open network. You can leave the password empty. )} + + {/* Password requirements */} + {selectedWifi?.auth?.includes('WPA') && ( + + Password must be 8-63 characters for WPA/WPA2/WPA3 networks. + + )} ); @@ -468,7 +526,7 @@ export default function WifiSetupScreen() { - You'll see updates in the dashboard + You'll see updates in the dashboard diff --git a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx index 34d49e8..a65ca8f 100644 --- a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx +++ b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx @@ -35,6 +35,11 @@ import { Spacing, Shadows, } from '@/constants/theme'; +import { + validateWiFiCredentials, + getValidationErrorMessage, + sanitizeWiFiCredentials, +} from '@/utils/wifiValidation'; // Type for device passed via navigation params interface DeviceParam { @@ -450,20 +455,55 @@ export default function SetupWiFiScreen() { Alert.alert('Error', 'Please select a WiFi network'); return; } - if (!password) { - Alert.alert('Error', 'Please enter WiFi password'); + + // Validate credentials + const validation = validateWiFiCredentials(selectedNetwork.ssid, password); + + if (!validation.valid) { + const errorMsg = getValidationErrorMessage(validation); + Alert.alert('Invalid WiFi Credentials', errorMsg); return; } + // Show warnings if present (but allow to continue) + if (validation.warnings.length > 0) { + const warningMsg = validation.warnings.join('\n'); + Alert.alert( + 'WiFi Password Warning', + `${warningMsg}\n\nDo you want to continue?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Continue', + onPress: () => startBatchSetupProcess(), + }, + ] + ); + return; + } + + // No warnings, proceed directly + await startBatchSetupProcess(); + }; + + // Actual batch setup process + const startBatchSetupProcess = async () => { + if (!selectedNetwork) return; + + // Sanitize credentials before saving and using + const sanitized = sanitizeWiFiCredentials({ + ssid: selectedNetwork.ssid, + password, + }); + // Save password for this network (by SSID) to SecureStore try { - await wifiPasswordStore.saveWiFiPassword(selectedNetwork.ssid, password); + await wifiPasswordStore.saveWiFiPassword(sanitized.ssid, sanitized.password); // Update local state - const updatedPasswords = { ...savedPasswords, [selectedNetwork.ssid]: password }; + const updatedPasswords = { ...savedPasswords, [sanitized.ssid]: sanitized.password }; savedPasswordsRef.current = updatedPasswords; setSavedPasswords(updatedPasswords); - } catch (error) { // Continue with setup even if save fails } @@ -804,6 +844,13 @@ export default function SetupWiFiScreen() { + {/* Password requirements hint */} + {selectedNetwork && !selectedNetwork.ssid.includes('Open') && ( + + Password must be 8-63 characters for secured networks + + )} + {/* Connect Button */} { + describe('validateSSID', () => { + it('should accept valid SSIDs', () => { + const validSSIDs = [ + 'MyHomeWiFi', + 'Guest Network', + 'Office-5G', + 'Café WiFi', + 'Network_2.4GHz', + 'a', + '1234567890123456789012345678901', // 31 chars + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456', // 32 chars + ]; + + validSSIDs.forEach((ssid) => { + const result = validateSSID(ssid); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + it('should reject empty SSID', () => { + const result = validateSSID(''); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Network name (SSID) cannot be empty'); + }); + + it('should reject whitespace-only SSID', () => { + const result = validateSSID(' '); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Network name (SSID) cannot be empty'); + }); + + it('should reject SSID longer than 32 bytes', () => { + const longSSID = 'A'.repeat(33); + const result = validateSSID(longSSID); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Network name is too long (max 32 bytes)'); + }); + + it('should reject SSID with control characters', () => { + const result = validateSSID('Network\x00Name'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Network name contains invalid control characters'); + }); + + it('should warn about leading/trailing spaces', () => { + const result1 = validateSSID(' MyWiFi'); + expect(result1.valid).toBe(true); + expect(result1.warnings).toContain( + 'Network name has leading or trailing spaces - this may cause connection issues' + ); + + const result2 = validateSSID('MyWiFi '); + expect(result2.valid).toBe(true); + expect(result2.warnings).toContain( + 'Network name has leading or trailing spaces - this may cause connection issues' + ); + }); + + it('should warn about multiple consecutive spaces', () => { + const result = validateSSID('My WiFi'); + expect(result.valid).toBe(true); + expect(result.warnings).toContain('Network name has multiple consecutive spaces'); + }); + }); + + describe('validatePassword', () => { + describe('WPA/WPA2/WPA3 networks', () => { + it('should accept valid WPA passwords', () => { + const validPasswords = [ + 'MySecurePass123', + 'abcdefgh', // 8 chars minimum + 'A'.repeat(63), // 63 chars maximum + 'Pass with spaces!@#', + 'ComplexP@ssw0rd!', + ]; + + validPasswords.forEach((password) => { + const result = validatePassword(password, 'WPA2 PSK'); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + it('should reject passwords shorter than 8 characters', () => { + const result = validatePassword('1234567', 'WPA2 PSK'); + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'Password must be at least 8 characters for WPA/WPA2/WPA3 networks' + ); + }); + + it('should reject passwords longer than 63 characters', () => { + const longPassword = 'A'.repeat(64); + const result = validatePassword(longPassword, 'WPA2 PSK'); + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'Password cannot exceed 63 characters for WPA/WPA2/WPA3 networks' + ); + }); + + it('should warn about weak passwords', () => { + const result = validatePassword('12345678', 'WPA2 PSK'); + expect(result.valid).toBe(true); + expect(result.warnings).toContain('Password is weak - consider using at least 12 characters'); + }); + + it('should warn about commonly used weak passwords', () => { + const weakPasswords = ['password', '12345678', 'qwerty123']; + + weakPasswords.forEach((password) => { + const result = validatePassword(password, 'WPA2 PSK'); + expect(result.warnings).toContain('This is a commonly used weak password'); + }); + }); + + it('should warn about numeric-only passwords', () => { + const result = validatePassword('12345678', 'WPA2 PSK'); + expect(result.warnings).toContain( + 'Password contains only numbers - consider adding letters and symbols' + ); + }); + + it('should warn about non-ASCII characters', () => { + const result = validatePassword('Password™123', 'WPA2 PSK'); + expect(result.valid).toBe(true); + expect(result.warnings).toContain( + 'Password contains non-ASCII characters - this may cause issues on some devices' + ); + }); + }); + + describe('Open networks', () => { + it('should accept empty password for open networks', () => { + const result = validatePassword('', 'Open'); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should accept any password for open networks (will be ignored)', () => { + const result = validatePassword('any password', 'Open'); + expect(result.valid).toBe(true); + }); + }); + + describe('WEP networks', () => { + it('should accept valid 5-character WEP password', () => { + const result = validatePassword('abcde', 'WEP'); + expect(result.valid).toBe(true); + expect(result.warnings).toContain( + 'WEP is deprecated and insecure - consider upgrading your router to WPA2/WPA3' + ); + }); + + it('should accept valid 13-character WEP password', () => { + const result = validatePassword('abcdefghijklm', 'WEP'); + expect(result.valid).toBe(true); + }); + + it('should accept valid 10-digit hex WEP password', () => { + const result = validatePassword('1234567890', 'WEP'); + expect(result.valid).toBe(true); + }); + + it('should accept valid 26-digit hex WEP password', () => { + const result = validatePassword('12345678901234567890123456', 'WEP'); + expect(result.valid).toBe(true); + }); + + it('should reject invalid WEP password length', () => { + const result = validatePassword('abc', 'WEP'); + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'WEP password must be 5 or 13 ASCII characters, or 10/26 hexadecimal digits' + ); + }); + }); + + describe('Secured networks without auth type', () => { + it('should require password', () => { + const result = validatePassword(''); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Password is required for secured networks'); + }); + + it('should apply WPA rules by default', () => { + const result = validatePassword('1234567'); // 7 chars + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'Password must be at least 8 characters for WPA/WPA2/WPA3 networks' + ); + }); + }); + + it('should reject passwords with control characters', () => { + const result = validatePassword('Pass\x00word123', 'WPA2 PSK'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Password contains invalid control characters'); + }); + }); + + describe('validateWiFiCredentials', () => { + it('should validate both SSID and password', () => { + const result = validateWiFiCredentials('MyWiFi', 'SecurePass123', 'WPA2 PSK'); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should combine errors from both validations', () => { + const result = validateWiFiCredentials('', '123', 'WPA2 PSK'); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Network name (SSID) cannot be empty'); + expect(result.errors).toContain( + 'Password must be at least 8 characters for WPA/WPA2/WPA3 networks' + ); + }); + + it('should combine warnings from both validations', () => { + const result = validateWiFiCredentials(' MyWiFi ', 'password123', 'WPA2 PSK'); + expect(result.valid).toBe(true); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('should validate open network correctly', () => { + const result = validateWiFiCredentials('GuestNetwork', '', 'Open'); + expect(result.valid).toBe(true); + }); + }); + + describe('isStrongPassword', () => { + it('should accept strong passwords', () => { + const strongPasswords = [ + 'MyP@ssw0rd123!', + 'Secure_Password_2024', + 'Compl3x!P@ssw0rd', + 'abCD1234!@#$', + ]; + + strongPasswords.forEach((password) => { + expect(isStrongPassword(password)).toBe(true); + }); + }); + + it('should reject passwords shorter than 12 characters', () => { + expect(isStrongPassword('P@ssw0rd')).toBe(false); + }); + + it('should reject passwords with less than 3 character types', () => { + expect(isStrongPassword('onlylowercase')).toBe(false); + expect(isStrongPassword('12345678901234')).toBe(false); + expect(isStrongPassword('ONLYUPPERCASE')).toBe(false); + }); + + it('should accept passwords with 3 or 4 character types', () => { + // 3 types: lowercase, uppercase, numbers + expect(isStrongPassword('Password1234')).toBe(true); + + // 4 types: lowercase, uppercase, numbers, special + expect(isStrongPassword('P@ssword1234')).toBe(true); + }); + }); + + describe('sanitizeWiFiCredentials', () => { + it('should trim SSID whitespace', () => { + const result = sanitizeWiFiCredentials({ + ssid: ' MyWiFi ', + password: 'password', + }); + expect(result.ssid).toBe('MyWiFi'); + }); + + it('should preserve password whitespace', () => { + const result = sanitizeWiFiCredentials({ + ssid: 'MyWiFi', + password: ' password with spaces ', + }); + expect(result.password).toBe(' password with spaces '); + }); + + it('should not modify valid credentials', () => { + const result = sanitizeWiFiCredentials({ + ssid: 'MyWiFi', + password: 'SecurePass123', + }); + expect(result.ssid).toBe('MyWiFi'); + expect(result.password).toBe('SecurePass123'); + }); + }); + + describe('getValidationErrorMessage', () => { + it('should return empty string for valid credentials', () => { + const result = validateWiFiCredentials('MyWiFi', 'SecurePass123', 'WPA2 PSK'); + expect(getValidationErrorMessage(result)).toBe(''); + }); + + it('should return first error message', () => { + const result = validateWiFiCredentials('', '123', 'WPA2 PSK'); + expect(getValidationErrorMessage(result)).toBe('Network name (SSID) cannot be empty'); + }); + + it('should return default message if no specific error', () => { + const result = { valid: false, errors: [], warnings: [] }; + expect(getValidationErrorMessage(result)).toBe('Invalid WiFi credentials'); + }); + }); + + describe('getValidationMessage', () => { + it('should return empty string for valid credentials with no warnings', () => { + const result = { valid: true, errors: [], warnings: [] }; + expect(getValidationMessage(result)).toBe(''); + }); + + it('should return errors only', () => { + const result = { + valid: false, + errors: ['Error 1', 'Error 2'], + warnings: [], + }; + expect(getValidationMessage(result)).toBe('Error 1\nError 2'); + }); + + it('should return warnings only', () => { + const result = { + valid: true, + errors: [], + warnings: ['Warning 1', 'Warning 2'], + }; + expect(getValidationMessage(result)).toBe('Warning 1\nWarning 2'); + }); + + it('should return both errors and warnings', () => { + const result = { + valid: false, + errors: ['Error 1'], + warnings: ['Warning 1'], + }; + expect(getValidationMessage(result)).toBe('Error 1\nWarning 1'); + }); + }); +}); diff --git a/utils/wifiValidation.ts b/utils/wifiValidation.ts new file mode 100644 index 0000000..f4a4827 --- /dev/null +++ b/utils/wifiValidation.ts @@ -0,0 +1,235 @@ +/** + * WiFi Credentials Validation Utility + * + * Provides comprehensive validation for WiFi SSID and password + * following IEEE 802.11 standards and common router implementations. + */ + +export interface WiFiValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export interface WiFiCredentials { + ssid: string; + password: string; +} + +/** + * Validate WiFi SSID + * + * Rules: + * - Length: 1-32 characters (IEEE 802.11 standard) + * - Can contain: letters, numbers, spaces, and special characters + * - Cannot be empty + * - Should not contain control characters + */ +export function validateSSID(ssid: string): WiFiValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Check for empty SSID + if (!ssid || ssid.trim().length === 0) { + errors.push('Network name (SSID) cannot be empty'); + return { valid: false, errors, warnings }; + } + + // Check length (IEEE 802.11 standard: max 32 bytes) + // Note: UTF-8 characters can be multiple bytes + const byteLength = new Blob([ssid]).size; + if (byteLength > 32) { + errors.push('Network name is too long (max 32 bytes)'); + } + + // Check for control characters (ASCII 0-31) + if (/[\x00-\x1F]/.test(ssid)) { + errors.push('Network name contains invalid control characters'); + } + + // Warnings for potentially problematic SSIDs + if (ssid.startsWith(' ') || ssid.endsWith(' ')) { + warnings.push('Network name has leading or trailing spaces - this may cause connection issues'); + } + + if (/\s{2,}/.test(ssid)) { + warnings.push('Network name has multiple consecutive spaces'); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validate WiFi Password + * + * Rules based on WPA/WPA2/WPA3 standards: + * - WPA/WPA2-PSK: 8-63 characters (ASCII) + * - Open networks: no password required + * - WEP: 5 or 13 characters (ASCII) or 10/26 hex digits (deprecated) + * + * Additional checks: + * - Should not be empty for secured networks + * - Should not contain control characters + * - Common weak passwords warning + */ +export function validatePassword( + password: string, + authType?: string +): WiFiValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Open networks don't need password + if (authType === 'Open') { + return { valid: true, errors, warnings }; + } + + // Check for empty password on secured networks + if (!password || password.length === 0) { + errors.push('Password is required for secured networks'); + return { valid: false, errors, warnings }; + } + + // WPA/WPA2/WPA3 validation (most common) + if (!authType || authType.includes('WPA')) { + // Length check: 8-63 characters + if (password.length < 8) { + errors.push('Password must be at least 8 characters for WPA/WPA2/WPA3 networks'); + } + + if (password.length > 63) { + errors.push('Password cannot exceed 63 characters for WPA/WPA2/WPA3 networks'); + } + + // Check for ASCII only (non-ASCII can cause issues) + + if (!/^[\x20-\x7E]*$/.test(password)) { + warnings.push('Password contains non-ASCII characters - this may cause issues on some devices'); + } + } + + // WEP validation (deprecated but still exists) + if (authType === 'WEP') { + const isValidWEP = + password.length === 5 || // 40-bit WEP + password.length === 13 || // 104-bit WEP + (password.length === 10 && /^[0-9A-Fa-f]+$/.test(password)) || // 40-bit hex + (password.length === 26 && /^[0-9A-Fa-f]+$/.test(password)); // 104-bit hex + + if (!isValidWEP) { + errors.push('WEP password must be 5 or 13 ASCII characters, or 10/26 hexadecimal digits'); + } + + warnings.push('WEP is deprecated and insecure - consider upgrading your router to WPA2/WPA3'); + } + + // Check for control characters + if (/[\x00-\x1F]/.test(password)) { + errors.push('Password contains invalid control characters'); + } + + // Weak password warnings + if (password.length < 12 && !errors.length) { + warnings.push('Password is weak - consider using at least 12 characters'); + } + + // Common weak passwords + const weakPasswords = [ + 'password', '12345678', 'qwerty123', 'abc123456', + 'password1', 'password123', '11111111', '00000000' + ]; + if (weakPasswords.includes(password.toLowerCase())) { + warnings.push('This is a commonly used weak password'); + } + + // Check for only numbers + if (/^\d+$/.test(password) && password.length === 8) { + warnings.push('Password contains only numbers - consider adding letters and symbols'); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validate complete WiFi credentials (SSID + password) + */ +export function validateWiFiCredentials( + ssid: string, + password: string, + authType?: string +): WiFiValidationResult { + const ssidResult = validateSSID(ssid); + const passwordResult = validatePassword(password, authType); + + return { + valid: ssidResult.valid && passwordResult.valid, + errors: [...ssidResult.errors, ...passwordResult.errors], + warnings: [...ssidResult.warnings, ...passwordResult.warnings], + }; +} + +/** + * Check if password is strong enough for production use + * More strict than basic validation + */ +export function isStrongPassword(password: string): boolean { + if (password.length < 12) return false; + + const hasUppercase = /[A-Z]/.test(password); + const hasLowercase = /[a-z]/.test(password); + const hasNumber = /\d/.test(password); + const hasSpecial = /[^A-Za-z0-9]/.test(password); + + // Require at least 3 of 4 character types + const typesCount = [hasUppercase, hasLowercase, hasNumber, hasSpecial].filter(Boolean).length; + return typesCount >= 3; +} + +/** + * Sanitize WiFi credentials before sending to device + * Trims whitespace and performs basic cleanup + */ +export function sanitizeWiFiCredentials(credentials: WiFiCredentials): WiFiCredentials { + return { + ssid: credentials.ssid.trim(), + password: credentials.password, // Don't trim password - spaces might be intentional + }; +} + +/** + * Get user-friendly error message for validation result + */ +export function getValidationErrorMessage(result: WiFiValidationResult): string { + if (result.valid) return ''; + + if (result.errors.length > 0) { + return result.errors[0]; // Return first error + } + + return 'Invalid WiFi credentials'; +} + +/** + * Get combined error and warning message + */ +export function getValidationMessage(result: WiFiValidationResult): string { + const messages: string[] = []; + + if (result.errors.length > 0) { + messages.push(...result.errors); + } + + if (result.warnings.length > 0) { + messages.push(...result.warnings); + } + + return messages.join('\n'); +}