Browse Source

Rewrite reducing crypto complexity

pull/1/head
Matthew Raymer 1 year ago
parent
commit
4ac17a24e9
  1. 7110
      package-lock.json
  2. 7
      package.json
  3. 66
      src/WebPushSender.ts
  4. 68
      src/main.ts
  5. 28
      src/routes.ts
  6. 18
      src/subscriptions.ts
  7. 82
      src/webpush.ts
  8. 5
      tsconfig.json
  9. 3
      tsconfig.release.json

7110
package-lock.json

File diff suppressed because it is too large

7
package.json

@ -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"

66
src/WebPushSender.ts

@ -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 });
}
}

68
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

@ -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

@ -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

@ -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}`
};
}
}

5
tsconfig.json

@ -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__/**/*"]
} }

3
tsconfig.release.json

@ -4,5 +4,6 @@
"sourceMap": false, "sourceMap": false,
"removeComments": true "removeComments": true
}, },
"include": ["src/**/*"] "include": ["src/**/*"],
"exclude": ["node_modules"]
} }

Loading…
Cancel
Save