diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..bf7e55c --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-04-25 - Cleartext Password Storage in AsyncStorage +**Vulnerability:** Passwords in the PasswordScreen were stored in plain text in `AsyncStorage`, which is unencrypted and accessible via backups or physical device access. +**Learning:** To handle the 2048-byte limit of `SecureStore` on Android while securing sensitive data, a hybrid storage pattern is required. +**Prevention:** Migrate the legacy unencrypted data to the secure store (`SecureStore`) using row-specific dynamic keys (e.g., `aerostaff_pwd_${id}`), and immediately overwrite the `AsyncStorage` values with masked strings (e.g., `***`) to prevent sensitive data from lingering in plain text. diff --git a/src/screens/PasswordScreen.tsx b/src/screens/PasswordScreen.tsx index c5338f1..7dfad85 100644 --- a/src/screens/PasswordScreen.tsx +++ b/src/screens/PasswordScreen.tsx @@ -149,8 +149,31 @@ export default function PasswordScreen() { // Load on mount useEffect(() => { (async () => { - const raw = await AsyncStorage.getItem(PASSWORDS_KEY); - if (raw) setEntries(JSON.parse(raw)); + try { + const raw = await AsyncStorage.getItem(PASSWORDS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + let needsMigration = false; + const hydrated = await Promise.all(parsed.map(async (e: PasswordEntry) => { + if (e.password === '***') { + const sec = await SecureStore.getItemAsync(`aerostaff_pwd_${e.id}`); + return { ...e, password: sec || '' }; + } else { + needsMigration = true; + if (e.password) { + await SecureStore.setItemAsync(`aerostaff_pwd_${e.id}`, e.password); + } + return e; + } + })); + setEntries(hydrated); + if (needsMigration) { + const masked = hydrated.map(e => ({ ...e, password: '***' })); + await AsyncStorage.setItem(PASSWORDS_KEY, JSON.stringify(masked)); + } + } + } catch(e) { console.error('Error loading passwords', e); } + const enabled = await AsyncStorage.getItem(PIN_ENABLED_KEY); const isEnabled = enabled === 'true'; setPinEnabled(isEnabled); @@ -160,7 +183,15 @@ export default function PasswordScreen() { const persist = useCallback(async (next: PasswordEntry[]) => { setEntries(next); - await AsyncStorage.setItem(PASSWORDS_KEY, JSON.stringify(next)); + const masked = await Promise.all(next.map(async (e) => { + if (e.password) { + await SecureStore.setItemAsync(`aerostaff_pwd_${e.id}`, e.password); + } else { + await SecureStore.deleteItemAsync(`aerostaff_pwd_${e.id}`).catch(() => {}); + } + return { ...e, password: '***' }; + })); + await AsyncStorage.setItem(PASSWORDS_KEY, JSON.stringify(masked)); }, []); // PIN toggle @@ -243,6 +274,7 @@ export default function PasswordScreen() { { text: 'Annulla', style: 'cancel' }, { text: 'Elimina', style: 'destructive', onPress: async () => { await persist(entries.filter(e => e.id !== id)); + await SecureStore.deleteItemAsync(`aerostaff_pwd_${id}`).catch(() => {}); }}, ]); }, [entries, persist]);