From 96d47924bf605016697688dbd228b7d8b9aed7a5 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 1 Dec 2018 20:32:22 -0700 Subject: [PATCH] v1.0.0: parse EC and RSA ssh public keys --- README.md | 65 +++++++++++++++++++++++++++ bin/ssh-to-jwk.js | 17 +++++++ index.js | 1 + lib/encoding.js | 28 ++++++++++++ lib/ssh-parser.js | 101 +++++++++++++++++++++++++++++++++++++++++ lib/telemetry.js | 111 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 37 ++++++++++++++++ 7 files changed, 360 insertions(+) create mode 100644 README.md create mode 100755 bin/ssh-to-jwk.js create mode 100644 index.js create mode 100644 lib/encoding.js create mode 100644 lib/ssh-parser.js create mode 100644 lib/telemetry.js create mode 100644 package.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f8b1cd --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# SSH to JWK (for node.js) + +A minimal library to parse an SSH public key (`id_rsa.pub`) +and convert it into a public JWK. + +Works for RSA and ECDSA public keys. + +Features +======== + +< 100 lines of code | <1kb gzipped | 1.8kb minified | 3.1kb with comments + +* [x] SSH Public Keys +* [x] RSA Public Keys +* [x] EC Public Keys + * P-256 (prime256v1, secp256r1) + * P-384 (secp384r1) +* [ ] Browser Version (coming soon) + +### Need JWK to SSH? SSH to PEM? + +Try one of these: + +* [jwk-to-ssh.js](https://git.coolaj86.com/coolaj86/jwk-to-ssh.js) (RSA + EC) +* [Eckles.js](https://git.coolaj86.com/coolaj86/eckles.js) (more EC utils) +* [Rasha.js](https://git.coolaj86.com/coolaj86/eckles.js) (more RSA utils) + +### Need SSH Private Keys? + +SSH private keys are just normal PEM files, +so you can use Eckles or Rasha, as mentioned above. + +# CLI + +You can install `ssh-to-jwk` and use it from command line: + +```bash +npm install -g ssh-to-jwk +``` + +```bash +ssh-to-jwk ~/.ssh/id_rsa.pub +``` + +# Usage + +You can also use it from JavaScript: + +```js +var fs = require('fs'); +var sshtojwk = require('sshtojwk'); + +var pub = fs.readFileSync("./id_rsa.pub"); +var jwk = sshtojwk(pub); + +console.info(jwk); +``` + +Legal +----- + +[ssh-to-jwk.js](https://git.coolaj86.com/coolaj86/ssh-to-jwk.js) | +MPL-2.0 | +[Terms of Use](https://therootcompany.com/legal/#terms) | +[Privacy Policy](https://therootcompany.com/legal/#privacy) diff --git a/bin/ssh-to-jwk.js b/bin/ssh-to-jwk.js new file mode 100755 index 0000000..76ac5b2 --- /dev/null +++ b/bin/ssh-to-jwk.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var sshtojwk = require('../index.js'); + +var pubfile = process.argv[2]; + +if (!pubfile) { + pubfile = path.join(require('os').homedir(), '.ssh/id_rsa.pub'); +} + +var buf = fs.readFileSync(pubfile); +var ssh = sshtojwk.parse(buf.toString('ascii')); + +console.info(JSON.stringify(ssh.jwk, null, 2)); diff --git a/index.js b/index.js new file mode 100644 index 0000000..9cb5e9a --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/ssh-parser.js'); diff --git a/lib/encoding.js b/lib/encoding.js new file mode 100644 index 0000000..8aabcca --- /dev/null +++ b/lib/encoding.js @@ -0,0 +1,28 @@ +'use strict'; + +var Enc = module.exports; + +Enc.base64ToBuf = function (str) { + // node handles both base64 and urlBase64 equally + return Buffer.from(str, 'base64'); +}; + +Enc.bufToBase64 = function (u8) { + // Ensure a node buffer, even if TypedArray + return Buffer.from(u8).toString('base64'); +}; + +Enc.bufToBin = function (u8) { + // Ensure a node buffer, even if TypedArray + return Buffer.from(u8).toString('binary'); +}; + +Enc.bufToHex = function (u8) { + // Ensure a node buffer, even if TypedArray + return Buffer.from(u8).toString('hex'); +}; + +Enc.bufToUrlBase64 = function (u8) { + return Enc.bufToBase64(u8) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; diff --git a/lib/ssh-parser.js b/lib/ssh-parser.js new file mode 100644 index 0000000..752e823 --- /dev/null +++ b/lib/ssh-parser.js @@ -0,0 +1,101 @@ +'use strict'; + +var SSH = module.exports; +var Enc = require('./encoding.js'); + +SSH.parse = function (ssh) { + ssh = ssh.split(/\s+/g); + + var result = { type: ssh[0], jwk: null, comment: ssh[2] || '' }; + var buf = Enc.base64ToBuf(ssh[1]); + var els = SSH.parseElements(buf); + var typ = Enc.bufToBin(els[0]); + var len; + + // RSA keys are all the same + if (SSH.types.rsa === typ) { + result.jwk = { + kty: 'RSA' + , n: Enc.bufToUrlBase64(els[2]) + , e: Enc.bufToUrlBase64(els[1]) + }; + return result; + } + + // EC keys are each different + if (SSH.types.p256 === typ) { + len = 32; + result.jwk = { kty: 'EC', crv: 'P-256' }; + } else if (SSH.types.p384 === typ) { + len = 48; + result.jwk = { kty: 'EC', crv: 'P-384' }; + } else { + throw new Error("Unsupported ssh public key type: " + + Enc.bufToBin(els[0])); + } + + // 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); + + // I don't think EC keys use 0x00 padding, but just in case + if (0x00 === x[0]) { x = x.slice(1); } + if (0x00 === y[0]) { y = y.slice(1); } + + result.jwk.x = Enc.bufToUrlBase64(x); + result.jwk.y = Enc.bufToUrlBase64(y); + + return result; +}; + +SSH.parseElements = function (buf) { + var fulllen = buf.byteLength || buf.length; + var offset = (buf.byteOffset || 0); + var i = 0; + var index = 0; + // using dataview to be browser-compatible (I do want _some_ code reuse) + var dv = new DataView(buf.buffer.slice(offset, offset + fulllen)); + var els = []; + var el; + var len; + + while (index < fulllen) { + i += 1; + if (i > 15) { throw new Error("15+ elements, probably not a public ssh key"); } + len = dv.getUint32(index, false); + index += 4; + el = buf.slice(index, index + len); + // remove BigUInt '00' prefix + if (0x00 === el[0]) { + el = el.slice(1); + } + els.push(el); + index += len; + } + if (fulllen !== index) { + throw new Error(els.map(function (b) { + return Enc.bufToHex(b); + }).join('\n') + "invalid ssh public key length"); + } + + return els; +}; + +SSH.types = { + // 19 '00000013' + // e c d s a - s h a 2 - n i s t p 2 5 6 + // 65636473612d736861322d6e69737470323536 + // 6e69737470323536 + p256: 'ecdsa-sha2-nistp256' + + // 19 '00000013' + // e c d s a - s h a 2 - n i s t p 3 8 4 + // 65636473612d736861322d6e69737470333834 + // 6e69737470323536 +, p384: 'ecdsa-sha2-nistp384' + + // 7 '00000007' + // s s h - r s a + // 7373682d727361 +, rsa: 'ssh-rsa' +}; diff --git a/lib/telemetry.js b/lib/telemetry.js new file mode 100644 index 0000000..9623b77 --- /dev/null +++ b/lib/telemetry.js @@ -0,0 +1,111 @@ +'use strict'; + +// We believe in a proactive approach to sustainable open source. +// As part of that we make it easy for you to opt-in to following our progress +// and we also stay up-to-date on telemetry such as operating system and node +// version so that we can focus our efforts where they'll have the greatest impact. +// +// Want to learn more about our Terms, Privacy Policy, and Mission? +// Check out https://therootcompany.com/legal/ + +var os = require('os'); +var crypto = require('crypto'); +var https = require('https'); +var pkg = require('../package.json'); + +// to help focus our efforts in the right places +var data = { + package: pkg.name +, version: pkg.version +, node: process.version +, arch: process.arch || os.arch() +, platform: process.platform || os.platform() +, release: os.release() +}; + +function addCommunityMember(opts) { + setTimeout(function () { + var req = https.request({ + hostname: 'api.therootcompany.com' + , port: 443 + , path: '/api/therootcompany.com/public/community' + , method: 'POST' + , headers: { 'Content-Type': 'application/json' } + }, function (resp) { + // let the data flow, so we can ignore it + resp.on('data', function () {}); + //resp.on('data', function (chunk) { console.log(chunk.toString()); }); + resp.on('error', function () { /*ignore*/ }); + //resp.on('error', function (err) { console.error(err); }); + }); + var obj = JSON.parse(JSON.stringify(data)); + obj.action = 'updates'; + try { + obj.ppid = ppid(obj.action); + } catch(e) { + // ignore + //console.error(e); + } + obj.name = opts.name || undefined; + obj.address = opts.email; + obj.community = 'node.js@therootcompany.com'; + + req.write(JSON.stringify(obj, 2, null)); + req.end(); + req.on('error', function () { /*ignore*/ }); + //req.on('error', function (err) { console.error(err); }); + }, 50); +} + +function ping(action) { + setTimeout(function () { + var req = https.request({ + hostname: 'api.therootcompany.com' + , port: 443 + , path: '/api/therootcompany.com/public/ping' + , method: 'POST' + , headers: { 'Content-Type': 'application/json' } + }, function (resp) { + // let the data flow, so we can ignore it + resp.on('data', function () { }); + //resp.on('data', function (chunk) { console.log(chunk.toString()); }); + resp.on('error', function () { /*ignore*/ }); + //resp.on('error', function (err) { console.error(err); }); + }); + var obj = JSON.parse(JSON.stringify(data)); + obj.action = action; + try { + obj.ppid = ppid(obj.action); + } catch(e) { + // ignore + //console.error(e); + } + + req.write(JSON.stringify(obj, 2, null)); + req.end(); + req.on('error', function (/*e*/) { /*console.error('req.error', e);*/ }); + }, 50); +} + +// to help identify unique installs without getting +// the personally identifiable info that we don't want +function ppid(action) { + var parts = [ action, data.package, data.version, data.node, data.arch, data.platform, data.release ]; + var ifaces = os.networkInterfaces(); + Object.keys(ifaces).forEach(function (ifname) { + if (/^en/.test(ifname) || /^eth/.test(ifname) || /^wl/.test(ifname)) { + if (ifaces[ifname] && ifaces[ifname].length) { + parts.push(ifaces[ifname][0].mac); + } + } + }); + return crypto.createHash('sha1').update(parts.join(',')).digest('base64'); +} + +module.exports.ping = ping; +module.exports.joinCommunity = addCommunityMember; + +if (require.main === module) { + ping('install'); + //addCommunityMember({ name: "AJ ONeal", email: 'coolaj86@gmail.com' }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4d799e1 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "ssh-to-jwk", + "version": "1.0.0", + "description": "💯 SSH to JWK in a lightweight, zero-dependency library.", + "homepage": "https://git.coolaj86.com/coolaj86/ssh-to-jwk.js", + "main": "index.js", + "bin": { + "ssh-to-jwk": "bin/ssh-to-jwk.js" + }, + "files": [ + "bin", + "fixtures", + "lib" + ], + "directories": { + "lib": "lib" + }, + "scripts": { + "postinstall": "node lib/telemetry.js event:install", + "test": "bash test.sh" + }, + "repository": { + "type": "git", + "url": "https://git.coolaj86.com/coolaj86/ssh-to-jwk.js" + }, + "keywords": [ + "zero-dependency", + "SSH-to-JWK", + "RSA", + "EC", + "SSH", + "JWK", + "ECDSA" + ], + "author": "AJ ONeal (https://coolaj86.com/)", + "license": "MPL-2.0" +}