From: Nicolas Boisselier Date: Sat, 6 Jul 2019 02:29:02 +0000 (+0100) Subject: bin/firefox_decrypt.py X-Git-Url: https://git.nbdom.net/?a=commitdiff_plain;h=6612bc9a9f84f305fe70b5580bc9d2af96a29680;p=nb.git bin/firefox_decrypt.py --- diff --git a/bin/firefox_decrypt.py b/bin/firefox_decrypt.py index d7578ba4..c86afe89 100755 --- a/bin/firefox_decrypt.py +++ b/bin/firefox_decrypt.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- # This program is free software: you can redistribute it and/or modify @@ -22,6 +22,7 @@ import ctypes as ct import json import logging import os +import select import sqlite3 import sys from base64 import b64decode @@ -53,6 +54,47 @@ except ImportError: PY3 = sys.version_info.major > 2 LOG = None VERBOSE = False +SYS64 = sys.maxsize > 2**32 + +if not PY3 and os.name == "nt": + sys.stderr.write("WARNING: You are using Python 2 on Windows. If your " + "passwords include non-alphanumeric characters you " + "will run into problems.\n") + sys.stderr.write("WARNING: Python 2 + Windows is no longer supported. " + "Please use Python 3 instead\n") + +# Windows uses a mixture of different codecs for different components +# ANSI CP1252 for system messages, while NSS uses UTF-8 +# To further complicate things, with python 2.7 the default stdout/stdin codec +# isn't UTF-8 but language dependent (tested on Windows 7) + +if os.name == "nt": + SYS_ENCODING = "cp1252" + LIB_ENCODING = "utf8" +else: + SYS_ENCODING = "utf8" + LIB_ENCODING = "utf8" + +# When using pipes stdin/stdout encoding may be None +USR_ENCODING = sys.stdin.encoding or sys.stdout.encoding or "utf8" + + +def py2_decode(_bytes, encoding=USR_ENCODING): + if PY3: + return _bytes + else: + return _bytes.decode(encoding) + + +def py2_encode(_unicode, encoding=USR_ENCODING): + if PY3: + return _unicode + else: + return _unicode.encode(encoding) + + +def type_decode(encoding): + return lambda x: py2_decode(x, encoding) def get_version(): @@ -60,7 +102,7 @@ def get_version(): the internal version number """ def internal_version(): - return '.'.join(map(str, __version_info__)) + return '.'.join(map(str, __version_info__[:3])) + ''.join(__version_info__[3:]) try: p = Popen(["git", "describe", "--tags"], stdout=PIPE, stderr=DEVNULL) @@ -72,10 +114,11 @@ def get_version(): if p.returncode: return internal_version() else: - return stdout.strip().decode("utf-8") + # Both py2 and py3 return bytes here + return stdout.decode(USR_ENCODING).strip() -__version_info__ = (0, 6, 2) +__version_info__ = (0, 8, 0, "+git") __version__ = get_version() @@ -93,7 +136,9 @@ class Exit(Exception): MISSING_SECRETS = 3 BAD_PROFILEINI = 4 LOCATION_NO_DIRECTORY = 5 + BAD_SECRETS = 6 + FAIL_LOCATE_NSS = 10 FAIL_LOAD_NSS = 11 FAIL_INIT_NSS = 12 FAIL_NSS_KEYSLOT = 13 @@ -184,8 +229,9 @@ class JsonCredentials(Credentials): try: logins = data["logins"] - except: - raise Exception("Unrecognized format in {0}".format(self.db)) + except Exception: + LOG.error("Unrecognized format in {0}".format(self.db)) + raise Exit(Exit.BAD_SECRETS) for i in logins: yield (i["hostname"], i["encryptedUsername"], @@ -239,28 +285,88 @@ class NSSDecoder(object): def find_nss(locations, nssname): """Locate nss is one of the many possible locations """ + fail_errors = [] + for loc in locations: - if os.path.exists(os.path.join(loc, nssname)): - return loc + nsslib = os.path.join(loc, nssname) + LOG.debug("Loading NSS library from %s", nsslib) + + if os.name == "nt": + # On windows in order to find DLLs referenced by nss3.dll + # we need to have those locations on PATH + os.environ["PATH"] = ';'.join([loc, os.environ["PATH"]]) + LOG.debug("PATH is now %s", os.environ["PATH"]) + # However this doesn't seem to work on all setups and needs to be + # set before starting python so as a workaround we chdir to + # Firefox's nss3.dll location + if loc: + if not os.path.isdir(loc): + # No point in trying to load from paths that don't exist + continue + + workdir = os.getcwd() + os.chdir(loc) + + try: + nss = ct.CDLL(nsslib) + except OSError as e: + fail_errors.append((nsslib, str(e))) + else: + LOG.debug("Loaded NSS library from %s", nsslib) + return nss + finally: + if os.name == "nt" and loc: + # Restore workdir changed above + os.chdir(workdir) + + else: + LOG.error("Couldn't find or load '%s'. This library is essential " + "to interact with your Mozilla profile.", nssname) + LOG.error("If you are seeing this error please perform a system-wide " + "search for '%s' and file a bug report indicating any " + "location found. Thanks!", nssname) + LOG.error("Alternatively you can try launching firefox_decrypt " + "from the location where you found '%s'. " + "That is 'cd' or 'chdir' to that location and run " + "firefox_decrypt from there.", nssname) + + LOG.error("Please also include the following on any bug report. " + "Errors seen while searching/loading NSS:") - LOG.warn("%s not found on any of the default locations for this platform. " - "Attempting to continue nonetheless.", nssname) - return "" + for target, error in fail_errors: + LOG.error("Error when loading %s was %s", target, py2_decode(str(error), SYS_ENCODING)) + + raise Exit(Exit.FAIL_LOCATE_NSS) def load_libnss(self): """Load libnss into python using the CDLL interface """ if os.name == "nt": nssname = "nss3.dll" - locations = ( - "", # Current directory or system lib finder - r"C:\Program Files (x86)\Mozilla Firefox", - r"C:\Program Files\Mozilla Firefox" - ) - firefox = self.find_nss(locations, nssname) + if SYS64: + locations = ( + "", # Current directory or system lib finder + r"C:\Program Files\Mozilla Firefox", + r"C:\Program Files\Mozilla Thunderbird", + r"C:\Program Files\Nightly", + ) + else: + locations = ( + "", # Current directory or system lib finder + r"C:\Program Files (x86)\Mozilla Firefox", + r"C:\Program Files (x86)\Mozilla Thunderbird", + r"C:\Program Files (x86)\Nightly", + # On windows 32bit these folders can also be 32bit + r"C:\Program Files\Mozilla Firefox", + r"C:\Program Files\Mozilla Thunderbird", + r"C:\Program Files\Nightly", + ) - os.environ["PATH"] = ';'.join([os.environ["PATH"], firefox]) - LOG.debug("PATH is now %s", os.environ["PATH"]) + # FIXME this was present in the past adding the location where NSS was found to PATH + # I'm not sure why this would be necessary. We don't need to run Firefox... + # TODO Test on a Windows machine and see if this works without the PATH change + # os.environ["PATH"] = ';'.join([os.environ["PATH"], firefox]) + # LOG.debug("PATH is now %s", os.environ["PATH"]) elif os.uname()[0] == "Darwin": nssname = "libnss3.dylib" @@ -272,23 +378,42 @@ class NSSDecoder(object): "/sw/lib/firefox", "/sw/lib/mozilla", "/usr/local/opt/nss/lib", # nss installed with Brew on Darwin + "/opt/pkg/lib/nss", # installed via pkgsrc ) - firefox = self.find_nss(locations, nssname) else: nssname = "libnss3.so" - firefox = "" # Current directory or system lib finder - - try: - nsslib = os.path.join(firefox, nssname) - LOG.debug("Loading NSS library from %s", nsslib) - - self.NSS = ct.CDLL(nsslib) + if SYS64: + locations = ( + "", # Current directory or system lib finder + "/usr/lib64", + "/usr/lib64/nss", + "/usr/lib", + "/usr/lib/nss", + "/usr/local/lib", + "/usr/local/lib/nss", + "/opt/local/lib", + "/opt/local/lib/nss", + os.path.expanduser("~/.nix-profile/lib"), + ) + else: + locations = ( + "", # Current directory or system lib finder + "/usr/lib", + "/usr/lib/nss", + "/usr/lib32", + "/usr/lib32/nss", + "/usr/lib64", + "/usr/lib64/nss", + "/usr/local/lib", + "/usr/local/lib/nss", + "/opt/local/lib", + "/opt/local/lib/nss", + os.path.expanduser("~/.nix-profile/lib"), + ) - except Exception as e: - LOG.error("Problems opening '%s' required for password decryption", nssname) - LOG.error("Error was %s", e) - raise Exit(Exit.FAIL_LOAD_NSS) + # If this succeeds libnss was loaded + self.NSS = self.find_nss(locations, nssname) def handle_error(self): """If an error happens in libnss, handle it and print some debug information @@ -297,10 +422,10 @@ class NSSDecoder(object): code = self._PORT_GetError() name = self._PR_ErrorToName(code) - name = "NULL" if name is None else name.decode("ascii") + name = "NULL" if name is None else name.decode(SYS_ENCODING) # 0 is the default language (localization related) text = self._PR_ErrorToString(code, 0) - text = text.decode("utf8") + text = text.decode(SYS_ENCODING) LOG.debug("%s: %s", name, text) @@ -317,7 +442,7 @@ class NSSDecoder(object): self.handle_error() raise Exit(Exit.NEED_MASTER_PASSWORD) - res = ct.string_at(out.data, out.len).decode("utf8") + res = ct.string_at(out.data, out.len).decode(LIB_ENCODING) finally: # Avoid leaking SECItem self._SECITEM_ZfreeItem(out, 0) @@ -339,11 +464,13 @@ class NSSInteraction(object): LOG.debug("Initializing NSS with profile path '%s'", profile) self.profile = profile - e = self.NSS._NSS_Init(self.profile.encode("utf8")) + profile = profile.encode(LIB_ENCODING) + + e = self.NSS._NSS_Init(b"sql:" + profile) LOG.debug("Initializing NSS returned %s", e) if e != 0: - LOG.error("Couldn't initialize NSS, maybe '%s' is not a valid profile?", profile) + LOG.error("Couldn't initialize NSS, maybe '%s' is not a valid profile?", self.profile) self.NSS.handle_error() raise Exit(Exit.FAIL_INIT_NSS) @@ -367,12 +494,12 @@ class NSSInteraction(object): # however accessing instance methods is not supported by ctypes. # More on this topic: http://stackoverflow.com/a/19636310 # A possibility would be to define such function using cython but - # this adds an unecessary runtime dependency + # this adds an unnecessary runtime dependency password = ask_password(self.profile, interactive) if password: LOG.debug("Authenticating with password '%s'", password) - e = self.NSS._PK11_CheckUserPassword(keyslot, password.encode("utf8")) + e = self.NSS._PK11_CheckUserPassword(keyslot, password.encode(LIB_ENCODING)) LOG.debug("Checking user password returned %s", e) @@ -383,13 +510,13 @@ class NSSInteraction(object): raise Exit(Exit.BAD_MASTER_PASSWORD) else: - LOG.warn("Attempting decryption with no Master Password") + LOG.warning("Attempting decryption with no Master Password") finally: # Avoid leaking PK11KeySlot self.NSS._PK11_FreeSlot(keyslot) def unload_profile(self): - """Shutdown NSS and deactive current profile + """Shutdown NSS and deactivate current profile """ e = self.NSS._NSS_Shutdown() @@ -415,12 +542,6 @@ class NSSInteraction(object): Decrypt requested profile using the provided password and print out all stored passwords. """ - def output_line(line): - if PY3: - sys.stdout.write(line) - else: - sys.stdout.write(line.encode("utf8")) - # Any password in this profile store at all? got_password = False header = True @@ -429,8 +550,7 @@ class NSSInteraction(object): LOG.info("Decrypting credentials") to_export = {} - - output_line("[FirefoxAccounts]\n") # NB + outputs = [] if output_format == "csv": csv_writer = csv.DictWriter( @@ -469,33 +589,33 @@ class NSSInteraction(object): if PY3: csv_writer.writerow(output) else: - csv_writer.writerow({k: v.encode("utf8") for k, v in output.items()}) + csv_writer.writerow({k: v.encode(USR_ENCODING) for k, v in output.items()}) + elif output_format == "json": + output = {"url": url, "user": user, "password": passw} + outputs.append(output) - # NB else: - elif 0: + else: output = ( u"\nWebsite: {0}\n".format(url), u"Username: '{0}'\n".format(user), u"Password: '{0}'\n".format(passw), ) for line in output: - output_line(line) - else: - # Other out format - NB 26.07.16 - if user and passw: - user = user+':' - output_line(url+' = '+user+passw+"\n") + sys.stdout.write(py2_encode(line, USR_ENCODING)) + if output_format == "json": + print(json.dumps(outputs)) + credentials.done() if not got_password: - LOG.warn("No passwords found in selected profile") + LOG.warning("No passwords found in selected profile") if export: return to_export -def test_password_store(export): +def test_password_store(export, pass_cmd): """Check if pass from passwordstore.org is installed If it is installed but not initialized, initialize it """ @@ -507,7 +627,7 @@ def test_password_store(export): LOG.debug("Testing if password store is installed and configured") try: - p = Popen(["pass"], stdout=PIPE, stderr=PIPE) + p = Popen([pass_cmd], stdout=PIPE, stderr=PIPE) except OSError as e: if e.errno == 2: LOG.error("Password store is not installed and exporting was requested") @@ -546,36 +666,41 @@ def obtain_credentials(profile): return credentials -def export_pass(to_export, prefix): +def export_pass(to_export, pass_cmd, prefix, username_prefix): """Export given passwords to password store Format of "to_export" should be: {"address": {"login": "password", ...}, ...} """ LOG.info("Exporting credentials to password store") + if prefix: + prefix = u"{0}/".format(prefix) + + LOG.debug("Using pass prefix '%s'", prefix) + for address in to_export: for user, passw in to_export[address].items(): # When more than one account exist for the same address, add # the login to the password identifier if len(to_export[address]) > 1: - passname = u"{0}/{1}/{2}".format(prefix, address, user) + passname = u"{0}{1}/{2}".format(prefix, address, user) else: - passname = u"{0}/{1}".format(prefix, address) + passname = u"{0}{1}".format(prefix, address) LOG.debug("Exporting credentials for '%s'", passname) - data = u"{0}\n{1}\n".format(passw, user) + data = u"{0}\n{1}{2}\n".format(passw, username_prefix, user) LOG.debug("Inserting pass '%s' '%s'", passname, data) # NOTE --force is used. Existing passwords will be overwritten - cmd = ["pass", "insert", "--force", "--multiline", passname] + cmd = [pass_cmd, "insert", "--force", "--multiline", passname] LOG.debug("Running command '%s' with stdin '%s'", cmd, data) p = Popen(cmd, stdout=PIPE, stderr=PIPE, stdin=PIPE) - out, err = p.communicate(data.encode("utf8")) + out, err = p.communicate(data.encode(SYS_ENCODING)) if p.returncode != 0: LOG.error("ERROR: passwordstore exited with non-zero: %s", p.returncode) @@ -649,21 +774,23 @@ def ask_password(profile, interactive): """ Prompt for profile password """ - utf8 = "UTF-8" - input_encoding = utf8 if sys.stdin.encoding in (None, 'ascii') else sys.stdin.encoding - passmsg = "\nMaster Password for profile {}: ".format(profile) + if not PY3: + profile = profile.encode(SYS_ENCODING) + + passmsg = "\nMaster Password for profile {0}: ".format(profile) if sys.stdin.isatty() and interactive: passwd = getpass(passmsg) else: # Ability to read the password from stdin (echo "pass" | ./firefox_...) - passwd = sys.stdin.readline().rstrip("\n") + if sys.stdin in select.select([sys.stdin], [], [], 0)[0]: + passwd = sys.stdin.readline().rstrip("\n") + else: + LOG.warning("Master Password not provided, continuing with blank password") + passwd = "" - if PY3: - return passwd - else: - return passwd.decode(input_encoding) + return py2_decode(passwd) def read_profiles(basepath, list_profiles): @@ -676,7 +803,7 @@ def read_profiles(basepath, list_profiles): LOG.debug("Reading profiles from %s", profileini) if not os.path.isfile(profileini): - LOG.warn("profile.ini not found in %s", basepath) + LOG.warning("profile.ini not found in %s", basepath) raise Exit(Exit.MISSING_PROFILEINI) # Read profiles from Firefox profile folder @@ -705,7 +832,7 @@ def get_profile(basepath, interactive, choice, list_profiles): profiles = read_profiles(basepath, list_profiles) except Exit as e: if e.exitcode == Exit.MISSING_PROFILEINI: - LOG.warn("Continuing and assuming '%s' is a profile location", basepath) + LOG.warning("Continuing and assuming '%s' is a profile location", basepath) profile = basepath if list_profiles: @@ -719,11 +846,9 @@ def get_profile(basepath, interactive, choice, list_profiles): raise else: if not interactive: - sections = get_sections(profiles) if choice and len(choice) == 1: - try: section = sections[(choice[0])] except KeyError: @@ -740,6 +865,7 @@ def get_profile(basepath, interactive, choice, list_profiles): # Ask user which profile to open section = ask_section(profiles, choice) + section = py2_decode(section, LIB_ENCODING) profile = os.path.join(basepath, section) if not os.path.isdir(profile): @@ -764,12 +890,19 @@ def parse_sys_args(): description="Access Firefox/Thunderbird profiles and decrypt existing passwords" ) parser.add_argument("profile", nargs="?", default=profile_path, + type=type_decode(SYS_ENCODING), help="Path to profile folder (default: {0})".format(profile_path)) parser.add_argument("-e", "--export-pass", action="store_true", help="Export URL, username and password to pass from passwordstore.org") + parser.add_argument("--pass-compat", action="store", + choices={"default", "browserpass", "username"}, + default="default", + help="Export username as is (default), or with one of the compatibility modes") parser.add_argument("-p", "--pass-prefix", action="store", default=u"web", help="Prefix for export to pass from passwordstore.org (default: %(default)s)") - parser.add_argument("-f", "--format", action="store", choices={"csv", "human"}, + parser.add_argument("-m", "--pass-cmd", action="store", default=u"pass", + help="Command/path to use when exporting to pass (default: %(default)s)") + parser.add_argument("-f", "--format", action="store", choices={"csv", "human", "json"}, default="human", help="Format for the output.") parser.add_argument("-d", "--delimiter", action="store", default=";", help="The delimiter for csv output") @@ -827,14 +960,16 @@ def main(): args = parse_sys_args() setup_logging(args) + if args.tabular: LOG.warning("--tabular is deprecated. Use `--format csv --delimiter \\t` instead") LOG.info("Running firefox_decrypt version: %s", __version__) LOG.debug("Parsed commandline arguments: %s", args) + LOG.debug("Running with encodings: USR: %s, SYS: %s, LIB: %s", USR_ENCODING, SYS_ENCODING, LIB_ENCODING) # Check whether pass from passwordstore.org is installed - test_password_store(args.export_pass) + test_password_store(args.export_pass, args.pass_cmd) # Initialize nss before asking the user for input nss = NSSInteraction() @@ -857,7 +992,14 @@ def main(): ) if args.export_pass: - export_pass(to_export, args.pass_prefix) + # List of compatibility modes for username prefixes + compat = { + "username": "username: ", + "browserpass": "login: ", + } + + username_prefix = compat.get(args.pass_compat, "") + export_pass(to_export, args.pass_cmd, args.pass_prefix, username_prefix) # And shutdown NSS nss.unload_profile()