]> git.nbdom.net Git - nb.git/commitdiff
bin/firefox_decrypt.py
authorNicolas Boisselier <nicolas.boisselier@semantico.com>
Tue, 26 Jul 2016 14:52:48 +0000 (15:52 +0100)
committerNicolas Boisselier <nicolas.boisselier@semantico.com>
Tue, 26 Jul 2016 14:52:48 +0000 (15:52 +0100)
bin/firefox_decrypt.py [new file with mode: 0755]

diff --git a/bin/firefox_decrypt.py b/bin/firefox_decrypt.py
new file mode 100755 (executable)
index 0000000..f9e9932
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+# 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)