@ethora/chat-component-rn 26.10.6 → 26.10.7

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 (73) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/README.md +6 -3
  3. package/lib/commonjs/helpers/secureStoreRuntime.js +10 -1
  4. package/lib/commonjs/helpers/secureStoreRuntime.js.map +1 -1
  5. package/lib/commonjs/main.js +81 -0
  6. package/lib/commonjs/main.js.map +1 -1
  7. package/lib/commonjs/networking/api-requests/push.api.js +22 -7
  8. package/lib/commonjs/networking/api-requests/push.api.js.map +1 -1
  9. package/lib/commonjs/push/pushKeyStore.js +169 -0
  10. package/lib/commonjs/push/pushKeyStore.js.map +1 -0
  11. package/lib/commonjs/push/sealedBox.js +106 -0
  12. package/lib/commonjs/push/sealedBox.js.map +1 -0
  13. package/lib/commonjs/push/sealedPush.js +124 -0
  14. package/lib/commonjs/push/sealedPush.js.map +1 -0
  15. package/lib/commonjs/push/sealedPushTask.js +195 -0
  16. package/lib/commonjs/push/sealedPushTask.js.map +1 -0
  17. package/lib/commonjs/services/pushRegistration.js +12 -7
  18. package/lib/commonjs/services/pushRegistration.js.map +1 -1
  19. package/lib/commonjs/version.js +1 -1
  20. package/lib/module/helpers/secureStoreRuntime.js +10 -1
  21. package/lib/module/helpers/secureStoreRuntime.js.map +1 -1
  22. package/lib/module/main.js +6 -0
  23. package/lib/module/main.js.map +1 -1
  24. package/lib/module/networking/api-requests/push.api.js +21 -6
  25. package/lib/module/networking/api-requests/push.api.js.map +1 -1
  26. package/lib/module/push/pushKeyStore.js +159 -0
  27. package/lib/module/push/pushKeyStore.js.map +1 -0
  28. package/lib/module/push/sealedBox.js +97 -0
  29. package/lib/module/push/sealedBox.js.map +1 -0
  30. package/lib/module/push/sealedPush.js +117 -0
  31. package/lib/module/push/sealedPush.js.map +1 -0
  32. package/lib/module/push/sealedPushTask.js +187 -0
  33. package/lib/module/push/sealedPushTask.js.map +1 -0
  34. package/lib/module/services/pushRegistration.js +12 -7
  35. package/lib/module/services/pushRegistration.js.map +1 -1
  36. package/lib/module/version.js +1 -1
  37. package/lib/typescript/helpers/secureStoreRuntime.d.ts +17 -3
  38. package/lib/typescript/helpers/secureStoreRuntime.d.ts.map +1 -1
  39. package/lib/typescript/main.d.ts +6 -0
  40. package/lib/typescript/main.d.ts.map +1 -1
  41. package/lib/typescript/networking/api-requests/push.api.d.ts +14 -2
  42. package/lib/typescript/networking/api-requests/push.api.d.ts.map +1 -1
  43. package/lib/typescript/push/pushKeyStore.d.ts +35 -0
  44. package/lib/typescript/push/pushKeyStore.d.ts.map +1 -0
  45. package/lib/typescript/push/sealedBox.d.ts +24 -0
  46. package/lib/typescript/push/sealedBox.d.ts.map +1 -0
  47. package/lib/typescript/push/sealedPush.d.ts +61 -0
  48. package/lib/typescript/push/sealedPush.d.ts.map +1 -0
  49. package/lib/typescript/push/sealedPushTask.d.ts +33 -0
  50. package/lib/typescript/push/sealedPushTask.d.ts.map +1 -0
  51. package/lib/typescript/services/pushRegistration.d.ts +8 -0
  52. package/lib/typescript/services/pushRegistration.d.ts.map +1 -1
  53. package/lib/typescript/types/types.d.ts +6 -0
  54. package/lib/typescript/types/types.d.ts.map +1 -1
  55. package/lib/typescript/version.d.ts +1 -1
  56. package/package.json +11 -1
  57. package/plugins/notification-service/NotificationService.swift +113 -0
  58. package/plugins/notification-service/SealedBox.swift +355 -0
  59. package/plugins/notification-service/check/gen-vectors.mjs +24 -0
  60. package/plugins/notification-service/check/main.swift +37 -0
  61. package/plugins/notification-service/check/run.sh +12 -0
  62. package/plugins/notification-service/check/vectors.json +1 -0
  63. package/plugins/withNotificationServiceExtension.js +70 -18
  64. package/src/helpers/secureStoreRuntime.ts +21 -3
  65. package/src/main.ts +30 -0
  66. package/src/networking/api-requests/push.api.ts +29 -4
  67. package/src/push/pushKeyStore.ts +163 -0
  68. package/src/push/sealedBox.ts +104 -0
  69. package/src/push/sealedPush.ts +127 -0
  70. package/src/push/sealedPushTask.ts +175 -0
  71. package/src/services/pushRegistration.ts +25 -5
  72. package/src/types/types.ts +6 -0
  73. package/src/version.ts +1 -1
