Rewrite reducing crypto complexity
This commit is contained in:
6956
package-lock.json
generated
6956
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
"@types/body-parser": "^1.19.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "~29.5",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/node": "~18",
|
||||
"@types/sqlite3": "^3.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
||||
@@ -42,10 +43,14 @@
|
||||
"body-parser": "^1.20.2",
|
||||
"elliptic": "^6.5.4",
|
||||
"express": "^4.18.2",
|
||||
"http_ece": "^1.1.0",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sqlite": "^5.0.1",
|
||||
"sqlite3": "^5.1.6",
|
||||
"tslib": "~2.6",
|
||||
"web-push": "^3.6.4"
|
||||
"typeorm": "^0.3.17"
|
||||
},
|
||||
"volta": {
|
||||
"node": "18.12.1"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
70
src/main.ts
70
src/main.ts
@@ -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';
|
||||
import router from './routes.js';
|
||||
|
||||
class PushService {
|
||||
private app = express();
|
||||
private db: any;
|
||||
private sender: WebPushSender;
|
||||
private port: number;
|
||||
const app = express();
|
||||
|
||||
constructor(port: number, vapidPrivateKey: string) {
|
||||
this.port = port;
|
||||
this.sender = new WebPushSender(vapidPrivateKey);
|
||||
this.app.use(bodyParser.json());
|
||||
app.use(bodyParser.json());
|
||||
app.use(router);
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
return this.sender.sendPushNotification(subscription, payload);
|
||||
} else {
|
||||
throw new Error('Subscription not found');
|
||||
}
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.app.listen(this.port, () => {
|
||||
console.log(`Server running on http://localhost:${this.port}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pushService = new PushService(3000, 'YOUR_PRIVATE_VAPID_KEY');
|
||||
pushService.start();
|
||||
app.listen(3000, () => {
|
||||
console.log('Server is running on port 3000');
|
||||
});
|
||||
|
||||
28
src/routes.ts
Normal file
28
src/routes.ts
Normal file
@@ -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;
|
||||
18
src/subscriptions.ts
Normal file
18
src/subscriptions.ts
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
82
src/webpush.ts
Normal file
82
src/webpush.ts
Normal file
@@ -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}`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"target": "es2022",
|
||||
"module": "node16",
|
||||
"lib": ["ES2022"],
|
||||
@@ -17,7 +18,9 @@
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitThis": false,
|
||||
"strictNullChecks": false
|
||||
"strictNullChecks": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*", "__tests__/**/*"]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"sourceMap": false,
|
||||
"removeComments": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user