From 29802c1af8da6d01d663161dccc17317d0ca5c81 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 9 Dec 2018 01:28:03 -0700 Subject: [PATCH] v1.2.0: add support for ssh private keys (EC and RSA) --- README.md | 29 ++++++- fixtures/privkey-ec-p256.openssh.pem | 9 +++ fixtures/privkey-ec-p384.openssh.pem | 10 +++ fixtures/privkey-rsa-2048.openssh.pem | 27 +++++++ fixtures/pub-ec-p256.ssh.pub | 1 + fixtures/pub-ec-p384.ssh.pub | 1 + fixtures/pub-rsa-2048.ssh.pub | 1 + lib/pem.js | 28 +++++++ lib/ssh-parser.js | 108 +++++++++++++++++++++++--- package.json | 2 +- 10 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 fixtures/privkey-ec-p256.openssh.pem create mode 100644 fixtures/privkey-ec-p384.openssh.pem create mode 100644 fixtures/privkey-rsa-2048.openssh.pem create mode 100644 fixtures/pub-ec-p256.ssh.pub create mode 100644 fixtures/pub-ec-p384.ssh.pub create mode 100644 fixtures/pub-rsa-2048.ssh.pub create mode 100644 lib/pem.js diff --git a/README.md b/README.md index 3e01e41..65662c3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ Features * [x] SSH Public Keys * fingerprint +* [x] SSH EC Private Keys +* [ ] SSH RSA Private Keys + * `dp` and `dq` values are unavailable * [x] RSA Public Keys * [x] EC Public Keys * P-256 (prime256v1, secp256r1) @@ -29,9 +32,14 @@ Try one of these: ### Need SSH Private Keys? -SSH private keys are just normal PEM files, +Many SSH private keys are just normal PEM files, so you can use Eckles or Rasha, as mentioned above. +As for the [OpenSSH-specific Private Keys](https://coolaj86.com/articles/the-openssh-private-key-format/), +EC is **fully supported**, but RSA has only partial support. + +For more information see the "SSH Private Keys" section at the end of this file. + # CLI You can install `ssh-to-jwk` and use it from command line: @@ -73,6 +81,25 @@ sshtojwk.fingerprint({ pub: pub }).then(function (fingerprint) { }); ``` +# SSH Private Keys + +As mentioned above, EC private keys are fully supported, +and RSA private keys are partially supported. + +It's unlikely that we'll support full SSH-to-JWK conversion for private RSA keys +because OpenSSH omits the `dp` and `dq` values. + +Although they are "optional" (they can be computed from the available values), +to compute them in JavaScript would require a large and expensive BigInt library - +and including (or writing) such a library would require contradicting the +"lightweight" and/or "zero dependency" goals for this library. + +That said, for someone willing to include a BigInt library in their code +it should be trivial to perform the operations to derive `dp` and `dq`. + +If that's you please open an issue because I am interested in creating +a `ssh-to-jwk-bigint` library... I just don't have a use case for it right now. + # Legal [ssh-to-jwk.js](https://git.coolaj86.com/coolaj86/ssh-to-jwk.js) | diff --git a/fixtures/privkey-ec-p256.openssh.pem b/fixtures/privkey-ec-p256.openssh.pem new file mode 100644 index 0000000..124f002 --- /dev/null +++ b/fixtures/privkey-ec-p256.openssh.pem @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQR9WZPeBSvixkhjQOh9yCXXlEx5CN9M +yh94CJJ1rigf8693gc90HmahIR5oMGHwlqMoS7kKrRw+4KpxqsF7LGvxAAAAqJZtgRuWbY +EbAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBH1Zk94FK+LGSGNA +6H3IJdeUTHkI30zKH3gIknWuKB/zr3eBz3QeZqEhHmgwYfCWoyhLuQqtHD7gqnGqwXssa/ +EAAAAgBzKpRmMyXZ4jnSt3ARz0ul6R79AXAr5gQqDAmoFeEKwAAAAOYWpAYm93aWUubG9j +YWwBAg== +-----END OPENSSH PRIVATE KEY----- diff --git a/fixtures/privkey-ec-p384.openssh.pem b/fixtures/privkey-ec-p384.openssh.pem new file mode 100644 index 0000000..9b6d753 --- /dev/null +++ b/fixtures/privkey-ec-p384.openssh.pem @@ -0,0 +1,10 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS +1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQTDNcLzjWUqG5H+grU/Z+RNOsvqH4V5 +qU1UlkUzqTImYvm7ClYgYtXqbReCzLn1E+DOQw1N1f5E/YjPNduLlklsEv3q55k7BDTTiN +k5c15CpCbIV4eWeLRSFSJBGQHlv+sAAADYZYs6wGWLOsAAAAATZWNkc2Etc2hhMi1uaXN0 +cDM4NAAAAAhuaXN0cDM4NAAAAGEEwzXC841lKhuR/oK1P2fkTTrL6h+FealNVJZFM6kyJm +L5uwpWIGLV6m0Xgsy59RPgzkMNTdX+RP2IzzXbi5ZJbBL96ueZOwQ004jZOXNeQqQmyFeH +lni0UhUiQRkB5b/rAAAAMQDbyZ7XRFtCCvdmdYJPkPuMzQBO5VJ1g/9eeFjI2ZLyIhtPh3 +tvrki2EjEi8X4iLroAAAAOYWpAYm93aWUubG9jYWwB +-----END OPENSSH PRIVATE KEY----- diff --git a/fixtures/privkey-rsa-2048.openssh.pem b/fixtures/privkey-rsa-2048.openssh.pem new file mode 100644 index 0000000..b501ce2 --- /dev/null +++ b/fixtures/privkey-rsa-2048.openssh.pem @@ -0,0 +1,27 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEA0zRlF7ykENrG3V3SC83iO7utM4gQx6gm62MVJHa6NCwPEqoppEHs +RynfUgVb68TZt5dS7AkZniIK8ZYcLqbbMoCvNK0V+SYrdgwkT+trcjASBCKi8QJLDBFtc+ +jN1yHSI9o1pC+noukL3q9NyVreE38WRveGsUj8T2h2H+j7G/pWZzH2K1l7VQ/YSQOr0Iyj +apqVKNSNnVqunuhdznqXo37vQs9cjJxLDSRtwjrmyUl1JBHbCWEq1t8H1JzDwa5Z47PLj+ +DQU4pPuUh5qW/qVN/tg44AuLbJ0yJIrrGiyKf6iZkvl9fKRc0QjMto319UHhzD7F5wUr3X +8iHWXyFbOQAAA8gqv28eKr9vHgAAAAdzc2gtcnNhAAABAQDTNGUXvKQQ2sbdXdILzeI7u6 +0ziBDHqCbrYxUkdro0LA8SqimkQexHKd9SBVvrxNm3l1LsCRmeIgrxlhwuptsygK80rRX5 +Jit2DCRP62tyMBIEIqLxAksMEW1z6M3XIdIj2jWkL6ei6Qver03JWt4TfxZG94axSPxPaH +Yf6Psb+lZnMfYrWXtVD9hJA6vQjKNqmpUo1I2dWq6e6F3Oepejfu9Cz1yMnEsNJG3COubJ +SXUkEdsJYSrW3wfUnMPBrlnjs8uP4NBTik+5SHmpb+pU3+2DjgC4tsnTIkiusaLIp/qJmS ++X18pFzRCMy2jfX1QeHMPsXnBSvdfyIdZfIVs5AAAAAwEAAQAAAQAUz+LuVd5s8sIJ6kba +du1GKZZFr7DHm+BJ7beVokVzAqxxkGcOEpjv4kZpVLHcJ8e0earoK3VkycH+UGZyimqrLV +cWf7/cj1BVD5k8btxloisEUU1xJmKyy7zXYSd3fZOxiL0kcrW4LfLHfMrTfqrHjQxq7dVN +/v0t7gNF3bVw6ipIqrO3Z+eDJYIhXZVtSPUmTke8XmAeYELX+IgmWuQTSxaQ8FlICEt86o +K4UNFQ9+i9K54X+lRRhIuqFAel1rXAGXpcMSsTWVTyRpVojunF/r9GzBOohYfgnQ0r/qf+ +lxNgcUlqAxbWp+dR7BexfEn/Xi3M2peg5a1Op/jkPwupAAAAgG0XaH1ZeYlx88Pa5VMnq6 +nZzTnSPS450JawMlFZH8TNQktfhPRd4+xeJB85uW1j57EudVZWOsV9NljwEx75FI81L7AG +3coPrAmE8OWkxUsxtg+gNMja19wmh5x7tDfBo+mv4XxMydRQ51EXn1BMo4EcAZf27GJLkN +yZH4bCBCjDAAAAgQD6rSj9fGzfHVz0eMJ7FHpA/FX5ZZ90ERBweMVCLCvTjkc9r2AFTT9J +Lp2g5vvF4pYO6T+pJEXW98AEteQPNj2MM8WZOmTa1x7FPY9m7VjRjSH4L2dALMF94NMoa/ +4mO7glFtKquGT3TtwxqOmic/jtBhipfgZG906dtYeLOg2KSwAAAIEA17CkB+asl/lX0vie +ylkIseZm74mQutso+bhHGNLx/VEbtn2EV3hiLfslsd4yxnMPTFdZKUTw+sPksYO8/0/3H2 +T18q8XTOm7rs9N5ahWHMmfx+shBpub2e8Z23tk60Hk5O1lzSBH5iktC9mE86BwpohZ1uvp +JffialjvTR5C/gsAAAAOYWpAYm93aWUubG9jYWwBAgMEBQ== +-----END OPENSSH PRIVATE KEY----- diff --git a/fixtures/pub-ec-p256.ssh.pub b/fixtures/pub-ec-p256.ssh.pub new file mode 100644 index 0000000..9943e8f --- /dev/null +++ b/fixtures/pub-ec-p256.ssh.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBH1Zk94FK+LGSGNA6H3IJdeUTHkI30zKH3gIknWuKB/zr3eBz3QeZqEhHmgwYfCWoyhLuQqtHD7gqnGqwXssa/E= aj@bowie.local diff --git a/fixtures/pub-ec-p384.ssh.pub b/fixtures/pub-ec-p384.ssh.pub new file mode 100644 index 0000000..9436533 --- /dev/null +++ b/fixtures/pub-ec-p384.ssh.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBMM1wvONZSobkf6CtT9n5E06y+ofhXmpTVSWRTOpMiZi+bsKViBi1eptF4LMufUT4M5DDU3V/kT9iM8124uWSWwS/ernmTsENNOI2TlzXkKkJshXh5Z4tFIVIkEZAeW/6w== aj@bowie.local diff --git a/fixtures/pub-rsa-2048.ssh.pub b/fixtures/pub-rsa-2048.ssh.pub new file mode 100644 index 0000000..9e76f80 --- /dev/null +++ b/fixtures/pub-rsa-2048.ssh.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDTNGUXvKQQ2sbdXdILzeI7u60ziBDHqCbrYxUkdro0LA8SqimkQexHKd9SBVvrxNm3l1LsCRmeIgrxlhwuptsygK80rRX5Jit2DCRP62tyMBIEIqLxAksMEW1z6M3XIdIj2jWkL6ei6Qver03JWt4TfxZG94axSPxPaHYf6Psb+lZnMfYrWXtVD9hJA6vQjKNqmpUo1I2dWq6e6F3Oepejfu9Cz1yMnEsNJG3COubJSXUkEdsJYSrW3wfUnMPBrlnjs8uP4NBTik+5SHmpb+pU3+2DjgC4tsnTIkiusaLIp/qJmS+X18pFzRCMy2jfX1QeHMPsXnBSvdfyIdZfIVs5 aj@bowie.local diff --git a/lib/pem.js b/lib/pem.js new file mode 100644 index 0000000..76aec96 --- /dev/null +++ b/lib/pem.js @@ -0,0 +1,28 @@ +'use strict'; + +var PEM = module.exports; +var Enc = require('./encoding.js'); + +PEM.parseBlock = function pemToDer(pem) { + var lines = pem.trim().split(/\n/); + var end = lines.length - 1; + var head = lines[0].match(/-----BEGIN (.*)-----/); + var foot = lines[end].match(/-----END (.*)-----/); + + if (head) { + lines = lines.slice(1, end); + head = head[1]; + if (head !== foot[1]) { + throw new Error("headers and footers do not match"); + } + } + + return { type: head, bytes: Enc.base64ToBuf(lines.join('')) }; +}; + +PEM.packBlock = function (opts) { + return '-----BEGIN ' + opts.type + '-----\n' + + Enc.bufToBase64(opts.bytes).match(/.{1,64}/g).join('\n') + '\n' + + '-----END ' + opts.type + '-----' + ; +}; diff --git a/lib/ssh-parser.js b/lib/ssh-parser.js index 7ac0fff..3c18dfd 100644 --- a/lib/ssh-parser.js +++ b/lib/ssh-parser.js @@ -2,11 +2,20 @@ var SSH = module.exports; var Enc = require('./encoding.js'); +var PEM = require('./pem.js'); SSH.parse = function (opts) { var pub = opts.pub || opts; var ssh = SSH.parseBlock(pub); - ssh = SSH.parseElements(ssh); + if ('OPENSSH PRIVATE KEY' === ssh.type) { + ssh = SSH.parsePrivateElements(ssh); + if (opts.public) { + ssh.elements = ssh.elements.slice(0, 3); + } + } else { + ssh.elements = SSH.parseElements(ssh.bytes); + } + //delete ssh.bytes; return SSH.parsePublicKey(ssh); }; @@ -27,6 +36,9 @@ SSH.fingerprint = function (opts) { }; SSH.parseBlock = function (ssh) { + if (/^-----BEGIN OPENSSH PRIVATE KEY-----/.test(ssh)) { + return PEM.parseBlock(ssh); + } ssh = ssh.split(/\s+/g); return { @@ -36,10 +48,69 @@ SSH.parseBlock = function (ssh) { }; }; -SSH.parseElements = function (ssh) { +SSH.parsePrivateElements = function (ssh) { + // https://coolaj86.com/articles/the-openssh-private-key-format/ var buf = ssh.bytes; var fulllen = buf.byteLength || buf.length; var offset = (buf.byteOffset || 0); + var dv = new DataView(buf.buffer.slice(offset, offset + fulllen)); + var index = 0; + var padlen = 0; + var len; + + // The last byte will be either + // * a non-printable pad character + // * a printable comment character + function lastByteIsPad() { + var n = ssh.bytes[(ssh.bytes.bytesLength || ssh.bytes.length) - 1]; + return n >= 0x01 && n <= 0x07; + } + while (lastByteIsPad()) { + padlen += 1; + len = (ssh.bytes.bytesLength || ssh.bytes.length); + ssh.bytes = ssh.bytes.slice(0, len - 1); + } + + // o p e n s s h - k e y - v 1 NULL + // 6f 70 65 6e 73 73 68 2d 6b 65 79 2d 76 31 00 + // 15 characters + // 4-byte len, "none" (encryption) + // 4-byte len, "none" (kdfname) + if ('none' !== Enc.bufToBin(ssh.bytes.slice(15 + 8 + 4, 15 + 8 + 8))) { + throw new Error("Key is either encrypted (not yet supported), corrupt, or not openssh-key-v1"); + } + if (padlen >= 8) { + throw new Error("Padding length should be between 0 and 7, not '" + padlen + "'." + + " Probably not an ssh private key."); + } + // 4-byte len, nil (kdf) + // 4-byte number of keys + index += 15 + 8 + 8 + 4 + 4; + + // length of public key + len = dv.getUint32(index, false); + // throw away public key (it's in the private key) + index += 4 + len; + + // length of dummy checksum + private key + padding + len = dv.getUint32(index, false) - padlen; + // throw away dummy checksum + index += 4 + 8; + + ssh.elements = SSH.parseElements(ssh.bytes.slice(index, index + (len - 8))); + index += Array.prototype.reduce.call(ssh.elements, function (el, sum) { + // 32-bit len + element len + return 4 + (el.byteLength || el.length) + sum; + }, 0); + + // comment will exist, even if it's an empty string + ssh.comment = Enc.bufToBin(ssh.elements.pop()); + return ssh; +}; +SSH.parseElements = function (buf) { + var fulllen = buf.byteLength || buf.length; + // Note: node has weird offsets + var offset = (buf.byteOffset || 0); var i = 0; var index = 0; // using dataview to be browser-compatible (I do want _some_ code reuse) @@ -53,6 +124,7 @@ SSH.parseElements = function (ssh) { if (i > 15) { throw new Error("15+ elements, probably not a public ssh key"); } len = dv.getUint32(index, false); index += 4; + if (0 === len) { continue; } el = buf.slice(index, index + len); // remove BigUInt '00' prefix if (0x00 === el[0]) { @@ -67,8 +139,7 @@ SSH.parseElements = function (ssh) { }).join('\n')); } - ssh.elements = els; - return ssh; + return els; }; SSH.parsePublicKey = function (ssh) { @@ -78,11 +149,26 @@ SSH.parsePublicKey = function (ssh) { // RSA keys are all the same if (SSH.types.rsa === typ) { - ssh.jwk = { - kty: 'RSA' - , n: Enc.bufToUrlBase64(els[2]) - , e: Enc.bufToUrlBase64(els[1]) - }; + if (3 === els.length) { + ssh.jwk = { + kty: 'RSA' + , n: Enc.bufToUrlBase64(els[2]) + , e: Enc.bufToUrlBase64(els[1]) + }; + } else { + console.log('len:', els.length); + ssh.jwk = { + kty: 'RSA' + , n: Enc.bufToUrlBase64(els[2]) + , e: Enc.bufToUrlBase64(els[1]) + , d: Enc.bufToUrlBase64(els[3]) + , p: Enc.bufToUrlBase64(els[5]) + , q: Enc.bufToUrlBase64(els[6]) + //, dp: Enc.bufToUrlBase64(els[x]) + //, dq: Enc.bufToUrlBase64(els[x]) + , qi: Enc.bufToUrlBase64(els[4]) + }; + } return ssh; } @@ -101,11 +187,15 @@ SSH.parsePublicKey = function (ssh) { // els[1] is just a repeat of a subset of els[0] var x = els[2].slice(1, 1 + len); var y = els[2].slice(1 + len, 1 + len + len); + var d; // I don't think EC keys use 0x00 padding, but just in case while (0x00 === x[0]) { x = x.slice(1); } while (0x00 === y[0]) { y = y.slice(1); } + if (els[3]) { + ssh.jwk.d = Enc.bufToUrlBase64(els[3]); + } ssh.jwk.x = Enc.bufToUrlBase64(x); ssh.jwk.y = Enc.bufToUrlBase64(y); diff --git a/package.json b/package.json index dd67052..4833e7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ssh-to-jwk", - "version": "1.1.0", + "version": "1.2.0", "description": "💯 SSH to JWK in a lightweight, zero-dependency library.", "homepage": "https://git.coolaj86.com/coolaj86/ssh-to-jwk.js", "main": "index.js",