From 0d770687e87790a6afbdf500b45230952dbb7a77 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:18:50 +0000 Subject: [PATCH] Fix insecure password storage in AsyncStorage Migrated password storage to use SecureStore for sensitive fields while keeping metadata in AsyncStorage to bypass Android size limits. Existing plain text passwords are automatically migrated. Co-authored-by: TargetMisser <52361977+TargetMisser@users.noreply.github.com> --- .jules/sentinel.md | 4 ++++ src/screens/PasswordScreen.tsx | 27 ++++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 .jules/sentinel.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..c47b920 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-04-20 - Insecure Password Storage Migration +**Vulnerability:** User passwords were saved in plain text in `AsyncStorage`, an unencrypted storage mechanism. +**Learning:** The entire list of passwords was originally stored in `AsyncStorage` because `SecureStore` has a 2048-byte limit on Android, which makes storing large JSON arrays of passwords fail. This led to storing secrets in plain text as a naive workaround. +**Prevention:** Use a hybrid storage pattern: lists of metadata are stored in `AsyncStorage` with secrets masked (e.g., `password: '***'`), and the actual secrets are stored individually in `SecureStore` using row-specific dynamic keys (e.g., `aerostaff_pwd_${id}`). When migrating, always overwrite the legacy unencrypted data with masked values immediately to prevent plain text persistence. diff --git a/src/screens/PasswordScreen.tsx b/src/screens/PasswordScreen.tsx index c5338f1..aea0f04 100644 --- a/src/screens/PasswordScreen.tsx +++ b/src/screens/PasswordScreen.tsx @@ -150,7 +150,25 @@ export default function PasswordScreen() { useEffect(() => { (async () => { const raw = await AsyncStorage.getItem(PASSWORDS_KEY); - if (raw) setEntries(JSON.parse(raw)); + if (raw) { + const parsed = JSON.parse(raw); + let migrated = false; + const loaded = await Promise.all(parsed.map(async (e: PasswordEntry) => { + if (e.password !== '***') { + await SecureStore.setItemAsync(`aerostaff_pwd_${e.id}`, e.password); + migrated = true; + return e; + } else { + const pwd = await SecureStore.getItemAsync(`aerostaff_pwd_${e.id}`); + return { ...e, password: pwd || '' }; + } + })); + setEntries(loaded); + if (migrated) { + const masked = loaded.map(e => ({ ...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 +178,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 AsyncStorage.setItem(PASSWORDS_KEY, JSON.stringify(masked)); + await Promise.all(next.map(e => SecureStore.setItemAsync(`aerostaff_pwd_${e.id}`, e.password))); }, []); // PIN toggle @@ -243,9 +263,10 @@ 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]); + }, [entries, persist, t]); // PIN overlays (setup and unlock) if (pinMode === 'unlock') {