Skip to content

Commit

Permalink
feat(keystone): support for gcm mode. Configurable compression and ke…
Browse files Browse the repository at this point in the history
…y derivation
  • Loading branch information
YEgorLu committed Nov 25, 2024
1 parent eb7353d commit 03197c0
Show file tree
Hide file tree
Showing 9 changed files with 407 additions and 79 deletions.
65 changes: 47 additions & 18 deletions bin/re-encrypt-in-current-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const get = require('lodash/get')
const isNil = require('lodash/isNil')
const set = require('lodash/set')

const { ENCRYPTION_PREFIX } = require('@open-condo/keystone/crypto/EncryptionManager')
const { getLogger } = require('@open-condo/keystone/logging')
const { prepareKeystoneExpressApp } = require('@open-condo/keystone/prepareKeystoneApp')
const { getAppName } = require('@open-condo/keystone/tracingUtils')
Expand All @@ -30,10 +31,26 @@ function logState (state, indent = '') {
console.log(chalk.green(`${indent}Errors count: ${state.errorCount}`))
}

function getWhereCondition (encryptionManagers, currentVersionIdsByField) {
const where = { AND: [], OR: [] }
for (const field in currentVersionIdsByField) {
if (OPTIONS.fromVersions) {
for (const versionId of OPTIONS.fromVersions) {
if (encryptionManagers[field]._config[versionId]) {
where.OR.push({ [`${field}_starts_with`]: `${ENCRYPTION_PREFIX}:${Buffer.from(versionId).toString('hex')}:` })
}
}
} else {
where.AND.push({ [`${field}_not_starts_with`]: `${ENCRYPTION_PREFIX}:${currentVersionIdsByField[field]}:` } )
}
}
return where
}

