Repository: moonD4rk/HackBrowserData (2026-04-13T14:06:59Z)

Issue:
[Linux] v11 encrypted cookies/passwords fail to decrypt
### Operating System

Linux

### OS Version

Ubuntu 22.04.5 LTS

### Browser

Brave

### Browser Version

Brave 1.89.132 (Official Build) (64-bit)

### Bug Description

All cookie's values are not exported.
When peeking around the cookiejar with sqlite3, the values look encoded by v11

{"name":"_gh_sess","encrypted_value":"v11f[....]

But in HackBrowserData result/cookie.json:
{
    "browser": "Brave",
    "profile": "Default",
    "host": "github.com",
    "path": "/",
    "name": "_gh_sess",
    "value": "",
    "is_secure": true,
    "is_http_only": true,
    "has_expire": false,
    "is_persistent": false,
    "expire_at": "1601-01-01T00:00:00+00:19",
    "created_at": "2026-04-11T17:19:44.772128+02:00"
  },


### Log Output

```shell
[DBG] browser.go:76: browser Chrome not found at /home/{munged}/.config/google-chrome
[DBG] browser.go:76: browser Microsoft Edge not found at /home/{munged}/.config/microsoft-edge
[DBG] browser.go:76: browser Chromium not found at /home/{munged}/.config/chromium
[DBG] browser.go:76: browser Chrome Beta not found at /home/{munged}/.config/google-chrome-beta
[DBG] browser.go:76: browser Opera not found at /home/{munged}/.config/opera
[DBG] browser.go:76: browser Vivaldi not found at /home/{munged}/.config/vivaldi
[DBG] browser.go:76: browser Firefox not found at /home/{munged}/.mozilla/firefox
[INF] Extracting Brave/Default...
[DBG] extract_password.go:48: decrypt passwords: 344 failed: DPAPI not supported on this platform
[DBG] extract_cookie.go:62: decrypt cookies: 2272 failed: DPAPI not supported on this platform
[DBG] chromium.go:237: extract extension for Brave/Default: cannot find extensions in settings

[INF] Exported to results/
[INF]   password.csv             367 entries
[INF]   cookie.csv               2272 entries
[INF]   history.csv              28370 entries
[INF]   download.csv             5053 entries
[INF]   bookmark.csv             234 entries
[INF]   localstorage.csv         15828 entries
[INF]   sessionstorage.csv       109 entries
```

### Expected vs Actual Behavior

Export the decoded values.


### Checklist

- [x] I have checked the [existing issues](https://github.com/moonD4rk/HackBrowserData/issues) for similar reports.
- [x] I ran `hack-browser-data` with administrator/root privileges.


Discussion:
Thanks for the report!

Chromium on Linux added a `v11` obfuscation prefix (used when the key comes from libsecret / kwallet — i.e. GNOME Keyring / KDE Wallet). We don't currently handle it: our `DetectVersion` only recognizes `v10` and `v20`, so `v11`-prefixed values fall back to the Windows DPAPI decryption path, which is why you see the misleading `DPAPI not supported on this platform` error on Linux.

v11 on Linux actually uses the same AES-CBC algorithm as v10 — only the key source differs — so the fix is small. Will ship it soon.
Fix merged to `main` as `eb58ebb` via #571.

`v11` ciphertexts are now routed through the same AES-CBC path as `v10`, using the master key that `DBusRetriever` already reads from your libsecret/kwallet keyring. The PR also mirrors Chromium's legacy `kEmptyKey` retry so any records corrupted by the Chrome ~89 KWallet race (crbug.com/40055416) are also recovered.

You can test it right away by building from `main`:

```bash
git clone https://github.com/moonD4rk/HackBrowserData
cd HackBrowserData
go build ./cmd/hack-browser-data
```

Otherwise it will ship in the next release.

**If the problem persists after upgrading, please reopen this issue** with the updated log output — thanks again for the clear report!



Patch:
diff --git a/browser/chromium/decrypt.go b/browser/chromium/decrypt.go
index 50cedeb3..d8262b61 100644
--- a/browser/chromium/decrypt.go
+++ b/browser/chromium/decrypt.go
@@ -16,7 +16,8 @@ func decryptValue(masterKey, ciphertext []byte) ([]byte, error) {
 
 	version := crypto.DetectVersion(ciphertext)
 	switch version {
-	case crypto.CipherV10:
+	case crypto.CipherV10, crypto.CipherV11:
+		// v11 is Linux-only and shares v10's AES-CBC path; only the key source differs.
 		return crypto.DecryptChromium(masterKey, ciphertext)
 	case crypto.CipherV20:
 		// TODO: implement App-Bound Encryption (Chrome 127+)
diff --git a/crypto/crypto_linux.go b/crypto/crypto_linux.go
index 78c152c1..460f0379 100644
--- a/crypto/crypto_linux.go
+++ b/crypto/crypto_linux.go
@@ -5,17 +5,33 @@ package crypto
 import (
 	"bytes"
 	"crypto/aes"
+	"crypto/sha1"
 )
 
 var chromiumCBCIV = bytes.Repeat([]byte{0x20}, aes.BlockSize)
 
+// kEmptyKey is Chromium's decrypt-only fallback for data corrupted by a
+// KWallet race in Chrome ~89 (crbug.com/40055416). Matches the kEmptyKey
+// constant in os_crypt_linux.cc.
+var kEmptyKey = PBKDF2Key([]byte(""), []byte("saltysalt"), 1, 16, sha1.New)
+
 const minCBCDataSize = versionPrefixLen + aes.BlockSize // "v10" + one AES block = 19 bytes minimum
 
 func DecryptChromium(key, ciphertext []byte) ([]byte, error) {
 	if len(ciphertext) < minCBCDataSize {
 		return nil, errShortCiphertext
 	}
-	return AESCBCDecrypt(key, chromiumCBCIV, ciphertext[versionPrefixLen:])
+	payload := ciphertext[versionPrefixLen:]
+
+	plaintext, err := AESCBCDecrypt(key, chromiumCBCIV, payload)
+	if err == nil {
+		return plaintext, nil
+	}
+	// Retry with kEmptyKey to recover crbug.com/40055416 data.
+	if alt, altErr := AESCBCDecrypt(kEmptyKey, chromiumCBCIV, payload); altErr == nil {
+		return alt, nil
+	}
+	return nil, err
 }
 
 func DecryptDPAPI(_ []byte) ([]byte, error) {
diff --git a/crypto/version.go b/crypto/version.go
index ac747344..2c593d8b 100644
--- a/crypto/version.go
+++ b/crypto/version.go
@@ -7,6 +7,10 @@ const (
 	// CipherV10 is Chrome 80+ encryption (AES-GCM on Windows, AES-CBC on macOS/Linux).
 	CipherV10 CipherVersion = "v10"
 
+	// CipherV11 is the Linux-only AES-CBC variant where the key comes from
+	// libsecret / kwallet. Same algorithm as CipherV10; only the key source differs.
+	CipherV11 CipherVersion = "v11"
+
 	// CipherV20 is Chrome 127+ App-Bound Encryption.
 	CipherV20 CipherVersion = "v20"
 
@@ -26,6 +30,8 @@ func DetectVersion(ciphertext []byte) CipherVersion {
 	switch prefix {
 	case "v10":
 		return CipherV10
+	case "v11":
+		return CipherV11
 	case "v20":
 		return CipherV20
 	default:
@@ -37,7 +43,7 @@ func DetectVersion(ciphertext []byte) CipherVersion {
 // Returns the ciphertext unchanged if no known prefix is found.
 func stripPrefix(ciphertext []byte) []byte {
 	ver := DetectVersion(ciphertext)
-	if ver == CipherV10 || ver == CipherV20 {
+	if ver == CipherV10 || ver == CipherV11 || ver == CipherV20 {
 		return ciphertext[versionPrefixLen:]
 	}
 	return ciphertext
diff --git a/rfcs/003-chromium-encryption.md b/rfcs/003-chromium-encryption.md
index 29346ac2..28b10061 100644
--- a/rfcs/003-chromium-encryption.md
+++ b/rfcs/003-chromium-encryption.md
@@ -17,6 +17,7 @@ Every encrypted value begins with a 3-byte prefix that identifies the cipher ver
 | Prefix | Version | Meaning |
 |--------|---------|---------|
 | `v10` | CipherV10 | Chrome 80+ standard encryption (AES-GCM on Windows, AES-CBC on macOS/Linux) |
+| `v11` | CipherV11 | Linux-only: AES-CBC variant where the key comes from libsecret / kwallet. Same algorithm and parameters as `v10` — only the key source differs |
 | `v20` | CipherV20 | Chrome 127+ App-Bound Encryption |
 | (none) | CipherDPAPI | Pre-Chrome 80 raw DPAPI encryption (Windows only, no prefix) |
 
@@ -69,9 +70,12 @@ With the master key, each encrypted value is decrypted as AES-256-GCM:
 
 ## 5. Linux Encryption
 
-Chromium on Linux retrieves a per-browser secret from D-Bus Secret Service (GNOME Keyring or KDE Wallet). The label matches the browser's storage name (e.g. "Chrome Safe Storage", "Chromium Safe Storage"). If D-Bus is unavailable, the hardcoded fallback password `peanuts` is used.
+Chromium on Linux has two obfuscation prefixes that share the same AES-128-CBC algorithm and PBKDF2 parameters — only the key source differs:
 
-The master key is derived via PBKDF2 with different parameters than macOS:
+- **`v10`** — the PBKDF2 password is the hardcoded string `peanuts`. Chromium writes this prefix when no keyring backend is available (headless sessions, `--password-store=basic`, LXQt, etc.).
+- **`v11`** — the PBKDF2 password is a random string read from D-Bus Secret Service (GNOME Keyring or KDE Wallet). The libsecret/kwallet item label matches the browser's storage name (e.g. "Chrome Safe Storage", "Brave Safe Storage"). Chromium writes this prefix whenever a keyring backend is available at encrypt time. On first run, Chromium generates and stores the random password automatically.
+
+Both prefixes are derived through the same PBKDF2 parameters:
 
 | Parameter | Value |
 |-----------|-------|
@@ -80,7 +84,11 @@ The master key is derived via PBKDF2 with different parameters than macOS:
 | Iterations | 1 |
 | Key length | 16 bytes (AES-128) |
 
-Decryption uses the same AES-128-CBC scheme as macOS (fixed IV of 16 space bytes, PKCS5 padding).
+Decryption uses AES-128-CBC with a fixed IV of 16 space bytes (`0x20`) and PKCS5 padding — identical to macOS except for the PBKDF2 iteration count.
+
+**Mixed v10/v11 in the same profile.** Because Chromium selects the prefix at encrypt time, a single profile may contain both versions if the keyring backend availability changed between sessions. Chromium decrypts each record independently by inspecting its prefix.
+
+**kEmptyKey legacy retry.** Chromium's `DecryptString` retries any failed v10/v11 decryption with a second key, `kEmptyKey = PBKDF2("", "saltysalt", 1, 16, sha1)`. This exists to recover data corrupted by a KWallet initialization race in Chrome ~89 (see `crbug.com/40055416`), where some records were written with this zero-derived key. Chromium never uses `kEmptyKey` for encryption — it is decrypt-only. HackBrowserData mirrors this retry for parity.
 
 ## 6. v20 App-Bound Encryption (Chrome 127+)
 
@@ -105,7 +113,7 @@ The high-level decryption path for any encrypted Chromium value:
 
 1. **Detect version** -- inspect the first 3 bytes of the ciphertext
 2. **Route by version**:
-   - `v10` -- strip prefix, call platform-specific decryption (AES-CBC on macOS/Linux, AES-GCM on Windows)
+   - `v10` / `v11` -- strip prefix, call platform-specific decryption (AES-CBC on macOS/Linux, AES-GCM on Windows). On Linux, a failed decryption retries once with `kEmptyKey` to recover legacy crbug.com/40055416 data
    - `v20` -- not yet supported, return error
    - DPAPI (no prefix) -- call Windows `CryptUnprotectData` directly (Windows only; returns error on other platforms)
 3. **Return plaintext** -- the decrypted bytes are interpreted as a UTF-8 string


Test patch:
diff --git a/browser/chromium/decrypt_test.go b/browser/chromium/decrypt_test.go
index f15e6a6f..08efca8a 100644
--- a/browser/chromium/decrypt_test.go
+++ b/browser/chromium/decrypt_test.go
@@ -52,6 +52,18 @@ func TestDecryptValue_V10(t *testing.T) {
 	}
 }
 
+func TestDecryptValue_V11(t *testing.T) {
+	plaintext := []byte("test_secret_value")
+	testCBCIV := bytes.Repeat([]byte{0x20}, 16)
+	cbcEncrypted, err := crypto.AESCBCEncrypt(testAESKey, testCBCIV, plaintext)
+	require.NoError(t, err)
+	v11Ciphertext := append([]byte("v11"), cbcEncrypted...)
+
+	got, err := decryptValue(testAESKey, v11Ciphertext)
+	require.NoError(t, err)
+	assert.Equal(t, plaintext, got)
+}
+
 func TestDecryptValue_V20(t *testing.T) {
 	// v20 App-Bound Encryption is not yet implemented.
 	// TODO: add successful decryption cases when implemented.
diff --git a/crypto/crypto_linux_test.go b/crypto/crypto_linux_test.go
new file mode 100644
index 00000000..ecb3885a
--- /dev/null
+++ b/crypto/crypto_linux_test.go
@@ -0,0 +1,40 @@
+//go:build linux
+
+package crypto
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestKEmptyKey_MatchesChromium pins the runtime-derived kEmptyKey to
+// Chromium's reference bytes in os_crypt_linux.cc.
+func TestKEmptyKey_MatchesChromium(t *testing.T) {
+	want := []byte{
+		0xd0, 0xd0, 0xec, 0x9c, 0x7d, 0x77, 0xd4, 0x3a,
+		0xc5, 0x41, 0x87, 0xfa, 0x48, 0x18, 0xd1, 0x7f,
+	}
+	assert.Equal(t, want, kEmptyKey)
+	assert.Len(t, kEmptyKey, 16)
+}
+
+func TestDecryptChromium_EmptyKeyFallback(t *testing.T) {
+	plaintext := []byte("legacy_kwallet_value")
+	encrypted, err := AESCBCEncrypt(kEmptyKey, chromiumCBCIV, plaintext)
+	require.NoError(t, err)
+	ciphertext := append([]byte("v11"), encrypted...)
+
+	wrongKey := bytes.Repeat([]byte{0xAA}, 16)
+	got, err := DecryptChromium(wrongKey, ciphertext)
+	require.NoError(t, err)
+	assert.Equal(t, plaintext, got)
+}
+
+func TestDecryptChromium_ShortCiphertext(t *testing.T) {
+	key := make([]byte, 16)
+	_, err := DecryptChromium(key, []byte("v11short"))
+	require.ErrorIs(t, err, errShortCiphertext)
+}
diff --git a/crypto/keyretriever/keyretriever_linux_test.go b/crypto/keyretriever/keyretriever_linux_test.go
index 8505b4a6..836e1238 100644
--- a/crypto/keyretriever/keyretriever_linux_test.go
+++ b/crypto/keyretriever/keyretriever_linux_test.go
@@ -34,6 +34,19 @@ func TestFallbackRetriever(t *testing.T) {
 	assert.Equal(t, key, key2, "fallback key should be the same for any storage")
 }
 
+// TestFallbackRetriever_MatchesChromiumKV10Key pins FallbackRetriever's
+// output to Chromium's kV10Key reference bytes in os_crypt_linux.cc.
+func TestFallbackRetriever_MatchesChromiumKV10Key(t *testing.T) {
+	want := []byte{
+		0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53,
+		0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78,
+	}
+	r := &FallbackRetriever{}
+	key, err := r.RetrieveKey("", "")
+	require.NoError(t, err)
+	assert.Equal(t, want, key)
+}
+
 func TestDefaultRetriever_Linux(t *testing.T) {
 	r := DefaultRetriever()
 	chain, ok := r.(*ChainRetriever)
diff --git a/crypto/version_test.go b/crypto/version_test.go
index 74afbeef..6f599acf 100644
--- a/crypto/version_test.go
+++ b/crypto/version_test.go
@@ -13,6 +13,7 @@ func TestDetectVersion(t *testing.T) {
 		want       CipherVersion
 	}{
 		{"v10 prefix", []byte("v10" + "encrypted_data"), CipherV10},
+		{"v11 prefix", []byte("v11" + "encrypted_data"), CipherV11},
 		{"v20 prefix", []byte("v20" + "encrypted_data"), CipherV20},
 		{"no prefix (DPAPI)", []byte{0x01, 0x00, 0x00, 0x00}, CipherDPAPI},
 		{"short input", []byte{0x01, 0x02}, CipherDPAPI},
@@ -34,6 +35,7 @@ func Test_stripPrefix(t *testing.T) {
 		want       []byte
 	}{
 		{"strips v10", []byte("v10PAYLOAD"), []byte("PAYLOAD")},
+		{"strips v11", []byte("v11PAYLOAD"), []byte("PAYLOAD")},
 		{"strips v20", []byte("v20PAYLOAD"), []byte("PAYLOAD")},
 		{"keeps DPAPI unchanged", []byte{0x01, 0x00, 0x00}, []byte{0x01, 0x00, 0x00}},
 		{"keeps short unchanged", []byte{0x01}, []byte{0x01}},