From eb58ebbbf4033b321b9b4dfc6e28d6d6edea1c1e Mon Sep 17 00:00:00 2001 From: Roger Date: Mon, 13 Apr 2026 22:12:40 +0800 Subject: [PATCH] fix: support Linux v11 cipher prefix for Chromium decryption (#571) --- browser/chromium/decrypt.go | 3 +- browser/chromium/decrypt_test.go | 12 ++++++ crypto/crypto_linux.go | 18 ++++++++- crypto/crypto_linux_test.go | 40 +++++++++++++++++++ .../keyretriever/keyretriever_linux_test.go | 13 ++++++ crypto/version.go | 8 +++- crypto/version_test.go | 2 + rfcs/003-chromium-encryption.md | 16 ++++++-- 8 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 crypto/crypto_linux_test.go diff --git a/browser/chromium/decrypt.go b/browser/chromium/decrypt.go index 50cedeb..d8262b6 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/browser/chromium/decrypt_test.go b/browser/chromium/decrypt_test.go index f15e6a6..08efca8 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.go b/crypto/crypto_linux.go index 78c152c..460f037 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/crypto_linux_test.go b/crypto/crypto_linux_test.go new file mode 100644 index 0000000..ecb3885 --- /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 8505b4a..836e123 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.go b/crypto/version.go index ac74734..2c593d8 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/crypto/version_test.go b/crypto/version_test.go index 74afbee..6f599ac 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}}, diff --git a/rfcs/003-chromium-encryption.md b/rfcs/003-chromium-encryption.md index 29346ac..28b1006 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