Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 14 additions & 14 deletions src/__tests__/services/formCache.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
/**
* Tests for #616: useFormCache cache key is user-scoped.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';

import {
cacheFormValues,
clearFormCache,
getCachedFieldValues,
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<typeof AsyncStorage>;
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', () => {
Expand All @@ -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 () => {
Expand All @@ -49,17 +50,16 @@ 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);
});

it('reads from the correct user key and ignores another user data', async () => {
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);
Expand All @@ -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);
});
});
113 changes: 113 additions & 0 deletions src/__tests__/utils/encryptedStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AsyncStorage>;
const mockSecureStore = SecureStore as jest.Mocked<typeof SecureStore>;

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<string, string> = {};
const asyncStorageData: Record<string, string> = {};

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\./)
);
});
});
4 changes: 2 additions & 2 deletions src/services/formCache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { encryptedGetItem, encryptedRemoveItem, encryptedSetItem } from '../utils/encryptedStorage';

import { safeStorageWrite } from '../utils/storage';

Expand Down Expand Up @@ -161,7 +161,7 @@ export function getSuggestionForField(
}

export async function clearFormCache(storageKey: string): Promise<void> {
await AsyncStorage.removeItem(storageKey);
await encryptedRemoveItem(storageKey);
}

/** Maps profile/edit labels to shared cache keys. */
Expand Down
7 changes: 6 additions & 1 deletion src/store/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';

import { createHydrationErrorRecovery, secureStorageJSONStorage, toUnixMs } from './persistence';
import { createHydrationErrorRecovery, secureStorageJSONStorage, toUnixMs } from './persistence';
import { clearFormCache, getFormCacheStorageKey } from '../services/formCache';
import { sentryContextService } from '../services/sentryContext';

export interface User {
Expand Down Expand Up @@ -91,6 +92,7 @@ export const useAppStore = create<AppState>()(
setAuthLoading: isAuthLoading => set({ isAuthLoading }, false, 'setAuthLoading'),
setAuthError: authError => set({ authError }, false, 'setAuthError'),
logout: () => {
const userId = get().user?.id;
set(
{
user: null,
Expand All @@ -108,6 +110,9 @@ export const useAppStore = create<AppState>()(
// 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'),
Expand Down
87 changes: 87 additions & 0 deletions src/utils/encryptedStorage.ts
Original file line number Diff line number Diff line change
@@ -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<CryptoKey> {
const secureKey = toSecureStoreKey(storageKey);
const stored = await SecureStore.getItemAsync(secureKey);

if (stored) {
return Crypto.subtle.importKey(

Check failure on line 32 in src/utils/encryptedStorage.ts

View workflow job for this annotation

GitHub Actions / ci

'subtle' not found in imported namespace 'Crypto'
'raw',
base64ToUint8(stored),
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}

const key = (await Crypto.subtle.generateKey(

Check failure on line 41 in src/utils/encryptedStorage.ts

View workflow job for this annotation

GitHub Actions / ci

'subtle' not found in imported namespace 'Crypto'
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
)) as CryptoKey;

const raw = await Crypto.subtle.exportKey('raw', key);

Check failure on line 47 in src/utils/encryptedStorage.ts

View workflow job for this annotation

GitHub Actions / ci

'subtle' not found in imported namespace 'Crypto'
await SecureStore.setItemAsync(secureKey, uint8ToBase64(new Uint8Array(raw as ArrayBuffer)));

return key;
}

export async function encryptedSetItem(storageKey: string, value: string): Promise<void> {
const key = await getOrCreateAesKey(storageKey);
const iv = Crypto.getRandomBytes(12);
const ciphertext = await Crypto.subtle.encrypt(

Check failure on line 56 in src/utils/encryptedStorage.ts

View workflow job for this annotation

GitHub Actions / ci

'subtle' not found in imported namespace 'Crypto'
{ 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<string | null> {
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);

Check failure on line 76 in src/utils/encryptedStorage.ts

View workflow job for this annotation

GitHub Actions / ci

'subtle' not found in imported namespace 'Crypto'

return new TextDecoder().decode(decrypted as ArrayBuffer);
} catch {
return null;
}
}

export async function encryptedRemoveItem(storageKey: string): Promise<void> {
await AsyncStorage.removeItem(storageKey);
await SecureStore.deleteItemAsync(toSecureStoreKey(storageKey)).catch(() => {});
}
Loading