Skip to content

Instantly share code, notes, and snippets.

@mildsunrise
Last active April 30, 2025 09:05
Show Gist options
  • Save mildsunrise/ac2bcb473c098a5dc3b2bdcc7b8eea51 to your computer and use it in GitHub Desktop.
Save mildsunrise/ac2bcb473c098a5dc3b2bdcc7b8eea51 to your computer and use it in GitHub Desktop.
Converts a Chromium cookie store to its decrypted form
#!/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