mirror of
https://github.com/unode/firefox_decrypt.git
synced 2025-12-16 12:01:52 +01:00
@@ -5,6 +5,8 @@
|
|||||||
- Allow overriding default encoding
|
- Allow overriding default encoding
|
||||||
- Add `--pass-always-with-login` to always include /login as part of pass's password path
|
- Add `--pass-always-with-login` to always include /login as part of pass's password path
|
||||||
- Improve compatibility with `gopass` by explicitly using `pass ls` instead of `pass`
|
- Improve compatibility with `gopass` by explicitly using `pass ls` instead of `pass`
|
||||||
|
- Add `--non-fatal-decryption` to attempt decrypting partially corrupt databases instead of aborting on first failure
|
||||||
|
- Enable All Contributors framework in project
|
||||||
|
|
||||||
##### 1.0.0
|
##### 1.0.0
|
||||||
- Improve detection of NSS in Windows and MacOS
|
- Improve detection of NSS in Windows and MacOS
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -83,7 +83,7 @@ You can also choose from one of the supported formats with `--format`:
|
|||||||
* `json` - a machine compatible format - see [JSON](https://en.wikipedia.org/wiki/JSON)
|
* `json` - a machine compatible format - see [JSON](https://en.wikipedia.org/wiki/JSON)
|
||||||
* `pass` - a special output format that directly calls to the [passwordstore.org](https://www.passwordstore.org) command to export passwords (*). See also `--pass-*` options.
|
* `pass` - a special output format that directly calls to the [passwordstore.org](https://www.passwordstore.org) command to export passwords (*). See also `--pass-*` options.
|
||||||
|
|
||||||
(*) `pass` can produce unintended consequences. Make sure to backup your password store before using this.
|
(*) `pass` can produce unintended consequences. Make sure to backup your password store before using this option.
|
||||||
|
|
||||||
##### Non-interactive mode
|
##### Non-interactive mode
|
||||||
|
|
||||||
@@ -164,6 +164,19 @@ There is currently no way to selectively export passwords.
|
|||||||
|
|
||||||
Exporting will overwrite existing passwords without warning. Ensure you have a backup or are using the `pass git` functionality.
|
Exporting will overwrite existing passwords without warning. Ensure you have a backup or are using the `pass git` functionality.
|
||||||
|
|
||||||
|
##### Non fatal password decryption
|
||||||
|
|
||||||
|
By default, encountering a corrupted username or password will abort decryption.
|
||||||
|
Since version `1.1.0` there is now `--non-fatal-decryption` that tolerates individual failures.
|
||||||
|
|
||||||
|
$ python firefox_decrypt.py --non-fatal-decryption
|
||||||
|
(...)
|
||||||
|
Website: https://github.com
|
||||||
|
Username: '*** decryption failed ***'
|
||||||
|
Password: '*** decryption failed ***'
|
||||||
|
|
||||||
|
which can also be combined with any of the above `--format` options.
|
||||||
|
|
||||||
#### Troubleshooting
|
#### Troubleshooting
|
||||||
|
|
||||||
If a problem occurs, please try `firefox_decrypt` in high verbosity mode by calling it with:
|
If a problem occurs, please try `firefox_decrypt` in high verbosity mode by calling it with:
|
||||||
@@ -176,6 +189,10 @@ If the output does not help you to identify the cause and a solution to the prob
|
|||||||
|
|
||||||
- your profile password, as well as other passwords, may be visible in the output – so **please remove any sensitive data** before sharing the output.
|
- your profile password, as well as other passwords, may be visible in the output – so **please remove any sensitive data** before sharing the output.
|
||||||
|
|
||||||
|
##### Silencing error messages
|
||||||
|
|
||||||
|
Logging messages above warning level are included in the standard error output by default as these can be useful to troubleshoot failures.
|
||||||
|
If you wish to omit this information append ` 2>/dev/null` to your command on UNIX and ` 2> nul` on Windows.
|
||||||
|
|
||||||
##### Windows
|
##### Windows
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ def get_version() -> str:
|
|||||||
"""Obtain version information from git if available otherwise use
|
"""Obtain version information from git if available otherwise use
|
||||||
the internal version number
|
the internal version number
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def internal_version():
|
def internal_version():
|
||||||
return '.'.join(map(str, __version_info__[:3])) + ''.join(__version_info__[3:])
|
return ".".join(map(str, __version_info__[:3])) + "".join(__version_info__[3:])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
p = run(["git", "describe", "--tags"], stdout=PIPE, stderr=DEVNULL, text=True)
|
p = run(["git", "describe", "--tags"], stdout=PIPE, stderr=DEVNULL, text=True)
|
||||||
@@ -78,14 +79,14 @@ __version__: str = get_version()
|
|||||||
|
|
||||||
|
|
||||||
class NotFoundError(Exception):
|
class NotFoundError(Exception):
|
||||||
"""Exception to handle situations where a credentials file is not found
|
"""Exception to handle situations where a credentials file is not found"""
|
||||||
"""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Exit(Exception):
|
class Exit(Exception):
|
||||||
"""Exception to allow a clean exit from any point in execution
|
"""Exception to allow a clean exit from any point in execution"""
|
||||||
"""
|
|
||||||
CLEAN = 0
|
CLEAN = 0
|
||||||
ERROR = 1
|
ERROR = 1
|
||||||
MISSING_PROFILEINI = 2
|
MISSING_PROFILEINI = 2
|
||||||
@@ -123,8 +124,8 @@ class Exit(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class Credentials:
|
class Credentials:
|
||||||
"""Base credentials backend manager
|
"""Base credentials backend manager"""
|
||||||
"""
|
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
@@ -145,8 +146,8 @@ class Credentials:
|
|||||||
|
|
||||||
|
|
||||||
class SqliteCredentials(Credentials):
|
class SqliteCredentials(Credentials):
|
||||||
"""SQLite credentials backend manager
|
"""SQLite credentials backend manager"""
|
||||||
"""
|
|
||||||
def __init__(self, profile):
|
def __init__(self, profile):
|
||||||
db = os.path.join(profile, "signons.sqlite")
|
db = os.path.join(profile, "signons.sqlite")
|
||||||
|
|
||||||
@@ -157,15 +158,16 @@ class SqliteCredentials(Credentials):
|
|||||||
|
|
||||||
def __iter__(self) -> Iterator[tuple[str, str, str, int]]:
|
def __iter__(self) -> Iterator[tuple[str, str, str, int]]:
|
||||||
LOG.debug("Reading password database in SQLite format")
|
LOG.debug("Reading password database in SQLite format")
|
||||||
self.c.execute("SELECT hostname, encryptedUsername, encryptedPassword, encType "
|
self.c.execute(
|
||||||
"FROM moz_logins")
|
"SELECT hostname, encryptedUsername, encryptedPassword, encType "
|
||||||
|
"FROM moz_logins"
|
||||||
|
)
|
||||||
for i in self.c:
|
for i in self.c:
|
||||||
# yields hostname, encryptedUsername, encryptedPassword, encType
|
# yields hostname, encryptedUsername, encryptedPassword, encType
|
||||||
yield i
|
yield i
|
||||||
|
|
||||||
def done(self):
|
def done(self):
|
||||||
"""Close the sqlite cursor and database connection
|
"""Close the sqlite cursor and database connection"""
|
||||||
"""
|
|
||||||
super(SqliteCredentials, self).done()
|
super(SqliteCredentials, self).done()
|
||||||
|
|
||||||
self.c.close()
|
self.c.close()
|
||||||
@@ -173,8 +175,8 @@ class SqliteCredentials(Credentials):
|
|||||||
|
|
||||||
|
|
||||||
class JsonCredentials(Credentials):
|
class JsonCredentials(Credentials):
|
||||||
"""JSON credentials backend manager
|
"""JSON credentials backend manager"""
|
||||||
"""
|
|
||||||
def __init__(self, profile):
|
def __init__(self, profile):
|
||||||
db = os.path.join(profile, "logins.json")
|
db = os.path.join(profile, "logins.json")
|
||||||
|
|
||||||
@@ -192,13 +194,16 @@ class JsonCredentials(Credentials):
|
|||||||
raise Exit(Exit.BAD_SECRETS)
|
raise Exit(Exit.BAD_SECRETS)
|
||||||
|
|
||||||
for i in logins:
|
for i in logins:
|
||||||
yield (i["hostname"], i["encryptedUsername"],
|
yield (
|
||||||
i["encryptedPassword"], i["encType"])
|
i["hostname"],
|
||||||
|
i["encryptedUsername"],
|
||||||
|
i["encryptedPassword"],
|
||||||
|
i["encType"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def find_nss(locations, nssname) -> ct.CDLL:
|
def find_nss(locations, nssname) -> ct.CDLL:
|
||||||
"""Locate nss is one of the many possible locations
|
"""Locate nss is one of the many possible locations"""
|
||||||
"""
|
|
||||||
fail_errors: list[tuple[str, str]] = []
|
fail_errors: list[tuple[str, str]] = []
|
||||||
|
|
||||||
OS = ("Windows", "Darwin")
|
OS = ("Windows", "Darwin")
|
||||||
@@ -210,7 +215,7 @@ def find_nss(locations, nssname) -> ct.CDLL:
|
|||||||
if SYSTEM in OS:
|
if SYSTEM in OS:
|
||||||
# On windows in order to find DLLs referenced by nss3.dll
|
# On windows in order to find DLLs referenced by nss3.dll
|
||||||
# we need to have those locations on PATH
|
# we need to have those locations on PATH
|
||||||
os.environ["PATH"] = ';'.join([loc, os.environ["PATH"]])
|
os.environ["PATH"] = ";".join([loc, os.environ["PATH"]])
|
||||||
LOG.debug("PATH is now %s", 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
|
# 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
|
# set before starting python so as a workaround we chdir to
|
||||||
@@ -236,18 +241,29 @@ def find_nss(locations, nssname) -> ct.CDLL:
|
|||||||
os.chdir(workdir)
|
os.chdir(workdir)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
LOG.error("Couldn't find or load '%s'. This library is essential "
|
LOG.error(
|
||||||
"to interact with your Mozilla profile.", nssname)
|
"Couldn't find or load '%s'. This library is essential "
|
||||||
LOG.error("If you are seeing this error please perform a system-wide "
|
"to interact with your Mozilla profile.",
|
||||||
"search for '%s' and file a bug report indicating any "
|
nssname,
|
||||||
"location found. Thanks!", nssname)
|
)
|
||||||
LOG.error("Alternatively you can try launching firefox_decrypt "
|
LOG.error(
|
||||||
"from the location where you found '%s'. "
|
"If you are seeing this error please perform a system-wide "
|
||||||
"That is 'cd' or 'chdir' to that location and run "
|
"search for '%s' and file a bug report indicating any "
|
||||||
"firefox_decrypt from there.", nssname)
|
"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. "
|
LOG.error(
|
||||||
"Errors seen while searching/loading NSS:")
|
"Please also include the following on any bug report. "
|
||||||
|
"Errors seen while searching/loading NSS:"
|
||||||
|
)
|
||||||
|
|
||||||
for target, error in fail_errors:
|
for target, error in fail_errors:
|
||||||
LOG.error("Error when loading %s was %s", target, error)
|
LOG.error("Error when loading %s was %s", target, error)
|
||||||
@@ -256,8 +272,7 @@ def find_nss(locations, nssname) -> ct.CDLL:
|
|||||||
|
|
||||||
|
|
||||||
def load_libnss():
|
def load_libnss():
|
||||||
"""Load libnss into python using the CDLL interface
|
"""Load libnss into python using the CDLL interface"""
|
||||||
"""
|
|
||||||
if SYSTEM == "Windows":
|
if SYSTEM == "Windows":
|
||||||
nssname = "nss3.dll"
|
nssname = "nss3.dll"
|
||||||
locations: list[str] = [
|
locations: list[str] = [
|
||||||
@@ -348,18 +363,19 @@ def load_libnss():
|
|||||||
|
|
||||||
class c_char_p_fromstr(ct.c_char_p):
|
class c_char_p_fromstr(ct.c_char_p):
|
||||||
"""ctypes char_p override that handles encoding str to bytes"""
|
"""ctypes char_p override that handles encoding str to bytes"""
|
||||||
|
|
||||||
def from_param(self):
|
def from_param(self):
|
||||||
return self.encode(DEFAULT_ENCODING)
|
return self.encode(DEFAULT_ENCODING)
|
||||||
|
|
||||||
|
|
||||||
class NSSProxy:
|
class NSSProxy:
|
||||||
class SECItem(ct.Structure):
|
class SECItem(ct.Structure):
|
||||||
"""struct needed to interact with libnss
|
"""struct needed to interact with libnss"""
|
||||||
"""
|
|
||||||
_fields_ = [
|
_fields_ = [
|
||||||
('type', ct.c_uint),
|
("type", ct.c_uint),
|
||||||
('data', ct.c_char_p), # actually: unsigned char *
|
("data", ct.c_char_p), # actually: unsigned char *
|
||||||
('len', ct.c_uint),
|
("len", ct.c_uint),
|
||||||
]
|
]
|
||||||
|
|
||||||
def decode_data(self):
|
def decode_data(self):
|
||||||
@@ -367,12 +383,12 @@ class NSSProxy:
|
|||||||
return _bytes.decode(DEFAULT_ENCODING)
|
return _bytes.decode(DEFAULT_ENCODING)
|
||||||
|
|
||||||
class PK11SlotInfo(ct.Structure):
|
class PK11SlotInfo(ct.Structure):
|
||||||
"""Opaque structure representing a logical PKCS slot
|
"""Opaque structure representing a logical PKCS slot"""
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, non_fatal_decryption=False):
|
||||||
# Locate libnss and try loading it
|
# Locate libnss and try loading it
|
||||||
self.libnss = load_libnss()
|
self.libnss = load_libnss()
|
||||||
|
self.non_fatal_decryption = non_fatal_decryption
|
||||||
|
|
||||||
SlotInfoPtr = ct.POINTER(self.PK11SlotInfo)
|
SlotInfoPtr = ct.POINTER(self.PK11SlotInfo)
|
||||||
SECItemPtr = ct.POINTER(self.SECItem)
|
SECItemPtr = ct.POINTER(self.SECItem)
|
||||||
@@ -382,8 +398,12 @@ class NSSProxy:
|
|||||||
self._set_ctypes(SlotInfoPtr, "PK11_GetInternalKeySlot")
|
self._set_ctypes(SlotInfoPtr, "PK11_GetInternalKeySlot")
|
||||||
self._set_ctypes(None, "PK11_FreeSlot", SlotInfoPtr)
|
self._set_ctypes(None, "PK11_FreeSlot", SlotInfoPtr)
|
||||||
self._set_ctypes(ct.c_int, "PK11_NeedLogin", SlotInfoPtr)
|
self._set_ctypes(ct.c_int, "PK11_NeedLogin", SlotInfoPtr)
|
||||||
self._set_ctypes(ct.c_int, "PK11_CheckUserPassword", SlotInfoPtr, c_char_p_fromstr)
|
self._set_ctypes(
|
||||||
self._set_ctypes(ct.c_int, "PK11SDR_Decrypt", SECItemPtr, SECItemPtr, ct.c_void_p)
|
ct.c_int, "PK11_CheckUserPassword", SlotInfoPtr, c_char_p_fromstr
|
||||||
|
)
|
||||||
|
self._set_ctypes(
|
||||||
|
ct.c_int, "PK11SDR_Decrypt", SECItemPtr, SECItemPtr, ct.c_void_p
|
||||||
|
)
|
||||||
self._set_ctypes(None, "SECITEM_ZfreeItem", SECItemPtr, ct.c_int)
|
self._set_ctypes(None, "SECITEM_ZfreeItem", SECItemPtr, ct.c_int)
|
||||||
|
|
||||||
# for error handling
|
# for error handling
|
||||||
@@ -392,19 +412,20 @@ class NSSProxy:
|
|||||||
self._set_ctypes(ct.c_char_p, "PR_ErrorToString", ct.c_int, ct.c_uint32)
|
self._set_ctypes(ct.c_char_p, "PR_ErrorToString", ct.c_int, ct.c_uint32)
|
||||||
|
|
||||||
def _set_ctypes(self, restype, name, *argtypes):
|
def _set_ctypes(self, restype, name, *argtypes):
|
||||||
"""Set input/output types on libnss C functions for automatic type casting
|
"""Set input/output types on libnss C functions for automatic type casting"""
|
||||||
"""
|
|
||||||
res = getattr(self.libnss, name)
|
res = getattr(self.libnss, name)
|
||||||
res.argtypes = argtypes
|
res.argtypes = argtypes
|
||||||
res.restype = restype
|
res.restype = restype
|
||||||
|
|
||||||
# Transparently handle decoding to string when returning a c_char_p
|
# Transparently handle decoding to string when returning a c_char_p
|
||||||
if restype == ct.c_char_p:
|
if restype == ct.c_char_p:
|
||||||
|
|
||||||
def _decode(result, func, *args):
|
def _decode(result, func, *args):
|
||||||
try:
|
try:
|
||||||
return result.decode(DEFAULT_ENCODING)
|
return result.decode(DEFAULT_ENCODING)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
res.errcheck = _decode
|
res.errcheck = _decode
|
||||||
|
|
||||||
setattr(self, "_" + name, res)
|
setattr(self, "_" + name, res)
|
||||||
@@ -469,8 +490,7 @@ class NSSProxy:
|
|||||||
self._PK11_FreeSlot(keyslot)
|
self._PK11_FreeSlot(keyslot)
|
||||||
|
|
||||||
def handle_error(self, exitcode: int, *logerror: Any):
|
def handle_error(self, exitcode: int, *logerror: Any):
|
||||||
"""If an error happens in libnss, handle it and print some debug information
|
"""If an error happens in libnss, handle it and print some debug information"""
|
||||||
"""
|
|
||||||
if logerror:
|
if logerror:
|
||||||
LOG.error(*logerror)
|
LOG.error(*logerror)
|
||||||
else:
|
else:
|
||||||
@@ -495,12 +515,16 @@ class NSSProxy:
|
|||||||
LOG.debug("Decryption of data returned %s", err_status)
|
LOG.debug("Decryption of data returned %s", err_status)
|
||||||
try:
|
try:
|
||||||
if err_status: # -1 means password failed, other status are unknown
|
if err_status: # -1 means password failed, other status are unknown
|
||||||
self.handle_error(
|
error_msg = (
|
||||||
Exit.DECRYPTION_FAILED,
|
|
||||||
"Username/Password decryption failed. "
|
"Username/Password decryption failed. "
|
||||||
"Credentials damaged or cert/key file mismatch.",
|
"Credentials damaged or cert/key file mismatch."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.non_fatal_decryption:
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
else:
|
||||||
|
self.handle_error(Exit.DECRYPTION_FAILED, error_msg)
|
||||||
|
|
||||||
res = out.decode_data()
|
res = out.decode_data()
|
||||||
finally:
|
finally:
|
||||||
# Avoid leaking SECItem
|
# Avoid leaking SECItem
|
||||||
@@ -513,13 +537,13 @@ class MozillaInteraction:
|
|||||||
"""
|
"""
|
||||||
Abstraction interface to Mozilla profile and lib NSS
|
Abstraction interface to Mozilla profile and lib NSS
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
|
||||||
|
def __init__(self, non_fatal_decryption=False):
|
||||||
self.profile = None
|
self.profile = None
|
||||||
self.proxy = NSSProxy()
|
self.proxy = NSSProxy(non_fatal_decryption)
|
||||||
|
|
||||||
def load_profile(self, profile):
|
def load_profile(self, profile):
|
||||||
"""Initialize the NSS library and profile
|
"""Initialize the NSS library and profile"""
|
||||||
"""
|
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
self.proxy.initialize(self.profile)
|
self.proxy.initialize(self.profile)
|
||||||
|
|
||||||
@@ -530,8 +554,7 @@ class MozillaInteraction:
|
|||||||
self.proxy.authenticate(self.profile, interactive)
|
self.proxy.authenticate(self.profile, interactive)
|
||||||
|
|
||||||
def unload_profile(self):
|
def unload_profile(self):
|
||||||
"""Shutdown NSS and deactivate current profile
|
"""Shutdown NSS and deactivate current profile"""
|
||||||
"""
|
|
||||||
self.proxy.shutdown()
|
self.proxy.shutdown()
|
||||||
|
|
||||||
def decrypt_passwords(self) -> PWStore:
|
def decrypt_passwords(self) -> PWStore:
|
||||||
@@ -541,7 +564,7 @@ class MozillaInteraction:
|
|||||||
credentials: Credentials = self.obtain_credentials()
|
credentials: Credentials = self.obtain_credentials()
|
||||||
|
|
||||||
LOG.info("Decrypting credentials")
|
LOG.info("Decrypting credentials")
|
||||||
outputs: list[dict[str, str]] = []
|
outputs: PWStore = []
|
||||||
|
|
||||||
url: str
|
url: str
|
||||||
user: str
|
user: str
|
||||||
@@ -556,11 +579,20 @@ class MozillaInteraction:
|
|||||||
LOG.debug("Decrypting password data '%s'", passw)
|
LOG.debug("Decrypting password data '%s'", passw)
|
||||||
passw = self.proxy.decrypt(passw)
|
passw = self.proxy.decrypt(passw)
|
||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
LOG.warning("Failed to decode username or password for entry from URL %s", url)
|
LOG.warning(
|
||||||
|
"Failed to decode username or password for entry from URL %s",
|
||||||
|
url,
|
||||||
|
)
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
continue
|
user = "*** decryption failed ***"
|
||||||
|
passw = "*** decryption failed ***"
|
||||||
|
|
||||||
LOG.debug("Decoded username '%s' and password '%s' for website '%s'", user, passw, url)
|
LOG.debug(
|
||||||
|
"Decoded username '%s' and password '%s' for website '%s'",
|
||||||
|
user,
|
||||||
|
passw,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
|
||||||
output = {"url": url, "user": user, "password": passw}
|
output = {"url": url, "user": user, "password": passw}
|
||||||
outputs.append(output)
|
outputs.append(output)
|
||||||
@@ -574,8 +606,7 @@ class MozillaInteraction:
|
|||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
def obtain_credentials(self) -> Credentials:
|
def obtain_credentials(self) -> Credentials:
|
||||||
"""Figure out which of the 2 possible backend credential engines is available
|
"""Figure out which of the 2 possible backend credential engines is available"""
|
||||||
"""
|
|
||||||
credentials: Credentials
|
credentials: Credentials
|
||||||
try:
|
try:
|
||||||
credentials = JsonCredentials(self.profile)
|
credentials = JsonCredentials(self.profile)
|
||||||
@@ -583,7 +614,9 @@ class MozillaInteraction:
|
|||||||
try:
|
try:
|
||||||
credentials = SqliteCredentials(self.profile)
|
credentials = SqliteCredentials(self.profile)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
LOG.error("Couldn't find credentials file (logins.json or signons.sqlite).")
|
LOG.error(
|
||||||
|
"Couldn't find credentials file (logins.json or signons.sqlite)."
|
||||||
|
)
|
||||||
raise Exit(Exit.MISSING_SECRETS)
|
raise Exit(Exit.MISSING_SECRETS)
|
||||||
|
|
||||||
return credentials
|
return credentials
|
||||||
@@ -739,14 +772,22 @@ class PassOutputFormat(OutputFormat):
|
|||||||
LOG.debug("Inserting pass '%s' '%s'", passname, data)
|
LOG.debug("Inserting pass '%s' '%s'", passname, data)
|
||||||
|
|
||||||
# NOTE --force is used. Existing passwords will be overwritten
|
# NOTE --force is used. Existing passwords will be overwritten
|
||||||
cmd: list[str] = [self.cmd, "insert", "--force", "--multiline", passname]
|
cmd: list[str] = [
|
||||||
|
self.cmd,
|
||||||
|
"insert",
|
||||||
|
"--force",
|
||||||
|
"--multiline",
|
||||||
|
passname,
|
||||||
|
]
|
||||||
|
|
||||||
LOG.debug("Running command '%s' with stdin '%s'", cmd, data)
|
LOG.debug("Running command '%s' with stdin '%s'", cmd, data)
|
||||||
|
|
||||||
p = run(cmd, input=data, capture_output=True, text=True)
|
p = run(cmd, input=data, capture_output=True, text=True)
|
||||||
|
|
||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
LOG.error("ERROR: passwordstore exited with non-zero: %s", p.returncode)
|
LOG.error(
|
||||||
|
"ERROR: passwordstore exited with non-zero: %s", p.returncode
|
||||||
|
)
|
||||||
LOG.error("Stdout: %s\nStderr: %s", p.stdout, p.stderr)
|
LOG.error("Stdout: %s\nStderr: %s", p.stdout, p.stderr)
|
||||||
raise Exit(Exit.PASSSTORE_ERROR)
|
raise Exit(Exit.PASSSTORE_ERROR)
|
||||||
|
|
||||||
@@ -843,7 +884,9 @@ def read_profiles(basepath):
|
|||||||
return profiles
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
def get_profile(basepath: str, interactive: bool, choice: Optional[str], list_profiles: bool):
|
def get_profile(
|
||||||
|
basepath: str, interactive: bool, choice: Optional[str], list_profiles: bool
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Select profile to use by either reading profiles.ini or assuming given
|
Select profile to use by either reading profiles.ini or assuming given
|
||||||
path is already a profile
|
path is already a profile
|
||||||
@@ -887,8 +930,10 @@ def get_profile(basepath: str, interactive: bool, choice: Optional[str], list_pr
|
|||||||
raise Exit(Exit.NO_SUCH_PROFILE)
|
raise Exit(Exit.NO_SUCH_PROFILE)
|
||||||
|
|
||||||
elif not interactive:
|
elif not interactive:
|
||||||
LOG.error("Don't know which profile to decrypt. "
|
LOG.error(
|
||||||
"We are in non-interactive mode and -c/--choice wasn't specified.")
|
"Don't know which profile to decrypt. "
|
||||||
|
"We are in non-interactive mode and -c/--choice wasn't specified."
|
||||||
|
)
|
||||||
raise Exit(Exit.MISSING_CHOICE)
|
raise Exit(Exit.MISSING_CHOICE)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -899,7 +944,10 @@ def get_profile(basepath: str, interactive: bool, choice: Optional[str], list_pr
|
|||||||
profile = os.path.join(basepath, section)
|
profile = os.path.join(basepath, section)
|
||||||
|
|
||||||
if not os.path.isdir(profile):
|
if not os.path.isdir(profile):
|
||||||
LOG.error("Profile location '%s' is not a directory. Has profiles.ini been tampered with?", profile)
|
LOG.error(
|
||||||
|
"Profile location '%s' is not a directory. Has profiles.ini been tampered with?",
|
||||||
|
profile,
|
||||||
|
)
|
||||||
raise Exit(Exit.BAD_PROFILEINI)
|
raise Exit(Exit.BAD_PROFILEINI)
|
||||||
|
|
||||||
return profile
|
return profile
|
||||||
@@ -911,6 +959,7 @@ class ConvertChoices(argparse.Action):
|
|||||||
mapping the user-specified choices values to the resulting option
|
mapping the user-specified choices values to the resulting option
|
||||||
values.
|
values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, choices, **kwargs):
|
def __init__(self, *args, choices, **kwargs):
|
||||||
super().__init__(*args, choices=choices.keys(), **kwargs)
|
super().__init__(*args, choices=choices.keys(), **kwargs)
|
||||||
self.mapping = choices
|
self.mapping = choices
|
||||||
@@ -920,11 +969,10 @@ class ConvertChoices(argparse.Action):
|
|||||||
|
|
||||||
|
|
||||||
def parse_sys_args() -> argparse.Namespace:
|
def parse_sys_args() -> argparse.Namespace:
|
||||||
"""Parse command line arguments
|
"""Parse command line arguments"""
|
||||||
"""
|
|
||||||
|
|
||||||
if SYSTEM == "Windows":
|
if SYSTEM == "Windows":
|
||||||
profile_path = os.path.join(os.environ['APPDATA'], "Mozilla", "Firefox")
|
profile_path = os.path.join(os.environ["APPDATA"], "Mozilla", "Firefox")
|
||||||
elif os.uname()[0] == "Darwin":
|
elif os.uname()[0] == "Darwin":
|
||||||
profile_path = "~/Library/Application Support/Firefox"
|
profile_path = "~/Library/Application Support/Firefox"
|
||||||
else:
|
else:
|
||||||
@@ -935,8 +983,10 @@ def parse_sys_args() -> argparse.Namespace:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"profile",
|
"profile",
|
||||||
nargs="?", default=profile_path,
|
nargs="?",
|
||||||
help=f"Path to profile folder (default: {profile_path})")
|
default=profile_path,
|
||||||
|
help=f"Path to profile folder (default: {profile_path})",
|
||||||
|
)
|
||||||
|
|
||||||
format_choices = {
|
format_choices = {
|
||||||
"human": HumanOutputFormat,
|
"human": HumanOutputFormat,
|
||||||
@@ -947,25 +997,34 @@ def parse_sys_args() -> argparse.Namespace:
|
|||||||
}
|
}
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-f", "--format",
|
"-f",
|
||||||
|
"--format",
|
||||||
action=ConvertChoices,
|
action=ConvertChoices,
|
||||||
choices=format_choices, default=HumanOutputFormat,
|
choices=format_choices,
|
||||||
help="Format for the output.")
|
default=HumanOutputFormat,
|
||||||
|
help="Format for the output.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-d", "--csv-delimiter",
|
"-d",
|
||||||
|
"--csv-delimiter",
|
||||||
action="store",
|
action="store",
|
||||||
default=";",
|
default=";",
|
||||||
help="The delimiter for csv output")
|
help="The delimiter for csv output",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-q", "--csv-quotechar",
|
"-q",
|
||||||
|
"--csv-quotechar",
|
||||||
action="store",
|
action="store",
|
||||||
default='"',
|
default='"',
|
||||||
help="The quote char for csv output")
|
help="The quote char for csv output",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-csv-header",
|
"--no-csv-header",
|
||||||
action="store_false", dest="csv_header",
|
action="store_false",
|
||||||
|
dest="csv_header",
|
||||||
default=True,
|
default=True,
|
||||||
help="Do not include a header in CSV output.")
|
help="Do not include a header in CSV output.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--pass-username-prefix",
|
"--pass-username-prefix",
|
||||||
action="store",
|
action="store",
|
||||||
@@ -973,47 +1032,69 @@ def parse_sys_args() -> argparse.Namespace:
|
|||||||
help=(
|
help=(
|
||||||
"Export username as is (default), or with the provided format prefix. "
|
"Export username as is (default), or with the provided format prefix. "
|
||||||
"For instance 'login: ' for browserpass."
|
"For instance 'login: ' for browserpass."
|
||||||
))
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-p", "--pass-prefix",
|
"-p",
|
||||||
|
"--pass-prefix",
|
||||||
action="store",
|
action="store",
|
||||||
default="web",
|
default="web",
|
||||||
help="Folder prefix for export to pass from passwordstore.org (default: %(default)s)")
|
help="Folder prefix for export to pass from passwordstore.org (default: %(default)s)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-m", "--pass-cmd",
|
"-m",
|
||||||
|
"--pass-cmd",
|
||||||
action="store",
|
action="store",
|
||||||
default="pass",
|
default="pass",
|
||||||
help="Command/path to use when exporting to pass (default: %(default)s)")
|
help="Command/path to use when exporting to pass (default: %(default)s)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--pass-always-with-login",
|
"--pass-always-with-login",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Always save as /<login> (default: only when multiple accounts per domain)")
|
help="Always save as /<login> (default: only when multiple accounts per domain)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-n", "--no-interactive",
|
"-n",
|
||||||
action="store_false", dest="interactive",
|
"--no-interactive",
|
||||||
|
action="store_false",
|
||||||
|
dest="interactive",
|
||||||
default=True,
|
default=True,
|
||||||
help="Disable interactivity.")
|
help="Disable interactivity.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-c", "--choice",
|
"--non-fatal-decryption",
|
||||||
help="The profile to use (starts with 1). If only one profile, defaults to that.")
|
|
||||||
parser.add_argument(
|
|
||||||
"-l", "--list",
|
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="List profiles and exit.")
|
default=False,
|
||||||
|
help="If set, corrupted entries will be skipped instead of aborting the process.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-e", "--encoding",
|
"-c",
|
||||||
|
"--choice",
|
||||||
|
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(
|
||||||
|
"-e",
|
||||||
|
"--encoding",
|
||||||
action="store",
|
action="store",
|
||||||
default=DEFAULT_ENCODING,
|
default=DEFAULT_ENCODING,
|
||||||
help="Override default encoding (%(default)s).")
|
help="Override default encoding (%(default)s).",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-v", "--verbose",
|
"-v",
|
||||||
|
"--verbose",
|
||||||
action="count",
|
action="count",
|
||||||
default=0,
|
default=0,
|
||||||
help="Verbosity level. Warning on -vv (highest level) user input will be printed on screen")
|
help="Verbosity level. Warning on -vv (highest level) user input will be printed on screen",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--version",
|
"--version",
|
||||||
action="version", version=__version__,
|
action="version",
|
||||||
help="Display version of firefox_decrypt and exit")
|
version=__version__,
|
||||||
|
help="Display version of firefox_decrypt and exit",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -1025,8 +1106,7 @@ def parse_sys_args() -> argparse.Namespace:
|
|||||||
|
|
||||||
|
|
||||||
def setup_logging(args) -> None:
|
def setup_logging(args) -> None:
|
||||||
"""Setup the logging level and configure the basic logger
|
"""Setup the logging level and configure the basic logger"""
|
||||||
"""
|
|
||||||
if args.verbose == 1:
|
if args.verbose == 1:
|
||||||
level = logging.INFO
|
level = logging.INFO
|
||||||
elif args.verbose >= 2:
|
elif args.verbose >= 2:
|
||||||
@@ -1059,8 +1139,7 @@ def identify_system_locale() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Main entry point
|
"""Main entry point"""
|
||||||
"""
|
|
||||||
args = parse_sys_args()
|
args = parse_sys_args()
|
||||||
|
|
||||||
setup_logging(args)
|
setup_logging(args)
|
||||||
@@ -1068,8 +1147,11 @@ def main() -> None:
|
|||||||
global DEFAULT_ENCODING
|
global DEFAULT_ENCODING
|
||||||
|
|
||||||
if args.encoding != DEFAULT_ENCODING:
|
if args.encoding != DEFAULT_ENCODING:
|
||||||
LOG.info("Overriding default encoding from '%s' to '%s'",
|
LOG.info(
|
||||||
DEFAULT_ENCODING, args.encoding)
|
"Overriding default encoding from '%s' to '%s'",
|
||||||
|
DEFAULT_ENCODING,
|
||||||
|
args.encoding,
|
||||||
|
)
|
||||||
|
|
||||||
# Override default encoding if specified by user
|
# Override default encoding if specified by user
|
||||||
DEFAULT_ENCODING = args.encoding
|
DEFAULT_ENCODING = args.encoding
|
||||||
@@ -1084,17 +1166,20 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
LOG.debug(
|
LOG.debug(
|
||||||
"Running with encodings: %s: %s, %s: %s, %s: %s, %s: %s",
|
"Running with encodings: %s: %s, %s: %s, %s: %s, %s: %s", *chain(*encodings)
|
||||||
*chain(*encodings)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for stream, encoding in encodings:
|
for stream, encoding in encodings:
|
||||||
if encoding.lower() != DEFAULT_ENCODING:
|
if encoding.lower() != DEFAULT_ENCODING:
|
||||||
LOG.warning("Running with unsupported encoding '%s': %s"
|
LOG.warning(
|
||||||
" - Things are likely to fail from here onwards", stream, encoding)
|
"Running with unsupported encoding '%s': %s"
|
||||||
|
" - Things are likely to fail from here onwards",
|
||||||
|
stream,
|
||||||
|
encoding,
|
||||||
|
)
|
||||||
|
|
||||||
# Load Mozilla profile and initialize NSS before asking the user for input
|
# Load Mozilla profile and initialize NSS before asking the user for input
|
||||||
moz = MozillaInteraction()
|
moz = MozillaInteraction(args.non_fatal_decryption)
|
||||||
|
|
||||||
basepath = os.path.expanduser(args.profile)
|
basepath = os.path.expanduser(args.profile)
|
||||||
|
|
||||||
|
|||||||
68
tests/handle_corrupted_passwords.t
Executable file
68
tests/handle_corrupted_passwords.t
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from simpletap.fdecrypt import lib
|
||||||
|
|
||||||
|
|
||||||
|
class TestCorruptedPassword(unittest.TestCase):
|
||||||
|
def validate_one(self, userkey, grepkey, output):
|
||||||
|
expected = lib.get_user_data(userkey)
|
||||||
|
matches = lib.grep(grepkey, output, context=1)
|
||||||
|
|
||||||
|
self.assertEqual(matches, expected)
|
||||||
|
|
||||||
|
def validate(self, out):
|
||||||
|
self.validate_one("decryption_failed", "Username: .* decryption failed", out)
|
||||||
|
self.validate_one("doesntexist", "doesntexist", out)
|
||||||
|
self.validate_one("onemore", "onemore", out)
|
||||||
|
self.validate_one("complex", "cömplex", out)
|
||||||
|
self.validate_one("jamie", "jãmïe", out)
|
||||||
|
|
||||||
|
def validate_exception(self, out):
|
||||||
|
# error is "ValueError: Username/Password decryption (...) Credentials damaged (...)"
|
||||||
|
err = "Credentials damaged or cert/key file mismatch."
|
||||||
|
match = lib.grep(err, out)
|
||||||
|
self.assertIn("ValueError: Username/Password", match)
|
||||||
|
|
||||||
|
def validate_error(self, out):
|
||||||
|
# error is "ERROR - Username/Password decryption (...) Credentials damaged (...)"
|
||||||
|
err = "Credentials damaged or cert/key file mismatch."
|
||||||
|
match = lib.grep(err, out)
|
||||||
|
self.assertIn("ERROR - Username/Password", match)
|
||||||
|
|
||||||
|
def run_firefox_nopassword(self, cmd):
|
||||||
|
output = lib.run(cmd)
|
||||||
|
self.validate(output)
|
||||||
|
self.validate_exception(output)
|
||||||
|
|
||||||
|
def run_firefox_nopassword_error(self, cmd):
|
||||||
|
# returncode 17 is DECRYPTION_FAILED
|
||||||
|
output = lib.run_error(cmd, returncode=17)
|
||||||
|
self.validate_error(output)
|
||||||
|
|
||||||
|
def test_corrupted_skip_firefox_114(self):
|
||||||
|
self.test = os.path.join(lib.get_test_data(),
|
||||||
|
"test_profile_firefox_nopassword_114")
|
||||||
|
|
||||||
|
# Must run in non-interactive mode or password prompt will be shown
|
||||||
|
cmd = lib.get_script() + [self.test, "-n", "--non-fatal-decryption"]
|
||||||
|
|
||||||
|
self.run_firefox_nopassword(cmd)
|
||||||
|
|
||||||
|
def test_corrupted_firefox_114(self):
|
||||||
|
self.test = os.path.join(lib.get_test_data(),
|
||||||
|
"test_profile_firefox_nopassword_114")
|
||||||
|
|
||||||
|
# Must run in non-interactive mode or password prompt will be shown
|
||||||
|
cmd = lib.get_script() + [self.test, "-n"]
|
||||||
|
|
||||||
|
self.run_firefox_nopassword_error(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from simpletap import TAPTestRunner
|
||||||
|
unittest.main(testRunner=TAPTestRunner(buffer=True), exit=False)
|
||||||
|
|
||||||
|
# vim: ai sts=4 et sw=4
|
||||||
BIN
tests/test_data/test_profile_firefox_nopassword_114/cert9.db
Normal file
BIN
tests/test_data/test_profile_firefox_nopassword_114/cert9.db
Normal file
Binary file not shown.
BIN
tests/test_data/test_profile_firefox_nopassword_114/key4.db
Normal file
BIN
tests/test_data/test_profile_firefox_nopassword_114/key4.db
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
{"nextId":6,"logins":[{"id":1,"hostname":"https://github.com","httpRealm":null,"formSubmitURL":"https://github.com","usernameField":"login","passwordField":"password","encryptedUsername":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECEgmzKOCavAWBAiab7yWG12/Rw==","encryptedPassword":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECIm+i6iNZ4okBAjDGi5OTiNc9Q==","guid":"{000011d3-d88f-4d72-916e-46aba374ee68}","encType":1,"timeCreated":1688676766618,"timeLastUsed":1688677135670,"timePasswordChanged":1688676766618,"timesUsed":2},{"id":2,"hostname":"https://github.com","httpRealm":null,"formSubmitURL":"https://github.com","usernameField":"login","passwordField":"password","encryptedUsername":"MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECMmE70ZVP0E4BBCJNgP9zigxQG7wA5Xtf6J/","encryptedPassword":"MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECAaKjRlvw62zBBDn4tthVPtEKCqkeOeUQZaM","guid":"{dd7a17bb-14c8-46e2-8170-f09cb35a6c01}","encType":1,"timeCreated":1688677150303,"timeLastUsed":1688677150303,"timePasswordChanged":1688677150303,"timesUsed":1},{"id":3,"hostname":"https://github.com","httpRealm":null,"formSubmitURL":"https://github.com","usernameField":"login","passwordField":"password","encryptedUsername":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECCb0mK51cDIgBAjToSpG3VVLnQ==","encryptedPassword":"MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECB3Oul6W4qDQBBB917xhQ/IHXUnctPwKU6vY","guid":"{badd0e1b-c526-4548-a79b-0dd878389276}","encType":1,"timeCreated":1688677214767,"timeLastUsed":1688677214767,"timePasswordChanged":1688677214767,"timesUsed":1},{"id":4,"hostname":"https://github.com","httpRealm":null,"formSubmitURL":"https://github.com","usernameField":"login","passwordField":"password","encryptedUsername":"MDoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECPWy/Wb/zVxsBBCHZJUabIuDWSSPh7IvF4M/","encryptedPassword":"MFIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECBWgpjB1kLSYBCj+3VaCZAPhdWXO40qlQTBj7wzjrAVOCGnug8366ez2dmn4/TpGPxyi","guid":"{ccd01360-2a8c-46d3-993c-5aee0dbbf647}","encType":1,"timeCreated":1688677292142,"timeLastUsed":1688677292142,"timePasswordChanged":1688677292142,"timesUsed":1},{"id":5,"hostname":"https://github.com","httpRealm":null,"formSubmitURL":"https://github.com","usernameField":"login","passwordField":"password","encryptedUsername":"MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECOcZUkTRCDK5BAjDFjezUYVSFg==","encryptedPassword":"MFoEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECM8rG1/2s/7uBDDLU1D80YJO5o2tkjh/WaXUbhOWNgLiCvPaaOFQfaI4/iCaS70SD4UrEXJSBm7ph7o=","guid":"{80816ccc-3b32-4125-89fd-36c388d91ebc}","encType":1,"timeCreated":1688677398674,"timeLastUsed":1688677398674,"timePasswordChanged":1688677398674,"timesUsed":1}],"potentiallyVulnerablePasswords":[],"dismissedBreachAlertsByLoginGUID":{},"version":3}
|
||||||
3
tests/test_data/users/decryption_failed.user
Normal file
3
tests/test_data/users/decryption_failed.user
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Website: https://github.com
|
||||||
|
Username: '*** decryption failed ***'
|
||||||
|
Password: '*** decryption failed ***'
|
||||||
Reference in New Issue
Block a user