From ed0370eb035f5861ba47643d18d3e9a480d2b9fc Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Sat, 15 Apr 2023 16:13:25 -0700 Subject: [PATCH 1/6] Factor out ChromiumBased._nt_to_unix_timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This same idiosyncratic timestamp format ("microseconds since Windows NT epoch") is apparently used by Chromium-based browsers for both cookies’ expiration timestamps, as well as timestamps associated with saved login credentials. --- __init__.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/__init__.py b/__init__.py index a9ee349..a200b23 100644 --- a/__init__.py +++ b/__init__.py @@ -318,8 +318,6 @@ def __get_kdewallet_password_jeepney(self, folder, key): class ChromiumBased: """Super class for all Chromium based browsers""" - UNIX_TO_NT_EPOCH_OFFSET = 11644473600 # seconds from 1601-01-01T00:00:00Z to 1970-01-01T00:00:00Z - def __init__(self, browser:str, cookie_file=None, domain_name="", key_file=None, **kwargs): self.salt = b'saltysalt' self.iv = b' ' * 16 @@ -382,6 +380,17 @@ def __add_key_and_cookie_file(self, def __str__(self): return self.browser + @staticmethod + def _nt_to_unix_timestamp(value): + # Per https://github.com/chromium/chromium/blob/main/base/time/time.h#L5-L7, + # Chromium-based browsers store cookies' expiration timestamps as MICROSECONDS elapsed + # since the Windows NT epoch (1601-01-01 0:00:00 GMT), or 0 for session cookies. + # + UNIX_TO_NT_EPOCH_OFFSET = 11644473600 # seconds from 1601-01-01T00:00:00Z to 1970-01-01T00:00:00Z + if value in (0, None): + return None + return (value / 1000000) - UNIX_TO_NT_EPOCH_OFFSET + def load(self): """Load sqlite cookies into a cookiejar""" con = _sqlite3_connect_readonly(self.cookie_file) @@ -399,17 +408,11 @@ def load(self): cj = http.cookiejar.CookieJar() for item in cur.fetchall(): - # Per https://github.com/chromium/chromium/blob/main/base/time/time.h#L5-L7, - # Chromium-based browsers store cookies' expiration timestamps as MICROSECONDS elapsed - # since the Windows NT epoch (1601-01-01 0:00:00 GMT), or 0 for session cookies. - # + host, path, secure, expires_nt_time_epoch, name, value, enc_value, http_only = item + # http.cookiejar stores cookies' expiration timestamps as SECONDS since the Unix epoch # (1970-01-01 0:00:00 GMT, or None for session cookies. - host, path, secure, expires_nt_time_epoch, name, value, enc_value, http_only = item - if (expires_nt_time_epoch == 0): - expires = None - else: - expires = (expires_nt_time_epoch / 1000000) - self.UNIX_TO_NT_EPOCH_OFFSET + expires = self._nt_to_unix_timestamp(expires_nt_time_epoch) value = self._decrypt(value, enc_value) c = create_cookie(host, path, secure, expires, name, value, http_only) From 4e33bdd38b14e6a41f82b5572b75a43a3d8606b7 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Sat, 15 Apr 2023 17:09:56 -0700 Subject: [PATCH 2/6] [WIP] Add method to load login credentials as well as cookies The machinery for decrypting login credentials on Chromium-based browsers is essentially identical to what's used to decrypt cookies. For a fairly hacky standalone implementation of login credential decryption, see https://github.com/priyankchheda/chrome_password_grabber. Done: - Add ChromiumBased.load_logins() method. - Autodetect paths for login credentials on Chrome and Chromium browsers on Linux. (Tested both) TODO: - Determine correct paths for login credentials on Windows, macOS, and other Chromium-based browsers. For now, you should be able to test whether the same decryption process works by finding the login credentials database path, and passing passing `login_file='THAT_PATH'` to the constructor for any `ChromiumBased` browser. - Add loading and decryption of login credentials from Firefox as well. Perhaps based on https://github.com/unode/firefox_decrypt? --- __init__.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/__init__.py b/__init__.py index a200b23..0c5a36a 100644 --- a/__init__.py +++ b/__init__.py @@ -10,9 +10,11 @@ import struct import subprocess import sys +from collections import namedtuple from io import BytesIO from pathlib import Path from typing import Union +from urllib.parse import urlsplit import sqlite3 @@ -318,25 +320,28 @@ def __get_kdewallet_password_jeepney(self, folder, key): class ChromiumBased: """Super class for all Chromium based browsers""" - def __init__(self, browser:str, cookie_file=None, domain_name="", key_file=None, **kwargs): + def __init__(self, browser:str, cookie_file=None, domain_name="", key_file=None, login_file=None, **kwargs): self.salt = b'saltysalt' self.iv = b' ' * 16 self.length = 16 self.browser = browser self.cookie_file = cookie_file + self.login_file = login_file self.domain_name = domain_name self.key_file = key_file self.__add_key_and_cookie_file(**kwargs) def __add_key_and_cookie_file(self, linux_cookies=None, windows_cookies=None, osx_cookies=None, - windows_keys=None, os_crypt_name=None, osx_key_service=None, osx_key_user=None): + windows_keys=None, os_crypt_name=None, osx_key_service=None, osx_key_user=None, + linux_logins=[]): if sys.platform == 'darwin': password = _get_osx_keychain_password(osx_key_service, osx_key_user) iterations = 1003 # number of pbkdf2 iterations on mac self.v10_key = PBKDF2(password, self.salt, self.length, iterations) cookie_file = self.cookie_file or _expand_paths(osx_cookies,'osx') + login_file = None # TODO elif sys.platform.startswith('linux') or 'bsd' in sys.platform.lower(): password = _LinuxPasswordManager(USE_DBUS_LINUX).get_password(os_crypt_name) @@ -345,6 +350,7 @@ def __add_key_and_cookie_file(self, self.v11_key = PBKDF2(password, self.salt, self.length, iterations) cookie_file = self.cookie_file or _expand_paths(linux_cookies, 'linux') + login_file = self.login_file or _expand_paths(linux_logins,'linux') elif sys.platform == "win32": key_file = self.key_file or _expand_paths(windows_keys,'windows') @@ -368,6 +374,8 @@ def __add_key_and_cookie_file(self, else: cookie_file = _expand_paths(windows_cookies,'windows') + login_file = None # TODO + else: raise BrowserCookieError( "OS not recognized. Works on OSX, Windows, and Linux.") @@ -376,6 +384,7 @@ def __add_key_and_cookie_file(self, raise BrowserCookieError('Failed to find {} cookie'.format(self.browser)) self.cookie_file = cookie_file + self.login_file = login_file def __str__(self): return self.browser @@ -420,6 +429,37 @@ def load(self): con.close() return cj + def load_logins(self): + """Load saved login credentials into a dictionary""" + if not self.login_file: + raise BrowserCookieError('{} saved logins database file not found, or not yet implemented'.format(self.browser)) + + con = _sqlite3_connect_readonly(self.login_file) + con.text_factory = _text_factory + cur = con.cursor() + cur.execute('SELECT signon_realm, username_value, password_value, date_last_used, date_created, date_password_modified, times_used ' + 'FROM logins WHERE signon_realm like ? ORDER BY signon_realm, username_value;', ('%{}%'.format(self.domain_name),)) + + logins = [] + for item in cur: + signon_realm, username, password_enc, accessed_nt, created_nt, modified_nt, times_used = item + password=self._decrypt(None, password_enc) + if not username and not password: + continue # No value in exporting this + + p = urlsplit(signon_realm) + logins.append(LoginCredential( + host=p.netloc, path=p.path if p.path not in ('', None, '/') else None, + username=username, password=password, + accessed=self._nt_to_unix_timestamp(accessed_nt), + created=self._nt_to_unix_timestamp(created_nt), + modified=self._nt_to_unix_timestamp(modified_nt), + secure=(p.scheme == 'https'), + times_used=times_used, + )) + con.close() + return logins + @staticmethod def _decrypt_windows_chromium(value, encrypted_value): @@ -483,7 +523,7 @@ def _decrypt(self, value, encrypted_value): class Chrome(ChromiumBased): """Class for Google Chrome""" - def __init__(self, cookie_file=None, domain_name="", key_file=None): + def __init__(self, cookie_file=None, domain_name="", key_file=None, login_file=None): args = { 'linux_cookies': _genarate_nix_paths_chromium( [ @@ -492,6 +532,15 @@ def __init__(self, cookie_file=None, domain_name="", key_file=None): ], channel=['', '-beta', '-unstable'] ), + 'linux_logins': _genarate_nix_paths_chromium( + [ + '~/.config/google-chrome{channel}/Default/Login Data For Account', + '~/.config/google-chrome{channel}/Profile */Login Data For Account', + '~/.config/google-chrome{channel}/Default/Login Data', + '~/.config/google-chrome{channel}/Profile */Login Data' + ], + channel=['', '-beta', '-unstable'] + ), 'windows_cookies': _genarate_win_paths_chromium( [ 'Google\\Chrome{channel}\\User Data\\Default\\Cookies', @@ -516,17 +565,21 @@ def __init__(self, cookie_file=None, domain_name="", key_file=None): 'osx_key_service' : 'Chrome Safe Storage', 'osx_key_user' : 'Chrome' } - super().__init__(browser='Chrome', cookie_file=cookie_file, domain_name=domain_name, key_file=key_file, **args) + super().__init__(browser='Chrome', cookie_file=cookie_file, domain_name=domain_name, key_file=key_file, login_file=login_file, **args) class Chromium(ChromiumBased): """Class for Chromium""" - def __init__(self, cookie_file=None, domain_name="", key_file=None): + def __init__(self, cookie_file=None, domain_name="", key_file=None, login_file=None): args = { 'linux_cookies':[ '~/.config/chromium/Default/Cookies', '~/.config/chromium/Profile */Cookies' ], + 'linux_logins':[ + '~/.config/chromium/Default/Login Data', + '~/.config/chromium/Profile */Login Data' + ], 'windows_cookies': _genarate_win_paths_chromium( [ 'Chromium\\User Data\\Default\\Cookies', @@ -546,7 +599,7 @@ def __init__(self, cookie_file=None, domain_name="", key_file=None): 'osx_key_service' : 'Chromium Safe Storage', 'osx_key_user' : 'Chromium' } - super().__init__(browser='Chromium', cookie_file=cookie_file, domain_name=domain_name, key_file=key_file, **args) + super().__init__(browser='Chromium', cookie_file=cookie_file, domain_name=domain_name, key_file=key_file, login_file=login_file, **args) class Opera(ChromiumBased): @@ -714,7 +767,7 @@ def __init__(self, cookie_file=None, domain_name="", key_file=None): class Firefox: """Class for Firefox""" - def __init__(self, cookie_file=None, domain_name=""): + def __init__(self, cookie_file=None, domain_name="", key_file=None, login_file=None): self.cookie_file = cookie_file or self.find_cookie_file() # current sessions are saved in sessionstore.js self.session_file = os.path.join( @@ -845,6 +898,9 @@ def load(self): return cj + def load_logins(self): + raise NotImplementedError('Loading login credentials is not yet supported for Firefox') + class Safari: """Class for Safari""" @@ -961,6 +1017,9 @@ def load(self): return cj +LoginCredential = namedtuple('LoginCredential', 'host path username password accessed created modified secure times_used') + + def create_cookie(host, path, secure, expires, name, value, http_only): """Shortcut function to create a cookie""" # HTTPOnly flag goes in _rest, if present (see https://github.com/python/cpython/pull/17471/files#r511187060) @@ -1028,19 +1087,36 @@ def safari(cookie_file=None, domain_name=""): """ return Safari(cookie_file, domain_name).load() + +all_browsers = [Chrome, Chromium, Opera, OperaGX, Brave, Edge, Vivaldi, Firefox, Safari] + + def load(domain_name=""): """Try to load cookies from all supported browsers and return combined cookiejar Optionally pass in a domain name to only load cookies from the specified domain """ cj = http.cookiejar.CookieJar() - for cookie_fn in [chrome, chromium, opera, opera_gx, brave, edge, vivaldi, firefox, safari]: + for browser in all_browsers: try: - for cookie in cookie_fn(domain_name=domain_name): + for cookie in browser(domain_name=domain_name).load(): cj.set_cookie(cookie) except BrowserCookieError: pass return cj +def load_logins(domain_name=""): + """Try to load login credentials from all supported browsers and return combined list + Optionally pass in a domain name to only load login credentials from the specified domain + """ + logins = [] + for browser in all_browsers: + try: + logins.extend(browser(domain_name=domain_name).load_logins()) + except (BrowserCookieError, NotImplementedError): + pass + return logins + + if __name__ == '__main__': print(load()) From 712e32ce076f07b3fb45bb238fceebd90844e513 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Sat, 15 Apr 2023 17:09:56 -0700 Subject: [PATCH 3/6] [WIP] Add method to load login credentials as well as cookies The machinery for decrypting login credentials on Chromium-based browsers is essentially identical to what's used to decrypt cookies. For a fairly hacky standalone implementation of login credential decryption, see https://github.com/priyankchheda/chrome_password_grabber. Done: - Add ChromiumBased.load_logins() method. - Autodetect paths for login credentials on Chrome and Chromium browsers on Linux. (Tested both) TODO: - Determine correct paths for login credentials on Windows, macOS, and other Chromium-based browsers. For now, you should be able to test whether the same decryption process works by finding the login credentials database path, and passing passing `login_file='THAT_PATH'` to the constructor for any `ChromiumBased` browser. - Add loading and decryption of login credentials from Firefox as well. Perhaps based on https://github.com/unode/firefox_decrypt? --- __init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index 0c5a36a..896a46e 100644 --- a/__init__.py +++ b/__init__.py @@ -520,7 +520,6 @@ def _decrypt(self, value, encrypted_value): raise BrowserCookieError('Unable to get key for cookie decryption') return decrypted.decode('utf-8') - class Chrome(ChromiumBased): """Class for Google Chrome""" def __init__(self, cookie_file=None, domain_name="", key_file=None, login_file=None): @@ -1020,6 +1019,9 @@ def load(self): LoginCredential = namedtuple('LoginCredential', 'host path username password accessed created modified secure times_used') +LoginCredential = namedtuple('LoginCredential', 'host path username password accessed created modified secure times_used') + + def create_cookie(host, path, secure, expires, name, value, http_only): """Shortcut function to create a cookie""" # HTTPOnly flag goes in _rest, if present (see https://github.com/python/cpython/pull/17471/files#r511187060) From 80dabfb4535baafdfe29d23a3a17dd1042e34a07 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Wed, 5 Jan 2022 11:42:16 -0800 Subject: [PATCH 4/6] Add browser-cookie CLI Also: - Put source package in a directory called browser_cookie3, so that __main__.py can 'from . import *' even while testing - Clean up/clarify BrowserCookieError() messages a bit - Make Firefox class and firefox() function take a key_file parameter, but ignore it, for a consistent interface with Chromium-based browsers - Cleanup all trailing whitespace (with https://github.com/dlenski/wtf) --- README.md | 21 +++++++- __init__.py => browser_cookie3/__init__.py | 28 +++++++--- browser_cookie3/__main__.py | 62 ++++++++++++++++++++++ setup.py | 3 +- 4 files changed, 104 insertions(+), 10 deletions(-) rename __init__.py => browser_cookie3/__init__.py (97%) create mode 100644 browser_cookie3/__main__.py diff --git a/README.md b/README.md index a46afcd..66ed27a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This is a python3 fork of [Richard Penman's Browser Cookie](https://github.com/r pip install browser-cookie3 ``` -## Usage ## +## Python usage ## Here is a *dangerous* hack to extract the title from a webpage: ```python @@ -86,6 +86,25 @@ Alternatively if you are only interested in cookies from a specific domain, you >>> get_title(r.content) 'richardpenman / home — Bitbucket' ``` + +## Command-line usage + +Run `browser-cookie --help` for all options. Brief examples: + +```sh +$ browser-cookie --firefox stackoverflow.com acct +t=BASE64_STRING_DESCRIBING_YOUR_STACKOVERFLOW_ACCOUNT + +$ browser-cookie --json --chrome stackoverflow.com acct +{"version": 0, "name": "acct", "value": "t=BASE64_STRING_DESCRIBING_YOUR_STACKOVERFLOW_ACCOUNT", +"port_specified": false, "domain": ".stackoverflow.com", "domain_specified": true, +"domain_initial_dot": true, "path": "/", "path_specified": true, "secure": 1, +"expires": 1657049738, "discard": false, "rfc2109": false} + +$ browser-cookie nonexistent-domain.com nonexistent-cookie && echo "Cookie found" || echo "No cookie found" +No cookie found +``` + ## Fresh cookie files Creating and testing a fresh cookie file can help eliminate some possible user specific issues. It also allows you to upload a cookie file you are having issues with, since you should never upload your main cookie file! ### Chrome and chromium diff --git a/__init__.py b/browser_cookie3/__init__.py similarity index 97% rename from __init__.py rename to browser_cookie3/__init__.py index 896a46e..3912607 100644 --- a/__init__.py +++ b/browser_cookie3/__init__.py @@ -410,9 +410,14 @@ def load(self): cur.execute('SELECT host_key, path, secure, expires_utc, name, value, encrypted_value, is_httponly ' 'FROM cookies WHERE host_key like ?;', ('%{}%'.format(self.domain_name),)) except sqlite3.OperationalError: - # chrome >=56 - cur.execute('SELECT host_key, path, is_secure, expires_utc, name, value, encrypted_value, is_httponly ' - 'FROM cookies WHERE host_key like ?;', ('%{}%'.format(self.domain_name),)) + try: + # chrome >=56 + cur.execute('SELECT host_key, path, is_secure, expires_utc, name, value, encrypted_value, is_httponly ' + 'FROM cookies WHERE host_key like ?;', ('%{}%'.format(self.domain_name),)) + except sqlite3.OperationalError as e: + if e.args[0].startswith(('no such table: ', 'file is not a database')): + raise BrowserCookieError('File {} is not a Chromium-based browser cookie file'.format(self.cookie_file)) + raise cj = http.cookiejar.CookieJar() @@ -882,8 +887,13 @@ def __add_session_cookies_lz4(self, cj): def load(self): con = _sqlite3_connect_readonly(self.cookie_file) cur = con.cursor() - cur.execute('select host, path, isSecure, expiry, name, value, isHttpOnly from moz_cookies ' - 'where host like ?', ('%{}%'.format(self.domain_name),)) + try: + cur.execute('select host, path, isSecure, expiry, name, value, isHttpOnly from moz_cookies ' + 'where host like ?', ('%{}%'.format(self.domain_name),)) + except sqlite3.DatabaseError as e: + if e.args[0].startswith(('no such table: ', 'file is not a database')): + raise BrowserCookieError('File {} is not a Firefox cookie file'.format(self.cookie_file)) + raise cj = http.cookiejar.CookieJar() for item in cur.fetchall(): @@ -1077,11 +1087,12 @@ def vivaldi(cookie_file=None, domain_name="", key_file=None): return Vivaldi(cookie_file, domain_name, key_file).load() -def firefox(cookie_file=None, domain_name=""): +def firefox(cookie_file=None, domain_name="", key_file=None): """Returns a cookiejar of the cookies and sessions used by Firefox. Optionally pass in a domain name to only load cookies from the specified domain """ - return Firefox(cookie_file, domain_name).load() + return Firefox(cookie_file, domain_name, key_file).load() + def safari(cookie_file=None, domain_name=""): """Returns a cookiejar of the cookies and sessions used by Safari. Optionally @@ -1107,6 +1118,9 @@ def load(domain_name=""): return cj +__all__ = ['BrowserCookieError', 'load', 'all_browsers'] + all_browsers + + def load_logins(domain_name=""): """Try to load login credentials from all supported browsers and return combined list Optionally pass in a domain name to only load login credentials from the specified domain diff --git a/browser_cookie3/__main__.py b/browser_cookie3/__main__.py new file mode 100644 index 0000000..40c1fe6 --- /dev/null +++ b/browser_cookie3/__main__.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- + +import argparse +import browser_cookie3 +import json + + +def parse_args(args=None): + p = argparse.ArgumentParser( + description='Extract browser cookies using browser_cookie3.', + epilog='Exit status is 0 if cookie was found, 1 if not found, and 2 if errors occurred', + ) + p.add_argument('-j', '--json', action='store_true', + help="Output JSON with all cookie details, rather than just the cookie's value") + p.add_argument('domain') + p.add_argument('name') + + g = p.add_argument_group('Browser selection') + x = g.add_mutually_exclusive_group() + x.add_argument('-a', '--all', dest='browser', action='store_const', const=None, default=None, + help="Try to load cookies from all supported browsers") + for browser in browser_cookie3.all_browsers: + x.add_argument('--' + browser.__name__, dest='browser', action='store_const', const=browser, + help="Load cookies from {} browser".format(browser.__name__.title())) + g.add_argument('-f', '--cookie-file', + help="Use specific cookie file (default is to autodetect).") + g.add_argument('-k', '--key-file', + help="Use specific key file (default is to autodetect).") + + args = p.parse_args(args) + + if not args.browser and (args.cookie_file or args.key_file): + p.error("Must specify a specific browser with --cookie-file or --key-file arguments") + + return p, args + + +def main(args=None): + p, args = parse_args(args) + + try: + if args.browser: + cj = args.browser(cookie_file=args.cookie_file, key_file=args.key_file) + else: + cj = browser_cookie3.load() + except browser_cookie3.BrowserCookieError as e: + p.error(e.args[0]) + + for cookie in cj: + if cookie.domain in (args.domain, '.' + args.domain) and cookie.name == args.name: + if not args.json: + print(cookie.value) + else: + print(json.dumps({k: v for k, v in vars(cookie).items() + if v is not None and (k, v) != ('_rest', {})})) + break + else: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py index dd0b7b8..66935e0 100644 --- a/setup.py +++ b/setup.py @@ -4,12 +4,11 @@ name='browser-cookie3', version='0.17.1', packages=['browser_cookie3'], - # look for package contents in current directory - package_dir={'browser_cookie3': '.'}, author='Boris Babic', author_email='boris.ivan.babic@gmail.com', description='Loads cookies from your browser into a cookiejar object so can download with urllib and other libraries the same content you see in the web browser.', url='https://github.com/borisbabic/browser_cookie3', + entry_points={'console_scripts': ['browser-cookie=browser_cookie3.__main__:main']}, install_requires=[ 'lz4', 'pycryptodomex', From 3ef12e3ce812f8fce44759e6da50149a5cad8d6b Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Sat, 13 May 2023 09:20:48 -0700 Subject: [PATCH 5/6] Load Firefox logins.json file --- browser_cookie3/__init__.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/browser_cookie3/__init__.py b/browser_cookie3/__init__.py index 3912607..1a73c58 100644 --- a/browser_cookie3/__init__.py +++ b/browser_cookie3/__init__.py @@ -772,7 +772,8 @@ def __init__(self, cookie_file=None, domain_name="", key_file=None): class Firefox: """Class for Firefox""" def __init__(self, cookie_file=None, domain_name="", key_file=None, login_file=None): - self.cookie_file = cookie_file or self.find_cookie_file() + self.cookie_file = cookie_file or self.find_file('cookies.sqlite') + self.login_file = login_file or self.find_file('logins.json') # current sessions are saved in sessionstore.js self.session_file = os.path.join( os.path.dirname(self.cookie_file), 'sessionstore.js') @@ -815,17 +816,17 @@ def get_default_profile(user_data_path): return fallback_path @staticmethod - def find_cookie_file(): - cookie_files = [] + def find_file(basename: str): + found_files = [] if sys.platform == 'darwin': user_data_path = os.path.expanduser( '~/Library/Application Support/Firefox') elif sys.platform.startswith('linux') or 'bsd' in sys.platform.lower(): - # Looking for cookies from a Snap based Firefox first, as some + # Looking for files from a Snap based Firefox first, as some # users might have profiles at both this and the other location, # as they were migrated to Snap by their OS at some point, leaving - # cookies at the other location outdated. + # files at the other location outdated. general_path = os.path.expanduser('~/snap/firefox/common/.mozilla/firefox') if os.path.isdir(general_path): user_data_path = general_path @@ -835,19 +836,19 @@ def find_cookie_file(): user_data_path = os.path.join( os.environ.get('APPDATA'), 'Mozilla', 'Firefox') # legacy firefox <68 fallback - cookie_files = glob.glob(os.path.join(os.environ.get('PROGRAMFILES'), 'Mozilla Firefox', 'profile', 'cookies.sqlite')) \ - or glob.glob(os.path.join(os.environ.get('PROGRAMFILES(X86)'), 'Mozilla Firefox', 'profile', 'cookies.sqlite')) + found_files = glob.glob(os.path.join(os.environ.get('PROGRAMFILES'), 'Mozilla Firefox', 'profile', basename)) \ + or glob.glob(os.path.join(os.environ.get('PROGRAMFILES(X86)'), 'Mozilla Firefox', 'profile', basename)) else: raise BrowserCookieError( 'Unsupported operating system: ' + sys.platform) - cookie_files = glob.glob(os.path.join(Firefox.get_default_profile(user_data_path), 'cookies.sqlite')) \ - or cookie_files + found_files = glob.glob(os.path.join(Firefox.get_default_profile(user_data_path), basename)) \ + or found_files - if cookie_files: - return cookie_files[0] + if found_files: + return found_files[0] else: - raise BrowserCookieError('Failed to find Firefox cookie file') + raise BrowserCookieError(f'Failed to find Firefox files matching {basename!r}') @staticmethod def __create_session_cookie(cookie_json): From 82d9ffaf741647ce3fedf322f8373ac37382ee95 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Sat, 13 May 2023 09:21:49 -0700 Subject: [PATCH 6/6] [WIP needs decryption] Firefox load logins This pure-Python decryption looks like the best approach: https://github.com/lclevy/firepwd/blob/master/firepwd.py --- browser_cookie3/__init__.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/browser_cookie3/__init__.py b/browser_cookie3/__init__.py index 1a73c58..a3c7339 100644 --- a/browser_cookie3/__init__.py +++ b/browser_cookie3/__init__.py @@ -909,7 +909,33 @@ def load(self): return cj def load_logins(self): - raise NotImplementedError('Loading login credentials is not yet supported for Firefox') + """Load saved login credentials into a dictionary""" + if not self.login_file: + raise BrowserCookieError(f'{self.browser} saved logins file not found, or not yet implemented') + + with open(self.login_file) as lf: + lj = json.load(lf) + version = lj.get('version', 'unknown') + if version != 3: + raise BrowserCookieError(f'{self.browser} logins file has version {version!r}, rather than expected 3') + + logins = [] + for item in lj['logins']: + p = urlsplit(item['hostname']) + username_enc = item['encryptedUsername'] + password_enc = item['encryptedPassword'] + # FIXME: Need to decrypt in the manner of https://github.com/lclevy/firepwd/blob/master/firepwd.py + username, password = username_enc, password_enc + logins.append(LoginCredential( + host=p.netloc, path=p.path if p.path not in ('', None, '/') else None, + username=username, password=password, + accessed=item['timeLastUsed'] / 1000, + created=item['timeCreated'] / 1000, + modified=item['timePasswordChanged'] / 1000, + times_used=item['timeUsed'], + secure=(p.scheme == 'https'), + )) + return logins class Safari: