Matthew Raymer
1 year ago
9 changed files with 1118 additions and 6293 deletions
File diff suppressed because it is too large
@ -1,66 +0,0 @@ |
|||
import fetch from 'node-fetch'; |
|||
import * as crypto from 'crypto'; |
|||
import { ec as EC } from 'elliptic'; |
|||
|
|||
export class WebPushSender { |
|||
private ec = new EC('prime256v1'); |
|||
private vapidPrivateKey: string; |
|||
|
|||
constructor(vapidPrivateKey: string) { |
|||
this.vapidPrivateKey = vapidPrivateKey; |
|||
} |
|||
|
|||
private generateECDHKeyPair() { |
|||
return this.ec.genKeyPair(); |
|||
} |
|||
|
|||
private deriveSharedSecret(senderKeys, receiverPublicKeyBase64: string) { |
|||
const receiverPublicKey = Buffer.from(receiverPublicKeyBase64, 'base64'); |
|||
return senderKeys.derive(this.ec.keyFromPublic(receiverPublicKey).getPublic()); |
|||
} |
|||
|
|||
private generateContentEncryptionKeys(sharedSecret, authSecretBase64: string) { |
|||
const authSecret = Buffer.from(authSecretBase64, 'base64'); |
|||
const info = Buffer.from('Content-Encoding: auth\0', 'utf8'); |
|||
const prk = crypto.createHmac('sha256', authSecret).update(sharedSecret.toArray()).digest(); |
|||
const context = crypto.createHmac('sha256', prk).update(info).digest(); |
|||
const keyInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8'); |
|||
return crypto.createHmac('sha256', context).update(keyInfo).digest().slice(0, 16); |
|||
} |
|||
|
|||
private encryptPayload(contentEncryptionKey, payload: string) { |
|||
const nonce = Buffer.alloc(12); |
|||
const cipher = crypto.createCipheriv('aes-128-gcm', contentEncryptionKey, nonce); |
|||
return Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]); |
|||
} |
|||
|
|||
private generateVapidToken(endpoint: string) { |
|||
const tokenPayload = { |
|||
aud: endpoint, |
|||
exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60), // 24 hours
|
|||
sub: 'mailto:your-email@example.com', |
|||
}; |
|||
const encodedHeader = Buffer.from(JSON.stringify({ alg: 'ES256', typ: 'JWT' })).toString('base64'); |
|||
const encodedPayload = Buffer.from(JSON.stringify(tokenPayload)).toString('base64'); |
|||
const signature = this.ec.sign(encodedHeader + '.' + encodedPayload, this.vapidPrivateKey, 'base64'); |
|||
const encodedSignature = signature.toDER('hex'); |
|||
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`; |
|||
} |
|||
|
|||
public sendPushNotification(subscription, payload: string) { |
|||
const senderKeys = this.generateECDHKeyPair(); |
|||
const sharedSecret = this.deriveSharedSecret(senderKeys, subscription.keys.p256dh); |
|||
const contentEncryptionKey = this.generateContentEncryptionKeys(sharedSecret, subscription.keys.auth); |
|||
const encryptedPayload = this.encryptPayload(contentEncryptionKey, payload); |
|||
const vapidToken = this.generateVapidToken(subscription.endpoint); |
|||
|
|||
const headers = { |
|||
'Content-Encoding': 'aes128gcm', |
|||
'Authorization': `vapid t=${vapidToken}`, |
|||
'TTL': '60', |
|||
}; |
|||
|
|||
return fetch(subscription.endpoint, { method: 'POST', body: encryptedPayload, headers }); |
|||
} |
|||
} |
|||
|
@ -0,0 +1,28 @@ |
|||
import express from 'express'; |
|||
import { saveSubscription, getSubscriptions } from './subscriptions.js'; |
|||
import { WebPushService } from './webpush.js'; |
|||
|
|||
const router = express.Router(); |
|||
|
|||
router.post('/subscribe', (req, res) => { |
|||
const subscription = req.body; |
|||
saveSubscription(subscription); |
|||
res.status(201).send(); |
|||
}); |
|||
|
|||
router.post('/sendNotification', (_, res) => { |
|||
const webPush = new WebPushService(); |
|||
const subscriptions = getSubscriptions(); |
|||
const data = { |
|||
title: "Notification Title", |
|||
message: "This is a message from your web push server" |
|||
}; |
|||
|
|||
for (let sub of subscriptions) { |
|||
webPush.sendNotification(sub, data); |
|||
} |
|||
|
|||
res.status(201).send(); |
|||
}); |
|||
|
|||
export default router; |
@ -0,0 +1,18 @@ |
|||
export type Subscription = { |
|||
endpoint: string; |
|||
keys: { |
|||
p256dh: string; |
|||
auth: string; |
|||
}; |
|||
}; |
|||
|
|||
let subscriptions: Subscription[] = []; |
|||
|
|||
export function saveSubscription(sub: Subscription) { |
|||
subscriptions.push(sub); |
|||
} |
|||
|
|||
export function getSubscriptions(): Subscription[] { |
|||
return subscriptions; |
|||
} |
|||
|
@ -0,0 +1,82 @@ |
|||
import * as http_ece from 'http_ece'; |
|||
import { Subscription } from './subscriptions.js'; |
|||
import * as jwt from 'jsonwebtoken'; |
|||
import * as https from 'https'; |
|||
|
|||
export class WebPushService { |
|||
|
|||
private vapidPrivateKey = 'YOUR_VAPID_PRIVATE_KEY'; |
|||
private vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY'; |
|||
|
|||
async sendNotification(subscription: Subscription, data: any) { |
|||
const payload = JSON.stringify(data); |
|||
|
|||
// Encrypt the payload
|
|||
const encrypted = this.encrypt(subscription.keys.p256dh, subscription.keys.auth, payload); |
|||
|
|||
// VAPID headers
|
|||
const vapidHeaders = this.createVapidAuthHeader(); |
|||
|
|||
// Send the encrypted data to the endpoint
|
|||
const parsedUrl = new URL(subscription.endpoint); |
|||
const options: https.RequestOptions = { |
|||
method: 'POST', |
|||
hostname: parsedUrl.hostname, |
|||
path: parsedUrl.pathname, |
|||
port: parsedUrl.port, |
|||
headers: { |
|||
...vapidHeaders, |
|||
'TTL': '60', // Time-to-live in seconds
|
|||
'Content-Encoding': 'aes128gcm', |
|||
'Content-Type': 'application/octet-stream', |
|||
'Content-Length': encrypted.length |
|||
}, |
|||
}; |
|||
|
|||
return new Promise<void>((resolve, reject) => { |
|||
const req = https.request(options, (res) => { |
|||
if (res.statusCode! >= 200 && res.statusCode! < 300) { |
|||
resolve(); |
|||
} else { |
|||
reject(new Error(`Failed to send push notification. Status code: ${res.statusCode}`)); |
|||
} |
|||
}); |
|||
|
|||
req.on('error', (error) => { |
|||
reject(new Error(`Failed to send push notification. Error: ${error.message}`)); |
|||
}); |
|||
|
|||
req.write(encrypted); |
|||
req.end(); |
|||
}); |
|||
} |
|||
|
|||
encrypt(publicKey: string, auth: string, payload: string): Buffer { |
|||
http_ece.keys = { |
|||
'p256dh': publicKey, |
|||
'auth': auth |
|||
}; |
|||
|
|||
return http_ece.encrypt(payload, { |
|||
'salt': 'randomSalt', // Ideally, you should generate a unique salt for every message.
|
|||
'dh': 'yourVAPIDPublicKey', |
|||
'keyid': 'p256dh', |
|||
'contentEncoding': 'aes128gcm' |
|||
}); |
|||
} |
|||
|
|||
private createVapidAuthHeader(): { Authorization: string, 'Crypto-Key': string } { |
|||
const jwtPayload = { |
|||
aud: 'https://fcm.googleapis.com', |
|||
exp: Math.round(Date.now() / 1000) + (24 * 3600), // 24 hours
|
|||
sub: 'mailto:your@email.com' |
|||
}; |
|||
|
|||
const token = jwt.sign(jwtPayload, this.vapidPrivateKey, { algorithm: 'ES256' }); |
|||
|
|||
return { |
|||
Authorization: `Bearer ${token}`, |
|||
'Crypto-Key': `p256ecdsa=${this.vapidPublicKey}` |
|||
}; |
|||
} |
|||
} |
Loading…
Reference in new issue