@ethora/chat-component-rn 26.6.13 → 26.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +22 -0
  3. package/lib/commonjs/assets/icons.js +2 -2
  4. package/lib/commonjs/assets/icons.js.map +1 -1
  5. package/lib/commonjs/components/AuthForms/Login.js +2 -3
  6. package/lib/commonjs/components/AuthForms/Login.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js +9 -8
  8. package/lib/commonjs/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
  9. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js +110 -37
  10. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js.map +1 -1
  11. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js +16 -14
  12. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  13. package/lib/commonjs/components/Modals/NewChatModal/ModalContent.js +2 -1
  14. package/lib/commonjs/components/Modals/NewChatModal/ModalContent.js.map +1 -1
  15. package/lib/commonjs/components/Modals/NewChatModal/UpdNewChatModal.js +8 -0
  16. package/lib/commonjs/components/Modals/NewChatModal/UpdNewChatModal.js.map +1 -1
  17. package/lib/commonjs/components/Modals/UserProfileModal/EditUserModal.js +7 -21
  18. package/lib/commonjs/components/Modals/UserProfileModal/EditUserModal.js.map +1 -1
  19. package/lib/commonjs/components/styled/SendInput.js +0 -10
  20. package/lib/commonjs/components/styled/SendInput.js.map +1 -1
  21. package/lib/commonjs/context/xmppProvider.js +2 -3
  22. package/lib/commonjs/context/xmppProvider.js.map +1 -1
  23. package/lib/commonjs/helpers/ensureScopedChatCache.js +2 -2
  24. package/lib/commonjs/helpers/ensureScopedChatCache.js.map +1 -1
  25. package/lib/commonjs/helpers/persistCrypto.js +122 -0
  26. package/lib/commonjs/helpers/persistCrypto.js.map +1 -0
  27. package/lib/commonjs/helpers/pickImageAsset.js +68 -0
  28. package/lib/commonjs/helpers/pickImageAsset.js.map +1 -0
  29. package/lib/commonjs/helpers/resolveInitBeforeLoadUser.js +5 -6
  30. package/lib/commonjs/helpers/resolveInitBeforeLoadUser.js.map +1 -1
  31. package/lib/commonjs/helpers/secureKeyValue.js +83 -0
  32. package/lib/commonjs/helpers/secureKeyValue.js.map +1 -0
  33. package/lib/commonjs/helpers/secureStoreRuntime.js +59 -0
  34. package/lib/commonjs/helpers/secureStoreRuntime.js.map +1 -0
  35. package/lib/commonjs/helpers/secureUserStorage.js +75 -0
  36. package/lib/commonjs/helpers/secureUserStorage.js.map +1 -0
  37. package/lib/commonjs/hooks/useLogout.js +15 -7
  38. package/lib/commonjs/hooks/useLogout.js.map +1 -1
  39. package/lib/commonjs/main.js +6 -0
  40. package/lib/commonjs/main.js.map +1 -1
  41. package/lib/commonjs/networking/authRefresh.js +4 -5
  42. package/lib/commonjs/networking/authRefresh.js.map +1 -1
  43. package/lib/commonjs/roomStore/chatSettingsSlice.js +3 -4
  44. package/lib/commonjs/roomStore/chatSettingsSlice.js.map +1 -1
  45. package/lib/commonjs/roomStore/persistence.js +29 -15
  46. package/lib/commonjs/roomStore/persistence.js.map +1 -1
  47. package/lib/commonjs/roomStore/roomsSlice.js +2 -0
  48. package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
  49. package/lib/module/assets/icons.js +1 -1
  50. package/lib/module/assets/icons.js.map +1 -1
  51. package/lib/module/components/AuthForms/Login.js +2 -3
  52. package/lib/module/components/AuthForms/Login.js.map +1 -1
  53. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js +9 -8
  54. package/lib/module/components/MainComponents/ProfileImagePlaceholder.js.map +1 -1
  55. package/lib/module/components/Menu/HeaderRoomListMenu.js +110 -38
  56. package/lib/module/components/Menu/HeaderRoomListMenu.js.map +1 -1
  57. package/lib/module/components/Modals/AttachSheet/AttachSheet.js +16 -14
  58. package/lib/module/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  59. package/lib/module/components/Modals/NewChatModal/ModalContent.js +2 -1
  60. package/lib/module/components/Modals/NewChatModal/ModalContent.js.map +1 -1
  61. package/lib/module/components/Modals/NewChatModal/UpdNewChatModal.js +8 -0
  62. package/lib/module/components/Modals/NewChatModal/UpdNewChatModal.js.map +1 -1
  63. package/lib/module/components/Modals/UserProfileModal/EditUserModal.js +9 -23
  64. package/lib/module/components/Modals/UserProfileModal/EditUserModal.js.map +1 -1
  65. package/lib/module/components/styled/SendInput.js +1 -11
  66. package/lib/module/components/styled/SendInput.js.map +1 -1
  67. package/lib/module/context/xmppProvider.js +2 -3
  68. package/lib/module/context/xmppProvider.js.map +1 -1
  69. package/lib/module/helpers/ensureScopedChatCache.js +2 -2
  70. package/lib/module/helpers/ensureScopedChatCache.js.map +1 -1
  71. package/lib/module/helpers/persistCrypto.js +115 -0
  72. package/lib/module/helpers/persistCrypto.js.map +1 -0
  73. package/lib/module/helpers/pickImageAsset.js +63 -0
  74. package/lib/module/helpers/pickImageAsset.js.map +1 -0
  75. package/lib/module/helpers/resolveInitBeforeLoadUser.js +5 -6
  76. package/lib/module/helpers/resolveInitBeforeLoadUser.js.map +1 -1
  77. package/lib/module/helpers/secureKeyValue.js +75 -0
  78. package/lib/module/helpers/secureKeyValue.js.map +1 -0
  79. package/lib/module/helpers/secureStoreRuntime.js +52 -0
  80. package/lib/module/helpers/secureStoreRuntime.js.map +1 -0
  81. package/lib/module/helpers/secureUserStorage.js +72 -0
  82. package/lib/module/helpers/secureUserStorage.js.map +1 -0
  83. package/lib/module/hooks/useLogout.js +15 -7
  84. package/lib/module/hooks/useLogout.js.map +1 -1
  85. package/lib/module/main.js +1 -1
  86. package/lib/module/main.js.map +1 -1
  87. package/lib/module/networking/authRefresh.js +4 -5
  88. package/lib/module/networking/authRefresh.js.map +1 -1
  89. package/lib/module/roomStore/chatSettingsSlice.js +3 -4
  90. package/lib/module/roomStore/chatSettingsSlice.js.map +1 -1
  91. package/lib/module/roomStore/persistence.js +30 -15
  92. package/lib/module/roomStore/persistence.js.map +1 -1
  93. package/lib/module/roomStore/roomsSlice.js +2 -0
  94. package/lib/module/roomStore/roomsSlice.js.map +1 -1
  95. package/lib/typescript/assets/icons.d.ts +4 -0
  96. package/lib/typescript/assets/icons.d.ts.map +1 -1
  97. package/lib/typescript/components/AuthForms/Login.d.ts.map +1 -1
  98. package/lib/typescript/components/MainComponents/ProfileImagePlaceholder.d.ts.map +1 -1
  99. package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts +3 -0
  100. package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts.map +1 -1
  101. package/lib/typescript/components/Modals/AttachSheet/AttachSheet.d.ts.map +1 -1
  102. package/lib/typescript/components/Modals/NewChatModal/ModalContent.d.ts +4 -2
  103. package/lib/typescript/components/Modals/NewChatModal/ModalContent.d.ts.map +1 -1
  104. package/lib/typescript/components/Modals/NewChatModal/UpdNewChatModal.d.ts.map +1 -1
  105. package/lib/typescript/components/Modals/UserProfileModal/EditUserModal.d.ts.map +1 -1
  106. package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
  107. package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
  108. package/lib/typescript/helpers/persistCrypto.d.ts +13 -0
  109. package/lib/typescript/helpers/persistCrypto.d.ts.map +1 -0
  110. package/lib/typescript/helpers/pickImageAsset.d.ts +27 -0
  111. package/lib/typescript/helpers/pickImageAsset.d.ts.map +1 -0
  112. package/lib/typescript/helpers/resolveInitBeforeLoadUser.d.ts.map +1 -1
  113. package/lib/typescript/helpers/secureKeyValue.d.ts +13 -0
  114. package/lib/typescript/helpers/secureKeyValue.d.ts.map +1 -0
  115. package/lib/typescript/helpers/secureStoreRuntime.d.ts +29 -0
  116. package/lib/typescript/helpers/secureStoreRuntime.d.ts.map +1 -0
  117. package/lib/typescript/helpers/secureUserStorage.d.ts +23 -0
  118. package/lib/typescript/helpers/secureUserStorage.d.ts.map +1 -0
  119. package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
  120. package/lib/typescript/main.d.ts +1 -1
  121. package/lib/typescript/main.d.ts.map +1 -1
  122. package/lib/typescript/networking/authRefresh.d.ts.map +1 -1
  123. package/lib/typescript/roomStore/chatSettingsSlice.d.ts.map +1 -1
  124. package/lib/typescript/roomStore/persistence.d.ts.map +1 -1
  125. package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
  126. package/lib/typescript/types/types.d.ts +12 -0
  127. package/lib/typescript/types/types.d.ts.map +1 -1
  128. package/package.json +8 -1
  129. package/src/assets/icons.tsx +19 -0
  130. package/src/components/AuthForms/Login.tsx +2 -3
  131. package/src/components/MainComponents/ProfileImagePlaceholder.tsx +16 -9
  132. package/src/components/Menu/HeaderRoomListMenu.tsx +105 -11
  133. package/src/components/Modals/AttachSheet/AttachSheet.tsx +16 -14
  134. package/src/components/Modals/NewChatModal/ModalContent.tsx +6 -3
  135. package/src/components/Modals/NewChatModal/UpdNewChatModal.tsx +12 -1
  136. package/src/components/Modals/UserProfileModal/EditUserModal.tsx +9 -25
  137. package/src/components/styled/SendInput.tsx +0 -10
  138. package/src/context/xmppProvider.tsx +2 -3
  139. package/src/helpers/ensureScopedChatCache.ts +2 -2
  140. package/src/helpers/persistCrypto.ts +127 -0
  141. package/src/helpers/pickImageAsset.ts +68 -0
  142. package/src/helpers/resolveInitBeforeLoadUser.ts +5 -10
  143. package/src/helpers/secureKeyValue.ts +80 -0
  144. package/src/helpers/secureStoreRuntime.ts +62 -0
  145. package/src/helpers/secureUserStorage.ts +80 -0
  146. package/src/hooks/useLogout.tsx +15 -9
  147. package/src/main.ts +1 -1
  148. package/src/networking/authRefresh.ts +4 -9
  149. package/src/roomStore/chatSettingsSlice.ts +3 -6
  150. package/src/roomStore/persistence.ts +39 -19
  151. package/src/roomStore/roomsSlice.ts +2 -0
  152. package/src/types/types.ts +14 -0
