@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,159 @@
1
+ "use strict";
2
+
3
+ import { fromBase64, toBase64 } from '../e2ee/crypto';
4
+ import { loadSecureStore } from '../helpers/secureStoreRuntime';
5
+ import { pushLog } from '../utils/devLogger';
6
+ import { SEALED_BOX_PUBLIC_KEY_BYTES, SEALED_BOX_SECRET_KEY_BYTES, generateSealedBoxKeyPair, sealedBoxKeyId, sealedBoxPublicKeyOf } from './sealedBox';
7
+ // The device key for encrypted pushes: one X25519 pair per install, in the
8
+ // platform keychain / keystore through expo-secure-store. The public half
9
+ // goes to the push service with the token (`pushKey`); the secret half
10
+ // never leaves the device and is read by whoever renders a sealed push —
11
+ // the Android background task (`sealedPushTask.ts`, same process, same
12
+ // store) and the iOS notification service extension, which reads the
13
+ // keychain item directly (plugins/notification-service), so the item's
14
+ // attributes below are a contract with the Swift side:
15
+ // service `ethora.push` (expo-secure-store stores it as `ethora.push:no-auth`)
16
+ // account `ethora_push_secret_key`, UTF-8
17
+ // value base64 of the 32-byte secret key, UTF-8
18
+ // group the app's first keychain access group, which the config
19
+ // plugin shares with the extension
20
+ // Readable after the first unlock so a push that arrives while the phone
21
+ // is locked still decrypts; this device only, so an iCloud keychain copy
22
+ // on another device is never a key for pushes that go to this one.
23
+
24
+ export const PUSH_KEY_KEYCHAIN_SERVICE = 'ethora.push';
25
+ export const PUSH_SECRET_KEY_ITEM = 'ethora_push_secret_key';
26
+ export const PUSH_PUBLIC_KEY_ITEM = 'ethora_push_public_key';
27
+ let warnedMissingStore = false;
28
+ const warnMissingStoreOnce = () => {
29
+ if (warnedMissingStore) {
30
+ return;
31
+ }
32
+ warnedMissingStore = true;
33
+ pushLog('warn', 'push: expo-secure-store is not installed, so no device key for encrypted ' + 'pushes can be kept; pushes stay in the clear.');
34
+ };
35
+ const storeOptions = () => {
36
+ const store = loadSecureStore();
37
+ if (!store) {
38
+ return null;
39
+ }
40
+ const accessible = store.accessible.afterFirstUnlockThisDeviceOnly;
41
+ return {
42
+ keychainService: PUSH_KEY_KEYCHAIN_SERVICE,
43
+ ...(accessible !== undefined ? {
44
+ keychainAccessible: accessible
45
+ } : {})
46
+ };
47
+ };
48
+ const readItem = async item => {
49
+ const store = loadSecureStore();
50
+ const options = storeOptions();
51
+ if (!store || !options) {
52
+ return null;
53
+ }
54
+ try {
55
+ return await store.getItemAsync(item, options);
56
+ } catch (e) {
57
+ pushLog('warn', `push: reading ${item} failed`, e);
58
+ return null;
59
+ }
60
+ };
61
+ const decodeKey = (raw, length) => {
62
+ if (!raw) {
63
+ return null;
64
+ }
65
+ try {
66
+ const bytes = fromBase64(raw);
67
+ return bytes.length === length ? bytes : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ };
72
+
73
+ /**
74
+ * The stored pair, or null when there is none (or it is unreadable).
75
+ * The public key is re-derived from the secret, so the pair is consistent
76
+ * even if only the secret item survived.
77
+ */
78
+ export async function loadPushSecretKey() {
79
+ const secretKey = decodeKey(await readItem(PUSH_SECRET_KEY_ITEM), SEALED_BOX_SECRET_KEY_BYTES);
80
+ if (!secretKey) {
81
+ return null;
82
+ }
83
+ const stored = decodeKey(await readItem(PUSH_PUBLIC_KEY_ITEM), SEALED_BOX_PUBLIC_KEY_BYTES);
84
+ return {
85
+ publicKey: stored ?? sealedBoxPublicKeyOf(secretKey),
86
+ secretKey
87
+ };
88
+ }
89
+ const describe = publicKey => ({
90
+ publicKey: toBase64(publicKey),
91
+ keyId: sealedBoxKeyId(publicKey)
92
+ });
93
+
94
+ /** The public half of the stored pair, null when the device has no key. */
95
+ export async function getPushKeyPair() {
96
+ const key = await loadPushSecretKey();
97
+ return key ? describe(key.publicKey) : null;
98
+ }
99
+
100
+ /**
101
+ * Generate and store a new pair, replacing any existing one. Pushes sealed
102
+ * for the old key stop opening at once, so re-register the token with the
103
+ * new `publicKey` right after. Null when expo-secure-store is missing or
104
+ * the keychain refused the write.
105
+ */
106
+ export async function resetPushKeyPair() {
107
+ const store = loadSecureStore();
108
+ const options = storeOptions();
109
+ if (!store || !options) {
110
+ warnMissingStoreOnce();
111
+ return null;
112
+ }
113
+ const pair = generateSealedBoxKeyPair();
114
+ try {
115
+ await store.setItemAsync(PUSH_SECRET_KEY_ITEM, toBase64(pair.secretKey), options);
116
+ await store.setItemAsync(PUSH_PUBLIC_KEY_ITEM, toBase64(pair.publicKey), options);
117
+ } catch (e) {
118
+ pushLog('warn', 'push: storing the device key failed', e);
119
+ return null;
120
+ }
121
+ const check = await loadPushSecretKey();
122
+ if (!check || toBase64(check.publicKey) !== toBase64(pair.publicKey)) {
123
+ pushLog('warn', 'push: the device key was not kept');
124
+ return null;
125
+ }
126
+ pushLog('rn', `push: new device key ${sealedBoxKeyId(pair.publicKey)}`);
127
+ return describe(pair.publicKey);
128
+ }
129
+
130
+ /**
131
+ * The device key for encrypted pushes, created on first call. Hand
132
+ * `publicKey` to `registerPushToken` (or send it as `pushKey` yourself).
133
+ */
134
+ export async function ensurePushKeyPair() {
135
+ const existing = await getPushKeyPair();
136
+ return existing ?? resetPushKeyPair();
137
+ }
138
+
139
+ /** Remove the pair; the next `ensurePushKeyPair` creates a new one. */
140
+ export async function clearPushKeyPair() {
141
+ const store = loadSecureStore();
142
+ const options = storeOptions();
143
+ if (!store || !options) {
144
+ return;
145
+ }
146
+ for (const item of [PUSH_SECRET_KEY_ITEM, PUSH_PUBLIC_KEY_ITEM]) {
147
+ try {
148
+ await store.deleteItemAsync(item, options);
149
+ } catch {
150
+ // Not there — nothing to remove.
151
+ }
152
+ }
153
+ }
154
+
155
+ /** Test seam only. */
156
+ export const __resetPushKeyStoreForTests = () => {
157
+ warnedMissingStore = false;
158
+ };
159
+ //# sourceMappingURL=pushKeyStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["fromBase64","toBase64","loadSecureStore","pushLog","SEALED_BOX_PUBLIC_KEY_BYTES","SEALED_BOX_SECRET_KEY_BYTES","generateSealedBoxKeyPair","sealedBoxKeyId","sealedBoxPublicKeyOf","PUSH_KEY_KEYCHAIN_SERVICE","PUSH_SECRET_KEY_ITEM","PUSH_PUBLIC_KEY_ITEM","warnedMissingStore","warnMissingStoreOnce","storeOptions","store","accessible","afterFirstUnlockThisDeviceOnly","keychainService","undefined","keychainAccessible","readItem","item","options","getItemAsync","e","decodeKey","raw","length","bytes","loadPushSecretKey","secretKey","stored","publicKey","describe","keyId","getPushKeyPair","key","resetPushKeyPair","pair","setItemAsync","check","ensurePushKeyPair","existing","clearPushKeyPair","deleteItemAsync","__resetPushKeyStoreForTests"],"sourceRoot":"../../../src","sources":["push/pushKeyStore.ts"],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+
3
+ import { hsalsa, xsalsa20poly1305 } from '@noble/ciphers/salsa.js';
4
+ import { clean, concatBytes, u32, u8, utf8ToBytes } from '@noble/ciphers/utils.js';
5
+ import { x25519 } from '@noble/curves/ed25519.js';
6
+ import { blake2b } from '@noble/hashes/blake2.js';
7
+ import { sha256 } from '@noble/hashes/sha2.js';
8
+ import { bytesToHex } from '@noble/hashes/utils.js';
9
+
10
+ // libsodium's sealed box (`crypto_box_seal` / `crypto_box_seal_open`) on
11
+ // the noble primitives the e2ee code already ships, so the JS side needs
12
+ // no native libsodium. The push service seals every chat push for a
13
+ // device that registered an X25519 public key (ethora-backend,
14
+ // services/push/README.md, "Encrypted pushes"); this is the matching
15
+ // receiver. The iOS notification service extension decrypts the same
16
+ // boxes in Swift with libsodium (plugins/notification-service).
17
+ //
18
+ // Layout: ephemeral public key (32) || crypto_box(message, nonce,
19
+ // recipient pk, ephemeral sk), with crypto_box = XSalsa20-Poly1305 under
20
+ // HSalsa20(X25519 shared secret) and nonce = BLAKE2b-192(epk || rpk).
21
+ // `__tests__/sealedBox.test.ts` pins this against libsodium itself.
22
+
23
+ export const SEALED_BOX_PUBLIC_KEY_BYTES = 32;
24
+ export const SEALED_BOX_SECRET_KEY_BYTES = 32;
25
+ /** Ephemeral public key (32) + Poly1305 tag (16). */
26
+ export const SEALED_BOX_OVERHEAD_BYTES = 48;
27
+ const NONCE_BYTES = 24;
28
+ const SIGMA = u32(utf8ToBytes('expand 32-byte k'));
29
+ const ZERO_NONCE_PREFIX = new Uint32Array(4);
30
+ export const generateSealedBoxKeyPair = () => {
31
+ const {
32
+ secretKey,
33
+ publicKey
34
+ } = x25519.keygen();
35
+ return {
36
+ publicKey,
37
+ secretKey
38
+ };
39
+ };
40
+ export const sealedBoxPublicKeyOf = secretKey => x25519.getPublicKey(secretKey);
41
+
42
+ /**
43
+ * The key id the push service derives when the registration carries none:
44
+ * the first 16 hex characters of SHA-256 over the raw public key. Sent back
45
+ * as `kid` in every sealed push.
46
+ */
47
+ export const sealedBoxKeyId = publicKey => bytesToHex(sha256(publicKey)).slice(0, 16);
48
+
49
+ // crypto_box_beforenm: HSalsa20 over the X25519 shared secret.
50
+ const boxSharedKey = (theirPublicKey, mySecretKey) => {
51
+ const shared = x25519.getSharedSecret(mySecretKey, theirPublicKey);
52
+ const words = new Uint32Array(8);
53
+ hsalsa(SIGMA, u32(shared), ZERO_NONCE_PREFIX, words);
54
+ clean(shared);
55
+ return u8(words);
56
+ };
57
+ const sealNonce = (ephemeralPublicKey, recipientPublicKey) => blake2b(concatBytes(ephemeralPublicKey, recipientPublicKey), {
58
+ dkLen: NONCE_BYTES
59
+ });
60
+
61
+ /** `crypto_box_seal`: used by the tests and the key tool, not by the app. */
62
+ export function sealedBoxSeal(message, recipientPublicKey) {
63
+ if (recipientPublicKey.length !== SEALED_BOX_PUBLIC_KEY_BYTES) {
64
+ throw new Error('sealedBoxSeal: the public key must be 32 bytes');
65
+ }
66
+ const ephemeral = generateSealedBoxKeyPair();
67
+ const key = boxSharedKey(recipientPublicKey, ephemeral.secretKey);
68
+ const nonce = sealNonce(ephemeral.publicKey, recipientPublicKey);
69
+ const box = xsalsa20poly1305(key, nonce).encrypt(message);
70
+ clean(key, ephemeral.secretKey);
71
+ return concatBytes(ephemeral.publicKey, box);
72
+ }
73
+
74
+ /**
75
+ * `crypto_box_seal_open`. Returns null for anything that does not open:
76
+ * a wrong key, a box for another key, a damaged or truncated box.
77
+ */
78
+ export function sealedBoxOpen(sealed, publicKey, secretKey) {
79
+ if (sealed.length < SEALED_BOX_OVERHEAD_BYTES || publicKey.length !== SEALED_BOX_PUBLIC_KEY_BYTES || secretKey.length !== SEALED_BOX_SECRET_KEY_BYTES) {
80
+ return null;
81
+ }
82
+ const ephemeralPublicKey = sealed.subarray(0, SEALED_BOX_PUBLIC_KEY_BYTES);
83
+ const box = sealed.subarray(SEALED_BOX_PUBLIC_KEY_BYTES);
84
+ let key = null;
85
+ try {
86
+ key = boxSharedKey(ephemeralPublicKey, secretKey);
87
+ const nonce = sealNonce(ephemeralPublicKey, publicKey);
88
+ return xsalsa20poly1305(key, nonce).decrypt(box);
89
+ } catch {
90
+ return null;
91
+ } finally {
92
+ if (key) {
93
+ clean(key);
94
+ }
95
+ }
96
+ }
97
+ //# sourceMappingURL=sealedBox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["hsalsa","xsalsa20poly1305","clean","concatBytes","u32","u8","utf8ToBytes","x25519","blake2b","sha256","bytesToHex","SEALED_BOX_PUBLIC_KEY_BYTES","SEALED_BOX_SECRET_KEY_BYTES","SEALED_BOX_OVERHEAD_BYTES","NONCE_BYTES","SIGMA","ZERO_NONCE_PREFIX","Uint32Array","generateSealedBoxKeyPair","secretKey","publicKey","keygen","sealedBoxPublicKeyOf","getPublicKey","sealedBoxKeyId","slice","boxSharedKey","theirPublicKey","mySecretKey","shared","getSharedSecret","words","sealNonce","ephemeralPublicKey","recipientPublicKey","dkLen","sealedBoxSeal","message","length","Error","ephemeral","key","nonce","box","encrypt","sealedBoxOpen","sealed","subarray","decrypt"],"sourceRoot":"../../../src","sources":["push/sealedBox.ts"],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+
3
+ import { fromBase64 } from '../e2ee/crypto';
4
+ import { sealedBoxKeyId, sealedBoxOpen } from './sealedBox';
5
+
6
+ // The envelope of an encrypted chat push (ethora-backend,
7
+ // services/push/README.md, "Encrypted pushes"): the FCM `data` map on
8
+ // Android, the custom keys next to `aps` on iOS. Only ids travel in the
9
+ // clear; everything shown to the user is inside `enc`, a sealed box for
10
+ // the key the device registered with its token.
11
+
12
+ export const SEALED_PUSH_ALG = 'sealed-box-x25519';
13
+ export const SEALED_PUSH_VERSION = '1';
14
+
15
+ /** What the box holds: the notification the service would have sent in the clear. */
16
+
17
+ const str = value => typeof value === 'string' ? value : '';
18
+
19
+ /** The envelope of `data` when it is a sealed chat push, else null. */
20
+ export const sealedPushEnvelopeOf = data => {
21
+ if (!data || data.alg !== SEALED_PUSH_ALG) {
22
+ return null;
23
+ }
24
+ const enc = str(data.enc);
25
+ return {
26
+ v: str(data.v) || SEALED_PUSH_VERSION,
27
+ alg: SEALED_PUSH_ALG,
28
+ kid: str(data.kid),
29
+ jid: str(data.jid),
30
+ msgID: str(data.msgID),
31
+ userJid: str(data.userJid),
32
+ ...(enc ? {
33
+ enc
34
+ } : {})
35
+ };
36
+ };
37
+ export const isSealedPush = data => sealedPushEnvelopeOf(data) !== null;
38
+ const parseContent = plaintext => {
39
+ try {
40
+ const parsed = JSON.parse(new TextDecoder().decode(plaintext));
41
+ if (!parsed || typeof parsed !== 'object') {
42
+ return null;
43
+ }
44
+ return {
45
+ title: str(parsed.title),
46
+ body: str(parsed.body),
47
+ sender: str(parsed.sender),
48
+ senderJid: str(parsed.senderJid),
49
+ chat: str(parsed.chat),
50
+ chatJid: str(parsed.chatJid),
51
+ msgID: str(parsed.msgID),
52
+ image: str(parsed.image)
53
+ };
54
+ } catch {
55
+ return null;
56
+ }
57
+ };
58
+ /**
59
+ * Open a sealed push with the device key. `key` null means there is no key
60
+ * on this device. Never throws: every failure names why, so the caller can
61
+ * show the placeholder and log the reason.
62
+ */
63
+ export function openSealedPush(envelope, key) {
64
+ if (!key) {
65
+ return {
66
+ ok: false,
67
+ reason: 'no-key',
68
+ envelope
69
+ };
70
+ }
71
+ if (envelope.kid && envelope.kid !== sealedBoxKeyId(key.publicKey)) {
72
+ return {
73
+ ok: false,
74
+ reason: 'key-mismatch',
75
+ envelope
76
+ };
77
+ }
78
+ if (!envelope.enc) {
79
+ return {
80
+ ok: false,
81
+ reason: 'no-ciphertext',
82
+ envelope
83
+ };
84
+ }
85
+ let sealed;
86
+ try {
87
+ sealed = fromBase64(envelope.enc);
88
+ } catch {
89
+ return {
90
+ ok: false,
91
+ reason: 'bad-ciphertext',
92
+ envelope
93
+ };
94
+ }
95
+ const plaintext = sealedBoxOpen(sealed, key.publicKey, key.secretKey);
96
+ if (!plaintext) {
97
+ return {
98
+ ok: false,
99
+ reason: 'bad-ciphertext',
100
+ envelope
101
+ };
102
+ }
103
+ const content = parseContent(plaintext);
104
+ if (!content) {
105
+ return {
106
+ ok: false,
107
+ reason: 'bad-content',
108
+ envelope
109
+ };
110
+ }
111
+ return {
112
+ ok: true,
113
+ content,
114
+ envelope
115
+ };
116
+ }
117
+ //# sourceMappingURL=sealedPush.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["fromBase64","sealedBoxKeyId","sealedBoxOpen","SEALED_PUSH_ALG","SEALED_PUSH_VERSION","str","value","sealedPushEnvelopeOf","data","alg","enc","v","kid","jid","msgID","userJid","isSealedPush","parseContent","plaintext","parsed","JSON","parse","TextDecoder","decode","title","body","sender","senderJid","chat","chatJid","image","openSealedPush","envelope","key","ok","reason","publicKey","sealed","secretKey","content"],"sourceRoot":"../../../src","sources":["push/sealedPush.ts"],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+
3
+ import { AppState, Platform } from 'react-native';
4
+ import { pushLog } from '../utils/devLogger';
5
+ import { loadPushSecretKey } from './pushKeyStore';
6
+ import { openSealedPush, sealedPushEnvelopeOf } from './sealedPush';
7
+
8
+ // Android half of encrypted pushes. A sealed push is data-only, so the OS
9
+ // shows nothing by itself: expo-notifications hands it to a background
10
+ // task (expo-task-manager, foreground, background and killed app alike)
11
+ // and this task opens the box and posts the notification. iOS does the
12
+ // same in the notification service extension; here `registerSealedPushTask`
13
+ // is a no-op.
14
+ //
15
+ // Both packages are optional peers, required lazily: a host that does not
16
+ // want encrypted pushes never pays for them.
17
+
18
+ export const SEALED_PUSH_TASK = 'ETHORA_SEALED_PUSH';
19
+ const PLACEHOLDER_TITLE = 'New message';
20
+ let registered = false;
21
+ const loadTaskManager = () => {
22
+ try {
23
+ return require('expo-task-manager');
24
+ } catch {
25
+ return null;
26
+ }
27
+ };
28
+ const loadNotifications = () => {
29
+ try {
30
+ return require('expo-notifications');
31
+ } catch {
32
+ return null;
33
+ }
34
+ };
35
+
36
+ /**
37
+ * The FCM data map out of the task payload. expo-notifications passes the
38
+ * message as `{ notification, data }`; the data keys are on `data`
39
+ * itself, `data.dataString` carries the same as a JSON string on some
40
+ * versions, and a Firebase `RemoteMessage` keeps them under `data.data`.
41
+ */
42
+ export const sealedPushDataOf = payload => {
43
+ const root = payload && typeof payload === 'object' ? payload : null;
44
+ if (!root) {
45
+ return null;
46
+ }
47
+ const candidates = [root.data, root];
48
+ const data = root.data && typeof root.data === 'object' ? root.data : null;
49
+ if (data) {
50
+ if (typeof data.dataString === 'string') {
51
+ try {
52
+ candidates.unshift(JSON.parse(data.dataString));
53
+ } catch {
54
+ // not JSON — fall through to the map itself
55
+ }
56
+ }
57
+ candidates.push(data.data);
58
+ }
59
+ for (const candidate of candidates) {
60
+ if (sealedPushEnvelopeOf(candidate)) {
61
+ return candidate;
62
+ }
63
+ }
64
+ return null;
65
+ };
66
+ const notificationContent = (result, options) => {
67
+ const {
68
+ envelope
69
+ } = result;
70
+ // What the tap handler (`handlePushPayload` through the host's response
71
+ // listener) needs: the same ids a plain push carries on `data`.
72
+ const data = {
73
+ jid: envelope.jid,
74
+ msgID: envelope.msgID,
75
+ userJid: envelope.userJid,
76
+ sealed: true
77
+ };
78
+ if (!result.ok) {
79
+ return {
80
+ title: options.placeholderTitle || PLACEHOLDER_TITLE,
81
+ ...(options.placeholderBody ? {
82
+ body: options.placeholderBody
83
+ } : {}),
84
+ data,
85
+ sound: 'default'
86
+ };
87
+ }
88
+ const {
89
+ content
90
+ } = result;
91
+ return {
92
+ title: content.title || content.sender || content.chat || PLACEHOLDER_TITLE,
93
+ body: content.body,
94
+ data: {
95
+ ...data,
96
+ chatJid: content.chatJid,
97
+ senderJid: content.senderJid
98
+ },
99
+ sound: 'default'
100
+ };
101
+ };
102
+
103
+ /**
104
+ * Open a sealed push and show it. One notification per chat: the room JID
105
+ * is the identifier, so a newer message replaces the older one the way the
106
+ * service's collapse key does for plain pushes.
107
+ */
108
+ export async function presentSealedPush(data, options = {}) {
109
+ const envelope = sealedPushEnvelopeOf(data);
110
+ if (!envelope) {
111
+ return null;
112
+ }
113
+ const notifications = loadNotifications();
114
+ if (!notifications?.scheduleNotificationAsync) {
115
+ return null;
116
+ }
117
+ const result = openSealedPush(envelope, await loadPushSecretKey());
118
+ // console, not only pushLog: with the app killed this runs headless and
119
+ // `adb logcat -s ReactNativeJS` is the only place to see it.
120
+ if (result.ok) {
121
+ console.log(`[push] sealed push kid=${envelope.kid} opened for ${envelope.jid}`);
122
+ } else {
123
+ console.warn(`[push] sealed push kid=${envelope.kid} not opened (${result.reason}), showing the placeholder`);
124
+ pushLog('warn', `push: sealed push not opened (${result.reason}), showing the placeholder`);
125
+ }
126
+ await notifications.scheduleNotificationAsync({
127
+ identifier: envelope.jid || envelope.msgID || undefined,
128
+ content: notificationContent(result, options),
129
+ trigger: options.channelId ? {
130
+ channelId: options.channelId
131
+ } : null
132
+ });
133
+ return result;
134
+ }
135
+
136
+ /**
137
+ * Define and register the background task that renders sealed pushes on
138
+ * Android. Call it at module scope of a file the app loads first (the
139
+ * entry file, or the root layout with expo-router): a killed app is
140
+ * started headless to run it, and the task must exist by then. No-op on
141
+ * iOS and when expo-task-manager / expo-notifications are not installed.
142
+ */
143
+ export function registerSealedPushTask(options = {}) {
144
+ if (Platform.OS !== 'android' || registered) {
145
+ return registered;
146
+ }
147
+ const taskManager = loadTaskManager();
148
+ const notifications = loadNotifications();
149
+ if (!taskManager?.defineTask || !notifications?.registerTaskAsync) {
150
+ pushLog('warn', 'push: expo-task-manager and expo-notifications are needed to show encrypted pushes on Android');
151
+ return false;
152
+ }
153
+ taskManager.defineTask(SEALED_PUSH_TASK, async ({
154
+ data,
155
+ error
156
+ }) => {
157
+ if (error) {
158
+ return;
159
+ }
160
+ const map = sealedPushDataOf(data);
161
+ if (!map) {
162
+ return;
163
+ }
164
+ // In the foreground the room is live over XMPP; the host's
165
+ // notification handler decides about plain pushes there too.
166
+ if (AppState.currentState === 'active') {
167
+ return;
168
+ }
169
+ try {
170
+ await presentSealedPush(map, options);
171
+ } catch (e) {
172
+ pushLog('warn', 'push: showing a sealed push failed', e);
173
+ }
174
+ });
175
+ registered = true;
176
+ notifications.registerTaskAsync(SEALED_PUSH_TASK).catch(e => {
177
+ registered = false;
178
+ pushLog('warn', 'push: registering the sealed push task failed', e);
179
+ });
180
+ return true;
181
+ }
182
+
183
+ /** Test seam only. */
184
+ export const __resetSealedPushTaskForTests = () => {
185
+ registered = false;
186
+ };
187
+ //# sourceMappingURL=sealedPushTask.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["AppState","Platform","pushLog","loadPushSecretKey","openSealedPush","sealedPushEnvelopeOf","SEALED_PUSH_TASK","PLACEHOLDER_TITLE","registered","loadTaskManager","require","loadNotifications","sealedPushDataOf","payload","root","candidates","data","dataString","unshift","JSON","parse","push","candidate","notificationContent","result","options","envelope","jid","msgID","userJid","sealed","ok","title","placeholderTitle","placeholderBody","body","sound","content","sender","chat","chatJid","senderJid","presentSealedPush","notifications","scheduleNotificationAsync","console","log","kid","warn","reason","identifier","undefined","trigger","channelId","registerSealedPushTask","OS","taskManager","defineTask","registerTaskAsync","error","map","currentState","e","catch","__resetSealedPushTaskForTests"],"sourceRoot":"../../../src","sources":["push/sealedPushTask.ts"],"mappings":"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","ignoreList":[]}
@@ -28,7 +28,7 @@ const readSession = () => {
28
28
  };
29
29
  const isGateway = session => !!session.gatewayUrl;
30
30
  const isReady = session => !!session.authToken && (isGateway(session) ? !!session.xmppUsername && !!session.projectName : !!session.appId);
31
- const sameRegistration = (token, current, session) => !!current && current.token === token.token && current.tokenType === token.tokenType && current.gateway === isGateway(session) && current.appId === session.appId && current.xmppUsername === session.xmppUsername;
31
+ const sameRegistration = (token, current, session) => !!current && current.token === token.token && current.tokenType === token.tokenType && (current.pushKey || '') === (token.pushKey || '') && current.gateway === isGateway(session) && current.appId === session.appId && current.xmppUsername === session.xmppUsername;
32
32
  const slotsNeedingRegistration = session => SLOTS.filter(slot => {
33
33
  const token = held.get(slot);
34
34
  return !!token && !sameRegistration(token, registered.get(slot), session);
@@ -63,7 +63,8 @@ const registerOne = async (token, session) => {
63
63
  appId: session.appId,
64
64
  token: token.token,
65
65
  tokenType: token.tokenType,
66
- authToken: session.authToken
66
+ authToken: session.authToken,
67
+ pushKey: token.pushKey
67
68
  });
68
69
  }
69
70
  registered.set(slotOf(token.tokenType), {
@@ -72,7 +73,7 @@ const registerOne = async (token, session) => {
72
73
  xmppUsername: session.xmppUsername,
73
74
  gateway: isGateway(session)
74
75
  });
75
- pushLog('rn', `push: ${token.tokenType} token registered` + (isGateway(session) ? ' (gateway)' : ` for app ${session.appId}`));
76
+ pushLog('rn', `push: ${token.tokenType} token registered` + (isGateway(session) ? ' (gateway)' : ` for app ${session.appId}`) + (token.pushKey && !isGateway(session) ? ' with a device key (encrypted pushes)' : ''));
76
77
  };
77
78
  const registerNow = () => {
78
79
  if (inflight) {
@@ -117,7 +118,7 @@ const runTokenHook = session => {
117
118
  if (typeof entry?.token !== 'string' || !entry.token.trim()) {
118
119
  continue;
119
120
  }
120
- hold(entry.token.trim(), entry.tokenType);
121
+ hold(entry.token.trim(), entry.tokenType, entry.pushKey);
121
122
  }
122
123
  await registerNow();
123
124
  })().catch(error => {
@@ -126,11 +127,15 @@ const runTokenHook = session => {
126
127
  pushLog('warn', 'push: getPushTokens failed; will retry', error);
127
128
  });
128
129
  };
129
- const hold = (token, tokenType) => {
130
+ const hold = (token, tokenType, pushKey) => {
130
131
  const resolved = tokenType ?? detectPushTokenType(token);
132
+ const key = typeof pushKey === 'string' ? pushKey.trim() : '';
131
133
  const entry = {
132
134
  token,
133
- tokenType: resolved
135
+ tokenType: resolved,
136
+ ...(key ? {
137
+ pushKey: key
138
+ } : {})
134
139
  };
135
140
  held.set(slotOf(resolved), entry);
136
141
  return entry;
@@ -178,7 +183,7 @@ export async function registerPushToken(token, options) {
178
183
  if (typeof token !== 'string' || !token.trim()) {
179
184
  throw new TypeError('registerPushToken: token must be a non-empty string');
180
185
  }
181
- hold(token.trim(), options?.tokenType);
186
+ hold(token.trim(), options?.tokenType, options?.pushKey);
182
187
  ensureWatching();
183
188
  const session = readSession();
184
189
  if (session.pushDisabled) {