Skip to content

Instantly share code, notes, and snippets.

@creachadair
Last active April 20, 2025 03:38
Show Gist options
  • Save creachadair/937179894a24571ce9860e2475a2d2ec to your computer and use it in GitHub Desktop.
Save creachadair/937179894a24571ce9860e2475a2d2ec to your computer and use it in GitHub Desktop.
Encryption format for Chrome browser cookies

Google Chrome Encrypted Cookies

Google Chrome stores browser cookies in an SQLite database. The database has two tables, meta containing format and version metadata, and cookies with the contents of the cookies. The cookies table uses this schema:

-- To reproduce: sqlite path/to/Cookies .schema
CREATE TABLE cookies (
   creation_utc     INTEGER  NOT NULL,  -- microseconds since epoch
   host_key         TEXT     NOT NULL,  -- domain
   name             TEXT     NOT NULL,
   value            TEXT     NOT NULL,
   path             TEXT     NOT NULL,
   expires_utc      INTEGER  NOT NULL,  -- microseconds since epoch
   is_secure        INTEGER  NOT NULL,
   is_httponly      INTEGER  NOT NULL,
   last_access_utc  INTEGER  NOT NULL,
   has_expires      INTEGER  NOT NULL DEFAULT  1,
   is_persistent    INTEGER  NOT NULL DEFAULT  1,
   priority         INTEGER  NOT NULL DEFAULT  1,
   encrypted_value  BLOB              DEFAULT '',
   samesite         INTEGER  NOT NULL DEFAULT  -1,
   source_scheme    INTEGER  NOT NULL DEFAULT  0,

   -- samesite values, from Chromium cookies/cookie_constants.h
   --   UNSPECIFIED    = -1
   --   NO_RESTRICTION = 0    "None"
   --   LAX_MODE       = 1    "Lax"
   --   STRICT_MODE    = 2    "Strict"

   UNIQUE (host_key, name, path)
);

Timestamps

The expires_utc and creation_utc fields contain timestamps given as integer numbers of microseconds elapsed since midnight 01-Jan-1601 UTC in the proleptic calendar. The Unix epoch is 11644473600 seconds after this moment.

Values

The value and encrypted_value fields are used to store cookie values. In practice, one or the other is populated, but not both.

value encrypted_value Description
empty non-empty Encrypted value
non-empty empty Non-zero length value, unencrypted
empty empty Zero-length value, unencrypted
non-empty non-empty (not observed)

Storage Format

An encrypted value consists of a data packet that is encrypted with AES-128 in CBC mode. The encrypted data packet has the following format:

Bytes Content Description
3 "v10" (0x76 0x31 0x30) Version tag (unencrypted)
n value Payload (encrypted)
p padding Padding (encrypted), 1–16 bytes

The encrypted portion of the packet (n + p) contains a multiple of 16 bytes. If n is a multiple of 16, p = 16; otherwise 1 ≤ p ≤ 15.

In database versions ≥ 24, the payload is prefixed with a SHA256 digest of the host_key ("domain") field of the cookie, prior to encryption. This extends the payload by the 32 bytes of the digest. For example, if the value of the cookie is apple pear plum and the host_key is .google.com, then the plaintext payload is (in hex):

                                                                      "apple pear plum"
                                                                vvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
5d59719991ce1e2237eef373ad1cc97eafbd4d9738f18d37d86e7816e1b4f6dc6170706c65207065617220706c756d
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                   SHA256(".google.com")

This digest must be present or the cookie will be rejected by Chrome. Versions ≤ 23 do not require this prefix. Note that the "database" version does not change the "content" version (v10); the database version is recorded in the meta table under the version key, e.g.,

select value from meta where key = 'version';

Padding

The encrypted value is padded as per PCKD#5: Before encryption, p bytes of padding are added to the plaintext value to ensure a multiple of 16 bytes. At least one byte of padding is always added, so if the value is already a multiple of 16 bytes, p=16 additional are added. Each padding byte has the value p, so if p=5, the padding is the 5-byte sequence [5, 5, 5, 5, 5].

After decryption, the padding must be removed, and it can be used to verify that the decryption key was correct. The final byte of the decrypted packet must be a padding byte with value 1 ≤ p ≤ 16, and the last p bytes of the packet must contain the value p. Otherwise, the decryption key can be assumed to be incorrect.

Encryption

Encryption and decryption are performed using AES-128 in cipher-block chaining (CBC) mode with an initialization vector consisting of 16 space bytes (Unicode 32). The encryption key is described below.

Key Generation

The 16-byte AES-128 encryption key is generated using the PBKDF2 (RFC 2898) algorithm from a user-provided passphrase. The key generation salt is the fixed string saltysalt. On macOS, Chrome uses 1003 iterations of the key generation algorithm; on Linux it uses 1 iteration. I don't know what it does on Windows.

On macOS, Chrome stores the encryption passphrase in the user's login keychain under "Chrome Safe Storage". The passphrase is base64-encoded but is used directly in its base64-encoded form.

Older versions of Chrome and Chromium on Linux used the fixed passphrase peanuts, but more recent versions use the Gnome keyring.

@laithrafid
Copy link

laithrafid commented Jul 10, 2023

i'm trying to decrypt encrypted_value

