-
Notifications
You must be signed in to change notification settings - Fork 309
/
Copy pathvapid-helper.js
243 lines (204 loc) · 7.25 KB
/
vapid-helper.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import crypto from 'crypto';
import asn1 from 'asn1.js';
import jws from 'jws';
import { URL } from 'url';
import WebPushConstants from './web-push-constants.js';
import * as urlBase64Helper from './urlsafe-base64-helper.js';
/**
* DEFAULT_EXPIRATION is set to seconds in 12 hours
*/
const DEFAULT_EXPIRATION_SECONDS = 12 * 60 * 60;
// Maximum expiration is 24 hours according. (See VAPID spec)
const MAX_EXPIRATION_SECONDS = 24 * 60 * 60;
const ECPrivateKeyASN = asn1.define('ECPrivateKey', function() {
this.seq().obj(
this.key('version').int(),
this.key('privateKey').octstr(),
this.key('parameters').explicit(0).objid()
.optional(),
this.key('publicKey').explicit(1).bitstr()
.optional()
);
});
function toPEM(key) {
return ECPrivateKeyASN.encode({
version: 1,
privateKey: key,
parameters: [1, 2, 840, 10045, 3, 1, 7] // prime256v1
}, 'pem', {
label: 'EC PRIVATE KEY'
});
}
export function generateVAPIDKeys() {
const curve = crypto.createECDH('prime256v1');
curve.generateKeys();
let publicKeyBuffer = curve.getPublicKey();
let privateKeyBuffer = curve.getPrivateKey();
// Occassionally the keys will not be padded to the correct lengh resulting
// in errors, hence this padding.
// See https://github.com/web-push-libs/web-push/issues/295 for history.
if (privateKeyBuffer.length < 32) {
const padding = Buffer.alloc(32 - privateKeyBuffer.length);
padding.fill(0);
privateKeyBuffer = Buffer.concat([padding, privateKeyBuffer]);
}
if (publicKeyBuffer.length < 65) {
const padding = Buffer.alloc(65 - publicKeyBuffer.length);
padding.fill(0);
publicKeyBuffer = Buffer.concat([padding, publicKeyBuffer]);
}
return {
publicKey: publicKeyBuffer.toString('base64url'),
privateKey: privateKeyBuffer.toString('base64url')
};
}
export function validateSubject(subject) {
if (!subject) {
throw new Error('No subject set in vapidDetails.subject.');
}
if (typeof subject !== 'string' || subject.length === 0) {
throw new Error('The subject value must be a string containing an https: URL or '
+ 'mailto: address. ' + subject);
}
let subjectParseResult = null;
try {
subjectParseResult = new URL(subject);
} catch (err) {
throw new Error('Vapid subject is not a valid URL. ' + subject);
}
if (!['https:', 'mailto:'].includes(subjectParseResult.protocol)) {
throw new Error('Vapid subject is not an https: or mailto: URL. ' + subject);
}
if (subjectParseResult.hostname === 'localhost') {
console.warn('Vapid subject points to a localhost web URI, which is unsupported by '
+ 'Apple\'s push notification server and will result in a BadJwtToken error when '
+ 'sending notifications.');
}
}
export function validatePublicKey(publicKey) {
if (!publicKey) {
throw new Error('No key set vapidDetails.publicKey');
}
if (typeof publicKey !== 'string') {
throw new Error('Vapid public key is must be a URL safe Base 64 '
+ 'encoded string.');
}
if (!urlBase64Helper.validate(publicKey)) {
throw new Error('Vapid public key must be a URL safe Base 64 (without "=")');
}
publicKey = Buffer.from(publicKey, 'base64url');
if (publicKey.length !== 65) {
throw new Error('Vapid public key should be 65 bytes long when decoded.');
}
}
export function validatePrivateKey(privateKey) {
if (!privateKey) {
throw new Error('No key set in vapidDetails.privateKey');
}
if (typeof privateKey !== 'string') {
throw new Error('Vapid private key must be a URL safe Base 64 '
+ 'encoded string.');
}
if (!urlBase64Helper.validate(privateKey)) {
throw new Error('Vapid private key must be a URL safe Base 64 (without "=")');
}
privateKey = Buffer.from(privateKey, 'base64url');
if (privateKey.length !== 32) {
throw new Error('Vapid private key should be 32 bytes long when decoded.');
}
}
/**
* Given the number of seconds calculates
* the expiration in the future by adding the passed `numSeconds`
* with the current seconds from Unix Epoch
*
* @param {Number} numSeconds Number of seconds to be added
* @return {Number} Future expiration in seconds
*/
export function getFutureExpirationTimestamp(numSeconds) {
const futureExp = new Date();
futureExp.setSeconds(futureExp.getSeconds() + numSeconds);
return Math.floor(futureExp.getTime() / 1000);
}
/**
* Validates the Expiration Header based on the VAPID Spec
* Throws error of type `Error` if the expiration is not validated
*
* @param {Number} expiration Expiration seconds from Epoch to be validated
*/
export function validateExpiration(expiration) {
if (!Number.isInteger(expiration)) {
throw new Error('`expiration` value must be a number');
}
if (expiration < 0) {
throw new Error('`expiration` must be a positive integer');
}
// Roughly checks the time of expiration, since the max expiration can be ahead
// of the time than at the moment the expiration was generated
const maxExpirationTimestamp = getFutureExpirationTimestamp(MAX_EXPIRATION_SECONDS);
if (expiration >= maxExpirationTimestamp) {
throw new Error('`expiration` value is greater than maximum of 24 hours');
}
}
/**
* This method takes the required VAPID parameters and returns the required
* header to be added to a Web Push Protocol Request.
* @param {string} audience This must be the origin of the push service.
* @param {string} subject This should be a URL or a 'mailto:' email
* address.
* @param {string} publicKey The VAPID public key.
* @param {string} privateKey The VAPID private key.
* @param {string} contentEncoding The contentEncoding type.
* @param {integer} [expiration] The expiration of the VAPID JWT.
* @return {Object} Returns an Object with the Authorization and
* 'Crypto-Key' values to be used as headers.
*/
export function getVapidHeaders(audience, subject, publicKey, privateKey, contentEncoding, expiration) {
if (!audience) {
throw new Error('No audience could be generated for VAPID.');
}
if (typeof audience !== 'string' || audience.length === 0) {
throw new Error('The audience value must be a string containing the '
+ 'origin of a push service. ' + audience);
}
try {
new URL(audience); // eslint-disable-line no-new
} catch (err) {
throw new Error('VAPID audience is not a url. ' + audience);
}
validateSubject(subject);
validatePublicKey(publicKey);
validatePrivateKey(privateKey);
privateKey = Buffer.from(privateKey, 'base64url');
if (expiration) {
validateExpiration(expiration);
} else {
expiration = getFutureExpirationTimestamp(DEFAULT_EXPIRATION_SECONDS);
}
const header = {
typ: 'JWT',
alg: 'ES256'
};
const jwtPayload = {
aud: audience,
exp: expiration,
sub: subject
};
const jwt = jws.sign({
header: header,
payload: jwtPayload,
privateKey: toPEM(privateKey)
});
if (contentEncoding === WebPushConstants.supportedContentEncodings.AES_128_GCM) {
return {
Authorization: 'vapid t=' + jwt + ', k=' + publicKey
};
}
if (contentEncoding === WebPushConstants.supportedContentEncodings.AES_GCM) {
return {
Authorization: 'WebPush ' + jwt,
'Crypto-Key': 'p256ecdsa=' + publicKey
};
}
throw new Error('Unsupported encoding type specified.');
}