From 44aec4ad8bcf7324009c92a52e4c81487cbc24b9 Mon Sep 17 00:00:00 2001 From: Alex Wilson Date: Tue, 18 Dec 2018 13:56:37 -0800 Subject: [PATCH] joyent/node-sshpk#59 want support for SPKI fingerprint format Reviewed by: Isaac Davis Reviewed by: Cody Peter Mello --- README.md | 32 +++++++++++++------ bin/sshpk-conv | 70 +++++++++++++++++++++++++++++++----------- lib/fingerprint.js | 73 +++++++++++++++++++++++++++++++++++++------- lib/formats/pkcs8.js | 9 +++++- lib/key.js | 39 ++++++++++++++++------- lib/private-key.js | 11 +++++-- package.json | 2 +- test/fingerprint.js | 38 ++++++++++++++++++++++- 8 files changed, 220 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index bd3d5c3..1f7b337 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Parameters Same as `this.toBuffer(format).toString()`. -### `Key#fingerprint([algorithm = 'sha256'])` +### `Key#fingerprint([algorithm = 'sha256'[, hashType = 'ssh']])` Creates a new `Fingerprint` object representing this Key's fingerprint. @@ -182,6 +182,9 @@ Parameters - `algorithm` -- String name of hash algorithm to use, valid options are `md5`, `sha1`, `sha256`, `sha384`, `sha512` +- `hashType` -- String name of fingerprint hash type to use, valid options are + `ssh` (the type of fingerprint used by OpenSSH, e.g. in + `ssh-keygen`), `spki` (used by HPKP, some OpenSSL applications) ### `Key#createVerify([hashAlgorithm])` @@ -333,7 +336,7 @@ Parameters ## Fingerprints -### `parseFingerprint(fingerprint[, algorithms])` +### `parseFingerprint(fingerprint[, options])` Pre-parses a fingerprint, creating a `Fingerprint` object that can be used to quickly locate a key by using the `Fingerprint#matches` function. @@ -341,9 +344,15 @@ quickly locate a key by using the `Fingerprint#matches` function. Parameters - `fingerprint` -- String, the fingerprint value, in any supported format -- `algorithms` -- Optional list of strings, names of hash algorithms to limit - support to. If `fingerprint` uses a hash algorithm not on - this list, throws `InvalidAlgorithmError`. +- `options` -- Optional Object, with properties: + - `algorithms` -- Array of strings, names of hash algorithms to limit + support to. If `fingerprint` uses a hash algorithm not on + this list, throws `InvalidAlgorithmError`. + - `hashType` -- String, the type of hash the fingerprint uses, either `ssh` + or `spki` (normally auto-detected based on the format, but + can be overridden) + - `type` -- String, the entity this fingerprint identifies, either `key` or + `certificate` ### `Fingerprint.isFingerprint(obj)` @@ -364,14 +373,19 @@ Parameters `base64`. If this `Fingerprint` uses the `md5` algorithm, the default format is `hex`. Otherwise, the default is `base64`. -### `Fingerprint#matches(key)` +### `Fingerprint#matches(keyOrCertificate)` -Verifies whether or not this `Fingerprint` matches a given `Key`. This function -uses double-hashing to avoid leaking timing information. Returns a boolean. +Verifies whether or not this `Fingerprint` matches a given `Key` or +`Certificate`. This function uses double-hashing to avoid leaking timing +information. Returns a boolean. + +Note that a `Key`-type Fingerprint will always return `false` if asked to match +a `Certificate` and vice versa. Parameters -- `key` -- a `Key` object, the key to match this fingerprint against +- `keyOrCertificate` -- a `Key` object or `Certificate` object, the entity to + match this fingerprint against ## Signatures diff --git a/bin/sshpk-conv b/bin/sshpk-conv index 856a03e..dc86b6e 100755 --- a/bin/sshpk-conv +++ b/bin/sshpk-conv @@ -1,7 +1,7 @@ #!/usr/bin/env node // -*- mode: js -*- // vim: set filetype=javascript : -// Copyright 2015 Joyent, Inc. All rights reserved. +// Copyright 2018 Joyent, Inc. All rights reserved. var dashdash = require('dashdash'); var sshpk = require('../lib/index'); @@ -47,6 +47,21 @@ var options = [ type: 'bool', help: 'Print key metadata instead of converting' }, + { + names: ['fingerprint', 'F'], + type: 'bool', + help: 'Output key fingerprint' + }, + { + names: ['hash', 'H'], + type: 'string', + help: 'Hash function to use for key fingeprint with -F' + }, + { + names: ['spki', 's'], + type: 'bool', + help: 'With -F, generates an SPKI fingerprint instead of SSH' + }, { names: ['comment', 'c'], type: 'string', @@ -75,13 +90,17 @@ if (require.main === module) { var help = parser.help({}).trimRight(); console.error('sshpk-conv: converts between SSH key formats\n'); console.error(help); - console.error('\navailable formats:'); + console.error('\navailable key formats:'); console.error(' - pem, pkcs1 eg id_rsa'); console.error(' - ssh eg id_rsa.pub'); console.error(' - pkcs8 format you want for openssl'); console.error(' - openssh like output of ssh-keygen -o'); console.error(' - rfc4253 raw OpenSSH wire format'); console.error(' - dnssec dnssec-keygen format'); + console.error('\navailable fingerprint formats:'); + console.error(' - hex colon-separated hex for SSH'); + console.error(' straight hex for SPKI'); + console.error(' - base64 SHA256:* format from OpenSSH'); process.exit(1); } @@ -172,18 +191,7 @@ if (require.main === module) { if (opts.comment) key.comment = opts.comment; - if (!opts.identify) { - fmt = undefined; - if (opts.outformat) - fmt = opts.outformat; - outFile.write(key.toBuffer(fmt)); - if (fmt === 'ssh' || - (!opts.private && fmt === undefined)) - outFile.write('\n'); - outFile.once('drain', function () { - process.exit(0); - }); - } else { + if (opts.identify) { var kind = 'public'; if (sshpk.PrivateKey.isPrivateKey(key)) kind = 'private'; @@ -193,10 +201,38 @@ if (require.main === module) { console.log('ECDSA curve: %s', key.curve); if (key.comment) console.log('Comment: %s', key.comment); - console.log('Fingerprint:'); - console.log(' ' + key.fingerprint().toString()); - console.log(' ' + key.fingerprint('md5').toString()); + console.log('SHA256 fingerprint: ' + + key.fingerprint('sha256').toString()); + console.log('MD5 fingerprint: ' + + key.fingerprint('md5').toString()); + console.log('SPKI-SHA256 fingerprint: ' + + key.fingerprint('sha256', 'spki').toString()); process.exit(0); + return; } + + if (opts.fingerprint) { + var hash = opts.hash; + var type = opts.spki ? 'spki' : 'ssh'; + var format = opts.outformat; + var fp = key.fingerprint(hash, type).toString(format); + outFile.write(fp); + outFile.write('\n'); + outFile.once('drain', function () { + process.exit(0); + }); + return; + } + + fmt = undefined; + if (opts.outformat) + fmt = opts.outformat; + outFile.write(key.toBuffer(fmt)); + if (fmt === 'ssh' || + (!opts.private && fmt === undefined)) + outFile.write('\n'); + outFile.once('drain', function () { + process.exit(0); + }); }); } diff --git a/lib/fingerprint.js b/lib/fingerprint.js index 4382f60..0004b37 100644 --- a/lib/fingerprint.js +++ b/lib/fingerprint.js @@ -1,4 +1,4 @@ -// Copyright 2015 Joyent, Inc. +// Copyright 2018 Joyent, Inc. module.exports = Fingerprint; @@ -8,6 +8,7 @@ var algs = require('./algs'); var crypto = require('crypto'); var errs = require('./errors'); var Key = require('./key'); +var PrivateKey = require('./private-key'); var Certificate = require('./certificate'); var utils = require('./utils'); @@ -26,11 +27,12 @@ function Fingerprint(opts) { this.hash = opts.hash; this.type = opts.type; + this.hashType = opts.hashType; } Fingerprint.prototype.toString = function (format) { if (format === undefined) { - if (this.algorithm === 'md5') + if (this.algorithm === 'md5' || this.hashType === 'spki') format = 'hex'; else format = 'base64'; @@ -39,8 +41,12 @@ Fingerprint.prototype.toString = function (format) { switch (format) { case 'hex': + if (this.hashType === 'spki') + return (this.hash.toString('hex')); return (addColons(this.hash.toString('hex'))); case 'base64': + if (this.hashType === 'spki') + return (this.hash.toString('base64')); return (sshBase64Format(this.algorithm, this.hash.toString('base64'))); default: @@ -50,14 +56,20 @@ Fingerprint.prototype.toString = function (format) { Fingerprint.prototype.matches = function (other) { assert.object(other, 'key or certificate'); - if (this.type === 'key') { + if (this.type === 'key' && this.hashType !== 'ssh') { + utils.assertCompatible(other, Key, [1, 7], 'key with spki'); + if (PrivateKey.isPrivateKey(other)) { + utils.assertCompatible(other, PrivateKey, [1, 6], + 'privatekey with spki support'); + } + } else if (this.type === 'key') { utils.assertCompatible(other, Key, [1, 0], 'key'); } else { utils.assertCompatible(other, Certificate, [1, 0], 'certificate'); } - var theirHash = other.hash(this.algorithm); + var theirHash = other.hash(this.algorithm, this.hashType); var theirHash2 = crypto.createHash(this.algorithm). update(theirHash).digest('base64'); @@ -68,6 +80,11 @@ Fingerprint.prototype.matches = function (other) { return (this.hash2 === theirHash2); }; +/*JSSTYLED*/ +var base64RE = /^[A-Za-z0-9+\/=]+$/; +/*JSSTYLED*/ +var hexRE = /^[a-fA-F0-9]+$/; + Fingerprint.parse = function (fp, options) { assert.string(fp, 'fingerprint'); @@ -81,13 +98,18 @@ Fingerprint.parse = function (fp, options) { options = {}; if (options.enAlgs !== undefined) enAlgs = options.enAlgs; + if (options.algorithms !== undefined) + enAlgs = options.algorithms; assert.optionalArrayOfString(enAlgs, 'algorithms'); + var hashType = 'ssh'; + if (options.hashType !== undefined) + hashType = options.hashType; + assert.string(hashType, 'options.hashType'); + var parts = fp.split(':'); if (parts.length == 2) { alg = parts[0].toLowerCase(); - /*JSSTYLED*/ - var base64RE = /^[A-Za-z0-9+\/=]+$/; if (!base64RE.test(parts[1])) throw (new FingerprintFormatError(fp)); try { @@ -107,15 +129,42 @@ Fingerprint.parse = function (fp, options) { return (p); }); parts = parts.join(''); - /*JSSTYLED*/ - var md5RE = /^[a-fA-F0-9]+$/; - if (!md5RE.test(parts) || parts.length % 2 !== 0) + if (!hexRE.test(parts) || parts.length % 2 !== 0) throw (new FingerprintFormatError(fp)); try { hash = Buffer.from(parts, 'hex'); } catch (e) { throw (new FingerprintFormatError(fp)); } + } else { + if (hexRE.test(fp)) { + hash = Buffer.from(fp, 'hex'); + } else if (base64RE.test(fp)) { + hash = Buffer.from(fp, 'base64'); + } else { + throw (new FingerprintFormatError(fp)); + } + + switch (hash.length) { + case 32: + alg = 'sha256'; + break; + case 16: + alg = 'md5'; + break; + case 20: + alg = 'sha1'; + break; + case 64: + alg = 'sha512'; + break; + default: + throw (new FingerprintFormatError(fp)); + } + + /* Plain hex/base64: guess it's probably SPKI unless told. */ + if (options.hashType === undefined) + hashType = 'spki'; } if (alg === undefined) @@ -133,7 +182,8 @@ Fingerprint.parse = function (fp, options) { return (new Fingerprint({ algorithm: alg, hash: hash, - type: options.type || 'key' + type: options.type || 'key', + hashType: hashType })); }; @@ -159,8 +209,9 @@ Fingerprint.isFingerprint = function (obj, ver) { * API versions for Fingerprint: * [1,0] -- initial ver * [1,1] -- first tagged ver + * [1,2] -- hashType and spki support */ -Fingerprint.prototype._sshpkApiVersion = [1, 1]; +Fingerprint.prototype._sshpkApiVersion = [1, 2]; Fingerprint._oldVersionDetect = function (obj) { assert.func(obj.toString); diff --git a/lib/formats/pkcs8.js b/lib/formats/pkcs8.js index aa27427..bf7a06b 100644 --- a/lib/formats/pkcs8.js +++ b/lib/formats/pkcs8.js @@ -1,10 +1,11 @@ -// Copyright 2015 Joyent, Inc. +// Copyright 2018 Joyent, Inc. module.exports = { read: read, readPkcs8: readPkcs8, write: write, writePkcs8: writePkcs8, + pkcs8ToBuffer: pkcs8ToBuffer, readECDSACurve: readECDSACurve, writeECDSACurve: writeECDSACurve @@ -412,6 +413,12 @@ function readPkcs8X25519Private(der) { return (new PrivateKey(key)); } +function pkcs8ToBuffer(key) { + var der = new asn1.BerWriter(); + writePkcs8(der, key); + return (der.buffer); +} + function writePkcs8(der, key) { der.startSequence(); diff --git a/lib/key.js b/lib/key.js index f8ef22d..177204a 100644 --- a/lib/key.js +++ b/lib/key.js @@ -1,4 +1,4 @@ -// Copyright 2017 Joyent, Inc. +// Copyright 2018 Joyent, Inc. module.exports = Key; @@ -98,28 +98,44 @@ Key.prototype.toString = function (format, options) { return (this.toBuffer(format, options).toString()); }; -Key.prototype.hash = function (algo) { +Key.prototype.hash = function (algo, type) { assert.string(algo, 'algorithm'); + assert.optionalString(type, 'type'); + if (type === undefined) + type = 'ssh'; algo = algo.toLowerCase(); if (algs.hashAlgs[algo] === undefined) throw (new InvalidAlgorithmError(algo)); - if (this._hashCache[algo]) - return (this._hashCache[algo]); - var hash = crypto.createHash(algo). - update(this.toBuffer('rfc4253')).digest(); - this._hashCache[algo] = hash; + var cacheKey = algo + '||' + type; + if (this._hashCache[cacheKey]) + return (this._hashCache[cacheKey]); + + var buf; + if (type === 'ssh') { + buf = this.toBuffer('rfc4253'); + } else if (type === 'spki') { + buf = formats.pkcs8.pkcs8ToBuffer(this); + } else { + throw (new Error('Hash type ' + type + ' not supported')); + } + var hash = crypto.createHash(algo).update(buf).digest(); + this._hashCache[cacheKey] = hash; return (hash); }; -Key.prototype.fingerprint = function (algo) { +Key.prototype.fingerprint = function (algo, type) { if (algo === undefined) algo = 'sha256'; + if (type === undefined) + type = 'ssh'; assert.string(algo, 'algorithm'); + assert.string(type, 'type'); var opts = { type: 'key', - hash: this.hash(algo), - algorithm: algo + hash: this.hash(algo, type), + algorithm: algo, + hashType: type }; return (new Fingerprint(opts)); }; @@ -257,8 +273,9 @@ Key.isKey = function (obj, ver) { * [1,4] -- added ed support, createDH * [1,5] -- first explicitly tagged version * [1,6] -- changed ed25519 part names + * [1,7] -- spki hash types */ -Key.prototype._sshpkApiVersion = [1, 6]; +Key.prototype._sshpkApiVersion = [1, 7]; Key._oldVersionDetect = function (obj) { assert.func(obj.toBuffer); diff --git a/lib/private-key.js b/lib/private-key.js index f1e7cab..5600838 100644 --- a/lib/private-key.js +++ b/lib/private-key.js @@ -54,8 +54,12 @@ PrivateKey.prototype.toBuffer = function (format, options) { return (formats[format].write(this, options)); }; -PrivateKey.prototype.hash = function (algo) { - return (this.toPublic().hash(algo)); +PrivateKey.prototype.hash = function (algo, type) { + return (this.toPublic().hash(algo, type)); +}; + +PrivateKey.prototype.fingerprint = function (algo, type) { + return (this.toPublic().fingerprint(algo, type)); }; PrivateKey.prototype.toPublic = function () { @@ -225,8 +229,9 @@ PrivateKey.generate = function (type, options) { * [1,3] -- added derive, ed, createDH * [1,4] -- first tagged version * [1,5] -- changed ed25519 part names and format + * [1,6] -- type arguments for hash() and fingerprint() */ -PrivateKey.prototype._sshpkApiVersion = [1, 5]; +PrivateKey.prototype._sshpkApiVersion = [1, 6]; PrivateKey._oldVersionDetect = function (obj) { assert.func(obj.toPublic); diff --git a/package.json b/package.json index 720e4a5..198d5e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sshpk", - "version": "1.15.2", + "version": "1.16.0", "description": "A library for finding and using SSH public keys", "main": "lib/index.js", "scripts": { diff --git a/test/fingerprint.js b/test/fingerprint.js index 27d04ca..c2eb5c7 100644 --- a/test/fingerprint.js +++ b/test/fingerprint.js @@ -1,9 +1,13 @@ -// Copyright 2011 Joyent, Inc. All rights reserved. +// Copyright 2018 Joyent, Inc. All rights reserved. var test = require('tape').test; +var fs = require('fs'); +var path = require('path'); var sshpk = require('../lib/index'); +var testDir = path.join(__dirname, 'assets'); + var SSH_1024 = 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAvad19ePSDckmgmo6Unqmd8' + 'n2G7o1794VN3FazVhV09yooXIuUhA+7OmT7ChiHueayxSubgL2MrO/HvvF/GGVUs/t3e0u4' + '5YwRC51EVhyDuqthVJWjKrYxgDMbHru8fc1oV51l0bKdmvmJWbA/VyeJvstoX+eiSGT3Jge' + @@ -62,6 +66,38 @@ test('sha256 fingerprint', function(t) { t.end(); }); +test('spki sha256 fingerprint', function (t) { + var k = sshpk.parseKey(SSH_1024, 'ssh'); + var fp2 = k.fingerprint('sha256', 'spki').toString(); + t.equal(fp2, + 'c8f710c0a469a5e0cad31b10175e672665b926f4e75aad5447e9f1005a2aec93'); + var fp = sshpk.parseFingerprint(fp2); + t.ok(fp.matches(k)); + var fp3 = k.fingerprint('sha1', 'spki').toString('base64'); + t.equal(fp3, 'HleMUmn/a1NRW5iUZsEKCe+ojTw='); + t.end(); +}); + +test('fingerprints of a private key', function (t) { + var pem = fs.readFileSync(path.join(testDir, 'id_ecdsa')); + var k = sshpk.parsePrivateKey(pem, 'pem'); + var pk = k.toPublic(); + + var fp1 = k.fingerprint('sha512'); + t.ok(fp1.matches(pk)); + var fp2 = pk.fingerprint('sha512'); + t.ok(fp2.matches(k)); + t.equal(fp1.toString(), fp2.toString()); + + var fp3 = k.fingerprint('sha256', 'spki'); + t.ok(fp3.matches(pk)); + var fp4 = pk.fingerprint('sha256', 'spki'); + t.ok(fp4.matches(k)); + t.equal(fp3.toString(), fp4.toString()); + + t.end(); +}); + test('fingerprint with invalid algo', function(t) { var k = sshpk.parseKey(SSH_1024, 'ssh'); t.throws(function() {