Skip to content

Commit 8ee48cc

Browse files
committed
Initial commit
1 parent 4bf85ef commit 8ee48cc

22 files changed

+462
-234
lines changed

.npmignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
npm-debug.log
3+
src
4+
test
5+
README.md
6+
.gitignore

README.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# web-push-browser
2+
3+
> This project is not affiliated or based upon the original [web-push](https://github.com/web-push-libs/web-push) package or [web-push-lib](https://github.com/web-push-libs) organization.
4+
5+
This package is aimed at being a lightweight replacement for [web-push](https://github.com/web-push-libs/web-push), as (at the time of writing) it relies on Node.js dependencies that are not available in the browser.
6+
7+
## Installation
8+
9+
```bash
10+
npm install web-push-browser
11+
```
12+
13+
## Example Usage
14+
15+
### Subscribing a User
16+
17+
```ts
18+
import { fromBase64Url } from 'web-push-browser';
19+
20+
//...
21+
22+
const registration = await navigator.serviceWorker.register('./service-worker.js', { type: 'module' });
23+
try {
24+
// Subscribe to push notifications
25+
const sub = await registration.pushManager.subscribe({
26+
userVisibleOnly: true,
27+
applicationServerKey: fromBase64Url(PUBLIC_VAPID_KEY),
28+
});
29+
30+
// Store the subscription in your backend
31+
// ...
32+
} catch (err) {
33+
console.error('Failed to subscribe to notifications', err);
34+
if (await registration.pushManager.getSubscription()) {
35+
// Cleanup if existing subscription exists
36+
await sub.unsubscribe();
37+
}
38+
}
39+
```
40+
41+
### Sending a Push Notification
42+
43+
```ts
44+
import { sendNotification, deserializeVapidKeys } from 'web-push-browser';
45+
46+
// You can use `deserializeVapidKeys` to convert your VAPID keys from strings into a KeyPair
47+
const keyPair = await deserializeVapidKeys({
48+
publicKey: PUBLIC_VAPID_KEY,
49+
privateKey: VAPID_PRIVATE_KEY,
50+
});
51+
52+
const sub = // Get the subscription from your backend
53+
const { auth, p256dh } = sub.keys;
54+
55+
const res = await sendPushNotification(
56+
keyPair,
57+
{
58+
endpoint: sub.endpoint,
59+
keys: {
60+
auth: auth,
61+
p256dh: p256dh,
62+
},
63+
},
64+
65+
JSON.stringify("Insert JSON payload here"),
66+
);
67+
if (!res.ok) {
68+
console.error('Failed to send push notification', res);
69+
}
70+
```
71+
72+
### Generating VAPID Keys
73+
74+
```js
75+
import { generateVapidKeys, serializeVapidKeys } from 'web-push-browser';
76+
77+
const keys = await generateVapidKeys();
78+
const serializedKeys = await serializeVapidKeys(keys);
79+
console.log(serializedKeys);
80+
```
81+
82+
## Extended Usage
83+
84+
This package only supports the basic functionality. If you need more advanced features, such as proxies, custom headers, etc. you can access the internal functions to create your own requests.

package-lock.json

Lines changed: 2 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
{
2-
"name": "webpush-notification",
2+
"name": "web-push-browser",
33
"version": "1.0.0",
44
"description": "Minimal library for sending notifications via the browser Push API",
5-
"main": "build/pushNotification.js",
5+
"main": "build/index.js",
66
"dependencies": {
77
"jose": "^5.8.0"
88
},
99
"devDependencies": {
10-
"base64url": "^3.0.1",
11-
"js-crypto-hkdf": "^1.0.7",
1210
"typescript": "^5.5.4"
1311
},
1412
"scripts": {
15-
"test": "echo \"Error: no test specified\" && exit 1",
1613
"build": "tsc"
1714
},
1815
"author": "Cole Crouter",
1916
"license": "ISC",
2017
"type": "module"
21-
}
18+
}

src/crypto/deriveSharedSecret.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

src/crypto/encode.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.

src/crypto/encryptPayload.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

src/crypto/generateVapidKeys.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/crypto/jwt.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { SignJWT } from "jose";
2+
3+
/**
4+
* Generate and sign a JWT token.
5+
*
6+
* To be used in the `Authorization` header of a POST request to a web Push API endpoint.
7+
* @param privateVapidKey - The private key to sign the JWT with.
8+
* @param endpoint - The URL of the web Push API endpoint.
9+
* @param email - The email address to use as the `sub` claim. For example, `[email protected]`.
10+
* @returns
11+
*/
12+
export async function createJWT(
13+
privateVapidKey: CryptoKey,
14+
endpoint: URL,
15+
email: string,
16+
): Promise<string> {
17+
const aud = endpoint.origin;
18+
const exp = Math.floor(Date.now() / 1000) + 12 * 60 * 60; // 12 hours from now
19+
const sub = `mailto:${email}`;
20+
21+
return await new SignJWT({
22+
aud,
23+
exp,
24+
sub,
25+
})
26+
.setProtectedHeader({ alg: "ES256", typ: "JWT" })
27+
.sign(privateVapidKey);
28+
}

src/crypto/payload.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { PushNotificationSubscription } from "../types.js";
2+
import { fromBase64Url } from "../utils/base64url.js";
3+
4+
/**
5+
* Encrypt a plaintext payload using the keys provided by the PushSubscription.
6+
* @param payload - The plaintext payload to encrypt.
7+
* @param keys - The keys from the PushSubscription.
8+
*/
9+
export async function encryptPayload(
10+
payload: string,
11+
keys: PushNotificationSubscription["keys"],
12+
) {
13+
const encoder = new TextEncoder();
14+
const salt = crypto.getRandomValues(new Uint8Array(16));
15+
16+
if (!keys.p256dh || !keys.auth) {
17+
throw new Error("Missing p256dh or auth key");
18+
}
19+
20+
// Get the p256dh and auth keys from the subscription
21+
const auth =
22+
typeof keys.auth === "string" ? fromBase64Url(keys.auth) : keys.auth;
23+
const p256dh =
24+
typeof keys.p256dh === "string" ? fromBase64Url(keys.p256dh) : keys.p256dh;
25+
26+
// Generate a new ECDH key pair for this encryption
27+
const localKeyPair = await crypto.subtle.generateKey(
28+
{ name: "ECDH", namedCurve: "P-256" },
29+
true,
30+
["deriveBits"],
31+
);
32+
33+
// Import the client's public key
34+
const clientPublicKey = await crypto.subtle.importKey(
35+
"raw",
36+
p256dh,
37+
{ name: "ECDH", namedCurve: "P-256" },
38+
true,
39+
[],
40+
);
41+
42+
// Generate a shared secret
43+
const sharedSecret = await crypto.subtle.deriveBits(
44+
{ name: "ECDH", public: clientPublicKey },
45+
localKeyPair.privateKey,
46+
256,
47+
);
48+
49+
// Create the PRK
50+
const prk = await crypto.subtle.importKey(
51+
"raw",
52+
await crypto.subtle.digest(
53+
"SHA-256",
54+
new Uint8Array([
55+
...new Uint8Array(auth),
56+
...new Uint8Array(sharedSecret),
57+
]),
58+
),
59+
{ name: "HKDF" },
60+
false,
61+
["deriveBits"],
62+
);
63+
64+
// Derive the Content Encryption Key
65+
const cekInfo = encoder.encode("Content-Encoding: aes128gcm");
66+
const cek = await crypto.subtle.deriveBits(
67+
{
68+
name: "HKDF",
69+
hash: "SHA-256",
70+
salt: salt,
71+
info: cekInfo,
72+
},
73+
prk,
74+
128,
75+
);
76+
77+
const iv = crypto.getRandomValues(new Uint8Array(12));
78+
79+
// Encrypt the payload
80+
const encryptedPayload = await crypto.subtle.encrypt(
81+
{ name: "AES-GCM", iv: iv },
82+
await crypto.subtle.importKey(
83+
"raw",
84+
new Uint8Array(cek),
85+
{ name: "AES-GCM" },
86+
false,
87+
["encrypt"],
88+
),
89+
encoder.encode(payload),
90+
);
91+
92+
// Prepend the salt and server public key to the payload
93+
// Export the server's public key
94+
const serverPublicKeyBytes = new Uint8Array(
95+
await crypto.subtle.exportKey("raw", localKeyPair.publicKey),
96+
);
97+
98+
// Construct the header
99+
const header = new Uint8Array([
100+
...salt, // 16 bytes
101+
...new Uint8Array(4), // 4 bytes for record size (we'll fill this later)
102+
...serverPublicKeyBytes, // 65 bytes for public key
103+
]);
104+
105+
// Construct the full message
106+
const message = new Uint8Array([
107+
...header,
108+
...new Uint8Array(encryptedPayload),
109+
]);
110+
111+
// Now fill in the record size
112+
const recordSize = new Uint32Array([encryptedPayload.byteLength]);
113+
new Uint8Array(message.buffer, 16, 4).set(new Uint8Array(recordSize.buffer));
114+
const encrypted = message.buffer;
115+
const serverPublicKey = localKeyPair.publicKey;
116+
117+
return { encrypted, salt, serverPublicKey };
118+
}

0 commit comments

Comments
 (0)