@freecorps/ember-expo-push 0.0.0-stage → 0.3.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FreeCorps
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # @freecorps/ember-expo-push
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The device side of Ember's **end-to-end encrypted pushes**. Notifications are sealed on your Ember Worker
4
+ (`@freecorps/ember-push`, direct APNs/FCM transport) to a key that only the phone holds. Apple and Google carry
5
+ ciphertext plus a fallback text that says nothing ("New mail"). Subject and sender never pass through them in
6
+ clear.
7
+
8
+ - **iOS:** a Notification Service Extension (added by this package's Expo config plugin) decrypts each push with
9
+ CryptoKit before it is shown.
10
+ - **Android:** sealed pushes are FCM data-only messages. A background task decrypts them and posts a local
11
+ notification.
12
+ - If something fails (no key yet, iOS 16, a push sealed to an old or foreign key), the fallback text is shown. The
13
+ device never stays silent.
14
+
15
+ The cipher suite is HPKE (RFC 9180) base mode: DHKEM(P-256, HKDF-SHA256) + HKDF-SHA256 + AES-128-GCM. It was chosen
16
+ by Freecorps Brigade's spike S1 and is ported from Brigade's MIT `packages/push`. JS decryption uses
17
+ [noble](https://paulmillr.com/noble/), since React Native has no WebCrypto.
18
+
19
+ ## Setup
20
+
21
+ ```sh
22
+ npx expo install expo-notifications expo-secure-store expo-constants expo-task-manager expo-crypto
23
+ npm install @freecorps/ember-expo-push @freecorps/ember-react-native
24
+ ```
25
+
26
+ ```ts
27
+ // app.config.ts
28
+ plugins: [
29
+ "expo-secure-store",
30
+ "expo-notifications",
31
+ ["@freecorps/ember-expo-push", { /* appGroup, apsEnvironment, timeSensitive, deploymentTarget */ }],
32
+ ],
33
+ ios: { bundleIdentifier: "com.acme.mail" },
34
+ android: { package: "com.acme.mail", googleServicesFile: process.env.GOOGLE_SERVICES_JSON ?? "./google-services.json" },
35
+ ```
36
+
37
+ ```ts
38
+ // index.ts: at module top level, before registerRootComponent (Android headless JS needs it)
39
+ import { defineEmberPushTask } from "@freecorps/ember-expo-push";
40
+ defineEmberPushTask();
41
+
42
+ // after sign-in
43
+ import { ensurePushKey, registerEmberPushTask } from "@freecorps/ember-expo-push";
44
+ import { registerForPush } from "@freecorps/ember-react-native";
45
+
46
+ const key = await ensurePushKey(); // P-256, created once, kept in SecureStore
47
+ const r = await registerForPush({ kind: "device", publicKey: key.publicKey, androidChannels: [{ id: "mail", name: "Mail" }] });
48
+ if (r.status === "granted") {
49
+ await registerDevice({ token: r.token, platform: r.platform, environment: r.environment, publicKey: r.publicKey });
50
+ if (r.platform === "android") await registerEmberPushTask();
51
+ }
52
+ ```
53
+
54
+ `registerDevice` is your mutation calling `push.register(ctx, { userId, ...args })` (component) or
55
+ `registerPushDevice`. On the server, enable the direct transport and encryption (see
56
+ [`@freecorps/ember-push`](../push/README.md)): `pushComponent` with `encryption: "required"` for mail.
57
+
58
+ ## Config plugin options
59
+
60
+ | Option | Default | What |
61
+ |---|---|---|
62
+ | `appGroup` | `group.<bundle id>.ember` | App Group shared by the app and the extension; also the keychain access group of the device key |
63
+ | `apsEnvironment` | `development` locally, `production` when `EAS_BUILD` is set | The `aps-environment` entitlement; `EMBER_APS_ENVIRONMENT` overrides it too. Recorded in `extra.emberPush.apsEnvironment`, which `pushEnvironment()` in `@freecorps/ember-react-native` reads, so the token registers with the right APNs host |
64
+ | `timeSensitive` | `true` | Adds `com.apple.developer.usernotifications.time-sensitive`, needed for `urgency: "high"` |
65
+ | `deploymentTarget` | `16.4` | The extension's minimum iOS version. Decryption needs iOS 17 (CryptoKit HPKE); older devices show the fallback |
66
+ | `keychainService`, `keyName` | `ember.push`, `ember.push.keys.v1` | Where the key lives (must match `ensurePushKey` options if you change them) |
67
+
68
+ What it generates on iOS:
69
+ - an `EmberNotificationService` app-extension target (bundle id `<bundle id>.EmberNotificationService`), embedded
70
+ in the app, built from `ios/EmberNotificationService/*.swift`;
71
+ - the App Group on both targets;
72
+ - `aps-environment`, plus the time-sensitive entitlement;
73
+ - an `extra.eas.build.experimental.ios.appExtensions` entry, so EAS Build creates and signs the extension's
74
+ provisioning profile.
75
+
76
+ Android needs nothing from the plugin.
77
+
78
+ ## API
79
+
80
+ | Export | What |
81
+ |---|---|
82
+ | `ensurePushKey(opts?)` | `{ publicKey, keyId }`. Creates the key pair on first use and stores it with `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` (readable while locked, never synced or restored to another device), in the App Group's access group on iOS |
83
+ | `rotatePushKey()`, `deletePushKeys()`, `loadPushKeys()` | Rotation keeps one previous key, so pushes sealed before the server learned the new key still open. Call delete at sign-out |
84
+ | `defineEmberPushTask(opts?)`, `registerEmberPushTask()` | Android: define the task at top level and register it after sign-in |
85
+ | `presentEncryptedPush(payload, opts?)` | What the task does: decrypt, then show the content on its `channelId`, replacing by `tag` (the collapse id), or else the fallback |
86
+ | `openPushEnvelope(envelope, keys)`, `hpkeOpen`, `hpkeSeal`, `pushKeyId` | Pure-JS HPKE for any JS client |
87
+
88
+ ## Verifying the extension (done for this package, macOS + Xcode)
89
+
90
+ - `test/swift.test.ts` compiles `EmberPushCrypto.swift` with CryptoKit on macOS. It opens envelopes the server sealed
91
+ (WebCrypto) with keys stored the way `ensurePushKey` stores them, including a rotated key. Foreign and tampered
92
+ envelopes are refused. The test also type-checks the whole extension against the iOS Simulator SDK. It is skipped
93
+ where there is no Swift toolchain.
94
+ - `test/crypto.test.ts` checks the noble decoder against RFC 9180 vector A.3.1 and against the server's WebCrypto,
95
+ in both directions.
96
+ - `test/plugin.test.ts` applies the plugin to a real Expo SDK 58 `project.pbxproj`.
97
+ - By hand, on apps/example-expo with `EMBER_PUSH_DIRECT=1`: `npx expo prebuild --platform ios --no-install`, then
98
+ `xcodebuild -project ios/EmberChat.xcodeproj -target EmberNotificationService -sdk iphonesimulator
99
+ CODE_SIGNING_ALLOWED=NO build` gives `BUILD SUCCEEDED`. The app embeds the `.appex` in PlugIns, and running
100
+ prebuild again does not add the target twice.
101
+
102
+ Still to do on a real device: see "Real-device testing" in [`@freecorps/ember-push`](../push/README.md).
103
+
104
+ License: MIT.
package/app.plugin.cjs ADDED
@@ -0,0 +1,221 @@
1
+ // @ts-check
2
+ /**
3
+ * Expo config plugin for Ember's end-to-end encrypted pushes (`plugins: ["@freecorps/ember-expo-push"]` or
4
+ * `["@freecorps/ember-expo-push", { appGroup, apsEnvironment, timeSensitive, deploymentTarget }]`).
5
+ *
6
+ * iOS (written for Ember from Apple's and Expo's documentation; same arrangement Freecorps Brigade verified on a real
7
+ * iPhone in its spike S1):
8
+ * - an `EmberNotificationService` Notification Service Extension target (bundle id `<app>.EmberNotificationService`)
9
+ * built from this package's `ios/EmberNotificationService/*.swift`, embedded in the app, iOS 16.4+ (decrypts on
10
+ * iOS 17+, shows the fallback text below);
11
+ * - the App Group `group.<bundle id>.ember` on both targets: the device key is stored in its keychain access group so
12
+ * the extension can read it (`ensurePushKey`);
13
+ * - `aps-environment`: `development` for local builds, `production` when `EAS_BUILD` is set (EAS builds are ad hoc or
14
+ * App Store signed); `EMBER_APS_ENVIRONMENT` or the `apsEnvironment` option override it. Recorded in
15
+ * `extra.emberPush.apsEnvironment` so the app registers its tokens with the right environment (`pushEnvironment()`
16
+ * in @freecorps/ember-react-native);
17
+ * - `com.apple.developer.usernotifications.time-sensitive` (urgency `high`) unless `timeSensitive: false`;
18
+ * - the extension declared to EAS Build (`extra.eas.build.experimental.ios.appExtensions`) so EAS signs it.
19
+ *
20
+ * Android needs nothing from this plugin: sealed pushes are data-only FCM messages the app decrypts in a background
21
+ * task (`defineEmberPushTask`). Set `android.googleServicesFile` for FCM as usual.
22
+ */
23
+ const fs = require("fs");
24
+ const path = require("path");
25
+
26
+ /** @returns {any} */
27
+ function configPlugins() {
28
+ try {
29
+ return require("expo/config-plugins");
30
+ } catch {
31
+ return require("@expo/config-plugins");
32
+ }
33
+ }
34
+
35
+ const TARGET = "EmberNotificationService";
36
+ const SWIFT_FILES = ["NotificationService.swift", "EmberPushCrypto.swift"];
37
+ const DEFAULTS = { keychainService: "ember.push", keyName: "ember.push.keys.v1", deploymentTarget: "16.4", timeSensitive: true };
38
+
39
+ /**
40
+ * @typedef {{ appGroup?: string; apsEnvironment?: "development" | "production"; timeSensitive?: boolean;
41
+ * deploymentTarget?: string; keychainService?: string; keyName?: string; extensionBundleIdentifier?: string }} EmberPushPluginProps
42
+ */
43
+
44
+ /** @param {EmberPushPluginProps} props @param {Record<string, string | undefined>} env */
45
+ function resolveApsEnvironment(props = {}, env = process.env) {
46
+ const v = props.apsEnvironment ?? env.EMBER_APS_ENVIRONMENT ?? (env.EAS_BUILD ? "production" : "development");
47
+ if (v !== "development" && v !== "production") throw new Error(`@freecorps/ember-expo-push: apsEnvironment must be "development" or "production", got ${JSON.stringify(v)}`);
48
+ return v;
49
+ }
50
+
51
+ /** @param {any} config @param {EmberPushPluginProps} props @param {Record<string, string | undefined>} env */
52
+ function emberPushSettings(config, props = {}, env = process.env) {
53
+ const bundleId = config.ios?.bundleIdentifier;
54
+ if (!bundleId) throw new Error("@freecorps/ember-expo-push: set ios.bundleIdentifier in the app config");
55
+ return {
56
+ bundleId,
57
+ appGroup: props.appGroup ?? `group.${bundleId}.ember`,
58
+ extensionBundleId: props.extensionBundleIdentifier ?? `${bundleId}.${TARGET}`,
59
+ apsEnvironment: resolveApsEnvironment(props, env),
60
+ keychainService: props.keychainService ?? DEFAULTS.keychainService,
61
+ keyName: props.keyName ?? DEFAULTS.keyName,
62
+ deploymentTarget: props.deploymentTarget ?? DEFAULTS.deploymentTarget,
63
+ timeSensitive: props.timeSensitive ?? DEFAULTS.timeSensitive,
64
+ };
65
+ }
66
+
67
+ /** @param {unknown} v @param {string} indent @returns {string} */
68
+ function plistValue(v, indent) {
69
+ const esc = (/** @type {string} */ s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
70
+ if (Array.isArray(v)) return `<array>\n${v.map((x) => `${indent}\t${plistValue(x, indent + "\t")}`).join("\n")}\n${indent}</array>`;
71
+ if (typeof v === "boolean") return v ? "<true/>" : "<false/>";
72
+ if (v && typeof v === "object") {
73
+ const lines = Object.entries(v).map(([k, x]) => `${indent}\t<key>${esc(k)}</key>\n${indent}\t${plistValue(x, indent + "\t")}`);
74
+ return `<dict>\n${lines.join("\n")}\n${indent}</dict>`;
75
+ }
76
+ return `<string>${esc(String(v))}</string>`;
77
+ }
78
+
79
+ /** @param {Record<string, unknown>} dict */
80
+ function plist(dict) {
81
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n${plistValue(dict, "")}\n</plist>\n`;
82
+ }
83
+
84
+ /** The extension's Info.plist: NSExtension + where the device key is (read by NotificationService.swift). */
85
+ function extensionInfoPlist(/** @type {ReturnType<typeof emberPushSettings>} */ s) {
86
+ return plist({
87
+ CFBundleDevelopmentRegion: "$(DEVELOPMENT_LANGUAGE)",
88
+ CFBundleDisplayName: TARGET,
89
+ CFBundleExecutable: "$(EXECUTABLE_NAME)",
90
+ CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)",
91
+ CFBundleInfoDictionaryVersion: "6.0",
92
+ CFBundleName: "$(PRODUCT_NAME)",
93
+ CFBundlePackageType: "$(PRODUCT_BUNDLE_PACKAGE_TYPE)",
94
+ CFBundleShortVersionString: "$(MARKETING_VERSION)",
95
+ CFBundleVersion: "$(CURRENT_PROJECT_VERSION)",
96
+ NSExtension: { NSExtensionPointIdentifier: "com.apple.usernotifications.service", NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).NotificationService" },
97
+ EmberPushAppGroup: s.appGroup,
98
+ EmberPushKeychainService: s.keychainService,
99
+ EmberPushKeyName: s.keyName,
100
+ });
101
+ }
102
+
103
+ function extensionEntitlements(/** @type {ReturnType<typeof emberPushSettings>} */ s) {
104
+ return plist({ "com.apple.security.application-groups": [s.appGroup] });
105
+ }
106
+
107
+ /** Adds the app's entitlements (pure: for tests). */
108
+ function applyAppEntitlements(/** @type {Record<string, any>} */ ent, /** @type {ReturnType<typeof emberPushSettings>} */ s) {
109
+ ent["aps-environment"] = s.apsEnvironment;
110
+ const groups = new Set([...(ent["com.apple.security.application-groups"] ?? []), s.appGroup]);
111
+ ent["com.apple.security.application-groups"] = [...groups];
112
+ if (s.timeSensitive) ent["com.apple.developer.usernotifications.time-sensitive"] = true;
113
+ return ent;
114
+ }
115
+
116
+ /** Records the settings for the app (expo-constants) and declares the extension to EAS (pure: for tests). */
117
+ function applyAppConfig(/** @type {any} */ config, /** @type {ReturnType<typeof emberPushSettings>} */ s) {
118
+ config.extra = config.extra ?? {};
119
+ config.extra.emberPush = { apsEnvironment: s.apsEnvironment, appGroup: s.appGroup, keychainService: s.keychainService, keyName: s.keyName };
120
+ const eas = (config.extra.eas = config.extra.eas ?? {});
121
+ const build = (eas.build = eas.build ?? {});
122
+ const exp = (build.experimental = build.experimental ?? {});
123
+ const ios = (exp.ios = exp.ios ?? {});
124
+ const list = (ios.appExtensions = (ios.appExtensions ?? []).filter((/** @type {any} */ e) => e.targetName !== TARGET));
125
+ list.push({ targetName: TARGET, bundleIdentifier: s.extensionBundleId, entitlements: { "com.apple.security.application-groups": [s.appGroup] } });
126
+ return config;
127
+ }
128
+
129
+ /**
130
+ * Adds the extension target to the Xcode project (idempotent): group with the sources, Info.plist and entitlements;
131
+ * an `app_extension` target embedded in the app (the xcode library adds the copy phase and the dependency); Sources,
132
+ * Resources and Frameworks phases; build settings.
133
+ */
134
+ function addExtensionTarget(/** @type {any} */ project, /** @type {ReturnType<typeof emberPushSettings>} */ s, /** @type {any} */ config) {
135
+ // `addTarget` stores the name quoted until the project is written and parsed again: match both forms.
136
+ const targets = project.pbxNativeTargetSection();
137
+ if (Object.values(targets).some((/** @type {any} */ t) => typeof t === "object" && (t.name === TARGET || t.name === `"${TARGET}"`))) return project;
138
+ const objects = project.hash.project.objects;
139
+ objects.PBXTargetDependency = objects.PBXTargetDependency ?? {};
140
+ objects.PBXContainerItemProxy = objects.PBXContainerItemProxy ?? {};
141
+
142
+ const files = [...SWIFT_FILES, "Info.plist", `${TARGET}.entitlements`];
143
+ const group = project.addPbxGroup(files, TARGET, TARGET);
144
+ const mainGroup = project.getFirstProject().firstProject.mainGroup;
145
+ project.addToPbxGroup(group.uuid, mainGroup);
146
+
147
+ const target = project.addTarget(TARGET, "app_extension", TARGET, s.extensionBundleId);
148
+ project.addBuildPhase(SWIFT_FILES, "PBXSourcesBuildPhase", "Sources", target.uuid);
149
+ project.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", target.uuid);
150
+ project.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", target.uuid);
151
+
152
+ const configs = project.pbxXCBuildConfigurationSection();
153
+ const listId = target.pbxNativeTarget.buildConfigurationList;
154
+ const list = project.pbxXCConfigurationList()[listId];
155
+ const ids = new Set((list?.buildConfigurations ?? []).map((/** @type {any} */ c) => c.value));
156
+ for (const id of ids) {
157
+ const bs = configs[id]?.buildSettings;
158
+ if (!bs) continue;
159
+ Object.assign(bs, {
160
+ INFOPLIST_FILE: `${TARGET}/Info.plist`,
161
+ CODE_SIGN_ENTITLEMENTS: `${TARGET}/${TARGET}.entitlements`,
162
+ CODE_SIGN_STYLE: "Automatic",
163
+ IPHONEOS_DEPLOYMENT_TARGET: s.deploymentTarget,
164
+ SWIFT_VERSION: "5.0",
165
+ TARGETED_DEVICE_FAMILY: '"1,2"',
166
+ MARKETING_VERSION: config.version ?? "1.0.0",
167
+ CURRENT_PROJECT_VERSION: config.ios?.buildNumber ?? "1",
168
+ PRODUCT_BUNDLE_IDENTIFIER: s.extensionBundleId,
169
+ GENERATE_INFOPLIST_FILE: "NO",
170
+ APPLICATION_EXTENSION_API_ONLY: "YES",
171
+ SKIP_INSTALL: "YES",
172
+ });
173
+ if (config.ios?.appleTeamId) bs.DEVELOPMENT_TEAM = config.ios.appleTeamId;
174
+ }
175
+ return project;
176
+ }
177
+
178
+ /** @param {any} config @param {EmberPushPluginProps} [props] */
179
+ function withEmberPush(config, props = {}) {
180
+ const { withEntitlementsPlist, withDangerousMod, withXcodeProject } = configPlugins();
181
+ const s = emberPushSettings(config, props);
182
+ applyAppConfig(config, s);
183
+ config = withEntitlementsPlist(config, (/** @type {any} */ c) => {
184
+ applyAppEntitlements(c.modResults, s);
185
+ return c;
186
+ });
187
+ config = withDangerousMod(config, [
188
+ "ios",
189
+ async (/** @type {any} */ c) => {
190
+ const dir = path.join(c.modRequest.platformProjectRoot, TARGET);
191
+ fs.mkdirSync(dir, { recursive: true });
192
+ for (const f of SWIFT_FILES) fs.copyFileSync(path.join(__dirname, "ios", TARGET, f), path.join(dir, f));
193
+ fs.writeFileSync(path.join(dir, "Info.plist"), extensionInfoPlist(s));
194
+ fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), extensionEntitlements(s));
195
+ return c;
196
+ },
197
+ ]);
198
+ config = withXcodeProject(config, (/** @type {any} */ c) => {
199
+ addExtensionTarget(c.modResults, s, c);
200
+ return c;
201
+ });
202
+ return config;
203
+ }
204
+
205
+ const pkg = require("./package.json");
206
+ let plugin = withEmberPush;
207
+ try {
208
+ plugin = configPlugins().createRunOncePlugin(withEmberPush, pkg.name, pkg.version);
209
+ } catch {
210
+ // Without expo installed (tests of the pure helpers), export the plain function.
211
+ }
212
+ module.exports = plugin;
213
+ module.exports.withEmberPush = withEmberPush;
214
+ module.exports.emberPushSettings = emberPushSettings;
215
+ module.exports.resolveApsEnvironment = resolveApsEnvironment;
216
+ module.exports.applyAppEntitlements = applyAppEntitlements;
217
+ module.exports.applyAppConfig = applyAppConfig;
218
+ module.exports.addExtensionTarget = addExtensionTarget;
219
+ module.exports.extensionInfoPlist = extensionInfoPlist;
220
+ module.exports.extensionEntitlements = extensionEntitlements;
221
+ module.exports.EMBER_NSE_TARGET = TARGET;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Android: sealed pushes arrive as FCM data-only messages (`@freecorps/ember-push` `fcmMessage`): `{ ember: <envelope
3
+ * JSON>, fallbackTitle, fallbackBody, channelId?, tag?, urgency }`. Nothing is shown by the system for those;
4
+ * expo-notifications hands them to a background task (`Notifications.registerTaskAsync`), which runs in the foreground,
5
+ * in the background and with the app killed (headless JS). The task decrypts with the device key and posts a local
6
+ * notification with the content — or, when the push does not open, the fallback text: never silence.
7
+ *
8
+ * // index.ts — at module top level, before registerRootComponent:
9
+ * import { defineEmberPushTask } from "@freecorps/ember-expo-push";
10
+ * defineEmberPushTask();
11
+ * // after sign-in (once per launch is fine):
12
+ * await registerEmberPushTask();
13
+ *
14
+ * iOS needs none of this: the Notification Service Extension decrypts before the notification is shown.
15
+ */
16
+ import { type OpenedPush, type PushKeyPair } from "./crypto.ts";
17
+ import { type PushKeyOptions } from "./keys.ts";
18
+ import { type NotificationsLike, type TaskManagerLike } from "./modules.ts";
19
+ export declare const EMBER_PUSH_TASK = "ember-push-decrypt";
20
+ /** The sealed fields of a data message, wherever expo-notifications put them in the task payload. */
21
+ export interface EncryptedPushData {
22
+ ember: string;
23
+ fallbackTitle?: string;
24
+ fallbackBody?: string;
25
+ channelId?: string;
26
+ tag?: string;
27
+ urgency?: string;
28
+ }
29
+ /**
30
+ * Finds the sealed fields in a background-task payload. expo-notifications passes `{ data: { ...fcmData } }`, with the
31
+ * data also as a JSON string in `data.dataString` on some versions; a `RemoteMessage`-like `{ data }` or the FCM data
32
+ * itself works too. Null when this is not an Ember sealed push.
33
+ */
34
+ export declare function extractEncryptedPush(payload: unknown): EncryptedPushData | null;
35
+ export interface PresentOptions extends PushKeyOptions {
36
+ notifications?: NotificationsLike | null;
37
+ /** Keys to open with (default: the stored device keys). */
38
+ keys?: readonly PushKeyPair[];
39
+ /** Change the content before it is shown (e.g. a per-app layout); return null to show nothing. */
40
+ transform?: (opened: OpenedPush, data: EncryptedPushData) => Record<string, unknown> | null;
41
+ /** Default fallback when the push carries none. */
42
+ fallback?: {
43
+ title: string;
44
+ body: string;
45
+ };
46
+ }
47
+ export type PresentResult = {
48
+ shown: "content" | "fallback";
49
+ identifier: string;
50
+ reason?: string;
51
+ } | {
52
+ shown: "nothing";
53
+ reason: string;
54
+ };
55
+ /** Decrypts a sealed data push and posts it as a local notification (the content, or the fallback text). */
56
+ export declare function presentEncryptedPush(payload: unknown, o?: PresentOptions): Promise<PresentResult>;
57
+ export interface TaskOptions extends PresentOptions {
58
+ taskManager?: TaskManagerLike | null;
59
+ taskName?: string;
60
+ /** Default Platform.OS; the task does nothing on iOS (the NSE decrypts there). */
61
+ platform?: string;
62
+ onResult?: (r: PresentResult) => void;
63
+ }
64
+ /**
65
+ * Defines the background task that shows sealed pushes on Android. Call at module top level (headless JS needs the
66
+ * definition before the app renders). Returns the task name.
67
+ */
68
+ export declare function defineEmberPushTask(o?: TaskOptions): string;
69
+ /** Registers the task with expo-notifications so data-only pushes reach it. */
70
+ export declare function registerEmberPushTask(o?: {
71
+ notifications?: NotificationsLike | null;
72
+ taskName?: string;
73
+ }): Promise<void>;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Android: sealed pushes arrive as FCM data-only messages (`@freecorps/ember-push` `fcmMessage`): `{ ember: <envelope
3
+ * JSON>, fallbackTitle, fallbackBody, channelId?, tag?, urgency }`. Nothing is shown by the system for those;
4
+ * expo-notifications hands them to a background task (`Notifications.registerTaskAsync`), which runs in the foreground,
5
+ * in the background and with the app killed (headless JS). The task decrypts with the device key and posts a local
6
+ * notification with the content — or, when the push does not open, the fallback text: never silence.
7
+ *
8
+ * // index.ts — at module top level, before registerRootComponent:
9
+ * import { defineEmberPushTask } from "@freecorps/ember-expo-push";
10
+ * defineEmberPushTask();
11
+ * // after sign-in (once per launch is fine):
12
+ * await registerEmberPushTask();
13
+ *
14
+ * iOS needs none of this: the Notification Service Extension decrypts before the notification is shown.
15
+ */
16
+ import { openPushEnvelope, parsePushEnvelope } from "./crypto.js";
17
+ import { loadPushKeys } from "./keys.js";
18
+ import { loadNotifications, loadPlatformOS, loadTaskManager } from "./modules.js";
19
+ export const EMBER_PUSH_TASK = "ember-push-decrypt";
20
+ const isRecord = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
21
+ /**
22
+ * Finds the sealed fields in a background-task payload. expo-notifications passes `{ data: { ...fcmData } }`, with the
23
+ * data also as a JSON string in `data.dataString` on some versions; a `RemoteMessage`-like `{ data }` or the FCM data
24
+ * itself works too. Null when this is not an Ember sealed push.
25
+ */
26
+ export function extractEncryptedPush(payload) {
27
+ const candidates = [];
28
+ if (isRecord(payload)) {
29
+ candidates.push(payload);
30
+ const data = payload.data;
31
+ if (isRecord(data)) {
32
+ candidates.push(data);
33
+ if (typeof data.dataString === "string") {
34
+ try {
35
+ candidates.push(JSON.parse(data.dataString));
36
+ }
37
+ catch {
38
+ /* ignore */
39
+ }
40
+ }
41
+ if (isRecord(data.data))
42
+ candidates.push(data.data);
43
+ }
44
+ if (isRecord(payload.notification) && isRecord(payload.notification.data))
45
+ candidates.push(payload.notification.data);
46
+ }
47
+ for (const c of candidates) {
48
+ if (!isRecord(c) || typeof c.ember !== "string")
49
+ continue;
50
+ const out = { ember: c.ember };
51
+ for (const f of ["fallbackTitle", "fallbackBody", "channelId", "tag", "urgency"])
52
+ if (typeof c[f] === "string")
53
+ out[f] = c[f];
54
+ return out;
55
+ }
56
+ return null;
57
+ }
58
+ /** Decrypts a sealed data push and posts it as a local notification (the content, or the fallback text). */
59
+ export async function presentEncryptedPush(payload, o = {}) {
60
+ const data = extractEncryptedPush(payload);
61
+ if (!data)
62
+ return { shown: "nothing", reason: "not an Ember sealed push" };
63
+ const N = o.notifications === undefined ? loadNotifications() : o.notifications;
64
+ if (!N)
65
+ return { shown: "nothing", reason: "expo-notifications is not installed" };
66
+ const trigger = data.channelId ? { channelId: data.channelId } : null;
67
+ const base = { ...(data.tag ? { identifier: data.tag } : {}), trigger };
68
+ let content;
69
+ let reason;
70
+ try {
71
+ const envelope = parsePushEnvelope(data.ember);
72
+ if (!envelope)
73
+ throw new Error("malformed envelope");
74
+ const keys = o.keys ?? (await loadPushKeys(o));
75
+ if (!keys.length)
76
+ throw new Error("no device key");
77
+ const opened = openPushEnvelope(envelope, keys);
78
+ content = o.transform ? o.transform(opened, data) : { title: opened.title, subtitle: opened.subtitle, body: opened.body, data: opened.data ?? {}, sound: "default" };
79
+ if (content === null)
80
+ return { shown: "nothing", reason: "transform returned null" };
81
+ for (const k of Object.keys(content))
82
+ if (content[k] === undefined)
83
+ delete content[k];
84
+ const identifier = await N.scheduleNotificationAsync({ ...base, content });
85
+ return { shown: "content", identifier };
86
+ }
87
+ catch (e) {
88
+ reason = e.message;
89
+ }
90
+ // Never silence: the fallback the server sent (or ours).
91
+ const fb = { title: data.fallbackTitle ?? o.fallback?.title ?? "New notification", body: data.fallbackBody ?? o.fallback?.body ?? "Open the app to see it." };
92
+ const identifier = await N.scheduleNotificationAsync({ ...base, content: { ...fb, data: {} } });
93
+ return { shown: "fallback", identifier, reason };
94
+ }
95
+ /**
96
+ * Defines the background task that shows sealed pushes on Android. Call at module top level (headless JS needs the
97
+ * definition before the app renders). Returns the task name.
98
+ */
99
+ export function defineEmberPushTask(o = {}) {
100
+ const TM = o.taskManager === undefined ? loadTaskManager() : o.taskManager;
101
+ const name = o.taskName ?? EMBER_PUSH_TASK;
102
+ if (!TM)
103
+ throw new Error("@freecorps/ember-expo-push: expo-task-manager is required for the Android decrypt task");
104
+ TM.defineTask(name, async ({ data, error }) => {
105
+ if (error)
106
+ return;
107
+ if ((o.platform ?? loadPlatformOS()) === "ios")
108
+ return;
109
+ const r = await presentEncryptedPush(data, o);
110
+ o.onResult?.(r);
111
+ });
112
+ return name;
113
+ }
114
+ /** Registers the task with expo-notifications so data-only pushes reach it. */
115
+ export async function registerEmberPushTask(o = {}) {
116
+ const N = o.notifications === undefined ? loadNotifications() : o.notifications;
117
+ if (!N?.registerTaskAsync)
118
+ throw new Error("@freecorps/ember-expo-push: expo-notifications registerTaskAsync is unavailable");
119
+ await N.registerTaskAsync(o.taskName ?? EMBER_PUSH_TASK);
120
+ }
@@ -0,0 +1,50 @@
1
+ /** Must equal `EMBER_PUSH_HPKE_INFO` in `@freecorps/ember-push` and the NSE's (test/crypto.test.ts checks). */
2
+ export declare const EMBER_PUSH_HPKE_INFO = "freecorps.ember push v1";
3
+ export declare const EMBER_PUSH_ENVELOPE_VERSION = 1;
4
+ export declare function toBase64url(value: Uint8Array): string;
5
+ export declare function fromBase64url(value: string): Uint8Array;
6
+ /** HPKE single-shot open (base mode, sequence 0). Throws if anything does not match. */
7
+ export declare function hpkeOpen(privateKey: Uint8Array, publicKey: Uint8Array, enc: Uint8Array, ct: Uint8Array, opts?: {
8
+ info?: Uint8Array;
9
+ aad?: Uint8Array;
10
+ }): Uint8Array;
11
+ /** HPKE single-shot seal (for tests and JS senders). `ephemeralKey` only for test vectors. */
12
+ export declare function hpkeSeal(publicKey: Uint8Array, plaintext: Uint8Array, opts?: {
13
+ info?: Uint8Array;
14
+ aad?: Uint8Array;
15
+ ephemeralKey?: Uint8Array;
16
+ random?: (n: number) => Uint8Array;
17
+ }): {
18
+ enc: Uint8Array;
19
+ ct: Uint8Array;
20
+ };
21
+ /** A device key pair: the raw private scalar (32 bytes) and the uncompressed public key (65 bytes). */
22
+ export interface PushKeyPair {
23
+ privateKey: Uint8Array;
24
+ publicKey: Uint8Array;
25
+ }
26
+ /** A new P-256 key pair from 48 random bytes (`random`: `crypto.getRandomValues`-backed on the device). */
27
+ export declare function generatePushKeyPair(random: (n: number) => Uint8Array): PushKeyPair;
28
+ /** The key id an envelope names (`k`): base64url of SHA-256(public key)'s first 6 bytes. Same as the server's. */
29
+ export declare function pushKeyId(publicKey: Uint8Array): string;
30
+ export interface PushEnvelope {
31
+ v: 1;
32
+ k: string;
33
+ enc: string;
34
+ ct: string;
35
+ }
36
+ /** What an envelope opens to. `iat`: when the server sealed it (Unix seconds). */
37
+ export interface OpenedPush {
38
+ title?: string;
39
+ subtitle?: string;
40
+ body?: string;
41
+ data?: Record<string, unknown>;
42
+ iat: number;
43
+ }
44
+ /** An envelope from a push payload (object, or the JSON string FCM data carries), or null. */
45
+ export declare function parsePushEnvelope(value: unknown): PushEnvelope | null;
46
+ /**
47
+ * Opens an envelope with the device's keys (the one named by `k` first, then the others, e.g. right after a
48
+ * rotation). Throws when none opens it or the plaintext is not a push.
49
+ */
50
+ export declare function openPushEnvelope(envelope: PushEnvelope, keys: readonly PushKeyPair[]): OpenedPush;
package/dist/crypto.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The device side of Ember's encrypted pushes, in pure JS (noble), because React Native has no WebCrypto: HPKE
3
+ * (RFC 9180) base mode with DHKEM(P-256, HKDF-SHA256) + HKDF-SHA256 + AES-128-GCM — the suite and `info` the server
4
+ * seals with (`@freecorps/ember-push`, ported from Freecorps Brigade's packages/push, MIT) and iOS CryptoKit opens in
5
+ * the Notification Service Extension. Checked against RFC 9180 test vector A.3.1 and against the server's WebCrypto
6
+ * implementation in both directions (test/crypto.test.ts).
7
+ */
8
+ import { gcm } from "@noble/ciphers/aes.js";
9
+ import { p256 } from "@noble/curves/nist.js";
10
+ import { expand, extract } from "@noble/hashes/hkdf.js";
11
+ import { sha256 } from "@noble/hashes/sha2.js";
12
+ /** Must equal `EMBER_PUSH_HPKE_INFO` in `@freecorps/ember-push` and the NSE's (test/crypto.test.ts checks). */
13
+ export const EMBER_PUSH_HPKE_INFO = "freecorps.ember push v1";
14
+ export const EMBER_PUSH_ENVELOPE_VERSION = 1;
15
+ const te = new TextEncoder();
16
+ export function toBase64url(value) {
17
+ let binary = "";
18
+ for (const byte of value)
19
+ binary += String.fromCharCode(byte);
20
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
21
+ }
22
+ export function fromBase64url(value) {
23
+ if (!/^[A-Za-z0-9_-]*={0,2}$/.test(value))
24
+ throw new Error("invalid base64url");
25
+ const binary = atob(value.replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/"));
26
+ const out = new Uint8Array(binary.length);
27
+ for (let i = 0; i < binary.length; i++)
28
+ out[i] = binary.charCodeAt(i);
29
+ return out;
30
+ }
31
+ function concat(...parts) {
32
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
33
+ let o = 0;
34
+ for (const p of parts) {
35
+ out.set(p, o);
36
+ o += p.length;
37
+ }
38
+ return out;
39
+ }
40
+ const i2osp2 = (v) => Uint8Array.of((v >> 8) & 0xff, v & 0xff);
41
+ const EMPTY = new Uint8Array(0);
42
+ const KEM_SUITE = concat(te.encode("KEM"), i2osp2(0x0010));
43
+ const HPKE_SUITE = concat(te.encode("HPKE"), i2osp2(0x0010), i2osp2(0x0001), i2osp2(0x0001));
44
+ const labeledExtract = (suite, salt, label, ikm) => extract(sha256, concat(te.encode("HPKE-v1"), suite, te.encode(label), ikm), salt);
45
+ const labeledExpand = (suite, prk, label, info, length) => expand(sha256, prk, concat(i2osp2(length), te.encode("HPKE-v1"), suite, te.encode(label), info), length);
46
+ function checkPoint(raw) {
47
+ if (raw.length !== 65 || raw[0] !== 0x04)
48
+ throw new Error("expected an uncompressed P-256 public key (65 bytes)");
49
+ p256.Point.fromBytes(raw).assertValidity();
50
+ }
51
+ function keySchedule(dh, enc, pkR, info) {
52
+ const sharedSecret = labeledExpand(KEM_SUITE, labeledExtract(KEM_SUITE, EMPTY, "eae_prk", dh), "shared_secret", concat(enc, pkR), 32);
53
+ const context = concat(Uint8Array.of(0), labeledExtract(HPKE_SUITE, EMPTY, "psk_id_hash", EMPTY), labeledExtract(HPKE_SUITE, EMPTY, "info_hash", info));
54
+ const secret = labeledExtract(HPKE_SUITE, sharedSecret, "secret", EMPTY);
55
+ return { key: labeledExpand(HPKE_SUITE, secret, "key", context, 16), nonce: labeledExpand(HPKE_SUITE, secret, "base_nonce", context, 12) };
56
+ }
57
+ /** HPKE single-shot open (base mode, sequence 0). Throws if anything does not match. */
58
+ export function hpkeOpen(privateKey, publicKey, enc, ct, opts = {}) {
59
+ checkPoint(enc);
60
+ const dh = p256.getSharedSecret(privateKey, enc, true).subarray(1);
61
+ const { key, nonce } = keySchedule(dh, enc, publicKey, opts.info ?? EMPTY);
62
+ return gcm(key, nonce, opts.aad ?? EMPTY).decrypt(ct);
63
+ }
64
+ /** HPKE single-shot seal (for tests and JS senders). `ephemeralKey` only for test vectors. */
65
+ export function hpkeSeal(publicKey, plaintext, opts = {}) {
66
+ checkPoint(publicKey);
67
+ const skE = opts.ephemeralKey ?? p256.utils.randomSecretKey(opts.random ? opts.random(48) : undefined);
68
+ const enc = p256.getPublicKey(skE, false);
69
+ const dh = p256.getSharedSecret(skE, publicKey, true).subarray(1);
70
+ const { key, nonce } = keySchedule(dh, enc, publicKey, opts.info ?? EMPTY);
71
+ return { enc, ct: gcm(key, nonce, opts.aad ?? EMPTY).encrypt(plaintext) };
72
+ }
73
+ /** A new P-256 key pair from 48 random bytes (`random`: `crypto.getRandomValues`-backed on the device). */
74
+ export function generatePushKeyPair(random) {
75
+ const privateKey = p256.utils.randomSecretKey(random(48));
76
+ return { privateKey, publicKey: p256.getPublicKey(privateKey, false) };
77
+ }
78
+ /** The key id an envelope names (`k`): base64url of SHA-256(public key)'s first 6 bytes. Same as the server's. */
79
+ export function pushKeyId(publicKey) {
80
+ return toBase64url(sha256(publicKey).subarray(0, 6));
81
+ }
82
+ const isRecord = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
83
+ /** An envelope from a push payload (object, or the JSON string FCM data carries), or null. */
84
+ export function parsePushEnvelope(value) {
85
+ if (typeof value === "string") {
86
+ try {
87
+ value = JSON.parse(value);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ if (!isRecord(value) || value.v !== EMBER_PUSH_ENVELOPE_VERSION)
94
+ return null;
95
+ const { k, enc, ct } = value;
96
+ if (typeof k !== "string" || !/^[A-Za-z0-9_-]{8}$/.test(k) || typeof enc !== "string" || typeof ct !== "string")
97
+ return null;
98
+ try {
99
+ if (fromBase64url(enc).length !== 65 || fromBase64url(ct).length < 16)
100
+ return null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ return { v: 1, k, enc, ct };
106
+ }
107
+ /**
108
+ * Opens an envelope with the device's keys (the one named by `k` first, then the others, e.g. right after a
109
+ * rotation). Throws when none opens it or the plaintext is not a push.
110
+ */
111
+ export function openPushEnvelope(envelope, keys) {
112
+ const info = te.encode(EMBER_PUSH_HPKE_INFO);
113
+ const enc = fromBase64url(envelope.enc);
114
+ const ct = fromBase64url(envelope.ct);
115
+ const ordered = [...keys].sort((a, b) => Number(pushKeyId(b.publicKey) === envelope.k) - Number(pushKeyId(a.publicKey) === envelope.k));
116
+ let last = new Error("no device key");
117
+ for (const key of ordered) {
118
+ let pt;
119
+ try {
120
+ pt = hpkeOpen(key.privateKey, key.publicKey, enc, ct, { info });
121
+ }
122
+ catch (e) {
123
+ last = e;
124
+ continue;
125
+ }
126
+ return parseOpened(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(pt)));
127
+ }
128
+ throw new Error(`push did not open: ${last.message}`);
129
+ }
130
+ function parseOpened(value) {
131
+ if (!isRecord(value) || !Number.isSafeInteger(value.iat))
132
+ throw new Error("push content has no iat");
133
+ const out = { iat: value.iat };
134
+ for (const f of ["title", "subtitle", "body"]) {
135
+ const s = value[f];
136
+ if (s === undefined || s === null)
137
+ continue;
138
+ if (typeof s !== "string")
139
+ throw new Error(`push content ${f} is not a string`);
140
+ out[f] = s;
141
+ }
142
+ if (value.data !== undefined && value.data !== null) {
143
+ if (!isRecord(value.data))
144
+ throw new Error("push content data is not an object");
145
+ out.data = value.data;
146
+ }
147
+ return out;
148
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@freecorps/ember-expo-push`: the device side of Ember's end-to-end encrypted pushes (server side:
3
+ * `@freecorps/ember-push` with the direct transport). Subject and sender never pass through Apple or Google in clear.
4
+ *
5
+ * - `app.plugin.cjs` (Expo config plugin `"@freecorps/ember-expo-push"`): iOS Notification Service Extension target that
6
+ * decrypts with CryptoKit, App Group + keychain sharing, `aps-environment` per build, time-sensitive entitlement.
7
+ * - `ensurePushKey()`: the device key (P-256, SecureStore, shared with the NSE); register its `publicKey`.
8
+ * - `defineEmberPushTask()`: Android decrypt-and-show for data-only sealed pushes.
9
+ * - `openPushEnvelope()`: pure-JS HPKE (noble) for any JS client.
10
+ */
11
+ export { EMBER_PUSH_ENVELOPE_VERSION, EMBER_PUSH_HPKE_INFO, fromBase64url, generatePushKeyPair, hpkeOpen, hpkeSeal, openPushEnvelope, parsePushEnvelope, pushKeyId, toBase64url, type OpenedPush, type PushEnvelope, type PushKeyPair, } from "./crypto.ts";
12
+ export { DEFAULT_KEY_NAME, DEFAULT_KEYCHAIN_SERVICE, deletePushKeys, ensurePushKey, loadPushKeys, rotatePushKey, type DevicePushKey, type PushKeyOptions } from "./keys.ts";
13
+ export { EMBER_PUSH_TASK, defineEmberPushTask, extractEncryptedPush, presentEncryptedPush, registerEmberPushTask, type EncryptedPushData, type PresentOptions, type PresentResult, type TaskOptions, } from "./android.ts";
14
+ export type { ConstantsLike, EmberPushExtra, NotificationsLike, SecureStoreLike, TaskManagerLike } from "./modules.ts";
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@freecorps/ember-expo-push`: the device side of Ember's end-to-end encrypted pushes (server side:
3
+ * `@freecorps/ember-push` with the direct transport). Subject and sender never pass through Apple or Google in clear.
4
+ *
5
+ * - `app.plugin.cjs` (Expo config plugin `"@freecorps/ember-expo-push"`): iOS Notification Service Extension target that
6
+ * decrypts with CryptoKit, App Group + keychain sharing, `aps-environment` per build, time-sensitive entitlement.
7
+ * - `ensurePushKey()`: the device key (P-256, SecureStore, shared with the NSE); register its `publicKey`.
8
+ * - `defineEmberPushTask()`: Android decrypt-and-show for data-only sealed pushes.
9
+ * - `openPushEnvelope()`: pure-JS HPKE (noble) for any JS client.
10
+ */
11
+ export { EMBER_PUSH_ENVELOPE_VERSION, EMBER_PUSH_HPKE_INFO, fromBase64url, generatePushKeyPair, hpkeOpen, hpkeSeal, openPushEnvelope, parsePushEnvelope, pushKeyId, toBase64url, } from "./crypto.js";
12
+ export { DEFAULT_KEY_NAME, DEFAULT_KEYCHAIN_SERVICE, deletePushKeys, ensurePushKey, loadPushKeys, rotatePushKey } from "./keys.js";
13
+ export { EMBER_PUSH_TASK, defineEmberPushTask, extractEncryptedPush, presentEncryptedPush, registerEmberPushTask, } from "./android.js";
package/dist/keys.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The device key for sealed pushes. Created on first use, kept in SecureStore, and on iOS shared with the
3
+ * Notification Service Extension through the App Group, which doubles as a keychain access group (Brigade spike S1
4
+ * found the same arrangement works on a locked phone: `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY`, never synced or restored
5
+ * to another device).
6
+ *
7
+ * The stored value is JSON, `{ "v": 1, "keys": [{ "d": <private scalar>, "pub": <public key> }, ...] }` (base64url),
8
+ * current key first; after `rotatePushKey` the previous key stays second so pushes sealed before the server learned
9
+ * the new key still open. The NSE (ios/EmberNotificationService/EmberPushCrypto.swift) reads the same item:
10
+ * expo-secure-store saves it as a generic password with service `<keychainService>:no-auth`, account `<keyName>` as UTF-8
11
+ * Data, and the App Group as access group.
12
+ */
13
+ import { type PushKeyPair } from "./crypto.ts";
14
+ import { type ConstantsLike, type SecureStoreLike } from "./modules.ts";
15
+ export declare const DEFAULT_KEYCHAIN_SERVICE = "ember.push";
16
+ export declare const DEFAULT_KEY_NAME = "ember.push.keys.v1";
17
+ export interface PushKeyOptions {
18
+ secureStore?: SecureStoreLike | null;
19
+ /** iOS: the App Group (keychain access group) the NSE reads. Default: what the config plugin recorded. */
20
+ accessGroup?: string;
21
+ keychainService?: string;
22
+ keyName?: string;
23
+ /** Random bytes (default `crypto.getRandomValues` or expo-crypto). */
24
+ random?: (n: number) => Uint8Array;
25
+ constants?: ConstantsLike | null;
26
+ /** Default: Platform.OS. The access group only applies on iOS. */
27
+ platform?: string;
28
+ }
29
+ export interface DevicePushKey {
30
+ /** Register this with the device (`publicKey` of `registerForPush` / `push.register`). */
31
+ publicKey: string;
32
+ keyId: string;
33
+ }
34
+ /** The device's current public key, creating the key pair on first use. */
35
+ export declare function ensurePushKey(o?: PushKeyOptions): Promise<DevicePushKey>;
36
+ /** Every key this device can open with (current first). For the Android decrypt task and JS clients. */
37
+ export declare function loadPushKeys(o?: PushKeyOptions): Promise<PushKeyPair[]>;
38
+ /** A new current key; the previous one is kept (only one) until the next rotation. Register the returned key. */
39
+ export declare function rotatePushKey(o?: PushKeyOptions): Promise<DevicePushKey>;
40
+ /** Forgets every key (sign-out): pushes sealed to them show the fallback text from now on. */
41
+ export declare function deletePushKeys(o?: PushKeyOptions): Promise<void>;
package/dist/keys.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The device key for sealed pushes. Created on first use, kept in SecureStore, and on iOS shared with the
3
+ * Notification Service Extension through the App Group, which doubles as a keychain access group (Brigade spike S1
4
+ * found the same arrangement works on a locked phone: `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY`, never synced or restored
5
+ * to another device).
6
+ *
7
+ * The stored value is JSON, `{ "v": 1, "keys": [{ "d": <private scalar>, "pub": <public key> }, ...] }` (base64url),
8
+ * current key first; after `rotatePushKey` the previous key stays second so pushes sealed before the server learned
9
+ * the new key still open. The NSE (ios/EmberNotificationService/EmberPushCrypto.swift) reads the same item:
10
+ * expo-secure-store saves it as a generic password with service `<keychainService>:no-auth`, account `<keyName>` as UTF-8
11
+ * Data, and the App Group as access group.
12
+ */
13
+ import { fromBase64url, generatePushKeyPair, pushKeyId, toBase64url } from "./crypto.js";
14
+ import { defaultRandom, loadConstants, loadPlatformOS, loadSecureStore } from "./modules.js";
15
+ export const DEFAULT_KEYCHAIN_SERVICE = "ember.push";
16
+ export const DEFAULT_KEY_NAME = "ember.push.keys.v1";
17
+ function resolve(o) {
18
+ const store = o.secureStore === undefined ? loadSecureStore() : o.secureStore;
19
+ if (!store)
20
+ throw new Error("@freecorps/ember-expo-push: expo-secure-store is required for the device key");
21
+ const extra = (o.constants === undefined ? loadConstants() : o.constants)?.expoConfig?.extra?.emberPush ?? {};
22
+ const platform = o.platform ?? loadPlatformOS() ?? "ios";
23
+ const options = { keychainService: o.keychainService ?? extra.keychainService ?? DEFAULT_KEYCHAIN_SERVICE };
24
+ if (store.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY !== undefined)
25
+ options.keychainAccessible = store.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
26
+ const group = o.accessGroup ?? extra.appGroup;
27
+ if (group && platform === "ios")
28
+ options.accessGroup = group;
29
+ return { store, key: o.keyName ?? extra.keyName ?? DEFAULT_KEY_NAME, options, random: o.random ?? defaultRandom };
30
+ }
31
+ async function read(r) {
32
+ const raw = await r.store.getItemAsync(r.key, r.options);
33
+ if (!raw)
34
+ return [];
35
+ try {
36
+ const s = JSON.parse(raw);
37
+ if (s?.v !== 1 || !Array.isArray(s.keys))
38
+ return [];
39
+ return s.keys.map((k) => ({ privateKey: fromBase64url(k.d), publicKey: fromBase64url(k.pub) })).filter((k) => k.privateKey.length === 32 && k.publicKey.length === 65);
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
45
+ async function write(r, keys) {
46
+ const s = { v: 1, keys: keys.map((k) => ({ d: toBase64url(k.privateKey), pub: toBase64url(k.publicKey) })) };
47
+ await r.store.setItemAsync(r.key, JSON.stringify(s), r.options);
48
+ }
49
+ const describe = (k) => ({ publicKey: toBase64url(k.publicKey), keyId: pushKeyId(k.publicKey) });
50
+ /** The device's current public key, creating the key pair on first use. */
51
+ export async function ensurePushKey(o = {}) {
52
+ const r = resolve(o);
53
+ const keys = await read(r);
54
+ if (keys[0])
55
+ return describe(keys[0]);
56
+ const created = generatePushKeyPair(r.random);
57
+ await write(r, [created]);
58
+ return describe(created);
59
+ }
60
+ /** Every key this device can open with (current first). For the Android decrypt task and JS clients. */
61
+ export async function loadPushKeys(o = {}) {
62
+ return read(resolve(o));
63
+ }
64
+ /** A new current key; the previous one is kept (only one) until the next rotation. Register the returned key. */
65
+ export async function rotatePushKey(o = {}) {
66
+ const r = resolve(o);
67
+ const keys = await read(r);
68
+ const created = generatePushKeyPair(r.random);
69
+ await write(r, [created, ...keys.slice(0, 1)]);
70
+ return describe(created);
71
+ }
72
+ /** Forgets every key (sign-out): pushes sealed to them show the fallback text from now on. */
73
+ export async function deletePushKeys(o = {}) {
74
+ const r = resolve(o);
75
+ await r.store.deleteItemAsync(r.key, r.options);
76
+ }
@@ -0,0 +1,44 @@
1
+ export interface SecureStoreLike {
2
+ getItemAsync(key: string, options?: Record<string, unknown>): Promise<string | null>;
3
+ setItemAsync(key: string, value: string, options?: Record<string, unknown>): Promise<void>;
4
+ deleteItemAsync(key: string, options?: Record<string, unknown>): Promise<void>;
5
+ AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY?: number;
6
+ }
7
+ export interface NotificationsLike {
8
+ scheduleNotificationAsync(request: {
9
+ identifier?: string;
10
+ content: Record<string, unknown>;
11
+ trigger: Record<string, unknown> | null;
12
+ }): Promise<string>;
13
+ registerTaskAsync?(taskName: string): Promise<null | void>;
14
+ }
15
+ export interface TaskManagerLike {
16
+ defineTask(name: string, task: (body: {
17
+ data: unknown;
18
+ error: unknown;
19
+ executionInfo?: unknown;
20
+ }) => Promise<unknown> | unknown): void;
21
+ }
22
+ export interface ConstantsLike {
23
+ expoConfig?: {
24
+ extra?: {
25
+ emberPush?: EmberPushExtra;
26
+ };
27
+ } | null;
28
+ }
29
+ /** What the config plugin records in `expo.extra.emberPush` (read at runtime through expo-constants). */
30
+ export interface EmberPushExtra {
31
+ apsEnvironment?: "development" | "production";
32
+ /** iOS App Group shared by the app and the Notification Service Extension; also the keychain access group. */
33
+ appGroup?: string;
34
+ keychainService?: string;
35
+ keyName?: string;
36
+ }
37
+ export declare const pick: <T>(m: any, named?: string) => T | null;
38
+ export declare function loadSecureStore(): SecureStoreLike | null;
39
+ export declare function loadNotifications(): NotificationsLike | null;
40
+ export declare function loadTaskManager(): TaskManagerLike | null;
41
+ export declare function loadConstants(): ConstantsLike | null;
42
+ export declare function loadPlatformOS(): string | undefined;
43
+ /** Random bytes: `crypto.getRandomValues` when the runtime has it, else expo-crypto's. Throws without either. */
44
+ export declare function defaultRandom(n: number): Uint8Array;
@@ -0,0 +1,62 @@
1
+ export const pick = (m, named) => (m ? ((named ? m[named] : undefined) ?? m.default ?? m) : null);
2
+ export function loadSecureStore() {
3
+ try {
4
+ const m = require("expo-secure-store");
5
+ return m && typeof m.getItemAsync === "function" ? m : pick(m);
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ export function loadNotifications() {
12
+ try {
13
+ const m = require("expo-notifications");
14
+ return m && typeof m.scheduleNotificationAsync === "function" ? m : pick(m);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function loadTaskManager() {
21
+ try {
22
+ const m = require("expo-task-manager");
23
+ return m && typeof m.defineTask === "function" ? m : pick(m);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function loadConstants() {
30
+ try {
31
+ return pick(require("expo-constants"));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ export function loadPlatformOS() {
38
+ try {
39
+ return require("react-native")?.Platform?.OS;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ /** Random bytes: `crypto.getRandomValues` when the runtime has it, else expo-crypto's. Throws without either. */
46
+ export function defaultRandom(n) {
47
+ const out = new Uint8Array(n);
48
+ const c = globalThis.crypto;
49
+ if (c?.getRandomValues)
50
+ return c.getRandomValues(out);
51
+ try {
52
+ const m = require("expo-crypto");
53
+ if (typeof m.getRandomValues === "function")
54
+ return m.getRandomValues(out);
55
+ if (typeof m.getRandomBytes === "function")
56
+ return m.getRandomBytes(n);
57
+ }
58
+ catch {
59
+ /* fall through */
60
+ }
61
+ throw new Error("@freecorps/ember-expo-push: no secure random source (install expo-crypto)");
62
+ }
@@ -0,0 +1,149 @@
1
+ // Ember end-to-end encrypted pushes, device side (iOS Notification Service Extension).
2
+ //
3
+ // Opens the `ember` envelope `{ v: 1, k, enc, ct }` that @freecorps/ember-push seals: HPKE (RFC 9180) base mode,
4
+ // DHKEM(P-256, HKDF-SHA256) + HKDF-SHA256 + AES-128-GCM, info "freecorps.ember push v1" — CryptoKit's HPKE (iOS 17+).
5
+ // The suite is the one Freecorps Brigade chose in its spike S1 (one CryptoKit call); this file is written for Ember.
6
+ //
7
+ // The device keys are the JSON the app stored with expo-secure-store (@freecorps/ember-expo-push `ensurePushKey`):
8
+ // `{ "v": 1, "keys": [{ "d": <raw P-256 scalar>, "pub": <uncompressed public key> }] }`, base64url, in the keychain as a
9
+ // generic password (service "<keychainService>:no-auth", account "<keyName>") in the App Group's access group.
10
+ //
11
+ // Pure Foundation + CryptoKit + Security, so it compiles and is tested on macOS too (test/swift).
12
+
13
+ import CryptoKit
14
+ import Foundation
15
+ import Security
16
+
17
+ public struct EmberPushContent {
18
+ public var title: String?
19
+ public var subtitle: String?
20
+ public var body: String?
21
+ public var data: [String: Any]?
22
+ public var iat: Int
23
+ }
24
+
25
+ public enum EmberPushError: Error, CustomStringConvertible {
26
+ case noEnvelope, malformedEnvelope, noKeys, unsupportedOS, notOpened, malformedContent
27
+
28
+ public var description: String {
29
+ switch self {
30
+ case .noEnvelope: return "noEnvelope"
31
+ case .malformedEnvelope: return "malformedEnvelope"
32
+ case .noKeys: return "noKeys"
33
+ case .unsupportedOS: return "unsupportedOS"
34
+ case .notOpened: return "notOpened"
35
+ case .malformedContent: return "malformedContent"
36
+ }
37
+ }
38
+ }
39
+
40
+ public struct EmberPushKey {
41
+ public let privateKey: Data // 32-byte scalar
42
+ public let publicKey: Data // 65-byte uncompressed point
43
+ }
44
+
45
+ public enum EmberPushCrypto {
46
+ public static let info = Data("freecorps.ember push v1".utf8)
47
+ public static let envelopeVersion = 1
48
+
49
+ public static func base64url(_ string: String) -> Data? {
50
+ var s = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
51
+ while s.count % 4 != 0 { s += "=" }
52
+ return Data(base64Encoded: s)
53
+ }
54
+
55
+ public static func base64urlString(_ data: Data) -> String {
56
+ data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
57
+ .replacingOccurrences(of: "=", with: "")
58
+ }
59
+
60
+ /// The key id an envelope names: base64url of SHA-256(public key)'s first 6 bytes (same as the server's `pushKeyId`).
61
+ public static func keyId(_ publicKey: Data) -> String {
62
+ base64urlString(Data(SHA256.hash(data: publicKey).prefix(6)))
63
+ }
64
+
65
+ /// The stored key JSON → keys (current first). Malformed entries are skipped.
66
+ public static func parseKeys(_ json: Data) -> [EmberPushKey] {
67
+ guard let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any],
68
+ (obj["v"] as? Int) == 1, let list = obj["keys"] as? [[String: Any]]
69
+ else { return [] }
70
+ return list.compactMap { entry in
71
+ guard let d = (entry["d"] as? String).flatMap(base64url), let pub = (entry["pub"] as? String).flatMap(base64url),
72
+ d.count == 32, pub.count == 65
73
+ else { return nil }
74
+ return EmberPushKey(privateKey: d, publicKey: pub)
75
+ }
76
+ }
77
+
78
+ /// The envelope in a notification's userInfo (`ember`), as a dictionary or a JSON string.
79
+ public static func envelope(in userInfo: [AnyHashable: Any]) -> [String: Any]? {
80
+ if let e = userInfo["ember"] as? [String: Any] { return e }
81
+ if let s = userInfo["ember"] as? String, let d = s.data(using: .utf8) {
82
+ return (try? JSONSerialization.jsonObject(with: d)) as? [String: Any]
83
+ }
84
+ return nil
85
+ }
86
+
87
+ /// Opens an envelope with the device keys (the one named by `k` first). Throws `EmberPushError`.
88
+ public static func open(envelope: [String: Any], keys: [EmberPushKey]) throws -> EmberPushContent {
89
+ guard (envelope["v"] as? Int) == envelopeVersion, let k = envelope["k"] as? String,
90
+ let enc = (envelope["enc"] as? String).flatMap(base64url), let ct = (envelope["ct"] as? String).flatMap(base64url),
91
+ enc.count == 65, ct.count >= 16
92
+ else { throw EmberPushError.malformedEnvelope }
93
+ guard !keys.isEmpty else { throw EmberPushError.noKeys }
94
+ guard #available(iOS 17.0, macOS 14.0, *) else { throw EmberPushError.unsupportedOS }
95
+ let ordered = keys.filter { keyId($0.publicKey) == k } + keys.filter { keyId($0.publicKey) != k }
96
+ for key in ordered {
97
+ guard let plaintext = try? hpkeOpen(privateKey: key.privateKey, enc: enc, ct: ct) else { continue }
98
+ return try parseContent(plaintext)
99
+ }
100
+ throw EmberPushError.notOpened
101
+ }
102
+
103
+ @available(iOS 17.0, macOS 14.0, *)
104
+ static func hpkeOpen(privateKey: Data, enc: Data, ct: Data) throws -> Data {
105
+ let sk = try P256.KeyAgreement.PrivateKey(rawRepresentation: privateKey)
106
+ let suite = HPKE.Ciphersuite(kem: .P256_HKDF_SHA256, kdf: .HKDF_SHA256, aead: .AES_GCM_128)
107
+ var recipient = try HPKE.Recipient(privateKey: sk, ciphersuite: suite, info: info, encapsulatedKey: enc)
108
+ return try recipient.open(ct)
109
+ }
110
+
111
+ static func parseContent(_ plaintext: Data) throws -> EmberPushContent {
112
+ guard let obj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any], let iat = obj["iat"] as? Int else {
113
+ throw EmberPushError.malformedContent
114
+ }
115
+ func str(_ k: String) throws -> String? {
116
+ guard let v = obj[k], !(v is NSNull) else { return nil }
117
+ guard let s = v as? String else { throw EmberPushError.malformedContent }
118
+ return s
119
+ }
120
+ var data: [String: Any]? = nil
121
+ if let d = obj["data"], !(d is NSNull) {
122
+ guard let dict = d as? [String: Any] else { throw EmberPushError.malformedContent }
123
+ data = dict
124
+ }
125
+ return EmberPushContent(title: try str("title"), subtitle: try str("subtitle"), body: try str("body"), data: data, iat: iat)
126
+ }
127
+
128
+ /// Reads the stored key JSON from the keychain (expo-secure-store's layout), or nil.
129
+ ///
130
+ /// The query mirrors expo-secure-store's (v57/v58 `SecureStoreModule.query`) byte for byte: generic password,
131
+ /// service `<keychainService>:no-auth` (then the legacy un-suffixed service), account `Data(key.utf8)` (Data, as
132
+ /// expo-secure-store writes it, not a String), and the App Group as access group. test/swift.test.ts checks it in
133
+ /// the iOS Simulator against an item written the way expo-secure-store writes it.
134
+ public static func readKeychain(service: String, account: String, accessGroup: String?) -> Data? {
135
+ for svc in ["\(service):no-auth", service] {
136
+ var query: [String: Any] = [
137
+ kSecClass as String: kSecClassGenericPassword,
138
+ kSecAttrService as String: svc,
139
+ kSecAttrAccount as String: Data(account.utf8),
140
+ kSecReturnData as String: true,
141
+ kSecMatchLimit as String: kSecMatchLimitOne,
142
+ ]
143
+ if let group = accessGroup, !group.isEmpty { query[kSecAttrAccessGroup as String] = group }
144
+ var out: CFTypeRef?
145
+ if SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess, let data = out as? Data { return data }
146
+ }
147
+ return nil
148
+ }
149
+ }
@@ -0,0 +1,53 @@
1
+ // Ember Notification Service Extension (generated into the app by the @freecorps/ember-expo-push config plugin).
2
+ //
3
+ // For a push with `mutable-content: 1` and an `ember` envelope, replaces the fallback title/body APNs delivered with
4
+ // the decrypted title, subtitle and body, and puts the decrypted `data` where expo-notifications reads it
5
+ // (`userInfo["body"]`). When anything fails (no key yet, iOS 16, malformed or foreign envelope, out of time) the
6
+ // fallback text stays: never an empty notification. Logs the failing step, never content.
7
+
8
+ import UserNotifications
9
+ import os
10
+
11
+ final class NotificationService: UNNotificationServiceExtension {
12
+ private var contentHandler: ((UNNotificationContent) -> Void)?
13
+ private var bestAttempt: UNMutableNotificationContent?
14
+ private let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "EmberNotificationService", category: "push")
15
+
16
+ override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
17
+ self.contentHandler = contentHandler
18
+ guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
19
+ contentHandler(request.content)
20
+ return
21
+ }
22
+ bestAttempt = content
23
+ guard let envelope = EmberPushCrypto.envelope(in: content.userInfo) else {
24
+ contentHandler(content) // not an Ember sealed push
25
+ return
26
+ }
27
+ let info = Bundle.main.infoDictionary ?? [:]
28
+ let service = info["EmberPushKeychainService"] as? String ?? "ember.push"
29
+ let account = info["EmberPushKeyName"] as? String ?? "ember.push.keys.v1"
30
+ let group = info["EmberPushAppGroup"] as? String
31
+ do {
32
+ guard let stored = EmberPushCrypto.readKeychain(service: service, account: account, accessGroup: group) else {
33
+ throw EmberPushError.noKeys
34
+ }
35
+ let opened = try EmberPushCrypto.open(envelope: envelope, keys: EmberPushCrypto.parseKeys(stored))
36
+ if let t = opened.title { content.title = t }
37
+ if let s = opened.subtitle { content.subtitle = s }
38
+ if let b = opened.body { content.body = b }
39
+ var userInfo = content.userInfo
40
+ userInfo.removeValue(forKey: "ember")
41
+ if let data = opened.data { userInfo["body"] = data }
42
+ content.userInfo = userInfo
43
+ log.info("push decrypted")
44
+ } catch {
45
+ log.error("push not decrypted: \(String(describing: error), privacy: .public)")
46
+ }
47
+ contentHandler(content)
48
+ }
49
+
50
+ override func serviceExtensionTimeWillExpire() {
51
+ if let contentHandler, let bestAttempt { contentHandler(bestAttempt) }
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,82 @@
1
1
  {
2
2
  "name": "@freecorps/ember-expo-push",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "End-to-end encrypted push for Ember Expo apps: device key, Notification Service Extension (iOS) config plugin, Android decrypt task, pure-JS HPKE.",
5
+ "keywords": [
6
+ "ember",
7
+ "expo",
8
+ "react-native",
9
+ "push",
10
+ "apns",
11
+ "fcm",
12
+ "hpke",
13
+ "e2ee",
14
+ "notification-service-extension"
15
+ ],
16
+ "homepage": "https://github.com/freecorps/ember/tree/main/packages/expo-push#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/freecorps/ember/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/freecorps/ember.git",
23
+ "directory": "packages/expo-push"
24
+ },
25
+ "license": "MIT",
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./app.plugin.cjs": "./app.plugin.cjs",
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "app.plugin.cjs",
41
+ "ios"
42
+ ],
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "dependencies": {
50
+ "@noble/ciphers": "^2.4.0",
51
+ "@noble/curves": "^2.4.0",
52
+ "@noble/hashes": "^2.4.0"
53
+ },
54
+ "peerDependencies": {
55
+ "expo": "*",
56
+ "expo-constants": "*",
57
+ "expo-crypto": "*",
58
+ "expo-notifications": "*",
59
+ "expo-secure-store": "*",
60
+ "expo-task-manager": "*"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "expo": {
64
+ "optional": true
65
+ },
66
+ "expo-constants": {
67
+ "optional": true
68
+ },
69
+ "expo-crypto": {
70
+ "optional": true
71
+ },
72
+ "expo-notifications": {
73
+ "optional": true
74
+ },
75
+ "expo-secure-store": {
76
+ "optional": true
77
+ },
78
+ "expo-task-manager": {
79
+ "optional": true
80
+ }
81
+ }
82
+ }