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
+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