joyent/node-triton#245 triton profile should generate separate keys for Docker

Reviewed by: Trent Mick <trent.mick@joyent.com>
Reviewed by: Marsell Kukuljevic <marsell@joyent.com>
This commit is contained in:
Alex Wilson 2018-04-20 17:58:17 -07:00
parent 6015cf2145
commit 5734123e75
4 changed files with 155 additions and 74 deletions

View File

@ -6,7 +6,10 @@ Known issues:
## not yet released ## 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 ## 6.0.0

View File

@ -23,7 +23,8 @@ function do_docker_setup(subcmd, opts, args, cb) {
cli: this.top, cli: this.top,
name: profileName, name: profileName,
implicit: false, implicit: false,
yes: opts.yes yes: opts.yes,
lifetime: opts.lifetime
}, cb); }, cb);
} }
@ -33,6 +34,11 @@ do_docker_setup.options = [
type: 'bool', type: 'bool',
help: 'Show this help.' help: 'Show this help.'
}, },
{
names: ['lifetime', 't'],
type: 'number',
help: 'Lifetime of the generated docker certificate, in days'
},
{ {
names: ['yes', 'y'], names: ['yes', 'y'],
type: 'bool', type: 'bool',

View File

@ -24,6 +24,7 @@ var rimraf = require('rimraf');
var semver = require('semver'); var semver = require('semver');
var sshpk = require('sshpk'); var sshpk = require('sshpk');
var mod_url = require('url'); var mod_url = require('url');
var crypto = require('crypto');
var vasync = require('vasync'); var vasync = require('vasync');
var which = require('which'); var which = require('which');
var wordwrap = require('wordwrap')(78); 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 * Setup the given profile for Docker usage. This means checking the cloudapi
* has a Docker service (ListServices), finding the user's SSH *private* key, * 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. * implicit, we silently skip if ListServices shows no Docker service.
* - {Boolean} yes: Optional. Boolean indicating if confirmation prompts * - {Boolean} yes: Optional. Boolean indicating if confirmation prompts
* should be skipped, assuming a "yes" answer. * 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) { function profileDockerSetup(opts, cb) {
assert.object(opts.cli, 'opts.cli'); assert.object(opts.cli, 'opts.cli');
assert.string(opts.name, 'opts.name'); assert.string(opts.name, 'opts.name');
assert.optionalBool(opts.implicit, 'opts.implicit'); assert.optionalBool(opts.implicit, 'opts.implicit');
assert.optionalBool(opts.yes, 'opts.yes'); assert.optionalBool(opts.yes, 'opts.yes');
assert.optionalNumber(opts.lifetime, 'opts.lifetime');
assert.func(cb, 'cb'); assert.func(cb, 'cb');
/* Default to a 10 year certificate. */
if (!opts.lifetime)
opts.lifetime = 3650;
var cli = opts.cli; var cli = opts.cli;
var tritonapi = cli.tritonapiFromProfileName({profileName: opts.name}); var tritonapi = cli.tritonapiFromProfileName({profileName: opts.name});
@ -165,13 +172,17 @@ function profileDockerSetup(opts, cb) {
function dockerKeyWarning(arg, next) { function dockerKeyWarning(arg, next) {
console.log(wordwrap('WARNING: Docker uses authentication via ' + console.log(wordwrap('WARNING: Docker uses authentication via ' +
'client TLS certificates that do not support encrypted ' + 'client TLS certificates that do not support encrypted ' +
'(passphrase protected) keys or SSH agents. If you continue, ' + '(passphrase protected) keys or SSH agents.\n'));
'this profile setup will attempt to write a copy of your ' + console.log(wordwrap('If you continue, this profile setup will ' +
'SSH private key formatted as an unencrypted TLS certificate ' + 'create a fresh private key to be written unencrypted to ' +
'in "~/.triton/docker" for use by the Docker client.\n')); 'disk in "~/.triton/docker" for use by the Docker client. ' +
'This key will be useable only for Docker.\n'));
if (yes) { if (yes) {
next(); next();
return; 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) { common.promptYesNo({msg: 'Continue? [y/n] '}, function (answer) {
if (answer !== 'y') { if (answer !== 'y') {
@ -311,79 +322,143 @@ function profileDockerSetup(opts, cb) {
}); });
}, },
/* function getSigningKey(arg, next) {
* 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.
}
var kr = new auth.KeyRing(); var kr = new auth.KeyRing();
var profileFp = sshpk.parseFingerprint(tritonapi.profile.keyId); var profileFp = sshpk.parseFingerprint(profile.keyId);
kr.find(profileFp, function (findErr, keyPairs) { kr.findSigningKeyPair(profileFp,
function unlockAndStash(findErr, keyPair) {
if (findErr) { if (findErr) {
next(findErr); next(findErr);
return; return;
} }
/* arg.signKeyPair = keyPair;
* If our keyId was found, and with the 'homedir' plugin, then if (!keyPair.isLocked()) {
* we should have access to the private key (modulo unlocking). next();
*/ return;
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)));
} }
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, arg.dockerCertPath = path.resolve(cli.configDir,
'docker', common.profileSlug(profile)); 'docker', common.profileSlug(profile));
mkdirp(arg.dockerCertPath, next); mkdirp(arg.dockerCertPath, next);
}, },
function genClientCert_key(arg, next) { function writeClientCertKey(arg, next) {
arg.keyPath = path.resolve(arg.dockerCertPath, 'key.pem'); arg.keyPath = path.resolve(arg.dockerCertPath, 'key.pem');
var data = arg.privKey.toBuffer('pkcs1'); var data = arg.privKey.toBuffer('pkcs1');
fs.writeFile(arg.keyPath, data, function (err) { 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'); arg.certPath = path.resolve(arg.dockerCertPath, 'cert.pem');
var data = arg.cert.toBuffer('pem');
var id = sshpk.identityFromDN('CN=' + profile.account);
var cert = sshpk.createSelfSignedCertificate(id, arg.privKey);
var data = cert.toBuffer('pem');
fs.writeFile(arg.certPath, data, function (err) { fs.writeFile(arg.certPath, data, function (err) {
if (err) { if (err) {

View File

@ -21,9 +21,9 @@
"restify-errors": "3.0.0", "restify-errors": "3.0.0",
"rimraf": "2.4.4", "rimraf": "2.4.4",
"semver": "5.1.0", "semver": "5.1.0",
"smartdc-auth": "2.5.6", "smartdc-auth": "2.5.7",
"sshpk": "1.10.2", "sshpk": "1.14.1",
"sshpk-agent": "1.4.2", "sshpk-agent": "1.7.0",
"strsplit": "1.0.0", "strsplit": "1.0.0",
"tabula": "1.10.0", "tabula": "1.10.0",
"vasync": "1.6.3", "vasync": "1.6.3",