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