@freecorps/ember-push 0.1.0 → 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/README.md +222 -5
- package/dist/apns.d.ts +78 -0
- package/dist/apns.js +284 -0
- package/dist/base64url.d.ts +4 -0
- package/dist/base64url.js +19 -0
- package/dist/component.d.ts +295 -0
- package/dist/component.js +254 -0
- package/dist/delivery.d.ts +47 -0
- package/dist/delivery.js +126 -0
- package/dist/devices.d.ts +38 -2
- package/dist/devices.js +77 -12
- package/dist/direct.d.ts +75 -0
- package/dist/direct.js +150 -0
- package/dist/envelope.d.ts +62 -0
- package/dist/envelope.js +121 -0
- package/dist/expo.d.ts +7 -6
- package/dist/expo.js +22 -2
- package/dist/fcm.d.ts +77 -0
- package/dist/fcm.js +244 -0
- package/dist/hpke.d.ts +37 -0
- package/dist/hpke.js +153 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.js +14 -2
- package/dist/jwt.d.ts +27 -0
- package/dist/jwt.js +77 -0
- package/dist/types.d.ts +92 -5
- package/dist/types.js +0 -4
- package/package.json +13 -9
package/dist/hpke.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Ported from Freecorps Brigade, packages/push/src/hpke.ts (MIT License, Copyright (c) 2026 Freecorps Tecnologia da
|
|
2
|
+
// Informação LTDA; https://github.com/freecorps/brigade/tree/main/packages/push). Kept byte-for-byte compatible with it:
|
|
3
|
+
// same suite, same RFC 9180 steps, so Brigade's test vectors and native decoders apply unchanged.
|
|
4
|
+
/**
|
|
5
|
+
* HPKE (RFC 9180), base mode, single-shot, with one cipher suite: DHKEM(P-256, HKDF-SHA256), HKDF-SHA256 and
|
|
6
|
+
* AES-128-GCM. Only WebCrypto, so it runs unchanged in Workers, browsers and Node. iOS opens it with CryptoKit
|
|
7
|
+
* (`HPKE.Ciphersuite(kem: .P256_HKDF_SHA256, kdf: .HKDF_SHA256, aead: .AES_GCM_128)`, iOS 17+) and Android
|
|
8
|
+
* with the pure-JS decoder in `@freecorps/ember-expo-push`. Why this suite: Brigade's spike S1
|
|
9
|
+
* (https://github.com/freecorps/brigade/blob/main/docs/spikes/s1-apns-encryption.md).
|
|
10
|
+
*/
|
|
11
|
+
import { toBase64url } from "./base64url.js";
|
|
12
|
+
const KEM_ID = 0x0010; // DHKEM(P-256, HKDF-SHA256)
|
|
13
|
+
const KDF_ID = 0x0001; // HKDF-SHA256
|
|
14
|
+
const AEAD_ID = 0x0001; // AES-128-GCM
|
|
15
|
+
const MODE_BASE = 0x00;
|
|
16
|
+
/** Hash, shared secret, AES key and nonce lengths. */
|
|
17
|
+
const N_H = 32;
|
|
18
|
+
const N_SECRET = 32;
|
|
19
|
+
const N_K = 16;
|
|
20
|
+
const N_N = 12;
|
|
21
|
+
/** An uncompressed P-256 point (0x04 || x || y): the encapsulated key and every public key. */
|
|
22
|
+
export const P256_PUBLIC_KEY_LENGTH = 65;
|
|
23
|
+
const ECDH = { name: "ECDH", namedCurve: "P-256" };
|
|
24
|
+
const encoder = new TextEncoder();
|
|
25
|
+
/** Encrypts `plaintext` to the holder of the private key for `recipientPublicKey` (uncompressed P-256). */
|
|
26
|
+
export async function seal(recipientPublicKey, plaintext, options = {}) {
|
|
27
|
+
const pkR = await importPublicKey(recipientPublicKey);
|
|
28
|
+
const ephemeral = options.ephemeral ??
|
|
29
|
+
(await crypto.subtle.generateKey(ECDH, false, ["deriveBits"]));
|
|
30
|
+
const enc = new Uint8Array((await crypto.subtle.exportKey("raw", ephemeral.publicKey)));
|
|
31
|
+
const dh = await deriveDh(ephemeral.privateKey, pkR);
|
|
32
|
+
const sharedSecret = await extractAndExpand(dh, concat(enc, recipientPublicKey));
|
|
33
|
+
const { key, baseNonce } = await keySchedule(sharedSecret, options.info);
|
|
34
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: baseNonce, additionalData: bytes(options.aad) }, key, bytes(plaintext));
|
|
35
|
+
return { enc, ct: new Uint8Array(ct) };
|
|
36
|
+
}
|
|
37
|
+
/** Decrypts what `seal` produced. Throws if the key, `info`, `aad` or any byte does not match. */
|
|
38
|
+
export async function open(recipient, sealed, options = {}) {
|
|
39
|
+
const pkE = await importPublicKey(sealed.enc);
|
|
40
|
+
const dh = await deriveDh(recipient.privateKey, pkE);
|
|
41
|
+
const sharedSecret = await extractAndExpand(dh, concat(sealed.enc, recipient.publicKey));
|
|
42
|
+
const { key, baseNonce } = await keySchedule(sharedSecret, options.info);
|
|
43
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: baseNonce, additionalData: bytes(options.aad) }, key, bytes(sealed.ct));
|
|
44
|
+
return new Uint8Array(pt);
|
|
45
|
+
}
|
|
46
|
+
/** A fresh recipient key pair. The private key is not extractable. */
|
|
47
|
+
export async function generateRecipient() {
|
|
48
|
+
const pair = (await crypto.subtle.generateKey(ECDH, false, ["deriveBits"]));
|
|
49
|
+
const publicKey = new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)));
|
|
50
|
+
return { privateKey: pair.privateKey, publicKey };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Imports a P-256 key pair from its raw private scalar (32 bytes) and uncompressed public key, the form CryptoKit
|
|
54
|
+
* (`rawRepresentation`, `x963Representation`) and the RFC 9180 test vectors use.
|
|
55
|
+
*/
|
|
56
|
+
export async function importKeyPair(privateKey, publicKey) {
|
|
57
|
+
if (privateKey.length !== 32)
|
|
58
|
+
throw new Error("P-256 private key must be 32 bytes");
|
|
59
|
+
const { x, y } = await publicJwk(publicKey);
|
|
60
|
+
const jwk = { kty: "EC", crv: "P-256", x, y, d: toBase64url(privateKey) };
|
|
61
|
+
const priv = await crypto.subtle.importKey("jwk", jwk, ECDH, false, ["deriveBits"]);
|
|
62
|
+
return { privateKey: priv, publicKey: await importPublicKey(publicKey) };
|
|
63
|
+
}
|
|
64
|
+
async function importPublicKey(raw) {
|
|
65
|
+
// Uncompressed only: WebCrypto's raw import also takes the 33-byte compressed form, which the KEM context
|
|
66
|
+
// would then hash differently from the other side.
|
|
67
|
+
if (raw.length !== P256_PUBLIC_KEY_LENGTH || raw[0] !== 0x04) {
|
|
68
|
+
throw new Error("expected an uncompressed P-256 public key (65 bytes)");
|
|
69
|
+
}
|
|
70
|
+
return crypto.subtle.importKey("raw", bytes(raw), ECDH, true, []);
|
|
71
|
+
}
|
|
72
|
+
async function publicJwk(raw) {
|
|
73
|
+
const jwk = (await crypto.subtle.exportKey("jwk", await importPublicKey(raw)));
|
|
74
|
+
if (!jwk.x || !jwk.y)
|
|
75
|
+
throw new Error("invalid P-256 public key");
|
|
76
|
+
return { x: jwk.x, y: jwk.y };
|
|
77
|
+
}
|
|
78
|
+
async function deriveDh(privateKey, publicKey) {
|
|
79
|
+
// The x coordinate of the shared point (RFC 9180, section 7.1.1).
|
|
80
|
+
return new Uint8Array(
|
|
81
|
+
// Cast: DOM lib types call the peer key `public`, @cloudflare/workers-types `$public`; runtimes read `public`.
|
|
82
|
+
await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256));
|
|
83
|
+
}
|
|
84
|
+
// RFC 9180, section 4.1: DHKEM's ExtractAndExpand.
|
|
85
|
+
async function extractAndExpand(dh, kemContext) {
|
|
86
|
+
const suite = concat(encoder.encode("KEM"), i2osp(KEM_ID, 2));
|
|
87
|
+
const eaePrk = await labeledExtract(suite, new Uint8Array(0), "eae_prk", dh);
|
|
88
|
+
return labeledExpand(suite, eaePrk, "shared_secret", kemContext, N_SECRET);
|
|
89
|
+
}
|
|
90
|
+
// RFC 9180, section 5.1, base mode (no PSK).
|
|
91
|
+
async function keySchedule(sharedSecret, info = new Uint8Array(0)) {
|
|
92
|
+
const suite = concat(encoder.encode("HPKE"), i2osp(KEM_ID, 2), i2osp(KDF_ID, 2), i2osp(AEAD_ID, 2));
|
|
93
|
+
const empty = new Uint8Array(0);
|
|
94
|
+
const pskIdHash = await labeledExtract(suite, empty, "psk_id_hash", empty);
|
|
95
|
+
const infoHash = await labeledExtract(suite, empty, "info_hash", bytes(info));
|
|
96
|
+
const context = concat(Uint8Array.of(MODE_BASE), pskIdHash, infoHash);
|
|
97
|
+
const secret = await labeledExtract(suite, sharedSecret, "secret", empty);
|
|
98
|
+
const keyBytes = await labeledExpand(suite, secret, "key", context, N_K);
|
|
99
|
+
const baseNonce = await labeledExpand(suite, secret, "base_nonce", context, N_N);
|
|
100
|
+
// Single-shot: one message at sequence number 0, so the nonce is the base nonce.
|
|
101
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, [
|
|
102
|
+
"encrypt",
|
|
103
|
+
"decrypt",
|
|
104
|
+
]);
|
|
105
|
+
return { key, baseNonce };
|
|
106
|
+
}
|
|
107
|
+
function labeledExtract(suite, salt, label, ikm) {
|
|
108
|
+
return hkdfExtract(salt, concat(encoder.encode("HPKE-v1"), suite, encoder.encode(label), ikm));
|
|
109
|
+
}
|
|
110
|
+
function labeledExpand(suite, prk, label, info, length) {
|
|
111
|
+
const labeled = concat(i2osp(length, 2), encoder.encode("HPKE-v1"), suite, encoder.encode(label), info);
|
|
112
|
+
return hkdfExpand(prk, labeled, length);
|
|
113
|
+
}
|
|
114
|
+
// RFC 5869 with SHA-256. WebCrypto's HKDF fuses extract and expand, and HPKE labels each step on its own.
|
|
115
|
+
async function hkdfExtract(salt, ikm) {
|
|
116
|
+
// An empty salt means HashLen zero bytes; WebCrypto rejects a zero-length HMAC key, and HMAC pads either way.
|
|
117
|
+
return hmac(salt.length === 0 ? new Uint8Array(N_H) : salt, ikm);
|
|
118
|
+
}
|
|
119
|
+
async function hkdfExpand(prk, info, length) {
|
|
120
|
+
const out = new Uint8Array(length);
|
|
121
|
+
let block = new Uint8Array(0);
|
|
122
|
+
for (let i = 1, offset = 0; offset < length; i++) {
|
|
123
|
+
block = await hmac(prk, concat(block, info, Uint8Array.of(i)));
|
|
124
|
+
out.set(block.subarray(0, Math.min(N_H, length - offset)), offset);
|
|
125
|
+
offset += N_H;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
async function hmac(key, data) {
|
|
130
|
+
const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, [
|
|
131
|
+
"sign",
|
|
132
|
+
]);
|
|
133
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", k, data));
|
|
134
|
+
}
|
|
135
|
+
function i2osp(value, length) {
|
|
136
|
+
const out = new Uint8Array(length);
|
|
137
|
+
for (let i = length - 1, v = value; i >= 0; i--, v >>>= 8)
|
|
138
|
+
out[i] = v & 0xff;
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
function concat(...parts) {
|
|
142
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
143
|
+
let offset = 0;
|
|
144
|
+
for (const part of parts) {
|
|
145
|
+
out.set(part, offset);
|
|
146
|
+
offset += part.length;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/** A copy backed by a plain ArrayBuffer, which WebCrypto's types require. */
|
|
151
|
+
function bytes(value = new Uint8Array(0)) {
|
|
152
|
+
return new Uint8Array(value);
|
|
153
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,8 +5,20 @@
|
|
|
5
5
|
* - Sending (`sendPush`, `checkPushReceipts`): plain `fetch`, so call them from an **action** (PLAN D6: I/O only in
|
|
6
6
|
* actions). Typical flow: a mutation schedules an action → the action reads tokens with `ctx.runQuery`, calls
|
|
7
7
|
* `sendPush`, deletes `unregisteredTokens` with `ctx.runMutation`, and schedules a receipts check ~15 min later.
|
|
8
|
-
* -
|
|
8
|
+
* - Transports: Expo Push (`sendPush`, zero setup) or direct APNs + FCM (`sendApns`, `sendFcm`, `sendToDevices`), with
|
|
9
|
+
* optional end-to-end encryption (HPKE envelopes, `sealPushContent`; the device side is `@freecorps/ember-expo-push`).
|
|
10
|
+
* - Or install it as a component (`pushComponent`, PLAN §12): devices in the component's own table, sends enqueued
|
|
11
|
+
* from mutations (atomically), receipts checked later, dead tokens pruned. See `PushNotifications`.
|
|
9
12
|
*/
|
|
10
13
|
export * from "./types.ts";
|
|
14
|
+
export { fromBase64url, toBase64url } from "./base64url.ts";
|
|
15
|
+
export { generateRecipient, importKeyPair, open, seal, P256_PUBLIC_KEY_LENGTH, type HpkeOptions, type HpkeRecipient, type HpkeSealed, type SealOptions } from "./hpke.ts";
|
|
16
|
+
export { EMBER_PUSH_ENVELOPE_VERSION, EMBER_PUSH_HPKE_INFO, PUSH_KEY_ID, PUSH_PLAINTEXT_MAX_BYTES, isDevicePublicKey, openPushEnvelope, parseDevicePublicKey, parseOpenedContent, parsePushEnvelope, pushKeyId, sealPushContent, type EncryptedPushContent, type OpenedPushContent, type PushEnvelope, } from "./envelope.ts";
|
|
11
17
|
export { EXPO_MAX_PAYLOAD, EXPO_PUSH_URL, EXPO_RECEIPTS_CHUNK, EXPO_RECEIPTS_URL, EXPO_SEND_CHUNK, checkPushReceipts, expoPushProvider, isExpoPushToken, payloadSize, sendExpoMessages, sendPush, toExpoMessage, type ExpoPushOptions, } from "./expo.ts";
|
|
12
|
-
export { PUSH_DEVICES_TABLE, pushDevicesForUser, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, unregisterPushDevice, type PushDevice, type RegisterPushDeviceInput, } from "./devices.ts";
|
|
18
|
+
export { PUSH_DEVICES_TABLE, inferPushProvider, isPushProviderName, pushDevicesForUser, pushDevicesForUsers, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, removeVoipTokens, setPushDeviceEnvironment, unregisterPushDevice, type PushDevice, type PushProviderName, type RegisterPushDeviceInput, } from "./devices.ts";
|
|
19
|
+
export { apnsOptionsFromEnv, fcmOptionsFromEnv, pushComponent, pushFunctions, PushNotifications, setPushSeal, type PushComponentApi, type PushInstallConfig, type DeliveryResult } from "./component.ts";
|
|
20
|
+
export { decodeJwt, importSigningKey, pemToDer, signJwt, verifyJwt, type CredentialsError, type JwtAlgorithm } from "./jwt.ts";
|
|
21
|
+
export { DEFAULT_PUSH_FALLBACK, PUSH_MAX_TTL_SECONDS, PUSH_TTL_DEFAULTS, resolveInterruptionLevel, resolveTtlSeconds, resolveUrgency } from "./delivery.ts";
|
|
22
|
+
export { APNS_MAX_PAYLOAD, APNS_PRODUCTION_URL, APNS_SANDBOX_URL, APNS_TOKEN_REFRESH_MS, APNS_VOIP_MAX_PAYLOAD, apnsErrorCode, apnsHeaders, apnsPayload, apnsProviderToken, apnsPushProvider, apnsPushType, clearApnsTokenCache, isApnsToken, sendApns, type ApnsCredentials, type ApnsOptions, } from "./apns.ts";
|
|
23
|
+
export { FCM_MAX_PAYLOAD, FCM_MAX_TTL_SECONDS, FCM_SCOPE, FCM_SEND_URL, FCM_TOKEN_MARGIN_MS, GOOGLE_TOKEN_URL, clearFcmTokenCache, fcmAccessToken, fcmErrorCode, fcmMessage, fcmPushProvider, isFcmToken, parseServiceAccount, sendFcm, type FcmOptions, type ServiceAccount, } from "./fcm.ts";
|
|
24
|
+
export { sendToDevices, type DeviceSendResult, type PushTarget, type PushTransports } from "./direct.ts";
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,20 @@
|
|
|
5
5
|
* - Sending (`sendPush`, `checkPushReceipts`): plain `fetch`, so call them from an **action** (PLAN D6: I/O only in
|
|
6
6
|
* actions). Typical flow: a mutation schedules an action → the action reads tokens with `ctx.runQuery`, calls
|
|
7
7
|
* `sendPush`, deletes `unregisteredTokens` with `ctx.runMutation`, and schedules a receipts check ~15 min later.
|
|
8
|
-
* -
|
|
8
|
+
* - Transports: Expo Push (`sendPush`, zero setup) or direct APNs + FCM (`sendApns`, `sendFcm`, `sendToDevices`), with
|
|
9
|
+
* optional end-to-end encryption (HPKE envelopes, `sealPushContent`; the device side is `@freecorps/ember-expo-push`).
|
|
10
|
+
* - Or install it as a component (`pushComponent`, PLAN §12): devices in the component's own table, sends enqueued
|
|
11
|
+
* from mutations (atomically), receipts checked later, dead tokens pruned. See `PushNotifications`.
|
|
9
12
|
*/
|
|
10
13
|
export * from "./types.js";
|
|
14
|
+
export { fromBase64url, toBase64url } from "./base64url.js";
|
|
15
|
+
export { generateRecipient, importKeyPair, open, seal, P256_PUBLIC_KEY_LENGTH } from "./hpke.js";
|
|
16
|
+
export { EMBER_PUSH_ENVELOPE_VERSION, EMBER_PUSH_HPKE_INFO, PUSH_KEY_ID, PUSH_PLAINTEXT_MAX_BYTES, isDevicePublicKey, openPushEnvelope, parseDevicePublicKey, parseOpenedContent, parsePushEnvelope, pushKeyId, sealPushContent, } from "./envelope.js";
|
|
11
17
|
export { EXPO_MAX_PAYLOAD, EXPO_PUSH_URL, EXPO_RECEIPTS_CHUNK, EXPO_RECEIPTS_URL, EXPO_SEND_CHUNK, checkPushReceipts, expoPushProvider, isExpoPushToken, payloadSize, sendExpoMessages, sendPush, toExpoMessage, } from "./expo.js";
|
|
12
|
-
export { PUSH_DEVICES_TABLE, pushDevicesForUser, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, unregisterPushDevice, } from "./devices.js";
|
|
18
|
+
export { PUSH_DEVICES_TABLE, inferPushProvider, isPushProviderName, pushDevicesForUser, pushDevicesForUsers, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, removeVoipTokens, setPushDeviceEnvironment, unregisterPushDevice, } from "./devices.js";
|
|
19
|
+
export { apnsOptionsFromEnv, fcmOptionsFromEnv, pushComponent, pushFunctions, PushNotifications, setPushSeal } from "./component.js";
|
|
20
|
+
export { decodeJwt, importSigningKey, pemToDer, signJwt, verifyJwt } from "./jwt.js";
|
|
21
|
+
export { DEFAULT_PUSH_FALLBACK, PUSH_MAX_TTL_SECONDS, PUSH_TTL_DEFAULTS, resolveInterruptionLevel, resolveTtlSeconds, resolveUrgency } from "./delivery.js";
|
|
22
|
+
export { APNS_MAX_PAYLOAD, APNS_PRODUCTION_URL, APNS_SANDBOX_URL, APNS_TOKEN_REFRESH_MS, APNS_VOIP_MAX_PAYLOAD, apnsErrorCode, apnsHeaders, apnsPayload, apnsProviderToken, apnsPushProvider, apnsPushType, clearApnsTokenCache, isApnsToken, sendApns, } from "./apns.js";
|
|
23
|
+
export { FCM_MAX_PAYLOAD, FCM_MAX_TTL_SECONDS, FCM_SCOPE, FCM_SEND_URL, FCM_TOKEN_MARGIN_MS, GOOGLE_TOKEN_URL, clearFcmTokenCache, fcmAccessToken, fcmErrorCode, fcmMessage, fcmPushProvider, isFcmToken, parseServiceAccount, sendFcm, } from "./fcm.js";
|
|
24
|
+
export { sendToDevices } from "./direct.js";
|
package/dist/jwt.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type JwtAlgorithm = "ES256" | "RS256";
|
|
2
|
+
/**
|
|
3
|
+
* The DER bytes of a PEM `PRIVATE KEY` (PKCS#8), the form of an Apple `.p8` file and of a Google service account's
|
|
4
|
+
* `private_key`. Tolerates what secrets go through: `\n` written as two characters, `\r\n`, surrounding whitespace and
|
|
5
|
+
* quotes. Throws for an encrypted or non-PKCS#8 key (`BEGIN EC PRIVATE KEY`, `BEGIN RSA PRIVATE KEY`).
|
|
6
|
+
*/
|
|
7
|
+
export declare function pemToDer(pem: string): Uint8Array<ArrayBuffer>;
|
|
8
|
+
/** An error about the configured key itself (not the network): senders report it as `InvalidCredentials`. */
|
|
9
|
+
export type CredentialsError = Error & {
|
|
10
|
+
credentials: true;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Imports a PKCS#8 PEM private key for signing with `alg`. The key is not extractable. Throws a `CredentialsError`
|
|
14
|
+
* (`credentials: true`) for anything wrong with the key: not PEM, not PKCS#8, bad base64, wrong curve or type.
|
|
15
|
+
*/
|
|
16
|
+
export declare function importSigningKey(pem: string, alg: JwtAlgorithm): Promise<CryptoKey>;
|
|
17
|
+
/** `base64url(header).base64url(claims).base64url(signature)`, with `alg` and `typ: "JWT"` set in the header. */
|
|
18
|
+
export declare function signJwt(alg: JwtAlgorithm, key: CryptoKey, claims: Record<string, unknown>, header?: Record<string, unknown>): Promise<string>;
|
|
19
|
+
/** Splits a compact JWS into its decoded header, claims and raw signature (no verification). For tests and fakes. */
|
|
20
|
+
export declare function decodeJwt(token: string): {
|
|
21
|
+
header: Record<string, unknown>;
|
|
22
|
+
claims: Record<string, unknown>;
|
|
23
|
+
signature: Uint8Array<ArrayBuffer>;
|
|
24
|
+
signingInput: string;
|
|
25
|
+
};
|
|
26
|
+
/** Verifies a compact JWS with a public key (`verify` usage). For tests and fakes. */
|
|
27
|
+
export declare function verifyJwt(token: string, alg: JwtAlgorithm, publicKey: CryptoKey): Promise<boolean>;
|
package/dist/jwt.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact JWS signing on WebCrypto, for the two tokens direct push needs (RFC 7515/7518):
|
|
3
|
+
*
|
|
4
|
+
* - **ES256** — the APNs provider token, signed with the `.p8` key from Apple (a PKCS#8 P-256 key). WebCrypto's ECDSA
|
|
5
|
+
* signature is already the raw `r ‖ s` (64 bytes) that JWS wants, so no DER conversion.
|
|
6
|
+
* - **RS256** — the assertion Google's OAuth token endpoint exchanges for an FCM access token, signed with the
|
|
7
|
+
* service account's `private_key` (PKCS#8 RSA).
|
|
8
|
+
*
|
|
9
|
+
* Written for Ember from the RFCs and Apple/Google documentation; no Node APIs, so it runs in Workers.
|
|
10
|
+
*/
|
|
11
|
+
import { fromBase64url, toBase64url } from "./base64url.js";
|
|
12
|
+
const ALGORITHMS = {
|
|
13
|
+
ES256: { importParams: { name: "ECDSA", namedCurve: "P-256" }, signParams: { name: "ECDSA", hash: "SHA-256" } },
|
|
14
|
+
RS256: { importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, signParams: { name: "RSASSA-PKCS1-v1_5" } },
|
|
15
|
+
};
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
/**
|
|
18
|
+
* The DER bytes of a PEM `PRIVATE KEY` (PKCS#8), the form of an Apple `.p8` file and of a Google service account's
|
|
19
|
+
* `private_key`. Tolerates what secrets go through: `\n` written as two characters, `\r\n`, surrounding whitespace and
|
|
20
|
+
* quotes. Throws for an encrypted or non-PKCS#8 key (`BEGIN EC PRIVATE KEY`, `BEGIN RSA PRIVATE KEY`).
|
|
21
|
+
*/
|
|
22
|
+
export function pemToDer(pem) {
|
|
23
|
+
const text = pem.trim().replace(/^"|"$/g, "").replace(/\\n/g, "\n").replace(/\r/g, "");
|
|
24
|
+
const m = /-----BEGIN ([A-Z ]+)-----([\s\S]*?)-----END \1-----/.exec(text);
|
|
25
|
+
if (!m)
|
|
26
|
+
throw new Error("not a PEM key (expected -----BEGIN PRIVATE KEY-----)");
|
|
27
|
+
if (m[1] !== "PRIVATE KEY")
|
|
28
|
+
throw new Error(`expected a PKCS#8 "PRIVATE KEY", got "${m[1]}" (convert it with \`openssl pkcs8 -topk8 -nocrypt\`)`);
|
|
29
|
+
const b64 = m[2].replace(/\s+/g, "");
|
|
30
|
+
const bin = atob(b64);
|
|
31
|
+
const out = new Uint8Array(bin.length);
|
|
32
|
+
for (let i = 0; i < bin.length; i++)
|
|
33
|
+
out[i] = bin.charCodeAt(i);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
const credentialsError = (message) => Object.assign(new Error(message), { credentials: true });
|
|
37
|
+
/**
|
|
38
|
+
* Imports a PKCS#8 PEM private key for signing with `alg`. The key is not extractable. Throws a `CredentialsError`
|
|
39
|
+
* (`credentials: true`) for anything wrong with the key: not PEM, not PKCS#8, bad base64, wrong curve or type.
|
|
40
|
+
*/
|
|
41
|
+
export async function importSigningKey(pem, alg) {
|
|
42
|
+
let der;
|
|
43
|
+
try {
|
|
44
|
+
der = pemToDer(pem);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
throw credentialsError(e.message);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return await crypto.subtle.importKey("pkcs8", der, ALGORITHMS[alg].importParams, false, ["sign"]);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
throw credentialsError(`cannot import the ${alg === "ES256" ? "P-256 (.p8)" : "RSA"} private key: ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const b64json = (value) => toBase64url(encoder.encode(JSON.stringify(value)));
|
|
57
|
+
/** `base64url(header).base64url(claims).base64url(signature)`, with `alg` and `typ: "JWT"` set in the header. */
|
|
58
|
+
export async function signJwt(alg, key, claims, header = {}) {
|
|
59
|
+
const signingInput = `${b64json({ alg, typ: "JWT", ...header })}.${b64json(claims)}`;
|
|
60
|
+
const signature = await crypto.subtle.sign(ALGORITHMS[alg].signParams, key, encoder.encode(signingInput));
|
|
61
|
+
return `${signingInput}.${toBase64url(new Uint8Array(signature))}`;
|
|
62
|
+
}
|
|
63
|
+
/** Splits a compact JWS into its decoded header, claims and raw signature (no verification). For tests and fakes. */
|
|
64
|
+
export function decodeJwt(token) {
|
|
65
|
+
const parts = token.split(".");
|
|
66
|
+
if (parts.length !== 3)
|
|
67
|
+
throw new Error("not a compact JWS");
|
|
68
|
+
const json = (s) => JSON.parse(new TextDecoder().decode(fromBase64url(s)));
|
|
69
|
+
return { header: json(parts[0]), claims: json(parts[1]), signature: fromBase64url(parts[2]), signingInput: `${parts[0]}.${parts[1]}` };
|
|
70
|
+
}
|
|
71
|
+
/** Verifies a compact JWS with a public key (`verify` usage). For tests and fakes. */
|
|
72
|
+
export async function verifyJwt(token, alg, publicKey) {
|
|
73
|
+
const { header, signature, signingInput } = decodeJwt(token);
|
|
74
|
+
if (header.alg !== alg)
|
|
75
|
+
return false;
|
|
76
|
+
return crypto.subtle.verify(ALGORITHMS[alg].signParams, publicKey, signature, encoder.encode(signingInput));
|
|
77
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provider-neutral push types.
|
|
3
|
-
* over HTTP/2)
|
|
2
|
+
* Provider-neutral push types. Two transports implement them: Expo Push (`expo.ts`, zero setup) and direct APNs (ES256
|
|
3
|
+
* provider token over HTTP/2, `apns.ts`) + FCM HTTP v1 (service-account OAuth, `fcm.ts`). Callers do not change.
|
|
4
4
|
*/
|
|
5
|
+
import type { PushEnvelope } from "./envelope.ts";
|
|
6
|
+
/**
|
|
7
|
+
* How urgent a push is. Direct transports map it to the APNs priority, the FCM Android priority, the TTL default and
|
|
8
|
+
* the iOS interruption level (Brigade's `low`/`high`/`page`, see `delivery.ts`):
|
|
9
|
+
*
|
|
10
|
+
* | urgency | APNs priority | FCM priority | TTL default | interruption level |
|
|
11
|
+
* |----------|---------------|--------------|-------------|--------------------|
|
|
12
|
+
* | `low` | 5 | NORMAL | 4 h | active |
|
|
13
|
+
* | `normal` | 10 | HIGH | 1 h | active |
|
|
14
|
+
* | `high` | 10 | HIGH | 15 min | time-sensitive |
|
|
15
|
+
*/
|
|
16
|
+
export type PushUrgency = "low" | "normal" | "high";
|
|
17
|
+
/** iOS 15+ interruption levels. `time-sensitive` needs the entitlement; `critical` needs Apple's approval. */
|
|
18
|
+
export type PushInterruptionLevel = "passive" | "active" | "time-sensitive" | "critical";
|
|
19
|
+
/** APNs environment of an iOS device token: Xcode/development-signed builds get sandbox tokens, ad hoc/TestFlight/App Store ones production. */
|
|
20
|
+
export type ApnsEnvironment = "sandbox" | "production";
|
|
21
|
+
/**
|
|
22
|
+
* End-to-end encryption of a push (direct transport only):
|
|
23
|
+
* - `off`: content in clear;
|
|
24
|
+
* - `auto` (default): sealed for devices that registered a public key, in clear for the others;
|
|
25
|
+
* - `required`: sealed; a device without a key (or an Expo token) gets only the fallback text.
|
|
26
|
+
*/
|
|
27
|
+
export type PushEncryption = "off" | "auto" | "required";
|
|
28
|
+
/**
|
|
29
|
+
* iOS Critical Alert sound (the app needs Apple's Critical Alerts entitlement): plays even on silent and through Focus.
|
|
30
|
+
* APNs gets `{ critical: 1, name, volume }` and `interruption-level: critical`; Android and Expo play `name`.
|
|
31
|
+
*/
|
|
32
|
+
export interface PushCriticalSound {
|
|
33
|
+
critical: true;
|
|
34
|
+
/** Sound file in the app bundle (default `"default"`). */
|
|
35
|
+
name?: string;
|
|
36
|
+
/** 0 to 1 (default 1). */
|
|
37
|
+
volume?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A payload the app built itself (its own envelope and keys), sent as given by the direct transports. Ember still sets
|
|
41
|
+
* the headers (push type, priority, expiration, collapse id), the provider token, retries and error mapping.
|
|
42
|
+
* `apns`: the whole JSON body (`aps` included). `fcmData`: an FCM data message's `data` (string values only).
|
|
43
|
+
* A transport without its part builds the message from the other fields as usual; Expo ignores both.
|
|
44
|
+
*/
|
|
45
|
+
export interface PushPayload {
|
|
46
|
+
apns?: Record<string, unknown>;
|
|
47
|
+
fcmData?: Record<string, string>;
|
|
48
|
+
}
|
|
5
49
|
/** What a notification says. Fields a provider does not support are ignored by it. */
|
|
6
50
|
export interface PushMessage {
|
|
7
51
|
title?: string;
|
|
@@ -9,8 +53,8 @@ export interface PushMessage {
|
|
|
9
53
|
body?: string;
|
|
10
54
|
/** Delivered to the app (JSON object). Counts toward the 4 KiB payload limit. */
|
|
11
55
|
data?: Record<string, unknown>;
|
|
12
|
-
/** `"default"` plays the default sound (iOS). */
|
|
13
|
-
sound?: "default" | null;
|
|
56
|
+
/** `"default"` plays the default sound (iOS); a `PushCriticalSound` rings as a Critical Alert. */
|
|
57
|
+
sound?: "default" | null | PushCriticalSound;
|
|
14
58
|
/** iOS app icon badge; 0 clears it. */
|
|
15
59
|
badge?: number;
|
|
16
60
|
/** Android notification channel (created by the app, e.g. `registerForPush` creates `"default"`). */
|
|
@@ -24,23 +68,66 @@ export interface PushMessage {
|
|
|
24
68
|
categoryId?: string;
|
|
25
69
|
/** iOS Notification Service Extension may modify the content. */
|
|
26
70
|
mutableContent?: boolean;
|
|
71
|
+
/** Direct transports: see `PushUrgency`. Without it, `priority: "normal"` means `low`, anything else `normal`. */
|
|
72
|
+
urgency?: PushUrgency;
|
|
73
|
+
/** Replaces an earlier notification with the same id (APNs `apns-collapse-id`, ≤ 64 bytes; FCM `collapse_key` + tag; Expo `collapseId` + `tag`). */
|
|
74
|
+
collapseId?: string;
|
|
75
|
+
/** iOS notification grouping (`thread-id`). */
|
|
76
|
+
threadId?: string;
|
|
77
|
+
/** iOS interruption level; default from `urgency` (`high` → `time-sensitive`). */
|
|
78
|
+
interruptionLevel?: PushInterruptionLevel;
|
|
79
|
+
/** Direct transports: overrides the install's encryption setting for this message (see `PushEncryption`). */
|
|
80
|
+
encryption?: PushEncryption;
|
|
81
|
+
/**
|
|
82
|
+
* iOS: send as a PushKit VoIP push to the device's `voipToken` (topic `<bundle id>.voip`, push type `voip`). The app
|
|
83
|
+
* must report a call to CallKit for every one, or iOS stops delivering them. Devices without a VoIP token are skipped.
|
|
84
|
+
*/
|
|
85
|
+
voip?: boolean;
|
|
27
86
|
}
|
|
28
87
|
/** A message for one device token. */
|
|
29
88
|
export interface AddressedPushMessage extends PushMessage {
|
|
30
89
|
to: string;
|
|
90
|
+
/** APNs environment of `to` (iOS). Unknown: the transport's default, then the other host if APNs says `BadDeviceToken`. */
|
|
91
|
+
environment?: ApnsEnvironment;
|
|
92
|
+
/**
|
|
93
|
+
* Set by `sendToDevices` when the content is sealed for this device: the transport sends this envelope and the
|
|
94
|
+
* fallback text instead of title/subtitle/body/data.
|
|
95
|
+
*/
|
|
96
|
+
sealed?: PushEnvelope;
|
|
97
|
+
/** The app's own payload for this device (see `PushPayload`); wins over `sealed` and the content fields. */
|
|
98
|
+
payload?: PushPayload;
|
|
99
|
+
}
|
|
100
|
+
/** What a device shows when an encrypted push cannot be opened (or, with `encryption: "required"`, has no key). */
|
|
101
|
+
export interface PushFallback {
|
|
102
|
+
title?: string;
|
|
103
|
+
body?: string;
|
|
104
|
+
/** iOS: localization keys from the app's `Localizable.strings`, preferred over the plain text when set. */
|
|
105
|
+
titleLocKey?: string;
|
|
106
|
+
locKey?: string;
|
|
31
107
|
}
|
|
32
108
|
/** Well-known per-message errors (Expo names; APNs/FCM providers map theirs onto these). */
|
|
33
109
|
export type PushErrorCode = "DeviceNotRegistered" | "MessageTooBig" | "MessageRateExceeded" | "MismatchSenderId" | "InvalidCredentials" | "InvalidToken" | "RequestFailed" | (string & {});
|
|
34
110
|
/** The push service's answer at send time. `ok` means accepted, not delivered: check receipts later. */
|
|
35
|
-
export type PushTicket =
|
|
111
|
+
export type PushTicket =
|
|
112
|
+
/** `environment`: set when a direct send found the device's APNs environment by trying the other host. */
|
|
113
|
+
{
|
|
36
114
|
token: string;
|
|
37
115
|
status: "ok";
|
|
38
116
|
id: string;
|
|
117
|
+
environment?: ApnsEnvironment;
|
|
39
118
|
} | {
|
|
40
119
|
token: string;
|
|
41
120
|
status: "error";
|
|
42
121
|
message: string;
|
|
43
122
|
error?: PushErrorCode;
|
|
123
|
+
/**
|
|
124
|
+
* The provider's own reason, as it said it (direct sends): APNs' `reason` (`DeviceTokenNotForTopic`,
|
|
125
|
+
* `BadDeviceToken`, …) or FCM's `errorCode` / error `status` (`UNREGISTERED`, `INVALID_ARGUMENT`, …). For
|
|
126
|
+
* rules Ember's codes fold together (a token of another topic, a token FCM calls malformed).
|
|
127
|
+
*/
|
|
128
|
+
reason?: string;
|
|
129
|
+
/** The provider's HTTP status (direct sends). */
|
|
130
|
+
httpStatus?: number;
|
|
44
131
|
};
|
|
45
132
|
/** Final delivery outcome reported by the provider (Expo keeps receipts for 24 h). */
|
|
46
133
|
export type PushReceipt = {
|
package/dist/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freecorps/ember-push",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Push notifications for Ember
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Push notifications for Ember: device registry, Expo Push, direct APNs + FCM, end-to-end encrypted payloads (HPKE), and a push component.",
|
|
5
5
|
"keywords": [
|
|
6
|
-
"
|
|
7
|
-
"convex",
|
|
6
|
+
"apns",
|
|
8
7
|
"cloudflare",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
8
|
+
"convex",
|
|
9
|
+
"e2ee",
|
|
10
|
+
"ember",
|
|
11
11
|
"expo",
|
|
12
|
-
"
|
|
12
|
+
"fcm",
|
|
13
|
+
"hpke",
|
|
14
|
+
"notifications",
|
|
15
|
+
"push",
|
|
16
|
+
"workers"
|
|
13
17
|
],
|
|
14
18
|
"homepage": "https://github.com/freecorps/ember/tree/main/packages/push#readme",
|
|
15
19
|
"bugs": {
|
|
@@ -41,7 +45,7 @@
|
|
|
41
45
|
"access": "public"
|
|
42
46
|
},
|
|
43
47
|
"dependencies": {
|
|
44
|
-
"@freecorps/ember-server": "0.
|
|
45
|
-
"@freecorps/ember-values": "0.
|
|
48
|
+
"@freecorps/ember-server": "0.3.0",
|
|
49
|
+
"@freecorps/ember-values": "0.3.0"
|
|
46
50
|
}
|
|
47
51
|
}
|