@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
@@ -1,15 +1,21 @@
1
1
  /**
2
- * Expo config plugin — adds an iOS Notification Service Extension that shows
3
- * the attachment thumbnail (photo / video preview) in chat pushes.
4
- *
5
- * The push service sends `previewUrl` with `mutable-content: 1`. Android
6
- * renders the picture by itself; iOS only does it through an extension
7
- * target inside the app, which downloads the file and attaches it
8
- * (plugins/notification-service/NotificationService.swift).
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 FILES = ['NotificationService.swift', 'Info.plist'];
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 extensionBundleId = (config) => {
33
- const appBundleId = config.ios && config.ios.bundleIdentifier;
34
- if (!appBundleId) {
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 `${appBundleId}.${TARGET_NAME}`;
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
- if (!extensions.some((ext) => ext.targetName === TARGET_NAME)) {
53
- extensions.push({ targetName: TARGET_NAME, bundleIdentifier, entitlements: {} });
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(['NotificationService.swift'], 'PBXSourcesBuildPhase', 'Sources', target.uuid);
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
- const canDowngrade = tokenType === 'apns' || tokenType === 'fcm';
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
- : 'Push registration'
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
+ }