diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..f2bc291 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2024-04-21 - Secure Password Storage +**Vulnerability:** Plain text user passwords were being stored in AsyncStorage, exposing them on rooted/jailbroken devices. +**Learning:** SecureStore on Android has a 2048-byte limit, so we can't store a large JSON array of passwords in it. The project uses a hybrid storage pattern. +**Prevention:** Use hybrid storage pattern: lists of metadata are stored in AsyncStorage with secrets masked, and the actual secrets are stored individually in SecureStore using row-specific dynamic keys. diff --git a/src/screens/PasswordScreen.tsx b/src/screens/PasswordScreen.tsx index c5338f1..d2bf9aa 100644 --- a/src/screens/PasswordScreen.tsx +++ b/src/screens/PasswordScreen.tsx @@ -150,7 +150,26 @@ export default function PasswordScreen() { useEffect(() => { (async () => { const raw = await AsyncStorage.getItem(PASSWORDS_KEY); - if (raw) setEntries(JSON.parse(raw)); + if (raw) { + let parsed = JSON.parse(raw); + let needsMigration = false; + parsed = await Promise.all(parsed.map(async (e: any) => { + if (e.password === '***') { + const securePw = await SecureStore.getItemAsync(`aerostaff_pwd_${e.id}`); + return { ...e, password: securePw || '' }; + } else { + needsMigration = true; + return e; + } + })); + setEntries(parsed); + if (needsMigration) { + // Trigger persist to move legacy plain text into SecureStore and mask AsyncStorage + const masked = parsed.map((e: any) => ({ ...e, password: '***' })); + await Promise.all(parsed.map((e: any) => SecureStore.setItemAsync(`aerostaff_pwd_${e.id}`, e.password))); + await AsyncStorage.setItem(PASSWORDS_KEY, JSON.stringify(masked)); + } + } const enabled = await AsyncStorage.getItem(PIN_ENABLED_KEY); const isEnabled = enabled === 'true'; setPinEnabled(isEnabled); @@ -160,7 +179,9 @@ export default function PasswordScreen() { const persist = useCallback(async (next: PasswordEntry[]) => { setEntries(next); - await AsyncStorage.setItem(PASSWORDS_KEY, JSON.stringify(next)); + const masked = next.map(e => ({ ...e, password: '***' })); + await Promise.all(next.map(e => SecureStore.setItemAsync(`aerostaff_pwd_${e.id}`, e.password))); + await AsyncStorage.setItem(PASSWORDS_KEY, JSON.stringify(masked)); }, []); // PIN toggle @@ -242,6 +263,7 @@ export default function PasswordScreen() { Alert.alert(t('passwordDeleteTitle'), t('passwordDeleteMsg'), [ { text: 'Annulla', style: 'cancel' }, { text: 'Elimina', style: 'destructive', onPress: async () => { + await SecureStore.deleteItemAsync(`aerostaff_pwd_${id}`); await persist(entries.filter(e => e.id !== id)); }}, ]);