2023-02-14 09:17:55 -05:00
|
|
|
const { promisify } = require('util')
|
2021-08-16 05:54:39 -04:00
|
|
|
const crypto = require('crypto')
|
2020-02-17 10:04:53 -05:00
|
|
|
|
2021-08-16 05:54:39 -04:00
|
|
|
const ALGORITHM = 'aes-256-ctr'
|
2020-02-17 10:17:53 -05:00
|
|
|
|
2023-02-14 09:17:55 -05:00
|
|
|
const cryptoHkdf = promisify(crypto.hkdf)
|
|
|
|
const cryptoRandomBytes = promisify(crypto.randomBytes)
|
2020-02-17 10:04:53 -05:00
|
|
|
|
2023-02-14 09:17:55 -05:00
|
|
|
class AbstractAccessTokenScheme {
|
|
|
|
constructor(cipherLabel, cipherPassword) {
|
|
|
|
this.cipherLabel = cipherLabel
|
|
|
|
this.cipherPassword = cipherPassword
|
|
|
|
}
|
2021-08-16 05:54:39 -04:00
|
|
|
|
2023-02-14 09:17:55 -05:00
|
|
|
/**
|
|
|
|
* @param {Object} json
|
|
|
|
* @return {Promise<string>}
|
|
|
|
*/
|
|
|
|
async encryptJson(json) {
|
|
|
|
throw new Error('encryptJson is not implemented')
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} encryptedJson
|
|
|
|
* @return {Promise<Object>}
|
|
|
|
*/
|
|
|
|
async decryptToJson(encryptedJson) {
|
|
|
|
throw new Error('decryptToJson is not implemented')
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class AccessTokenSchemeWithGenericKeyFn extends AbstractAccessTokenScheme {
|
|
|
|
/**
|
|
|
|
* @param {Buffer} salt
|
|
|
|
* @return {Promise<Buffer>}
|
|
|
|
*/
|
|
|
|
async keyFn(salt) {
|
|
|
|
throw new Error('keyFn is not implemented')
|
|
|
|
}
|
|
|
|
|
|
|
|
async encryptJson(json) {
|
|
|
|
const plainText = JSON.stringify(json)
|
|
|
|
|
|
|
|
const bytes = await cryptoRandomBytes(32)
|
|
|
|
const salt = bytes.slice(0, 16)
|
|
|
|
const iv = bytes.slice(16, 32)
|
|
|
|
const key = await this.keyFn(salt)
|
|
|
|
|
|
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
|
|
|
const cipherText =
|
|
|
|
cipher.update(plainText, 'utf8', 'base64') + cipher.final('base64')
|
|
|
|
|
|
|
|
return [
|
|
|
|
this.cipherLabel,
|
|
|
|
salt.toString('hex'),
|
|
|
|
cipherText,
|
|
|
|
iv.toString('hex'),
|
|
|
|
].join(':')
|
|
|
|
}
|
|
|
|
|
|
|
|
async decryptToJson(encryptedJson) {
|
|
|
|
const [, salt, cipherText, iv] = encryptedJson.split(':', 4)
|
|
|
|
const key = await this.keyFn(Buffer.from(salt, 'hex'))
|
|
|
|
|
|
|
|
const decipher = crypto.createDecipheriv(
|
|
|
|
ALGORITHM,
|
|
|
|
key,
|
|
|
|
Buffer.from(iv, 'hex')
|
|
|
|
)
|
|
|
|
const plainText =
|
|
|
|
decipher.update(cipherText, 'base64', 'utf8') + decipher.final('utf8')
|
|
|
|
try {
|
|
|
|
return JSON.parse(plainText)
|
|
|
|
} catch (e) {
|
|
|
|
throw new Error('error decrypting token')
|
2021-08-16 05:54:39 -04:00
|
|
|
}
|
|
|
|
}
|
2023-02-14 09:17:55 -05:00
|
|
|
}
|
2021-08-16 05:54:39 -04:00
|
|
|
|
2023-02-14 09:17:55 -05:00
|
|
|
class AccessTokenSchemeV3 extends AccessTokenSchemeWithGenericKeyFn {
|
|
|
|
async keyFn(salt) {
|
|
|
|
const optionalInfo = ''
|
|
|
|
return cryptoHkdf('sha512', this.cipherPassword, salt, optionalInfo, 32)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class AccessTokenEncryptor {
|
|
|
|
constructor(settings) {
|
|
|
|
this.schemeByCipherLabel = new Map()
|
|
|
|
for (const cipherLabel of Object.keys(settings.cipherPasswords)) {
|
|
|
|
if (!cipherLabel) {
|
|
|
|
throw new Error('cipherLabel cannot be empty')
|
2021-08-16 05:54:39 -04:00
|
|
|
}
|
2023-02-14 09:17:55 -05:00
|
|
|
if (cipherLabel.match(/:/)) {
|
|
|
|
throw new Error(
|
|
|
|
`cipherLabel must not contain a colon (:), got ${cipherLabel}`
|
2021-08-16 05:54:39 -04:00
|
|
|
)
|
2023-02-14 09:17:55 -05:00
|
|
|
}
|
2023-02-22 04:17:37 -05:00
|
|
|
const [, version] = cipherLabel.split('-')
|
2023-02-14 09:17:55 -05:00
|
|
|
if (!version) {
|
|
|
|
throw new Error(
|
|
|
|
`cipherLabel must contain version suffix (e.g. 2042.1-v42), got ${cipherLabel}`
|
|
|
|
)
|
|
|
|
}
|
2021-08-16 05:54:39 -04:00
|
|
|
|
2023-02-14 09:17:55 -05:00
|
|
|
const cipherPassword = settings.cipherPasswords[cipherLabel]
|
|
|
|
if (!cipherPassword) {
|
|
|
|
throw new Error(`cipherPasswords['${cipherLabel}'] is missing`)
|
|
|
|
}
|
|
|
|
if (cipherPassword.length < 16) {
|
|
|
|
throw new Error(`cipherPasswords['${cipherLabel}'] is too short`)
|
|
|
|
}
|
|
|
|
|
2023-02-22 04:17:37 -05:00
|
|
|
let scheme
|
2023-02-14 09:17:55 -05:00
|
|
|
switch (version) {
|
|
|
|
case 'v3':
|
|
|
|
scheme = new AccessTokenSchemeV3(cipherLabel, cipherPassword)
|
|
|
|
break
|
|
|
|
default:
|
|
|
|
throw new Error(`unknown version '${version}' for ${cipherLabel}`)
|
|
|
|
}
|
|
|
|
this.schemeByCipherLabel.set(cipherLabel, scheme)
|
2021-08-16 05:54:39 -04:00
|
|
|
}
|
2023-02-14 09:17:55 -05:00
|
|
|
|
|
|
|
this.defaultScheme = this.schemeByCipherLabel.get(settings.cipherLabel)
|
|
|
|
if (!this.defaultScheme) {
|
|
|
|
throw new Error(`unknown default cipherLabel ${settings.cipherLabel}`)
|
2021-08-16 05:54:39 -04:00
|
|
|
}
|
2023-02-14 09:17:55 -05:00
|
|
|
}
|
2021-08-16 05:54:39 -04:00
|
|
|
|
2023-02-14 09:17:55 -05:00
|
|
|
encryptJson(json, callback) {
|
|
|
|
this.defaultScheme.encryptJson(json).then(s => callback(null, s), callback)
|
|
|
|
}
|
2021-08-16 05:54:39 -04:00
|
|
|
|
2023-02-14 09:17:55 -05:00
|
|
|
decryptToJson(encryptedJson, callback) {
|
|
|
|
const [label] = encryptedJson.split(':', 1)
|
|
|
|
const scheme = this.schemeByCipherLabel.get(label)
|
|
|
|
if (!scheme) {
|
|
|
|
return callback(
|
|
|
|
new Error('unknown access-token-encryptor label ' + label)
|
2021-08-16 05:54:39 -04:00
|
|
|
)
|
2023-02-14 09:17:55 -05:00
|
|
|
}
|
|
|
|
scheme.decryptToJson(encryptedJson).then(o => callback(null, o), callback)
|
2021-08-16 05:54:39 -04:00
|
|
|
}
|
2020-02-17 10:04:53 -05:00
|
|
|
}
|
|
|
|
|
2021-08-16 05:54:39 -04:00
|
|
|
module.exports = AccessTokenEncryptor
|