From 61a590efa23efa1736c502f2586619506af84b82 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:17:40 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Fix=20plaintext=20password=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrated password storage to a hybrid model. Metadata is stored in AsyncStorage with passwords masked as '***'. The actual passwords are stored individually in SecureStore using dynamic keys (`aerostaff_pwd_${id}`). Added auto-migration logic for legacy plain-text entries. Co-authored-by: TargetMisser <52361977+TargetMisser@users.noreply.github.com> --- .jules/sentinel.md | 4 ++++ src/screens/PasswordScreen.tsx | 26 ++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 .jules/sentinel.md 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)); }}, ]);