Skip to content

Commit eb58ebb

Browse files
authored
fix: support Linux v11 cipher prefix for Chromium decryption (#571)
1 parent 370c588 commit eb58ebb

8 files changed

Lines changed: 105 additions & 7 deletions

File tree

browser/chromium/decrypt.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ func decryptValue(masterKey, ciphertext []byte) ([]byte, error) {
1616

1717
version := crypto.DetectVersion(ciphertext)
1818
switch version {
19-
case crypto.CipherV10:
19+
case crypto.CipherV10, crypto.CipherV11:
20+
// v11 is Linux-only and shares v10's AES-CBC path; only the key source differs.
2021
return crypto.DecryptChromium(masterKey, ciphertext)
2122
case crypto.CipherV20:
2223
// TODO: implement App-Bound Encryption (Chrome 127+)

browser/chromium/decrypt_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ func TestDecryptValue_V10(t *testing.T) {
5252
}
5353
}
5454

55+
func TestDecryptValue_V11(t *testing.T) {
56+
plaintext := []byte("test_secret_value")
57+
testCBCIV := bytes.Repeat([]byte{0x20}, 16)
58+
cbcEncrypted, err := crypto.AESCBCEncrypt(testAESKey, testCBCIV, plaintext)
59+
require.NoError(t, err)
60+
v11Ciphertext := append([]byte("v11"), cbcEncrypted...)
61+
62+
got, err := decryptValue(testAESKey, v11Ciphertext)
63+
require.NoError(t, err)
64+
assert.Equal(t, plaintext, got)
65+
}
66+
5567
func TestDecryptValue_V20(t *testing.T) {
5668
// v20 App-Bound Encryption is not yet implemented.
5769
// TODO: add successful decryption cases when implemented.

crypto/crypto_linux.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,33 @@ package crypto
55
import (
66
"bytes"
77
"crypto/aes"
8+
"crypto/sha1"
89
)
910

1011
var chromiumCBCIV = bytes.Repeat([]byte{0x20}, aes.BlockSize)
1112

13+
// kEmptyKey is Chromium's decrypt-only fallback for data corrupted by a
14+
// KWallet race in Chrome ~89 (crbug.com/40055416). Matches the kEmptyKey
15+
// constant in os_crypt_linux.cc.
16+
var kEmptyKey = PBKDF2Key([]byte(""), []byte("saltysalt"), 1, 16, sha1.New)
17+
1218
const minCBCDataSize = versionPrefixLen + aes.BlockSize // "v10" + one AES block = 19 bytes minimum
1319

1420
func DecryptChromium(key, ciphertext []byte) ([]byte, error) {
1521
if len(ciphertext) < minCBCDataSize {
1622
return nil, errShortCiphertext
1723
}
18-
return AESCBCDecrypt(key, chromiumCBCIV, ciphertext[versionPrefixLen:])
24+
payload := ciphertext[versionPrefixLen:]
25+
26+
plaintext, err := AESCBCDecrypt(key, chromiumCBCIV, payload)
27+
if err == nil {
28+
return plaintext, nil
29+
}
30+
// Retry with kEmptyKey to recover crbug.com/40055416 data.
31+
if alt, altErr := AESCBCDecrypt(kEmptyKey, chromiumCBCIV, payload); altErr == nil {
32+
return alt, nil
33+
}
34+
return nil, err
1935
}
2036

2137
func DecryptDPAPI(_ []byte) ([]byte, error) {

crypto/crypto_linux_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//go:build linux
2+
3+
package crypto
4+
5+
import (
6+
"bytes"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestKEmptyKey_MatchesChromium pins the runtime-derived kEmptyKey to
14+
// Chromium's reference bytes in os_crypt_linux.cc.
15+
func TestKEmptyKey_MatchesChromium(t *testing.T) {
16+
want := []byte{
17+
0xd0, 0xd0, 0xec, 0x9c, 0x7d, 0x77, 0xd4, 0x3a,
18+
0xc5, 0x41, 0x87, 0xfa, 0x48, 0x18, 0xd1, 0x7f,
19+
}
20+
assert.Equal(t, want, kEmptyKey)
21+
assert.Len(t, kEmptyKey, 16)
22+
}
23+
24+
func TestDecryptChromium_EmptyKeyFallback(t *testing.T) {
25+
plaintext := []byte("legacy_kwallet_value")
26+
encrypted, err := AESCBCEncrypt(kEmptyKey, chromiumCBCIV, plaintext)
27+
require.NoError(t, err)
28+
ciphertext := append([]byte("v11"), encrypted...)
29+
30+
wrongKey := bytes.Repeat([]byte{0xAA}, 16)
31+
got, err := DecryptChromium(wrongKey, ciphertext)
32+
require.NoError(t, err)
33+
assert.Equal(t, plaintext, got)
34+
}
35+
36+
func TestDecryptChromium_ShortCiphertext(t *testing.T) {
37+
key := make([]byte, 16)
38+
_, err := DecryptChromium(key, []byte("v11short"))
39+
require.ErrorIs(t, err, errShortCiphertext)
40+
}

crypto/keyretriever/keyretriever_linux_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ func TestFallbackRetriever(t *testing.T) {
3434
assert.Equal(t, key, key2, "fallback key should be the same for any storage")
3535
}
3636

37+
// TestFallbackRetriever_MatchesChromiumKV10Key pins FallbackRetriever's
38+
// output to Chromium's kV10Key reference bytes in os_crypt_linux.cc.
39+
func TestFallbackRetriever_MatchesChromiumKV10Key(t *testing.T) {
40+
want := []byte{
41+
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53,
42+
0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78,
43+
}
44+
r := &FallbackRetriever{}
45+
key, err := r.RetrieveKey("", "")
46+
require.NoError(t, err)
47+
assert.Equal(t, want, key)
48+
}
49+
3750
func TestDefaultRetriever_Linux(t *testing.T) {
3851
r := DefaultRetriever()
3952
chain, ok := r.(*ChainRetriever)

crypto/version.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ const (
77
// CipherV10 is Chrome 80+ encryption (AES-GCM on Windows, AES-CBC on macOS/Linux).
88
CipherV10 CipherVersion = "v10"
99

10+
// CipherV11 is the Linux-only AES-CBC variant where the key comes from
11+
// libsecret / kwallet. Same algorithm as CipherV10; only the key source differs.
12+
CipherV11 CipherVersion = "v11"
13+
1014
// CipherV20 is Chrome 127+ App-Bound Encryption.
1115
CipherV20 CipherVersion = "v20"
1216

@@ -26,6 +30,8 @@ func DetectVersion(ciphertext []byte) CipherVersion {
2630
switch prefix {
2731
case "v10":
2832
return CipherV10
33+
case "v11":
34+
return CipherV11
2935
case "v20":
3036
return CipherV20
3137
default:
@@ -37,7 +43,7 @@ func DetectVersion(ciphertext []byte) CipherVersion {
3743
// Returns the ciphertext unchanged if no known prefix is found.
3844
func stripPrefix(ciphertext []byte) []byte {
3945
ver := DetectVersion(ciphertext)
40-
if ver == CipherV10 || ver == CipherV20 {
46+
if ver == CipherV10 || ver == CipherV11 || ver == CipherV20 {
4147
return ciphertext[versionPrefixLen:]
4248
}
4349
return ciphertext

crypto/version_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func TestDetectVersion(t *testing.T) {
1313
want CipherVersion
1414
}{
1515
{"v10 prefix", []byte("v10" + "encrypted_data"), CipherV10},
16+
{"v11 prefix", []byte("v11" + "encrypted_data"), CipherV11},
1617
{"v20 prefix", []byte("v20" + "encrypted_data"), CipherV20},
1718
{"no prefix (DPAPI)", []byte{0x01, 0x00, 0x00, 0x00}, CipherDPAPI},
1819
{"short input", []byte{0x01, 0x02}, CipherDPAPI},
@@ -34,6 +35,7 @@ func Test_stripPrefix(t *testing.T) {
3435
want []byte
3536
}{
3637
{"strips v10", []byte("v10PAYLOAD"), []byte("PAYLOAD")},
38+
{"strips v11", []byte("v11PAYLOAD"), []byte("PAYLOAD")},
3739
{"strips v20", []byte("v20PAYLOAD"), []byte("PAYLOAD")},
3840
{"keeps DPAPI unchanged", []byte{0x01, 0x00, 0x00}, []byte{0x01, 0x00, 0x00}},
3941
{"keeps short unchanged", []byte{0x01}, []byte{0x01}},

rfcs/003-chromium-encryption.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Every encrypted value begins with a 3-byte prefix that identifies the cipher ver
1717
| Prefix | Version | Meaning |
1818
|--------|---------|---------|
1919
| `v10` | CipherV10 | Chrome 80+ standard encryption (AES-GCM on Windows, AES-CBC on macOS/Linux) |
20+
| `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 |
2021
| `v20` | CipherV20 | Chrome 127+ App-Bound Encryption |
2122
| (none) | CipherDPAPI | Pre-Chrome 80 raw DPAPI encryption (Windows only, no prefix) |
2223

@@ -69,9 +70,12 @@ With the master key, each encrypted value is decrypted as AES-256-GCM:
6970

7071
## 5. Linux Encryption
7172

72-
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.
73+
Chromium on Linux has two obfuscation prefixes that share the same AES-128-CBC algorithm and PBKDF2 parameters — only the key source differs:
7374

74-
The master key is derived via PBKDF2 with different parameters than macOS:
75+
- **`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.).
76+
- **`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.
77+
78+
Both prefixes are derived through the same PBKDF2 parameters:
7579

7680
| Parameter | Value |
7781
|-----------|-------|
@@ -80,7 +84,11 @@ The master key is derived via PBKDF2 with different parameters than macOS:
8084
| Iterations | 1 |
8185
| Key length | 16 bytes (AES-128) |
8286

83-
Decryption uses the same AES-128-CBC scheme as macOS (fixed IV of 16 space bytes, PKCS5 padding).
87+
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.
88+
89+
**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.
90+
91+
**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.
8492

8593
## 6. v20 App-Bound Encryption (Chrome 127+)
8694

@@ -105,7 +113,7 @@ The high-level decryption path for any encrypted Chromium value:
105113

106114
1. **Detect version** -- inspect the first 3 bytes of the ciphertext
107115
2. **Route by version**:
108-
- `v10` -- strip prefix, call platform-specific decryption (AES-CBC on macOS/Linux, AES-GCM on Windows)
116+
- `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
109117
- `v20` -- not yet supported, return error
110118
- DPAPI (no prefix) -- call Windows `CryptUnprotectData` directly (Windows only; returns error on other platforms)
111119
3. **Return plaintext** -- the decrypted bytes are interpreted as a UTF-8 string

0 commit comments

Comments
 (0)