@@ -0,0 +1,127 @@
1
+ import { fromBase64 } from '../e2ee/crypto';
2
+ import { sealedBoxKeyId, sealedBoxOpen } from './sealedBox';
3
+
4
+ // The envelope of an encrypted chat push (ethora-backend,
5
+ // services/push/README.md, "Encrypted pushes"): the FCM `data` map on
6
+ // Android, the custom keys next to `aps` on iOS. Only ids travel in the
7
+ // clear; everything shown to the user is inside `enc`, a sealed box for
8
+ // the key the device registered with its token.
9
+
10
+ export const SEALED_PUSH_ALG = 'sealed-box-x25519';
11
+ export const SEALED_PUSH_VERSION = '1';
12
+
13
+ export interface SealedPushEnvelope {
14
+ v: string;
15
+ alg: string;
16
+ /** Id of the key the box was sealed for. */
17
+ kid: string;
18
+ /** Room JID. */
19
+ jid: string;
20
+ msgID: string;
21
+ /** Recipient. */
22
+ userJid: string;
23
+ /** Base64 of the sealed box; absent when the service could not seal. */
24
+ enc?: string;
25
+ }
26
+
27
+ /** What the box holds: the notification the service would have sent in the clear. */
28
+ export interface SealedPushContent {
29
+ title: string;
30
+ body: string;
31
+ sender: string;
32
+ senderJid: string;
33
+ chat: string;
34
+ chatJid: string;
35
+ msgID: string;
36
+ /** Sender avatar URL, '' when none. */
37
+ image: string;
38
+ }
39
+
40
+ export type SealedPushFailure =
41
+ /** No key on this device (never generated, or the store is not readable). */
42
+ | 'no-key'
43
+ /** Sealed for another key — the app was reinstalled or the key rotated after registration. */
44
+ | 'key-mismatch'
45
+ /** The service sent ids only (it could not seal). */
46
+ | 'no-ciphertext'
47
+ /** Not a box for our key, or damaged. */
48
+ | 'bad-ciphertext'
49
+ /** Opened, but the plaintext is not the expected JSON. */
50
+ | 'bad-content';
51
+
52
+ export type OpenSealedPushResult =
53
+ | { ok: true; content: SealedPushContent; envelope: SealedPushEnvelope }
54
+ | { ok: false; reason: SealedPushFailure; envelope: SealedPushEnvelope };
55
+
56
+ const str = (value: unknown): string => (typeof value === 'string' ? value : '');
57
+
58
+ /** The envelope of `data` when it is a sealed chat push, else null. */
59
+ export const sealedPushEnvelopeOf = (
60
+ data: Record<string, unknown> | null | undefined
61
+ ): SealedPushEnvelope | null => {
62
+ if (!data || data.alg !== SEALED_PUSH_ALG) {return null;}
63
+ const enc = str(data.enc);
64
+ return {
65
+ v: str(data.v) || SEALED_PUSH_VERSION,
66
+ alg: SEALED_PUSH_ALG,
67
+ kid: str(data.kid),
68
+ jid: str(data.jid),
69
+ msgID: str(data.msgID),
70
+ userJid: str(data.userJid),
71
+ ...(enc ? { enc } : {}),
72
+ };
73
+ };
74
+
75
+ export const isSealedPush = (data: Record<string, unknown> | null | undefined): boolean =>
76
+ sealedPushEnvelopeOf(data) !== null;
77
+
78
+ const parseContent = (plaintext: Uint8Array): SealedPushContent | null => {
79
+ try {
80
+ const parsed = JSON.parse(new TextDecoder().decode(plaintext));
81
+ if (!parsed || typeof parsed !== 'object') {return null;}
82
+ return {
83
+ title: str(parsed.title),
84
+ body: str(parsed.body),
85
+ sender: str(parsed.sender),
86
+ senderJid: str(parsed.senderJid),
87
+ chat: str(parsed.chat),
88
+ chatJid: str(parsed.chatJid),
89
+ msgID: str(parsed.msgID),
90
+ image: str(parsed.image),
91
+ };
92
+ } catch {
93
+ return null;
94
+ }
95
+ };
96
+
97
+ export interface SealedPushKey {
98
+ publicKey: Uint8Array;
99
+ secretKey: Uint8Array;
100
+ }
101
+
102
+ /**
103
+ * Open a sealed push with the device key. `key` null means there is no key
104
+ * on this device. Never throws: every failure names why, so the caller can
105
+ * show the placeholder and log the reason.
106
+ */
107
+ export function openSealedPush(
108
+ envelope: SealedPushEnvelope,
109
+ key: SealedPushKey | null
110
+ ): OpenSealedPushResult {
111
+ if (!key) {return { ok: false, reason: 'no-key', envelope };}
112
+ if (envelope.kid && envelope.kid !== sealedBoxKeyId(key.publicKey)) {
113
+ return { ok: false, reason: 'key-mismatch', envelope };
114
+ }
115
+ if (!envelope.enc) {return { ok: false, reason: 'no-ciphertext', envelope };}
116
+ let sealed: Uint8Array;
117
+ try {
118
+ sealed = fromBase64(envelope.enc);
119
+ } catch {
120
+ return { ok: false, reason: 'bad-ciphertext', envelope };
121
+ }
122
+ const plaintext = sealedBoxOpen(sealed, key.publicKey, key.secretKey);
123
+ if (!plaintext) {return { ok: false, reason: 'bad-ciphertext', envelope };}
124
+ const content = parseContent(plaintext);
125
+ if (!content) {return { ok: false, reason: 'bad-content', envelope };}
126
+ return { ok: true, content, envelope };
127
+ }
@@ -0,0 +1,175 @@
1
+ import { AppState, Platform } from 'react-native';
2
+ import { pushLog } from '../utils/devLogger';
3
+ import { loadPushSecretKey } from './pushKeyStore';
4
+ import { openSealedPush, sealedPushEnvelopeOf, type OpenSealedPushResult } from './sealedPush';
5
+
6
+ // Android half of encrypted pushes. A sealed push is data-only, so the OS
7
+ // shows nothing by itself: expo-notifications hands it to a background
8
+ // task (expo-task-manager, foreground, background and killed app alike)
9
+ // and this task opens the box and posts the notification. iOS does the
10
+ // same in the notification service extension; here `registerSealedPushTask`
11
+ // is a no-op.
12
+ //
13
+ // Both packages are optional peers, required lazily: a host that does not
14
+ // want encrypted pushes never pays for them.
15
+
16
+ export const SEALED_PUSH_TASK = 'ETHORA_SEALED_PUSH';
17
+
18
+ export interface SealedPushTaskOptions {
19
+ /** Android notification channel; the OS default channel when omitted. */
20
+ channelId?: string;
21
+ /** Shown when the box does not open (no key, another key, damaged). Default "New message". */
22
+ placeholderTitle?: string;
23
+ placeholderBody?: string;
24
+ }
25
+
26
+ const PLACEHOLDER_TITLE = 'New message';
27
+
28
+ let registered = false;
29
+
30
+ const loadTaskManager = (): any => {
31
+ try {
32
+ return require('expo-task-manager');
33
+ } catch {
34
+ return null;
35
+ }
36
+ };
37
+
38
+ const loadNotifications = (): any => {
39
+ try {
40
+ return require('expo-notifications');
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ /**
47
+ * The FCM data map out of the task payload. expo-notifications passes the
48
+ * message as `{ notification, data }`; the data keys are on `data`
49
+ * itself, `data.dataString` carries the same as a JSON string on some
50
+ * versions, and a Firebase `RemoteMessage` keeps them under `data.data`.
51
+ */
52
+ export const sealedPushDataOf = (payload: unknown): Record<string, unknown> | null => {
53
+ const root = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : null;
54
+ if (!root) {return null;}
55
+ const candidates: unknown[] = [root.data, root];
56
+ const data = root.data && typeof root.data === 'object' ? (root.data as Record<string, unknown>) : null;
57
+ if (data) {
58
+ if (typeof data.dataString === 'string') {
59
+ try {
60
+ candidates.unshift(JSON.parse(data.dataString));
61
+ } catch {
62
+ // not JSON — fall through to the map itself
63
+ }
64
+ }
65
+ candidates.push(data.data);
66
+ }
67
+ for (const candidate of candidates) {
68
+ if (sealedPushEnvelopeOf(candidate as Record<string, unknown>)) {
69
+ return candidate as Record<string, unknown>;
70
+ }
71
+ }
72
+ return null;
73
+ };
74
+
75
+ const notificationContent = (result: OpenSealedPushResult, options: SealedPushTaskOptions) => {
76
+ const { envelope } = result;
77
+ // What the tap handler (`handlePushPayload` through the host's response
78
+ // listener) needs: the same ids a plain push carries on `data`.
79
+ const data = {
80
+ jid: envelope.jid,
81
+ msgID: envelope.msgID,
82
+ userJid: envelope.userJid,
83
+ sealed: true,
84
+ };
85
+ if (!result.ok) {
86
+ return {
87
+ title: options.placeholderTitle || PLACEHOLDER_TITLE,
88
+ ...(options.placeholderBody ? { body: options.placeholderBody } : {}),
89
+ data,
90
+ sound: 'default',
91
+ };
92
+ }
93
+ const { content } = result;
94
+ return {
95
+ title: content.title || content.sender || content.chat || PLACEHOLDER_TITLE,
96
+ body: content.body,
97
+ data: { ...data, chatJid: content.chatJid, senderJid: content.senderJid },
98
+ sound: 'default',
99
+ };
100
+ };
101
+
102
+ /**
103
+ * Open a sealed push and show it. One notification per chat: the room JID
104
+ * is the identifier, so a newer message replaces the older one the way the
105
+ * service's collapse key does for plain pushes.
106
+ */
107
+ export async function presentSealedPush(
108
+ data: Record<string, unknown>,
109
+ options: SealedPushTaskOptions = {}
110
+ ): Promise<OpenSealedPushResult | null> {
111
+ const envelope = sealedPushEnvelopeOf(data);
112
+ if (!envelope) {return null;}
113
+ const notifications = loadNotifications();
114
+ if (!notifications?.scheduleNotificationAsync) {return null;}
115
+
116
+ const result = openSealedPush(envelope, await loadPushSecretKey());
117
+ // console, not only pushLog: with the app killed this runs headless and
118
+ // `adb logcat -s ReactNativeJS` is the only place to see it.
119
+ if (result.ok) {
120
+ console.log(`[push] sealed push kid=${envelope.kid} opened for ${envelope.jid}`);
121
+ } else {
122
+ console.warn(`[push] sealed push kid=${envelope.kid} not opened (${result.reason}), showing the placeholder`);
123
+ pushLog('warn', `push: sealed push not opened (${result.reason}), showing the placeholder`);
124
+ }
125
+ await notifications.scheduleNotificationAsync({
126
+ identifier: envelope.jid || envelope.msgID || undefined,
127
+ content: notificationContent(result, options),
128
+ trigger: options.channelId ? { channelId: options.channelId } : null,
129
+ });
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * Define and register the background task that renders sealed pushes on
135
+ * Android. Call it at module scope of a file the app loads first (the
136
+ * entry file, or the root layout with expo-router): a killed app is
137
+ * started headless to run it, and the task must exist by then. No-op on
138
+ * iOS and when expo-task-manager / expo-notifications are not installed.
139
+ */
140
+ export function registerSealedPushTask(options: SealedPushTaskOptions = {}): boolean {
141
+ if (Platform.OS !== 'android' || registered) {return registered;}
142
+ const taskManager = loadTaskManager();
143
+ const notifications = loadNotifications();
144
+ if (!taskManager?.defineTask || !notifications?.registerTaskAsync) {
145
+ pushLog(
146
+ 'warn',
147
+ 'push: expo-task-manager and expo-notifications are needed to show encrypted pushes on Android'
148
+ );
149
+ return false;
150
+ }
151
+ taskManager.defineTask(SEALED_PUSH_TASK, async ({ data, error }: { data: unknown; error: unknown }) => {
152
+ if (error) {return;}
153
+ const map = sealedPushDataOf(data);
154
+ if (!map) {return;}
155
+ // In the foreground the room is live over XMPP; the host's
156
+ // notification handler decides about plain pushes there too.
157
+ if (AppState.currentState === 'active') {return;}
158
+ try {
159
+ await presentSealedPush(map, options);
160
+ } catch (e) {
161
+ pushLog('warn', 'push: showing a sealed push failed', e);
162
+ }
163
+ });
164
+ registered = true;
165
+ notifications.registerTaskAsync(SEALED_PUSH_TASK).catch((e: unknown) => {
166
+ registered = false;
167
+ pushLog('warn', 'push: registering the sealed push task failed', e);
168
+ });
169
+ return true;
170
+ }
171
+
172
+ /** Test seam only. */
173
+ export const __resetSealedPushTaskForTests = (): void => {
174
+ registered = false;
175
+ };
@@ -11,6 +11,13 @@ import { pushLog } from '../utils/devLogger';
11
11
 
12
12
  export interface RegisterPushTokenOptions {
13
13
  tokenType?: PushTokenType;
14
+ /**
15
+ * Encrypted pushes: base64 of the device's X25519 public key
16
+ * (`ensurePushKeyPair().publicKey`). Sent with the token as `pushKey`;
17
+ * the service seals every chat push for it. A backend without the
18
+ * feature refuses the registration instead of delivering in the clear.
19
+ */
20
+ pushKey?: string;
14
21
  }
15
22
 
16
23
  export type RegisterPushTokenOutcome =
@@ -29,6 +36,7 @@ export type UnregisterPushTokenOutcome =
29
36
  export interface RegisteredPushToken {
30
37
  token: string;
31
38
  tokenType: PushTokenType;
39
+ pushKey?: string;
32
40
  }
33
41
 
34
42
  type Slot = 'chat' | 'voip';
@@ -93,6 +101,7 @@ const sameRegistration = (
93
101
  !!current &&
94
102
  current.token === token.token &&
95
103
  current.tokenType === token.tokenType &&
104
+ (current.pushKey || '') === (token.pushKey || '') &&
96
105
  current.gateway === isGateway(session) &&
97
106
  current.appId === session.appId &&
98
107
  current.xmppUsername === session.xmppUsername;
@@ -147,6 +156,7 @@ const registerOne = async (
147
156
  token: token.token,
148
157
  tokenType: token.tokenType,
149
158
  authToken: session.authToken,
159
+ pushKey: token.pushKey,
150
160
  });
151
161
  }
152
162
  registered.set(slotOf(token.tokenType), {
@@ -158,7 +168,8 @@ const registerOne = async (
158
168
  pushLog(
159
169
  'rn',
160
170
  `push: ${token.tokenType} token registered` +
161
- (isGateway(session) ? ' (gateway)' : ` for app ${session.appId}`)
171
+ (isGateway(session) ? ' (gateway)' : ` for app ${session.appId}`) +
172
+ (token.pushKey && !isGateway(session) ? ' with a device key (encrypted pushes)' : '')
162
173
  );
163
174
  };
164
175
 
@@ -209,7 +220,7 @@ const runTokenHook = (session: Session): void => {
209
220
  if (typeof entry?.token !== 'string' || !entry.token.trim()) {
210
221
  continue;
211
222
  }
212
- hold(entry.token.trim(), entry.tokenType);
223
+ hold(entry.token.trim(), entry.tokenType, entry.pushKey);
213
224
  }
214
225
  await registerNow();
215
226
  })().catch((error) => {
@@ -219,9 +230,18 @@ const runTokenHook = (session: Session): void => {
219
230
  });
220
231
  };
221
232
 
222
- const hold = (token: string, tokenType?: PushTokenType): RegisteredPushToken => {
233
+ const hold = (
234
+ token: string,
235
+ tokenType?: PushTokenType,
236
+ pushKey?: string
237
+ ): RegisteredPushToken => {
223
238
  const resolved = tokenType ?? detectPushTokenType(token);
224
- const entry: RegisteredPushToken = { token, tokenType: resolved };
239
+ const key = typeof pushKey === 'string' ? pushKey.trim() : '';
240
+ const entry: RegisteredPushToken = {
241
+ token,
242
+ tokenType: resolved,
243
+ ...(key ? { pushKey: key } : {}),
244
+ };
225
245
  held.set(slotOf(resolved), entry);
226
246
  return entry;
227
247
  };
@@ -274,7 +294,7 @@ export async function registerPushToken(
274
294
  if (typeof token !== 'string' || !token.trim()) {
275
295
  throw new TypeError('registerPushToken: token must be a non-empty string');
276
296
  }
277
- hold(token.trim(), options?.tokenType);
297
+ hold(token.trim(), options?.tokenType, options?.pushKey);
278
298
  ensureWatching();
279
299
 
280
300
  const session = readSession();
@@ -257,6 +257,12 @@ export type PushTokenType = 'apns' | 'apns-voip' | 'fcm' | 'expo';
257
257
  export interface PushTokenRegistration {
258
258
  token: string;
259
259
  tokenType?: PushTokenType;
260
+ /**
261
+ * Encrypted pushes: base64 of the device's X25519 public key
262
+ * (`ensurePushKeyPair().publicKey`). The service then seals every chat
263
+ * push for it; see README, "Encrypted pushes".
264
+ */
265
+ pushKey?: string;
260
266
  }
261
267
 
262
268
  export interface MessageBubble {
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/sync-version.mjs on `npm version`.
2
2
  // Edit package.json's version instead of this file.
3
- export const ETHORA_CHAT_COMPONENT_VERSION = '26.10.6';
3
+ export const ETHORA_CHAT_COMPONENT_VERSION = '26.10.7';