vapid-helper.js 7.6 KB

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