From 1bf5758fb299f850f4471c0ff4fbff6da283e513 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Sat, 12 Aug 2023 21:10:07 +0800 Subject: [PATCH] Adding VAPID dynamic version --- src/notificationService.ts | 96 ++++++++++++++++++++++++++++++++++++++ src/routes.ts | 24 +++------- src/vapidService.ts | 66 ++++++++++++++++++++++++++ src/webpush.ts | 79 ------------------------------- 4 files changed, 169 insertions(+), 96 deletions(-) create mode 100644 src/notificationService.ts create mode 100644 src/vapidService.ts diff --git a/src/notificationService.ts b/src/notificationService.ts new file mode 100644 index 0000000..0e912de --- /dev/null +++ b/src/notificationService.ts @@ -0,0 +1,96 @@ +import { SubscriptionService, Subscription } from './subscriptionService.js'; +import { VapidService, VapidKeys } from './vapidService.js'; +import * as https from 'https'; +import * as http_ece from 'http_ece'; +import crypto from 'crypto'; + +export interface Message { + title: string; + body?: string; + [key: string]: any; +} + +export class NotificationService { + + private subscriptionService: SubscriptionService; + private vapidService: VapidService; + private vapidKeys: VapidKeys; + + constructor() { + this.subscriptionService = new SubscriptionService(); + this.vapidService = new VapidService(); + } + + private generateSalt(length = 16): Buffer { + return crypto.randomBytes(length); + } + + async broadcast(message: Message): Promise { + const subscriptions = await this.subscriptionService.fetchSubscriptions(); + this.vapidKeys = await this.vapidService.getVapidKeys(); + + for (const subscription of subscriptions) { + await this.pushToEndpoint(subscription, message); + } + } + + async sendNotification(subscription: Subscription, message: Message) { + this.vapidKeys = await this.vapidService.getVapidKeys(); + await this.pushToEndpoint(subscription, message); + } + + private async pushToEndpoint(subscription: Subscription, message: Message): Promise { + const payload = JSON.stringify(message); + + const encrypted = this.encrypt(subscription.keys.p256dh, subscription.keys.auth, payload); + + const vapidHeaders = this.vapidService.createVapidAuthHeader(); + + 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', + 'Content-Encoding': 'aes128gcm', + 'Content-Type': 'application/octet-stream', + 'Content-Length': encrypted.length + }, + }; + + return new Promise((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(); + }); + } + + private encrypt(publicKey: string, auth: string, payload: string): Buffer { + http_ece.keys = { + 'p256dh': publicKey, + 'auth': auth + }; + + return http_ece.encrypt(payload, { + 'salt': this.generateSalt(), + 'dh': this.vapidKeys.publicKey, + 'keyid': 'p256dh', + 'contentEncoding': 'aes128gcm' + }); + } + +} diff --git a/src/routes.ts b/src/routes.ts index 93d56e4..a650d3e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,31 +1,21 @@ import express from 'express'; -import { SubscriptionService } from './subscriptionService.js'; -import { WebPushService } from './webpush.js'; +import { SubscriptionService, Subscription } from './subscriptionService.js'; +import { NotificationService, Message } from './notificationService.js'; const router = express.Router(); router.post('/subscribe', async (req, res) => { - const subscription = req.body; + const subscription = req.body as Subscription; const subscriptionService = new SubscriptionService(); await subscriptionService.addSubscription(subscription); res.status(201).send(); }); -router.post('/sendNotification', async (_, res) => { - const webPush = new WebPushService(); +router.post('/broadcast', async (req, res) => { + const message = req.body as Message; + const notificationService = new NotificationService(); - const subscriptionService = new SubscriptionService(); - const subscriptions = await subscriptionService.fetchSubscriptions(); - - const data = { - title: "Notification Title", - message: "This is a message from your web push server" - }; - - for (let sub of subscriptions) { - webPush.sendNotification(sub, data); - } - + notificationService.broadcast(message); res.status(201).send(); }); diff --git a/src/vapidService.ts b/src/vapidService.ts new file mode 100644 index 0000000..97b14ad --- /dev/null +++ b/src/vapidService.ts @@ -0,0 +1,66 @@ +import { Database } from 'sqlite3'; +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; + +export interface VapidKeys { + publicKey: string; + privateKey: string; +} + +export class VapidService { + private db: Database; + + constructor() { + this.db = new Database('vapidKeys.db'); + this.initializeDatabase(); + } + + private initializeDatabase(): void { + this.db.run(`CREATE TABLE IF NOT EXISTS vapid (id INTEGER PRIMARY KEY, publicKey TEXT, privateKey TEXT)`); + } + + private generateVAPIDKeys(): VapidKeys { + const ecdh = crypto.createECDH('prime256v1'); + ecdh.generateKeys(); + + return { + publicKey: ecdh.getPublicKey().toString('base64'), + privateKey: ecdh.getPrivateKey().toString('base64') + }; + } + + async getVapidKeys(): Promise { + return new Promise((resolve, reject) => { + this.db.get('SELECT publicKey, privateKey FROM vapid WHERE id = ?', [1], (err, row) => { + if (err) reject(err); + + if (row) { + resolve({ publicKey: row.publicKey, privateKey: row.privateKey }); + } else { + const keys = this.generateVAPIDKeys(); + this.db.run('INSERT INTO vapid (publicKey, privateKey) VALUES (?, ?)', [keys.publicKey, keys.privateKey], (err) => { + if (err) reject(err); + resolve(keys); + }); + } + }); + }); + } + + async createVapidAuthHeader(endpoint: string, expiration: number, subject: string): Promise<{ 'Authorization': string, 'Crypto-Key': string }> { + const { publicKey, privateKey } = await this.getVapidKeys(); + + const jwtInfo = { + aud: new URL(endpoint).origin, + exp: Math.floor((Date.now() / 1000) + expiration), + sub: subject + }; + + const jwtToken = jwt.sign(jwtInfo, privateKey, { algorithm: 'ES256' }); + + return { + 'Authorization': `vapid t=${jwtToken}, k=${publicKey}`, + 'Crypto-Key': publicKey + }; + } +} diff --git a/src/webpush.ts b/src/webpush.ts index f4e0f95..147c866 100644 --- a/src/webpush.ts +++ b/src/webpush.ts @@ -1,82 +1,3 @@ -import * as http_ece from 'http_ece'; -import { Subscription } from './subscriptionService.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((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}` - }; - } }