From 57f5d442eac7e528fa6a2bd1cfc823dcac90d05a Mon Sep 17 00:00:00 2001 From: Nicolas Boisselier Date: Tue, 26 Jul 2016 15:52:48 +0100 Subject: [PATCH] bin/firefox_decrypt.py --- bin/firefox_decrypt.py | 690 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 690 insertions(+) create mode 100755 bin/firefox_decrypt.py diff --git a/bin/firefox_decrypt.py b/bin/firefox_decrypt.py new file mode 100755 index 00000000..f9e99324 --- /dev/null +++ b/bin/firefox_decrypt.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# 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 + +import argparse +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 + +try: + # Python 3 + from urllib.parse import urlparse +except ImportError: + # Python 2 + from urlparse import urlparse + +try: + # Python 3 + from configparser import ConfigParser + raw_input = input +except ImportError: + # Python 2 + from ConfigParser import ConfigParser + +PY3 = sys.version_info.major > 2 +LOG = None +VERBOSE = False + + +class NotFoundError(Exception): + """Exception to handle situations where a credentials file is not found + """ + pass + + +class Exit(Exception): + """Exception to allow a clean exit from any point in execution + """ + ERROR = 1 + MISSING_PROFILEINI = 2 + MISSING_SECRETS = 3 + BAD_PROFILEINI = 4 + LOCATION_NO_DIRECTORY = 5 + + FAIL_LOAD_NSS = 11 + FAIL_INIT_NSS = 12 + FAIL_NSS_KEYSLOT = 13 + BAD_MASTER_PASSWORD = 15 + NEED_MASTER_PASSWORD = 16 + + PASSSTORE_NOT_INIT = 20 + PASSSTORE_MISSING = 21 + PASSSTORE_ERROR = 22 + + READ_GOT_EOF = 30 + MISSING_CHOICE = 31 + NO_SUCH_PROFILE = 32 + + UNKNOWN_ERROR = 100 + UNEXPECTED_END = 101 + KEYBOARD_INTERRUPT = 102 + + def __init__(self, exitcode): + self.exitcode = exitcode + + def __unicode__(self): + 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 + """ + def __init__(self, db): + self.db = db + + LOG.debug("Database location: %s", self.db) + if not os.path.isfile(db): + raise NotFoundError("ERROR - {0} database not found\n".format(db)) + + LOG.info("Using %s for credentials.", db) + + def __iter__(self): + pass + + def done(self): + """Override this method if the credentials subclass needs to do any + action after interaction + """ + pass + + +class SqliteCredentials(Credentials): + """SQLite credentials backend manager + """ + def __init__(self, profile): + db = profile + "/signons.sqlite" + + super(SqliteCredentials, self).__init__(db) + + self.conn = sqlite3.connect(db) + self.c = self.conn.cursor() + + def __iter__(self): + LOG.debug("Reading password database in SQLite format") + self.c.execute("SELECT hostname, encryptedUsername, encryptedPassword, encType " + "FROM moz_logins") + for i in self.c: + # yields hostname, encryptedUsername, encryptedPassword, encType + yield i + + def done(self): + """Close the sqlite cursor and database connection + """ + super(SqliteCredentials, self).done() + + self.c.close() + self.conn.close() + + +class JsonCredentials(Credentials): + """JSON credentials backend manager + """ + def __init__(self, profile): + db = profile + "/logins.json" + + super(JsonCredentials, self).__init__(db) + + def __iter__(self): + with open(self.db) as fh: + LOG.debug("Reading password database in JSON format") + data = json.load(fh) + + try: + logins = data["logins"] + except: + raise Exception("Unrecognized format in {0}".format(self.db)) + + for i in logins: + yield (i["hostname"], i["encryptedUsername"], + i["encryptedPassword"], i["encType"]) + + +class NSSInteraction(object): + """ + Interact with lib NSS + """ + def __init__(self): + self.NSS = None + self.load_libnss() + + 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" + os.environ["PATH"] = ';'.join([os.environ["PATH"], firefox]) + LOG.debug("PATH is now %s", os.environ["PATH"]) + + else: + nssname = "libnss3.so" + + try: + nsslib = os.path.join(firefox, nssname) + LOG.debug("Loading NSS library from %s", nsslib) + + self.NSS = CDLL(nsslib) + + 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) + + def handle_error(self): + """If an error happens in libnss, handle it and print some debug information + """ + 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) + + if PY3: + error_name = error_name.decode("utf8") + error_str = error_str.decode("utf8") + + LOG.debug("%s: %s", error_name, error_str) + + def initialize_libnss(self, profile, password): + """Initialize the NSS library by authenticating with the user supplied password + """ + LOG.debug("Initializing NSS with profile path '%s'", profile) + + i = self.NSS.NSS_Init(profile.encode("utf8")) + LOG.debug("Initializing NSS returned %s", i) + + if i != 0: + LOG.error("Couldn't initialize NSS, maybe '%s' is not a valid profile?", profile) + self.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) + + LOG.debug("Authenticating with password '%s'", password) + + i = self.NSS.PK11_CheckUserPassword(keyslot, p_password) + LOG.debug("Checking user password returned %s", i) + + 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") + + def decode_entry(self, user, passw): + """Decrypt one entry in the database + """ + username = Item() + passwd = Item() + outuser = Item() + outpass = Item() + + 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)) + + LOG.debug("Decrypting username data '%s'", user) + + i = self.NSS.PK11SDR_Decrypt(byref(username), byref(outuser), None) + LOG.debug("Decryption of username returned %s", i) + + if i == -1: + LOG.error("Passwords protected by a Master Password!") + self.handle_error() + raise Exit(Exit.NEED_MASTER_PASSWORD) + + LOG.debug("Decrypting password data '%s'", passw) + + i = self.NSS.PK11SDR_Decrypt(byref(passwd), byref(outpass), None) + LOG.debug("Decryption of password returned %s", i) + + 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) + + user = string_at(outuser.data, outuser.len) + passw = string_at(outpass.data, outpass.len) + + return user, passw + + def decrypt_passwords(self, profile, password, export): + """ + Decrypt requested profile using the provided password and print out all + stored passwords. + """ + + self.initialize_libnss(profile, password) + + # Any password in this profile store at all? + got_password = False + + credentials = obtain_credentials(profile) + + LOG.info("Decrypting credentials") + to_export = {} + + for host, user, passw, enctype in credentials: + got_password = True + + # 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)) + + 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) + + if address.netloc not in to_export: + to_export[address.netloc] = {user: passw} + + else: + to_export[address.netloc][user] = passw + + else: + output = ( + u"\nWebsite: {0}\n".format(host), + 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")) + + credentials.done() + self.NSS.NSS_Shutdown() + + if export: + export_pass(to_export) + + if not got_password: + LOG.warn("No passwords found in selected profile") + + +def test_password_store(export): + """Check if pass from passwordstore.org is installed + If it is installed but not initialized, initialize it + """ + # Nothing to do here if exporting wasn't requested + if not export: + LOG.debug("Skipping password store test, not exporting") + return + + LOG.debug("Testing if password store is installed and configured") + + try: + p = Popen(["pass"], stdout=PIPE, stderr=PIPE) + except OSError as e: + if e.errno == 2: + LOG.error("Password store is not installed and exporting was requested") + raise Exit(Exit.PASSSTORE_MISSING) + else: + LOG.error("Unknown error happened.") + LOG.error("Error was %s", e) + raise Exit(Exit.UNKNOWN_ERROR) + + out, err = p.communicate() + LOG.debug("pass returned: %s %s", out, err) + + if p.returncode != 0: + if 'Try "pass init"' in err: + LOG.error("Password store was not initialized.") + LOG.error("Initialize the password store manually by using 'pass init'") + raise Exit(Exit.PASSSTORE_NOT_INIT) + else: + LOG.error("Unknown error happened when running 'pass'.") + LOG.error("Stdout/Stderr was '%s' '%s'", out, err) + raise Exit(Exit.UNKNOWN_ERROR) + + +def obtain_credentials(profile): + """Figure out which of the 2 possible backend credential engines is available + """ + try: + credentials = JsonCredentials(profile) + except NotFoundError: + try: + credentials = SqliteCredentials(profile) + except NotFoundError: + LOG.error("Couldn't find credentials file (logins.json or signons.sqlite).") + raise Exit(Exit.MISSING_SECRETS) + + return credentials + + +def export_pass(to_export): + """Export given passwords to password store + + Format of "to_export" should be: + {"address": {"login": "password", ...}, ...} + """ + LOG.info("Exporting credentials to password store") + 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"web/{0}/{1}".format(address, user) + + else: + passname = u"web/{0}".format(address) + + LOG.debug("Exporting credentials for '%s'", passname) + + data = u"{0}\n{1}\n".format(passw, user) + + LOG.debug("Inserting pass '%s' '%s'", passname, data) + + # NOTE --force is used. Existing passwords will be overwritten + cmd = ["pass", "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")) + + if p.returncode != 0: + LOG.error("ERROR: passwordstore exited with non-zero: %s", p.returncode) + LOG.error("Stdout/Stderr was '%s' '%s'", out, err) + raise Exit(Exit.PASSSTORE_ERROR) + + LOG.debug("Successfully exported '%s'", passname) + + +def get_sections(profiles): + """ + Returns hash of profile numbers and profile names. + """ + sections = {} + i = 1 + for section in profiles.sections(): + if section.startswith("Profile"): + sections[str(i)] = profiles.get(section, "Path") + i += 1 + else: + continue + return sections + +def print_sections(sections, textIOWrapper=sys.stderr): + """ + Prints all available sections to an textIOWrapper (defaults to sys.stderr) + """ + for i in sorted(sections): + 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 + """ + sections = get_sections(profiles) + + # Do not ask for choice if user already gave one + if choice_arg and len(choice_arg) == 1: + choice = choice_arg[0] + else: + # If only one menu entry exists, use it without prompting + if len(sections) == 1: + choice = "1" + + else: + choice = None + while choice not in sections: + sys.stderr.write("Select the Firefox profile you wish to decrypt\n") + print_sections(sections) + try: + choice = raw_input("Choice: ") + except EOFError as e: + LOG.error("Could not read Choice, got EOF") + raise Exit(Exit.READ_GOT_EOF) + + + try: + final_choice = sections[choice] + except KeyError: + LOG.error("Profile No. %s does not exist!", choice) + raise Exit(Exit.NO_SUCH_PROFILE) + + LOG.debug("Profile selection matched %s", final_choice) + + return final_choice + + +def ask_password(profile, no_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 sys.stdin.isatty() and not no_interactive: + passwd = getpass(passmsg) + + else: + # Ability to read the password from stdin (echo "pass" | ./firefox_...) + passwd = sys.stdin.readline().rstrip("\n") + + if PY3: + return passwd + else: + return passwd.decode(input_encoding) + + +def read_profiles(basepath, list_profiles): + """ + Parse Firefox profiles in provided location. + If list_profiles is true, will exit after listing available profiles. + """ + profileini = os.path.join(basepath, "profiles.ini") + + LOG.debug("Reading profiles from %s", profileini) + + if not os.path.isfile(profileini): + LOG.warn("profile.ini not found in %s", basepath) + raise Exit(Exit.MISSING_PROFILEINI) + + # Read profiles from Firefox profile folder + profiles = ConfigParser() + profiles.read(profileini) + + LOG.debug("Read profiles %s", profiles.sections()) + + if list_profiles: + LOG.debug("Listing available profiles...") + print_sections(get_sections(profiles), sys.stdout) + raise Exit(0) + + return profiles + + +def get_profile(basepath, no_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. + choice contains the choice the user gave us as an CLI arg. + If list_profiles is true will exits after listing all available profiles. + """ + try: + 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) + profile = basepath + + if list_profiles: + LOG.error("Listing single profiles not permitted.") + raise + + if not os.path.isdir(profile): + LOG.error("Profile location '%s' is not a directory", profile) + raise + else: + raise + else: + if no_interactive: + + sections = get_sections(profiles) + + if choice and len(choice) == 1: + + try: + section = sections[(choice[0])] + except KeyError: + LOG.error("Profile No. %s does not exist!", choice[0]) + raise Exit(Exit.NO_SUCH_PROFILE) + + elif len(sections) == 1: + section = sections['1'] + + else: + LOG.error("Don't know which profile to decrypt. We are in non-interactive mode and -c/--choice is missing.") + raise Exit(Exit.MISSING_CHOICE) + else: + # Ask user which profile to open + section = ask_section(profiles, choice) + + profile = os.path.join(basepath, section) + + if not os.path.isdir(profile): + LOG.error("Profile location '%s' is not a directory. Has profiles.ini been tampered with?", profile) + raise Exit(Exit.BAD_PROFILEINI) + + return profile + + +def parse_sys_args(): + """Parse command line arguments + """ + profile_path = "~/.mozilla/firefox/" + + parser = argparse.ArgumentParser( + description="Access Firefox/Thunderbird profiles and decrypt existing passwords" + ) + 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", + 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.") + parser.add_argument("-l", "--list", action="store_true", + 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") + + args = parser.parse_args() + + return args + + +def setup_logging(args): + """Setup the logging level and configure the basic logger + """ + if args.verbose == 1: + level = logging.INFO + elif args.verbose >= 2: + level = logging.DEBUG + else: + level = logging.WARN + + logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", + level=level, + ) + + global LOG + LOG = logging.getLogger(__name__) + + +def main(): + """Main entry point + """ + args = parse_sys_args() + + setup_logging(args) + + LOG.debug("Parsed commandline arguments: %s", args) + + # Check whether pass from passwordstore.org is installed + test_password_store(args.export_pass) + + 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) + + # Prompt for Master Password + password = ask_password(profile, args.no_interactive) + + # And finally decode all passwords + nss.decrypt_passwords(profile, password, args.export_pass) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt as e: + print("Quit.") + sys.exit(Exit.KEYBOARD_INTERRUPT) + except Exit as e: + sys.exit(e.exitcode) -- 2.47.3