Skip to content

Instantly share code, notes, and snippets.

@devtanc
Created October 25, 2017 19:36
Show Gist options
  • Save devtanc/15cbd2ca968f5bc78ca2fc27ed8aef57 to your computer and use it in GitHub Desktop.
Save devtanc/15cbd2ca968f5bc78ca2fc27ed8aef57 to your computer and use it in GitHub Desktop.
Node Credstash Decryption
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