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/body-parser": "^1.19.2",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "~29.5",
|
"@types/jest": "~29.5",
|
||||||
|
"@types/jsonwebtoken": "^9.0.2",
|
||||||
"@types/node": "~18",
|
"@types/node": "~18",
|
||||||
"@types/sqlite3": "^3.1.8",
|
"@types/sqlite3": "^3.1.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
||||||
@@ -42,10 +43,14 @@
|
|||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"elliptic": "^6.5.4",
|
"elliptic": "^6.5.4",
|
||||||
"express": "^4.18.2",
|
"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",
|
"sqlite": "^5.0.1",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"tslib": "~2.6",
|
"tslib": "~2.6",
|
||||||
"web-push": "^3.6.4"
|
"typeorm": "^0.3.17"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "18.12.1"
|
"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 express from 'express';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import { Request, Response } from 'express';
|
import router from './routes.js';
|
||||||
import sqlite3 from 'sqlite3';
|
|
||||||
import { open } from 'sqlite';
|
|
||||||
import { WebPushSender } from './WebPushSender.js';
|
|
||||||
|
|
||||||
class PushService {
|
const app = express();
|
||||||
private app = express();
|
|
||||||
private db: any;
|
|
||||||
private sender: WebPushSender;
|
|
||||||
private port: number;
|
|
||||||
|
|
||||||
constructor(port: number, vapidPrivateKey: string) {
|
app.use(bodyParser.json());
|
||||||
this.port = port;
|
app.use(router);
|
||||||
this.sender = new WebPushSender(vapidPrivateKey);
|
|
||||||
this.app.use(bodyParser.json());
|
|
||||||
|
|
||||||
open({ filename: './subscriptions.db', driver: sqlite3.Database }).then((db) => {
|
app.listen(3000, () => {
|
||||||
this.db = db;
|
console.log('Server is running on port 3000');
|
||||||
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();
|
|
||||||
|
|||||||
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": {
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true,
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
"module": "node16",
|
"module": "node16",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
@@ -17,7 +18,9 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"noImplicitThis": false,
|
"noImplicitThis": false,
|
||||||
"strictNullChecks": false
|
"strictNullChecks": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "__tests__/**/*"]
|
"include": ["src/**/*", "__tests__/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"removeComments": true
|
"removeComments": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user