Skip to content

Commit

Permalink
feat(condo): DOMA-10642 add prefix to determine, that string was encr…
Browse files Browse the repository at this point in the history
…ypted by manager; Do not encrypt field before database, if it is encrypted; Enhance script; Rename field;
  • Loading branch information
YEgorLu committed Nov 22, 2024
1 parent 73cf05d commit eb7353d
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 65 deletions.
4 changes: 2 additions & 2 deletions packages/keystone/KSv5v6/v5/registerSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const {
Select,
SignedDecimal,
Text,
SymmetricEncryptedText,
EncryptedText,
} = require('../../fields')
const { HiddenRelationship } = require('../../plugins/utils/HiddenRelationship')
const { AuthedRelationship, Relationship } = require('../../plugins/utils/Relationship')
Expand Down Expand Up @@ -84,7 +84,7 @@ function convertStringToTypes (schema) {
Select,
SignedDecimal,
Text,
SymmetricEncryptedText,
EncryptedText,
}
const allTypesForPrint = Object.keys(mapping).map(item => `"${item}"`).join(', ')

Expand Down
49 changes: 46 additions & 3 deletions packages/keystone/crypto/EncryptionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ const logger = getLogger('EncryptionManager')
let DEFAULT_CONFIG
let DEFAULT_VERSION_ID

// INVISIBLE / WHITESPACE CHARACTERS
// \u{200B} - ZERO WIDTH SPACE
// \u{034F} - COMBINING GRAPHEME JOINER
// \u{180C} - MONGOLIAN FREE VARIATION SELECTOR TWO
// \u{1D175} - MUSICAL SYMBOL BEGIN TIE
// \u{E003B} - TAG SEMICOLON
// \u{2800} - BRAILLE PATTERN BLANK

