|
#!/usr/bin/env python3 |
|
# -*- 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/>. |
|
|
|
# Based on original work from: www.dumpzilla.org |
|
|
|
import argparse |
|
import csv |
|
import ctypes as ct |
|
import json |
|
import logging |
|
import os |
|
import select |
|
import sqlite3 |
|
import sys |
|
from base64 import b64decode |
|
from getpass import getpass |
|
from subprocess import PIPE, Popen |
|
|
|
try: |
|
# Python 3 |
|
from subprocess import DEVNULL |
|
except ImportError: |
|
# Python 2 |
|
DEVNULL = open(os.devnull, 'w') |
|
|
|
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 |
|
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(): |
|
"""Obtain version information from git if available otherwise use |
|
the internal version number |
|
""" |
|
def internal_version(): |
|
return '.'.join(map(str, __version_info__[:3])) + ''.join(__version_info__[3:]) |
|
|
|
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: |
|
# Both py2 and py3 return bytes here |
|
return stdout.decode(USR_ENCODING).strip() |
|
|
|
|
|
__version_info__ = (0, 8, 0, "+git") |
|
__version__ = get_version() |
|
|
|
|
|
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 |
|
BAD_SECRETS = 6 |
|
|
|
FAIL_LOCATE_NSS = 10 |
|
FAIL_LOAD_NSS = 11 |
|
FAIL_INIT_NSS = 12 |
|
FAIL_NSS_KEYSLOT = 13 |
|
FAIL_SHUTDOWN_NSS = 14 |
|
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 |
|
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 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 = os.path.join(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 = os.path.join(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 Exception: |
|
LOG.error("Unrecognized format in {0}".format(self.db)) |
|
raise Exit(Exit.BAD_SECRETS) |
|
|
|
for i in logins: |
|
yield (i["hostname"], i["encryptedUsername"], |
|
i["encryptedPassword"], i["encType"]) |
|
|
|
|
|
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 |
|
""" |
|
fail_errors = [] |
|
|
|
for loc in locations: |
|
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:") |
|
|
|
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" |
|
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", |
|
) |
|
|
|
# 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" |
|
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 |
|
"/opt/pkg/lib/nss", # installed via pkgsrc |
|
) |
|
|
|
else: |
|
nssname = "libnss3.so" |
|
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"), |
|
) |
|
|
|
# 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 |
|
""" |
|
LOG.debug("Error during a call to NSS library, trying to obtain error info") |
|
|
|
code = self._PORT_GetError() |
|
name = self._PR_ErrorToName(code) |
|
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(SYS_ENCODING) |
|
|
|
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(LIB_ENCODING) |
|
finally: |
|
# Avoid leaking SECItem |
|
self._SECITEM_ZfreeItem(out, 0) |
|
|
|
return res |
|
|
|
|
|
class NSSInteraction(object): |
|
""" |
|
Interact with lib NSS |
|
""" |
|
def __init__(self): |
|
self.profile = None |
|
self.NSS = NSSDecoder() |
|
|
|
def load_profile(self, profile): |
|
"""Initialize the NSS library and profile |
|
""" |
|
LOG.debug("Initializing NSS with profile path '%s'", profile) |
|
self.profile = profile |
|
|
|
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?", self.profile) |
|
self.NSS.handle_error() |
|
raise Exit(Exit.FAIL_INIT_NSS) |
|
|
|
def attack(self, dictionary): |
|
"""Dictionary attack |
|
""" |
|
with open(dictionary) as f: |
|
for l in f: |
|
password = l.strip() |
|
if password == "" or password.startswith("#"): |
|
continue |
|
try: |
|
self.authenticate(False, password, True) |
|
LOG.info("Password found: {}".format(password)) |
|
break |
|
except Exit: |
|
continue |
|
self.authenticate(False, password) |
|
|
|
def authenticate(self, interactive, password=None, silent=False): |
|
"""Check if the current profile is protected by a master password, |
|
prompt the user and unlock the profile. |
|
""" |
|
if not silent: |
|
LOG.debug("Retrieving internal key slot") |
|
keyslot = self.NSS._PK11_GetInternalKeySlot() |
|
|
|
if not silent: |
|
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) |
|
|
|
if not password: |
|
password = ask_password(self.profile, interactive) |
|
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 unnecessary runtime dependency |
|
|
|
if password: |
|
LOG.debug("Authenticating with password '%s'", password) |
|
e = self.NSS._PK11_CheckUserPassword(keyslot, password.encode(LIB_ENCODING)) |
|
|
|
if not silent: |
|
LOG.debug("Checking user password returned %s", e) |
|
|
|
if e != 0: |
|
if not silent: |
|
LOG.error("Master password is not correct") |
|
|
|
self.NSS.handle_error() |
|
raise Exit(Exit.BAD_MASTER_PASSWORD) |
|
|
|
else: |
|
LOG.warning("Attempting decryption with no Master Password") |
|
finally: |
|
# Avoid leaking PK11KeySlot |
|
self.NSS._PK11_FreeSlot(keyslot) |
|
|
|
def unload_profile(self): |
|
"""Shutdown NSS and deactivate current profile |
|
""" |
|
e = self.NSS._NSS_Shutdown() |
|
|
|
if e != 0: |
|
LOG.error("Couldn't shutdown current NSS profile") |
|
|
|
self.NSS.handle_error() |
|
raise Exit(Exit.FAIL_SHUTDOWN_NSS) |
|
|
|
def decode_entry(self, user64, passw64): |
|
"""Decrypt one entry in the database |
|
""" |
|
LOG.debug("Decrypting username data '%s'", user64) |
|
user = self.NSS.decode(user64) |
|
|
|
LOG.debug("Decrypting password data '%s'", passw64) |
|
passw = self.NSS.decode(passw64) |
|
|
|
return user, passw |
|
|
|
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. |
|
""" |
|
# Any password in this profile store at all? |
|
got_password = False |
|
header = True |
|
|
|
credentials = obtain_credentials(self.profile) |
|
|
|
LOG.info("Decrypting credentials") |
|
to_export = {} |
|
outputs = [] |
|
|
|
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 |
|
if enctype: |
|
user, passw = self.decode_entry(user, passw) |
|
|
|
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(url) |
|
|
|
if address.netloc not in to_export: |
|
to_export[address.netloc] = {user: passw} |
|
|
|
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(USR_ENCODING) for k, v in output.items()}) |
|
elif output_format == "json": |
|
output = {"url": url, "user": user, "password": passw} |
|
outputs.append(output) |
|
|
|
else: |
|
output = ( |
|
u"\nWebsite: {0}\n".format(url), |
|
u"Username: '{0}'\n".format(user), |
|
u"Password: '{0}'\n".format(passw), |
|
) |
|
for line in output: |
|
sys.stdout.write(py2_encode(line, USR_ENCODING)) |
|
if output_format == "json": |
|
print(json.dumps(outputs)) |
|
|
|
|
|
credentials.done() |
|
|
|
if not got_password: |
|
LOG.warning("No passwords found in selected profile") |
|
|
|
if export: |
|
return to_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 |
|
""" |
|
# 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_cmd], 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, 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) |
|
|
|
else: |
|
passname = u"{0}{1}".format(prefix, address) |
|
|
|
LOG.debug("Exporting credentials for '%s'", passname) |
|
|
|
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_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(SYS_ENCODING)) |
|
|
|
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() |
|
except EOFError: |
|
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, interactive): |
|
""" |
|
Prompt for profile password |
|
""" |
|
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_...) |
|
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 = "" |
|
|
|
return py2_decode(passwd) |
|
|
|
|
|
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.warning("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, interactive, choice, list_profiles): |
|
""" |
|
Select profile to use by either reading profiles.ini or assuming given |
|
path is already a profile |
|
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. |
|
""" |
|
try: |
|
profiles = read_profiles(basepath, list_profiles) |
|
except Exit as e: |
|
if e.exitcode == Exit.MISSING_PROFILEINI: |
|
LOG.warning("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 not 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) |
|
|
|
section = py2_decode(section, LIB_ENCODING) |
|
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 |
|
""" |
|
|
|
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, |
|
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("-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") |
|
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.") |
|
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") |
|
parser.add_argument("--version", action="version", version=__version__, |
|
help="Display version of firefox_decrypt and exit") |
|
parser.add_argument("-w", "--wordlist", help="wordlist for password guessing attack") |
|
|
|
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 |
|
|
|
|
|
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) |
|
|
|
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, args.pass_cmd) |
|
|
|
# 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.interactive, args.choice, args.list) |
|
|
|
# Start NSS for selected profile |
|
nss.load_profile(profile) |
|
if not args.wordlist: |
|
# Check if profile is password protected and prompt for a password |
|
nss.authenticate(args.interactive) |
|
else: |
|
nss.attack(args.wordlist) |
|
# Decode all passwords |
|
to_export = nss.decrypt_passwords( |
|
export=args.export_pass, |
|
output_format=args.format, |
|
csv_delimiter=args.delimiter, |
|
csv_quotechar=args.quotechar, |
|
) |
|
|
|
if args.export_pass: |
|
# 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() |
|
|
|
|
|
if __name__ == "__main__": |
|
try: |
|
main() |
|
except KeyboardInterrupt as e: |
|
print("Quit.") |
|
sys.exit(Exit.KEYBOARD_INTERRUPT) |
|
except Exit as e: |
|
sys.exit(e.exitcode) |