Instantly share code, notes, and snippets.
Last active
April 30, 2025 09:05
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save mildsunrise/ac2bcb473c098a5dc3b2bdcc7b8eea51 to your computer and use it in GitHub Desktop.
Converts a Chromium cookie store to its decrypted form
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
''' | |
Converts a Chromium cookie store to its decrypted form. | |
## Background | |
Chromium stores cookies in a file named 'Cookies' under the profile | |
directory. Each cookie can be in encrypted or unencrypted form. | |
When stored in encrypted form, the decryption key is stored in an | |
OS-dependent facility (for Linux, it is usually libsecret or | |
KDE wallet). | |
When starting, Chromium will WITHOUT CONFIRMATION, PERMANENTLY DELETE | |
any cookies it can't successfully decrypt. This can lead to losing | |
all your cookies after migrating to a new OS, migrating to a new | |
desktop environment or changing any other part of your setup that | |
affects how Chromium chooses to store the encryption key. | |
This tool decrypts all cookies it can from the store, storing them in | |
decrypted form instead. This ensures Chromium will not delete them at | |
startup (and will probably re-encrypt them using the new key). The | |
tool operates in place, but unlike Chromium it will never delete a | |
cookie and will only change those cookies it can correctly decrypt. | |
## Usage | |
You must feed this tool the OS-stored decryption password. For Linux, | |
if using --password-store=gnome-secret (often the default), you will | |
have a secret named "Chromium Safe Storage" which can be retrieved | |
using secret-tool (should be a short base64 string): | |
secret-tool search --unlock application chromium | grep ^secret | |
# or for chrome: | |
secret-tool search --unlock application chrome | grep ^secret | |
An example invocation would look like: | |
./decrypt-chromium-cookies.py ~/.config/chromium/Default/Cookies 2Bs3ChiPaxktNz1qsFBzhA== | |
Please ensure Chromium is NOT started while using this tool. And if | |
you've already started Chromium under the new setup, cookies may have | |
already been deleted :( | |
The tool has options --raw-key and --hex-key which you'll 99% NOT | |
want to use, they're for more advanced use cases. | |
''' | |
import argparse | |
import sqlite3 | |
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC | |
from cryptography.hazmat.primitives.hashes import Hash, SHA1, SHA256 | |
from cryptography.hazmat.primitives.ciphers import Cipher | |
from cryptography.hazmat.primitives.ciphers.algorithms import AES128 | |
from cryptography.hazmat.primitives.ciphers.modes import CBC | |
from cryptography.hazmat.primitives.padding import PKCS7 | |
# CLI argument parsing | |
parser = argparse.ArgumentParser( | |
prog='decrypt-chromium-cookies', | |
description='Converts a Chromium cookie store to its decrypted form (see script docstring for more info)' | |
) | |
parser.add_argument('filename', | |
help='cookie store location (example: ~/.config/chromium/Default/Cookies)') | |
parser.add_argument('key', | |
help='OS-stored encryption password (example: 2Bs3ChiPaxktNz1qsFBzhA==)') | |
parser.add_argument('--hex-key', action='store_true', | |
help='indicates <key> is expressed in hex instead of directly as text (this is useful if it contains binary data)') | |
parser.add_argument('--raw-key', action='store_true', | |
help='indicates <key> is the direct encryption key, rather than the password stored in OS facilities (in this case, the key must be exactly 16 bytes long)') | |
args = parser.parse_args() | |
if args.hex_key: | |
key = bytes.fromhex(args.key) | |
else: | |
key = args.key.encode() | |
# Chromium's Crypto(tm) | |
kdf = lambda x: \ | |
PBKDF2HMAC(SHA1(), length=16, salt=b"saltysalt", iterations=1).derive(x) | |
iv = b" " * 16 | |
keys = { | |
b"v10": kdf(b"peanuts"), | |
b"v11": key if args.raw_key else kdf(key), | |
} | |
apply = lambda ctx, x: (ctx.update(x) or b'') + ctx.finalize() | |
def core_decrypt(key: bytes, ctext: bytes) -> bytes: | |
ptext = apply(Cipher(AES128(key), CBC(iv)).decryptor(), ctext) | |
return apply(PKCS7(16*8).unpadder(), ptext) | |
def decrypt(enc_cookie: bytes) -> bytes: | |
for prefix, key in keys.items(): | |
if enc_cookie.startswith(prefix): | |
return core_decrypt(key, enc_cookie[len(prefix):]) | |
raise ValueError("invalid prefix") | |
# cookie store manipulation | |
con = sqlite3.connect(args.filename, autocommit=False) | |
def get_meta(key: str) -> str: | |
res = con.execute('SELECT value FROM meta WHERE key = ?', (key,)).fetchone() | |
assert len(res) == 1 and type(res[0]) is str | |
return res[0] | |
compat_version = int(get_meta('last_compatible_version')) | |
# TODO: make changes needed to support older versions: | |
# https://source.chromium.org/chromium/chromium/src/+/main:net/extras/sqlite/sqlite_persistent_cookie_store.cc;l=224;drc=56d30b6435d4bb9430d77af7a2b441372426d486 | |
assert 23 <= compat_version <= 24, \ | |
f'compatibility version of the cookie store ({compat_version}) is outside tested range; remove this assert at your own risk' | |
# Chromium is apparently buggy and used to store values as latin1 TEXT | |
# instead of BLOB (yes, the 'encrypted_value' column is of type BLOB, | |
# but in SQLite this means nothing and its *values* can still have | |
# arbitrary types. they insist this is a feature.). so, tell python | |
# not to attempt to decode TEXT values using UTF-8, we'll do it ourselves (selectively) | |
class SqliteText(bytes): pass | |
con.text_factory = SqliteText | |
decode_text = lambda x: x.decode('utf-8') if type(x) is SqliteText else x | |
def decrypt_cookie(blob: bytes, domain: str) -> str: | |
raw_value = decrypt(blob) | |
if compat_version >= 24: | |
digest, raw_value = raw_value[:32], raw_value[32:] | |
exp_digest = apply(Hash(SHA256()), domain.encode('utf-8')) | |
if digest != exp_digest: | |
raise ValueError(f'domain checksum fail (expected {exp_digest.hex()}, got {digest.hex()})') | |
return raw_value.decode('utf-8') | |
index_fields = ('host_key', 'top_frame_site_key', 'has_cross_site_ancestor', 'name', 'path', 'source_scheme', 'source_port') | |
attempts, failures = 0, 0 | |
selected_fields = index_fields + ('value', 'encrypted_value') | |
for row in con.execute(f"SELECT {', '.join(selected_fields)} FROM cookies WHERE encrypted_value!=X'' AND encrypted_value!=''"): | |
attempts += 1 | |
row_key, (orig_value, encrypted_value,) = row[:len(index_fields)], row[len(index_fields):] | |
assert not orig_value, f'cookie contains both value and encrypted_value' | |
row_key = tuple(map(decode_text, row_key)) | |
assert type(row_key[0]) is str | |
try: | |
value = decrypt_cookie(encrypted_value, row_key[0]) | |
except ValueError as exc: | |
print(f'failed decrypting {row_key!r}: {exc!r}') | |
failures += 1 | |
continue | |
update_where = ' AND '.join(f'{k}=?' for k in index_fields) | |
res = con.execute(f"UPDATE cookies SET value=?, encrypted_value=X'' WHERE {update_where}", (value, *row_key)) | |
assert res.rowcount == 1, f'update caused {res.rowcount} rows to be updated' | |
con.commit() | |
con.close() | |
if not attempts: | |
print('Cookie store not encrypted; nothing to do') | |
else: | |
print(f'Successfully decrypted {attempts - failures} cookies, failed on {failures}') | |
if failures: | |
exit(1) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment