Created
October 25, 2017 19:36
-
-
Save devtanc/15cbd2ca968f5bc78ca2fc27ed8aef57 to your computer and use it in GitHub Desktop.
Node Credstash Decryption
This file contains hidden or 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
const AWS = require('aws-sdk'); | |
const https = require('https'); | |
const crypto = require('crypto'); | |
//Set up https agent for AWS | |
const agent = new https.Agent({ | |
rejectUnauthorized: true, | |
keepAlive: true, | |
ciphers: 'ALL', | |
secureProtocol: 'TLSv1_method', | |
}); | |
//Update the AWS config before using the libraries | |
AWS.config.update({ | |
region: 'us-west-2', | |
httpOptions: { agent }, | |
}); | |
const KMS = new AWS.KMS(); | |
const documentClient = new AWS.DynamoDB.DocumentClient(); | |
// Binary Buffer nonce for AES CTR initialization vector | |
const nonce = Buffer.from('\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01', 'binary'); | |
/** | |
* Retrieves the decrypted plaintext contents for a single item from dynamo | |
* If a version is supplied, that version will be retrieved | |
* If no version is supplied, the highest version for that named item will be retrieved | |
* @param {String} tableName - Name of dynamo table | |
* @param {String} itemName - Primary key for item to retrieve | |
* @param {String} [itemVersion] - Sort key for item to retrieve | |
* @param {String} [encoding=base64] - Encoding for the Ciphertext Buffer | |
* @returns {Promise} Promise containing the retrieved item's name and decrypted plaintext contents | |
*/ | |
exports.getDecodedSecret = options => new Promise((resolve, reject) => { | |
if (!options) { | |
return reject(new Error('No options specified')); | |
} | |
return getItem(options) | |
.then(data => { | |
if (!data) { | |
return reject(new Error(`Problem retrieving item: ${ JSON.stringify(options) }`)); | |
} | |
return decryptKMSDataKey(data.key, options) | |
.then(key => { | |
const ciphertext = Buffer.from(data.contents, 'base64'); | |
const hmac = Buffer.from(data.hmac, 'hex').toString('hex'); | |
return decryptDynamoData(key, ciphertext, hmac, data.digest, data.name); | |
}) | |
.then(key => resolve({ name: options.itemName, key })) | |
.catch(err => reject(err)); | |
}) | |
.catch(err => reject(err)); | |
}); | |
/** | |
* Retrieves the decrypted plaintext contents for an array of items from dynamo | |
* Where versions are supplied, that version will be retrieved | |
* Where versions are not supplied, the highest version for that named item will be retrieved | |
* @param {String} tableName - Name of dynamo table | |
* @param {String} [encoding=base64] - Encoding for the Ciphertext Buffer | |
* @param {Object[]} items - Array of items to retrieve | |
* @param {String} items[].name - Primary key for item to retrieve | |
* @param {String} [items[].version] - Sort key for item to retrieve | |
* @returns {Promise} Promise containing the retrieved item's name and decrypted plaintext contents as a base64 encoded Buffer | |
*/ | |
exports.getDecodedSecrets = options => { | |
options.items = options.items.filter(obj => Object.keys(obj).length !== 0 && obj.constructor === Object); | |
if (options.items.find(item => item.version !== undefined)) { | |
return getItems(options.tableName, options.items) | |
.then(result => { | |
const promises = []; | |
result.data.forEach(keyData => promises.push( | |
decryptKMSDataKey(keyData.key, options) | |
.then(key => | |
decryptDynamoData( | |
key, | |
Buffer.from(keyData.contents, 'base64'), | |
Buffer.from(keyData.hmac, 'hex').toString('hex'), | |
keyData.digest, | |
keyData.name | |
) | |
) | |
)); | |
return Promise.all([ | |
batchGetHighestVersionSecrets({ | |
tableName: options.tableName, | |
items: result.nonVersions, | |
decoded: true, | |
}), | |
Promise.all(promises).then(decryptedData => | |
result.data.map((keyData, i) => | |
({ | |
name: keyData.name, | |
key: decryptedData[i], | |
}) | |
)), | |
]) | |
.then(([ set1, set2 ]) => set1.concat(set2)); | |
}); | |
} | |
return batchGetHighestVersionSecrets(Object.assign({ decoded: true }, options)); | |
}; | |
/** | |
* Retrieves the decrypted key for a single item from dynamo | |
* If a version is supplied, that version will be retrieved | |
* If no version is supplied, the highest version for that named item will be retrieved | |
* @param {String} tableName - Name of dynamo table | |
* @param {String} itemName - Primary key for item to retrieve | |
* @param {String} [itemVersion] - Sort key for item to retrieve | |
* @param {String} [encoding=base64] - Encoding for the Ciphertext Buffer | |
* @returns {Promise} Promise containing the retrieved item's name and decrypted key as a base64 encoded Buffer | |
*/ | |
exports.getSecret = options => new Promise((resolve, reject) => { | |
if (!options) { | |
return reject(new Error('No options specified')); | |
} | |
return getItem(options) | |
.then(data => { | |
if (!data) { | |
return reject(new Error(`Problem retrieving item: ${ JSON.stringify(options) }`)); | |
} | |
return decryptKMSDataKey(data.key, options) | |
.then(key => resolve({ name: options.itemName, key })) | |
.catch(err => reject(err)); | |
}) | |
.catch(err => reject(err)); | |
}); | |
/** | |
* Retrieves the decrypted keys for an array of items from dynamo | |
* Where versions are supplied, that version will be retrieved | |
* Where versions are not supplied, the highest version for that named item will be retrieved | |
* @param {String} tableName - Name of dynamo table | |
* @param {String} [encoding=base64] - Encoding for the Ciphertext Buffer | |
* @param {Object[]} items - Array of items to retrieve | |
* @param {String} items[].name - Primary key for item to retrieve | |
* @param {String} [items[].version] - Sort key for item to retrieve | |
* @returns {Promise} Promise containing the retrieved item's name and decrypted key as a base64 encoded Buffer | |
*/ | |
exports.getSecrets = options => { | |
options.items = options.items.filter(obj => Object.keys(obj).length !== 0 && obj.constructor === Object); | |
if (options.items.find(item => item.version !== undefined)) { | |
return getItems(options.tableName, options.items) | |
.then(result => { | |
const promises = []; | |
result.data.forEach(keyData => promises.push(decryptKMSDataKey(keyData.key, options))); | |
return Promise.all([ | |
batchGetHighestVersionSecrets({ | |
tableName: options.tableName, | |
items: result.nonVersions, | |
decoded: false, | |
}), | |
Promise.all(promises).then(decryptedKeys => | |
result.data.map((keyData, i) => | |
({ | |
name: keyData.name, | |
key: decryptedKeys[i], | |
}) | |
)), | |
]) | |
.then(([ set1, set2 ]) => set1.concat(set2)); | |
}); | |
} | |
return batchGetHighestVersionSecrets(Object.assign({ decoded: false }, options)); | |
}; | |
/** | |
* Retrieves a single item from dynamo using documentClient.get | |
* If a version is supplied, that version will be retrieved | |
* If no version is supplied, the highest version for that named item will be retrieved | |
* @param {Object} options - Options object | |
* @param {String} options.tableName - Name of dynamo table | |
* @param {Object[]} options.items - Array of items to retrieve | |
* @param {String} options.items[].name - Primary key for item to retrieve | |
* @param {String} [options.items[].version] - Sort key for item to retrieve | |
* @param {String} [options.items[].encoding] - Encoding for the Ciphertext Buffer | |
* @returns {Object[]} Promise containing an array of the retrieved items | |
*/ | |
function batchGetHighestVersionSecrets(options) { | |
const promises = []; | |
if (options.decoded) { | |
delete options.decoded; | |
options.items.forEach(item => promises.push(exports.getDecodedSecret({ | |
tableName: options.tableName, | |
itemName: item.name, | |
itemVersion: item.version || undefined, | |
encoding: options.encoding || undefined, | |
}))); | |
} | |
else { | |
delete options.decoded; | |
options.items.forEach(item => promises.push(exports.getSecret({ | |
tableName: options.tableName, | |
itemName: item.name, | |
itemVersion: item.version || undefined, | |
encoding: options.encoding || undefined, | |
}))); | |
} | |
return Promise.all(promises); | |
} | |
/** | |
* Retrieves a single item from dynamo using documentClient.get | |
* If a version is supplied, that version will be retrieved | |
* If no version is supplied, the highest version for that named item will be retrieved | |
* @param {Object} options - Options object | |
* @param {String} options.tableName - Name of dynamo table | |
* @param {String} options.itemName - Primary key for item to retrieve | |
* @param {String} [options.itemVersion] - Sort key for item to retrieve | |
* @returns {Object} Promise containing the retrieved item | |
*/ | |
function getItem(options) { | |
if (options.itemVersion) { | |
return new Promise((resolve, reject) => { | |
const params = { | |
TableName: options.tableName, | |
Key: { | |
name: options.itemName, | |
version: options.itemVersion, | |
}, | |
}; | |
documentClient.get(params, (err, data) => { | |
if (err) { | |
return reject(err); | |
} | |
return resolve(data.Item); | |
}); | |
}); | |
} | |
return new Promise((resolve, reject) => { | |
const params = { | |
TableName: options.tableName, | |
KeyConditionExpression: '#name = :val', | |
ExpressionAttributeNames: { | |
'#name': 'name', | |
}, | |
ExpressionAttributeValues: { | |
':val': options.itemName, | |
}, | |
ConsistentRead: true, | |
ScanIndexForward: false, | |
Limit: 1, | |
}; | |
documentClient.query(params, (err, data) => { | |
if (err) { | |
return reject(err); | |
} | |
return resolve(data); | |
}); | |
}) | |
.then(data => data.Items[0]); | |
} | |
/** | |
* Retrieves each item in an array from dynamo using documentClient.batchGet | |
* @param {String} TableName - Name of dynamo table | |
* @param {Object[]} items - Array of item objects to retrieve from the table | |
* @param {String} items[].name - Primary key for item to retrieve | |
* @param {String} [items[].version] - Sort key for item to retrieve | |
* @returns {Object} Promise containing the retrieved items and any items that did not have a specified version | |
*/ | |
function getItems(TableName, items) { | |
//Build the params data | |
const nonVersions = []; | |
const params = { | |
RequestItems: {}, | |
ReturnConsumedCapacity: 'NONE', | |
}; | |
params.RequestItems[TableName] = { | |
ConsistentRead: true, | |
Keys: [], | |
}; | |
items.forEach(item => { | |
if (item.version === undefined) { | |
nonVersions.push(item); | |
} | |
else { | |
params.RequestItems[TableName].Keys.push({ | |
name: item.name, | |
version: item.version, | |
}); | |
} | |
}); | |
//Send the batch get request to dynamo | |
return new Promise((resolve, reject) => { | |
documentClient.batchGet(params, (err, data) => { | |
if (err) { | |
return reject(err); | |
} | |
return resolve({ data: data.Responses[TableName], nonVersions }); | |
}); | |
}); | |
} | |
/** | |
* Decrypts a given key using KMS.decrypt and returns the decrypted key | |
* @param {String} key - key from dynamo row | |
* @param {Object} options - name from dynamo row | |
* @param {String} options.encoding - Ciphertext buffer encoding | |
* @returns {Buffer} Promise containing the decrypted key | |
*/ | |
function decryptKMSDataKey(key, options) { | |
return new Promise((resolve, reject) => { | |
const params = { | |
CiphertextBlob: new Buffer(key, options ? options.encoding || 'base64' : 'base64'), | |
}; | |
KMS.decrypt(params, (err, data) => { | |
if (err) { | |
return reject(err); | |
} | |
return resolve(data.Plaintext); | |
}); | |
}); | |
} | |
/** | |
* Decrypts data from a credstash table and returns the plaintext contents | |
* @param {Buffer} key - [base64] from performing KMS.decrypt on key from dynamo row | |
* @param {Buffer} nonce - [binary] nonce for AES CTR IV from credstash code | |
* @param {Buffer} ciphertext - [base64] contents from dynamo row | |
* @param {Buffer} hmacExpected - [hex] hmac from dynamo row | |
* @param {String} digest - digest from dynamo row | |
* @param {String} name - name from dynamo row | |
* @returns {String} Promise containing the decrypted plaintext string | |
*/ | |
function decryptDynamoData(key, ciphertext, hmacExpected, digest, name) { | |
// Split the key in half to get its component parts | |
[ dataKey, hmacKey ] = [ key.slice(0, key.length / 2), key.slice(key.length / 2) ]; | |
//Verify HMAC from dynamo matches an HMAC generated from the ciphertext | |
const hmac = crypto.createHmac(digest, hmacKey); | |
hmac.update(ciphertext); | |
const hmacFinal = hmac.digest('hex'); | |
if (hmacExpected !== hmacFinal) { | |
throw Error(`Computed HMAC on ${ name } does not match stored HMAC`); | |
} | |
//Decipher the final plaintext key | |
const decipher = crypto.createDecipheriv('aes-256-ctr', dataKey, nonce); | |
let decrypted = decipher.update(ciphertext, 'base64', 'utf8'); | |
decrypted += decipher.final('utf8'); | |
return Promise.resolve(decrypted); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment