@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.
- package/CHANGELOG.md +3 -1
- package/README.md +6 -3
- package/lib/commonjs/helpers/secureStoreRuntime.js +10 -1
- package/lib/commonjs/helpers/secureStoreRuntime.js.map +1 -1
- package/lib/commonjs/main.js +81 -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/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 +124 -0
- package/lib/commonjs/push/sealedPush.js.map +1 -0
- package/lib/commonjs/push/sealedPushTask.js +195 -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/helpers/secureStoreRuntime.js +10 -1
- package/lib/module/helpers/secureStoreRuntime.js.map +1 -1
- package/lib/module/main.js +6 -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/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 +117 -0
- package/lib/module/push/sealedPush.js.map +1 -0
- package/lib/module/push/sealedPushTask.js +187 -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/helpers/secureStoreRuntime.d.ts +17 -3
- package/lib/typescript/helpers/secureStoreRuntime.d.ts.map +1 -1
- package/lib/typescript/main.d.ts +6 -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/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 +61 -0
- package/lib/typescript/push/sealedPush.d.ts.map +1 -0
- package/lib/typescript/push/sealedPushTask.d.ts +33 -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/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/helpers/secureStoreRuntime.ts +21 -3
- package/src/main.ts +30 -0
- package/src/networking/api-requests/push.api.ts +29 -4
- package/src/push/pushKeyStore.ts +163 -0
- package/src/push/sealedBox.ts +104 -0
- package/src/push/sealedPush.ts +127 -0
- package/src/push/sealedPushTask.ts +175 -0
- package/src/services/pushRegistration.ts +25 -5
- package/src/types/types.ts +6 -0
- package/src/version.ts +1 -1
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Expo config plugin — adds an iOS Notification Service Extension
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* Expo config plugin — adds an iOS Notification Service Extension for Ethora
|
|
3
|
+
* chat pushes. The extension (plugins/notification-service/NotificationService.swift)
|
|
4
|
+
* - decrypts encrypted (sealed) pushes with the device key the app keeps in
|
|
5
|
+
* the keychain and shows the real title and text in place of the
|
|
6
|
+
* "New message" placeholder;
|
|
7
|
+
* - shows the attachment thumbnail (photo / video preview) the push service
|
|
8
|
+
* sends as `previewUrl`.
|
|
9
9
|
*
|
|
10
10
|
* An extension is a separate target signed with the app, so it cannot ship
|
|
11
11
|
* inside the npm package as code: this plugin writes it into ios/ on every
|
|
12
12
|
* `expo prebuild` (the generated ios/ folder is gitignored in Expo apps).
|
|
13
|
+
* For the key to be readable from the extension, the app and the extension
|
|
14
|
+
* share a keychain access group (`$(AppIdentifierPrefix)<bundle id>`, the
|
|
15
|
+
* app's own default group, listed first so items the app writes without a
|
|
16
|
+
* group — expo-secure-store's — land in it). The sealed box is opened in
|
|
17
|
+
* plain Swift (SealedBox.swift: CryptoKit for X25519, the rest ported from
|
|
18
|
+
* tweetnacl), so the extension needs no pod.
|
|
13
19
|
*
|
|
14
20
|
* Host app.json:
|
|
15
21
|
* "plugins": ["@ethora/chat-component-rn"]
|
|
@@ -23,25 +29,39 @@
|
|
|
23
29
|
|
|
24
30
|
const fs = require('fs');
|
|
25
31
|
const path = require('path');
|
|
26
|
-
const { withDangerousMod, withXcodeProject } = require('expo/config-plugins');
|
|
32
|
+
const { withDangerousMod, withEntitlementsPlist, withXcodeProject } = require('expo/config-plugins');
|
|
27
33
|
|
|
28
34
|
const TARGET_NAME = 'EthoraNotificationService';
|
|
29
35
|
const SOURCE_DIR = path.join(__dirname, 'notification-service');
|
|
30
|
-
const
|
|
36
|
+
const SOURCES = ['NotificationService.swift', 'SealedBox.swift'];
|
|
37
|
+
const FILES = [...SOURCES, 'Info.plist'];
|
|
38
|
+
const ENTITLEMENTS_FILE = `${TARGET_NAME}.entitlements`;
|
|
39
|
+
const KEYCHAIN_GROUPS_KEY = 'keychain-access-groups';
|
|
31
40
|
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
41
|
+
const appBundleId = (config) => {
|
|
42
|
+
const bundleId = config.ios && config.ios.bundleIdentifier;
|
|
43
|
+
if (!bundleId) {
|
|
35
44
|
throw new Error(
|
|
36
45
|
'[@ethora/chat-component-rn] ios.bundleIdentifier is required for the notification service extension'
|
|
37
46
|
);
|
|
38
47
|
}
|
|
39
|
-
return
|
|
48
|
+
return bundleId;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const extensionBundleId = (config) => `${appBundleId(config)}.${TARGET_NAME}`;
|
|
52
|
+
|
|
53
|
+
/** The keychain access group shared by the app and the extension. */
|
|
54
|
+
const keychainGroup = (config) => `$(AppIdentifierPrefix)${appBundleId(config)}`;
|
|
55
|
+
|
|
56
|
+
const withSharedGroupFirst = (groups, group) => {
|
|
57
|
+
const list = Array.isArray(groups) ? groups.filter((g) => g !== group) : [];
|
|
58
|
+
return [group, ...list];
|
|
40
59
|
};
|
|
41
60
|
|
|
42
61
|
// EAS Build creates signing credentials only for targets it knows about.
|
|
43
62
|
const withEasAppExtension = (config) => {
|
|
44
63
|
const bundleIdentifier = extensionBundleId(config);
|
|
64
|
+
const group = keychainGroup(config);
|
|
45
65
|
config.extra = config.extra || {};
|
|
46
66
|
config.extra.eas = config.extra.eas || {};
|
|
47
67
|
config.extra.eas.build = config.extra.eas.build || {};
|
|
@@ -49,12 +69,42 @@ const withEasAppExtension = (config) => {
|
|
|
49
69
|
const ios = (config.extra.eas.build.experimental.ios =
|
|
50
70
|
config.extra.eas.build.experimental.ios || {});
|
|
51
71
|
const extensions = (ios.appExtensions = ios.appExtensions || []);
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
let entry = extensions.find((ext) => ext.targetName === TARGET_NAME);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
entry = { targetName: TARGET_NAME, bundleIdentifier, entitlements: {} };
|
|
75
|
+
extensions.push(entry);
|
|
54
76
|
}
|
|
77
|
+
entry.entitlements = entry.entitlements || {};
|
|
78
|
+
entry.entitlements[KEYCHAIN_GROUPS_KEY] = withSharedGroupFirst(
|
|
79
|
+
entry.entitlements[KEYCHAIN_GROUPS_KEY],
|
|
80
|
+
group
|
|
81
|
+
);
|
|
55
82
|
return config;
|
|
56
83
|
};
|
|
57
84
|
|
|
85
|
+
// The app's side of the shared keychain group (Keychain Sharing capability).
|
|
86
|
+
const withAppKeychainGroup = (config) =>
|
|
87
|
+
withEntitlementsPlist(config, (cfg) => {
|
|
88
|
+
const group = keychainGroup(cfg);
|
|
89
|
+
cfg.modResults[KEYCHAIN_GROUPS_KEY] = withSharedGroupFirst(
|
|
90
|
+
cfg.modResults[KEYCHAIN_GROUPS_KEY],
|
|
91
|
+
group
|
|
92
|
+
);
|
|
93
|
+
return cfg;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const entitlementsPlist = (group) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
97
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
98
|
+
<plist version="1.0">
|
|
99
|
+
<dict>
|
|
100
|
+
<key>${KEYCHAIN_GROUPS_KEY}</key>
|
|
101
|
+
<array>
|
|
102
|
+
<string>${group}</string>
|
|
103
|
+
</array>
|
|
104
|
+
</dict>
|
|
105
|
+
</plist>
|
|
106
|
+
`;
|
|
107
|
+
|
|
58
108
|
// Copied on every prebuild so an SDK update reaches the app.
|
|
59
109
|
const withExtensionFiles = (config) =>
|
|
60
110
|
withDangerousMod(config, [
|
|
@@ -65,6 +115,7 @@ const withExtensionFiles = (config) =>
|
|
|
65
115
|
for (const file of FILES) {
|
|
66
116
|
fs.copyFileSync(path.join(SOURCE_DIR, file), path.join(dest, file));
|
|
67
117
|
}
|
|
118
|
+
fs.writeFileSync(path.join(dest, ENTITLEMENTS_FILE), entitlementsPlist(keychainGroup(cfg)));
|
|
68
119
|
return cfg;
|
|
69
120
|
},
|
|
70
121
|
]);
|
|
@@ -97,14 +148,14 @@ const withExtensionTarget = (config, { appleTeamId } = {}) =>
|
|
|
97
148
|
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
|
|
98
149
|
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
|
|
99
150
|
|
|
100
|
-
const group = project.addPbxGroup(FILES, TARGET_NAME, TARGET_NAME);
|
|
151
|
+
const group = project.addPbxGroup([...FILES, ENTITLEMENTS_FILE], TARGET_NAME, TARGET_NAME);
|
|
101
152
|
const mainGroup = project.getFirstProject().firstProject.mainGroup;
|
|
102
153
|
project.addToPbxGroup(group.uuid, mainGroup);
|
|
103
154
|
|
|
104
155
|
// 'app_extension' also embeds the .appex into the app (Copy Files to
|
|
105
156
|
// PlugIns) and makes the app depend on it.
|
|
106
157
|
const target = project.addTarget(TARGET_NAME, 'app_extension', TARGET_NAME, bundleIdentifier);
|
|
107
|
-
project.addBuildPhase(
|
|
158
|
+
project.addBuildPhase(SOURCES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
|
|
108
159
|
project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
|
|
109
160
|
project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
|
|
110
161
|
targetUuid = target.uuid;
|
|
@@ -116,6 +167,7 @@ const withExtensionTarget = (config, { appleTeamId } = {}) =>
|
|
|
116
167
|
PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`,
|
|
117
168
|
INFOPLIST_FILE: `"${TARGET_NAME}/Info.plist"`,
|
|
118
169
|
GENERATE_INFOPLIST_FILE: 'NO',
|
|
170
|
+
CODE_SIGN_ENTITLEMENTS: `"${TARGET_NAME}/${ENTITLEMENTS_FILE}"`,
|
|
119
171
|
SWIFT_VERSION: appSettings.SWIFT_VERSION || '5.0',
|
|
120
172
|
IPHONEOS_DEPLOYMENT_TARGET: appSettings.IPHONEOS_DEPLOYMENT_TARGET || '15.1',
|
|
121
173
|
TARGETED_DEVICE_FAMILY: appSettings.TARGETED_DEVICE_FAMILY || '"1,2"',
|
|
@@ -132,6 +184,6 @@ const withExtensionTarget = (config, { appleTeamId } = {}) =>
|
|
|
132
184
|
});
|
|
133
185
|
|
|
134
186
|
const withNotificationServiceExtension = (config, props = {}) =>
|
|
135
|
-
withExtensionTarget(withExtensionFiles(withEasAppExtension(config)), props);
|
|
187
|
+
withExtensionTarget(withExtensionFiles(withAppKeychainGroup(withEasAppExtension(config))), props);
|
|
136
188
|
|
|
137
189
|
module.exports = withNotificationServiceExtension;
|
|
@@ -12,10 +12,25 @@
|
|
|
12
12
|
* AsyncStorage rather than crash (see `secureKeyValue.ts`).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The subset of expo-secure-store's options the SDK uses. `keychainService`
|
|
17
|
+
* must be repeated on every read of an item written with it; the
|
|
18
|
+
* accessibility constants come from `SecureStoreRuntime.accessible`.
|
|
19
|
+
*/
|
|
20
|
+
export interface SecureStoreOptions {
|
|
21
|
+
keychainService?: string;
|
|
22
|
+
/** iOS only: one of `accessible`; ignored on Android. */
|
|
23
|
+
keychainAccessible?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
export interface SecureStoreRuntime {
|
|
16
|
-
getItemAsync: (key: string) => Promise<string | null>;
|
|
17
|
-
setItemAsync: (key: string, value: string) => Promise<void>;
|
|
18
|
-
deleteItemAsync: (key: string) => Promise<void>;
|
|
27
|
+
getItemAsync: (key: string, options?: SecureStoreOptions) => Promise<string | null>;
|
|
28
|
+
setItemAsync: (key: string, value: string, options?: SecureStoreOptions) => Promise<void>;
|
|
29
|
+
deleteItemAsync: (key: string, options?: SecureStoreOptions) => Promise<void>;
|
|
30
|
+
/** expo-secure-store's iOS keychain accessibility constants (undefined on older versions). */
|
|
31
|
+
accessible: {
|
|
32
|
+
afterFirstUnlockThisDeviceOnly?: unknown;
|
|
33
|
+
};
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
let cached: SecureStoreRuntime | null | undefined;
|
|
@@ -45,6 +60,9 @@ export const loadSecureStore = (): SecureStoreRuntime | null => {
|
|
|
45
60
|
getItemAsync: mod.getItemAsync,
|
|
46
61
|
setItemAsync: mod.setItemAsync,
|
|
47
62
|
deleteItemAsync: mod.deleteItemAsync,
|
|
63
|
+
accessible: {
|
|
64
|
+
afterFirstUnlockThisDeviceOnly: mod.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
|
65
|
+
},
|
|
48
66
|
};
|
|
49
67
|
}
|
|
50
68
|
} catch {
|
package/src/main.ts
CHANGED
|
@@ -56,6 +56,36 @@ export type {
|
|
|
56
56
|
PushSubscriptionPayload,
|
|
57
57
|
} from './networking/api-requests/push.api';
|
|
58
58
|
export type { PushTokenType, PushTokenRegistration } from './types/types';
|
|
59
|
+
// Encrypted (sealed) pushes: the device key and the Android renderer.
|
|
60
|
+
// iOS renders them in the notification service extension the config
|
|
61
|
+
// plugin adds. See README, "Encrypted pushes".
|
|
62
|
+
export {
|
|
63
|
+
ensurePushKeyPair,
|
|
64
|
+
getPushKeyPair,
|
|
65
|
+
resetPushKeyPair,
|
|
66
|
+
clearPushKeyPair,
|
|
67
|
+
PUSH_KEY_KEYCHAIN_SERVICE,
|
|
68
|
+
PUSH_SECRET_KEY_ITEM,
|
|
69
|
+
} from './push/pushKeyStore';
|
|
70
|
+
export type { PushKeyPair } from './push/pushKeyStore';
|
|
71
|
+
export {
|
|
72
|
+
registerSealedPushTask,
|
|
73
|
+
presentSealedPush,
|
|
74
|
+
SEALED_PUSH_TASK,
|
|
75
|
+
} from './push/sealedPushTask';
|
|
76
|
+
export type { SealedPushTaskOptions } from './push/sealedPushTask';
|
|
77
|
+
export {
|
|
78
|
+
openSealedPush,
|
|
79
|
+
isSealedPush,
|
|
80
|
+
sealedPushEnvelopeOf,
|
|
81
|
+
SEALED_PUSH_ALG,
|
|
82
|
+
} from './push/sealedPush';
|
|
83
|
+
export type {
|
|
84
|
+
SealedPushEnvelope,
|
|
85
|
+
SealedPushContent,
|
|
86
|
+
SealedPushFailure,
|
|
87
|
+
OpenSealedPushResult,
|
|
88
|
+
} from './push/sealedPush';
|
|
59
89
|
export { useChatRoomFocus } from './hooks/useChatRoomFocus';
|
|
60
90
|
export { logoutService, useLogout } from './hooks/useLogout';
|
|
61
91
|
export { useQRCodeChat, handleQRChatId } from './hooks/useQRCodeChatHandler';
|
|
@@ -67,10 +67,20 @@ const isUnknownFieldError = (error: any): boolean =>
|
|
|
67
67
|
error?.response?.status === 422 &&
|
|
68
68
|
/not allowed/i.test(String(error?.response?.data?.error ?? ''));
|
|
69
69
|
|
|
70
|
+
/** The only sealing scheme the push service knows (README, "Encrypted pushes"). */
|
|
71
|
+
export const PUSH_KEY_ALG = 'sealed-box-x25519';
|
|
72
|
+
|
|
70
73
|
export interface PushSubscriptionPayload {
|
|
71
74
|
registrationToken: string;
|
|
72
75
|
deviceType: PushDeviceType;
|
|
73
76
|
tokenType?: PushTokenType;
|
|
77
|
+
/**
|
|
78
|
+
* Encrypted pushes: base64 of the device's 32-byte X25519 public key.
|
|
79
|
+
* Absent keeps whatever key the service holds for this token.
|
|
80
|
+
*/
|
|
81
|
+
pushKey?: string;
|
|
82
|
+
pushKeyAlg?: typeof PUSH_KEY_ALG;
|
|
83
|
+
pushKeyId?: string;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
export interface RegisterPushSubscriptionArgs {
|
|
@@ -78,15 +88,23 @@ export interface RegisterPushSubscriptionArgs {
|
|
|
78
88
|
token: string;
|
|
79
89
|
tokenType: PushTokenType;
|
|
80
90
|
authToken: string;
|
|
91
|
+
/** Base64 public key for encrypted pushes (`ensurePushKeyPair().publicKey`). */
|
|
92
|
+
pushKey?: string;
|
|
93
|
+
pushKeyId?: string;
|
|
81
94
|
}
|
|
82
95
|
|
|
83
96
|
export const buildPushSubscriptionPayload = (
|
|
84
97
|
token: string,
|
|
85
|
-
tokenType: PushTokenType
|
|
98
|
+
tokenType: PushTokenType,
|
|
99
|
+
pushKey?: string,
|
|
100
|
+
pushKeyId?: string
|
|
86
101
|
): PushSubscriptionPayload => ({
|
|
87
102
|
registrationToken: token,
|
|
88
103
|
deviceType: resolvePushDeviceType(),
|
|
89
104
|
tokenType,
|
|
105
|
+
...(pushKey
|
|
106
|
+
? { pushKey, pushKeyAlg: PUSH_KEY_ALG, ...(pushKeyId ? { pushKeyId } : {}) }
|
|
107
|
+
: {}),
|
|
90
108
|
});
|
|
91
109
|
|
|
92
110
|
export async function registerPushSubscription({
|
|
@@ -94,22 +112,29 @@ export async function registerPushSubscription({
|
|
|
94
112
|
token,
|
|
95
113
|
tokenType,
|
|
96
114
|
authToken,
|
|
115
|
+
pushKey,
|
|
116
|
+
pushKeyId,
|
|
97
117
|
}: RegisterPushSubscriptionArgs): Promise<void> {
|
|
98
118
|
const path = `/v1/push/subscription/${encodeURIComponent(appId)}`;
|
|
99
119
|
const headers = { Authorization: authToken };
|
|
100
|
-
const payload = buildPushSubscriptionPayload(token, tokenType);
|
|
120
|
+
const payload = buildPushSubscriptionPayload(token, tokenType, pushKey, pushKeyId);
|
|
101
121
|
|
|
102
122
|
try {
|
|
103
123
|
await http.post(path, payload, { headers });
|
|
104
124
|
return;
|
|
105
125
|
} catch (error: any) {
|
|
106
|
-
|
|
126
|
+
// A backend that predates `tokenType` still delivers to apns / fcm
|
|
127
|
+
// tokens. One that predates `pushKey` would deliver in the clear: the
|
|
128
|
+
// host asked for encryption, so that is an error, never a quiet fallback.
|
|
129
|
+
const canDowngrade = (tokenType === 'apns' || tokenType === 'fcm') && !pushKey;
|
|
107
130
|
if (!(canDowngrade && isUnknownFieldError(error))) {
|
|
108
131
|
throw toRegistrationError(
|
|
109
132
|
error,
|
|
110
133
|
tokenType === 'expo'
|
|
111
134
|
? 'Push registration (the backend does not accept Expo push tokens yet)'
|
|
112
|
-
:
|
|
135
|
+
: pushKey && isUnknownFieldError(error)
|
|
136
|
+
? 'Push registration (the backend does not support encrypted pushes)'
|
|
137
|
+
: 'Push registration'
|
|
113
138
|
);
|
|
114
139
|
}
|
|
115
140
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { fromBase64, toBase64 } from '../e2ee/crypto';
|
|
2
|
+
import { loadSecureStore, type SecureStoreOptions } from '../helpers/secureStoreRuntime';
|
|
3
|
+
import { pushLog } from '../utils/devLogger';
|
|
4
|
+
import {
|
|
5
|
+
SEALED_BOX_PUBLIC_KEY_BYTES,
|
|
6
|
+
SEALED_BOX_SECRET_KEY_BYTES,
|
|
7
|
+
generateSealedBoxKeyPair,
|
|
8
|
+
sealedBoxKeyId,
|
|
9
|
+
sealedBoxPublicKeyOf,
|
|
10
|
+
} from './sealedBox';
|
|
11
|
+
import type { SealedPushKey } from './sealedPush';
|
|
12
|
+
|
|
13
|
+
// The device key for encrypted pushes: one X25519 pair per install, in the
|
|
14
|
+
// platform keychain / keystore through expo-secure-store. The public half
|
|
15
|
+
// goes to the push service with the token (`pushKey`); the secret half
|
|
16
|
+
// never leaves the device and is read by whoever renders a sealed push —
|
|
17
|
+
// the Android background task (`sealedPushTask.ts`, same process, same
|
|
18
|
+
// store) and the iOS notification service extension, which reads the
|
|
19
|
+
// keychain item directly (plugins/notification-service), so the item's
|
|
20
|
+
// attributes below are a contract with the Swift side:
|
|
21
|
+
// service `ethora.push` (expo-secure-store stores it as `ethora.push:no-auth`)
|
|
22
|
+
// account `ethora_push_secret_key`, UTF-8
|
|
23
|
+
// value base64 of the 32-byte secret key, UTF-8
|
|
24
|
+
// group the app's first keychain access group, which the config
|
|
25
|
+
// plugin shares with the extension
|
|
26
|
+
// Readable after the first unlock so a push that arrives while the phone
|
|
27
|
+
// is locked still decrypts; this device only, so an iCloud keychain copy
|
|
28
|
+
// on another device is never a key for pushes that go to this one.
|
|
29
|
+
|
|
30
|
+
export const PUSH_KEY_KEYCHAIN_SERVICE = 'ethora.push';
|
|
31
|
+
export const PUSH_SECRET_KEY_ITEM = 'ethora_push_secret_key';
|
|
32
|
+
export const PUSH_PUBLIC_KEY_ITEM = 'ethora_push_public_key';
|
|
33
|
+
|
|
34
|
+
export interface PushKeyPair {
|
|
35
|
+
/** Base64 of the raw 32-byte X25519 public key — the `pushKey` of the registration. */
|
|
36
|
+
publicKey: string;
|
|
37
|
+
/** The `pushKeyId` the service derives for it, for logs and the kid check. */
|
|
38
|
+
keyId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let warnedMissingStore = false;
|
|
42
|
+
const warnMissingStoreOnce = () => {
|
|
43
|
+
if (warnedMissingStore) {return;}
|
|
44
|
+
warnedMissingStore = true;
|
|
45
|
+
pushLog(
|
|
46
|
+
'warn',
|
|
47
|
+
'push: expo-secure-store is not installed, so no device key for encrypted ' +
|
|
48
|
+
'pushes can be kept; pushes stay in the clear.'
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const storeOptions = (): SecureStoreOptions | null => {
|
|
53
|
+
const store = loadSecureStore();
|
|
54
|
+
if (!store) {return null;}
|
|
55
|
+
const accessible = store.accessible.afterFirstUnlockThisDeviceOnly;
|
|
56
|
+
return {
|
|
57
|
+
keychainService: PUSH_KEY_KEYCHAIN_SERVICE,
|
|
58
|
+
...(accessible !== undefined ? { keychainAccessible: accessible } : {}),
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const readItem = async (item: string): Promise<string | null> => {
|
|
63
|
+
const store = loadSecureStore();
|
|
64
|
+
const options = storeOptions();
|
|
65
|
+
if (!store || !options) {return null;}
|
|
66
|
+
try {
|
|
67
|
+
return await store.getItemAsync(item, options);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
pushLog('warn', `push: reading ${item} failed`, e);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const decodeKey = (raw: string | null, length: number): Uint8Array | null => {
|
|
75
|
+
if (!raw) {return null;}
|
|
76
|
+
try {
|
|
77
|
+
const bytes = fromBase64(raw);
|
|
78
|
+
return bytes.length === length ? bytes : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The stored pair, or null when there is none (or it is unreadable).
|
|
86
|
+
* The public key is re-derived from the secret, so the pair is consistent
|
|
87
|
+
* even if only the secret item survived.
|
|
88
|
+
*/
|
|
89
|
+
export async function loadPushSecretKey(): Promise<SealedPushKey | null> {
|
|
90
|
+
const secretKey = decodeKey(await readItem(PUSH_SECRET_KEY_ITEM), SEALED_BOX_SECRET_KEY_BYTES);
|
|
91
|
+
if (!secretKey) {return null;}
|
|
92
|
+
const stored = decodeKey(await readItem(PUSH_PUBLIC_KEY_ITEM), SEALED_BOX_PUBLIC_KEY_BYTES);
|
|
93
|
+
return { publicKey: stored ?? sealedBoxPublicKeyOf(secretKey), secretKey };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const describe = (publicKey: Uint8Array): PushKeyPair => ({
|
|
97
|
+
publicKey: toBase64(publicKey),
|
|
98
|
+
keyId: sealedBoxKeyId(publicKey),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** The public half of the stored pair, null when the device has no key. */
|
|
102
|
+
export async function getPushKeyPair(): Promise<PushKeyPair | null> {
|
|
103
|
+
const key = await loadPushSecretKey();
|
|
104
|
+
return key ? describe(key.publicKey) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate and store a new pair, replacing any existing one. Pushes sealed
|
|
109
|
+
* for the old key stop opening at once, so re-register the token with the
|
|
110
|
+
* new `publicKey` right after. Null when expo-secure-store is missing or
|
|
111
|
+
* the keychain refused the write.
|
|
112
|
+
*/
|
|
113
|
+
export async function resetPushKeyPair(): Promise<PushKeyPair | null> {
|
|
114
|
+
const store = loadSecureStore();
|
|
115
|
+
const options = storeOptions();
|
|
116
|
+
if (!store || !options) {
|
|
117
|
+
warnMissingStoreOnce();
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const pair = generateSealedBoxKeyPair();
|
|
121
|
+
try {
|
|
122
|
+
await store.setItemAsync(PUSH_SECRET_KEY_ITEM, toBase64(pair.secretKey), options);
|
|
123
|
+
await store.setItemAsync(PUSH_PUBLIC_KEY_ITEM, toBase64(pair.publicKey), options);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
pushLog('warn', 'push: storing the device key failed', e);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const check = await loadPushSecretKey();
|
|
129
|
+
if (!check || toBase64(check.publicKey) !== toBase64(pair.publicKey)) {
|
|
130
|
+
pushLog('warn', 'push: the device key was not kept');
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
pushLog('rn', `push: new device key ${sealedBoxKeyId(pair.publicKey)}`);
|
|
134
|
+
return describe(pair.publicKey);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The device key for encrypted pushes, created on first call. Hand
|
|
139
|
+
* `publicKey` to `registerPushToken` (or send it as `pushKey` yourself).
|
|
140
|
+
*/
|
|
141
|
+
export async function ensurePushKeyPair(): Promise<PushKeyPair | null> {
|
|
142
|
+
const existing = await getPushKeyPair();
|
|
143
|
+
return existing ?? resetPushKeyPair();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Remove the pair; the next `ensurePushKeyPair` creates a new one. */
|
|
147
|
+
export async function clearPushKeyPair(): Promise<void> {
|
|
148
|
+
const store = loadSecureStore();
|
|
149
|
+
const options = storeOptions();
|
|
150
|
+
if (!store || !options) {return;}
|
|
151
|
+
for (const item of [PUSH_SECRET_KEY_ITEM, PUSH_PUBLIC_KEY_ITEM]) {
|
|
152
|
+
try {
|
|
153
|
+
await store.deleteItemAsync(item, options);
|
|
154
|
+
} catch {
|
|
155
|
+
// Not there — nothing to remove.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Test seam only. */
|
|
161
|
+
export const __resetPushKeyStoreForTests = (): void => {
|
|
162
|
+
warnedMissingStore = false;
|
|
163
|
+
};
|
|
@@ -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
|
+
}
|