import os, sys import base64 import hashlib import simplejson as json def my_b32encode(bytes): # a paddingless prettier base32 encoder return base64.b32encode(bytes).lower().strip("=") def my_b32decode(s): # add "=" until we have a multiple of 8 characters (i.e. 40 bits / 5 bytes) padding = "=" * ((8-len(s)%8)%8) return base64.b32decode(s.upper()+padding) def remove_prefix(s, prefix, errormsg): if not s.startswith(prefix): raise ValueError(errormsg) return s[len(prefix):] def vk_to_jid(vk): """Return 'jid0-XYZ', where 'XYZ' is a string that securely identifies a specific public key. To get a suitable add-on ID, append '@jetpack' to this string. """ # per https://developer.mozilla.org/en/Install_Manifests#id all XPI id # values must either be in the form of a 128-bit GUID (crazy braces # and all) or in the form of an email address (crazy @ and all). # Firefox will refuse to install an add-on with an id that doesn't # match one of these forms. The actual regexp is at: # http://mxr.mozilla.org/mozilla-central/source/toolkit/mozapps/extensions/XPIProvider.jsm#130 # So the JID needs an @-suffix, and the only legal punctuation is # "-._". So we start with a base64 encoding, and replace the # punctuation (+/) with letters (AB), losing a few bits of integrity. # even better: windows has a maximum path length limitation of 256 # characters: # http://msdn.microsoft.com/en-us/library/aa365247%28VS.85%29.aspx # (unless all paths are prefixed with "\\?\", I kid you not). The # typical install will put add-on code in a directory like: # C:\Documents and Settings\\Application Data\Mozilla\Firefox\Profiles\232353483.default\extensions\$JID\... # (which is 108 chars long without the $JID). # Then the unpacked XPI contains packaged resources like: # resources/$JID-jetpack-core-lib/main.js (35 chars plus the $JID) # # We hash the pubkey into a 160 bit string, base64 encode that (with # AB instead of +/ to be path-safe), then bundle it into # "jid0-XYZ@jetpack". This gives us 40 characters. The resulting # main.js will have a path length of 224 characters, leaving us 32 # characters of margin. # if length were no issue, we'd prefer to use this: # s = base64.b64encode(vk.to_string()).strip("=") h = hashlib.sha256("jetpack-id-v0:"+vk.to_string()).digest()[:160/8] s = base64.b64encode(h, "AB").strip("=") jid = "jid0-" + s return jid def jid_to_programid(jid): return jid + "@jetpack" def create_key(keydir, name): # return jid from ecdsa import SigningKey, NIST256p sk = SigningKey.generate(curve=NIST256p) sk_text = "private-jid0-%s" % my_b32encode(sk.to_string()) vk = sk.get_verifying_key() vk_text = "public-jid0-%s" % my_b32encode(vk.to_string()) jid = vk_to_jid(vk) program_id = jid_to_programid(jid) # save privkey to ~/.jetpack-keys/$jid f = open(os.path.join(keydir, jid), "w") f.write("private-key: %s\n" % sk_text) f.write("public-key: %s\n" % vk_text) f.write("jid: %s\n" % jid) f.write("program-id: %s\n" % program_id) f.write("name: %s\n" % name) f.close() return jid def programid_to_jid(programid): assert programid.endswith("@jetpack") jid = programid[:-len("@jetpack")] return jid def check_for_privkey(keydir, jid, stderr): if jid.startswith("anonid0-"): return None keypath = os.path.join(keydir, jid) if not os.path.isfile(keypath): msg = """\ Your package.json says our ID is: %(jid)s But I don't have a corresponding private key in: %(keypath)s If you are the original developer of this package and have recently copied the source code from a different machine to this one, you should copy the private key into the file named above. Otherwise, if you are a new developer who has made a copy of an existing package to use as a starting point, you need to remove the 'id' property from package.json, so that we can generate a new id and keypair. This will disassociate our new package from the old one. If you're collaborating on the same addon with a team, make sure at least one person on the team has the private key. In the future, you may not be able to distribute your addon without it. """ print >>stderr, msg % {"jid": jid, "keypath": keypath} return None keylines = open(keypath, "r").readlines() keydata = {} for line in keylines: line = line.strip() if line: k,v = line.split(":", 1) keydata[k.strip()] = v.strip() if "private-key" not in keydata: raise ValueError("invalid keydata: can't find 'private-key' line") sk_s = remove_prefix(keydata["private-key"], "private-jid0-", errormsg="unable to parse private-key data") from ecdsa import SigningKey, VerifyingKey, NIST256p sk = SigningKey.from_string(my_b32decode(sk_s), curve=NIST256p) vk = sk.get_verifying_key() jid_2 = vk_to_jid(vk) if jid_2 != jid: raise ValueError("invalid keydata: private-key in %s does not match" " public key for %s" % (keypath, jid)) vk_s = remove_prefix(keydata["public-key"], "public-jid0-", errormsg="unable to parse public-key data") vk2 = VerifyingKey.from_string(my_b32decode(vk_s), curve=NIST256p) if vk.to_string() != vk2.to_string(): raise ValueError("invalid keydata: public-key mismatch") return sk def preflight_config(target_cfg, filename, stderr=sys.stderr, keydir=None, err_if_privkey_not_found=True): # check the top-level package.json for missing keys. We generate anything # that we can, and ask the user for the rest. if keydir is None: keydir = os.path.expanduser("~/.jetpack/keys") modified = False config = json.load(open(filename, 'r')) name = target_cfg["name"] # defaults to parentdir if not set if "id" not in config: print >>stderr, ("No 'id' in package.json: creating a new" " keypair for you.") if not os.path.isdir(keydir): os.makedirs(keydir, 0700) # not world readable jid = create_key(keydir, name) config["id"] = jid modified = True # make sure we have the privkey: this catches the case where developer B # copies an add-on from developer A and then (accidentally) tries to # publish it without replacing the JID sk = check_for_privkey(keydir, config["id"], stderr) if not sk and err_if_privkey_not_found: return False, False if modified: i = 0 backup = filename + ".backup" while os.path.exists(backup): if i > 1000: raise ValueError("I'm having problems finding a good name" " for the backup file. Please move %s out" " of the way and try again." % (filename + ".backup")) backup = filename + ".backup-%d" % i i += 1 os.rename(filename, backup) new_json = json.dumps(config, indent=4) open(filename, 'w').write(new_json+"\n") return False, True return True, False