From bf4b520c1ed2b54ea86e2aa40477021235cec5ec Mon Sep 17 00:00:00 2001 From: Vox-d-glitch Date: Sat, 27 Jun 2026 23:21:35 +0100 Subject: [PATCH] feat(security): encrypt AsyncStorage form cache entries with AES-256-GCM (#587) Plain-text form data (names, addresses, emails) persisted to AsyncStorage was readable by any process with storage access. Each storage key now gets its own AES-256-GCM key stored in SecureStore (hardware-backed on Android, Secure Enclave on iOS); encrypted blobs are written as iv_b64.ciphertext_b64. logout() wipes the user's form cache from both AsyncStorage and SecureStore so no residue survives a session change. --- package.json | 1 + src/__tests__/services/formCache.test.ts | 28 ++--- src/__tests__/utils/encryptedStorage.test.ts | 113 +++++++++++++++++++ src/services/formCache.ts | 16 +-- src/store/index.ts | 7 +- src/utils/encryptedStorage.ts | 87 ++++++++++++++ 6 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 src/__tests__/utils/encryptedStorage.test.ts create mode 100644 src/utils/encryptedStorage.ts diff --git a/package.json b/package.json index 5fef862..21b9776 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "expo-battery": "^55.0.13", "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.13", + "expo-crypto": "~14.0.1", "expo-device": "~8.0.10", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.23", diff --git a/src/__tests__/services/formCache.test.ts b/src/__tests__/services/formCache.test.ts index 05a9d23..5ca1564 100644 --- a/src/__tests__/services/formCache.test.ts +++ b/src/__tests__/services/formCache.test.ts @@ -1,8 +1,6 @@ /** * Tests for #616: useFormCache cache key is user-scoped. */ -import AsyncStorage from '@react-native-async-storage/async-storage'; - import { cacheFormValues, clearFormCache, @@ -10,17 +8,20 @@ import { getFormCacheStorageKey, loadFormCache, } from '../../services/formCache'; +import { encryptedGetItem, encryptedRemoveItem, encryptedSetItem } from '../../utils/encryptedStorage'; -jest.mock('@react-native-async-storage/async-storage'); +jest.mock('../../utils/encryptedStorage'); -const mockStorage = AsyncStorage as jest.Mocked; +const mockGetItem = jest.mocked(encryptedGetItem); +const mockSetItem = jest.mocked(encryptedSetItem); +const mockRemoveItem = jest.mocked(encryptedRemoveItem); describe('formCache – user-scoped storage key (#616)', () => { beforeEach(() => { jest.clearAllMocks(); - mockStorage.getItem.mockResolvedValue(null); - mockStorage.setItem.mockResolvedValue(undefined); - mockStorage.removeItem.mockResolvedValue(undefined); + mockGetItem.mockResolvedValue(null); + mockSetItem.mockResolvedValue(undefined); + mockRemoveItem.mockResolvedValue(undefined); }); it('generates distinct keys for different users', () => { @@ -34,13 +35,13 @@ describe('formCache – user-scoped storage key (#616)', () => { it('uses user-scoped key when reading cache', async () => { const key = getFormCacheStorageKey('user-1'); await loadFormCache(key); - expect(mockStorage.getItem).toHaveBeenCalledWith(key); + expect(mockGetItem).toHaveBeenCalledWith(key); }); it('uses user-scoped key when writing cache', async () => { const key = getFormCacheStorageKey('user-1'); await cacheFormValues(key, { fullName: 'Alice' }); - expect(mockStorage.setItem).toHaveBeenCalledWith(key, expect.any(String)); + expect(mockSetItem).toHaveBeenCalledWith(key, expect.any(String)); }); it('does not read or write to another user key', async () => { @@ -49,7 +50,7 @@ describe('formCache – user-scoped storage key (#616)', () => { await cacheFormValues(keyA, { fullName: 'Alice' }); - const allSetCalls = mockStorage.setItem.mock.calls.map(([k]) => k); + const allSetCalls = mockSetItem.mock.calls.map(([k]) => k); expect(allSetCalls).not.toContain(keyB); }); @@ -57,9 +58,8 @@ describe('formCache – user-scoped storage key (#616)', () => { const keyA = getFormCacheStorageKey('user-A'); const keyB = getFormCacheStorageKey('user-B'); - // Seed user-A cache with data const now = Date.now(); - mockStorage.getItem.mockImplementation(k => { + mockGetItem.mockImplementation(k => { if (k === keyA) return Promise.resolve(JSON.stringify({ fullName: { value: 'Alice', updatedAt: now } })); return Promise.resolve(null); @@ -75,7 +75,7 @@ describe('formCache – user-scoped storage key (#616)', () => { it('clears only the correct user key', async () => { const keyA = getFormCacheStorageKey('user-A'); await clearFormCache(keyA); - expect(mockStorage.removeItem).toHaveBeenCalledWith(keyA); - expect(mockStorage.removeItem).toHaveBeenCalledTimes(1); + expect(mockRemoveItem).toHaveBeenCalledWith(keyA); + expect(mockRemoveItem).toHaveBeenCalledTimes(1); }); }); diff --git a/src/__tests__/utils/encryptedStorage.test.ts b/src/__tests__/utils/encryptedStorage.test.ts new file mode 100644 index 0000000..b3ca241 --- /dev/null +++ b/src/__tests__/utils/encryptedStorage.test.ts @@ -0,0 +1,113 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as SecureStore from 'expo-secure-store'; + +import { encryptedGetItem, encryptedRemoveItem, encryptedSetItem } from '../../utils/encryptedStorage'; + +jest.mock('@react-native-async-storage/async-storage'); +jest.mock('expo-secure-store'); +jest.mock('expo-crypto', () => ({ + getRandomBytes: jest.fn(() => new Uint8Array(12).fill(0x42)), + subtle: { + generateKey: jest.fn().mockResolvedValue({ type: 'secret' }), + exportKey: jest.fn().mockResolvedValue(new Uint8Array(32).fill(0x01).buffer), + importKey: jest.fn().mockResolvedValue({ type: 'secret' }), + encrypt: jest.fn().mockImplementation((_: unknown, __: unknown, data: Uint8Array) => { + const output = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) output[i] = data[i] ^ 0xab; + return Promise.resolve(output.buffer); + }), + decrypt: jest.fn().mockImplementation((_: unknown, __: unknown, data: Uint8Array) => { + const output = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) output[i] = data[i] ^ 0xab; + return Promise.resolve(output.buffer); + }), + }, +})); + +const mockAsyncStorage = AsyncStorage as jest.Mocked; +const mockSecureStore = SecureStore as jest.Mocked; + +const STORAGE_KEY = '@teachlink/form-cache/user-1/v1'; +const PLAINTEXT = JSON.stringify({ fullName: { value: 'Alice', updatedAt: 1_000_000 } }); + +describe('encryptedStorage – AES-256-GCM', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAsyncStorage.getItem.mockResolvedValue(null); + mockAsyncStorage.setItem.mockResolvedValue(undefined); + mockAsyncStorage.removeItem.mockResolvedValue(undefined); + mockSecureStore.getItemAsync.mockResolvedValue(null); + mockSecureStore.setItemAsync.mockResolvedValue(undefined); + mockSecureStore.deleteItemAsync.mockResolvedValue(undefined); + }); + + it('does not store plaintext in AsyncStorage', async () => { + await encryptedSetItem(STORAGE_KEY, PLAINTEXT); + + expect(mockAsyncStorage.setItem).toHaveBeenCalledTimes(1); + const [, storedValue] = mockAsyncStorage.setItem.mock.calls[0]; + expect(storedValue).not.toBe(PLAINTEXT); + expect(storedValue).not.toContain('Alice'); + }); + + it('stored payload has iv.ciphertext dot-separated format', async () => { + await encryptedSetItem(STORAGE_KEY, PLAINTEXT); + + const [, storedValue] = mockAsyncStorage.setItem.mock.calls[0]; + expect(storedValue.indexOf('.')).toBeGreaterThan(0); + }); + + it('creates an encryption key and persists it to SecureStore under fce. prefix', async () => { + await encryptedSetItem(STORAGE_KEY, PLAINTEXT); + + expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith( + expect.stringMatching(/^fce\./), + expect.any(String) + ); + }); + + it('returns null for a missing key', async () => { + const result = await encryptedGetItem(STORAGE_KEY); + expect(result).toBeNull(); + }); + + it('returns null gracefully when stored payload has no dot separator', async () => { + mockAsyncStorage.getItem.mockResolvedValue('invaliddatanodot'); + const result = await encryptedGetItem(STORAGE_KEY); + expect(result).toBeNull(); + }); + + it('round-trips: decrypted value equals original plaintext', async () => { + const secureStoreData: Record = {}; + const asyncStorageData: Record = {}; + + mockSecureStore.getItemAsync.mockImplementation(k => + Promise.resolve(secureStoreData[k] ?? null) + ); + mockSecureStore.setItemAsync.mockImplementation((k, v) => { + secureStoreData[k] = v; + return Promise.resolve(); + }); + mockAsyncStorage.setItem.mockImplementation((k, v) => { + asyncStorageData[k] = v; + return Promise.resolve(); + }); + mockAsyncStorage.getItem.mockImplementation(k => + Promise.resolve(asyncStorageData[k] ?? null) + ); + + await encryptedSetItem(STORAGE_KEY, PLAINTEXT); + const result = await encryptedGetItem(STORAGE_KEY); + + expect(result).toBe(PLAINTEXT); + }); + + it('removes item from AsyncStorage and deletes SecureStore key on encryptedRemoveItem', async () => { + await encryptedRemoveItem(STORAGE_KEY); + + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith(STORAGE_KEY); + expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith( + expect.stringMatching(/^fce\./) + ); + }); +}); diff --git a/src/services/formCache.ts b/src/services/formCache.ts index ac05452..01ed67b 100644 --- a/src/services/formCache.ts +++ b/src/services/formCache.ts @@ -1,16 +1,10 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import { encryptedGetItem, encryptedRemoveItem, encryptedSetItem } from '../utils/encryptedStorage'; /** Returns an AsyncStorage key scoped to the given user (versioned for future migrations). */ export function getFormCacheStorageKey(userId: string): string { return `@teachlink/form-cache/${userId}/v1`; } -/** @deprecated Use getFormCacheStorageKey(userId) instead. Kept for migration only. */ -import { safeStorageWrite } from '../utils/storage'; - -/** AsyncStorage key for the form value cache (versioned for future migrations). */ -export const FORM_CACHE_STORAGE_KEY = '@teachlink/form-cache/v1'; - /** Cached entries older than this are pruned on read/write (90 days). */ export const FORM_CACHE_TTL_MS = 90 * 24 * 60 * 60 * 1000; @@ -55,7 +49,7 @@ export function pruneExpiredCache(store: FormCacheStore, now = Date.now()): Form } export async function loadFormCache(storageKey: string): Promise { - const raw = await AsyncStorage.getItem(storageKey); + const raw = await encryptedGetItem(storageKey); if (!raw) return {}; try { const parsed = JSON.parse(raw) as FormCacheStore; @@ -70,9 +64,7 @@ export async function loadFormCache(storageKey: string): Promise } export async function saveFormCache(storageKey: string, store: FormCacheStore): Promise { - await AsyncStorage.setItem(storageKey, JSON.stringify(store)); -export async function saveFormCache(store: FormCacheStore): Promise { - await safeStorageWrite(FORM_CACHE_STORAGE_KEY, JSON.stringify(store)); + await encryptedSetItem(storageKey, JSON.stringify(store)); } export async function getCachedFieldValue( @@ -143,7 +135,7 @@ export function getSuggestionForField( } export async function clearFormCache(storageKey: string): Promise { - await AsyncStorage.removeItem(storageKey); + await encryptedRemoveItem(storageKey); } /** Maps profile/edit labels to shared cache keys. */ diff --git a/src/store/index.ts b/src/store/index.ts index 8d5651f..41efea5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { createJSONStorage, devtools, persist, subscribeWithSelector } from 'zustand/middleware'; import { secureStorageJSONStorage, toUnixMs } from './persistence'; +import { clearFormCache, getFormCacheStorageKey } from '../services/formCache'; import { sentryContextService } from '../services/sentryContext'; export interface User { @@ -38,7 +39,7 @@ interface AppState { export const useAppStore = create()( devtools( persist( - subscribeWithSelector(set => ({ + subscribeWithSelector((set, get) => ({ user: null, isAuthenticated: false, isAuthLoading: false, @@ -81,6 +82,7 @@ export const useAppStore = create()( setAuthLoading: isAuthLoading => set({ isAuthLoading }, false, 'setAuthLoading'), setAuthError: authError => set({ authError }, false, 'setAuthError'), logout: () => { + const userId = get().user?.id; set( { user: null, @@ -98,6 +100,9 @@ export const useAppStore = create()( // Clear Sentry user scope and reset breadcrumb trail on logout sentryContextService.clearUser(); sentryContextService.resetSession(); + if (userId) { + clearFormCache(getFormCacheStorageKey(userId)).catch(() => {}); + } }, setLoading: isLoading => set({ isLoading }, false, 'setLoading'), setError: error => set({ error }, false, 'setError'), diff --git a/src/utils/encryptedStorage.ts b/src/utils/encryptedStorage.ts new file mode 100644 index 0000000..7d99b50 --- /dev/null +++ b/src/utils/encryptedStorage.ts @@ -0,0 +1,87 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Crypto from 'expo-crypto'; +import * as SecureStore from 'expo-secure-store'; + +// SecureStore accepts alphanumeric, '.', '-', '_'. Prefix 'fce.' scopes keys to form-cache encryption. +function toSecureStoreKey(storageKey: string): string { + return 'fce.' + storageKey.replace(/[^a-zA-Z0-9._-]/g, '.'); +} + +function uint8ToBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function base64ToUint8(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function getOrCreateAesKey(storageKey: string): Promise { + const secureKey = toSecureStoreKey(storageKey); + const stored = await SecureStore.getItemAsync(secureKey); + + if (stored) { + return Crypto.subtle.importKey( + 'raw', + base64ToUint8(stored), + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } + + const key = (await Crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + )) as CryptoKey; + + const raw = await Crypto.subtle.exportKey('raw', key); + await SecureStore.setItemAsync(secureKey, uint8ToBase64(new Uint8Array(raw as ArrayBuffer))); + + return key; +} + +export async function encryptedSetItem(storageKey: string, value: string): Promise { + const key = await getOrCreateAesKey(storageKey); + const iv = Crypto.getRandomBytes(12); + const ciphertext = await Crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + new TextEncoder().encode(value) + ); + const payload = uint8ToBase64(iv) + '.' + uint8ToBase64(new Uint8Array(ciphertext as ArrayBuffer)); + await AsyncStorage.setItem(storageKey, payload); +} + +export async function encryptedGetItem(storageKey: string): Promise { + const payload = await AsyncStorage.getItem(storageKey); + if (!payload) return null; + + try { + const dot = payload.indexOf('.'); + if (dot === -1) return null; + + const iv = base64ToUint8(payload.slice(0, dot)); + const ciphertext = base64ToUint8(payload.slice(dot + 1)); + const key = await getOrCreateAesKey(storageKey); + const decrypted = await Crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + + return new TextDecoder().decode(decrypted as ArrayBuffer); + } catch { + return null; + } +} + +export async function encryptedRemoveItem(storageKey: string): Promise { + await AsyncStorage.removeItem(storageKey); + await SecureStore.deleteItemAsync(toSecureStoreKey(storageKey)).catch(() => {}); +}