Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions browser/chromium/decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
moonD4rk marked this conversation as resolved.
Comment on lines +32 to +35
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
Expand Down
19 changes: 18 additions & 1 deletion browser/chromium/decrypt_v20_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
8 changes: 5 additions & 3 deletions browser/chromium/decrypt_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
38 changes: 30 additions & 8 deletions crypto/crypto.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package crypto

import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/des"
"crypto/sha1"
"fmt"
)

Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down
16 changes: 0 additions & 16 deletions crypto/crypto_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 0 additions & 32 deletions crypto/crypto_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 0 additions & 40 deletions crypto/crypto_linux_test.go

This file was deleted.

64 changes: 64 additions & 0 deletions crypto/crypto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 0 additions & 12 deletions crypto/crypto_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading