diff --git a/browser/chromium/decrypt.go b/browser/chromium/decrypt.go index 1d77905..f331f4a 100644 --- a/browser/chromium/decrypt.go +++ b/browser/chromium/decrypt.go @@ -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 diff --git a/browser/chromium/decrypt_v20_test.go b/browser/chromium/decrypt_v20_test.go index 546ff10..5c3306e 100644 --- a/browser/chromium/decrypt_v20_test.go +++ b/browser/chromium/decrypt_v20_test.go @@ -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) +} diff --git a/browser/chromium/decrypt_windows_test.go b/browser/chromium/decrypt_windows_test.go index f2a4d5f..8002138 100644 --- a/browser/chromium/decrypt_windows_test.go +++ b/browser/chromium/decrypt_windows_test.go @@ -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) } diff --git a/crypto/crypto.go b/crypto/crypto.go index 1602e61..1d87976 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -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) diff --git a/crypto/crypto_darwin.go b/crypto/crypto_darwin.go index c739daa..142ec34 100644 --- a/crypto/crypto_darwin.go +++ b/crypto/crypto_darwin.go @@ -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 } diff --git a/crypto/crypto_linux.go b/crypto/crypto_linux.go index 460f037..4dd23c1 100644 --- a/crypto/crypto_linux.go +++ b/crypto/crypto_linux.go @@ -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 } diff --git a/crypto/crypto_linux_test.go b/crypto/crypto_linux_test.go deleted file mode 100644 index ecb3885..0000000 --- a/crypto/crypto_linux_test.go +++ /dev/null @@ -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) -} diff --git a/crypto/crypto_test.go b/crypto/crypto_test.go index 249d446..ccb09b5 100644 --- a/crypto/crypto_test.go +++ b/crypto/crypto_test.go @@ -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) +} diff --git a/crypto/crypto_windows.go b/crypto/crypto_windows.go index 7f36b39..7b2170b 100644 --- a/crypto/crypto_windows.go +++ b/crypto/crypto_windows.go @@ -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