From aa49d05c5883a1bb49eefeedd44f92a15004d1fc Mon Sep 17 00:00:00 2001 From: Patrick Roumanoff Date: Thu, 3 Sep 2015 21:58:00 +1000 Subject: [PATCH] working state --- README.md | 1 + base64url.js | 21 +++++++++ bower.json | 25 +++++++++++ index.html | 72 ++++++++++++++++++++++++++++++ keypair.js | 70 +++++++++++++++++++++++++++++ package.json | 14 ++++++ qunit.html | 17 +++++++ ssh-util.js | 118 +++++++++++++++++++++++++++++++++++++++++++++++++ tests.js | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 460 insertions(+) create mode 100644 README.md create mode 100644 base64url.js create mode 100644 bower.json create mode 100644 index.html create mode 100644 keypair.js create mode 100644 package.json create mode 100644 qunit.html create mode 100644 ssh-util.js create mode 100644 tests.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..10e53d6 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Generate a keypair using the webcrypto API diff --git a/base64url.js b/base64url.js new file mode 100644 index 0000000..fb2a67c --- /dev/null +++ b/base64url.js @@ -0,0 +1,21 @@ +//adapted from https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-08#appendix-C + +function base64urlEncode(s) { + var s = window.btoa(arg); // Regular base64 encoder + s = s.split('=')[0]; // Remove any trailing '='s + s = s.replace(/+/g, '-'); // 62nd char of encoding + s = s.replace(/\//g, '_'); // 63rd char of encoding + return s; +} + +function base64urlDecode(s) { + s = s.replace(/-/g, '+'); // 62nd char of encoding + s = s.replace(/_/g, '/'); // 63rd char of encoding + switch (s.length % 4) { // Pad with trailing '='s + case 0: break; // No pad chars in this case + case 2: s += "=="; break; // Two pad chars + case 3: s += "="; break; // One pad char + default: throw "Illegal base64url string!"; + } + return window.atob(s); // Standard base64 decoder +} \ No newline at end of file diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..82d6e0b --- /dev/null +++ b/bower.json @@ -0,0 +1,25 @@ +{ + "name": "js-keypair", + "version": "0.0.0", + "authors": [ + "Patrick Roumanoff " + ], + "description": "generate keypair in the browser", + "main": "index.html", + "moduleType": [ + "globals" + ], + "license": "MIT", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ], + "dependencies": { + }, + "devDependencies": { + "qunit": "~1.19.0" + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..d4c9a3b --- /dev/null +++ b/index.html @@ -0,0 +1,72 @@ + + + + Key Pair generation for SSH access + + + + + + +
+
+
+
+ +
+
+ + +Private Key or
+ + +
+Public Key or
+ + + + + + \ No newline at end of file diff --git a/keypair.js b/keypair.js new file mode 100644 index 0000000..a5e6240 --- /dev/null +++ b/keypair.js @@ -0,0 +1,70 @@ +var extractable = true; + +function wrap(text, len) { + var length = len || 72, i, result = ""; + for(i=0; i < text.length; i += length) { + result += text.slice(i, i + length) + "\n"; + } + return result; +} + +function rsaPrivateKey(key) { + return "-----BEGIN RSA PRIVATE KEY-----\n" + key + "-----END RSA PRIVATE KEY-----"; +} + +function arrayBufferToBase64( buffer ) { + var binary = ''; + var bytes = new Uint8Array( buffer ); + var len = bytes.byteLength; + for (var i = 0; i < len; i++) { + binary += String.fromCharCode( bytes[ i ] ); + } + return window.btoa( binary ); +} + +function generateKeyPair(alg, size, name) { + return window.crypto.subtle.generateKey({ + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, //can be 1024, 2048, or 4096 + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: {name: "SHA-1"}, //can be "SHA-1", "SHA-256", "SHA-384", or "SHA-512" + }, + extractable, + ["sign", "verify"] + ).then(function(key){ + + var private = window.crypto.subtle.exportKey( + "jwk", + key.privateKey + ).then(encodePrivateKey).then(wrap).then(rsaPrivateKey); + + var public = window.crypto.subtle.exportKey( + "jwk", + key.publicKey + ).then(function(jwk){ + return encodePublicKey(jwk, name); + }); + + return Promise.all([private, public]); + }); +} + +function buildHref(data) { + return "data:application/octet-stream;charset=utf-8;base64," + window.btoa(data); +} + +document.addEventListener("DOMContentLoaded", function(event) { + document.querySelector('#generate').addEventListener('click', function(event) { + var name = document.querySelector('#name').value || "name"; + var alg = document.querySelector('#alg').value || "RSASSA-PKCS1-v1_5"; + var size = parseInt(document.querySelector('#size').value || "2048"); + generateKeyPair(alg, size, name).then(function (keys) { + document.querySelector('#private').setAttribute("href", buildHref(keys[0])); + document.querySelector('#public').setAttribute("href", buildHref(keys[1])); + document.querySelector('#privateKey').textContent = keys[0]; + document.querySelector('#publicKey').textContent = keys[1]; + }).catch(function(err){ + console.error(err); + }); + }); +}); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..45eca41 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "js-keypair", + "version": "1.0.0", + "description": "Generate a key pair using the webcrypto API", + "main": "keypair.js", + "dependencies": { + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "patrick@roumanoff.com", + "license": "Apache 2.0" +} diff --git a/qunit.html b/qunit.html new file mode 100644 index 0000000..c616fae --- /dev/null +++ b/qunit.html @@ -0,0 +1,17 @@ + + + + + QUnit Example + + + +
+
+ + + + + + + \ No newline at end of file diff --git a/ssh-util.js b/ssh-util.js new file mode 100644 index 0000000..7e9b5f3 --- /dev/null +++ b/ssh-util.js @@ -0,0 +1,118 @@ +function arrayToString(a) { + return String.fromCharCode.apply(null, a); +} + +function stringToArray(s) { + return s.split('').map(function (c) { + return c.charCodeAt(); + }); +} + +function base64urlToArray(s) { + return stringToArray(base64urlDecode(s)); +} + +function pemToArray(pem) { + return stringToArray(window.atob(pem)); +} + +function arrayToPem(a) { + return window.btoa(a.map(function (c) { + return String.fromCharCode(c); + }).join('')); +} + +function arrayToLen(a) { + var result = 0, i; + for(i = 0; i < a.length; i++) { + result = result * 256 + a[i]; + } + return result; +} + +function integerToOctet(n) { + var result = []; + for(;n > 0; n = n >> 8 ) { + result.push(n & 0xFF); + } + return result.reverse(); +} + +function lenToArray(n) { + var oct = integerToOctet(n), i; + for(i = oct.length; i < 4; i++) { + oct.unshift(0); + } + return oct; +} + +function decodePublicKey(s) { + var split = s.split(" "); + var prefix = split[0]; + if(prefix != "ssh-rsa") { + throw ("Unknown prefix:" + prefix); + } + var buffer = pemToArray(split[1]); + var nameLen = arrayToLen(buffer.splice(0, 4)); + var type = arrayToString(buffer.splice(0, nameLen)); + if(type != "ssh-rsa") { + throw ("Unknown key type:" + type); + } + var exponentLen = arrayToLen(buffer.splice(0, 4)); + var exponent = buffer.splice(0, exponentLen); + var keyLen = arrayToLen(buffer.splice(0, 4)); + var key = buffer.splice(0, keyLen); + return {type: type, exponent: exponent, key: key, name: split[2]}; +} + +function jwkToInternal(jwk) { + return { + type: "ssh-rsa", + exponent: checkHighestBit(stringToArray(base64urlDecode(jwk.e))), + name: "name", + key: checkHighestBit(stringToArray(base64urlDecode(jwk.n))) + }; +} + +function checkHighestBit(v) { + if(v[0] >> 7 === 1) { // add leading zero if first bit is set + v.unshift(0); + } + return v; +} + +function encodePublicKey(jwk, name) { + var k = jwkToInternal(jwk); + k.name = name; + var keyLenA = lenToArray(k.key.length); + var exponentLenA = lenToArray(k.exponent.length); + var typeLenA = lenToArray(k.type.length); + var array = [].concat(typeLenA, stringToArray(k.type), exponentLenA, k.exponent, keyLenA, k.key); + var encoding = arrayToPem(array); + return k.type + " " + encoding + " " + k.name; +} + +function asnEncodeLen(n) { + var result = []; + if(n >> 7) { + result = integerToOctet(n); + result.unshift(0x80 + result.length); + } else { + result.push(n); + } + return result; +} + +function encodePrivateKey(jwk) { + var order = ["n", "e", "d", "p", "q", "dp", "dq", "qi"]; + var list = order.map(function (prop) { + var v = checkHighestBit(stringToArray(base64urlDecode(jwk[prop]))); + var len = asnEncodeLen(v.length); + return [0x02].concat(len, v); // int tag is 0x02 + }); + var seq = [0x02, 0x01, 0x00]; // extra seq for SSH + seq = seq.concat.apply(seq, list); + var len = asnEncodeLen(seq.length); + var a = [0x30].concat(len , seq); // seq is 0x30 + return arrayToPem(a); +} \ No newline at end of file diff --git a/tests.js b/tests.js new file mode 100644 index 0000000..b906f65 --- /dev/null +++ b/tests.js @@ -0,0 +1,122 @@ +var public_ssh = ["ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCwi36YMW0eDS3NXSAM/Gcs0txeLOcZE0LQmGPYmHX09Fm1FC9AdzvDWQIfwVylqNy8G6X8+pE0TMuWav4rQjtWRls3j43LdrXkfaTZV2PNJH0ki2zaCND3cz46hBR1bSwi3O4LoN0ZHXoC4ZXoMBXKtYEOg+9jS+pE3vu2QSPruiRROTOYYvrjWx0Bwi8DJc90TmNVeqvPjewPAm4qaTdmh96jIgJQq+vAdhDHu90i31Kl3JUF94x6pzFmg8ZyXOv0Py2GtK9c5To3C33FXI8yTm/sf2Bp7fwd3MEGNcdVNqa7Tt0z2u5Jcmsws93SZuj4iVjbR6xqme9EmIa3BTB7 name", +"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCuew50MaphQgiuM6H7zxMspNojI2Ujf77MuWlAjmw1JxcTkfE7JKzV+9fqmESJNtnZSr3+I2dxQhJ72jttrz+2dFt9ol91muTPWzKrA8XXIBH2o7sEJ+QB8/q7S03d+Zgw6tlo+qdXLOWcKqL5MJhYwzTFEdGTSMF00cBFadcpDq1xFPygGTHRa7m3pK723nGz7TMGWmtBK2bHx+Zlp7geLK/7hl+NRG1lTyIbtdkP2T4Y81Z0bhz9kNHroUei3MFD6HvN93qMJWl3/LZZzTb++1BedNeybGKqbtsB3xp0v3c6bQy49wR3RwrAwL03AKbCwTawAufSeoXyRI+rtgZ/ name", +"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCpNNtjZeldPuJ5ZgcjO4i6eSJb6kiuf1sULOoWaW9acwSxAfqrmN6Hn7VGg3GK3kSKJUmBKMsF2u+ECajVBec+OTMlbL7oZrYNl2neUYsI7O0G/8lpozZjADYu8CaMqVSAeTa3ORga9Ht/qgCpqXIyEcTsFSbZ45hhaZF0fXQ0GHDCkV/ylBduQHxheCe1SPBSWIO2BwqSlGx/Q76lkL/BnGdcx7xVi3h2yNbEGxqzFuPK75VADZfWria4x09rTqvu41GWIyqzFcbB7BxNImVNh6WVk/qKTcXbfWwH8ck9Cd5bX9g36QaImZ6tW8i/bl3o75bGgP2hSWpsNx8CMVn9 name"]; +var public_ssh_decoded = [{ + type: "ssh-rsa", + exponent: [1,0,1], + name: "name", + key: [0,176,139,126,152,49,109,30,13,45,205,93,32,12,252,103,44,210,220,94,44,231,25,19,66,208,152,99,216,152,117,244,244,89,181,20,47,64,119,59,195,89,2,31,193,92,165,168,220,188,27,165,252,250,145,52,76,203,150,106,254,43,66,59,86,70,91,55,143,141,203,118,181,228,125,164,217,87,99,205,36,125,36,139,108,218,8,208,247,115,62,58,132,20,117,109,44,34,220,238,11,160,221,25,29,122,2,225,149,232,48,21,202,181,129,14,131,239,99,75,234,68,222,251,182,65,35,235,186,36,81,57,51,152,98,250,227,91,29,1,194,47,3,37,207,116,78,99,85,122,171,207,141,236,15,2,110,42,105,55,102,135,222,163,34,2,80,171,235,192,118,16,199,187,221,34,223,82,165,220,149,5,247,140,122,167,49,102,131,198,114,92,235,244,63,45,134,180,175,92,229,58,55,11,125,197,92,143,50,78,111,236,127,96,105,237,252,29,220,193,6,53,199,85,54,166,187,78,221,51,218,238,73,114,107,48,179,221,210,102,232,248,137,88,219,71,172,106,153,239,68,152,134,183,5,48,123] +}]; + + var jwk_public = {"alg":"RS1","e":"AQAB","ext":true,"key_ops":["verify"],"kty":"RSA","n":"3PWJ6uDsFPgQo67of3IYw0Svyq95SNh9GS-2gorv68GxWIYYeAShaG_UtTf8mvf6u-VIUr54Re2FoLc78ICR3nRhFH5D1_fNaP9hkMAHBqaJ8ATiq4d7-PfeXTCi0yY0qfWkGjuPtOC3IK7WmnEkiA5qUVpy0oHFPiqoAyNynWJRDFJka00JEpM1QFyF1Tz3PEGp0XlFnClY48iJG9UqXlDgaysnG3ro2sDm8ftva0IjX1Sp7Z9FyWQci-yOYfST00wKHQd7z5-Eo3cTd5M0BhcVXeR0gprdK1TTDLZLznFJQ36HYwrUFEXvTyme6vkZfNPRb0z8KPq5Gs7dujE_cw"}; + +var jwk_private = { + "kty":"RSA", + "kid":"juliet@capulet.lit", + "use":"enc", + "n":"t6Q8PWSi1dkJj9hTP8hNYFlvadM7DflW9mWepOJhJ66w7nyoK1gPNqFMSQRyO125Gp-TEkodhWr0iujjHVx7BcV0llS4w5ACGgPrcAd6ZcSR0-Iqom-QFcNP8Sjg086MwoqQU_LYywlAGZ21WSdS_PERyGFiNnj3QQlO8Yns5jCtLCRwLHL0Pb1fEv45AuRIuUfVcPySBWYnDyGxvjYGDSM-AqWS9zIQ2ZilgT-GqUmipg0XOC0Cc20rgLe2ymLHjpHciCKVAbY5-L32-lSeZO-Os6U15_aXrk9Gw8cPUaX1_I8sLGuSiVdt3C_Fn2PZ3Z8i744FPFGGcG1qs2Wz-Q", + "e":"AQAB", + "d":"GRtbIQmhOZtyszfgKdg4u_N-R_mZGU_9k7JQ_jn1DnfTuMdSNprTeaSTyWfSNkuaAwnOEbIQVy1IQbWVV25NY3ybc_IhUJtfri7bAXYEReWaCl3hdlPKXy9UvqPYGR0kIXTQRqns-dVJ7jahlI7LyckrpTmrM8dWBo4_PMaenNnPiQgO0xnuToxutRZJfJvG4Ox4ka3GORQd9CsCZ2vsUDmsXOfUENOyMqADC6p1M3h33tsurY15k9qMSpG9OX_IJAXmxzAh_tWiZOwk2K4yxH9tS3Lq1yX8C1EWmeRDkK2ahecG85-oLKQt5VEpWHKmjOi_gJSdSgqcN96X52esAQ", + "p":"2rnSOV4hKSN8sS4CgcQHFbs08XboFDqKum3sc4h3GRxrTmQdl1ZK9uw-PIHfQP0FkxXVrx-WE-ZEbrqivH_2iCLUS7wAl6XvARt1KkIaUxPPSYB9yk31s0Q8UK96E3_OrADAYtAJs-M3JxCLfNgqh56HDnETTQhH3rCT5T3yJws", + "q":"1u_RiFDP7LBYh3N4GXLT9OpSKYP0uQZyiaZwBtOCBNJgQxaj10RWjsZu0c6Iedis4S7B_coSKB0Kj9PaPaBzg-IySRvvcQuPamQu66riMhjVtG6TlV8CLCYKrYl52ziqK0E_ym2QnkwsUX7eYTB7LbAHRK9GqocDE5B0f808I4s", + "dp":"KkMTWqBUefVwZ2_Dbj1pPQqyHSHjj90L5x_MOzqYAJMcLMZtbUtwKqvVDq3tbEo3ZIcohbDtt6SbfmWzggabpQxNxuBpoOOf_a_HgMXK_lhqigI4y_kqS1wY52IwjUn5rgRrJ-yYo1h41KR-vz2pYhEAeYrhttWtxVqLCRViD6c", + "dq":"AvfS0-gRxvn0bwJoMSnFxYcK1WnuEjQFluMGfwGitQBWtfZ1Er7t1xDkbN9GQTB9yqpDoYaN06H7CFtrkxhJIBQaj6nkF5KKS3TQtQ5qCzkOkmxIe3KRbBymXxkb5qwUpX5ELD5xFc6FeiafWYY63TmmEAu_lRFCOJ3xDea-ots", + "qi":"lSQi-w9CpyUReMErP1RsBLk7wNtOvs5EQpPqmuMvqW57NBUczScEoPwmUqqabu9V0-Py4dQ57_bapoKRu1R90bvuFnU63SHWEFglZQvJDMeAvmj4sm-Fp0oYu_neotgQ0hzbI5gry7ajdYy9-2lNx_76aBZoOUu9HCJ-UsfSOI8" +}; + +var tags = { + SEQUENCE: 0x30, + INTEGER: 0x02, + BIT_STRING: 0x03, + OCTET_STRING: 0x04, + NULL: 0x05, + OBJECT_IDENTIFIER: 0x06, + SET: 0x11, + PrintableString: 0x13, + T61String: 0x14, + IA5String: 0x16, + UTCTime: 0x17, +}; + +QUnit.test("array to PEM", function(assert) { + var a = [1,2,3]; + var p = arrayToPem(a); + var a2 = pemToArray(p); + assert.deepEqual(a2, a, "can you count?"); +}); + +QUnit.test("array to String", function(assert) { + var a = "ssh-rsa".split('').map(function (c) {return c.charCodeAt();}); + assert.equal(arrayToString(pemToArray(arrayToPem(a))), "ssh-rsa"); +}); + +QUnit.test("lenToArray", function(assert) { + var a = 66051; + assert.deepEqual(lenToArray(a), [0, 1, 2, 3]); +}); + +QUnit.test("arrayToLen", function(assert) { + var a = [0, 1, 2, 3]; + assert.deepEqual(arrayToLen(a), 66051); +}); + +public_ssh.forEach(function (public, index) { + QUnit.test("decoding ssh public key " + index, function(assert) { + var key = decodePublicKey(public); + assert.equal(key.type, "ssh-rsa", "type"); + assert.equal(key.name, "name", "name"); + if(index===0) { + assert.deepEqual(key.key, public_ssh_decoded[0].key, "key"); + } + }) +}); + +QUnit.test("Encoding ssh public key", function (assert) { + var result = encodePublicKey(jwk_public, "name"); + assert.equal(result, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDc9Ynq4OwU+BCjruh/chjDRK/Kr3lI2H0ZL7aCiu/rwbFYhhh4BKFob9S1N/ya9/q75UhSvnhF7YWgtzvwgJHedGEUfkPX981o/2GQwAcGponwBOKrh3v4995dMKLTJjSp9aQaO4+04LcgrtaacSSIDmpRWnLSgcU+KqgDI3KdYlEMUmRrTQkSkzVAXIXVPPc8QanReUWcKVjjyIkb1SpeUOBrKycbeujawObx+29rQiNfVKntn0XJZByL7I5h9JPTTAodB3vPn4SjdxN3kzQGFxVd5HSCmt0rVNMMtkvOcUlDfodjCtQURe9PKZ7q+Rl809FvTPwo+rkazt26MT9z name"); +}); + +QUnit.test("base64url", function (assert){ + var result = stringToArray(base64urlDecode(jwk_public.n)); + assert.equal(result.length, 256); + +}); + +QUnit.test("high bit", function (assert) { + assert.deepEqual(checkHighestBit([0x80]), [0x00, 0x80]); + assert.deepEqual(checkHighestBit([0x0F]), [0x0F]); +}); + +QUnit.test("jwk", function (assert){ + var sshkey = encodePublicKey(jwk_public, "name"); + assert.deepEqual(stringToArray(base64urlDecode(jwk_public.e)), [1,0,1]); + assert.equal(sshkey, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDc9Ynq4OwU+BCjruh/chjDRK/Kr3lI2H0ZL7aCiu/rwbFYhhh4BKFob9S1N/ya9/q75UhSvnhF7YWgtzvwgJHedGEUfkPX981o/2GQwAcGponwBOKrh3v4995dMKLTJjSp9aQaO4+04LcgrtaacSSIDmpRWnLSgcU+KqgDI3KdYlEMUmRrTQkSkzVAXIXVPPc8QanReUWcKVjjyIkb1SpeUOBrKycbeujawObx+29rQiNfVKntn0XJZByL7I5h9JPTTAodB3vPn4SjdxN3kzQGFxVd5HSCmt0rVNMMtkvOcUlDfodjCtQURe9PKZ7q+Rl809FvTPwo+rkazt26MT9z name") +}); + +[{len:0x01, octet:[0x01]}, + {len:0x104, octet:[0x01, 0x04]}, + {len:0xFF32, octet:[0xFF,0x32]}, + {len:0x1000000, octet:[1,0,0,0]}, + {len:0x7FFFFFFF, octet:[0x7F,0xFF,0xFF,0xFF]}, //biggest one +].forEach(function (t) { + QUnit.test("Integer to Octet:" + t.len, function (assert) { + assert.deepEqual(integerToOctet(t.len), t.octet, t.len); + }); +}); + +[{len: 0x34 , asn: [0x34]}, + {len: 256 , asn: [0x80 + 2, 0x01, 0x00]}, + {len: 0x134 , asn: [0x80 + 2, 0x01, 0x34]}, + {len: 0x12345 , asn: [0x80 + 3, 0x01, 0x23, 0x45]}, + {len: 0x123456 , asn: [0x80 + 3, 0x12, 0x34, 0x56]}, + ].forEach(function (t) { + QUnit.test("ASN.1 Len Writing:" + t.len, function (assert) { + assert.deepEqual(asnEncodeLen(t.len), t.asn, t.len); + }); +}); + +QUnit.test("encodePrivateKey", function (assert) { + var encoded = encodePrivateKey(jwk_private); + console.log(encoded); + assert.ok(true); +}); \ No newline at end of file