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:
parent
6015cf2145
commit
5734123e75
@ -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
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
||||
arg.signKeyPair = keyPair;
|
||||
if (!keyPair.isLocked()) {
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
next(new errors.SetupError(format('could not obtain SSH ' +
|
||||
'private key for keyId "%s" to create Docker ' +
|
||||
'certificate', profile.keyId)));
|
||||
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) {
|
||||
|
@ -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",
|
||||
|
Reference in New Issue
Block a user