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 84% rename from __init__.py rename to browser_cookie3/__init__.py index a9ee349..a3c7339 100644 --- a/__init__.py +++ b/browser_cookie3/__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,27 +320,28 @@ 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): + 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) @@ -347,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') @@ -370,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.") @@ -378,10 +384,22 @@ 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 + @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) @@ -392,24 +410,23 @@ 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() 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) @@ -417,6 +434,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): @@ -477,10 +525,9 @@ 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): + def __init__(self, cookie_file=None, domain_name="", key_file=None, login_file=None): args = { 'linux_cookies': _genarate_nix_paths_chromium( [ @@ -489,6 +536,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', @@ -513,17 +569,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', @@ -543,7 +603,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): @@ -711,8 +771,9 @@ def __init__(self, cookie_file=None, domain_name="", key_file=None): class Firefox: """Class for Firefox""" - def __init__(self, cookie_file=None, domain_name=""): - self.cookie_file = cookie_file or self.find_cookie_file() + def __init__(self, cookie_file=None, domain_name="", key_file=None, login_file=None): + 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') @@ -755,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 @@ -775,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): @@ -827,8 +888,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(): @@ -842,6 +908,35 @@ def load(self): return cj + def load_logins(self): + """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: """Class for Safari""" @@ -958,6 +1053,12 @@ def load(self): return cj +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) @@ -1013,11 +1114,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 @@ -1025,19 +1127,39 @@ 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 +__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 + """ + 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()) 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',