]> jfr.im git - yt-dlp.git/blame - yt_dlp/cookies.py
[cookies] Handle `sqlite` `ImportError` gracefully (#554)
[yt-dlp.git] / yt_dlp / cookies.py
CommitLineData
982ee69a
MB
1import ctypes
2import json
3import os
4import shutil
982ee69a
MB
5import struct
6import subprocess
7import sys
8import tempfile
9from datetime import datetime, timedelta, timezone
10from hashlib import pbkdf2_hmac
11
12from yt_dlp.aes import aes_cbc_decrypt
13from yt_dlp.compat import (
14 compat_b64decode,
15 compat_cookiejar_Cookie,
16)
17from yt_dlp.utils import (
18 bytes_to_intlist,
19 expand_path,
20 intlist_to_bytes,
21 process_communicate_or_kill,
22 YoutubeDLCookieJar,
23)
24
767b02a9
MB
25try:
26 import sqlite3
27 SQLITE_AVAILABLE = True
28except ImportError:
29 # although sqlite3 is part of the standard library, it is possible to compile python without
30 # sqlite support. See: https://github.com/yt-dlp/yt-dlp/issues/544
31 SQLITE_AVAILABLE = False
32
33
982ee69a
MB
34try:
35 from Crypto.Cipher import AES
36 CRYPTO_AVAILABLE = True
37except ImportError:
38 CRYPTO_AVAILABLE = False
39
40try:
41 import keyring
42 KEYRING_AVAILABLE = True
43except ImportError:
44 KEYRING_AVAILABLE = False
45
46
47CHROMIUM_BASED_BROWSERS = {'brave', 'chrome', 'chromium', 'edge', 'opera', 'vivaldi'}
48SUPPORTED_BROWSERS = CHROMIUM_BASED_BROWSERS | {'firefox', 'safari'}
49
50
51class YDLLogger:
52 def __init__(self, ydl=None):
53 self._ydl = ydl
54
55 def debug(self, message):
56 if self._ydl:
57 self._ydl.write_debug(message)
58
59 def info(self, message):
60 if self._ydl:
61 self._ydl.to_screen(f'[Cookies] {message}')
62
63 def warning(self, message, only_once=False):
64 if self._ydl:
65 self._ydl.report_warning(message, only_once)
66
67 def error(self, message):
68 if self._ydl:
69 self._ydl.report_error(message)
70
71
72def load_cookies(cookie_file, browser_specification, ydl):
73 cookie_jars = []
74 if browser_specification is not None:
75 browser_name, profile = _parse_browser_specification(*browser_specification)
76 cookie_jars.append(extract_cookies_from_browser(browser_name, profile, YDLLogger(ydl)))
77
78 if cookie_file is not None:
79 cookie_file = expand_path(cookie_file)
80 jar = YoutubeDLCookieJar(cookie_file)
81 if os.access(cookie_file, os.R_OK):
82 jar.load(ignore_discard=True, ignore_expires=True)
83 cookie_jars.append(jar)
84
85 return _merge_cookie_jars(cookie_jars)
86
87
88def extract_cookies_from_browser(browser_name, profile=None, logger=YDLLogger()):
89 if browser_name == 'firefox':
90 return _extract_firefox_cookies(profile, logger)
91 elif browser_name == 'safari':
92 return _extract_safari_cookies(profile, logger)
93 elif browser_name in CHROMIUM_BASED_BROWSERS:
94 return _extract_chrome_cookies(browser_name, profile, logger)
95 else:
96 raise ValueError('unknown browser: {}'.format(browser_name))
97
98
99def _extract_firefox_cookies(profile, logger):
100 logger.info('Extracting cookies from firefox')
767b02a9
MB
101 if not SQLITE_AVAILABLE:
102 logger.warning('Cannot extract cookies from firefox without sqlite3 support. '
103 'Please use a python interpreter compiled with sqlite3 support')
104 return YoutubeDLCookieJar()
982ee69a
MB
105
106 if profile is None:
107 search_root = _firefox_browser_dir()
108 elif _is_path(profile):
109 search_root = profile
110 else:
111 search_root = os.path.join(_firefox_browser_dir(), profile)
112
113 cookie_database_path = _find_most_recently_used_file(search_root, 'cookies.sqlite')
114 if cookie_database_path is None:
115 raise FileNotFoundError('could not find firefox cookies database in {}'.format(search_root))
116 logger.debug('extracting from: "{}"'.format(cookie_database_path))
117
118 with tempfile.TemporaryDirectory(prefix='youtube_dl') as tmpdir:
119 cursor = None
120 try:
121 cursor = _open_database_copy(cookie_database_path, tmpdir)
122 cursor.execute('SELECT host, name, value, path, expiry, isSecure FROM moz_cookies')
123 jar = YoutubeDLCookieJar()
124 for host, name, value, path, expiry, is_secure in cursor.fetchall():
125 cookie = compat_cookiejar_Cookie(
126 version=0, name=name, value=value, port=None, port_specified=False,
127 domain=host, domain_specified=bool(host), domain_initial_dot=host.startswith('.'),
128 path=path, path_specified=bool(path), secure=is_secure, expires=expiry, discard=False,
129 comment=None, comment_url=None, rest={})
130 jar.set_cookie(cookie)
131 logger.info('Extracted {} cookies from firefox'.format(len(jar)))
132 return jar
133 finally:
134 if cursor is not None:
135 cursor.connection.close()
136
137
138def _firefox_browser_dir():
139 if sys.platform in ('linux', 'linux2'):
140 return os.path.expanduser('~/.mozilla/firefox')
141 elif sys.platform == 'win32':
142 return os.path.expandvars(r'%APPDATA%\Mozilla\Firefox\Profiles')
143 elif sys.platform == 'darwin':
144 return os.path.expanduser('~/Library/Application Support/Firefox')
145 else:
146 raise ValueError('unsupported platform: {}'.format(sys.platform))
147
148
149def _get_chromium_based_browser_settings(browser_name):
150 # https://chromium.googlesource.com/chromium/src/+/HEAD/docs/user_data_dir.md
151 if sys.platform in ('linux', 'linux2'):
152 config = _config_home()
153 browser_dir = {
154 'brave': os.path.join(config, 'BraveSoftware/Brave-Browser'),
155 'chrome': os.path.join(config, 'google-chrome'),
156 'chromium': os.path.join(config, 'chromium'),
157 'edge': os.path.join(config, 'microsoft-edge'),
158 'opera': os.path.join(config, 'opera'),
159 'vivaldi': os.path.join(config, 'vivaldi'),
160 }[browser_name]
161
162 elif sys.platform == 'win32':
163 appdata_local = os.path.expandvars('%LOCALAPPDATA%')
164 appdata_roaming = os.path.expandvars('%APPDATA%')
165 browser_dir = {
166 'brave': os.path.join(appdata_local, r'BraveSoftware\Brave-Browser\User Data'),
167 'chrome': os.path.join(appdata_local, r'Google\Chrome\User Data'),
168 'chromium': os.path.join(appdata_local, r'Chromium\User Data'),
169 'edge': os.path.join(appdata_local, r'Microsoft\Edge\User Data'),
170 'opera': os.path.join(appdata_roaming, r'Opera Software\Opera Stable'),
171 'vivaldi': os.path.join(appdata_local, r'Vivaldi\User Data'),
172 }[browser_name]
173
174 elif sys.platform == 'darwin':
175 appdata = os.path.expanduser('~/Library/Application Support')
176 browser_dir = {
177 'brave': os.path.join(appdata, 'BraveSoftware/Brave-Browser'),
178 'chrome': os.path.join(appdata, 'Google/Chrome'),
179 'chromium': os.path.join(appdata, 'Chromium'),
180 'edge': os.path.join(appdata, 'Microsoft Edge'),
181 'opera': os.path.join(appdata, 'com.operasoftware.Opera'),
182 'vivaldi': os.path.join(appdata, 'Vivaldi'),
183 }[browser_name]
184
185 else:
186 raise ValueError('unsupported platform: {}'.format(sys.platform))
187
188 # Linux keyring names can be determined by snooping on dbus while opening the browser in KDE:
189 # dbus-monitor "interface='org.kde.KWallet'" "type=method_return"
190 keyring_name = {
191 'brave': 'Brave',
192 'chrome': 'Chrome',
193 'chromium': 'Chromium',
29b208f6 194 'edge': 'Microsoft Edge' if sys.platform == 'darwin' else 'Chromium',
982ee69a
MB
195 'opera': 'Opera' if sys.platform == 'darwin' else 'Chromium',
196 'vivaldi': 'Vivaldi' if sys.platform == 'darwin' else 'Chrome',
197 }[browser_name]
198
199 browsers_without_profiles = {'opera'}
200
201 return {
202 'browser_dir': browser_dir,
203 'keyring_name': keyring_name,
204 'supports_profiles': browser_name not in browsers_without_profiles
205 }
206
207
208def _extract_chrome_cookies(browser_name, profile, logger):
209 logger.info('Extracting cookies from {}'.format(browser_name))
767b02a9
MB
210
211 if not SQLITE_AVAILABLE:
212 logger.warning(('Cannot extract cookies from {} without sqlite3 support. '
213 'Please use a python interpreter compiled with sqlite3 support').format(browser_name))
214 return YoutubeDLCookieJar()
215
982ee69a
MB
216 config = _get_chromium_based_browser_settings(browser_name)
217
218 if profile is None:
219 search_root = config['browser_dir']
220 elif _is_path(profile):
221 search_root = profile
222 config['browser_dir'] = os.path.dirname(profile) if config['supports_profiles'] else profile
223 else:
224 if config['supports_profiles']:
225 search_root = os.path.join(config['browser_dir'], profile)
226 else:
227 logger.error('{} does not support profiles'.format(browser_name))
228 search_root = config['browser_dir']
229
230 cookie_database_path = _find_most_recently_used_file(search_root, 'Cookies')
231 if cookie_database_path is None:
232 raise FileNotFoundError('could not find {} cookies database in "{}"'.format(browser_name, search_root))
233 logger.debug('extracting from: "{}"'.format(cookie_database_path))
234
235 decryptor = get_cookie_decryptor(config['browser_dir'], config['keyring_name'], logger)
236
237 with tempfile.TemporaryDirectory(prefix='youtube_dl') as tmpdir:
238 cursor = None
239 try:
240 cursor = _open_database_copy(cookie_database_path, tmpdir)
241 cursor.connection.text_factory = bytes
242 column_names = _get_column_names(cursor, 'cookies')
243 secure_column = 'is_secure' if 'is_secure' in column_names else 'secure'
244 cursor.execute('SELECT host_key, name, value, encrypted_value, path, '
245 'expires_utc, {} FROM cookies'.format(secure_column))
246 jar = YoutubeDLCookieJar()
247 failed_cookies = 0
248 for host_key, name, value, encrypted_value, path, expires_utc, is_secure in cursor.fetchall():
249 host_key = host_key.decode('utf-8')
250 name = name.decode('utf-8')
251 value = value.decode('utf-8')
252 path = path.decode('utf-8')
253
254 if not value and encrypted_value:
255 value = decryptor.decrypt(encrypted_value)
256 if value is None:
257 failed_cookies += 1
258 continue
259
260 cookie = compat_cookiejar_Cookie(
261 version=0, name=name, value=value, port=None, port_specified=False,
262 domain=host_key, domain_specified=bool(host_key), domain_initial_dot=host_key.startswith('.'),
263 path=path, path_specified=bool(path), secure=is_secure, expires=expires_utc, discard=False,
264 comment=None, comment_url=None, rest={})
265 jar.set_cookie(cookie)
266 if failed_cookies > 0:
267 failed_message = ' ({} could not be decrypted)'.format(failed_cookies)
268 else:
269 failed_message = ''
270 logger.info('Extracted {} cookies from {}{}'.format(len(jar), browser_name, failed_message))
271 return jar
272 finally:
273 if cursor is not None:
274 cursor.connection.close()
275
276
277class ChromeCookieDecryptor:
278 """
279 Overview:
280
281 Linux:
282 - cookies are either v10 or v11
283 - v10: AES-CBC encrypted with a fixed key
284 - v11: AES-CBC encrypted with an OS protected key (keyring)
285 - v11 keys can be stored in various places depending on the activate desktop environment [2]
286
287 Mac:
288 - cookies are either v10 or not v10
289 - v10: AES-CBC encrypted with an OS protected key (keyring) and more key derivation iterations than linux
290 - not v10: 'old data' stored as plaintext
291
292 Windows:
293 - cookies are either v10 or not v10
294 - v10: AES-GCM encrypted with a key which is encrypted with DPAPI
295 - not v10: encrypted with DPAPI
296
297 Sources:
298 - [1] https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/os_crypt/
299 - [2] https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/os_crypt/key_storage_linux.cc
300 - KeyStorageLinux::CreateService
301 """
302
303 def decrypt(self, encrypted_value):
304 raise NotImplementedError
305
306
307def get_cookie_decryptor(browser_root, browser_keyring_name, logger):
308 if sys.platform in ('linux', 'linux2'):
309 return LinuxChromeCookieDecryptor(browser_keyring_name, logger)
310 elif sys.platform == 'darwin':
311 return MacChromeCookieDecryptor(browser_keyring_name, logger)
312 elif sys.platform == 'win32':
313 return WindowsChromeCookieDecryptor(browser_root, logger)
314 else:
315 raise NotImplementedError('Chrome cookie decryption is not supported '
316 'on this platform: {}'.format(sys.platform))
317
318
319class LinuxChromeCookieDecryptor(ChromeCookieDecryptor):
320 def __init__(self, browser_keyring_name, logger):
321 self._logger = logger
322 self._v10_key = self.derive_key(b'peanuts')
323 if KEYRING_AVAILABLE:
324 self._v11_key = self.derive_key(_get_linux_keyring_password(browser_keyring_name))
325 else:
326 self._v11_key = None
327
328 @staticmethod
329 def derive_key(password):
330 # values from
331 # https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/os_crypt/os_crypt_linux.cc
332 return pbkdf2_sha1(password, salt=b'saltysalt', iterations=1, key_length=16)
333
334 def decrypt(self, encrypted_value):
335 version = encrypted_value[:3]
336 ciphertext = encrypted_value[3:]
337
338 if version == b'v10':
339 return _decrypt_aes_cbc(ciphertext, self._v10_key, self._logger)
340
341 elif version == b'v11':
342 if self._v11_key is None:
343 self._logger.warning('cannot decrypt cookie as the `keyring` module is not installed. '
344 'Please install by running `python3 -m pip install keyring`. '
345 'Note that depending on your platform, additional packages may be required '
346 'to access the keyring, see https://pypi.org/project/keyring', only_once=True)
347 return None
348 return _decrypt_aes_cbc(ciphertext, self._v11_key, self._logger)
349
350 else:
351 return None
352
353
354class MacChromeCookieDecryptor(ChromeCookieDecryptor):
355 def __init__(self, browser_keyring_name, logger):
356 self._logger = logger
357 password = _get_mac_keyring_password(browser_keyring_name)
358 self._v10_key = None if password is None else self.derive_key(password)
359
360 @staticmethod
361 def derive_key(password):
362 # values from
363 # https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/os_crypt/os_crypt_mac.mm
364 return pbkdf2_sha1(password, salt=b'saltysalt', iterations=1003, key_length=16)
365
366 def decrypt(self, encrypted_value):
367 version = encrypted_value[:3]
368 ciphertext = encrypted_value[3:]
369
370 if version == b'v10':
371 if self._v10_key is None:
372 self._logger.warning('cannot decrypt v10 cookies: no key found', only_once=True)
373 return None
374
375 return _decrypt_aes_cbc(ciphertext, self._v10_key, self._logger)
376
377 else:
378 # other prefixes are considered 'old data' which were stored as plaintext
379 # https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/os_crypt/os_crypt_mac.mm
380 return encrypted_value
381
382
383class WindowsChromeCookieDecryptor(ChromeCookieDecryptor):
384 def __init__(self, browser_root, logger):
385 self._logger = logger
386 self._v10_key = _get_windows_v10_key(browser_root, logger)
387
388 def decrypt(self, encrypted_value):
389 version = encrypted_value[:3]
390 ciphertext = encrypted_value[3:]
391
392 if version == b'v10':
393 if self._v10_key is None:
394 self._logger.warning('cannot decrypt v10 cookies: no key found', only_once=True)
395 return None
396 elif not CRYPTO_AVAILABLE:
397 self._logger.warning('cannot decrypt cookie as the `pycryptodome` module is not installed. '
398 'Please install by running `python3 -m pip install pycryptodome`',
399 only_once=True)
400 return None
401
402 # https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/os_crypt/os_crypt_win.cc
403 # kNonceLength
404 nonce_length = 96 // 8
405 # boringssl
406 # EVP_AEAD_AES_GCM_TAG_LEN
407 authentication_tag_length = 16
408
409 raw_ciphertext = ciphertext
410 nonce = raw_ciphertext[:nonce_length]
411 ciphertext = raw_ciphertext[nonce_length:-authentication_tag_length]
412 authentication_tag = raw_ciphertext[-authentication_tag_length:]
413
414 return _decrypt_aes_gcm(ciphertext, self._v10_key, nonce, authentication_tag, self._logger)
415
416 else:
417 # any other prefix means the data is DPAPI encrypted
418 # https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/os_crypt/os_crypt_win.cc
419 return _decrypt_windows_dpapi(encrypted_value, self._logger).decode('utf-8')
420
421
422def _extract_safari_cookies(profile, logger):
423 if profile is not None:
424 logger.error('safari does not support profiles')
425 if sys.platform != 'darwin':
426 raise ValueError('unsupported platform: {}'.format(sys.platform))
427
428 cookies_path = os.path.expanduser('~/Library/Cookies/Cookies.binarycookies')
429
430 if not os.path.isfile(cookies_path):
431 raise FileNotFoundError('could not find safari cookies database')
432
433 with open(cookies_path, 'rb') as f:
434 cookies_data = f.read()
435
436 jar = parse_safari_cookies(cookies_data, logger=logger)
437 logger.info('Extracted {} cookies from safari'.format(len(jar)))
438 return jar
439
440
441class ParserError(Exception):
442 pass
443
444
445class DataParser:
446 def __init__(self, data, logger):
447 self._data = data
448 self.cursor = 0
449 self._logger = logger
450
451 def read_bytes(self, num_bytes):
452 if num_bytes < 0:
453 raise ParserError('invalid read of {} bytes'.format(num_bytes))
454 end = self.cursor + num_bytes
455 if end > len(self._data):
456 raise ParserError('reached end of input')
457 data = self._data[self.cursor:end]
458 self.cursor = end
459 return data
460
461 def expect_bytes(self, expected_value, message):
462 value = self.read_bytes(len(expected_value))
463 if value != expected_value:
464 raise ParserError('unexpected value: {} != {} ({})'.format(value, expected_value, message))
465
466 def read_uint(self, big_endian=False):
467 data_format = '>I' if big_endian else '<I'
468 return struct.unpack(data_format, self.read_bytes(4))[0]
469
470 def read_double(self, big_endian=False):
471 data_format = '>d' if big_endian else '<d'
472 return struct.unpack(data_format, self.read_bytes(8))[0]
473
474 def read_cstring(self):
475 buffer = []
476 while True:
477 c = self.read_bytes(1)
478 if c == b'\x00':
479 return b''.join(buffer).decode('utf-8')
480 else:
481 buffer.append(c)
482
483 def skip(self, num_bytes, description='unknown'):
484 if num_bytes > 0:
485 self._logger.debug('skipping {} bytes ({}): {}'.format(
486 num_bytes, description, self.read_bytes(num_bytes)))
487 elif num_bytes < 0:
488 raise ParserError('invalid skip of {} bytes'.format(num_bytes))
489
490 def skip_to(self, offset, description='unknown'):
491 self.skip(offset - self.cursor, description)
492
493 def skip_to_end(self, description='unknown'):
494 self.skip_to(len(self._data), description)
495
496
497def _mac_absolute_time_to_posix(timestamp):
498 return int((datetime(2001, 1, 1, 0, 0, tzinfo=timezone.utc) + timedelta(seconds=timestamp)).timestamp())
499
500
501def _parse_safari_cookies_header(data, logger):
502 p = DataParser(data, logger)
503 p.expect_bytes(b'cook', 'database signature')
504 number_of_pages = p.read_uint(big_endian=True)
505 page_sizes = [p.read_uint(big_endian=True) for _ in range(number_of_pages)]
506 return page_sizes, p.cursor
507
508
509def _parse_safari_cookies_page(data, jar, logger):
510 p = DataParser(data, logger)
511 p.expect_bytes(b'\x00\x00\x01\x00', 'page signature')
512 number_of_cookies = p.read_uint()
513 record_offsets = [p.read_uint() for _ in range(number_of_cookies)]
514 if number_of_cookies == 0:
515 logger.debug('a cookies page of size {} has no cookies'.format(len(data)))
516 return
517
518 p.skip_to(record_offsets[0], 'unknown page header field')
519
520 for record_offset in record_offsets:
521 p.skip_to(record_offset, 'space between records')
522 record_length = _parse_safari_cookies_record(data[record_offset:], jar, logger)
523 p.read_bytes(record_length)
524 p.skip_to_end('space in between pages')
525
526
527def _parse_safari_cookies_record(data, jar, logger):
528 p = DataParser(data, logger)
529 record_size = p.read_uint()
530 p.skip(4, 'unknown record field 1')
531 flags = p.read_uint()
532 is_secure = bool(flags & 0x0001)
533 p.skip(4, 'unknown record field 2')
534 domain_offset = p.read_uint()
535 name_offset = p.read_uint()
536 path_offset = p.read_uint()
537 value_offset = p.read_uint()
538 p.skip(8, 'unknown record field 3')
539 expiration_date = _mac_absolute_time_to_posix(p.read_double())
540 _creation_date = _mac_absolute_time_to_posix(p.read_double()) # noqa: F841
541
542 try:
543 p.skip_to(domain_offset)
544 domain = p.read_cstring()
545
546 p.skip_to(name_offset)
547 name = p.read_cstring()
548
549 p.skip_to(path_offset)
550 path = p.read_cstring()
551
552 p.skip_to(value_offset)
553 value = p.read_cstring()
554 except UnicodeDecodeError:
555 logger.warning('failed to parse cookie because UTF-8 decoding failed')
556 return record_size
557
558 p.skip_to(record_size, 'space at the end of the record')
559
560 cookie = compat_cookiejar_Cookie(
561 version=0, name=name, value=value, port=None, port_specified=False,
562 domain=domain, domain_specified=bool(domain), domain_initial_dot=domain.startswith('.'),
563 path=path, path_specified=bool(path), secure=is_secure, expires=expiration_date, discard=False,
564 comment=None, comment_url=None, rest={})
565 jar.set_cookie(cookie)
566 return record_size
567
568
569def parse_safari_cookies(data, jar=None, logger=YDLLogger()):
570 """
571 References:
572 - https://github.com/libyal/dtformats/blob/main/documentation/Safari%20Cookies.asciidoc
573 - this data appears to be out of date but the important parts of the database structure is the same
574 - there are a few bytes here and there which are skipped during parsing
575 """
576 if jar is None:
577 jar = YoutubeDLCookieJar()
578 page_sizes, body_start = _parse_safari_cookies_header(data, logger)
579 p = DataParser(data[body_start:], logger)
580 for page_size in page_sizes:
581 _parse_safari_cookies_page(p.read_bytes(page_size), jar, logger)
582 p.skip_to_end('footer')
583 return jar
584
585
586def _get_linux_keyring_password(browser_keyring_name):
587 password = keyring.get_password('{} Keys'.format(browser_keyring_name),
588 '{} Safe Storage'.format(browser_keyring_name))
589 if password is None:
590 # this sometimes occurs in KDE because chrome does not check hasEntry and instead
591 # just tries to read the value (which kwallet returns "") whereas keyring checks hasEntry
592 # to verify this:
593 # dbus-monitor "interface='org.kde.KWallet'" "type=method_return"
594 # while starting chrome.
595 # this may be a bug as the intended behaviour is to generate a random password and store
596 # it, but that doesn't matter here.
597 password = ''
598 return password.encode('utf-8')
599
600
601def _get_mac_keyring_password(browser_keyring_name):
602 if KEYRING_AVAILABLE:
603 password = keyring.get_password('{} Safe Storage'.format(browser_keyring_name), browser_keyring_name)
604 return password.encode('utf-8')
605 else:
606 proc = subprocess.Popen(['security', 'find-generic-password',
607 '-w', # write password to stdout
608 '-a', browser_keyring_name, # match 'account'
609 '-s', '{} Safe Storage'.format(browser_keyring_name)], # match 'service'
610 stdout=subprocess.PIPE,
611 stderr=subprocess.DEVNULL)
612 try:
613 stdout, stderr = process_communicate_or_kill(proc)
614 return stdout
615 except BaseException:
616 return None
617
618
619def _get_windows_v10_key(browser_root, logger):
620 path = _find_most_recently_used_file(browser_root, 'Local State')
621 if path is None:
622 logger.error('could not find local state file')
623 return None
624 with open(path, 'r') as f:
625 data = json.load(f)
626 try:
627 base64_key = data['os_crypt']['encrypted_key']
628 except KeyError:
629 logger.error('no encrypted key in Local State')
630 return None
631 encrypted_key = compat_b64decode(base64_key)
632 prefix = b'DPAPI'
633 if not encrypted_key.startswith(prefix):
634 logger.error('invalid key')
635 return None
636 return _decrypt_windows_dpapi(encrypted_key[len(prefix):], logger)
637
638
639def pbkdf2_sha1(password, salt, iterations, key_length):
640 return pbkdf2_hmac('sha1', password, salt, iterations, key_length)
641
642
643def _decrypt_aes_cbc(ciphertext, key, logger, initialization_vector=b' ' * 16):
644 plaintext = aes_cbc_decrypt(bytes_to_intlist(ciphertext),
645 bytes_to_intlist(key),
646 bytes_to_intlist(initialization_vector))
647 padding_length = plaintext[-1]
648 try:
649 return intlist_to_bytes(plaintext[:-padding_length]).decode('utf-8')
650 except UnicodeDecodeError:
651 logger.warning('failed to decrypt cookie because UTF-8 decoding failed. Possibly the key is wrong?')
652 return None
653
654
655def _decrypt_aes_gcm(ciphertext, key, nonce, authentication_tag, logger):
656 cipher = AES.new(key, AES.MODE_GCM, nonce)
657 try:
658 plaintext = cipher.decrypt_and_verify(ciphertext, authentication_tag)
659 except ValueError:
660 logger.warning('failed to decrypt cookie because the MAC check failed. Possibly the key is wrong?')
661 return None
662
663 try:
664 return plaintext.decode('utf-8')
665 except UnicodeDecodeError:
666 logger.warning('failed to decrypt cookie because UTF-8 decoding failed. Possibly the key is wrong?')
667 return None
668
669
670def _decrypt_windows_dpapi(ciphertext, logger):
671 """
672 References:
673 - https://docs.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptunprotectdata
674 """
675 from ctypes.wintypes import DWORD
676
677 class DATA_BLOB(ctypes.Structure):
678 _fields_ = [('cbData', DWORD),
679 ('pbData', ctypes.POINTER(ctypes.c_char))]
680
681 buffer = ctypes.create_string_buffer(ciphertext)
682 blob_in = DATA_BLOB(ctypes.sizeof(buffer), buffer)
683 blob_out = DATA_BLOB()
684 ret = ctypes.windll.crypt32.CryptUnprotectData(
685 ctypes.byref(blob_in), # pDataIn
686 None, # ppszDataDescr: human readable description of pDataIn
687 None, # pOptionalEntropy: salt?
688 None, # pvReserved: must be NULL
689 None, # pPromptStruct: information about prompts to display
690 0, # dwFlags
691 ctypes.byref(blob_out) # pDataOut
692 )
693 if not ret:
694 logger.warning('failed to decrypt with DPAPI')
695 return None
696
697 result = ctypes.string_at(blob_out.pbData, blob_out.cbData)
698 ctypes.windll.kernel32.LocalFree(blob_out.pbData)
699 return result
700
701
702def _config_home():
703 return os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
704
705
706def _open_database_copy(database_path, tmpdir):
707 # cannot open sqlite databases if they are already in use (e.g. by the browser)
708 database_copy_path = os.path.join(tmpdir, 'temporary.sqlite')
709 shutil.copy(database_path, database_copy_path)
710 conn = sqlite3.connect(database_copy_path)
711 return conn.cursor()
712
713
714def _get_column_names(cursor, table_name):
715 table_info = cursor.execute('PRAGMA table_info({})'.format(table_name)).fetchall()
716 return [row[1].decode('utf-8') for row in table_info]
717
718
719def _find_most_recently_used_file(root, filename):
720 # if there are multiple browser profiles, take the most recently used one
721 paths = []
722 for root, dirs, files in os.walk(root):
723 for file in files:
724 if file == filename:
725 paths.append(os.path.join(root, file))
726 return None if not paths else max(paths, key=lambda path: os.lstat(path).st_mtime)
727
728
729def _merge_cookie_jars(jars):
730 output_jar = YoutubeDLCookieJar()
731 for jar in jars:
732 for cookie in jar:
733 output_jar.set_cookie(cookie)
734 if jar.filename is not None:
735 output_jar.filename = jar.filename
736 return output_jar
737
738
739def _is_path(value):
740 return os.path.sep in value
741
742
743def _parse_browser_specification(browser_name, profile=None):
744 if browser_name not in SUPPORTED_BROWSERS:
745 raise ValueError(f'unsupported browser: "{browser_name}"')
746 if profile is not None and _is_path(profile):
747 profile = os.path.expanduser(profile)
748 return browser_name, profile