AccessTokenEncryptor.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. const { promisify } = require('util')
  2. const crypto = require('crypto')
  3. const ALGORITHM = 'aes-256-ctr'
  4. const cryptoHkdf = promisify(crypto.hkdf)
  5. const cryptoPbkdf2 = promisify(crypto.pbkdf2)
  6. const cryptoRandomBytes = promisify(crypto.randomBytes)
  7. class AbstractAccessTokenScheme {
  8. constructor(cipherLabel, cipherPassword) {
  9. this.cipherLabel = cipherLabel
  10. this.cipherPassword = cipherPassword
  11. }
  12. /**
  13. * @param {Object} json
  14. * @return {Promise<string>}
  15. */
  16. async encryptJson(json) {
  17. throw new Error('encryptJson is not implemented')
  18. }
  19. /**
  20. * @param {string} encryptedJson
  21. * @return {Promise<Object>}
  22. */
  23. async decryptToJson(encryptedJson) {
  24. throw new Error('decryptToJson is not implemented')
  25. }
  26. }
  27. class AccessTokenSchemeWithGenericKeyFn extends AbstractAccessTokenScheme {
  28. /**
  29. * @param {Buffer} salt
  30. * @return {Promise<Buffer>}
  31. */
  32. async keyFn(salt) {
  33. throw new Error('keyFn is not implemented')
  34. }
  35. async encryptJson(json) {
  36. const plainText = JSON.stringify(json)
  37. const bytes = await cryptoRandomBytes(32)
  38. const salt = bytes.slice(0, 16)
  39. const iv = bytes.slice(16, 32)
  40. const key = await this.keyFn(salt)
  41. const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
  42. const cipherText =
  43. cipher.update(plainText, 'utf8', 'base64') + cipher.final('base64')
  44. return [
  45. this.cipherLabel,
  46. salt.toString('hex'),
  47. cipherText,
  48. iv.toString('hex'),
  49. ].join(':')
  50. }
  51. async decryptToJson(encryptedJson) {
  52. const [, salt, cipherText, iv] = encryptedJson.split(':', 4)
  53. const key = await this.keyFn(Buffer.from(salt, 'hex'))
  54. const decipher = crypto.createDecipheriv(
  55. ALGORITHM,
  56. key,
  57. Buffer.from(iv, 'hex')
  58. )
  59. const plainText =
  60. decipher.update(cipherText, 'base64', 'utf8') + decipher.final('utf8')
  61. try {
  62. return JSON.parse(plainText)
  63. } catch (e) {
  64. throw new Error('error decrypting token')
  65. }
  66. }
  67. }
  68. class AccessTokenSchemeV2 extends AccessTokenSchemeWithGenericKeyFn {
  69. async keyFn(salt) {
  70. return cryptoPbkdf2(this.cipherPassword, salt, 10000, 32, 'sha1')
  71. }
  72. }
  73. class AccessTokenSchemeV3 extends AccessTokenSchemeWithGenericKeyFn {
  74. async keyFn(salt) {
  75. const optionalInfo = ''
  76. return cryptoHkdf('sha512', this.cipherPassword, salt, optionalInfo, 32)
  77. }
  78. }
  79. class AccessTokenEncryptor {
  80. constructor(settings) {
  81. this.schemeByCipherLabel = new Map()
  82. for (const cipherLabel of Object.keys(settings.cipherPasswords)) {
  83. if (!cipherLabel) {
  84. throw new Error('cipherLabel cannot be empty')
  85. }
  86. if (cipherLabel.match(/:/)) {
  87. throw new Error(
  88. `cipherLabel must not contain a colon (:), got ${cipherLabel}`
  89. )
  90. }
  91. const [cipherLabelNoVersion, version] = cipherLabel.split('-')
  92. if (!version) {
  93. throw new Error(
  94. `cipherLabel must contain version suffix (e.g. 2042.1-v42), got ${cipherLabel}`
  95. )
  96. }
  97. const cipherPassword = settings.cipherPasswords[cipherLabel]
  98. if (!cipherPassword) {
  99. throw new Error(`cipherPasswords['${cipherLabel}'] is missing`)
  100. }
  101. if (cipherPassword.length < 16) {
  102. throw new Error(`cipherPasswords['${cipherLabel}'] is too short`)
  103. }
  104. let scheme, schemeNoVersion
  105. switch (version) {
  106. case 'v2':
  107. scheme = new AccessTokenSchemeV2(cipherLabel, cipherPassword)
  108. schemeNoVersion = new AccessTokenSchemeV2(
  109. cipherLabelNoVersion,
  110. cipherPassword
  111. )
  112. break
  113. case 'v3':
  114. scheme = new AccessTokenSchemeV3(cipherLabel, cipherPassword)
  115. schemeNoVersion = new AccessTokenSchemeV3(
  116. cipherLabelNoVersion,
  117. cipherPassword
  118. )
  119. break
  120. default:
  121. throw new Error(`unknown version '${version}' for ${cipherLabel}`)
  122. }
  123. this.schemeByCipherLabel.set(cipherLabel, scheme)
  124. this.schemeByCipherLabel.set(cipherLabelNoVersion, schemeNoVersion)
  125. }
  126. this.defaultScheme = this.schemeByCipherLabel.get(settings.cipherLabel)
  127. if (!this.defaultScheme) {
  128. throw new Error(`unknown default cipherLabel ${settings.cipherLabel}`)
  129. }
  130. }
  131. encryptJson(json, callback) {
  132. this.defaultScheme.encryptJson(json).then(s => callback(null, s), callback)
  133. }
  134. decryptToJson(encryptedJson, callback) {
  135. const [label] = encryptedJson.split(':', 1)
  136. const scheme = this.schemeByCipherLabel.get(label)
  137. if (!scheme) {
  138. return callback(
  139. new Error('unknown access-token-encryptor label ' + label)
  140. )
  141. }
  142. scheme.decryptToJson(encryptedJson).then(o => callback(null, o), callback)
  143. }
  144. }
  145. module.exports = AccessTokenEncryptor