def decrypt_mac_chrome_secrets2(encrypted_value, safe_storage_key):
    iv = b'' * 16
    key = hashlib.pbkdf2_hmac('sha1', safe_storage_key, b'saltysalt', 1003)[:16]

    cipher = AES.new(key, AES.MODE_CBC, IV=iv)
    decrypted_pass = cipher.decrypt(encrypted_value)

    # Retrieve padding length
    padding_length = decrypted_pass[-1]

    # Verify padding bytes
    padding_bytes = decrypted_pass[-padding_length:]
    if all(byte == padding_length for byte in padding_bytes) and padding_length > 0 and padding_length <= 16:
        # Remove padding
        decrypted_pass = decrypted_pass[:-padding_length]
    else:
        raise ValueError("Invalid padding")

    decrypted_pass = decrypted_pass.decode("utf-8", "ignore")
    decrypted_pass = decrypted_pass.replace("\x08", "")  # Remove backspace characters

    return decrypted_pass

using this code however i'm getting this error below which related to padding , have no idea what's missing

File "/Users/laithrafid/Library/Python/3.9/lib/python/site-packages/Cryptodome/Cipher/_mode_cbc.py", line 246, in decrypt raise ValueError("Data must be padded to %d byte boundary in CBC mode" % self.block_size) ValueError: Data must be padded to 16 byte boundary in CBC mode

@creachadair
Copy link
Author

I don't see an obvious problem with the decryption. Just to double check, are you pulling the value from the correct column in SQLite? (And, I guess, it's worth checking that the row you're fetching from actually has an encrypted value).

It might also be worth debug logging the (length of the) input value so you can verify it's something sensible.

@creachadair
Copy link
Author

Another thing to check is that the version tag has been stripped off the value before decryption.

@laithrafid
Copy link

laithrafid commented Jul 10, 2023

thanks , yeah i think i missed stripping off version number before decrypting , just in case someone else get stuck with it :

def decrypt_mac_chrome_secrets2(encrypted_value, safe_storage_key):
    if not encrypted_value:
        return ""  # Return empty string for empty encrypted value

    iv = b' ' * 16
    key = hashlib.pbkdf2_hmac('sha1', safe_storage_key, b'saltysalt', 1003)[:16]

    cipher = AES.new(key, AES.MODE_CBC, IV=iv)

    # Check and remove version tag
    if encrypted_value[:3] == b'v10':
        encrypted_payload = encrypted_value[3:]
    else:
        raise ValueError("Invalid version tag")

    decrypted_pass = cipher.decrypt(encrypted_payload)

    # Remove PKCS7 padding
    padding_length = decrypted_pass[-1]
    padding_value = decrypted_pass[-padding_length:]

    if padding_length > 0 and all(value == padding_length for value in padding_value):
        decrypted_pass = decrypted_pass[:-padding_length]
    else:
        raise ValueError("Invalid padding")

    decrypted_pass = decrypted_pass.decode("utf-8", "ignore")

    return decrypted_pass

@NCLnclNCL
Copy link

do you have any way to find the hardcoded encryption key in chrome.dll?

@lea2mich
Copy link

Hi,
Till chromium version 113 I was using an another alg to decrypt cookies. from which version the alg above is relevant?

@creachadair
Copy link
Author

Hi, Till chromium version 113 I was using an another alg to decrypt cookies. from which version the alg above is relevant?

I'm not sure, sorry. I stopped using Chrome recently, but until then I had been using this algorithm successfully for many years, though I never made note of what version was current when I first wrote the code.

@TaihouKai
Copy link

Just to add: On Windows, the AES (mode GCM) key is encrypted by Windows DPAPI, stored in Local State file.
The encrypted blob is like: v10 + nonce + cipher + verification_tag.

@creachadair
Copy link
Author

Just to add: On Windows, the AES (mode GCM) key is encrypted by Windows DPAPI, stored in Local State file. The encrypted blob is like: v10 + nonce + cipher + verification_tag.

Thanks, that's interesting. How are the contents organized within the string, e.g., are the various parts delimited or length-prefixed or something?

@TaihouKai
Copy link

Just to add: On Windows, the AES (mode GCM) key is encrypted by Windows DPAPI, stored in Local State file. The encrypted blob is like: v10 + nonce + cipher + verification_tag.

Thanks, that's interesting. How are the contents organized within the string, e.g., are the various parts delimited or length-prefixed or something?

The structure is like:

Field Length
v10 :3
nonce 3:15
ciphertext 15:-16
verification tag -16:

@trygveaa
Copy link

Note that in version 130 Chrome added the SHA256 hash of the domain name to the start of encrypted_value (before encrypting). This means that after decrypting the value you have to remove the first 32 bytes before using it (and before e.g. decoding it from UTF-8).

To check if you need to do this you can read the row from the meta table that has key = 'version'. If this value is >= 24 the hash will be included.

See chromium/chromium@5ea6d65 for more details.

@creachadair
Copy link
Author

Note that in version 130 Chrome added the SHA256 hash of the domain name to the start of encrypted_value (before encrypting). This means that after decrypting the value you have to remove the first 32 bytes before using it (and before e.g. decoding it from UTF-8).

To check if you need to do this you can read the row from the meta table that has key = 'version'. If this value is >= 24 the hash will be included.

See chromium/chromium@5ea6d65 for more details.

Thanks, I've updated the doc to make note of that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment