AccessTokenEncryptor.js 4.3 KB

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