-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
7fd235e
commit 648f433
Showing
14 changed files
with
1,049 additions
and
13 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import config from 'config'; | ||
import crypto from 'crypto'; | ||
|
||
// GCM mode is good for situations with random access and authenticity requirements | ||
// CBC mode is older, but is sufficiently secure with high performance for short payloads | ||
const algorithm = 'aes-256-cbc'; | ||
const encoding = 'base64'; | ||
const encodingCheck = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}={2})$/; | ||
const hashAlgorithm = 'sha256'; | ||
|
||
/** | ||
* @function encrypt | ||
* Yields an encrypted string containing the iv and ciphertext, separated by a colon. | ||
* If no key is provided, ciphertext will be the plaintext in base64 encoding. | ||
* @param {string} text The input string contents | ||
* @returns {string} The encrypted base64 formatted string in the format `iv:ciphertext`. | ||
*/ | ||
export function encrypt(text: string) { | ||
if (isEncrypted(text)) return text; | ||
|
||
const passphrase = config.has('server.passphrase') ? (config.get('server.passphrase') as string) : undefined; | ||
if (passphrase && passphrase.length) { | ||
let content = Buffer.from(text); | ||
const iv = crypto.randomBytes(16); | ||
const hash = crypto.createHash(hashAlgorithm); | ||
// AES-256 key length must be exactly 32 bytes | ||
const key = hash.update(passphrase).digest().subarray(0, 32); | ||
const cipher = crypto.createCipheriv(algorithm, key, iv); | ||
content = Buffer.concat([cipher.update(text), cipher.final()]); | ||
return `${iv.toString(encoding)}:${content.toString(encoding)}`; | ||
} else { | ||
return text; | ||
} | ||
} | ||
|
||
/** | ||
* @function decrypt | ||
* Yields the plaintext by accepting an encrypted string containing the iv and | ||
* ciphertext, separated by a colon. If no key is provided, the plaintext will be | ||
* the ciphertext. | ||
* @param {string} text The input encrypted string contents | ||
* @returns {string} The decrypted plaintext string, usually in utf-8 | ||
*/ | ||
export function decrypt(text: string) { | ||
if (!isEncrypted(text)) return text; | ||
|
||
const passphrase = config.has('server.passphrase') ? (config.get('server.passphrase') as string) : undefined; | ||
if (passphrase && passphrase.length) { | ||
const [iv, encrypted] = text.split(':').map((p) => Buffer.from(p, encoding)); | ||
let content = encrypted; | ||
const hash = crypto.createHash(hashAlgorithm); | ||
// AES-256 key length must be exactly 32 bytes | ||
const key = hash.update(passphrase).digest().subarray(0, 32); | ||
const decipher = crypto.createDecipheriv(algorithm, key, iv); | ||
content = Buffer.concat([decipher.update(encrypted), decipher.final()]); | ||
return Buffer.from(content, encoding).toString(); | ||
} else { | ||
return text; | ||
} | ||
} | ||
|
||
/** | ||
* @function isEncrypted | ||
* A predicate function for determining if the input text is encrypted | ||
* @param {string} text The input string contents | ||
* @returns {boolean} True if encrypted, false if not | ||
*/ | ||
export function isEncrypted(text: string) { | ||
if (!text) return false; | ||
if (typeof text !== 'string') return false; | ||
const textParts = text.split(':'); | ||
return ( | ||
textParts.length == 2 && | ||
textParts[0] && | ||
textParts[1] && | ||
textParts[0].length === 24 && // Base64 encoding of a 16 byte IV should be 24 | ||
encodingCheck.test(textParts[0]) && | ||
encodingCheck.test(textParts[1]) | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
[*.js] | ||
indent_size = unset |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
import { Knex, knex } from 'knex'; | ||
import { Model } from 'objection'; | ||
|
||
import { getLogger } from '../components/log'; | ||
import { knexConfig } from '../knexfile'; | ||
import models from './models'; | ||
|
||
const log = getLogger(module.filename); | ||
|
||
export default class DataConnection { | ||
/** | ||
* Creates a new DataConnection with default (Postgresql) Knex configuration. | ||
* @class | ||
*/ | ||
private static _instance: DataConnection; | ||
private _knex: Knex; | ||
private _connected: boolean = false; | ||
|
||
constructor() { | ||
if (!DataConnection._instance) { | ||
this._knex = knex(knexConfig); | ||
DataConnection._instance = this; | ||
} | ||
|
||
return DataConnection._instance; | ||
} | ||
|
||
/** | ||
* @function connected | ||
* True or false if connected. | ||
*/ | ||
get connected() { | ||
return this._connected; | ||
} | ||
|
||
/** | ||
* @function knex | ||
* Gets the current knex binding | ||
*/ | ||
get knex() { | ||
return this._knex; | ||
} | ||
|
||
/** | ||
* @function knex | ||
* Sets the current knex binding and forwards to Objection model | ||
* @param {object} v - a Knex object. | ||
*/ | ||
set knex(v) { | ||
this._knex = v; | ||
this._connected = false; | ||
Model.knex(this._knex); | ||
} | ||
|
||
/** | ||
* @function checkAll | ||
* Checks the Knex connection, the database schema, and Objection models | ||
* @returns {boolean} True if successful, otherwise false | ||
*/ | ||
async checkAll() { | ||
const modelsOk = !!this._knex; | ||
const [connectOk, schemaOk] = await Promise.all([this.checkConnection(), this.checkSchema()]); | ||
this._connected = connectOk && schemaOk && modelsOk; | ||
log.verbose(`Connect OK: ${connectOk}, Schema OK: ${schemaOk}, Models OK: ${modelsOk}`, { function: 'checkAll' }); | ||
|
||
if (!connectOk) { | ||
log.error('Could not connect to the database, check configuration and ensure database server is running', { | ||
function: 'checkAll' | ||
}); | ||
} | ||
if (!schemaOk) { | ||
log.error('Connected to the database, could not verify the schema. Ensure proper migrations have been run.', { | ||
function: 'checkAll' | ||
}); | ||
} | ||
if (!modelsOk) { | ||
log.error('Connected to the database, schema is ok, could not initialize Knex Models.', { function: 'checkAll' }); | ||
} | ||
|
||
return this._connected; | ||
} | ||
|
||
/** | ||
* @function checkConnection | ||
* Checks the current knex connection to Postgres | ||
* If the connected DB is in read-only mode, transaction_read_only will not be off | ||
* @returns {boolean} True if successful, otherwise false | ||
*/ | ||
async checkConnection() { | ||
try { | ||
const data = await this._knex.raw('show transaction_read_only'); | ||
const result = data?.rows[0]?.transaction_read_only === 'off'; | ||
if (result) { | ||
log.debug('Database connection ok', { function: 'checkConnection' }); | ||
} else { | ||
log.warn('Database connection is read-only', { function: 'checkConnection' }); | ||
} | ||
this._connected = result; | ||
return result; | ||
} catch (err) { | ||
log.error(`Error with database connection: ${err.message}`, { function: 'checkConnection' }); | ||
this._connected = false; | ||
return false; | ||
} | ||
} | ||
|
||
/** | ||
* @function checkSchema | ||
* Queries the knex connection to check for the existence of the expected schema tables | ||
* @returns {boolean} True if schema is ok, otherwise false | ||
*/ | ||
checkSchema() { | ||
try { | ||
const tables = Object.values(models).map((model) => model.tableName); | ||
return Promise.all( | ||
tables.map((table) => | ||
Promise.all(knexConfig.searchPath.map((schema) => this._knex.schema.withSchema(schema).hasTable(table))) | ||
) | ||
) | ||
.then((exists) => exists.every((table) => table.some((exist) => exist))) | ||
.then((result) => { | ||
if (result) log.debug('Database schema ok', { function: 'checkSchema' }); | ||
return result; | ||
}); | ||
} catch (err) { | ||
log.error(`Error with database schema: ${err.message}`, { function: 'checkSchema' }); | ||
log.error(err); | ||
return false; | ||
} | ||
} | ||
|
||
/** | ||
* @function checkModel | ||
* Attaches the Objection model to the existing knex connection | ||
* @returns {boolean} True if successful, otherwise false | ||
*/ | ||
checkModel() { | ||
try { | ||
Model.knex(this._knex); | ||
log.debug('Database models ok', { function: 'checkModel' }); | ||
return true; | ||
} catch (err) { | ||
log.error(`Error attaching Model to connection: ${err.message}`, { function: 'checkModel' }); | ||
log.error(err); | ||
return false; | ||
} | ||
} | ||
|
||
/** | ||
* @function close | ||
* Will close the DataConnection | ||
* @param {function} [cb] Optional callback | ||
*/ | ||
close(cb = undefined) { | ||
if (this._knex) { | ||
this._knex.destroy(() => { | ||
this._knex = undefined; | ||
log.info('Disconnected', { function: 'close' }); | ||
if (cb) cb(); | ||
}); | ||
} else if (cb) cb(); | ||
} | ||
|
||
/** | ||
* @function resetConnection | ||
* Invalidates and reconnects existing knex connection | ||
*/ | ||
resetConnection() { | ||
if (this._knex) { | ||
log.warn('Attempting to reset database connection pool', { function: 'resetConnection' }); | ||
this._knex.destroy(() => { | ||
this._knex.initialize(); | ||
}); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import Submission from './tables/submission'; | ||
|
||
const models = { | ||
// Tables | ||
Submission: Submission | ||
}; | ||
|
||
export default { models }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
const stamps = { | ||
createdBy: { type: ['string', 'null'], maxLength: 255 }, | ||
createdAt: { type: ['string', 'null'] }, | ||
updatedBy: { type: ['string', 'null'], maxLength: 255 }, | ||
updatedAt: { type: ['string', 'null'] } | ||
}; | ||
|
||
export default { stamps }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import { encrypt, decrypt } from '../../../components/crypt'; | ||
|
||
/** | ||
* Encrypt Objection Model Plugin | ||
* Add column encryption handlers to an Objection Model | ||
* | ||
* This class will automatically encrypt and decrypt specified column fields | ||
* during insert/update/get operations transparently. | ||
* Inspired by @see {@link https://github.com/Dialogtrail/objection-encrypt} | ||
* | ||
* @see module:knex | ||
* @see module:objection | ||
*/ | ||
export default function Encrypt(opts) { | ||
// Provide good default options if possible. | ||
const options = Object.assign( | ||
{ | ||
fields: [] | ||
}, | ||
opts | ||
); | ||
|
||
// Return the mixin | ||
return (Model) => { | ||
return class extends Model { | ||
async $beforeInsert(context) { | ||
await super.$beforeInsert(context); | ||
this.encryptFields(); | ||
} | ||
async $afterInsert(context) { | ||
await super.$afterInsert(context); | ||
return this.decryptFields(); | ||
} | ||
async $beforeUpdate(queryOptions, context) { | ||
await super.$beforeUpdate(queryOptions, context); | ||
this.encryptFields(); | ||
} | ||
async $afterUpdate(queryOptions, context) { | ||
await super.$afterUpdate(queryOptions, context); | ||
return this.decryptFields(); | ||
} | ||
async $afterFind(context) { | ||
await super.$afterFind(context); | ||
return this.decryptFields(); | ||
} | ||
|
||
/** | ||
* Encrypts specified fields | ||
*/ | ||
encryptFields() { | ||
options.fields.forEach((field) => { | ||
const value = this[field]; | ||
if (value) this[field] = encrypt(value); | ||
}); | ||
} | ||
|
||
/** | ||
* Decrypts specified fields | ||
*/ | ||
decryptFields() { | ||
options.fields.forEach((field) => { | ||
const value = this[field]; | ||
if (value) this[field] = decrypt(value); | ||
}); | ||
} | ||
}; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { default as Encrypt } from './encrypt'; | ||
export { default as Timestamps } from './timestamps'; |
Oops, something went wrong.