/**
* @param {Keystone} keystone
* @param {List} list
* @param {SymmetricEncryptedTextImplementation[]} fields
* @param {EncryptedTextImplementation[]} fields
* */
async function processList (keystone, { list, fields }) {

Expand All @@ -44,10 +61,7 @@ async function processList (keystone, { list, fields }) {
currentVersionIdsByField[field.path] = Buffer.from(field.encryptionManager._encryptionVersionId).toString('hex')
}

const where = { AND: [] }
for (const field in currentVersionIdsByField) {
where.AND.push({ [`${field}_not_starts_with`]: `${currentVersionIdsByField[field]}:` } )
}
const where = getWhereCondition(encryptionManagers, currentVersionIdsByField)

const adapter = list.adapter

Expand All @@ -60,8 +74,8 @@ async function processList (keystone, { list, fields }) {
processedCount: 0,
errorCount: 0,
successCount: 0,
decryptErrors: {},
updateErrors: {},
decryptErrors: undefined,
updateErrors: undefined,
}

const logIndent = ' '
Expand Down Expand Up @@ -127,13 +141,13 @@ async function processList (keystone, { list, fields }) {

function getListsWithEncryptedFields (keystone) {
return keystone.listsArray
.map(list => ({ list, fields: list.fields.filter(field => field.constructor.name === 'SymmetricEncryptedTextImplementation') } ) )
.map(list => ({ list, fields: list.fields.filter(field => field.constructor.name === 'EncryptedTextImplementation') } ) )
.filter(({ fields }) => fields.length)
}

function getOptions () {
function parseOptions () {
program.parse()
let { all, include, exclude } = program.opts()
let { all, include, exclude, fromVersions } = program.opts()

if (all) {
if (!isNil(include)) {
Expand Down Expand Up @@ -164,11 +178,21 @@ function getOptions () {
}, {})
}

return { all, include: transformToSchema(include), exclude: transformToSchema(exclude) }
OPTIONS.all = all
OPTIONS.include = transformToSchema(include)
OPTIONS.exclude = transformToSchema(exclude)
OPTIONS.fromVersions = isNil(fromVersions) ? fromVersions : Array.isArray(fromVersions) ? fromVersions : [fromVersions]
}

const OPTIONS = {
all: undefined,
include: undefined,
exclude: undefined,
fromVersions: undefined,
}

function getDataToReEncrypt (keystone) {
const { all, include, exclude } = getOptions()
const { all, include, exclude } = OPTIONS
let filteredLists = getListsWithEncryptedFields(keystone)
if (all) {
if (exclude !== null) {
Expand Down Expand Up @@ -208,19 +232,20 @@ function logErrors () {
}
}

program.option('-a --all', 're encrypt all fields of SymmetricEncryptedText type in all lists', false)
program.option('-a --all', 're encrypt all fields of EncryptedText type in all lists', false)
program.option('-i --include <listKey>', `Collection of <listKey> divided by space.
If passed, only these lists will be re encrypted. Works only with --all = false`, null)
program.option('-e --exclude <listKey>', 'Same as --include, but works only with --app = true and determines\n ' +
'models which should not be modified', null)
program.description(`Re encrypts fields of type SymmetricEncryptedText with old secrets to new secrets
program.option('--from-versions <versions>', 'Collection of versoin ids, which you need to re encrypt. If not passed, all versions are re encrypted', null)
program.description(`Re encrypts fields of type EncryptedText with old secrets to new secrets
NOTE: it only touches data, which was encrypted in existing versions. If old version was forgotten, this script won't tell, or might error
If there is data under field SymmetricEncryptedText, which was not encrypted by field methods, script will skip it or will error
If there is data under field EncryptedText, which was not encrypted by field methods, script will skip it or will error
`)


async function main () {
program.parse()
parseOptions()
const index = path.resolve('./index.js')
const { keystone } = await prepareKeystoneExpressApp(index)

Expand All @@ -235,8 +260,12 @@ async function main () {
STATE.successCount += listState.successCount
STATE.processedCount += listState.processedCount
STATE.errorCount += listState.errorCount
set(STATE, `errors.${listFieldsPair.list.key}.decrypt`, listState.decryptErrors)
set(STATE, `errors.${listFieldsPair.list.key}.update`, listState.updateErrors)
if (listState.decryptErrors) {
set(STATE, `errors.${listFieldsPair.list.key}.decrypt`, listState.decryptErrors)
}
if (listState.updateErrors) {
set(STATE, `errors.${listFieldsPair.list.key}.update`, listState.updateErrors)
}
console.log(chalk.green('--------------------------'))
console.log(chalk.greenBright(`Processed lists: ${i}/${listsWithEncryptedFieldsToReEncrypt.length}`))
logState(STATE)
Expand Down
100 changes: 56 additions & 44 deletions packages/keystone/crypto/EncryptionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ const isEmpty = require('lodash/isEmpty')
const isNil = require('lodash/isNil')

const conf = require('@open-condo/config')
const { getLogger } = require('@open-condo/keystone/logging/getLogger')

const logger = getLogger('EncryptionManager')
const { compressors } = require('./compressors')
const { EncryptionVersion } = require('./EncryptionVersion')
const { keyDerivers } = require('./keyDerivers')

let DEFAULT_CONFIG
let DEFAULT_VERSION_ID
Expand All @@ -22,7 +23,7 @@ let DEFAULT_VERSION_ID
// since data is converted in hex, ':' shouldn't be in it
const SEPARATOR = ':'
const ENCRYPTION_PREFIX = ['\u{200B}', '\u{034F}', '\u{180C}', '\u{1D175}', '\u{E003B}', '\u{2800}'].join('')
const SUPPORTED_MODES = ['cbc', 'ctr', 'cfb', 'ofb']
const SUPPORTED_MODES = ['cbc', 'ctr', 'cfb', 'ofb', 'gcm']
const SUGGESTIONS = {
'cfb': 'Please, consider using "ctr" or "cbc"',
'ofb': 'Please, consider using "ctr" or "cbc"',
Expand All @@ -38,6 +39,8 @@ const SUGGESTIONS = {
* @typedef {Object} EncryptionManagerVersion
* @property {string} algorithm - crypto algorithm
* @property {string} secret - secret key
* @property {string} compressor
* @property {string} keyDeriver
*/

/**
Expand Down Expand Up @@ -74,6 +77,7 @@ const SUGGESTIONS = {
class EncryptionManager {

_encryptionVersionId
/** @type {Record<string, EncryptionVersion>} */
_config = {}

/**
Expand All @@ -94,52 +98,55 @@ class EncryptionManager {
* @returns {string}
*/
encrypt (data) {
const { algorithm, ivLength, secret } = this._config[this._encryptionVersionId]
const iv = crypto.randomBytes(ivLength)

const cipheriv = crypto.createCipheriv(algorithm, secret, iv)
const encryptedValue = Buffer.concat([cipheriv.update(data), cipheriv.final()])
const version = this._config[this._encryptionVersionId]
return [
ENCRYPTION_PREFIX,
Buffer.from(this._encryptionVersionId).toString('hex'),
encryptedValue.toString('hex'),
iv.toString('hex'),
version.encrypt(data),
].join(SEPARATOR)
// const { algorithm, ivLength, secret } = this._config[this._encryptionVersionId]
// const iv = crypto.randomBytes(ivLength)
//
// const cipheriv = crypto.createCipheriv(algorithm, secret, iv)
// const encryptedValue = Buffer.concat([cipheriv.update(data), cipheriv.final()])
// return [
// ENCRYPTION_PREFIX,
// Buffer.from(this._encryptionVersionId).toString('hex'),
// encryptedValue.toString('hex'),
// iv.toString('hex'),
// ].join(SEPARATOR)
}

/** @param {string} encrypted
* @returns {string | null}
*/
decrypt (encrypted) {
if (typeof encrypted !== 'string') {
return null
throw new Error(`Invalid encrypted data, expected of type "string", received ${typeof encrypted}`)
}
const parts = encrypted.split(SEPARATOR)
if (parts.length < 3) {
return null
throw new Error('Invalid format of encrypted data')
}

const [encryptionPrefix, versionIdHex, encryptedPayload] = parts
if (encryptionPrefix !== ENCRYPTION_PREFIX) {
throw new Error('Invalid encrypted data. It was not encrypted with EncryptionManager')
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, versionIdHex, encodedHex, ivHex] = parts
const versionId = Buffer.from(versionIdHex, 'hex').toString()
const version = this._config[versionId]
if (isNil(version)) {
logger.error({ msg: 'Received version id, which is not present in versions', data: { versionId } })
return null
throw new Error('Invalid encrypted data. Versions id is not present in EncryptionManager')
}
const { algorithm, secret } = version
const decipheriv = crypto.createDecipheriv(algorithm, secret, Buffer.from(ivHex, 'hex'))
const decrypted = Buffer.concat([decipheriv.update(Buffer.from(encodedHex, 'hex')), decipheriv.final()])

return decrypted.toString()
return version.decrypt(encryptedPayload)
}

/**
* Is given string encrypted with one of versions of this instance. Can not check for secrets and algorithm
* @param {string} str
* @param {string?} versionId - validate specific version
* @returns {boolean}
*/
isEncrypted (str, versionId) {
isEncrypted (str) {
if (typeof str !== 'string') {
return false
}
Expand All @@ -151,9 +158,6 @@ class EncryptionManager {
return false
}
const decodedFromHexVersion = Buffer.from(parts[1], 'hex').toString()
if (!isNil(versionId)) {
return decodedFromHexVersion === versionId
}
return !!this._config[decodedFromHexVersion]
}

Expand All @@ -164,12 +168,12 @@ class EncryptionManager {
return
}

const defaultVersionsJSON = conf.DEFAULT_KEYSTONE_SYMMETRIC_ENCRYPTION_CONFIG
const defaultVersionsJSON = conf.DEFAULT_KEYSTONE_ENCRYPTION_CONFIG
if (isNil(defaultVersionsJSON)) {
throw new Error('env DEFAULT_KEYSTONE_SYMMETRIC_ENCRYPTION_CONFIG is not present')
}
const defaultVersions = JSON.parse(defaultVersionsJSON)
this._encryptionVersionId = isNil(overrideEncryptionVersionId) ? conf.DEFAULT_KEYSTONE_SYMMETRIC_ENCRYPTION_VERSION_ID : overrideEncryptionVersionId
this._encryptionVersionId = isNil(overrideEncryptionVersionId) ? conf.DEFAULT_KEYSTONE_ENCRYPTION_VERSION_ID : overrideEncryptionVersionId

this._validateVersions(defaultVersions)
this._config = this._parseVersions(defaultVersions)
Expand Down Expand Up @@ -214,27 +218,30 @@ class EncryptionManager {
_parseVersions (versions) {
const parsedConfig = {}
for (const versionId in versions) {
const { algorithm, secret } = versions[versionId]
const { ivLength } = crypto.getCipherInfo(algorithm)
parsedConfig[versionId] = {
const { algorithm, secret, keyDeriver = 'noop', compressor = 'noop' } = versions[versionId]
parsedConfig[versionId] = new EncryptionVersion({
id: versionId,
algorithm,
secret,
ivLength: ivLength || 0,
}
compressor: compressors[compressor],
keyDeriver: keyDerivers[keyDeriver],
})
}
return parsedConfig
}

_validateVersions (versions) {
for (const versionId in versions) {
if (versionId.includes(SEPARATOR)) {
throw new Error(`You should not put "${SEPARATOR}" in version id (key in object of versions), received ${versionId}`)
}
if (versionId.length === 0) {
throw new Error('Invalid version id. Empty string is forbidden')
}

const { algorithm, secret } = versions[versionId]
const {
algorithm,
secret,
keyDeriver = 'noop',
compressor = 'noop',
} = versions[versionId]
const cipherInfo = crypto.getCipherInfo(algorithm)
if (!cipherInfo) {
throw new Error(`Invalid algorithm at ${versionId}.algorithm`)
Expand All @@ -245,17 +252,22 @@ class EncryptionManager {
if (SUGGESTIONS[cipherInfo.mode]) {
console.warn(`${SUGGESTIONS[cipherInfo.mode]} at ${versionId}.algorithm`)
}

const keyLength = cipherInfo.keyLength

if (typeof secret !== 'string' || isEmpty(secret)) {
throw new Error(`Secret must be a non empty string at ${versionId}.secret`)
if ((typeof secret !== 'string' && !(secret instanceof Buffer)) || isEmpty(secret)) {
throw new Error(`Secret must be a non empty string or buffer at ${versionId}.secret`)
}
if (secret.length !== keyLength) {
throw new Error(`Secret for algorithm ${algorithm} must have length ${keyLength}, received ${secret.length} at ${versionId}.secret`)

if (!compressors[compressor]) {
throw new Error(`Invalid compressor ${compressor} at ${versionId}.compressor. Register it with "registerCompressor" or use another`)
}
if (!keyDerivers[keyDeriver]) {
throw new Error(`Invalid key deriver ${keyDeriver} at ${versionId}.keyDeriver. Register it with "registerKeyDeriver" or use another`)
}
if (!crypto.getCipherInfo(algorithm, { keyLength: secret.length })) {
throw new Error(`For some reason crypto does not accept ${algorithm} with secret of length ${secret.length}, debug why at ${versionId}.secret`)

if (keyDeriver === 'noop' && !crypto.getCipherInfo(algorithm, { keyLength: secret.length })) {
throw new Error(`Invalid secret key length for ${algorithm} with secret of length ${secret.length}, required ${keyLength}. Change secret key or provide keyDeriver at ${versionId}.secret`)
}
}
}
Expand Down
Loading

0 comments on commit 03197c0

Please sign in to comment.