From e4b0c405d5f5c806423843956f20804abbb76974 Mon Sep 17 00:00:00 2001 From: Nicolas Boisselier Date: Fri, 5 Jan 2018 00:53:14 +0000 Subject: [PATCH] bin/firefox_decrypt.py --- bin/firefox_decrypt.py | 430 +++++++++++++++++++++++++++++------------ 1 file changed, 303 insertions(+), 127 deletions(-) diff --git a/bin/firefox_decrypt.py b/bin/firefox_decrypt.py index 3934f801..d7578ba4 100755 --- a/bin/firefox_decrypt.py +++ b/bin/firefox_decrypt.py @@ -14,20 +14,26 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# Disclamer: Parts of this script were taken from the great tool: -# dumpzilla at www.dumpzilla.org +# Based on original work from: www.dumpzilla.org import argparse +import csv +import ctypes as ct import json import logging import os import sqlite3 import sys from base64 import b64decode -from ctypes import c_uint, c_void_p, c_char_p, cast, byref, string_at -from ctypes import Structure, CDLL from getpass import getpass -from subprocess import Popen, PIPE +from subprocess import PIPE, Popen + +try: + # Python 3 + from subprocess import DEVNULL +except ImportError: + # Python 2 + DEVNULL = open(os.devnull, 'w') try: # Python 3 @@ -49,6 +55,30 @@ LOG = None VERBOSE = False +def get_version(): + """Obtain version information from git if available otherwise use + the internal version number + """ + def internal_version(): + return '.'.join(map(str, __version_info__)) + + try: + p = Popen(["git", "describe", "--tags"], stdout=PIPE, stderr=DEVNULL) + except OSError: + return internal_version() + + stdout, stderr = p.communicate() + + if p.returncode: + return internal_version() + else: + return stdout.strip().decode("utf-8") + + +__version_info__ = (0, 6, 2) +__version__ = get_version() + + class NotFoundError(Exception): """Exception to handle situations where a credentials file is not found """ @@ -67,6 +97,7 @@ class Exit(Exception): FAIL_LOAD_NSS = 11 FAIL_INIT_NSS = 12 FAIL_NSS_KEYSLOT = 13 + FAIL_SHUTDOWN_NSS = 14 BAD_MASTER_PASSWORD = 15 NEED_MASTER_PASSWORD = 16 @@ -79,7 +110,6 @@ class Exit(Exception): NO_SUCH_PROFILE = 32 UNKNOWN_ERROR = 100 - UNEXPECTED_END = 101 KEYBOARD_INTERRUPT = 102 def __init__(self, exitcode): @@ -89,12 +119,6 @@ class Exit(Exception): return "Premature program exit with exit code {0}".format(self.exitcode) -class Item(Structure): - """struct needed to interact with libnss - """ - _fields_ = [('type', c_uint), ('data', c_void_p), ('len', c_uint)] - - class Credentials(object): """Base credentials backend manager """ @@ -121,7 +145,7 @@ class SqliteCredentials(Credentials): """SQLite credentials backend manager """ def __init__(self, profile): - db = profile + "/signons.sqlite" + db = os.path.join(profile, "signons.sqlite") super(SqliteCredentials, self).__init__(db) @@ -149,7 +173,7 @@ class JsonCredentials(Credentials): """JSON credentials backend manager """ def __init__(self, profile): - db = profile + "/logins.json" + db = os.path.join(profile, "logins.json") super(JsonCredentials, self).__init__(db) @@ -168,33 +192,98 @@ class JsonCredentials(Credentials): i["encryptedPassword"], i["encType"]) -class NSSInteraction(object): - """ - Interact with lib NSS - """ +class NSSDecoder(object): + class SECItem(ct.Structure): + """struct needed to interact with libnss + """ + _fields_ = [ + ('type', ct.c_uint), + ('data', ct.c_char_p), # actually: unsigned char * + ('len', ct.c_uint), + ] + + class PK11SlotInfo(ct.Structure): + """opaque structure representing a logical PKCS slot + """ + def __init__(self): + # Locate libnss and try loading it self.NSS = None self.load_libnss() + SlotInfoPtr = ct.POINTER(self.PK11SlotInfo) + SECItemPtr = ct.POINTER(self.SECItem) + + self._set_ctypes(ct.c_int, "NSS_Init", ct.c_char_p) + self._set_ctypes(ct.c_int, "NSS_Shutdown") + self._set_ctypes(SlotInfoPtr, "PK11_GetInternalKeySlot") + self._set_ctypes(None, "PK11_FreeSlot", SlotInfoPtr) + self._set_ctypes(ct.c_int, "PK11_CheckUserPassword", SlotInfoPtr, ct.c_char_p) + self._set_ctypes(ct.c_int, "PK11SDR_Decrypt", SECItemPtr, SECItemPtr, ct.c_void_p) + self._set_ctypes(None, "SECITEM_ZfreeItem", SECItemPtr, ct.c_int) + + # for error handling + self._set_ctypes(ct.c_int, "PORT_GetError") + self._set_ctypes(ct.c_char_p, "PR_ErrorToName", ct.c_int) + self._set_ctypes(ct.c_char_p, "PR_ErrorToString", ct.c_int, ct.c_uint32) + + def _set_ctypes(self, restype, name, *argtypes): + """Set input/output types on libnss C functions for automatic type casting + """ + res = getattr(self.NSS, name) + res.restype = restype + res.argtypes = argtypes + setattr(self, "_" + name, res) + + @staticmethod + def find_nss(locations, nssname): + """Locate nss is one of the many possible locations + """ + for loc in locations: + if os.path.exists(os.path.join(loc, nssname)): + return loc + + LOG.warn("%s not found on any of the default locations for this platform. " + "Attempting to continue nonetheless.", nssname) + return "" + def load_libnss(self): """Load libnss into python using the CDLL interface """ - firefox = "" - if os.name == "nt": nssname = "nss3.dll" - firefox = r"c:\Program Files (x86)\Mozilla Firefox" + 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) + 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" + locations = ( + "", # Current directory or system lib finder + "/usr/local/lib/nss", + "/usr/local/lib", + "/opt/local/lib/nss", + "/sw/lib/firefox", + "/sw/lib/mozilla", + "/usr/local/opt/nss/lib", # nss installed with Brew on Darwin + ) + + 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 = CDLL(nsslib) + self.NSS = ct.CDLL(nsslib) except Exception as e: LOG.error("Problems opening '%s' required for password decryption", nssname) @@ -206,129 +295,168 @@ class NSSInteraction(object): """ LOG.debug("Error during a call to NSS library, trying to obtain error info") - error = self.NSS.PORT_GetError() - self.NSS.PR_ErrorToString.restype = c_char_p - self.NSS.PR_ErrorToName.restype = c_char_p - error_str = self.NSS.PR_ErrorToString(error) - error_name = self.NSS.PR_ErrorToName(error) + code = self._PORT_GetError() + name = self._PR_ErrorToName(code) + name = "NULL" if name is None else name.decode("ascii") + # 0 is the default language (localization related) + text = self._PR_ErrorToString(code, 0) + text = text.decode("utf8") + + LOG.debug("%s: %s", name, text) + + def decode(self, data64): + data = b64decode(data64) + inp = self.SECItem(0, data, len(data)) + out = self.SECItem(0, None, 0) + + e = self._PK11SDR_Decrypt(inp, out, None) + LOG.debug("Decryption of data returned %s", e) + try: + if e == -1: + LOG.error("Password decryption failed. Passwords protected by a Master Password!") + self.handle_error() + raise Exit(Exit.NEED_MASTER_PASSWORD) + + res = ct.string_at(out.data, out.len).decode("utf8") + finally: + # Avoid leaking SECItem + self._SECITEM_ZfreeItem(out, 0) + + return res - if PY3: - error_name = error_name.decode("utf8") - error_str = error_str.decode("utf8") - LOG.debug("%s: %s", error_name, error_str) +class NSSInteraction(object): + """ + Interact with lib NSS + """ + def __init__(self): + self.profile = None + self.NSS = NSSDecoder() - def initialize_libnss(self, profile, password): - """Initialize the NSS library by authenticating with the user supplied password + def load_profile(self, profile): + """Initialize the NSS library and profile """ LOG.debug("Initializing NSS with profile path '%s'", profile) + self.profile = profile - i = self.NSS.NSS_Init(profile.encode("utf8")) - LOG.debug("Initializing NSS returned %s", i) + e = self.NSS._NSS_Init(self.profile.encode("utf8")) + LOG.debug("Initializing NSS returned %s", e) - if i != 0: + if e != 0: LOG.error("Couldn't initialize NSS, maybe '%s' is not a valid profile?", profile) - self.handle_error() + self.NSS.handle_error() raise Exit(Exit.FAIL_INIT_NSS) - if password: - LOG.debug("Retrieving internal key slot") - p_password = c_char_p(password.encode("utf8")) - keyslot = self.NSS.PK11_GetInternalKeySlot() - LOG.debug("Internal key slot %s", keyslot) - - if keyslot is None: - LOG.error("Failed to retrieve internal KeySlot") - self.handle_error() - raise Exit(Exit.FAIL_NSS_KEYSLOT) + def authenticate(self, interactive): + """Check if the current profile is protected by a master password, + prompt the user and unlock the profile. + """ + LOG.debug("Retrieving internal key slot") + keyslot = self.NSS._PK11_GetInternalKeySlot() - LOG.debug("Authenticating with password '%s'", password) + LOG.debug("Internal key slot %s", keyslot) + if not keyslot: + LOG.error("Failed to retrieve internal KeySlot") + self.NSS.handle_error() + raise Exit(Exit.FAIL_NSS_KEYSLOT) - i = self.NSS.PK11_CheckUserPassword(keyslot, p_password) - LOG.debug("Checking user password returned %s", i) + try: + # NOTE It would be great to be able to check if the profile is + # protected by a master password. In C++ one would do: + # if (keyslot->needLogin): + # 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 + password = ask_password(self.profile, interactive) - if i != 0: - LOG.error("Master password is not correct") - self.handle_error() - raise Exit(Exit.BAD_MASTER_PASSWORD) - else: - LOG.warn("Attempting decryption with no Master Password") + if password: + LOG.debug("Authenticating with password '%s'", password) + e = self.NSS._PK11_CheckUserPassword(keyslot, password.encode("utf8")) - def decode_entry(self, user, passw): - """Decrypt one entry in the database - """ - username = Item() - passwd = Item() - outuser = Item() - outpass = Item() + LOG.debug("Checking user password returned %s", e) - username.data = cast(c_char_p(b64decode(user)), c_void_p) - username.len = len(b64decode(user)) - passwd.data = cast(c_char_p(b64decode(passw)), c_void_p) - passwd.len = len(b64decode(passw)) + if e != 0: + LOG.error("Master password is not correct") - LOG.debug("Decrypting username data '%s'", user) + self.NSS.handle_error() + raise Exit(Exit.BAD_MASTER_PASSWORD) - i = self.NSS.PK11SDR_Decrypt(byref(username), byref(outuser), None) - LOG.debug("Decryption of username returned %s", i) + else: + LOG.warn("Attempting decryption with no Master Password") + finally: + # Avoid leaking PK11KeySlot + self.NSS._PK11_FreeSlot(keyslot) - if i == -1: - LOG.error("Passwords protected by a Master Password!") - self.handle_error() - raise Exit(Exit.NEED_MASTER_PASSWORD) + def unload_profile(self): + """Shutdown NSS and deactive current profile + """ + e = self.NSS._NSS_Shutdown() - LOG.debug("Decrypting password data '%s'", passw) + if e != 0: + LOG.error("Couldn't shutdown current NSS profile") - i = self.NSS.PK11SDR_Decrypt(byref(passwd), byref(outpass), None) - LOG.debug("Decryption of password returned %s", i) + self.NSS.handle_error() + raise Exit(Exit.FAIL_SHUTDOWN_NSS) - if i == -1: - # This shouldn't really happen but failsafe just in case - LOG.error("Given Master Password is not correct!") - self.handle_error() - raise Exit(Exit.UNEXPECTED_END) + def decode_entry(self, user64, passw64): + """Decrypt one entry in the database + """ + LOG.debug("Decrypting username data '%s'", user64) + user = self.NSS.decode(user64) - user = string_at(outuser.data, outuser.len) - passw = string_at(outpass.data, outpass.len) + LOG.debug("Decrypting password data '%s'", passw64) + passw = self.NSS.decode(passw64) return user, passw - def decrypt_passwords(self, profile, password, export): + def decrypt_passwords(self, export, output_format="human", csv_delimiter=";", csv_quotechar="|"): """ Decrypt requested profile using the provided password and print out all stored passwords. """ - - self.initialize_libnss(profile, password) + 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 - credentials = obtain_credentials(profile) + credentials = obtain_credentials(self.profile) LOG.info("Decrypting credentials") to_export = {} - sys.stdout.write("[FirefoxAccounts]\n") # NB + output_line("[FirefoxAccounts]\n") # NB - for host, user, passw, enctype in credentials: + if output_format == "csv": + csv_writer = csv.DictWriter( + sys.stdout, fieldnames=["url", "user", "password"], + lineterminator="\n", delimiter=csv_delimiter, + quotechar=csv_quotechar, quoting=csv.QUOTE_ALL, + ) + if header: + csv_writer.writeheader() + + for url, user, passw, enctype in credentials: got_password = True - # enctype informs if passwords are encrypted and protected by a master password + # enctype informs if passwords are encrypted and protected by + # a master password if enctype: user, passw = self.decode_entry(user, passw) - user = user.decode("utf8") - passw = passw.decode("utf8") - - LOG.debug("Decoding username '%s' and password '%s' for website '%s'", user, passw, host) - LOG.debug("Decoding username '%s' and password '%s' for website '%s'", type(user), type(passw), type(host)) + LOG.debug("Decoding username '%s' and password '%s' for website '%s'", user, passw, url) + LOG.debug("Decoding username '%s' and password '%s' for website '%s'", type(user), type(passw), type(url)) if export: # Keep track of web-address, username and passwords # If more than one username exists for the same web-address # the username will be used as name of the file - address = urlparse(host) + address = urlparse(url) if address.netloc not in to_export: to_export[address.netloc] = {user: passw} @@ -336,32 +464,36 @@ class NSSInteraction(object): else: to_export[address.netloc][user] = passw + if output_format == "csv": + output = {"url": url, "user": user, "password": passw} + if PY3: + csv_writer.writerow(output) + else: + csv_writer.writerow({k: v.encode("utf8") for k, v in output.items()}) + + # NB else: elif 0: output = ( - u"\nWebsite: {0}\n".format(host), + u"\nWebsite: {0}\n".format(url), u"Username: '{0}'\n".format(user), u"Password: '{0}'\n".format(passw), ) for line in output: - if PY3: - sys.stdout.write(line) - else: - sys.stdout.write(line.encode("utf8")) + output_line(line) else: # Other out format - NB 26.07.16 - if user and password: + if user and passw: user = user+':' - sys.stdout.write(host+' = '+user+password+"\n") + output_line(url+' = '+user+passw+"\n") credentials.done() - self.NSS.NSS_Shutdown() - - if export: - export_pass(to_export) if not got_password: LOG.warn("No passwords found in selected profile") + if export: + return to_export + def test_password_store(export): """Check if pass from passwordstore.org is installed @@ -414,7 +546,7 @@ def obtain_credentials(profile): return credentials -def export_pass(to_export): +def export_pass(to_export, prefix): """Export given passwords to password store Format of "to_export" should be: @@ -426,10 +558,10 @@ def export_pass(to_export): # 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"web/{0}/{1}".format(address, user) + passname = u"{0}/{1}/{2}".format(prefix, address, user) else: - passname = u"web/{0}".format(address) + passname = u"{0}/{1}".format(prefix, address) LOG.debug("Exporting credentials for '%s'", passname) @@ -467,6 +599,7 @@ def get_sections(profiles): continue return sections + def print_sections(sections, textIOWrapper=sys.stderr): """ Prints all available sections to an textIOWrapper (defaults to sys.stderr) @@ -475,6 +608,7 @@ def print_sections(sections, textIOWrapper=sys.stderr): textIOWrapper.write("{0} -> {1}\n".format(i, sections[i])) textIOWrapper.flush() + def ask_section(profiles, choice_arg): """ Prompt the user which profile should be used for decryption @@ -495,12 +629,11 @@ def ask_section(profiles, choice_arg): sys.stderr.write("Select the Firefox profile you wish to decrypt\n") print_sections(sections) try: - choice = raw_input("Choice: ") - except EOFError as e: + choice = raw_input() + except EOFError: LOG.error("Could not read Choice, got EOF") raise Exit(Exit.READ_GOT_EOF) - try: final_choice = sections[choice] except KeyError: @@ -512,7 +645,7 @@ def ask_section(profiles, choice_arg): return final_choice -def ask_password(profile, no_interactive): +def ask_password(profile, interactive): """ Prompt for profile password """ @@ -520,7 +653,7 @@ def ask_password(profile, no_interactive): input_encoding = utf8 if sys.stdin.encoding in (None, 'ascii') else sys.stdin.encoding passmsg = "\nMaster Password for profile {}: ".format(profile) - if sys.stdin.isatty() and not no_interactive: + if sys.stdin.isatty() and interactive: passwd = getpass(passmsg) else: @@ -560,11 +693,11 @@ def read_profiles(basepath, list_profiles): return profiles -def get_profile(basepath, no_interactive, choice, list_profiles): +def get_profile(basepath, interactive, choice, list_profiles): """ Select profile to use by either reading profiles.ini or assuming given path is already a profile - If no_interactive is true, will not try to ask which profile to decrypt. + If interactive is false, will not try to ask which profile to decrypt. choice contains the choice the user gave us as an CLI arg. If list_profiles is true will exits after listing all available profiles. """ @@ -585,7 +718,7 @@ def get_profile(basepath, no_interactive, choice, list_profiles): else: raise else: - if no_interactive: + if not interactive: sections = get_sections(profiles) @@ -619,16 +752,32 @@ def get_profile(basepath, no_interactive, choice, list_profiles): def parse_sys_args(): """Parse command line arguments """ - profile_path = "~/.mozilla/firefox/" + + if os.name == "nt": + profile_path = os.path.join(os.environ['APPDATA'], "Mozilla", "Firefox") + elif os.uname()[0] == "Darwin": + profile_path = "~/Library/Application Support/Firefox" + else: + profile_path = "~/.mozilla/firefox" parser = argparse.ArgumentParser( description="Access Firefox/Thunderbird profiles and decrypt existing passwords" ) - parser.add_argument("profile", nargs='?', default=profile_path, + parser.add_argument("profile", nargs="?", default=profile_path, 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("-n", "--no-interactive", action="store_true", + 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"}, + default="human", help="Format for the output.") + parser.add_argument("-d", "--delimiter", action="store", default=";", + help="The delimiter for csv output") + parser.add_argument("-q", "--quotechar", action="store", default='"', + help="The quote char for csv output") + parser.add_argument("-t", "--tabular", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("-n", "--no-interactive", dest="interactive", + default=True, action="store_false", help="Disable interactivity.") parser.add_argument("-c", "--choice", nargs=1, help="The profile to use (starts with 1). If only one profile, defaults to that.") @@ -636,9 +785,20 @@ def parse_sys_args(): help="List profiles and exit.") parser.add_argument("-v", "--verbose", action="count", default=0, help="Verbosity level. Warning on -vv (highest level) user input will be printed on screen") + parser.add_argument("--version", action="version", version=__version__, + help="Display version of firefox_decrypt and exit") args = parser.parse_args() + # replace character you can't enter as argument + if args.delimiter == "\\t": + args.delimiter = "\t" + + if args.tabular: + args.format = "csv" + args.delimiter = "\t" + args.quotechar = "'" + return args @@ -667,24 +827,40 @@ 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) # Check whether pass from passwordstore.org is installed test_password_store(args.export_pass) + # Initialize nss before asking the user for input nss = NSSInteraction() basepath = os.path.expanduser(args.profile) # Read profiles from profiles.ini in profile folder - profile = get_profile(basepath, args.no_interactive, args.choice, args.list) + profile = get_profile(basepath, args.interactive, args.choice, args.list) + + # Start NSS for selected profile + nss.load_profile(profile) + # Check if profile is password protected and prompt for a password + nss.authenticate(args.interactive) + # Decode all passwords + to_export = nss.decrypt_passwords( + export=args.export_pass, + output_format=args.format, + csv_delimiter=args.delimiter, + csv_quotechar=args.quotechar, + ) - # Prompt for Master Password - password = ask_password(profile, args.no_interactive) + if args.export_pass: + export_pass(to_export, args.pass_prefix) - # And finally decode all passwords - nss.decrypt_passwords(profile, password, args.export_pass) + # And shutdown NSS + nss.unload_profile() if __name__ == "__main__": -- 2.47.3