feat: decrypt Chromium v10/v11 across host OS (#605)

This commit is contained in:
Roger
2026-06-03 19:35:40 +08:00
committed by GitHub
parent c444314832
commit 2666b813cd
9 changed files with 129 additions and 120 deletions
+12 -8
View File
@@ -26,16 +26,20 @@ func decryptValue(masterKeys masterkey.MasterKeys, ciphertext []byte) ([]byte, e
version := crypto.DetectVersion(ciphertext)
switch version {
case crypto.CipherV10:
return crypto.DecryptChromium(masterKeys.V10, ciphertext)
// v10's cipher depends on the platform that sealed it: a 32-byte AES-256 key means GCM
// (Windows), a 16-byte AES-128 key means CBC (macOS/Linux). Dispatching on key length keeps
// cross-host decryption OS-independent: a 32-byte key dumped on Windows decrypts here on macOS.
if len(masterKeys.V10) == 32 {
return crypto.DecryptChromiumGCM(masterKeys.V10, ciphertext)
}
return crypto.DecryptChromiumCBC(masterKeys.V10, ciphertext)
case crypto.CipherV11:
// v11 is Linux-only and shares v10's AES-CBC path, but uses the keyring-derived kV11Key
// rather than the peanuts-derived kV10Key so a Linux profile with both prefixes needs
// distinct per-tier keys to decrypt everything.
return crypto.DecryptChromium(masterKeys.V11, ciphertext)
// v11 is Linux-only AES-CBC; same algorithm as Linux v10 but the key comes from the keyring
// (kV11Key) rather than peanuts (kV10Key), so both tiers need distinct keys.
return crypto.DecryptChromiumCBC(masterKeys.V11, ciphertext)
case crypto.CipherV20:
// v20 is cross-platform AES-GCM; routed through a dedicated function so Linux/macOS CI can
// exercise the same decryption path as Windows.
return crypto.DecryptChromiumV20(masterKeys.V20, ciphertext)
// v20 is cross-platform AES-GCM (Chrome 127+ ABE); same wire layout as Windows v10.
return crypto.DecryptChromiumGCM(masterKeys.V20, ciphertext)
case crypto.CipherV12:
// Chromium's SecretPortalKeyProvider (Flatpak / xdg-desktop-portal) — HKDF-SHA256 +
// AES-256-GCM with a secret retrieved via org.freedesktop.portal.Desktop. Recognized here
+18 -1
View File
@@ -13,7 +13,7 @@ import (
// TestDecryptValue_V20 is cross-platform because v20's ciphertext format
// (AES-GCM with 12-byte nonce) is platform-independent; only the key source
// (Chrome ABE on Windows) differs by OS. Running on Linux/macOS CI protects
// the routing in decryptValue + crypto.DecryptChromiumV20 from regressions.
// the routing in decryptValue + crypto.DecryptChromiumGCM from regressions.
func TestDecryptValue_V20(t *testing.T) {
plaintext := []byte("v20_test_value")
nonce := []byte("v20_nonce_12") // 12-byte AES-GCM nonce
@@ -34,3 +34,20 @@ func TestDecryptValue_V20_ShortCiphertext(t *testing.T) {
_, err := decryptValue(masterkey.MasterKeys{V20: testAESKey}, []byte("v20"))
require.Error(t, err)
}
// TestDecryptValue_V10_CrossHostGCM proves a v10 ciphertext sealed with a 32-byte
// AES-256 key (a Windows-origin dump) decrypts via decryptValue on any host — the
// core cross-OS guarantee. testAESKey is 16B, so this uses an explicit 32B key.
func TestDecryptValue_V10_CrossHostGCM(t *testing.T) {
key32 := []byte("0123456789abcdef0123456789abcdef") // 32 bytes
plaintext := []byte("v10_cross_host")
nonce := []byte("v10_nonce_12") // 12-byte AES-GCM nonce
gcm, err := crypto.AESGCMEncrypt(key32, nonce, plaintext)
require.NoError(t, err)
ciphertext := append([]byte("v10"), append(nonce, gcm...)...)
got, err := decryptValue(masterkey.MasterKeys{V10: key32}, ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, got)
}
+5 -3
View File
@@ -54,17 +54,19 @@ func encryptWithDPAPI(plaintext []byte) ([]byte, error) {
}
func TestDecryptValue_V10_Windows(t *testing.T) {
// Windows uses AES-GCM for v10 (not AES-CBC like macOS/Linux)
// Windows v10 is AES-256-GCM, so the master key is 32 bytes; decryptValue routes v10 by key
// length (32B→GCM). testAESKey is 16B, so this uses an explicit 32B key.
key32 := []byte("0123456789abcdef0123456789abcdef") // 32 bytes
plaintext := []byte("test_secret_value")
nonce := []byte("123456789012") // 12-byte nonce
gcmEncrypted, err := crypto.AESGCMEncrypt(testAESKey, nonce, plaintext)
gcmEncrypted, err := crypto.AESGCMEncrypt(key32, nonce, plaintext)
require.NoError(t, err)
// v10 format on Windows: "v10" + nonce(12) + encrypted
ciphertext := append([]byte("v10"), append(nonce, gcmEncrypted...)...)
got, err := decryptValue(masterkey.MasterKeys{V10: testAESKey}, ciphertext)
got, err := decryptValue(masterkey.MasterKeys{V10: key32}, ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, got)
}
+30 -8
View File
@@ -1,9 +1,11 @@
package crypto
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/des"
"crypto/sha1"
"fmt"
)
@@ -50,14 +52,16 @@ func DES3Decrypt(key, iv, ciphertext []byte) ([]byte, error) {
// same regardless of host OS (only Windows currently produces v20).
const gcmNonceSize = 12
// DecryptChromiumV20 decrypts a Chromium v20 (App-Bound Encryption) ciphertext.
// Format: "v20" prefix (3B) + nonce (12B) + AES-GCM(payload + 16B tag).
//
// Cross-platform: v20 is only produced by Chrome on Windows today, but the
// decryption math is platform-neutral. Keeping it here rather than in
// crypto_windows.go ensures the routing in browser/chromium/decrypt.go stays
// testable on Linux/macOS CI.
func DecryptChromiumV20(key, ciphertext []byte) ([]byte, error) {
// chromiumCBCIV is the fixed IV Chromium uses for AES-CBC v10/v11 (macOS/Linux).
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 kEmptyKey in os_crypt_linux.cc.
var kEmptyKey = PBKDF2Key([]byte(""), []byte("saltysalt"), 1, 16, sha1.New)
// DecryptChromiumGCM decrypts a prefixed AES-GCM blob: version(3B)+nonce(12B)+ct+tag.
// Used by Windows v10 (AES-256) and v20; the layout is identical and platform-neutral.
func DecryptChromiumGCM(key, ciphertext []byte) ([]byte, error) {
if len(ciphertext) < versionPrefixLen+gcmNonceSize {
return nil, errShortCiphertext
}
@@ -66,6 +70,24 @@ func DecryptChromiumV20(key, ciphertext []byte) ([]byte, error) {
return AESGCMDecrypt(key, nonce, payload)
}
// DecryptChromiumCBC decrypts a prefixed AES-CBC blob (version(3B)+ct) with Chromium's
// fixed IV, retrying with kEmptyKey to recover crbug.com/40055416 KWallet-corrupted data.
// Used by macOS/Linux v10 and Linux v11 (both AES-128).
func DecryptChromiumCBC(key, ciphertext []byte) ([]byte, error) {
if len(ciphertext) < versionPrefixLen+aes.BlockSize {
return nil, errShortCiphertext
}
payload := ciphertext[versionPrefixLen:]
plaintext, err := AESCBCDecrypt(key, chromiumCBCIV, payload)
if err == nil {
return plaintext, nil
}
if alt, altErr := AESCBCDecrypt(kEmptyKey, chromiumCBCIV, payload); altErr == nil {
return alt, nil
}
return nil, err
}
// AESGCMEncrypt encrypts data using AES-GCM mode.
func AESGCMEncrypt(key, nonce, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
-16
View File
@@ -2,22 +2,6 @@
package crypto
import (
"bytes"
"crypto/aes"
)
var chromiumCBCIV = bytes.Repeat([]byte{0x20}, aes.BlockSize)
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:])
}
func DecryptDPAPI(_ []byte) ([]byte, error) {
return nil, errDPAPINotSupported
}
-32
View File
@@ -2,38 +2,6 @@
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
}
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) {
return nil, errDPAPINotSupported
}
-40
View File
@@ -1,40 +0,0 @@
//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)
}
+64
View File
@@ -185,3 +185,67 @@ func TestDES3Decrypt_EmptyCiphertext(t *testing.T) {
_, err = DES3Decrypt(key, iv, []byte{})
require.Error(t, err)
}
// --- Cross-OS Chromium v10/v11 decryption ---
// DecryptChromiumGCM / DecryptChromiumCBC are platform-neutral, so these run on every
// GOOS and prove a key dumped on one platform decrypts that platform's data anywhere.
// key32 is the 32-byte AES-256-GCM tier (Windows v10 / v20); aesKey is the 16-byte
// AES-128-CBC tier (macOS/Linux v10/v11).
var key32 = bytes.Repeat([]byte(baseKey), 4)
func TestDecryptChromiumGCM_CrossPlatform(t *testing.T) {
plaintext := []byte("windows_v10_value")
gcm, err := AESGCMEncrypt(key32, aesGCMNonce, plaintext)
require.NoError(t, err)
ciphertext := append([]byte("v10"), append(aesGCMNonce, gcm...)...)
got, err := DecryptChromiumGCM(key32, ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, got)
}
func TestDecryptChromiumCBC_CrossPlatform(t *testing.T) {
plaintext := []byte("posix_v10_value")
enc, err := AESCBCEncrypt(aesKey, chromiumCBCIV, plaintext)
require.NoError(t, err)
ciphertext := append([]byte("v10"), enc...)
got, err := DecryptChromiumCBC(aesKey, ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, got)
}
// TestKEmptyKey_MatchesChromium pins the runtime-derived kEmptyKey to Chromium's
// reference bytes in os_crypt_linux.cc; now cross-platform since kEmptyKey is
// defined for every GOOS.
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 TestDecryptChromiumCBC_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 := DecryptChromiumCBC(wrongKey, ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, got)
}
func TestDecryptChromium_ShortCiphertext(t *testing.T) {
// GCM minimum is prefix(3)+nonce(12) = 15 bytes.
_, err := DecryptChromiumGCM(key32, []byte("v10nonce11"))
require.ErrorIs(t, err, errShortCiphertext)
// CBC minimum is prefix(3)+block(16) = 19 bytes.
_, err = DecryptChromiumCBC(aesKey, []byte("v11short"))
require.ErrorIs(t, err, errShortCiphertext)
}
-12
View File
@@ -6,18 +6,6 @@ import (
"github.com/moond4rk/hackbrowserdata/utils/winapi"
)
// gcmNonceSize is defined in crypto.go (cross-platform).
const minGCMDataSize = versionPrefixLen + gcmNonceSize // "v10" + nonce = 15 bytes minimum
func DecryptChromium(key, ciphertext []byte) ([]byte, error) {
if len(ciphertext) < minGCMDataSize {
return nil, errShortCiphertext
}
nonce := ciphertext[versionPrefixLen : versionPrefixLen+gcmNonceSize]
payload := ciphertext[versionPrefixLen+gcmNonceSize:]
return AESGCMDecrypt(key, nonce, payload)
}
// DecryptDPAPI decrypts a DPAPI-protected blob using the current user's
// master key. The actual Win32 call (and its DATA_BLOB / LocalFree dance)
// lives in utils/winapi so every package that needs a syscall handle