// 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 SUGGESTIONS = {
'cfb': 'Please, consider using "ctr" or "cbc"',
Expand Down Expand Up @@ -92,18 +100,26 @@ class EncryptionManager {
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) {
let [versionIdHex, encodedHex, ivHex] = encrypted.split(SEPARATOR)
if (typeof encrypted !== 'string') {
return null
}
const parts = encrypted.split(SEPARATOR)
if (parts.length < 3) {
return null
}
// 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)) {
Expand All @@ -117,6 +133,30 @@ class EncryptionManager {
return decrypted.toString()
}

/**
* 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) {
if (typeof str !== 'string') {
return false
}
const parts = str.split(SEPARATOR)
if (parts.length < 3) {
return false
}
if (parts[0] !== ENCRYPTION_PREFIX) {
return false
}
const decodedFromHexVersion = Buffer.from(parts[1], 'hex').toString()
if (!isNil(versionId)) {
return decodedFromHexVersion === versionId
}
return !!this._config[decodedFromHexVersion]
}

_initializeDefaults (overrideEncryptionVersionId) {
if (!isNil(DEFAULT_CONFIG) && !isNil(DEFAULT_VERSION_ID)) {
this._config = DEFAULT_CONFIG
Expand Down Expand Up @@ -190,7 +230,9 @@ class EncryptionManager {
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 cipherInfo = crypto.getCipherInfo(algorithm)
Expand Down Expand Up @@ -222,4 +264,5 @@ class EncryptionManager {
module.exports = {
EncryptionManager,
SUPPORTED_MODES,
ENCRYPTION_PREFIX,
}
94 changes: 93 additions & 1 deletion packages/keystone/crypto/EncryptionManager.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('EncryptionManager', () => {
const initialString = faker.random.alphaNumeric(20)
const encrypted = manager.encrypt(initialString)
expect(encrypted).not.toEqual(initialString)
expect(encrypted.split(':')).toHaveLength(3)
expect(encrypted.split(':')).toHaveLength(4)

const decrypted = manager.decrypt(encrypted)
expect(decrypted).toEqual(initialString)
Expand Down Expand Up @@ -106,5 +106,97 @@ describe('EncryptionManager', () => {
expect(decrypted).toEqual(exampleString)
})
})

describe('Unsuccessful decryption', () => {
const manager = new EncryptionManager({
versions: {
'1': { algorithm: 'aes-256-cbc', secret: faker.random.alphaNumeric(32) },
},
encryptionVersionId: '1',
})

const nonStrings = [
[],
{},
null,
undefined,
new Date(),
123,
Symbol(),
]

test.each(nonStrings)('Passed non string %p', (nonString) => {
expect(manager.decrypt(nonString)).toBeNull()
})

test('Is not encrypted', () => {
const notEncrypted = faker.random.alphaNumeric(10)
expect(manager.decrypt(notEncrypted)).toBeNull()
})

test('Is encrypted in version, which not provided', () => {
const anotherManager = new EncryptionManager({
versions: { '2': { algorithm: 'aes-256-cbc', secret: faker.random.alphaNumeric(32) } },
encryptionVersionId: '2',
})
const exampleString = faker.random.alphaNumeric(10)
const encryptedString = anotherManager.encrypt(exampleString)
expect(manager.decrypt(encryptedString)).toBeNull()
})

test('Versions are same, but secret or algorithm differs', () => {
const managerAnotherAlgorithm = new EncryptionManager({
versions: { '1': { algorithm: 'aes-128-cbc', secret: faker.random.alphaNumeric(16) } },
encryptionVersionId: '1',
})
const managerAnotherSecret = new EncryptionManager({
versions: { '1': { algorithm: 'aes-256-cbc', secret: faker.random.alphaNumeric(32) } },
encryptionVersionId: '1',
})
const exampleValue = faker.random.alphaNumeric(10)
const encryptedWithDifferentAlgorithm = managerAnotherAlgorithm.encrypt(exampleValue)
const encryptedWithDifferentSecret = managerAnotherSecret.encrypt(exampleValue)

expect(() => manager.decrypt(encryptedWithDifferentAlgorithm)).toThrow()
expect(() => manager.decrypt(encryptedWithDifferentSecret)).toThrow()
})
})

describe('isEncrypted', () => {
const manager = new EncryptionManager({
versions: {
'1': { algorithm: 'aes-256-cbc', secret: faker.random.alphaNumeric(32) },
},
encryptionVersionId: '1',
})

test('Checks, that value was encrypted with one of provided versions', () => {
const exampleValue = faker.random.alphaNumeric(10)
const encrypted = manager.encrypt(exampleValue)

const anotherManager = new EncryptionManager({
versions: { '2': { algorithm: 'aes-256-cbc', secret: faker.random.alphaNumeric(32) } },
encryptionVersionId: '2',
})
const anotherEncrypted = anotherManager.encrypt(exampleValue)

expect(manager.isEncrypted(encrypted)).toBe(true)
expect(manager.isEncrypted(exampleValue)).toBe(false)
expect(manager.isEncrypted(anotherEncrypted)).toBe(false)
})

test('Checks, that value was encrypted in specific version', () => {
const exampleValue = faker.random.alphaNumeric(10)
const encrypted = manager.encrypt(exampleValue)

const anotherManager = new EncryptionManager({
versions: { '2': { algorithm: 'aes-256-cbc', secret: faker.random.alphaNumeric(32) } },
encryptionVersionId: '2',
})
const anotherEncrypted = anotherManager.encrypt(exampleValue)

expect(manager.isEncrypted(encrypted, '1')).toBe(true)
expect(manager.isEncrypted(anotherEncrypted, '2')).toBe(true)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ function _getEncryptionManager (errorStart, options) {
return manager
}

function _getErrorStart (listKey, path) { return `${listKey}.${path}: SymmetricEncryptedText field` }
function _getErrorStart (listKey, path) { return `${listKey}.${path}: EncryptedText field` }


class SymmetricEncryptedTextImplementation extends Text.implementation {
class EncryptedTextImplementation extends Text.implementation {

/** @type {EncryptionManager} */
encryptionManager
Expand All @@ -50,5 +50,5 @@ class SymmetricEncryptedTextImplementation extends Text.implementation {
}

module.exports = {
SymmetricEncryptedTextImplementation,
EncryptedTextImplementation,
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SymmetricEncryptedText
# EncryptedText

`SymmetricEncryptedText` field simplifies work with encrypted data. It automatically encrypts text before storing in
`EncryptedText` field simplifies work with encrypted data. It automatically encrypts text before storing in
database and allows you to store data encrypted with different versions of keys.

## Basic usage
Expand All @@ -11,8 +11,8 @@ const conf = require('@open-condo/config')

keystone.createList('User', {
fields: {
email: { type: Text },
nonPublicData: { type: 'SymmetricEncryptedText' },
email: { type: 'Text' },
nonPublicData: { type: 'EncryptedText' },
},
});
```
Expand All @@ -32,7 +32,7 @@ keystone.createList('User', {
fields: {
email: { type: 'Text' },
nonPublicData: {
type: 'SymmetricEncryptedText',
type: 'EncryptedText',
encryptionManager: UserCrypto.nonPublicData,
hooks: {
resolveInput({ existingItem }) {
Expand Down Expand Up @@ -67,7 +67,7 @@ keystone.createList('User', {
fields: {
email: {type: 'Text'},
nonPublicData: {
type: 'SymmetricEncryptedText',
type: 'EncryptedText',
encryptionManager: UserCrypto.nonPublicData,
},
}
Expand All @@ -87,13 +87,13 @@ encrypted with old keys / algorithms. New data is being stored with CipherManage

## GraphQL

`SymmetricEncryptedText` fields behave as strings (`Text` field). On create / update operations input value will be
`EncryptedText` fields behave as strings (`Text` field). On create / update operations input value will be
encrypted before storing in database and returning in response. For read operations field expects encrypted value.

## Storage

The value stored is string containing provided version, iv and, encrypted with provided algorithm and secret, provided value.
#### Example: \<version from CipherManager\>:\<crypted data\>:\<iv or other service info\>
#### Example: \<our string to mark text encrypted\>:\<version from CipherManager\>:\<crypted data\>:\<iv or other service info\>

## Service information

Expand Down
38 changes: 38 additions & 0 deletions packages/keystone/fields/EncryptedText/adapters/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const { Text } = require('@keystonejs/fields')
const get = require('lodash/get')
const isNil = require('lodash/isNil')

const CommonInterface = superclass => class extends superclass {

/** @type {EncryptionManager} */
encryptionManager

constructor () {
super(...arguments)
this.encryptionManager = this.config.encryptionManager
}

setupHooks ({ addPreSaveHook }) {

addPreSaveHook(item => {
const fieldIsDefined = !isNil(item) && !isNil(item[this.path])
const fieldIsEncrypted = this.encryptionManager.isEncrypted(get(item, this.path))
if (fieldIsDefined && !fieldIsEncrypted) {
item[this.path] = this.encryptionManager.encrypt(item[this.path])
}
return item
})

}

}

class EncryptedTextKnexFieldAdapter extends CommonInterface(Text.adapters.knex) {}
class EncryptedTextMongooseFieldAdapter extends CommonInterface(Text.adapters.mongoose) {}
class EncryptedTextPrismaFieldAdapter extends CommonInterface(Text.adapters.prisma) {}

module.exports = {
mongoose: EncryptedTextMongooseFieldAdapter,
knex: EncryptedTextKnexFieldAdapter,
prisma: EncryptedTextPrismaFieldAdapter,
}
11 changes: 11 additions & 0 deletions packages/keystone/fields/EncryptedText/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const { Text } = require('@keystonejs/fields')

const EncryptedTextAdapters = require('./adapters')
const { EncryptedTextImplementation } = require('./Implementation')

module.exports = {
type: 'EncryptedText',
implementation: EncryptedTextImplementation,
views: Text.views,
adapters: EncryptedTextAdapters,
}
35 changes: 0 additions & 35 deletions packages/keystone/fields/SymmetricEncryptedText/adapters/index.js

This file was deleted.

11 changes: 0 additions & 11 deletions packages/keystone/fields/SymmetricEncryptedText/index.js

This file was deleted.

Loading

0 comments on commit eb7353d

Please sign in to comment.