diff --git a/browser/browser_darwin.go b/browser/browser_darwin.go index ed0a533..185bd4b 100644 --- a/browser/browser_darwin.go +++ b/browser/browser_darwin.go @@ -17,81 +17,81 @@ import ( func platformBrowsers() []types.BrowserConfig { return []types.BrowserConfig{ { - Key: "chrome", - Name: chromeName, - Kind: types.Chromium, - Storage: "Chrome", - UserDataDir: homeDir + "/Library/Application Support/Google/Chrome", + Key: "chrome", + Name: chromeName, + Kind: types.Chromium, + KeychainLabel: "Chrome", + UserDataDir: homeDir + "/Library/Application Support/Google/Chrome", }, { - Key: "edge", - Name: edgeName, - Kind: types.Chromium, - Storage: "Microsoft Edge", - UserDataDir: homeDir + "/Library/Application Support/Microsoft Edge", + Key: "edge", + Name: edgeName, + Kind: types.Chromium, + KeychainLabel: "Microsoft Edge", + UserDataDir: homeDir + "/Library/Application Support/Microsoft Edge", }, { - Key: "chromium", - Name: chromiumName, - Kind: types.Chromium, - Storage: "Chromium", - UserDataDir: homeDir + "/Library/Application Support/Chromium", + Key: "chromium", + Name: chromiumName, + Kind: types.Chromium, + KeychainLabel: "Chromium", + UserDataDir: homeDir + "/Library/Application Support/Chromium", }, { - Key: "chrome-beta", - Name: chromeBetaName, - Kind: types.Chromium, - Storage: "Chrome", - UserDataDir: homeDir + "/Library/Application Support/Google/Chrome Beta", + Key: "chrome-beta", + Name: chromeBetaName, + Kind: types.Chromium, + KeychainLabel: "Chrome", + UserDataDir: homeDir + "/Library/Application Support/Google/Chrome Beta", }, { - Key: "opera", - Name: operaName, - Kind: types.ChromiumOpera, - Storage: "Opera", - UserDataDir: homeDir + "/Library/Application Support/com.operasoftware.Opera", + Key: "opera", + Name: operaName, + Kind: types.ChromiumOpera, + KeychainLabel: "Opera", + UserDataDir: homeDir + "/Library/Application Support/com.operasoftware.Opera", }, { - Key: "opera-gx", - Name: operaGXName, - Kind: types.ChromiumOpera, - Storage: "Opera", - UserDataDir: homeDir + "/Library/Application Support/com.operasoftware.OperaGX", + Key: "opera-gx", + Name: operaGXName, + Kind: types.ChromiumOpera, + KeychainLabel: "Opera", + UserDataDir: homeDir + "/Library/Application Support/com.operasoftware.OperaGX", }, { - Key: "vivaldi", - Name: vivaldiName, - Kind: types.Chromium, - Storage: "Vivaldi", - UserDataDir: homeDir + "/Library/Application Support/Vivaldi", + Key: "vivaldi", + Name: vivaldiName, + Kind: types.Chromium, + KeychainLabel: "Vivaldi", + UserDataDir: homeDir + "/Library/Application Support/Vivaldi", }, { - Key: "coccoc", - Name: coccocName, - Kind: types.Chromium, - Storage: "CocCoc", - UserDataDir: homeDir + "/Library/Application Support/Coccoc", + Key: "coccoc", + Name: coccocName, + Kind: types.Chromium, + KeychainLabel: "CocCoc", + UserDataDir: homeDir + "/Library/Application Support/Coccoc", }, { - Key: "brave", - Name: braveName, - Kind: types.Chromium, - Storage: "Brave", - UserDataDir: homeDir + "/Library/Application Support/BraveSoftware/Brave-Browser", + Key: "brave", + Name: braveName, + Kind: types.Chromium, + KeychainLabel: "Brave", + UserDataDir: homeDir + "/Library/Application Support/BraveSoftware/Brave-Browser", }, { - Key: "yandex", - Name: yandexName, - Kind: types.ChromiumYandex, - Storage: "Yandex", - UserDataDir: homeDir + "/Library/Application Support/Yandex/YandexBrowser", + Key: "yandex", + Name: yandexName, + Kind: types.ChromiumYandex, + KeychainLabel: "Yandex", + UserDataDir: homeDir + "/Library/Application Support/Yandex/YandexBrowser", }, { - Key: "arc", - Name: arcName, - Kind: types.Chromium, - Storage: "Arc", - UserDataDir: homeDir + "/Library/Application Support/Arc/User Data", + Key: "arc", + Name: arcName, + Kind: types.Chromium, + KeychainLabel: "Arc", + UserDataDir: homeDir + "/Library/Application Support/Arc/User Data", }, { Key: "firefox", diff --git a/browser/browser_linux.go b/browser/browser_linux.go index abe2d59..2eb4398 100644 --- a/browser/browser_linux.go +++ b/browser/browser_linux.go @@ -10,53 +10,53 @@ import ( func platformBrowsers() []types.BrowserConfig { return []types.BrowserConfig{ { - Key: "chrome", - Name: chromeName, - Kind: types.Chromium, - Storage: "Chrome Safe Storage", - UserDataDir: homeDir + "/.config/google-chrome", + Key: "chrome", + Name: chromeName, + Kind: types.Chromium, + KeychainLabel: "Chrome Safe Storage", + UserDataDir: homeDir + "/.config/google-chrome", }, { - Key: "edge", - Name: edgeName, - Kind: types.Chromium, - Storage: "Chromium Safe Storage", - UserDataDir: homeDir + "/.config/microsoft-edge", + Key: "edge", + Name: edgeName, + Kind: types.Chromium, + KeychainLabel: "Chromium Safe Storage", + UserDataDir: homeDir + "/.config/microsoft-edge", }, { - Key: "chromium", - Name: chromiumName, - Kind: types.Chromium, - Storage: "Chromium Safe Storage", - UserDataDir: homeDir + "/.config/chromium", + Key: "chromium", + Name: chromiumName, + Kind: types.Chromium, + KeychainLabel: "Chromium Safe Storage", + UserDataDir: homeDir + "/.config/chromium", }, { - Key: "chrome-beta", - Name: chromeBetaName, - Kind: types.Chromium, - Storage: "Chrome Safe Storage", - UserDataDir: homeDir + "/.config/google-chrome-beta", + Key: "chrome-beta", + Name: chromeBetaName, + Kind: types.Chromium, + KeychainLabel: "Chrome Safe Storage", + UserDataDir: homeDir + "/.config/google-chrome-beta", }, { - Key: "opera", - Name: operaName, - Kind: types.ChromiumOpera, - Storage: "Chromium Safe Storage", - UserDataDir: homeDir + "/.config/opera", + Key: "opera", + Name: operaName, + Kind: types.ChromiumOpera, + KeychainLabel: "Chromium Safe Storage", + UserDataDir: homeDir + "/.config/opera", }, { - Key: "vivaldi", - Name: vivaldiName, - Kind: types.Chromium, - Storage: "Chrome Safe Storage", - UserDataDir: homeDir + "/.config/vivaldi", + Key: "vivaldi", + Name: vivaldiName, + Kind: types.Chromium, + KeychainLabel: "Chrome Safe Storage", + UserDataDir: homeDir + "/.config/vivaldi", }, { - Key: "brave", - Name: braveName, - Kind: types.Chromium, - Storage: "Brave Safe Storage", - UserDataDir: homeDir + "/.config/BraveSoftware/Brave-Browser", + Key: "brave", + Name: braveName, + Kind: types.Chromium, + KeychainLabel: "Brave Safe Storage", + UserDataDir: homeDir + "/.config/BraveSoftware/Brave-Browser", }, { Key: "firefox", diff --git a/browser/browser_windows.go b/browser/browser_windows.go index 58d65f2..c324735 100644 --- a/browser/browser_windows.go +++ b/browser/browser_windows.go @@ -13,14 +13,14 @@ func platformBrowsers() []types.BrowserConfig { Key: "chrome", Name: chromeName, Kind: types.Chromium, - Storage: "chrome", + WindowsABE: true, UserDataDir: homeDir + "/AppData/Local/Google/Chrome/User Data", }, { Key: "edge", Name: edgeName, Kind: types.Chromium, - Storage: "edge", + WindowsABE: true, UserDataDir: homeDir + "/AppData/Local/Microsoft/Edge/User Data", }, { @@ -33,7 +33,7 @@ func platformBrowsers() []types.BrowserConfig { Key: "chrome-beta", Name: chromeBetaName, Kind: types.Chromium, - Storage: "chrome-beta", + WindowsABE: true, UserDataDir: homeDir + "/AppData/Local/Google/Chrome Beta/User Data", }, { @@ -58,14 +58,14 @@ func platformBrowsers() []types.BrowserConfig { Key: "coccoc", Name: coccocName, Kind: types.Chromium, - Storage: "coccoc", + WindowsABE: true, UserDataDir: homeDir + "/AppData/Local/CocCoc/Browser/User Data", }, { Key: "brave", Name: braveName, Kind: types.Chromium, - Storage: "brave", + WindowsABE: true, UserDataDir: homeDir + "/AppData/Local/BraveSoftware/Brave-Browser/User Data", }, { diff --git a/browser/browser_windows_test.go b/browser/browser_windows_test.go index 71b8423..ef8944c 100644 --- a/browser/browser_windows_test.go +++ b/browser/browser_windows_test.go @@ -8,25 +8,38 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/winutil" ) -// TestWinUtilTableCoversABEBrowsers verifies that every Windows browser -// with ABE support in winutil.Table has a matching Storage key in -// platformBrowsers(). A mismatch means adding a new Chromium fork was -// incomplete: either the BrowserConfig row lacks Storage: "", or -// winutil.Table has a stale entry nobody retrieves keys for. +// TestWinUtilTableCoversABEBrowsers verifies that the set of Windows browsers +// with WindowsABE: true in platformBrowsers() exactly matches the set of +// winutil.Table entries that declare ABE support (keyed by BrowserConfig.Key == +// winutil.Entry.Key). A mismatch means adding a new Chromium fork was +// incomplete: either a BrowserConfig row is missing WindowsABE: true, or +// winutil.Table has a stale/missing entry. func TestWinUtilTableCoversABEBrowsers(t *testing.T) { - storages := make(map[string]struct{}) + abeConfigs := make(map[string]struct{}) for _, b := range platformBrowsers() { - if b.Storage != "" { - storages[b.Storage] = struct{}{} + if b.WindowsABE { + abeConfigs[b.Key] = struct{}{} } } + abeTable := make(map[string]struct{}) for key, entry := range winutil.Table { - if entry.ABE == winutil.ABENone { - continue + if entry.Key != key { + t.Errorf("winutil.Table[%q].Key = %q; map key and Entry.Key must match (winutil.Entry doc invariant)", key, entry.Key) } - if _, ok := storages[key]; !ok { - t.Errorf("winutil.Table[%q] declares ABE support but no BrowserConfig.Storage matches — either fix the table or set Storage: %q in platformBrowsers()", key, key) + if entry.ABE != winutil.ABENone { + abeTable[key] = struct{}{} + } + } + + for key := range abeTable { + if _, ok := abeConfigs[key]; !ok { + t.Errorf("winutil.Table[%q] declares ABE support but no BrowserConfig with Key %q sets WindowsABE: true — either fix the table or set WindowsABE: true in platformBrowsers()", key, key) + } + } + for key := range abeConfigs { + if _, ok := abeTable[key]; !ok { + t.Errorf("BrowserConfig with Key %q sets WindowsABE: true but winutil.Table[%q] is missing or declares no ABE — add the table entry", key, key) } } } diff --git a/browser/chromium/chromium.go b/browser/chromium/chromium.go index 96dee9d..710cd45 100644 --- a/browser/chromium/chromium.go +++ b/browser/chromium/chromium.go @@ -195,7 +195,16 @@ func (b *Browser) getMasterKeys(session *filemanager.Session) keyretriever.Maste break } - keys, err := keyretriever.NewMasterKeys(b.retrievers, b.cfg.Storage, localStateDst) + abeKey := "" + if b.cfg.WindowsABE { + abeKey = b.cfg.Key + } + hints := keyretriever.Hints{ + KeychainLabel: b.cfg.KeychainLabel, + WindowsABEKey: abeKey, + LocalStatePath: localStateDst, + } + keys, err := keyretriever.NewMasterKeys(b.retrievers, hints) if err != nil { installKey := b.BrowserName() + "|" + b.cfg.UserDataDir if _, already := warnedMasterKeyFailure.LoadOrStore(installKey, struct{}{}); !already { diff --git a/browser/chromium/chromium_test.go b/browser/chromium/chromium_test.go index e884b9a..df1a5d7 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -448,17 +448,15 @@ func TestLocalStatePath(t *testing.T) { // mockRetriever records the arguments passed to RetrieveKey. type mockRetriever struct { - storage string - localState string - key []byte - err error - called bool + hints keyretriever.Hints + key []byte + err error + called bool } -func (m *mockRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) { +func (m *mockRetriever) RetrieveKey(hints keyretriever.Hints) ([]byte, error) { m.called = true - m.storage = storage - m.localState = localStatePath + m.hints = hints return m.key, m.err } @@ -472,41 +470,41 @@ func TestGetMasterKeys(t *testing.T) { mkFile(dirNoLocalState, "Default", "History") tests := []struct { - name string - dir string - storage string - retriever keyretriever.KeyRetriever // nil → don't call SetKeyRetrievers - wantV10 []byte - wantStorage string - wantLocalState bool // whether localStatePath passed to retriever is non-empty + name string + dir string + keychainLabel string + retriever keyretriever.KeyRetriever // nil → don't call SetKeyRetrievers + wantV10 []byte + wantKeychainLabel string + wantLocalState bool // whether localStatePath passed to retriever is non-empty }{ { 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")}, - wantV10: []byte("fake-master-key"), - wantStorage: "Chrome", - wantLocalState: true, + name: "with Local State passes path to retriever", + dir: fixture.chrome, + keychainLabel: "Chrome", + retriever: &mockRetriever{key: []byte("fake-master-key")}, + wantV10: []byte("fake-master-key"), + wantKeychainLabel: "Chrome", + wantLocalState: true, }, { - name: "without Local State passes empty path", - dir: dirNoLocalState, - storage: "Chromium", - retriever: &mockRetriever{key: []byte("derived-key")}, - wantV10: []byte("derived-key"), - wantStorage: "Chromium", + name: "without Local State passes empty path", + dir: dirNoLocalState, + keychainLabel: "Chromium", + retriever: &mockRetriever{key: []byte("derived-key")}, + wantV10: []byte("derived-key"), + wantKeychainLabel: "Chromium", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { browsers, err := NewBrowsers(types.BrowserConfig{ - Name: "Test", Kind: types.Chromium, UserDataDir: tt.dir, Storage: tt.storage, + Name: "Test", Kind: types.Chromium, UserDataDir: tt.dir, KeychainLabel: tt.keychainLabel, }) require.NoError(t, err) require.NotEmpty(t, browsers) @@ -531,11 +529,11 @@ func TestGetMasterKeys(t *testing.T) { mock, ok := tt.retriever.(*mockRetriever) require.True(t, ok) assert.True(t, mock.called) - assert.Equal(t, tt.wantStorage, mock.storage) + assert.Equal(t, tt.wantKeychainLabel, mock.hints.KeychainLabel) if tt.wantLocalState { - assert.NotEmpty(t, mock.localState) + assert.NotEmpty(t, mock.hints.LocalStatePath) } else { - assert.Empty(t, mock.localState) + assert.Empty(t, mock.hints.LocalStatePath) } }) } @@ -553,7 +551,7 @@ func TestGetMasterKeys_AllTiersInvoked(t *testing.T) { v20mock := &mockRetriever{key: []byte("fake-v20-key")} browsers, err := NewBrowsers(types.BrowserConfig{ - Name: "Test", Kind: types.Chromium, UserDataDir: fixture.chrome, Storage: "Chrome", + Name: "Test", Kind: types.Chromium, UserDataDir: fixture.chrome, KeychainLabel: "Chrome", }) require.NoError(t, err) require.NotEmpty(t, browsers) @@ -573,8 +571,44 @@ func TestGetMasterKeys_AllTiersInvoked(t *testing.T) { 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") + assert.Equal(t, "Chrome", m.hints.KeychainLabel) + assert.NotEmpty(t, m.hints.LocalStatePath, "Local State path must be passed to every retriever") + } +} + +// TestGetMasterKeys_WindowsABEThreading pins cfg.WindowsABE → hints.WindowsABEKey threading. A +// regression here silently disables Windows ABE decryption with no dev-box-test signal — only the +// windows-tunnel sandbox 574-cookie regression would catch it — so it must be pinned at unit level. +func TestGetMasterKeys_WindowsABEThreading(t *testing.T) { + tests := []struct { + name string + key string + windowsABE bool + wantABEKey string + }{ + {"WindowsABE=true threads cfg.Key into hints.WindowsABEKey", "chrome", true, "chrome"}, + {"WindowsABE=false leaves hints.WindowsABEKey empty", "opera", false, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockRetriever{key: []byte("k")} + browsers, err := NewBrowsers(types.BrowserConfig{ + Key: tt.key, Name: "Test", Kind: types.Chromium, + UserDataDir: fixture.chrome, WindowsABE: tt.windowsABE, + }) + require.NoError(t, err) + require.NotEmpty(t, browsers) + + b := browsers[0] + b.SetKeyRetrievers(keyretriever.Retrievers{V20: mock}) + + session, err := filemanager.NewSession() + require.NoError(t, err) + defer session.Cleanup() + + b.getMasterKeys(session) + assert.Equal(t, tt.wantABEKey, mock.hints.WindowsABEKey) + }) } } @@ -605,7 +639,7 @@ func TestExtract(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { browsers, err := NewBrowsers(types.BrowserConfig{ - Name: "Test", Kind: types.Chromium, UserDataDir: dir, Storage: "Chrome", + Name: "Test", Kind: types.Chromium, UserDataDir: dir, KeychainLabel: "Chrome", }) require.NoError(t, err) require.Len(t, browsers, 1) diff --git a/crypto/keyretriever/abe_windows.go b/crypto/keyretriever/abe_windows.go index 6275ef9..2573e50 100644 --- a/crypto/keyretriever/abe_windows.go +++ b/crypto/keyretriever/abe_windows.go @@ -25,17 +25,15 @@ var errNoABEKey = errors.New("abe: Local State has no app_bound_encrypted_key") type ABERetriever struct{} -func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) { - // Non-ABE Chromium forks (Opera/Vivaldi/Yandex/...) call this with an empty storage key; pre-v20 - // Chrome profiles have no app_bound_encrypted_key in Local State. Both are "ABE not applicable" — - // return (nil, nil) so ChainRetriever falls through to DPAPI silently instead of emitting a Warnf - // for every non-ABE browser. - browserKey := strings.TrimSpace(storage) +func (r *ABERetriever) RetrieveKey(hints Hints) ([]byte, error) { + // Non-ABE forks (Opera/Vivaldi/Yandex) supply no WindowsABEKey — treat as "not applicable". + // (Pre-v20 Chrome takes the errNoABEKey path below.) + browserKey := strings.TrimSpace(hints.WindowsABEKey) if browserKey == "" { return nil, nil } - encKey, err := loadEncryptedKey(localStatePath) + encKey, err := loadEncryptedKey(hints.LocalStatePath) if errors.Is(err, errNoABEKey) { return nil, nil } diff --git a/crypto/keyretriever/keyretriever.go b/crypto/keyretriever/keyretriever.go index 29a5f2a..2929331 100644 --- a/crypto/keyretriever/keyretriever.go +++ b/crypto/keyretriever/keyretriever.go @@ -20,13 +20,16 @@ import ( // has no storage lookup. var errStorageNotFound = errors.New("not found in credential store") //nolint:unused // only used on darwin and linux -// KeyRetriever retrieves the master encryption key for a Chromium-based browser. Each platform has -// different implementations: -// - macOS: Keychain access (security command) or gcoredump exploit -// - Windows: DPAPI decryption of Local State file -// - Linux: D-Bus Secret Service or fallback to "peanuts" password +// Hints bundles inputs for KeyRetriever; each retriever reads only the field that applies to it. +type Hints struct { + KeychainLabel string // macOS Keychain account / Linux D-Bus Secret Service label + WindowsABEKey string // Windows ABE browser key (e.g. "chrome"); "" → ABE not applicable + LocalStatePath string // path to (temp-copied) Local State JSON; only used on Windows +} + +// KeyRetriever retrieves the master encryption key for a Chromium-based browser. type KeyRetriever interface { - RetrieveKey(storage, localStatePath string) ([]byte, error) + RetrieveKey(hints Hints) ([]byte, error) } // ChainRetriever tries multiple retrievers in order, returning the first success. Used on macOS @@ -40,10 +43,10 @@ func NewChain(retrievers ...KeyRetriever) KeyRetriever { return &ChainRetriever{retrievers: retrievers} } -func (c *ChainRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) { +func (c *ChainRetriever) RetrieveKey(hints Hints) ([]byte, error) { var errs []error for _, r := range c.retrievers { - key, err := r.RetrieveKey(storage, localStatePath) + key, err := r.RetrieveKey(hints) if err == nil && len(key) > 0 { return key, nil } diff --git a/crypto/keyretriever/keyretriever_darwin.go b/crypto/keyretriever/keyretriever_darwin.go index 3133332..51eafa0 100644 --- a/crypto/keyretriever/keyretriever_darwin.go +++ b/crypto/keyretriever/keyretriever_darwin.go @@ -43,7 +43,7 @@ type GcoredumpRetriever struct { // through to the next retriever silently. The most common failure ("requires root privileges") // is documented expected behavior, not a warning-worthy condition; surfacing it on every profile // would drown out genuine warnings. The same pattern is used by ABERetriever (see abe_windows.go). -func (r *GcoredumpRetriever) RetrieveKey(storage, _ string) ([]byte, error) { +func (r *GcoredumpRetriever) RetrieveKey(hints Hints) ([]byte, error) { r.once.Do(func() { r.records, r.err = DecryptKeychainRecords() }) @@ -52,7 +52,7 @@ func (r *GcoredumpRetriever) RetrieveKey(storage, _ string) ([]byte, error) { return nil, nil //nolint:nilerr // intentional silent fallthrough } - key, err := findStorageKey(r.records, storage) + key, err := findStorageKey(r.records, hints.KeychainLabel) if err != nil { log.Debugf("gcoredump: %v", err) return nil, nil //nolint:nilerr // intentional silent fallthrough @@ -96,7 +96,7 @@ type KeychainPasswordRetriever struct { err error } -func (r *KeychainPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, error) { +func (r *KeychainPasswordRetriever) RetrieveKey(hints Hints) ([]byte, error) { if r.Password == "" { return nil, fmt.Errorf("keychain password not provided") } @@ -108,7 +108,7 @@ func (r *KeychainPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, erro return nil, r.err } - return findStorageKey(r.records, storage) + return findStorageKey(r.records, hints.KeychainLabel) } // SecurityCmdRetriever uses macOS `security` CLI to query Keychain. @@ -124,7 +124,8 @@ type securityResult struct { err error } -func (r *SecurityCmdRetriever) RetrieveKey(storage, _ string) ([]byte, error) { +func (r *SecurityCmdRetriever) RetrieveKey(hints Hints) ([]byte, error) { + storage := hints.KeychainLabel r.mu.Lock() defer r.mu.Unlock() diff --git a/crypto/keyretriever/keyretriever_darwin_test.go b/crypto/keyretriever/keyretriever_darwin_test.go index a91e398..92cdea6 100644 --- a/crypto/keyretriever/keyretriever_darwin_test.go +++ b/crypto/keyretriever/keyretriever_darwin_test.go @@ -34,7 +34,7 @@ func TestFindStorageKey_NotFound(t *testing.T) { func TestKeychainPasswordRetriever_EmptyPassword(t *testing.T) { r := &KeychainPasswordRetriever{Password: ""} - key, err := r.RetrieveKey("Chrome", "") + key, err := r.RetrieveKey(Hints{KeychainLabel: "Chrome"}) require.Error(t, err) assert.Nil(t, key) assert.Contains(t, err.Error(), "keychain password not provided") diff --git a/crypto/keyretriever/keyretriever_linux.go b/crypto/keyretriever/keyretriever_linux.go index 9284e46..7fa760f 100644 --- a/crypto/keyretriever/keyretriever_linux.go +++ b/crypto/keyretriever/keyretriever_linux.go @@ -21,7 +21,8 @@ var linuxParams = pbkdf2Params{ // DBusRetriever queries GNOME Keyring / KDE Wallet via D-Bus Secret Service. type DBusRetriever struct{} -func (r *DBusRetriever) RetrieveKey(storage, _ string) ([]byte, error) { +func (r *DBusRetriever) RetrieveKey(hints Hints) ([]byte, error) { + storage := hints.KeychainLabel conn, err := dbus.SessionBus() if err != nil { return nil, fmt.Errorf("dbus session: %w", err) @@ -74,7 +75,7 @@ func (r *DBusRetriever) RetrieveKey(storage, _ string) ([]byte, error) { // the "v10" prefix when no keyring is available (headless servers, Docker, CI). type PosixRetriever struct{} -func (r *PosixRetriever) RetrieveKey(_, _ string) ([]byte, error) { +func (r *PosixRetriever) RetrieveKey(_ Hints) ([]byte, error) { return linuxParams.deriveKey([]byte("peanuts")), nil } diff --git a/crypto/keyretriever/keyretriever_linux_test.go b/crypto/keyretriever/keyretriever_linux_test.go index 548ba0a..e167d9b 100644 --- a/crypto/keyretriever/keyretriever_linux_test.go +++ b/crypto/keyretriever/keyretriever_linux_test.go @@ -12,7 +12,7 @@ import ( func TestPosixRetriever(t *testing.T) { r := &PosixRetriever{} - key, err := r.RetrieveKey("Chrome", "") + key, err := r.RetrieveKey(Hints{KeychainLabel: "Chrome"}) require.NoError(t, err) assert.Equal(t, linuxParams.deriveKey([]byte("peanuts")), key) assert.Len(t, key, linuxParams.keySize) @@ -27,9 +27,9 @@ func TestPosixRetriever(t *testing.T) { } assert.False(t, allZero, "derived key should not be all zeros") - // "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", "") + // "peanuts" is a hardcoded password, so the result should be the same regardless of the hints + // or number of calls. + key2, err := r.RetrieveKey(Hints{KeychainLabel: "Brave"}) require.NoError(t, err) assert.Equal(t, key, key2, "kV10Key should be constant across any storage label") } @@ -42,7 +42,7 @@ func TestPosixRetriever_MatchesChromiumKV10Key(t *testing.T) { 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78, } r := &PosixRetriever{} - key, err := r.RetrieveKey("", "") + key, err := r.RetrieveKey(Hints{}) require.NoError(t, err) assert.Equal(t, want, key) } diff --git a/crypto/keyretriever/keyretriever_test.go b/crypto/keyretriever/keyretriever_test.go index d823331..1c2c626 100644 --- a/crypto/keyretriever/keyretriever_test.go +++ b/crypto/keyretriever/keyretriever_test.go @@ -13,7 +13,7 @@ type mockRetriever struct { err error } -func (m *mockRetriever) RetrieveKey(_, _ string) ([]byte, error) { +func (m *mockRetriever) RetrieveKey(_ Hints) ([]byte, error) { return m.key, m.err } @@ -22,7 +22,7 @@ func TestChainRetriever_FirstSuccess(t *testing.T) { &mockRetriever{key: []byte("first-key")}, &mockRetriever{key: []byte("second-key")}, ) - key, err := chain.RetrieveKey("Chrome", "") + key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"}) require.NoError(t, err) assert.Equal(t, []byte("first-key"), key) } @@ -32,7 +32,7 @@ func TestChainRetriever_FallbackOnError(t *testing.T) { &mockRetriever{err: errors.New("first failed")}, &mockRetriever{key: []byte("fallback-key")}, ) - key, err := chain.RetrieveKey("Chrome", "") + key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"}) require.NoError(t, err) assert.Equal(t, []byte("fallback-key"), key) } @@ -42,7 +42,7 @@ func TestChainRetriever_AllFail(t *testing.T) { &mockRetriever{err: errors.New("first failed")}, &mockRetriever{err: errors.New("second failed")}, ) - key, err := chain.RetrieveKey("Chrome", "") + key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"}) require.Error(t, err) assert.Nil(t, key) assert.Contains(t, err.Error(), "all retrievers failed") @@ -56,14 +56,14 @@ func TestChainRetriever_SkipEmptyKey(t *testing.T) { &mockRetriever{key: nil, err: nil}, &mockRetriever{key: []byte("real-key")}, ) - key, err := chain.RetrieveKey("Chrome", "") + key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"}) require.NoError(t, err) assert.Equal(t, []byte("real-key"), key) } func TestChainRetriever_Empty(t *testing.T) { chain := NewChain() - key, err := chain.RetrieveKey("Chrome", "") + key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"}) require.Error(t, err) assert.Nil(t, key) } diff --git a/crypto/keyretriever/keyretriever_windows.go b/crypto/keyretriever/keyretriever_windows.go index 14a0054..fe6ed7c 100644 --- a/crypto/keyretriever/keyretriever_windows.go +++ b/crypto/keyretriever/keyretriever_windows.go @@ -16,8 +16,8 @@ import ( // and decrypts it using Windows DPAPI. type DPAPIRetriever struct{} -func (r *DPAPIRetriever) RetrieveKey(_, localStatePath string) ([]byte, error) { - data, err := os.ReadFile(localStatePath) +func (r *DPAPIRetriever) RetrieveKey(hints Hints) ([]byte, error) { + data, err := os.ReadFile(hints.LocalStatePath) if err != nil { return nil, fmt.Errorf("read Local State: %w", err) } diff --git a/crypto/keyretriever/masterkeys.go b/crypto/keyretriever/masterkeys.go index 0e24211..f088cda 100644 --- a/crypto/keyretriever/masterkeys.go +++ b/crypto/keyretriever/masterkeys.go @@ -5,47 +5,26 @@ import ( "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. +// MasterKeys holds per-cipher-version Chromium master keys. A profile may carry mixed prefixes +// (Chrome 127+ on Windows mixes v10+v20; Linux can mix v10+v11), so each tier must be populated +// independently for lossless decryption. A nil tier means that cipher version cannot be decrypted. 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). +// Retrievers is the per-tier retriever configuration; unused slots are 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) { +// NewMasterKeys fetches each non-nil tier in r and returns the assembled MasterKeys with per-tier +// errors joined. A retriever returning (nil, nil) signals "not applicable" and contributes no key +// silently. This function never logs; the caller decides severity. +func NewMasterKeys(r Retrievers, hints Hints) (MasterKeys, error) { var keys MasterKeys var errs []error @@ -61,7 +40,7 @@ func NewMasterKeys(r Retrievers, storage, localStatePath string) (MasterKeys, er if t.r == nil { continue } - k, err := t.r.RetrieveKey(storage, localStatePath) + k, err := t.r.RetrieveKey(hints) if err != nil { errs = append(errs, fmt.Errorf("%s: %w", t.name, err)) continue diff --git a/crypto/keyretriever/masterkeys_test.go b/crypto/keyretriever/masterkeys_test.go index 1b79932..bf90403 100644 --- a/crypto/keyretriever/masterkeys_test.go +++ b/crypto/keyretriever/masterkeys_test.go @@ -10,20 +10,18 @@ import ( ) // recordingRetriever captures call count and arguments so tests can verify each tier's retriever -// is invoked exactly once with the expected storage and localStatePath. +// is invoked exactly once with the expected hints. type recordingRetriever struct { key []byte err error - calls int - gotStorage string - gotPath string + calls int + gotHints Hints } -func (r *recordingRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) { +func (r *recordingRetriever) RetrieveKey(hints Hints) ([]byte, error) { r.calls++ - r.gotStorage = storage - r.gotPath = localStatePath + r.gotHints = hints return r.key, r.err } @@ -115,7 +113,7 @@ func TestNewMasterKeys_Matrix(t *testing.T) { r.V20 = tt.v20 } - keys, err := NewMasterKeys(r, "chrome", "/tmp/Local State") + keys, err := NewMasterKeys(r, Hints{KeychainLabel: "chrome", LocalStatePath: "/tmp/Local State"}) assert.Equal(t, tt.wantV10, keys.V10) assert.Equal(t, tt.wantV11, keys.V11) assert.Equal(t, tt.wantV20, keys.V20) @@ -136,8 +134,7 @@ func TestNewMasterKeys_Matrix(t *testing.T) { 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) + assert.Equal(t, Hints{KeychainLabel: "chrome", LocalStatePath: "/tmp/Local State"}, mock.gotHints) } }) } @@ -145,7 +142,7 @@ func TestNewMasterKeys_Matrix(t *testing.T) { 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") + keys, err := NewMasterKeys(Retrievers{}, Hints{KeychainLabel: "chrome", LocalStatePath: "/tmp/Local State"}) require.NoError(t, err) assert.Nil(t, keys.V10) assert.Nil(t, keys.V11) @@ -156,14 +153,14 @@ 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", "") + keys, err := NewMasterKeys(Retrievers{V10: r}, Hints{KeychainLabel: "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) + assert.Equal(t, Hints{KeychainLabel: "Chrome"}, r.gotHints) } func TestNewMasterKeys_ErrorWrapping(t *testing.T) { @@ -172,7 +169,7 @@ func TestNewMasterKeys_ErrorWrapping(t *testing.T) { sentinel := errors.New("sentinel") r := Retrievers{V20: &recordingRetriever{err: sentinel}} - _, err := NewMasterKeys(r, "chrome", "") + _, err := NewMasterKeys(r, Hints{KeychainLabel: "chrome"}) require.Error(t, err) assert.ErrorIs(t, err, sentinel, "errors.Is should find wrapped sentinel error") } diff --git a/rfcs/001-project-architecture.md b/rfcs/001-project-architecture.md index 8107e1b..4da6ddb 100644 --- a/rfcs/001-project-architecture.md +++ b/rfcs/001-project-architecture.md @@ -75,7 +75,7 @@ See `types/category.go` for the authoritative enum definition. ### 4.2 BrowserConfig -`BrowserConfig` is the declarative, platform-specific browser definition containing: Key (CLI matching), Name (display), Kind (engine), Storage (keychain label), UserDataDir (data path). +`BrowserConfig` is the declarative, platform-specific browser definition containing: Key (CLI matching; also the Windows ABE / winutil.Table identifier when WindowsABE is true), Name (display), Kind (engine), KeychainLabel (macOS Keychain / Linux D-Bus Secret Service label), WindowsABE (bool — enable Windows App-Bound Encryption v20 path), UserDataDir (data path). ### 4.3 Browser Selection Flow diff --git a/rfcs/006-key-retrieval-mechanisms.md b/rfcs/006-key-retrieval-mechanisms.md index 72a607a..d2881c7 100644 --- a/rfcs/006-key-retrieval-mechanisms.md +++ b/rfcs/006-key-retrieval-mechanisms.md @@ -20,10 +20,17 @@ For Chromium encryption details (cipher versions, AES-CBC/GCM), see [RFC-003](00 ## 2. KeyRetriever Interface -The interface takes two parameters: +The interface takes a single `Hints` struct so caller intent is explicit rather than positional: -- **`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). +```go +type Hints struct { + KeychainLabel string // macOS Keychain account / Linux D-Bus Secret Service item label (e.g. "Chrome", "Chrome Safe Storage") + WindowsABEKey string // Windows ABE browser key used by ABERetriever to locate the elevation-service COM interface (e.g. "chrome", "edge"). "" → ABE not applicable; ABERetriever returns (nil, nil) silently. + LocalStatePath string // path to Local State JSON. Only used on Windows (DPAPI + ABE both read it). +} +``` + +Each retriever reads only the field that applies to its platform; the others are ignored. Callers populate `Hints` from `BrowserConfig`: `KeychainLabel` is copied directly, `WindowsABEKey` is set to `cfg.Key` when `cfg.WindowsABE` is true. The return value is the **ready-to-use decryption key** — either the raw AES key (Windows) or the PBKDF2-derived key (macOS/Linux). @@ -67,11 +74,11 @@ All macOS strategies produce a raw password string from the keychain. This is de | Key length | 16 bytes (AES-128) | | | Hash | HMAC-SHA1 | | -### 3.4 Storage Labels +### 3.4 Keychain Labels Each browser identifies its Keychain entry with a short account string — typically the browser's base name (`"Chrome"`, `"Brave"`, `"Arc"`). Edge uses `"Microsoft Edge"`. Related variants share labels rather than defining their own: Chrome Beta aliases onto `"Chrome"`, Opera GX aliases onto `"Opera"`. -The authoritative mapping lives in the `Storage` field of each entry in `platformBrowsers()` (`browser/browser_darwin.go`). +The authoritative mapping lives in the `KeychainLabel` field of each entry in `platformBrowsers()` (`browser/browser_darwin.go`). ## 4. Windows Key Retrieval @@ -119,7 +126,7 @@ Windows populates two slots of the `keyretriever.Retrievers` struct — V10 (leg **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. +**Non-ABE Chromium forks** (Opera, Vivaldi, Yandex, 360, QQ, Sogou) omit `WindowsABE` in `platformBrowsers()` (default false). The caller leaves `Hints.WindowsABEKey` empty, and `ABERetriever` returns `(nil, nil)` for empty `WindowsABEKey`, 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 @@ -157,11 +164,11 @@ Both strategies produce a password, derived via PBKDF2 with notably weaker param A single iteration makes PBKDF2 essentially a keyed HMAC — no real key-stretching. Combined with the well-known fallback password `"peanuts"`, Linux Chromium encryption is trivial to break without the keyring. -### 5.4 Storage Labels +### 5.4 Keychain Labels Linux D-Bus labels follow a `" Safe Storage"` convention, but many browsers alias onto a small shared set rather than defining their own. The three distinct labels are `"Chrome Safe Storage"`, `"Chromium Safe Storage"`, and `"Brave Safe Storage"` — everything else maps onto one of these. -The authoritative mapping lives in the `Storage` field of each entry in `platformBrowsers()` (`browser/browser_linux.go`). +The authoritative mapping lives in the `KeychainLabel` field of each entry in `platformBrowsers()` (`browser/browser_linux.go`). ## 6. Platform Summary diff --git a/rfcs/010-chrome-abe-integration.md b/rfcs/010-chrome-abe-integration.md index ae60cef..56a8a05 100644 --- a/rfcs/010-chrome-abe-integration.md +++ b/rfcs/010-chrome-abe-integration.md @@ -295,7 +295,7 @@ Three steps. 2. **Mine IIDs from TypeLib** — the interface IIDs live in the TypeLib resource of `\Application\\elevation_service.exe`. PowerShell + `ITypeLib.GetTypeInfo` enumerates them. Map `IElevator` → v1 IID, `IElevator2` → v2 IID (absent for older vendors). 3. **Determine vtable slot** — count `IElevator` methods in the TypeLib. Chrome-family has 3 methods (slot 5). Edge prepends 3 placeholders (slot 8). Avast extends the interface further (slot 13). -Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/browser_meta_windows.go` (add a matching `winutil.Entry` with the right `ABEKind` and install-path fallbacks), `browser/browser_windows.go` (set `Storage: ""` for the new `BrowserConfig`), then `make payload-clean && make build-windows` and redeploy. +Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/browser_meta_windows.go` (add a matching `winutil.Entry` with the right `ABEKind` and install-path fallbacks; `Entry.Key` must equal the `BrowserConfig.Key` of the matching ABE-enabled config), `browser/browser_windows.go` (set `WindowsABE: true` on the new `BrowserConfig` so its `Key` doubles as the ABE / winutil.Table identifier), then `make payload-clean && make build-windows` and redeploy. ## 12. Known issues & future work diff --git a/types/category.go b/types/category.go index 8661311..23efe96 100644 --- a/types/category.go +++ b/types/category.go @@ -83,11 +83,12 @@ const ( // BrowserConfig holds the declarative configuration for a browser installation. type BrowserConfig struct { - Key string // lookup key: "chrome", "edge", "firefox" - Name string // display name: "Chrome", "Edge", "Firefox" - Kind BrowserKind // engine type - Storage string // macOS/Linux: keychain/GNOME label. Windows: ABE browser key (triggers reflective injection when populated). - UserDataDir string // base browser directory + Key string // lookup key; doubles as the Windows ABE / winutil.Table key when WindowsABE is true + Name string // display name + Kind BrowserKind // engine type + KeychainLabel string // macOS Keychain account / Linux D-Bus Secret Service label; "" = none + WindowsABE bool // enable Windows App-Bound Encryption v20 (reflective injection) + UserDataDir string // base browser directory } // BrowserData holds all extracted browser data with typed slices. diff --git a/utils/winutil/browser_meta_windows.go b/utils/winutil/browser_meta_windows.go index 241c225..d261938 100644 --- a/utils/winutil/browser_meta_windows.go +++ b/utils/winutil/browser_meta_windows.go @@ -35,9 +35,9 @@ const ( // Entry is the per-browser Windows metadata record. // -// Key must match browser.BrowserConfig.Storage so retrievers and path -// resolvers share a single lookup identifier. CLSID/IID bytes are *not* -// stored here; see the package doc for why. +// Key must match browser.BrowserConfig.Key for every config that sets +// WindowsABE: true, so retrievers and path resolvers share a single lookup +// identifier. CLSID/IID bytes are *not* stored here; see the package doc for why. type Entry struct { Key string ExeName string diff --git a/utils/winutil/browser_path_windows.go b/utils/winutil/browser_path_windows.go index da7b08e..a15776f 100644 --- a/utils/winutil/browser_path_windows.go +++ b/utils/winutil/browser_path_windows.go @@ -29,7 +29,7 @@ var ErrExecutableNotFound = errors.New("browser executable not found") // is not running and the registry is missing the entry). // // browserKey must match an Entry in Table; keys align with -// browser.BrowserConfig.Storage. +// browser.BrowserConfig.Key (for configs that set WindowsABE: true). func ExecutablePath(browserKey string) (string, error) { entry, ok := Table[browserKey] if !ok {