@@ -3,7 +3,7 @@ import { store } from '../roomStore';
3
3
  import { logout } from '../roomStore/chatSettingsSlice';
4
4
  import { setLogoutState } from '../roomStore/roomsSlice';
5
5
  import { asyncLocalStorage } from '../hooks/useLocalStorage';
6
- import { localStorageConstants } from './constants/LOCAL_STORAGE';
6
+ import { secureUserStorage } from './secureUserStorage';
7
7
  import { clearRoomsRestCache } from '../networking/api-requests/rooms.api';
8
8
  import { clearPersistedState } from '../roomStore/persistence';
9
9
  import {
@@ -53,7 +53,7 @@ export async function ensureScopedChatCache(config?: IConfig): Promise<void> {
53
53
  clearRoomsRestCache();
54
54
  store.dispatch(setLogoutState());
55
55
  store.dispatch(logout());
56
- await asyncLocalStorage(localStorageConstants.ETHORA_USER).remove();
56
+ await secureUserStorage().remove();
57
57
  await clearPersistedState();
58
58
  }
59
59
 
@@ -0,0 +1,127 @@
1
+ import CryptoJS from 'crypto-js';
2
+ import { secureGet, secureSet } from './secureKeyValue';
3
+
4
+ // AES-256-CBC at rest for the bulk persisted state (message history +
5
+ // room list) that `roomStore/persistence.ts` writes to AsyncStorage.
6
+ // This is envelope encryption, not a SecureStore write of the payload
7
+ // itself: expo-secure-store has historically capped a single value's
8
+ // size on Android (chunking exists in newer releases, but the payload
9
+ // here is unbounded — up to MESSAGE_LIMIT messages per room, times
10
+ // every room), so only the small symmetric KEY goes through the
11
+ // platform Keychain/Keystore (`secureKeyValue.ts`); the ciphertext
12
+ // itself stays in plain AsyncStorage, unreadable without that key.
13
+ const CIPHER_KEY_STORE_KEY = 'ethora_history_cipher_key';
14
+ const ENVELOPE_VERSION = 1;
15
+ const KEY_BYTES = 32; // AES-256
16
+ const IV_BYTES = 16; // one AES block
17
+
18
+ interface Envelope {
19
+ v: number;
20
+ iv: string; // base64
21
+ ct: string; // base64 ciphertext
22
+ }
23
+
24
+ /**
25
+ * `crypto-js`'s own `WordArray.random()` falls back to `Math.random()`
26
+ * when it can't detect a browser `window.crypto` — not safe for key/IV
27
+ * material. `react-native-get-random-values` (a REQUIRED peer
28
+ * dependency, imported at the top of `main.ts`/`index.js` before
29
+ * anything else in the SDK runs) polyfills the real thing onto
30
+ * `global.crypto.getRandomValues` instead; source bytes from that and
31
+ * hand them to crypto-js as a WordArray rather than trust its default.
32
+ */
33
+ const randomWordArray = (byteLength: number): CryptoJS.lib.WordArray => {
34
+ const bytes = new Uint8Array(byteLength);
35
+ (global as any).crypto.getRandomValues(bytes);
36
+ const words: number[] = [];
37
+ for (let i = 0; i < bytes.length; i += 4) {
38
+ words.push(
39
+ ((bytes[i] || 0) << 24) |
40
+ ((bytes[i + 1] || 0) << 16) |
41
+ ((bytes[i + 2] || 0) << 8) |
42
+ (bytes[i + 3] || 0)
43
+ );
44
+ }
45
+ return CryptoJS.lib.WordArray.create(words, byteLength);
46
+ };
47
+
48
+ let cachedKeyPromise: Promise<CryptoJS.lib.WordArray> | null = null;
49
+
50
+ /**
51
+ * One AES-256 key per install, generated on first use and persisted
52
+ * through the SecureStore/Keychain bridge. Reused across logins on
53
+ * purpose: the ciphertext it protects is deleted wholesale on logout
54
+ * (`clearPersistedState`), so there is no cross-account data for a
55
+ * stable key to expose — only the encrypted blob of whoever is
56
+ * currently logged in ever exists on disk at a given time.
57
+ */
58
+ const loadOrCreateKey = (): Promise<CryptoJS.lib.WordArray> => {
59
+ if (cachedKeyPromise) {return cachedKeyPromise;}
60
+ cachedKeyPromise = (async () => {
61
+ const existing = await secureGet(CIPHER_KEY_STORE_KEY);
62
+ if (existing) {
63
+ return CryptoJS.enc.Base64.parse(existing);
64
+ }
65
+ const key = randomWordArray(KEY_BYTES);
66
+ await secureSet(CIPHER_KEY_STORE_KEY, CryptoJS.enc.Base64.stringify(key));
67
+ return key;
68
+ })();
69
+ return cachedKeyPromise;
70
+ };
71
+
72
+ /** Encrypt a JSON-serialisable payload for storage. Returns the string to persist. */
73
+ export async function encryptForPersist(plaintext: string): Promise<string> {
74
+ const key = await loadOrCreateKey();
75
+ const iv = randomWordArray(IV_BYTES);
76
+ const encrypted = CryptoJS.AES.encrypt(plaintext, key, {
77
+ iv,
78
+ mode: CryptoJS.mode.CBC,
79
+ padding: CryptoJS.pad.Pkcs7,
80
+ });
81
+ const envelope: Envelope = {
82
+ v: ENVELOPE_VERSION,
83
+ iv: CryptoJS.enc.Base64.stringify(iv),
84
+ ct: encrypted.ciphertext.toString(CryptoJS.enc.Base64),
85
+ };
86
+ return JSON.stringify(envelope);
87
+ }
88
+
89
+ /**
90
+ * Decrypt a value previously written by `encryptForPersist`. Returns
91
+ * null (never throws) for anything that isn't a valid envelope for the
92
+ * current key — including a plaintext blob from a pre-encryption
93
+ * install: the app just re-hydrates that room/history from the server,
94
+ * same as any other empty-cache cold start.
95
+ */
96
+ export async function decryptFromPersist(
97
+ stored: string
98
+ ): Promise<string | null> {
99
+ try {
100
+ const envelope = JSON.parse(stored) as Partial<Envelope>;
101
+ if (
102
+ !envelope ||
103
+ envelope.v !== ENVELOPE_VERSION ||
104
+ !envelope.iv ||
105
+ !envelope.ct
106
+ ) {
107
+ return null;
108
+ }
109
+ const key = await loadOrCreateKey();
110
+ const iv = CryptoJS.enc.Base64.parse(envelope.iv);
111
+ const ciphertext = CryptoJS.enc.Base64.parse(envelope.ct);
112
+ const decrypted = CryptoJS.AES.decrypt(
113
+ { ciphertext } as CryptoJS.lib.CipherParams,
114
+ key,
115
+ { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }
116
+ );
117
+ const plaintext = decrypted.toString(CryptoJS.enc.Utf8);
118
+ return plaintext || null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** Test seam only. */
125
+ export const __resetPersistCryptoKeyForTests = (): void => {
126
+ cachedKeyPromise = null;
127
+ };
@@ -0,0 +1,68 @@
1
+ import { Alert, Linking } from 'react-native';
2
+ import * as ImagePicker from 'expo-image-picker';
3
+
4
+ /**
5
+ * A picked image in the shape React Native's FormData understands.
6
+ *
7
+ * NOT a web `File`: RN streams a multipart part straight from a `file://`
8
+ * uri and has no usable Blob/File/atob path. Screens ported from the web
9
+ * client used to carry base64 -> Blob -> File helpers that could never
10
+ * have run here.
11
+ */
12
+ export interface PickedImage {
13
+ uri: string;
14
+ type: string;
15
+ name: string;
16
+ }
17
+
18
+ /**
19
+ * Ask for library permission, open the picker, and normalise the result.
20
+ *
21
+ * Returns `null` for every non-error outcome — permission refused, user
22
+ * cancelled — so call sites can simply keep their current value.
23
+ *
24
+ * Exists because the avatar pickers are wired to `onPress`, which hands
25
+ * the callback a SyntheticEvent. Storing that event as "the image" (what
26
+ * two screens used to do) rendered a pooled event as an <Image source>
27
+ * and then crashed with "Property is not configurable" when React tried
28
+ * to release it — on top of never letting the user pick anything.
29
+ */
30
+ export const pickImageAsset = async (): Promise<PickedImage | null> => {
31
+ try {
32
+ // expo-image-picker owns the permission prompt internally and
33
+ // returns a `granted` flag.
34
+ const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
35
+ if (!perm.granted) {
36
+ Alert.alert(
37
+ 'Permission required',
38
+ 'Photo library permission is needed to select images.',
39
+ [
40
+ { text: 'Cancel', style: 'cancel' },
41
+ { text: 'Open Settings', onPress: () => Linking.openSettings() },
42
+ ]
43
+ );
44
+ return null;
45
+ }
46
+
47
+ const result = await ImagePicker.launchImageLibraryAsync({
48
+ mediaTypes: ['images'],
49
+ allowsEditing: true,
50
+ aspect: [1, 1],
51
+ quality: 0.8,
52
+ });
53
+ if (result.canceled || !result.assets?.[0]) {
54
+ return null;
55
+ }
56
+
57
+ const asset = result.assets[0];
58
+ const originalName = asset.fileName || asset.uri.split('/').pop();
59
+ return {
60
+ uri: asset.uri,
61
+ type: asset.mimeType || 'image/jpeg',
62
+ name: originalName || `image_${Date.now()}.jpg`,
63
+ };
64
+ } catch (error) {
65
+ console.error('Failed to pick an image:', error);
66
+ return null;
67
+ }
68
+ };
@@ -6,8 +6,7 @@ import { IConfig, User } from '../types/types';
6
6
  import { store } from '../roomStore';
7
7
  import { setUser } from '../roomStore/chatSettingsSlice';
8
8
  import { walletToUsername } from './walletUsername';
9
- import { asyncLocalStorage } from '../hooks/useLocalStorage';
10
- import { localStorageConstants } from './constants/LOCAL_STORAGE';
9
+ import { secureUserStorage } from './secureUserStorage';
11
10
 
12
11
  interface ResolveInitBeforeLoadUserOptions {
13
12
  config?: IConfig;
@@ -95,9 +94,7 @@ const readJwtIat = (token?: string | null): number => {
95
94
  */
96
95
  const adoptFresherPersistedSession = async (candidate: User): Promise<User> => {
97
96
  try {
98
- const stored = await asyncLocalStorage<User>(
99
- localStorageConstants.ETHORA_USER
100
- ).get();
97
+ const stored = await secureUserStorage().get();
101
98
  if (!stored) {return candidate;}
102
99
 
103
100
  const sameUser =
@@ -329,13 +326,13 @@ export const resolveInitBeforeLoadUser = async (
329
326
 
330
327
  // Priority 5: persisted user in AsyncStorage
331
328
  try {
332
- const storedUser = await asyncLocalStorage<User>(localStorageConstants.ETHORA_USER).get();
329
+ const storedUser = await secureUserStorage().get();
333
330
  if (storedUser && (storedUser.token || storedUser.refreshToken || storedUser.xmppPassword)) {
334
331
  const hydrated = await tryHydrateViaMy(storedUser, myEndpoint, signal).catch(
335
332
  () => null
336
333
  );
337
334
  if (hydrated && hasXmppCredentials(hydrated)) {return hydrated;}
338
- await asyncLocalStorage(localStorageConstants.ETHORA_USER).remove();
335
+ await secureUserStorage().remove();
339
336
  }
340
337
  } catch {
341
338
  // ignore storage errors
@@ -407,9 +404,7 @@ export const refreshUserCredentialsForXmpp = async (
407
404
  // should already mirror this, but if the redux state got cleared
408
405
  // mid-session (logout race) this gives us one last shot.
409
406
  try {
410
- const storedUser = await asyncLocalStorage<User>(
411
- localStorageConstants.ETHORA_USER
412
- ).get();
407
+ const storedUser = await secureUserStorage().get();
413
408
  if (
414
409
  storedUser &&
415
410
  (storedUser.token || storedUser.refreshToken || storedUser.xmppPassword)
@@ -0,0 +1,80 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
+ import { loadSecureStore } from './secureStoreRuntime';
3
+ import { pushLog } from '../utils/devLogger';
4
+
5
+ // Fallback keys are prefixed and namespaced away from every other
6
+ // AsyncStorage key this SDK writes, so a host that never installs
7
+ // expo-secure-store can still be told apart from one that used to lack
8
+ // it and has since added it (see `secureSet`'s stale-fallback cleanup).
9
+ const FALLBACK_PREFIX = '@ethora/secure-fallback:';
10
+
11
+ let warnedFallback = false;
12
+ const warnFallbackOnce = () => {
13
+ if (warnedFallback) {return;}
14
+ warnedFallback = true;
15
+ pushLog(
16
+ 'warn',
17
+ 'expo-secure-store is not installed — auth tokens, the XMPP ' +
18
+ 'password, and the message-history cipher key are falling back ' +
19
+ 'to plain AsyncStorage instead of the platform Keychain/Keystore. ' +
20
+ 'Add the optional peer dependency `expo-secure-store` to your app ' +
21
+ 'to store them securely.'
22
+ );
23
+ };
24
+
25
+ /**
26
+ * Small-value secure KV store: platform Keychain/Keystore via
27
+ * expo-secure-store when the host has it installed, plain (but still
28
+ * namespaced) AsyncStorage otherwise. NOT for bulk data — see
29
+ * `persistCrypto.ts`'s size note on why message history goes through
30
+ * AES instead of straight into this store.
31
+ */
32
+ export async function secureGet(key: string): Promise<string | null> {
33
+ const store = loadSecureStore();
34
+ if (store) {
35
+ try {
36
+ return await store.getItemAsync(key);
37
+ } catch (e) {
38
+ pushLog('warn', `secureStore: getItemAsync(${key}) failed`, e);
39
+ return null;
40
+ }
41
+ }
42
+ warnFallbackOnce();
43
+ return AsyncStorage.getItem(FALLBACK_PREFIX + key);
44
+ }
45
+
46
+ export async function secureSet(key: string, value: string): Promise<void> {
47
+ const store = loadSecureStore();
48
+ if (store) {
49
+ try {
50
+ await store.setItemAsync(key, value);
51
+ // Clean up a stale plaintext fallback copy from before this host
52
+ // added expo-secure-store — otherwise it sits on disk forever.
53
+ AsyncStorage.removeItem(FALLBACK_PREFIX + key).catch(() => undefined);
54
+ return;
55
+ } catch (e) {
56
+ pushLog('warn', `secureStore: setItemAsync(${key}) failed`, e);
57
+ return;
58
+ }
59
+ }
60
+ warnFallbackOnce();
61
+ await AsyncStorage.setItem(FALLBACK_PREFIX + key, value);
62
+ }
63
+
64
+ export async function secureRemove(key: string): Promise<void> {
65
+ const store = loadSecureStore();
66
+ if (store) {
67
+ try {
68
+ await store.deleteItemAsync(key);
69
+ } catch {
70
+ // Key may simply not exist — deleteItemAsync throwing here isn't
71
+ // actionable either way.
72
+ }
73
+ }
74
+ await AsyncStorage.removeItem(FALLBACK_PREFIX + key).catch(() => undefined);
75
+ }
76
+
77
+ /** Test seam only. */
78
+ export const __resetSecureKeyValueWarningForTests = (): void => {
79
+ warnedFallback = false;
80
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Optional-dependency bridge for `expo-secure-store` — the Keychain
3
+ * (iOS) / Keystore-backed EncryptedSharedPreferences (Android) API.
4
+ *
5
+ * Mirrors `components/VideoCalls/livekitRuntime.ts`: a plain top-level
6
+ * `import ... from 'expo-secure-store'` is not allowed anywhere in the
7
+ * SDK, since Metro resolves imports at bundle time and a host that
8
+ * hasn't added this optional peer dependency would fail to build over
9
+ * a feature it never opted into. Everything goes through
10
+ * `loadSecureStore()`, which requires the module lazily and returns
11
+ * null when it isn't installed — callers fall back to plain
12
+ * AsyncStorage rather than crash (see `secureKeyValue.ts`).
13
+ */
14
+
15
+ export interface SecureStoreRuntime {
16
+ getItemAsync: (key: string) => Promise<string | null>;
17
+ setItemAsync: (key: string, value: string) => Promise<void>;
18
+ deleteItemAsync: (key: string) => Promise<void>;
19
+ }
20
+
21
+ let cached: SecureStoreRuntime | null | undefined;
22
+
23
+ /**
24
+ * Resolve the SecureStore runtime, or null when the optional native
25
+ * package isn't installed in the host app. Result is memoised
26
+ * (including the failure) so a missing dependency doesn't re-throw on
27
+ * every read/write.
28
+ */
29
+ export const loadSecureStore = (): SecureStoreRuntime | null => {
30
+ if (cached !== undefined) {
31
+ return cached;
32
+ }
33
+
34
+ try {
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const mod = require('expo-secure-store');
37
+ if (
38
+ typeof mod?.getItemAsync !== 'function' ||
39
+ typeof mod?.setItemAsync !== 'function' ||
40
+ typeof mod?.deleteItemAsync !== 'function'
41
+ ) {
42
+ cached = null;
43
+ } else {
44
+ cached = {
45
+ getItemAsync: mod.getItemAsync,
46
+ setItemAsync: mod.setItemAsync,
47
+ deleteItemAsync: mod.deleteItemAsync,
48
+ };
49
+ }
50
+ } catch {
51
+ cached = null;
52
+ }
53
+
54
+ return cached;
55
+ };
56
+
57
+ export const isSecureStoreAvailable = (): boolean => loadSecureStore() !== null;
58
+
59
+ /** Test seam only. */
60
+ export const __resetSecureStoreRuntimeForTests = (): void => {
61
+ cached = undefined;
62
+ };
@@ -0,0 +1,80 @@
1
+ import { User } from '../types/types';
2
+ import { asyncLocalStorage } from '../hooks/useLocalStorage';
3
+ import { localStorageConstants } from './constants/LOCAL_STORAGE';
4
+ import { secureGet, secureSet, secureRemove } from './secureKeyValue';
5
+
6
+ // Keychain (iOS) / Keystore-backed EncryptedSharedPreferences (Android)
7
+ // keys for the four live bearer credentials on `User`. Deliberately one
8
+ // key per field rather than one JSON blob: expo-secure-store historically
9
+ // caps a single value's size on Android, and four short JWTs each stay
10
+ // comfortably under that ceiling where one combined blob might not.
11
+ const SECRET_KEYS = {
12
+ token: 'ethora_auth_token',
13
+ refreshToken: 'ethora_auth_refresh_token',
14
+ xmppPassword: 'ethora_auth_xmpp_password',
15
+ fileToken: 'ethora_auth_file_token',
16
+ } as const;
17
+
18
+ type SecretField = keyof typeof SECRET_KEYS;
19
+ const SECRET_FIELDS = Object.keys(SECRET_KEYS) as SecretField[];
20
+
21
+ /**
22
+ * Persisted-user storage, split across two backends behind the exact
23
+ * same `{ get, set, remove }` shape `asyncLocalStorage<User>(...)`
24
+ * already exposed — every existing call site swaps in by changing only
25
+ * the constructor call, `get()`/`set()`/`remove()` keep returning/taking
26
+ * a complete `User`.
27
+ *
28
+ * The four live credentials (REST access + refresh token, XMPP
29
+ * password, secure-media fileToken) never touch plain AsyncStorage:
30
+ * they go through `secureGet`/`secureSet`/`secureRemove` (platform
31
+ * Keychain/Keystore, falling back to namespaced AsyncStorage only when
32
+ * the host hasn't installed the optional `expo-secure-store` peer dep).
33
+ * Everything else on `User` — name, avatar, ids, xmppUsername, wallet
34
+ * address, none of it a bearer credential — keeps living under the
35
+ * existing `ETHORA_USER` AsyncStorage key, unchanged.
36
+ */
37
+ export function secureUserStorage() {
38
+ const get = async (): Promise<User | null> => {
39
+ const profile = await asyncLocalStorage<User>(
40
+ localStorageConstants.ETHORA_USER
41
+ ).get();
42
+ if (!profile) {return null;}
43
+
44
+ const secretValues = await Promise.all(
45
+ SECRET_FIELDS.map((field) => secureGet(SECRET_KEYS[field]))
46
+ );
47
+
48
+ const merged: User = { ...profile };
49
+ SECRET_FIELDS.forEach((field, i) => {
50
+ (merged as any)[field] = secretValues[i] || '';
51
+ });
52
+ return merged;
53
+ };
54
+
55
+ const set = async (user: User): Promise<void> => {
56
+ const profile: any = { ...user };
57
+ for (const field of SECRET_FIELDS) {
58
+ profile[field] = '';
59
+ }
60
+
61
+ await Promise.all([
62
+ asyncLocalStorage<User>(localStorageConstants.ETHORA_USER).set(profile),
63
+ ...SECRET_FIELDS.map((field) => {
64
+ const value = user?.[field] as string | undefined;
65
+ return value
66
+ ? secureSet(SECRET_KEYS[field], value)
67
+ : secureRemove(SECRET_KEYS[field]);
68
+ }),
69
+ ]);
70
+ };
71
+
72
+ const remove = async (): Promise<void> => {
73
+ await Promise.all([
74
+ asyncLocalStorage(localStorageConstants.ETHORA_USER).remove(),
75
+ ...SECRET_FIELDS.map((field) => secureRemove(SECRET_KEYS[field])),
76
+ ]);
77
+ };
78
+
79
+ return { get, set, remove };
80
+ }
@@ -9,7 +9,7 @@ import { clearHeap } from '../roomStore/roomHeapSlice';
9
9
  import { pushSubscriptionService } from '../services/pushSubscriptionService';
10
10
  import { clearRoomsRestCache } from '../networking/api-requests/rooms.api';
11
11
  import { clearPersistedState } from '../roomStore/persistence';
12
- import { localStorageConstants } from '../helpers/constants/LOCAL_STORAGE';
12
+ import { secureUserStorage } from '../helpers/secureUserStorage';
13
13
 
14
14
  // AsyncStorage keys the library writes but that aren't cleared by any
15
15
  // slice reducer. Listed here so a single logout call wipes the full
@@ -120,17 +120,23 @@ const logoutService = {
120
120
 
121
121
  // 6. Stray keys the slices don't touch.
122
122
  try {
123
- await AsyncStorage.multiRemove([
124
- ...LIBRARY_STRAY_KEYS,
125
- // Belt-and-suspenders: the chat slice's `logout` reducer
126
- // schedules an async remove of ETHORA_USER fire-and-forget;
127
- // re-issue it here so the disk is provably clean before this
128
- // Promise resolves.
129
- localStorageConstants.ETHORA_USER,
130
- ]);
123
+ await AsyncStorage.multiRemove(LIBRARY_STRAY_KEYS);
131
124
  } catch (e) {
132
125
  console.warn('logoutService: stray-key clear failed', e);
133
126
  }
127
+
128
+ // 7. Belt-and-suspenders: the chat slice's `logout` reducer already
129
+ // fires an async ETHORA_USER removal, but re-issue it here (via
130
+ // the same secureUserStorage split the reducer uses) so BOTH the
131
+ // plain-AsyncStorage profile half AND the Keychain/Keystore
132
+ // secrets are provably gone before this Promise resolves — a bare
133
+ // `AsyncStorage.removeItem(ETHORA_USER)` here would only clear the
134
+ // profile half and leave the tokens behind.
135
+ try {
136
+ await secureUserStorage().remove();
137
+ } catch (e) {
138
+ console.warn('logoutService: secure user storage clear failed', e);
139
+ }
134
140
  },
135
141
  };
136
142
 
package/src/main.ts CHANGED
@@ -32,7 +32,7 @@ export { XmppProvider } from './context/xmppProvider';
32
32
  // Hooks that consumers usually need (badge counts, logout, push, etc.).
33
33
  export { useUnread } from './hooks/useUnreadMessagesCounter';
34
34
  export { useChatRoomFocus } from './hooks/useChatRoomFocus';
35
- export { logoutService } from './hooks/useLogout';
35
+ export { logoutService, useLogout } from './hooks/useLogout';
36
36
  export { useQRCodeChat, handleQRChatId } from './hooks/useQRCodeChatHandler';
37
37
  export { resendMessage } from './utils/resendMessage';
38
38
  // Font loader, exposed so hosts can also load the font outside <Chat> if needed.
@@ -1,8 +1,7 @@
1
1
  import http from './apiClient';
2
2
  import { store } from '../roomStore';
3
3
  import { refreshTokens } from '../roomStore/chatSettingsSlice';
4
- import { asyncLocalStorage } from '../hooks/useLocalStorage';
5
- import { localStorageConstants } from '../helpers/constants/LOCAL_STORAGE';
4
+ import { secureUserStorage } from '../helpers/secureUserStorage';
6
5
  import { pushLog } from '../utils/devLogger';
7
6
  import { User } from '../types/types';
8
7
 
@@ -163,9 +162,7 @@ const readCurrentRefreshToken = async (): Promise<string> => {
163
162
  return fromStore;
164
163
  }
165
164
 
166
- const stored = await asyncLocalStorage<User>(
167
- localStorageConstants.ETHORA_USER
168
- ).get();
165
+ const stored = await secureUserStorage().get();
169
166
  return stored?.refreshToken || '';
170
167
  };
171
168
 
@@ -190,7 +187,7 @@ const persistTokens = async (result: RefreshResult): Promise<void> => {
190
187
  // Write the post-dispatch snapshot rather than a read-modify-write
191
188
  // merge, so this can't race the reducer's own write back to a
192
189
  // stale copy of the surrounding user fields.
193
- await asyncLocalStorage<User>(localStorageConstants.ETHORA_USER).set(
190
+ await secureUserStorage().set(
194
191
  store.getState().chatSettingStore.user as User
195
192
  );
196
193
  } catch (error) {
@@ -204,9 +201,7 @@ const persistTokens = async (result: RefreshResult): Promise<void> => {
204
201
  * web build and this one stay structurally identical.
205
202
  */
206
203
  const adoptStoredTokens = async (): Promise<RefreshResult | null> => {
207
- const stored = await asyncLocalStorage<User>(
208
- localStorageConstants.ETHORA_USER
209
- ).get();
204
+ const stored = await secureUserStorage().get();
210
205
  if (!stored?.refreshToken || !stored?.token) {
211
206
  return null;
212
207
  }
@@ -11,8 +11,7 @@ import {
11
11
  } from '../types/types';
12
12
  import { Iso639_1Codes } from '../types/models/language.model';
13
13
  import type { TranslateMode } from '../utils/translateModePolicy';
14
- import { localStorageConstants } from '../helpers/constants/LOCAL_STORAGE';
15
- import { asyncLocalStorage } from '../hooks/useLocalStorage';
14
+ import { secureUserStorage } from '../helpers/secureUserStorage';
16
15
 
17
16
  export interface ChatState {
18
17
  user: User;
@@ -113,9 +112,7 @@ const initialState: ChatState = {
113
112
  const reducers = {
114
113
  setUser: (state: WritableDraft<ChatState>, action: PayloadAction<User>) => {
115
114
  state.user = unpackAndTransform(action.payload);
116
- asyncLocalStorage(localStorageConstants.ETHORA_USER).set(
117
- unpackAndTransform(action.payload)
118
- );
115
+ secureUserStorage().set(unpackAndTransform(action.payload));
119
116
  },
120
117
  updateUser(state: WritableDraft<ChatState>, action: PayloadAction<{ updates: Partial<User> }>) {
121
118
  const { updates } = action.payload;
@@ -186,7 +183,7 @@ const reducers = {
186
183
  state.client = undefined;
187
184
  state.langSource = undefined;
188
185
  state.translateMode = undefined;
189
- asyncLocalStorage(localStorageConstants.ETHORA_USER).remove();
186
+ secureUserStorage().remove();
190
187
  },
191
188
  setLangSource: (
192
189
  state: WritableDraft<ChatState>,