UNPKG

aes-gcm-stream

Version:

A dependency free NodeJS module that implements AES256 GCM encryption and decryption as transform streams.

250 lines (221 loc) 7.24 kB
/** * AES GCM Stream * This module exports encrypt and decrypt stream constructors which can be * used to protect data with authenticated encryption. * * Helper methods are also provided to do things like generate secure keys * and salt. */ 'use strict'; var stream = require('stream'); var Transform = stream.Transform; var util = require('util'); var crypto = require('crypto'); var PBKDF2_PASS_LENGTH = 256; var PBKDF2_SALT_LENGTH = 32; var PBKDF2_ITERATIONS = 5000; var PBKDF2_DIGEST = 'sha256'; var KEY_LENGTH = 32; // bytes var GCM_NONCE_LENGTH = 12; //bytes var GCM_MAC_LENGTH = 16; //bytes var keyEncoding = 'base64'; /** * Private helper method to validate a key passed into the Encrypt and Decrypt streams. * Strings are converted it into a buffer, buffers are returned as they are. * @param key * @throws Missing, Encoding, or Length errors * @returns Buffer */ var validateAndConvertKey = function(key) { if (key && key instanceof Buffer && key.length === KEY_LENGTH) { return key; } else if (key && typeof key === 'string') { // Because we don't have a reliable way to test string encoding, we assume that a string type // key was created by the createEncodedKey method and that it is using the keyEncoding which // has been set on the module, whether that's the default or something explicitly set by the // user via the setKeyEncoding method. var bufKey = new Buffer(key, keyEncoding); if (bufKey.length !== KEY_LENGTH) { var encodingErrorMessage = 'Provided key string is either of an unknown encoding (expected: ' + keyEncoding + ') or the wrong length.'; throw new Error(encodingErrorMessage); } return bufKey; } else { var message = 'The key options property is required! Expected ' + keyEncoding + ' encoded string or a buffer.'; throw new Error(message); } }; exports.encrypt = EncryptionStream; exports.decrypt = DecryptionStream; /** * getEncoding * Helper which returns the current encoding being used for keys * @returns {string} */ exports.getKeyEncoding = function() { return keyEncoding; }; /** * setEncoding * Helper to set the encoding being used for keys * @param enc */ exports.setKeyEncoding = function(enc) { keyEncoding = Buffer.isEncoding(enc) ? enc : keyEncoding; }; /** * createSalt * Helper method that returns a salt * @returns string * @throws error */ exports.createSalt = function(length) { try { return crypto.randomBytes(length); } catch (ex) { console.error('Problem reading random data and generating salt!'); throw ex; } }; /** * createKeyBuffer * Method which returns a buffer representing a secure key generated with PBKDF2 * @returns Buffer */ exports.createKeyBuffer = function() { try { var passphrase = crypto.randomBytes(PBKDF2_PASS_LENGTH); var salt = this.createSalt(PBKDF2_SALT_LENGTH); return crypto.pbkdf2Sync(passphrase, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST); } catch (ex) { console.error('Problem reading random data and generating a key!'); throw ex; } }; /** * createEncodedKey * Helper method that returns an encoded key * @returns string * @throws error */ exports.createEncodedKey = function() { return exports.createKeyBuffer().toString(keyEncoding); }; /** * EncryptionStream * A constructor which returns an encryption stream * The stream first outputs a 12 byte nonce then encrypted cipher text. * When the stream is flushed it outputs a 16 byte MAC. * @param options Object Object.key is the only required param * @returns {EncryptionStream} * @constructor */ function EncryptionStream(options) { if (!(this instanceof EncryptionStream)) { return new EncryptionStream(options); } var nonce = options.nonce || exports.createSalt(12); this._key = validateAndConvertKey(options.key); this._cipher = crypto.createCipheriv('aes-256-gcm', this._key, nonce); Transform.call(this, options); this.push(nonce); } util.inherits(EncryptionStream, Transform); EncryptionStream.prototype._transform = function(chunk, enc, cb) { this.push(this._cipher.update(chunk)); cb(); }; EncryptionStream.prototype._flush = function(cb) { // final must be called on the cipher before generating a MAC this._cipher.final(); // this will never output data this.push(this._cipher.getAuthTag()); // 16 bytes cb(); }; /** * DecryptionStream * A constructor which returns a decryption stream * The stream assumes the first 12 bytes of data are the nonce and the final * 16 bytes received is the MAC. * @param options Object Object.key is the only required param * @returns {DecryptionStream} * @constructor */ function DecryptionStream(options) { if (!(this instanceof DecryptionStream)) { return new DecryptionStream(options); } this._started = false; this._nonce = new Buffer(GCM_NONCE_LENGTH); this._nonceBytesRead = 0; this._cipherTextChunks = []; this._key = validateAndConvertKey(options.key); Transform.call(this, options); } util.inherits(DecryptionStream, Transform); DecryptionStream.prototype._transform = function(chunk, enc, cb) { var chunkLength = chunk.length; var chunkOffset = 0; if (!this._started) { if (this._nonceBytesRead < GCM_NONCE_LENGTH) { var nonceRemaining = GCM_NONCE_LENGTH - this._nonceBytesRead; chunkOffset = chunkLength <= nonceRemaining ? chunkLength : nonceRemaining; chunk.copy(this._nonce, this._nonceBytesRead, 0, chunkOffset); chunk = chunk.slice(chunkOffset); chunkLength = chunk.length; this._nonceBytesRead += chunkOffset; } if (this._nonceBytesRead === GCM_NONCE_LENGTH) { this._decipher = crypto.createDecipheriv('aes-256-gcm', this._key, this._nonce); this._started = true; } } // We can't use an else because we have no idea how long our chunks will be // all we know is that once we've got a nonce we can start storing cipher text if (this._started) { this._cipherTextChunks.push(chunk); } cb(); }; DecryptionStream.prototype._flush = function(cb) { var mac = pullOutMac(this._cipherTextChunks); if (!mac) { return this.emit('error', new Error('Decryption failed: bad cipher text.')); } this._decipher.setAuthTag(mac); var decrypted = this._cipherTextChunks.map(function(item) { return this._decipher.update(item); }, this); try { this._decipher.final(); } catch (e) { return cb(e); } decrypted.forEach(function(item) { this.push(item); }, this); cb(); }; function pullOutMac(array) { var macBits = []; var macByteCount = 0; var current, macStartIndex; while (macByteCount !== GCM_MAC_LENGTH && array.length) { current = array.pop(); if (macByteCount + current.length <= GCM_MAC_LENGTH) { macBits.push(current); macByteCount += current.length; } else { macStartIndex = (macByteCount + current.length) - GCM_MAC_LENGTH; macBits.push(current.slice(macStartIndex)); array.push(current.slice(0, macStartIndex)); macByteCount += (current.length - macStartIndex); } } if (macByteCount !== GCM_MAC_LENGTH) { return; } macBits.reverse(); return Buffer.concat(macBits, GCM_MAC_LENGTH); }