From f3c297270aad821dda4f8b3381f3413537608e8e Mon Sep 17 00:00:00 2001 From: Alex Wilson Date: Wed, 3 Aug 2016 15:49:07 -0700 Subject: [PATCH] joyent/node-sshpk#13 support for encrypted OpenSSH format private keys Reviewed by: Cody Mello --- bin/sshpk-conv | 6 ++ lib/formats/pem.js | 4 +- lib/formats/ssh-private.js | 157 +++++++++++++++++++++++++++++++++---- lib/utils.js | 44 ++++++++++- package.json | 5 +- test/assets/id_ecdsa_enc | 10 +++ test/private-key.js | 31 ++++++++ 7 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 test/assets/id_ecdsa_enc diff --git a/bin/sshpk-conv b/bin/sshpk-conv index a1205a4..444045a 100755 --- a/bin/sshpk-conv +++ b/bin/sshpk-conv @@ -149,6 +149,12 @@ if (require.main === module) { } catch (e) { if (e.name === 'KeyEncryptedError') { getPassword(function (err, pw) { + if (err) { + console.log('sshpk-conv: ' + + err.name + ': ' + + err.message); + process.exit(1); + } parseOpts.passphrase = pw; processKey(); }); diff --git a/lib/formats/pem.js b/lib/formats/pem.js index 5318b35..c254e4e 100644 --- a/lib/formats/pem.js +++ b/lib/formats/pem.js @@ -107,9 +107,9 @@ function read(buf, options, forceType) { /* The new OpenSSH internal format abuses PEM headers */ if (alg && alg.toLowerCase() === 'openssh') - return (sshpriv.readSSHPrivate(type, buf)); + return (sshpriv.readSSHPrivate(type, buf, options)); if (alg && alg.toLowerCase() === 'ssh2') - return (rfc4253.readType(type, buf)); + return (rfc4253.readType(type, buf, options)); var der = new asn1.BerReader(buf); der.originalInput = input; diff --git a/lib/formats/ssh-private.js b/lib/formats/ssh-private.js index bfbdab5..2fcf719 100644 --- a/lib/formats/ssh-private.js +++ b/lib/formats/ssh-private.js @@ -17,6 +17,9 @@ var PrivateKey = require('../private-key'); var pem = require('./pem'); var rfc4253 = require('./rfc4253'); var SSHBuffer = require('../ssh-buffer'); +var errors = require('../errors'); + +var bcrypt; function read(buf, options) { return (pem.read(buf, options)); @@ -24,7 +27,7 @@ function read(buf, options) { var MAGIC = 'openssh-key-v1'; -function readSSHPrivate(type, buf) { +function readSSHPrivate(type, buf, options) { buf = new SSHBuffer({buffer: buf}); var magic = buf.readCString(); @@ -32,16 +35,7 @@ function readSSHPrivate(type, buf) { var cipher = buf.readString(); var kdf = buf.readString(); - - /* We only support unencrypted keys. */ - if (cipher !== 'none' || kdf !== 'none') { - throw (new Error('OpenSSH-format key is encrypted ' + - '(password-protected). Please use the SSH agent ' + - 'or decrypt the key.')); - } - - /* Skip over kdfoptions. */ - buf.readString(); + var kdfOpts = buf.readBuffer(); var nkeys = buf.readInt(); if (nkeys !== 1) { @@ -59,11 +53,74 @@ function readSSHPrivate(type, buf) { var privKeyBlob = buf.readBuffer(); assert.ok(buf.atEnd(), 'excess bytes left after key'); + var kdfOptsBuf = new SSHBuffer({ buffer: kdfOpts }); + switch (kdf) { + case 'none': + if (cipher !== 'none') { + throw (new Error('OpenSSH-format key uses KDF "none" ' + + 'but specifies a cipher other than "none"')); + } + break; + case 'bcrypt': + var salt = kdfOptsBuf.readBuffer(); + var rounds = kdfOptsBuf.readInt(); + var cinf = utils.opensshCipherInfo(cipher); + if (bcrypt === undefined) { + bcrypt = require('bcrypt-pbkdf'); + } + + if (typeof (options.passphrase) === 'string') { + options.passphrase = new Buffer(options.passphrase, + 'utf-8'); + } + if (!Buffer.isBuffer(options.passphrase)) { + throw (new errors.KeyEncryptedError( + options.filename, 'OpenSSH')); + } + + var pass = new Uint8Array(options.passphrase); + var salti = new Uint8Array(salt); + /* Use the pbkdf to derive both the key and the IV. */ + var out = new Uint8Array(cinf.keySize + cinf.blockSize); + var res = bcrypt.pbkdf(pass, pass.length, salti, salti.length, + out, out.length, rounds); + if (res !== 0) { + throw (new Error('bcrypt_pbkdf function returned ' + + 'failure, parameters invalid')); + } + out = new Buffer(out); + var ckey = out.slice(0, cinf.keySize); + var iv = out.slice(cinf.keySize, cinf.keySize + cinf.blockSize); + var cipherStream = crypto.createDecipheriv(cinf.opensslName, + ckey, iv); + cipherStream.setAutoPadding(false); + var chunk, chunks = []; + cipherStream.once('error', function (e) { + if (e.toString().indexOf('bad decrypt') !== -1) { + throw (new Error('Incorrect passphrase ' + + 'supplied, could not decrypt key')); + } + throw (e); + }); + cipherStream.write(privKeyBlob); + cipherStream.end(); + while ((chunk = cipherStream.read()) !== null) + chunks.push(chunk); + privKeyBlob = Buffer.concat(chunks); + break; + default: + throw (new Error( + 'OpenSSH-format key uses unknown KDF "' + kdf + '"')); + } + buf = new SSHBuffer({buffer: privKeyBlob}); var checkInt1 = buf.readInt(); var checkInt2 = buf.readInt(); - assert.strictEqual(checkInt1, checkInt2, 'checkints do not match'); + if (checkInt1 !== checkInt2) { + throw (new Error('Incorrect passphrase supplied, could not ' + + 'decrypt key')); + } var ret = {}; var key = rfc4253.readInternal(ret, 'private', buf.remainder()); @@ -83,6 +140,26 @@ function write(key, options) { else pubKey = key; + var cipher = 'none'; + var kdf = 'none'; + var kdfopts = new Buffer(0); + var cinf = { blockSize: 8 }; + var passphrase; + if (options !== undefined) { + passphrase = options.passphrase; + if (typeof (passphrase) === 'string') + passphrase = new Buffer(passphrase, 'utf-8'); + if (passphrase !== undefined) { + assert.buffer(passphrase, 'options.passphrase'); + assert.optionalString(options.cipher, 'options.cipher'); + cipher = options.cipher; + if (cipher === undefined) + cipher = 'aes128-ctr'; + cinf = utils.opensshCipherInfo(cipher); + kdf = 'bcrypt'; + } + } + var privBuf; if (PrivateKey.isPrivateKey(key)) { privBuf = new SSHBuffer({}); @@ -93,22 +170,68 @@ function write(key, options) { privBuf.writeString(key.comment || ''); var n = 1; - while (privBuf._offset % 8 !== 0) + while (privBuf._offset % cinf.blockSize !== 0) privBuf.writeChar(n++); + privBuf = privBuf.toBuffer(); + } + + switch (kdf) { + case 'none': + break; + case 'bcrypt': + var salt = crypto.randomBytes(16); + var rounds = 16; + var kdfssh = new SSHBuffer({}); + kdfssh.writeBuffer(salt); + kdfssh.writeInt(rounds); + kdfopts = kdfssh.toBuffer(); + + if (bcrypt === undefined) { + bcrypt = require('bcrypt-pbkdf'); + } + var pass = new Uint8Array(passphrase); + var salti = new Uint8Array(salt); + /* Use the pbkdf to derive both the key and the IV. */ + var out = new Uint8Array(cinf.keySize + cinf.blockSize); + var res = bcrypt.pbkdf(pass, pass.length, salti, salti.length, + out, out.length, rounds); + if (res !== 0) { + throw (new Error('bcrypt_pbkdf function returned ' + + 'failure, parameters invalid')); + } + out = new Buffer(out); + var ckey = out.slice(0, cinf.keySize); + var iv = out.slice(cinf.keySize, cinf.keySize + cinf.blockSize); + + var cipherStream = crypto.createCipheriv(cinf.opensslName, + ckey, iv); + cipherStream.setAutoPadding(false); + var chunk, chunks = []; + cipherStream.once('error', function (e) { + throw (e); + }); + cipherStream.write(privBuf); + cipherStream.end(); + while ((chunk = cipherStream.read()) !== null) + chunks.push(chunk); + privBuf = Buffer.concat(chunks); + break; + default: + throw (new Error('Unsupported kdf ' + kdf)); } var buf = new SSHBuffer({}); buf.writeCString(MAGIC); - buf.writeString('none'); /* cipher */ - buf.writeString('none'); /* kdf */ - buf.writeBuffer(new Buffer(0)); /* kdfoptions */ + buf.writeString(cipher); /* cipher */ + buf.writeString(kdf); /* kdf */ + buf.writeBuffer(kdfopts); /* kdfoptions */ buf.writeInt(1); /* nkeys */ buf.writeBuffer(pubKey.toBuffer('rfc4253')); if (privBuf) - buf.writeBuffer(privBuf.toBuffer()); + buf.writeBuffer(privBuf); buf = buf.toBuffer(); diff --git a/lib/utils.js b/lib/utils.js index d57245c..466634c 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -9,7 +9,8 @@ module.exports = { countZeros: countZeros, assertCompatible: assertCompatible, isCompatible: isCompatible, - opensslKeyDeriv: opensslKeyDeriv + opensslKeyDeriv: opensslKeyDeriv, + opensshCipherInfo: opensshCipherInfo }; var assert = require('assert-plus'); @@ -244,3 +245,44 @@ function addRSAMissing(key) { key.parts.push(key.part.dmodq); } } + +function opensshCipherInfo(cipher) { + var inf = {}; + switch (cipher) { + case '3des-cbc': + inf.keySize = 24; + inf.blockSize = 8; + inf.opensslName = 'des-ede3-cbc'; + break; + case 'blowfish-cbc': + inf.keySize = 16; + inf.blockSize = 8; + inf.opensslName = 'bf-cbc'; + break; + case 'aes128-cbc': + case 'aes128-ctr': + case 'aes128-gcm@openssh.com': + inf.keySize = 16; + inf.blockSize = 16; + inf.opensslName = 'aes-128-' + cipher.slice(7, 10); + break; + case 'aes192-cbc': + case 'aes192-ctr': + case 'aes192-gcm@openssh.com': + inf.keySize = 24; + inf.blockSize = 16; + inf.opensslName = 'aes-192-' + cipher.slice(7, 10); + break; + case 'aes256-cbc': + case 'aes256-ctr': + case 'aes256-gcm@openssh.com': + inf.keySize = 32; + inf.blockSize = 16; + inf.opensslName = 'aes-256-' + cipher.slice(7, 10); + break; + default: + throw (new Error( + 'Unsupported openssl cipher "' + cipher + '"')); + } + return (inf); +} diff --git a/package.json b/package.json index 621a5a8..db9bfc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sshpk", - "version": "1.9.2", + "version": "1.10.0", "description": "A library for finding and using SSH public keys", "main": "lib/index.js", "scripts": { @@ -48,7 +48,8 @@ "jsbn": "~0.1.0", "tweetnacl": "~0.13.0", "jodid25519": "^1.0.0", - "ecc-jsbn": "~0.1.1" + "ecc-jsbn": "~0.1.1", + "bcrypt-pbkdf": "^1.0.0" }, "devDependencies": { "tape": "^3.5.0", diff --git a/test/assets/id_ecdsa_enc b/test/assets/id_ecdsa_enc new file mode 100644 index 0000000..513b6f5 --- /dev/null +++ b/test/assets/id_ecdsa_enc @@ -0,0 +1,10 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABCxRf8+2g +kOoRguCgCcgocnAAAAEAAAAAEAAABoAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlz +dHAyNTYAAABBBJIH5wFP3F6cuSwYBr0L1PdH+sL3uNIwpLlXF3OaUUIt1omTUKaGZ79vFb +tIH5A78WRmEdLMLi6EA5Hy6AI5YNEAAADAJsEFTLkiT/A2Vfer0iK3rGtvNrjuuuYGS8VV +xRenc9N4QFNtUHMbNoqTriyLplAU5/LwEAJ9kXtdCkvcpFjW3h6OqG9ttvSiyrfk/84ULG +raqvAuBdyEK6T8iuo4f62r7kdJxGQMJM52LtKaU/E2aPFadwTDfeQY8W53AFKIplHyG4Hj +5LOSvoDzkMZgMxqCLHyEqAenPwj9OjIJ7ff60Mk6dJs+RmFynAEGYI3d3oviIRNvYJdjhv +lAAFohORH6 +-----END OPENSSH PRIVATE KEY----- diff --git a/test/private-key.js b/test/private-key.js index 6ab5834..a5a0275 100644 --- a/test/private-key.js +++ b/test/private-key.js @@ -19,6 +19,8 @@ var ID_ED25519_FP = sshpk.parseFingerprint( 'SHA256:2UeFLCUKw2lvd8O1zfINNVzE0kUcu2HJHXQr/TGHt60'); var ID_RSA_O_FP = sshpk.parseFingerprint( 'SHA256:sfZqx0wyXwuXhsza0Ld99+/YNEMFyubTD8fPJ1Jo7Xw'); +var ID_ECDSA_ENC_FP = sshpk.parseFingerprint( + 'SHA256:n2/53LRiEy+DBbKltRHQC36vwRndRJve+912b8zDvow'); test('PrivateKey load RSA key', function (t) { var keyPem = fs.readFileSync(path.join(testDir, 'id_rsa')); @@ -112,6 +114,35 @@ test('PrivateKey convert ssh-private rsa to pem', function (t) { t.end(); }); +test('parse and produce encrypted ssh-private ecdsa', function (t) { + var keySsh = fs.readFileSync(path.join(testDir, 'id_ecdsa_enc')); + t.throws(function () { + sshpk.parsePrivateKey(keySsh, 'ssh-private'); + }); + t.throws(function () { + sshpk.parsePrivateKey(keySsh, 'ssh-private', + { passphrase: 'incorrect' }); + }); + var key = sshpk.parsePrivateKey(keySsh, 'ssh-private', + { passphrase: 'foobar' }); + t.strictEqual(key.type, 'ecdsa'); + t.strictEqual(key.size, 256); + t.ok(ID_ECDSA_ENC_FP.matches(key)); + + var keySsh2 = key.toBuffer('ssh-private', { passphrase: 'foobar2' }); + t.throws(function () { + sshpk.parsePrivateKey(keySsh2, 'ssh-private', + { passphrase: 'foobar' }); + }); + var key2 = sshpk.parsePrivateKey(keySsh2, 'ssh-private', + { passphrase: 'foobar2' }); + t.strictEqual(key.type, 'ecdsa'); + t.strictEqual(key.size, 256); + t.ok(ID_ECDSA_ENC_FP.matches(key)); + + t.end(); +}); + var KEY_RSA, KEY_DSA, KEY_ECDSA, KEY_ECDSA2, KEY_ED25519; test('setup keys', function (t) {