diff --git a/CHANGES.md b/CHANGES.md index c630503..51acbd5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,10 @@ Known issues: ## not yet released -(nothing yet) +- [joyent/node-triton#245] `triton profile` now generates fresh new keys during + Docker setup and signs them with an account key, rather than copying (and + decrypting) the account key itself. This makes using Docker simpler with keys + in an SSH Agent. ## 6.0.0 diff --git a/lib/do_profile/do_docker_setup.js b/lib/do_profile/do_docker_setup.js index 343c52f..4722f5e 100644 --- a/lib/do_profile/do_docker_setup.js +++ b/lib/do_profile/do_docker_setup.js @@ -23,7 +23,8 @@ function do_docker_setup(subcmd, opts, args, cb) { cli: this.top, name: profileName, implicit: false, - yes: opts.yes + yes: opts.yes, + lifetime: opts.lifetime }, cb); } @@ -33,6 +34,11 @@ do_docker_setup.options = [ type: 'bool', help: 'Show this help.' }, + { + names: ['lifetime', 't'], + type: 'number', + help: 'Lifetime of the generated docker certificate, in days' + }, { names: ['yes', 'y'], type: 'bool', diff --git a/lib/do_profile/profilecommon.js b/lib/do_profile/profilecommon.js index 0fa5dff..5f6cac5 100644 --- a/lib/do_profile/profilecommon.js +++ b/lib/do_profile/profilecommon.js @@ -24,6 +24,7 @@ var rimraf = require('rimraf'); var semver = require('semver'); var sshpk = require('sshpk'); var mod_url = require('url'); +var crypto = require('crypto'); var vasync = require('vasync'); var which = require('which'); var wordwrap = require('wordwrap')(78); @@ -128,7 +129,6 @@ function setCurrentProfile(opts, cb) { }); } - /** * Setup the given profile for Docker usage. This means checking the cloudapi * has a Docker service (ListServices), finding the user's SSH *private* key, @@ -143,14 +143,21 @@ function setCurrentProfile(opts, cb) { * implicit, we silently skip if ListServices shows no Docker service. * - {Boolean} yes: Optional. Boolean indicating if confirmation prompts * should be skipped, assuming a "yes" answer. + * - {Number} lifetime: Optional. Number of days to make the Docker + * certificate valid for. Defaults to 3650 (10 years). */ function profileDockerSetup(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.string(opts.name, 'opts.name'); assert.optionalBool(opts.implicit, 'opts.implicit'); assert.optionalBool(opts.yes, 'opts.yes'); + assert.optionalNumber(opts.lifetime, 'opts.lifetime'); assert.func(cb, 'cb'); + /* Default to a 10 year certificate. */ + if (!opts.lifetime) + opts.lifetime = 3650; + var cli = opts.cli; var tritonapi = cli.tritonapiFromProfileName({profileName: opts.name}); @@ -165,13 +172,17 @@ function profileDockerSetup(opts, cb) { function dockerKeyWarning(arg, next) { console.log(wordwrap('WARNING: Docker uses authentication via ' + 'client TLS certificates that do not support encrypted ' + - '(passphrase protected) keys or SSH agents. If you continue, ' + - 'this profile setup will attempt to write a copy of your ' + - 'SSH private key formatted as an unencrypted TLS certificate ' + - 'in "~/.triton/docker" for use by the Docker client.\n')); + '(passphrase protected) keys or SSH agents.\n')); + console.log(wordwrap('If you continue, this profile setup will ' + + 'create a fresh private key to be written unencrypted to ' + + 'disk in "~/.triton/docker" for use by the Docker client. ' + + 'This key will be useable only for Docker.\n')); if (yes) { next(); return; + } else { + console.log(wordwrap('If you do not specifically want to use ' + + 'Docker, you can answer "no" here.\n')); } common.promptYesNo({msg: 'Continue? [y/n] '}, function (answer) { if (answer !== 'y') { @@ -311,79 +322,143 @@ function profileDockerSetup(opts, cb) { }); }, - /* - * We need the private key to format as a client cert. If this profile's - * key was found in the SSH agent (and by default it prefers to take - * it from there), then we can't use `tritonapi.keyPair`, because - * the SSH agent protocol will not allow us access to the private key - * data (by design). - * - * As a fallback we'll look (via KeyRing) for a local copy of the - * private key to use, and then unlock it if necessary. - */ - function getPrivKey(arg, next) { - // If the key pair already works, then use that... - try { - arg.privKey = tritonapi.keyPair.getPrivateKey(); - next(); - return; - } catch (_) { - // ... else fall through. - } - + function getSigningKey(arg, next) { var kr = new auth.KeyRing(); - var profileFp = sshpk.parseFingerprint(tritonapi.profile.keyId); - kr.find(profileFp, function (findErr, keyPairs) { + var profileFp = sshpk.parseFingerprint(profile.keyId); + kr.findSigningKeyPair(profileFp, + function unlockAndStash(findErr, keyPair) { + if (findErr) { next(findErr); return; } - /* - * If our keyId was found, and with the 'homedir' plugin, then - * we should have access to the private key (modulo unlocking). - */ - var homedirKeyPair; - for (var i = 0; i < keyPairs.length; i++) { - if (keyPairs[i].plugin === 'homedir') { - homedirKeyPair = keyPairs[i]; - break; - } - } - if (homedirKeyPair) { - common.promptPassphraseUnlockKey({ - // Fake the `tritonapi` object, only `.keyPair` is used. - tritonapi: {keyPair: homedirKeyPair} - }, function (unlockErr) { - if (unlockErr) { - next(unlockErr); - return; - } - try { - arg.privKey = homedirKeyPair.getPrivateKey(); - } catch (homedirErr) { - next(new errors.SetupError(homedirErr, format( - 'could not obtain SSH private key for keyId ' + - '"%s" to create Docker certificate', - profile.keyId))); - return; - } - next(); - }); - } else { - next(new errors.SetupError(format('could not obtain SSH ' + - 'private key for keyId "%s" to create Docker ' + - 'certificate', profile.keyId))); + arg.signKeyPair = keyPair; + if (!keyPair.isLocked()) { + next(); + return; } + + common.promptPassphraseUnlockKey({ + /* Fake the `tritonapi` object, only `.keyPair` is used. */ + tritonapi: { keyPair: keyPair } + }, next); }); }, + function generateAndSignCert(arg, next) { + var key = arg.signKeyPair; + var pubKey = key.getPublicKey(); - function genClientCert_dir(arg, next) { + /* + * There isn't a particular reason this has to be ECDSA, but + * Docker supports it, and ECDSA keys are much easier to + * generate from inside node than RSA ones (since sshpk will + * do them for us instead of us shelling out and mucking with + * temporary files). + */ + arg.privKey = sshpk.generatePrivateKey('ecdsa'); + + var id = sshpk.identityFromDN('CN=' + profile.account); + var parentId = sshpk.identityFromDN('CN=' + + pubKey.fingerprint('md5').toString('base64')); + var serial = crypto.randomBytes(8); + /* + * Backdate the certificate by 5 minutes to account for clock + * sync -- we only allow 5 mins drift in cloudapi generally so + * using the same amount here seems fine. + */ + var validFrom = new Date(); + validFrom.setTime(validFrom.getTime() - 300*1000); + var validUntil = new Date(); + validUntil.setTime(validFrom.getTime() + + 24*3600*1000*opts.lifetime); + /* + * Generate it self-signed for now -- we will clear this + * signature out and replace it with the real one below. + */ + var cert = sshpk.createCertificate(id, arg.privKey, parentId, + arg.privKey, { validFrom: validFrom, validUntil: validUntil, + purposes: ['clientAuth', 'joyentDocker'], serial: serial }); + + var algo = pubKey.type + '-' + pubKey.defaultHashAlgorithm(); + + /* + * This code is using private API in sshpk because there is + * no public API as of 1.14.x for async signing of certificates. + * + * If the sshpk version in package.json is updated (even a + * patch bump) this code could break! This will be fixed up + * eventually, but for now we just have to be careful. + */ + var x509 = require('sshpk/lib/formats/x509'); + cert.signatures = {}; + cert.signatures.x509 = {}; + cert.signatures.x509.algo = algo; + var signer = key.createSign({ + user: profile.account, + algorithm: algo + }); + /* + * The smartdc-auth KeyPair signer produces an object with + * strings on it intended for http-signature instead of just a + * Signature instance (which is what the x509 format module + * expects). We wrap it up here to convert it. + */ + var signerConv = function (buf, ccb) { + signer(buf, function convertSignature(signErr, sigData) { + if (signErr) { + ccb(signErr); + return; + } + var algparts = sigData.algorithm.split('-'); + var sig = sshpk.parseSignature(sigData.signature, + algparts[0], 'asn1'); + sig.hashAlgorithm = algparts[1]; + sig.curve = pubKey.curve; + ccb(null, sig); + }); + }; + /* + * Sign a "test" string first to double-check the hash algo + * it's going to use. The SSH agent may not support SHA256 + * signatures, for example, and we will only find out by + * testing like this. + */ + signer('test', function afterTestSig(testErr, testSigData) { + + if (testErr) { + next(new errors.SetupError(testErr, format( + 'failed to sign Docker certificate using key ' + + '"%s"', profile.keyId))); + return; + } + + cert.signatures.x509.algo = testSigData.algorithm; + + x509.signAsync(cert, signerConv, + function afterCertSign(signErr) { + + if (signErr) { + next(new errors.SetupError(signErr, format( + 'failed to sign Docker certificate using key ' + + '"%s"', profile.keyId))); + return; + } + + cert.issuerKey = undefined; + /* Double-check that it came out ok. */ + assert.ok(cert.isSignedByKey(pubKey)); + arg.cert = cert; + next(); + }); + }); + }, + function makeClientCertDir(arg, next) { arg.dockerCertPath = path.resolve(cli.configDir, 'docker', common.profileSlug(profile)); mkdirp(arg.dockerCertPath, next); }, - function genClientCert_key(arg, next) { + function writeClientCertKey(arg, next) { arg.keyPath = path.resolve(arg.dockerCertPath, 'key.pem'); var data = arg.privKey.toBuffer('pkcs1'); fs.writeFile(arg.keyPath, data, function (err) { @@ -395,12 +470,9 @@ function profileDockerSetup(opts, cb) { } }); }, - function genClientCert_cert(arg, next) { + function writeClientCert(arg, next) { arg.certPath = path.resolve(arg.dockerCertPath, 'cert.pem'); - - var id = sshpk.identityFromDN('CN=' + profile.account); - var cert = sshpk.createSelfSignedCertificate(id, arg.privKey); - var data = cert.toBuffer('pem'); + var data = arg.cert.toBuffer('pem'); fs.writeFile(arg.certPath, data, function (err) { if (err) { diff --git a/package.json b/package.json index f14edf5..e0da727 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,9 @@ "restify-errors": "3.0.0", "rimraf": "2.4.4", "semver": "5.1.0", - "smartdc-auth": "2.5.6", - "sshpk": "1.10.2", - "sshpk-agent": "1.4.2", + "smartdc-auth": "2.5.7", + "sshpk": "1.14.1", + "sshpk-agent": "1.7.0", "strsplit": "1.0.0", "tabula": "1.10.0", "vasync": "1.6.3",