@ethora/chat-component-rn 26.10.6 → 26.10.8
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.
- package/CHANGELOG.md +7 -1
- package/README.md +6 -3
- package/lib/commonjs/components/MessageBubble/Message.js +16 -6
- package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +16 -2
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/callActions.js +32 -2
- package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/systemCall.js +83 -0
- package/lib/commonjs/components/VideoCalls/systemCall.js.map +1 -0
- package/lib/commonjs/components/VideoCalls/useCallKeep.js +83 -66
- package/lib/commonjs/components/VideoCalls/useCallKeep.js.map +1 -1
- package/lib/commonjs/helpers/incomingCallFromPush.js +95 -21
- package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
- package/lib/commonjs/helpers/secureStoreRuntime.js +10 -1
- package/lib/commonjs/helpers/secureStoreRuntime.js.map +1 -1
- package/lib/commonjs/main.js +114 -0
- package/lib/commonjs/main.js.map +1 -1
- package/lib/commonjs/networking/api-requests/push.api.js +22 -7
- package/lib/commonjs/networking/api-requests/push.api.js.map +1 -1
- package/lib/commonjs/networking/callTokenStanza.js +6 -0
- package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
- package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js +22 -6
- package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
- package/lib/commonjs/networking/xmppClient.js.map +1 -1
- package/lib/commonjs/push/pushKeyStore.js +169 -0
- package/lib/commonjs/push/pushKeyStore.js.map +1 -0
- package/lib/commonjs/push/sealedBox.js +106 -0
- package/lib/commonjs/push/sealedBox.js.map +1 -0
- package/lib/commonjs/push/sealedPush.js +135 -0
- package/lib/commonjs/push/sealedPush.js.map +1 -0
- package/lib/commonjs/push/sealedPushTask.js +221 -0
- package/lib/commonjs/push/sealedPushTask.js.map +1 -0
- package/lib/commonjs/services/pushRegistration.js +12 -7
- package/lib/commonjs/services/pushRegistration.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/components/MessageBubble/Message.js +16 -6
- package/lib/module/components/MessageBubble/Message.js.map +1 -1
- package/lib/module/components/VideoCalls/VideoCallOverlay.js +17 -3
- package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/module/components/VideoCalls/callActions.js +29 -0
- package/lib/module/components/VideoCalls/callActions.js.map +1 -1
- package/lib/module/components/VideoCalls/systemCall.js +73 -0
- package/lib/module/components/VideoCalls/systemCall.js.map +1 -0
- package/lib/module/components/VideoCalls/useCallKeep.js +77 -64
- package/lib/module/components/VideoCalls/useCallKeep.js.map +1 -1
- package/lib/module/helpers/incomingCallFromPush.js +92 -22
- package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
- package/lib/module/helpers/secureStoreRuntime.js +10 -1
- package/lib/module/helpers/secureStoreRuntime.js.map +1 -1
- package/lib/module/main.js +9 -0
- package/lib/module/main.js.map +1 -1
- package/lib/module/networking/api-requests/push.api.js +21 -6
- package/lib/module/networking/api-requests/push.api.js.map +1 -1
- package/lib/module/networking/callTokenStanza.js +6 -0
- package/lib/module/networking/callTokenStanza.js.map +1 -1
- package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js +21 -6
- package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
- package/lib/module/networking/xmppClient.js.map +1 -1
- package/lib/module/push/pushKeyStore.js +159 -0
- package/lib/module/push/pushKeyStore.js.map +1 -0
- package/lib/module/push/sealedBox.js +97 -0
- package/lib/module/push/sealedBox.js.map +1 -0
- package/lib/module/push/sealedPush.js +127 -0
- package/lib/module/push/sealedPush.js.map +1 -0
- package/lib/module/push/sealedPushTask.js +212 -0
- package/lib/module/push/sealedPushTask.js.map +1 -0
- package/lib/module/services/pushRegistration.js +12 -7
- package/lib/module/services/pushRegistration.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/callActions.d.ts +6 -0
- package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/systemCall.d.ts +25 -0
- package/lib/typescript/components/VideoCalls/systemCall.d.ts.map +1 -0
- package/lib/typescript/components/VideoCalls/useCallKeep.d.ts +8 -23
- package/lib/typescript/components/VideoCalls/useCallKeep.d.ts.map +1 -1
- package/lib/typescript/helpers/incomingCallFromPush.d.ts +5 -13
- package/lib/typescript/helpers/incomingCallFromPush.d.ts.map +1 -1
- package/lib/typescript/helpers/secureStoreRuntime.d.ts +17 -3
- package/lib/typescript/helpers/secureStoreRuntime.d.ts.map +1 -1
- package/lib/typescript/main.d.ts +11 -0
- package/lib/typescript/main.d.ts.map +1 -1
- package/lib/typescript/networking/api-requests/push.api.d.ts +14 -2
- package/lib/typescript/networking/api-requests/push.api.d.ts.map +1 -1
- package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
- package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts +13 -1
- package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts.map +1 -1
- package/lib/typescript/networking/xmppClient.d.ts +2 -4
- package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
- package/lib/typescript/push/pushKeyStore.d.ts +35 -0
- package/lib/typescript/push/pushKeyStore.d.ts.map +1 -0
- package/lib/typescript/push/sealedBox.d.ts +24 -0
- package/lib/typescript/push/sealedBox.d.ts.map +1 -0
- package/lib/typescript/push/sealedPush.d.ts +67 -0
- package/lib/typescript/push/sealedPush.d.ts.map +1 -0
- package/lib/typescript/push/sealedPushTask.d.ts +44 -0
- package/lib/typescript/push/sealedPushTask.d.ts.map +1 -0
- package/lib/typescript/services/pushRegistration.d.ts +8 -0
- package/lib/typescript/services/pushRegistration.d.ts.map +1 -1
- package/lib/typescript/types/models/xmpp.model.d.ts +2 -4
- package/lib/typescript/types/models/xmpp.model.d.ts.map +1 -1
- package/lib/typescript/types/types.d.ts +6 -0
- package/lib/typescript/types/types.d.ts.map +1 -1
- package/lib/typescript/version.d.ts +1 -1
- package/package.json +11 -1
- package/plugins/notification-service/NotificationService.swift +113 -0
- package/plugins/notification-service/SealedBox.swift +355 -0
- package/plugins/notification-service/check/gen-vectors.mjs +24 -0
- package/plugins/notification-service/check/main.swift +37 -0
- package/plugins/notification-service/check/run.sh +12 -0
- package/plugins/notification-service/check/vectors.json +1 -0
- package/plugins/withNotificationServiceExtension.js +70 -18
- package/src/components/MessageBubble/Message.tsx +16 -5
- package/src/components/VideoCalls/VideoCallOverlay.tsx +18 -2
- package/src/components/VideoCalls/callActions.ts +28 -0
- package/src/components/VideoCalls/systemCall.ts +109 -0
- package/src/components/VideoCalls/useCallKeep.ts +89 -97
- package/src/helpers/incomingCallFromPush.ts +97 -26
- package/src/helpers/secureStoreRuntime.ts +21 -3
- package/src/main.ts +38 -0
- package/src/networking/api-requests/push.api.ts +29 -4
- package/src/networking/callTokenStanza.ts +10 -0
- package/src/networking/xmpp/sendMessageReaction.xmpp.ts +38 -6
- package/src/networking/xmppClient.ts +2 -2
- package/src/push/pushKeyStore.ts +163 -0
- package/src/push/sealedBox.ts +104 -0
- package/src/push/sealedPush.ts +138 -0
- package/src/push/sealedPushTask.ts +221 -0
- package/src/services/pushRegistration.ts +25 -5
- package/src/types/models/xmpp.model.ts +2 -1
- package/src/types/types.ts +6 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { hsalsa, xsalsa20poly1305 } from '@noble/ciphers/salsa.js';
|
|
2
|
+
import { clean, concatBytes, u32, u8, utf8ToBytes } from '@noble/ciphers/utils.js';
|
|
3
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
4
|
+
import { blake2b } from '@noble/hashes/blake2.js';
|
|
5
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
6
|
+
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
7
|
+
|
|
8
|
+
// libsodium's sealed box (`crypto_box_seal` / `crypto_box_seal_open`) on
|
|
9
|
+
// the noble primitives the e2ee code already ships, so the JS side needs
|
|
10
|
+
// no native libsodium. The push service seals every chat push for a
|
|
11
|
+
// device that registered an X25519 public key (ethora-backend,
|
|
12
|
+
// services/push/README.md, "Encrypted pushes"); this is the matching
|
|
13
|
+
// receiver. The iOS notification service extension decrypts the same
|
|
14
|
+
// boxes in Swift with libsodium (plugins/notification-service).
|
|
15
|
+
//
|
|
16
|
+
// Layout: ephemeral public key (32) || crypto_box(message, nonce,
|
|
17
|
+
// recipient pk, ephemeral sk), with crypto_box = XSalsa20-Poly1305 under
|
|
18
|
+
// HSalsa20(X25519 shared secret) and nonce = BLAKE2b-192(epk || rpk).
|
|
19
|
+
// `__tests__/sealedBox.test.ts` pins this against libsodium itself.
|
|
20
|
+
|
|
21
|
+
export const SEALED_BOX_PUBLIC_KEY_BYTES = 32;
|
|
22
|
+
export const SEALED_BOX_SECRET_KEY_BYTES = 32;
|
|
23
|
+
/** Ephemeral public key (32) + Poly1305 tag (16). */
|
|
24
|
+
export const SEALED_BOX_OVERHEAD_BYTES = 48;
|
|
25
|
+
|
|
26
|
+
const NONCE_BYTES = 24;
|
|
27
|
+
const SIGMA = u32(utf8ToBytes('expand 32-byte k'));
|
|
28
|
+
const ZERO_NONCE_PREFIX = new Uint32Array(4);
|
|
29
|
+
|
|
30
|
+
export interface SealedBoxKeyPair {
|
|
31
|
+
publicKey: Uint8Array;
|
|
32
|
+
secretKey: Uint8Array;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const generateSealedBoxKeyPair = (): SealedBoxKeyPair => {
|
|
36
|
+
const { secretKey, publicKey } = x25519.keygen();
|
|
37
|
+
return { publicKey, secretKey };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const sealedBoxPublicKeyOf = (secretKey: Uint8Array): Uint8Array =>
|
|
41
|
+
x25519.getPublicKey(secretKey);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The key id the push service derives when the registration carries none:
|
|
45
|
+
* the first 16 hex characters of SHA-256 over the raw public key. Sent back
|
|
46
|
+
* as `kid` in every sealed push.
|
|
47
|
+
*/
|
|
48
|
+
export const sealedBoxKeyId = (publicKey: Uint8Array): string =>
|
|
49
|
+
bytesToHex(sha256(publicKey)).slice(0, 16);
|
|
50
|
+
|
|
51
|
+
// crypto_box_beforenm: HSalsa20 over the X25519 shared secret.
|
|
52
|
+
const boxSharedKey = (theirPublicKey: Uint8Array, mySecretKey: Uint8Array): Uint8Array => {
|
|
53
|
+
const shared = x25519.getSharedSecret(mySecretKey, theirPublicKey);
|
|
54
|
+
const words = new Uint32Array(8);
|
|
55
|
+
hsalsa(SIGMA, u32(shared), ZERO_NONCE_PREFIX, words);
|
|
56
|
+
clean(shared);
|
|
57
|
+
return u8(words);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const sealNonce = (ephemeralPublicKey: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array =>
|
|
61
|
+
blake2b(concatBytes(ephemeralPublicKey, recipientPublicKey), { dkLen: NONCE_BYTES });
|
|
62
|
+
|
|
63
|
+
/** `crypto_box_seal`: used by the tests and the key tool, not by the app. */
|
|
64
|
+
export function sealedBoxSeal(message: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array {
|
|
65
|
+
if (recipientPublicKey.length !== SEALED_BOX_PUBLIC_KEY_BYTES) {
|
|
66
|
+
throw new Error('sealedBoxSeal: the public key must be 32 bytes');
|
|
67
|
+
}
|
|
68
|
+
const ephemeral = generateSealedBoxKeyPair();
|
|
69
|
+
const key = boxSharedKey(recipientPublicKey, ephemeral.secretKey);
|
|
70
|
+
const nonce = sealNonce(ephemeral.publicKey, recipientPublicKey);
|
|
71
|
+
const box = xsalsa20poly1305(key, nonce).encrypt(message);
|
|
72
|
+
clean(key, ephemeral.secretKey);
|
|
73
|
+
return concatBytes(ephemeral.publicKey, box);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `crypto_box_seal_open`. Returns null for anything that does not open:
|
|
78
|
+
* a wrong key, a box for another key, a damaged or truncated box.
|
|
79
|
+
*/
|
|
80
|
+
export function sealedBoxOpen(
|
|
81
|
+
sealed: Uint8Array,
|
|
82
|
+
publicKey: Uint8Array,
|
|
83
|
+
secretKey: Uint8Array
|
|
84
|
+
): Uint8Array | null {
|
|
85
|
+
if (
|
|
86
|
+
sealed.length < SEALED_BOX_OVERHEAD_BYTES ||
|
|
87
|
+
publicKey.length !== SEALED_BOX_PUBLIC_KEY_BYTES ||
|
|
88
|
+
secretKey.length !== SEALED_BOX_SECRET_KEY_BYTES
|
|
89
|
+
) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const ephemeralPublicKey = sealed.subarray(0, SEALED_BOX_PUBLIC_KEY_BYTES);
|
|
93
|
+
const box = sealed.subarray(SEALED_BOX_PUBLIC_KEY_BYTES);
|
|
94
|
+
let key: Uint8Array | null = null;
|
|
95
|
+
try {
|
|
96
|
+
key = boxSharedKey(ephemeralPublicKey, secretKey);
|
|
97
|
+
const nonce = sealNonce(ephemeralPublicKey, publicKey);
|
|
98
|
+
return xsalsa20poly1305(key, nonce).decrypt(box);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
} finally {
|
|
102
|
+
if (key) {clean(key);}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
/** 'reaction' for a push about an emoji put on the user's message; absent for a message. */
|
|
24
|
+
type?: string;
|
|
25
|
+
/** Base64 of the sealed box; absent when the service could not seal. */
|
|
26
|
+
enc?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the box holds: the notification the service would have sent in the clear. */
|
|
30
|
+
export interface SealedPushContent {
|
|
31
|
+
title: string;
|
|
32
|
+
body: string;
|
|
33
|
+
sender: string;
|
|
34
|
+
senderJid: string;
|
|
35
|
+
chat: string;
|
|
36
|
+
chatJid: string;
|
|
37
|
+
msgID: string;
|
|
38
|
+
/** Sender avatar URL, '' when none. */
|
|
39
|
+
image: string;
|
|
40
|
+
/** 'reaction' with the emoji in `reaction`; absent for a message. */
|
|
41
|
+
type?: string;
|
|
42
|
+
reaction?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const isReactionPush = (envelope: Pick<SealedPushEnvelope, 'type'>): boolean =>
|
|
46
|
+
envelope.type === 'reaction';
|
|
47
|
+
|
|
48
|
+
export type SealedPushFailure =
|
|
49
|
+
/** No key on this device (never generated, or the store is not readable). */
|
|
50
|
+
| 'no-key'
|
|
51
|
+
/** Sealed for another key — the app was reinstalled or the key rotated after registration. */
|
|
52
|
+
| 'key-mismatch'
|
|
53
|
+
/** The service sent ids only (it could not seal). */
|
|
54
|
+
| 'no-ciphertext'
|
|
55
|
+
/** Not a box for our key, or damaged. */
|
|
56
|
+
| 'bad-ciphertext'
|
|
57
|
+
/** Opened, but the plaintext is not the expected JSON. */
|
|
58
|
+
| 'bad-content';
|
|
59
|
+
|
|
60
|
+
export type OpenSealedPushResult =
|
|
61
|
+
| { ok: true; content: SealedPushContent; envelope: SealedPushEnvelope }
|
|
62
|
+
| { ok: false; reason: SealedPushFailure; envelope: SealedPushEnvelope };
|
|
63
|
+
|
|
64
|
+
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
65
|
+
|
|
66
|
+
/** The envelope of `data` when it is a sealed chat push, else null. */
|
|
67
|
+
export const sealedPushEnvelopeOf = (
|
|
68
|
+
data: Record<string, unknown> | null | undefined
|
|
69
|
+
): SealedPushEnvelope | null => {
|
|
70
|
+
if (!data || data.alg !== SEALED_PUSH_ALG) {return null;}
|
|
71
|
+
const enc = str(data.enc);
|
|
72
|
+
return {
|
|
73
|
+
v: str(data.v) || SEALED_PUSH_VERSION,
|
|
74
|
+
alg: SEALED_PUSH_ALG,
|
|
75
|
+
kid: str(data.kid),
|
|
76
|
+
jid: str(data.jid),
|
|
77
|
+
msgID: str(data.msgID),
|
|
78
|
+
userJid: str(data.userJid),
|
|
79
|
+
...(str(data.type) ? { type: str(data.type) } : {}),
|
|
80
|
+
...(enc ? { enc } : {}),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const isSealedPush = (data: Record<string, unknown> | null | undefined): boolean =>
|
|
85
|
+
sealedPushEnvelopeOf(data) !== null;
|
|
86
|
+
|
|
87
|
+
const parseContent = (plaintext: Uint8Array): SealedPushContent | null => {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
90
|
+
if (!parsed || typeof parsed !== 'object') {return null;}
|
|
91
|
+
return {
|
|
92
|
+
title: str(parsed.title),
|
|
93
|
+
body: str(parsed.body),
|
|
94
|
+
sender: str(parsed.sender),
|
|
95
|
+
senderJid: str(parsed.senderJid),
|
|
96
|
+
chat: str(parsed.chat),
|
|
97
|
+
chatJid: str(parsed.chatJid),
|
|
98
|
+
msgID: str(parsed.msgID),
|
|
99
|
+
image: str(parsed.image),
|
|
100
|
+
...(str(parsed.type) ? { type: str(parsed.type) } : {}),
|
|
101
|
+
...(str(parsed.reaction) ? { reaction: str(parsed.reaction) } : {}),
|
|
102
|
+
};
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export interface SealedPushKey {
|
|
109
|
+
publicKey: Uint8Array;
|
|
110
|
+
secretKey: Uint8Array;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Open a sealed push with the device key. `key` null means there is no key
|
|
115
|
+
* on this device. Never throws: every failure names why, so the caller can
|
|
116
|
+
* show the placeholder and log the reason.
|
|
117
|
+
*/
|
|
118
|
+
export function openSealedPush(
|
|
119
|
+
envelope: SealedPushEnvelope,
|
|
120
|
+
key: SealedPushKey | null
|
|
121
|
+
): OpenSealedPushResult {
|
|
122
|
+
if (!key) {return { ok: false, reason: 'no-key', envelope };}
|
|
123
|
+
if (envelope.kid && envelope.kid !== sealedBoxKeyId(key.publicKey)) {
|
|
124
|
+
return { ok: false, reason: 'key-mismatch', envelope };
|
|
125
|
+
}
|
|
126
|
+
if (!envelope.enc) {return { ok: false, reason: 'no-ciphertext', envelope };}
|
|
127
|
+
let sealed: Uint8Array;
|
|
128
|
+
try {
|
|
129
|
+
sealed = fromBase64(envelope.enc);
|
|
130
|
+
} catch {
|
|
131
|
+
return { ok: false, reason: 'bad-ciphertext', envelope };
|
|
132
|
+
}
|
|
133
|
+
const plaintext = sealedBoxOpen(sealed, key.publicKey, key.secretKey);
|
|
134
|
+
if (!plaintext) {return { ok: false, reason: 'bad-ciphertext', envelope };}
|
|
135
|
+
const content = parseContent(plaintext);
|
|
136
|
+
if (!content) {return { ok: false, reason: 'bad-content', envelope };}
|
|
137
|
+
return { ok: true, content, envelope };
|
|
138
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { AppState, Platform } from 'react-native';
|
|
2
|
+
import { pushLog } from '../utils/devLogger';
|
|
3
|
+
import { isCallPush } from '../helpers/callPush';
|
|
4
|
+
import { loadPushSecretKey } from './pushKeyStore';
|
|
5
|
+
import {
|
|
6
|
+
isReactionPush,
|
|
7
|
+
openSealedPush,
|
|
8
|
+
sealedPushEnvelopeOf,
|
|
9
|
+
type OpenSealedPushResult,
|
|
10
|
+
} from './sealedPush';
|
|
11
|
+
|
|
12
|
+
// Android half of encrypted pushes. A sealed push is data-only, so the OS
|
|
13
|
+
// shows nothing by itself: expo-notifications hands it to a background
|
|
14
|
+
// task (expo-task-manager, foreground, background and killed app alike)
|
|
15
|
+
// and this task opens the box and posts the notification. iOS does the
|
|
16
|
+
// same in the notification service extension; here `registerSealedPushTask`
|
|
17
|
+
// is a no-op.
|
|
18
|
+
//
|
|
19
|
+
// Both packages are optional peers, required lazily: a host that does not
|
|
20
|
+
// want encrypted pushes never pays for them.
|
|
21
|
+
|
|
22
|
+
export const SEALED_PUSH_TASK = 'ETHORA_SEALED_PUSH';
|
|
23
|
+
|
|
24
|
+
export interface SealedPushTaskOptions {
|
|
25
|
+
/** Android notification channel; the OS default channel when omitted. */
|
|
26
|
+
channelId?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Channel for a reaction to the user's message (a silent one, Telegram's
|
|
29
|
+
* way); `channelId` when omitted. Reactions carry no sound of their own
|
|
30
|
+
* either way.
|
|
31
|
+
*/
|
|
32
|
+
reactionChannelId?: string;
|
|
33
|
+
/** Shown when the box does not open (no key, another key, damaged). Default "New message". */
|
|
34
|
+
placeholderTitle?: string;
|
|
35
|
+
placeholderBody?: string;
|
|
36
|
+
/**
|
|
37
|
+
* An incoming-call push (data-only, high priority) while the app is not
|
|
38
|
+
* in the foreground; the host shows its own incoming-call notification
|
|
39
|
+
* (full-screen intent, Answer / Decline). Not called in the foreground,
|
|
40
|
+
* where the XMPP invite rings the in-app call screen.
|
|
41
|
+
*/
|
|
42
|
+
onCallPush?: (data: Record<string, unknown>) => void | Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PLACEHOLDER_TITLE = 'New message';
|
|
46
|
+
const PLACEHOLDER_REACTION_TITLE = 'New reaction';
|
|
47
|
+
|
|
48
|
+
let registered = false;
|
|
49
|
+
|
|
50
|
+
const loadTaskManager = (): any => {
|
|
51
|
+
try {
|
|
52
|
+
return require('expo-task-manager');
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const loadNotifications = (): any => {
|
|
59
|
+
try {
|
|
60
|
+
return require('expo-notifications');
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The FCM data map out of the task payload. expo-notifications passes the
|
|
68
|
+
* message as `{ notification, data }`; the data keys are on `data`
|
|
69
|
+
* itself, `data.dataString` carries the same as a JSON string on some
|
|
70
|
+
* versions, and a Firebase `RemoteMessage` keeps them under `data.data`.
|
|
71
|
+
*/
|
|
72
|
+
const pushDataCandidates = (payload: unknown): unknown[] => {
|
|
73
|
+
const root = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : null;
|
|
74
|
+
if (!root) {return [];}
|
|
75
|
+
const candidates: unknown[] = [root.data, root];
|
|
76
|
+
const data = root.data && typeof root.data === 'object' ? (root.data as Record<string, unknown>) : null;
|
|
77
|
+
if (data) {
|
|
78
|
+
if (typeof data.dataString === 'string') {
|
|
79
|
+
try {
|
|
80
|
+
candidates.unshift(JSON.parse(data.dataString));
|
|
81
|
+
} catch {
|
|
82
|
+
// not JSON — fall through to the map itself
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
candidates.push(data.data);
|
|
86
|
+
}
|
|
87
|
+
return candidates.filter((c) => c && typeof c === 'object');
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const sealedPushDataOf = (payload: unknown): Record<string, unknown> | null =>
|
|
91
|
+
(pushDataCandidates(payload).find((candidate) =>
|
|
92
|
+
sealedPushEnvelopeOf(candidate as Record<string, unknown>)
|
|
93
|
+
) as Record<string, unknown> | undefined) ?? null;
|
|
94
|
+
|
|
95
|
+
/** The call fields of an incoming-call push in the task payload, if it is one. */
|
|
96
|
+
export const callPushDataOf = (payload: unknown): Record<string, unknown> | null =>
|
|
97
|
+
(pushDataCandidates(payload).find((candidate) =>
|
|
98
|
+
isCallPush(candidate as Record<string, unknown>)
|
|
99
|
+
) as Record<string, unknown> | undefined) ?? null;
|
|
100
|
+
|
|
101
|
+
const notificationContent = (result: OpenSealedPushResult, options: SealedPushTaskOptions) => {
|
|
102
|
+
const { envelope } = result;
|
|
103
|
+
// What the tap handler (`handlePushPayload` through the host's response
|
|
104
|
+
// listener) needs: the same ids a plain push carries on `data`.
|
|
105
|
+
const reaction = isReactionPush(envelope);
|
|
106
|
+
const data = {
|
|
107
|
+
jid: envelope.jid,
|
|
108
|
+
msgID: envelope.msgID,
|
|
109
|
+
userJid: envelope.userJid,
|
|
110
|
+
sealed: true,
|
|
111
|
+
...(reaction ? { type: 'reaction' } : {}),
|
|
112
|
+
};
|
|
113
|
+
const sound = reaction ? false : 'default';
|
|
114
|
+
if (!result.ok) {
|
|
115
|
+
return {
|
|
116
|
+
title: options.placeholderTitle || (reaction ? PLACEHOLDER_REACTION_TITLE : PLACEHOLDER_TITLE),
|
|
117
|
+
...(options.placeholderBody ? { body: options.placeholderBody } : {}),
|
|
118
|
+
data,
|
|
119
|
+
sound,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const { content } = result;
|
|
123
|
+
return {
|
|
124
|
+
title: content.title || content.sender || content.chat || PLACEHOLDER_TITLE,
|
|
125
|
+
body: content.body,
|
|
126
|
+
data: { ...data, chatJid: content.chatJid, senderJid: content.senderJid },
|
|
127
|
+
sound,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Open a sealed push and show it. One notification per chat: the room JID
|
|
133
|
+
* is the identifier, so a newer message replaces the older one the way the
|
|
134
|
+
* service's collapse key does for plain pushes. A reaction has its own
|
|
135
|
+
* identifier per chat (and its own channel), so it never replaces the
|
|
136
|
+
* chat's new-message notification.
|
|
137
|
+
*/
|
|
138
|
+
export async function presentSealedPush(
|
|
139
|
+
data: Record<string, unknown>,
|
|
140
|
+
options: SealedPushTaskOptions = {}
|
|
141
|
+
): Promise<OpenSealedPushResult | null> {
|
|
142
|
+
const envelope = sealedPushEnvelopeOf(data);
|
|
143
|
+
if (!envelope) {return null;}
|
|
144
|
+
const notifications = loadNotifications();
|
|
145
|
+
if (!notifications?.scheduleNotificationAsync) {return null;}
|
|
146
|
+
|
|
147
|
+
const result = openSealedPush(envelope, await loadPushSecretKey());
|
|
148
|
+
// console, not only pushLog: with the app killed this runs headless and
|
|
149
|
+
// `adb logcat -s ReactNativeJS` is the only place to see it.
|
|
150
|
+
if (result.ok) {
|
|
151
|
+
console.log(`[push] sealed push kid=${envelope.kid} opened for ${envelope.jid}`);
|
|
152
|
+
} else {
|
|
153
|
+
console.warn(`[push] sealed push kid=${envelope.kid} not opened (${result.reason}), showing the placeholder`);
|
|
154
|
+
pushLog('warn', `push: sealed push not opened (${result.reason}), showing the placeholder`);
|
|
155
|
+
}
|
|
156
|
+
const reaction = isReactionPush(envelope);
|
|
157
|
+
const base = envelope.jid || envelope.msgID || '';
|
|
158
|
+
const identifier = base ? (reaction ? `${base}#reaction` : base) : undefined;
|
|
159
|
+
const channelId = (reaction && options.reactionChannelId) || options.channelId;
|
|
160
|
+
await notifications.scheduleNotificationAsync({
|
|
161
|
+
identifier,
|
|
162
|
+
content: notificationContent(result, options),
|
|
163
|
+
trigger: channelId ? { channelId } : null,
|
|
164
|
+
});
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Define and register the background task that renders sealed pushes on
|
|
170
|
+
* Android. Call it at module scope of a file the app loads first (the
|
|
171
|
+
* entry file, or the root layout with expo-router): a killed app is
|
|
172
|
+
* started headless to run it, and the task must exist by then. No-op on
|
|
173
|
+
* iOS and when expo-task-manager / expo-notifications are not installed.
|
|
174
|
+
*/
|
|
175
|
+
export function registerSealedPushTask(options: SealedPushTaskOptions = {}): boolean {
|
|
176
|
+
if (Platform.OS !== 'android' || registered) {return registered;}
|
|
177
|
+
const taskManager = loadTaskManager();
|
|
178
|
+
const notifications = loadNotifications();
|
|
179
|
+
if (!taskManager?.defineTask || !notifications?.registerTaskAsync) {
|
|
180
|
+
pushLog(
|
|
181
|
+
'warn',
|
|
182
|
+
'push: expo-task-manager and expo-notifications are needed to show encrypted pushes on Android'
|
|
183
|
+
);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
taskManager.defineTask(SEALED_PUSH_TASK, async ({ data, error }: { data: unknown; error: unknown }) => {
|
|
187
|
+
if (error) {return;}
|
|
188
|
+
const call = callPushDataOf(data);
|
|
189
|
+
if (call) {
|
|
190
|
+
if (AppState.currentState === 'active' || !options.onCallPush) {return;}
|
|
191
|
+
try {
|
|
192
|
+
console.log(`[push] incoming call push ${String(call.callId ?? '')}`);
|
|
193
|
+
await options.onCallPush(call);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
pushLog('warn', 'push: showing an incoming call failed', e);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const map = sealedPushDataOf(data);
|
|
200
|
+
if (!map) {return;}
|
|
201
|
+
// In the foreground the room is live over XMPP; the host's
|
|
202
|
+
// notification handler decides about plain pushes there too.
|
|
203
|
+
if (AppState.currentState === 'active') {return;}
|
|
204
|
+
try {
|
|
205
|
+
await presentSealedPush(map, options);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
pushLog('warn', 'push: showing a sealed push failed', e);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
registered = true;
|
|
211
|
+
notifications.registerTaskAsync(SEALED_PUSH_TASK).catch((e: unknown) => {
|
|
212
|
+
registered = false;
|
|
213
|
+
pushLog('warn', 'push: registering the sealed push task failed', e);
|
|
214
|
+
});
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Test seam only. */
|
|
219
|
+
export const __resetSealedPushTaskForTests = (): void => {
|
|
220
|
+
registered = false;
|
|
221
|
+
};
|
|
@@ -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 = (
|
|
233
|
+
const hold = (
|
|
234
|
+
token: string,
|
|
235
|
+
tokenType?: PushTokenType,
|
|
236
|
+
pushKey?: string
|
|
237
|
+
): RegisteredPushToken => {
|
|
223
238
|
const resolved = tokenType ?? detectPushTokenType(token);
|
|
224
|
-
const
|
|
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();
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// methods) rather than duplicating here.
|
|
6
6
|
|
|
7
7
|
import { Client } from '@xmpp/client';
|
|
8
|
+
import type { ReactionStanzaData } from '../../networking/xmpp/sendMessageReaction.xmpp';
|
|
8
9
|
import XmppClient, {
|
|
9
10
|
HistoryPage,
|
|
10
11
|
HistoryPageCursor,
|
|
@@ -153,7 +154,7 @@ export interface XmppClientInterface {
|
|
|
153
154
|
messageId: string,
|
|
154
155
|
roomJid: string,
|
|
155
156
|
reactionsList: string[],
|
|
156
|
-
data?:
|
|
157
|
+
data?: ReactionStanzaData
|
|
157
158
|
): void;
|
|
158
159
|
sendTextMessageWithTranslateTagStanza?(
|
|
159
160
|
roomJID: string,
|
package/src/types/types.ts
CHANGED
|
@@ -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