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 }); |
|||
} |
|||
} |
|||
|
@ -1,68 +1,12 @@ |
|||
|
|||
import express from 'express'; |
|||
import bodyParser from 'body-parser'; |
|||
import { Request, Response } from 'express'; |
|||
import sqlite3 from 'sqlite3'; |
|||
import { open } from 'sqlite'; |
|||
import { WebPushSender } from './WebPushSender.js'; |
|||
|
|||
class PushService { |
|||
private app = express(); |
|||
private db: any; |
|||
private sender: WebPushSender; |
|||
private port: number; |
|||
|
|||
constructor(port: number, vapidPrivateKey: string) { |
|||
this.port = port; |
|||
this.sender = new WebPushSender(vapidPrivateKey); |
|||
this.app.use(bodyParser.json()); |
|||
|
|||
open({ filename: './subscriptions.db', driver: sqlite3.Database }).then((db) => { |
|||
this.db = db; |
|||
this.db.run('CREATE TABLE IF NOT EXISTS subscriptions (p256dh TEXT PRIMARY KEY, endpoint TEXT, auth TEXT)'); |
|||
this.setupRoutes(); |
|||
}); |
|||
} |
|||
|
|||
private setupRoutes() { |
|||
this.app.post('/subscribe', this.handleSubscribe.bind(this)); |
|||
} |
|||
|
|||
private async handleSubscribe(req: Request, res: Response) { |
|||
const { endpoint, keys } = req.body; |
|||
const { p256dh, auth } = keys; |
|||
|
|||
try { |
|||
await this.db.run('INSERT INTO subscriptions (p256dh, endpoint, auth) VALUES (?, ?, ?)', [p256dh, endpoint, auth]); |
|||
res.status(201).send('Subscription created successfully'); |
|||
} catch (err) { |
|||
res.status(400).send('Failed to create subscription'); |
|||
} |
|||
} |
|||
|
|||
public async sendNotification(p256dh: string, payload: string) { |
|||
const row = await this.db.get('SELECT * FROM subscriptions WHERE p256dh = ?', [p256dh]); |
|||
if (row) { |
|||
const subscription = { |
|||
endpoint: row.endpoint, |
|||
keys: { |
|||
p256dh: row.p256dh, |
|||
auth: row.auth, |
|||
}, |
|||
}; |
|||
import router from './routes.js'; |
|||
|
|||
return this.sender.sendPushNotification(subscription, payload); |
|||
} else { |
|||
throw new Error('Subscription not found'); |
|||
} |
|||
} |
|||
const app = express(); |
|||
|
|||
public start() { |
|||
this.app.listen(this.port, () => { |
|||
console.log(`Server running on http://localhost:${this.port}`); |
|||
}); |
|||
} |
|||
} |
|||
app.use(bodyParser.json()); |
|||
app.use(router); |
|||
|
|||
const pushService = new PushService(3000, 'YOUR_PRIVATE_VAPID_KEY'); |
|||
pushService.start(); |
|||
app.listen(3000, () => { |
|||
console.log('Server is running on port 3000'); |
|||
}); |
|||
|
@ -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