diff --git a/README.md b/README.md index 3b797dd..71babbc 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,23 @@ > crx is a utility to **package Google Chrome extensions** via a *Node API* and the *command line*. It is written **purely in JavaScript** and **does not require OpenSSL**! +Massive hat tip to the [node-rsa project](https://npmjs.com/node-rsa) for the pure JavaScript encryption! + +**Compatibility**: this extension is compatible with `node>=10`. + Packages are available to use `crx` with: - *grunt*: [grunt-crx](https://npmjs.com/grunt-crx) - *gulp*: [gulp-crx-pack](https://npmjs.com/gulp-crx-pack) - *webpack*: [crx-webpack-plugin](https://npmjs.com/crx-webpack-plugin) -Massive hat tip to the [node-rsa project](https://npmjs.com/node-rsa) for the pure JavaScript encryption! - -**Compatibility**: this extension is compatible with `node>=10`. - ## Install ```bash $ npm install crx ``` -## Module API +## crx API Asynchronous functions returns a native ECMAScript Promise. @@ -46,12 +46,17 @@ crx.load( path.resolve(__dirname, './myExtension') ) }); ``` -### ChromeExtension = require("crx") -### crx = new ChromeExtension(attrs) +### `ChromeExtension(options)` This module exports the `ChromeExtension` constructor directly, which can take an optional attribute object, which is used to extend the instance. -### crx.load(path|files) +```js +var ChromeExtension = require("crx"); + +crx = new ChromeExtension({ ... }); +``` + +### `crx.load(path|files)` Prepares the temporary workspace for the Chrome Extension located at `path` — which is expected to directly contain `manifest.json`. @@ -69,7 +74,7 @@ crx.load(['/my/extension/manifest.json', '/my/extension/background.json']).then( }); ``` -### crx.pack() +### `crx.pack()` Packs the Chrome Extension and resolves the promise with a Buffer containing the `.crx` file. @@ -81,7 +86,7 @@ crx.load('/path/to/extension') }); ``` -### crx.generateUpdateXML() +### `crx.generateUpdateXML()` Returns a Buffer containing the update.xml file used for `autoupdate`, as specified for `update_url` in the manifest. In this case, the instance must have a property called `codebase`. @@ -103,6 +108,39 @@ Generates application id (extension id) from given path. ```js new crx().generateAppId('/path/to/ext') // epgkjnfaepceeghkjflpimappmlalchn + +## crypto API + +### `generateAppId(publicKey)` + +```js +const crypto = require('crx/crypto'); + + +``` + +### `generateAppIdFromPath(path)` + +```js +const crypto = require('crx/crypto'); + + +``` + +### `generatePrivateKey()` + +```js +const crypto = require('crx/crypto'); + + +``` + +### `generatePublicKey(privateKey[, format])` + +```js +const crypto = require('crx/crypto'); + + ``` ## CLI API diff --git a/bin/crx.js b/bin/crx.js index 3c7e8b6..637d038 100755 --- a/bin/crx.js +++ b/bin/crx.js @@ -2,10 +2,10 @@ var path = require("path"); var fs = require("fs"); -var rsa = require("node-rsa"); var {promisify} = require("util"); var writeFile = promisify(fs.writeFile); var readFile = promisify(fs.readFile); +var {generatePrivateKey} = require("../crypto"); var program = require("commander"); var ChromeExtension = require(".."); @@ -44,16 +44,6 @@ program program.parse(process.argv); -/** - * Generate a new key file - * @param {String} keyPath path of the key file to create - * @returns {Promise} - */ -function generateKeyFile(keyPath) { - return Promise.resolve(new rsa({ b: 2048 })) - .then(key => key.exportKey("pkcs1-private-pem")) - .then(keyVal => writeFile(keyPath, keyVal)); -} function keygen(dir, program) { dir = dir ? resolve(cwd, dir) : cwd; @@ -65,7 +55,7 @@ function keygen(dir, program) { throw new Error("key.pem already exists in the given location."); } - generateKeyFile(keyPath); + generatePrivateKey().then(privateKey => writeFile(keyPath, privateKey)); }); } @@ -96,26 +86,16 @@ function pack(dir, program) { } } - var crx = new ChromeExtension({ - rootDirectory: input, - maxBuffer: program.maxBuffer - }); - readFile(keyPath) - .then(null, function(err) { - // If the key file doesn't exist, create one - if (err.code === "ENOENT") { - return generateKeyFile(keyPath); - } else { - throw err; - } - }) - .then(function(key) { - crx.privateKey = key; + .then(function(privateKey) { + return new ChromeExtension({ + rootDirectory: input, + maxBuffer: program.maxBuffer, + privateKey + }); }) - .then(function() { - crx - .load() + .then(function(crx) { + crx.load() .then(() => crx.loadContents()) .then(function(fileBuffer) { if (program.zipOutput) { diff --git a/crypto.js b/crypto.js new file mode 100644 index 0000000..27300eb --- /dev/null +++ b/crypto.js @@ -0,0 +1,98 @@ +"use strict"; + +var RSA = require("node-rsa"); +var crypto = require("crypto"); + +/** + * Generate an AppId from a public key + * + * @param {String|Buffer} content Public Key content + * @return {String} AppId + */ +function generateAppId (content) { + if (typeof content !== 'string' && !(content instanceof Buffer)) { + throw new Error('Public key is neither set, nor given'); + } + + return crypto + .createHash("sha256") + .update(content) + .digest() + .toString("hex") + .split("") + .map(x => (parseInt(x, 16) + 0x0a).toString(26)) + .join("") + .slice(0, 32); +} + +/** + * Generate an AppId from a filesystem path + * + * @param {String} path Unix or Windows path to the folder containing the manifest.json + * @return {String} AppId + */ +function generateAppIdFromPath (path) { + var charCode = path.charCodeAt(0); + + // Handling Windows Path + // 65 (A) < charCode < 122 (z) + if (charCode >= 65 && charCode <= 122 && path[1] === ':') { + path = Buffer.from( + path[0].toUpperCase() + path.slice(1), + "utf-16le" + ); + } + + return generateAppId(path); +} + +/** + * [generatePrivateKey description] + * @return {Promise} [description] + */ +function generatePrivateKey () { + return Promise.resolve(new RSA({ b: 2048 })) + .then(key => key.exportKey("pkcs1-private-pem")); +} + +/** + * [generatePublicKey description] + * @param {Buffer} privateKey [description] + * @param {String} format [description] + * @return {String|Buffer} [description] + */ +function generatePublicKey (privateKey, format) { + var ALLOWED_FORMATS = ['der', 'pem']; + + return new Promise(function(resolve, reject){ + if (!privateKey) { + return reject('Impossible to generate a public key: privateKey option has not been defined or is empty.'); + } + + if (format && ALLOWED_FORMATS.indexOf(format) === -1) { + return reject('Allowed public key formats are "der" (default) or "pem".'); + } + + var key = new RSA(privateKey); + + resolve(key.exportKey('pkcs8-public-' + (format || 'der'))); + }); +}; + +/** + * [sign description] + * @param {Any} content [description] + * @param {Buffer|String} privateKey [description] + * @return {Buffer} [description] + */ +function sign (content, privateKey) { + return crypto.createSign("sha1").update(content).sign(privateKey); +} + +module.exports = { + generateAppId: generateAppId, + generateAppIdFromPath: generateAppIdFromPath, + generatePublicKey: generatePublicKey, + generatePrivateKey: generatePrivateKey, + sign: sign, +} diff --git a/src/crx.js b/src/crx.js index 8d04ac9..a4d04de 100644 --- a/src/crx.js +++ b/src/crx.js @@ -2,10 +2,9 @@ var path = require("path"); var join = path.join; -var crypto = require("crypto"); -var RSA = require("node-rsa"); var archiver = require("archiver"); var resolve = require("./resolver.js"); +var {generateAppId, generatePublicKey, sign} = require("../crypto"); const DEFAULTS = { appId: null, @@ -98,19 +97,7 @@ class ChromeExtension { * }); */ generatePublicKey () { - var privateKey = this.privateKey; - - return new Promise(function(resolve, reject) { - if (!privateKey) { - return reject( - "Impossible to generate a public key: privateKey option has not been defined or is empty." - ); - } - - var key = new RSA(privateKey); - - resolve(key.exportKey("pkcs8-public-der")); - }); + return generatePublicKey(this.privateKey, "der"); } /** @@ -122,13 +109,7 @@ class ChromeExtension { * @returns {Buffer} */ generateSignature (contents) { - return Buffer.from( - crypto - .createSign("sha1") - .update(contents) - .sign(this.privateKey), - "binary" - ); + return sign(contents, this.privateKey); } /** @@ -209,62 +190,30 @@ class ChromeExtension { } /** - * Generates an appId from the publicKey. - * Public key has to be set for this to work, otherwise an error is thrown. * - * BC BREAK `this.appId` is not stored anymore (since 1.0.0) - * BC BREAK introduced `publicKey` parameter as it is not stored any more since 2.0.0 * - * @param {Buffer|string} [publicKey] the public key to use to generate the app ID - * @returns {string} + * + * @returns */ - generateAppId (keyOrPath) { - keyOrPath = keyOrPath || this.publicKey; - - if (typeof keyOrPath !== "string" && !(keyOrPath instanceof Buffer)) { - throw new Error("Public key is neither set, nor given"); - } - - // Handling Windows Path - // Possibly to be moved in a different method - if (typeof keyOrPath === "string") { - var charCode = keyOrPath.charCodeAt(0); - - // 65 (A) < charCode < 122 (z) - if (charCode >= 65 && charCode <= 122 && keyOrPath[1] === ":") { - keyOrPath = keyOrPath[0].toUpperCase() + keyOrPath.slice(1); - - keyOrPath = Buffer.from(keyOrPath, "utf-16le"); - } - } - - return crypto - .createHash("sha256") - .update(keyOrPath) - .digest() - .toString("hex") - .split("") - .map(x => (parseInt(x, 16) + 0x0a).toString(26)) - .join("") - .slice(0, 32); - } - /** * Generates an updateXML file from the extension content. * - * BC BREAK `this.updateXML` is not stored anymore (since 1.0.0) - * - * @returns {Buffer} + * @param {Object=} options + * @param {String=} options.appId AppId generated `generateAppId()` or `generateAppIdFromPath()` + * @param {String=} options.codebase Absolute URL from which the self-hosted and signed extension will be accessible from. + * @return {Buffer} */ - generateUpdateXML () { + generateUpdateXML (options={}) { + const {appId, codebase} = options; + if (!this.codebase) { throw new Error("No URL provided for update.xml."); } return Buffer.from(` - - + + `); } diff --git a/test/crypto.js b/test/crypto.js new file mode 100644 index 0000000..92e696c --- /dev/null +++ b/test/crypto.js @@ -0,0 +1,78 @@ +'use strict'; + +var test = require("tape"); +var fs = require("fs"); +var join = require("path").join; + +var crypto = require("../crypto"); +var privateKey = fs.readFileSync(join(__dirname, "key.pem")); +var expectedPublicKeyPEM = fs.readFileSync(join(__dirname, "expectations", "public-key.pem")); +var expectedPublicKeyDER = fs.readFileSync(join(__dirname, "expectations", "public-key.der")); + +test('#generateAppId', function(t) { + t.plan(2); + + t.throws( + function() { crypto.generateAppId(); }, + /Public key is neither set, nor given/ + ); + + // from Public Key + crypto.generatePublicKey(privateKey) + .then(function(publicKey) { + t.equals( + crypto.generateAppId(publicKey), + 'eoilidhiokfphdhpmhoaengdkehanjif' + ); + }) + .catch(t.error.bind(t)); +}); + +test('#generateAppIdFromPath', function(t){ + t.plan(2); + + // from Linux Path + t.equals( + crypto.generateAppIdFromPath('/usr/local/extension'), 'ioglhmppkolgcgoonkfdbjkcedfjhbcd' + ); + + // from Windows Path + t.equals( + crypto.generateAppIdFromPath('c:\\a'), + 'igchicfaapedlfgmepccnpolhajaphik' + ); +}); + +test('#generatePublicKey', function(t){ + t.plan(3); + + // wrong format + crypto.generatePublicKey(privateKey, 'INVALID FORMAT') + .catch(t.ok.bind(t)) + + // DER + crypto.generatePublicKey(privateKey) + .then(function(publicKey){ + t.ok(publicKey.equals(expectedPublicKeyDER), 'DER'); + }); + + // PEM + crypto.generatePublicKey(privateKey, 'pem') + .then(function(publicKey){ + t.equals(publicKey, expectedPublicKeyPEM.toString(), 'PEM'); + }); +}); + +test('#generatePrivateKey', function(t){ + t.plan(2); + + crypto.generatePrivateKey() + .then(function(privateKey){ + t.ok(privateKey.match('-----BEGIN RSA PRIVATE KEY-----')); + t.ok(privateKey.match('-----END RSA PRIVATE KEY-----')); + }); +}); + +test('#sign', function(t){ + t.end(); +}); diff --git a/test/expectations/public-key.der b/test/expectations/public-key.der new file mode 100644 index 0000000..43faf05 Binary files /dev/null and b/test/expectations/public-key.der differ diff --git a/test/expectations/public-key.pem b/test/expectations/public-key.pem new file mode 100644 index 0000000..3fac5c6 --- /dev/null +++ b/test/expectations/public-key.pem @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCx1nHfHr1yCt21hPRWE9yKwC8X +uq+y/dPDrkjJ8cVsgPRoq25iiej2siZEWryDR7WWuAmdGaUtBFgQyRvCyGQS4Ytk +Kot8iOTFzJo656hgHJUvZP2QYy7ERJ3rZRwLxpWmvYQiXx92LSy19eC6Bi5+FAaT +MCQNDdklanijb5D6fwIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/test/index.js b/test/index.js index d3cd072..f428656 100644 --- a/test/index.js +++ b/test/index.js @@ -5,6 +5,7 @@ var fs = require("fs"); var test = require("tape"); var Zip = require("adm-zip"); var ChromeExtension = require("../"); +var {generateAppId} = require("../crypto.js"); var join = require("path").join; var privateKey = fs.readFileSync(join(__dirname, "key.pem")); var updateXml = fs.readFileSync(join(__dirname, "expectations", "update.xml")); @@ -138,23 +139,9 @@ test('#generatePublicKey', function(t) { }); test('#generateAppId', function(t) { - t.plan(4); - - t.throws(function() { newCrx().generateAppId(); }, /Public key is neither set, nor given/); - - var crx = newCrx() - - // from Public Key - crx.generatePublicKey().then(function(publicKey){ - t.equals(crx.generateAppId(publicKey), 'eoilidhiokfphdhpmhoaengdkehanjif'); - }) - .catch(t.error.bind(t)); - - // from Linux Path - t.equals(crx.generateAppId('/usr/local/extension'), 'ioglhmppkolgcgoonkfdbjkcedfjhbcd'); + t.plan(1); - // from Windows Path - t.equals(crx.generateAppId('c:\\a'), 'igchicfaapedlfgmepccnpolhajaphik'); + t.throws(() => newCrx().generateAppId(), /is not a function/); }); test('end to end', function (t) {