diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..2185ee3 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright (c) 2011 Jed Schmidt, http://jed.is/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6136c35 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +crxmake +======= + +crxmake is a [node.js](http://nodejs.org/) module for packing and serving Google Chrome extensions. + +## Requirements + +* [node.js](http://nodejs.org/), tested with 0.4.12 +* openssl +* zip + +## Install + + $ npm install crxmake + +## API + +### Constructor + +#### crx = new ChromeExtension(options, [callback]) + +Returns a `ChromeExtension` instance. If an optional callback is provided, the `load` method is called. + +### Methods + +#### crx.load([callback]) + +Loads data for the instance. If the instance has a `package` key, the `loadFromPackage` method is called. If the instance has a `sourcePath` key, `loadFromSourcePath` is called. + +#### crx.loadFromPackage([callback]) + +Populates the instance based on the contents of the `package` buffer. + +#### crx.loadFromSourcePath([callback]) + +Populates the instance based on the directory at `sourcePath` and the `privateKey` buffer. + +#### crx.generateAppId() + +Uses a hash of the public key to generate the app ID used to uniquely identify the extension, and caches it in the `appID` property. + +#### crx.generateUpdateXml() + +Calculates the app ID and pulls the version and update URL from the manifest, and returns an XML file that can be served to enable autoupdates, as described [here](http://code.google.com/chrome/extensions/autoupdate.html#H2-2). + +### Properties + +#### crx.package + +A buffer containing the source of the extension, which can be served as the `.crx` file. + +#### crx.publicKey + +The public key for the extension, which is generated from `privateKey` when the extension is built. + +#### crx.privateKey + +The private key for the extension. This is used to generate the public key and sign the package. + +#### crx.version + +The version of the extension. This is currently fixed at `2`. + +#### crx.signature + +A cryptographic signature used to verify that the private key was used to sign the package. + +#### crx.contents + +A zip file representing the extension's source tree. + +#### crx.manifest + +An object parsed from the extensions `manifest.json` file. + +## Example + +```javascript +// from ./test/test.js + +var fs = require("fs") + , assert = require("assert") + , join = require("path").join + , http = require("http") + + , ChromeExtension = require("../") + + , extPath = join(__dirname, "myFirstExtension") + , crxPath = extPath + ".crx" + , key = fs.readFileSync(extPath + ".pem") + +// create an extension with the existing key +new ChromeExtension({sourcePath: extPath, privateKey: key}, function(err, fromPath){ + // make sure no error occurred and that something was returned + assert.ok(!err) + assert.ok(!!fromPath) + + // make sure that the sizes and names are the same + assert.equal(fromPath.publicKey.length, 162) + assert.equal(fromPath.signature.length, 128) + assert.equal(fromPath.manifest.name, "My First Extension") + + // use the created extension to create a new instance + new ChromeExtension({package: fromPath.package}, function(err, fromPackage) { + // make sure no error occurred and that something was returned + assert.ok(!err) + assert.ok(!!fromPackage) + + // make sure that the public keys are the same + assert.equal( + fromPath.publicKey.toString(), + fromPackage.publicKey.toString() + ) + + // make sure that the signatures are the same + assert.equal( + fromPath.signature.toString(), + fromPackage.signature.toString() + ) + + // make sure that the contents are the same + assert.equal( + fromPath.contents.length, + fromPackage.contents.length + ) + + // write the extension to disk for further testing + fs.writeFile(crxPath, fromPath.package, function() { + console.log("Open the following extension for further testing:\n%s", crxPath) + }) + }) +}) +``` + +## TODO + +* Find out how to generate packages without keys and obtain a `.pem` file + +Copyright +--------- + +Copyright (c) 2011 Jed Schmidt. See LICENSE.txt for details. + +Send any questions or comments [here](http://twitter.com/jedschmidt). \ No newline at end of file diff --git a/bin/crx.js b/bin/crx.js new file mode 100755 index 0000000..76e3939 --- /dev/null +++ b/bin/crx.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +var path = require("path") + , fs = require("fs") + , child = require("child_process") + + , program = require("commander") + , ChromeExtension = require("..") + + , resolve = path.resolve + , join = path.join + , spawn = child.spawn + , exec = child.exec + + , cwd = process.cwd() + +program + .version("0.2.0") + .option("-f, --file [file]", "input/output instead of stdin/stdout") + .option("-p, --private-key ", "relative path to private key [key.pem]") + // coming soon + // .option("-x, --xml", "output autoupdate xml instead of extension ") + +program + .command("keygen [directory]") + .description("generate a private key in [directory]/key.pem") + .action(keygen) + +program + .command("pack [directory]") + .description("pack [directory] into a .crx extension") + .action(pack) + +// program +// .command("unpack [directory]") +// .description("unpack a .crx extension into a directory") +// .action(unpack) + +program.parse(process.argv) + +function keygen(dir, cb) { + dir = resolve(cwd, dir) + + var key = join(dir, "key.pem") + + path.exists(key, function(exists) { + if (exists) return cb && cb() + + var pubPath = key + ".pub" + , command = "ssh-keygen -N '' -b 1024 -t rsa -f key.pem" + + exec(command, {cwd: dir}, function(err) { + if (err) throw err + + // TODO: find a way to prevent .pub output + fs.unlink(pubPath) + cb && cb() + }) + }) +} + +function pack(dir) { + var input = resolve(cwd, dir) + , output = + program.file === true ? input + ".crx" : + program.file ? resolve(cwd, program.file) : false + + , stream = output ? fs.createWriteStream(output) : process.stdout + , key = program.privateKey + ? resolve(cwd, program.privateKey) + : join(input, "key.pem") + + , crx = new ChromeExtension + + fs.readFile(key, function(err, data) { + if (err) keygen(dir, pack.bind(null, dir)) + + crx.privateKey = data + + crx.load(input, function(err) { + if (err) throw err + + this.pack(function(err, data){ + if (err) throw err + + stream.end(data) + this.destroy() + }) + }) + }) +} \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..16c9e86 --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require("./src/crx.js") \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f9c91aa --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "author": "Jed Schmidt (http://jed.is)", + "name": "crx", + "description": "Build Google Chrome extensions with node.js", + "version": "0.1.1", + "homepage": "https://github.com/jed/crx", + "bin": { + "crx": "./bin/crx.js" + }, + "repository": { + "type": "git", + "url": "git://github.com/jed/crx.git" + }, + "engines": { + "node": "~0.4.12" + }, + "dependencies": {}, + "devDependencies": {} +} \ No newline at end of file diff --git a/src/crx.js b/src/crx.js new file mode 100644 index 0000000..056be0d --- /dev/null +++ b/src/crx.js @@ -0,0 +1,167 @@ +var fs = require("fs") + , join = require("path").join + , crypto = require("crypto") + , child = require("child_process") + , spawn = child.spawn + , exec = child.exec + +module.exports = new function() { + function ChromeExtension() { + this.path = join("/tmp", "crx-" + (Math.random() * 1e17).toString(36)) + } + + ChromeExtension.prototype = this + + this.destroy = function() { + spawn("rm", ["-rf", this.path]) + } + + this.pack = function(cb) { + this.loadManifest(function() { + this.generatePublicKey(function() { + this.loadContents(function() { + this.generateSignature() + this.generatePackage() + + cb.call(this, null, this.package) + }) + }) + }) + } + + this.load = function(path, cb) { + fs.stat(path, function(err, stat) { + if (stat.isDirectory()) this.loadFromDir(path, cb) + + else if (stat.isFile()) this.loadFromFile(path, cb) + }.bind(this)) + } + + this.loadFromDir = function(path, cb) { + spawn("cp", ["-R", path, this.path]).on("exit", cb.bind(this)) + } + + this.readFile = function(name, cb) { + var path = join(this.path, name) + + fs.readFile(path, "binary", function(err, data) { + if (err) return cb.call(this, err) + + cb.call(this, null, this[name] = data) + }.bind(this)) + } + + this.writeFile = function(path, data, cb) { + path = join(this.path, path) + + fs.writeFile(path, data, function(err, data) { + if (err) return cb.call(this, err) + + cb.call(this) + }.bind(this)) + } + + this.loadFromFile = function(path, cb) { + fs.readFile(path, function(err, data) { + if (err) return cb.call(this, err) + + path = this.path + ".zip" + data = data.slice(16 + crx[8] + crx[12]) + + fs.writeFile(path, data, function(err) { + if (err) return cb.call(this, err) + + spawn("unzip", [path], {dir: this.path}, function() { + fs.unlink(path) + cb.call(this) + }) + }.bind(this)) + }.bind(this)) + } + + this.loadManifest = function(cb) { + this.readFile("manifest.json", function(err, data) { + if (!err) { + try { this.manifest = JSON.parse(data.toString()) } + catch (e) { err = e } + } + + err + ? cb.call(this, err) + : cb.call(this, null, this.manifest) + }) + } + + this.generatePublicKey = function(cb) { + var rsa = spawn("openssl", ["rsa", "-pubout", "-outform", "DER"]) + + rsa.stdout.on("data", function(data) { + this.publicKey = data + cb && cb.call(this, null, this) + }.bind(this)) + + rsa.stdin.end(this.privateKey) + } + + this.generateSignature = function() { + return this.signature = new Buffer( + crypto + .createSign("sha1") + .update(this.contents) + .sign(this.privateKey), + + "binary" + ) + } + + this.loadContents = function(cb) { + var command = "zip -qr -9 -X - . -x key.pem" + , options = {cwd: this.path, encoding: "binary"} + + exec(command, options, function(err, data) { + if (err) return cb.call(this, err) + + this.contents = new Buffer(data, "binary") + + cb.call(this) + }.bind(this)) + } + + this.generatePackage = function() { + var signature = this.signature + , publicKey = this.publicKey + , contents = this.contents + + , keyLength = publicKey.length + , sigLength = signature.length + , zipLength = contents.length + , length = 16 + keyLength + sigLength + zipLength + + , crx = new Buffer(length) + + crx.write("Cr24" + Array(13).join("\x00"), "binary") + + crx[4] = 2 + crx[8] = keyLength + crx[12] = sigLength + + publicKey.copy(crx, 16) + signature.copy(crx, 16 + keyLength) + contents.copy(crx, 16 + keyLength + sigLength) + + return this.package = crx + } + + this.generateAppId = function() { + return this.appId = crypto + .createHash("sha256") + .update(this.publicKey) + .digest("hex") + .slice(0, 32) + .replace(/./g, function(x) { + return (parseInt(x, 16) + 10).toString(26) + }) + } + + return ChromeExtension +} \ No newline at end of file diff --git a/test/myFirstExtension/icon.png b/test/myFirstExtension/icon.png new file mode 100644 index 0000000..0fe39b0 Binary files /dev/null and b/test/myFirstExtension/icon.png differ diff --git a/test/myFirstExtension/manifest.json b/test/myFirstExtension/manifest.json new file mode 100644 index 0000000..3a62281 --- /dev/null +++ b/test/myFirstExtension/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "My First Extension", + "version": "1.0", + "description": "The first extension that I made.", + "browser_action": { + "default_icon": "icon.png" + }, + "permissions": [ + "http://api.flickr.com/" + ] +} \ No newline at end of file