Skip to content

Commit a0b4412

Browse files
authored
fix: share key retriever across all browsers to avoid repeated prompts (#560)
* fix: share key retriever across all browsers to avoid repeated password prompts
1 parent ccc8643 commit a0b4412

8 files changed

Lines changed: 355 additions & 80 deletions

File tree

browser/browser.go

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/moond4rk/hackbrowserdata/browser/chromium"
1010
"github.com/moond4rk/hackbrowserdata/browser/firefox"
11+
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
1112
"github.com/moond4rk/hackbrowserdata/log"
1213
"github.com/moond4rk/hackbrowserdata/types"
1314
)
@@ -34,19 +35,27 @@ func PickBrowsers(opts PickOptions) ([]Browser, error) {
3435
return pickFromConfigs(platformBrowsers(), opts)
3536
}
3637

37-
// pickFromConfigs is the testable core of PickBrowsers.
38+
// pickFromConfigs is the testable core of PickBrowsers. It iterates over
39+
// platform browser configs, discovers installed profiles, and injects a
40+
// shared key retriever into Chromium browsers for decryption.
3841
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
3942
name := strings.ToLower(opts.Name)
4043
if name == "" {
4144
name = "all"
4245
}
4346

47+
// Create a single key retriever shared across all Chromium browsers.
48+
// On macOS this avoids repeated password prompts; on other platforms
49+
// it's harmless (DPAPI reads Local State per-profile, D-Bus is stateless).
50+
retriever := keyretriever.DefaultRetriever(opts.KeychainPassword)
51+
4452
var browsers []Browser
4553
for _, cfg := range configs {
4654
if name != "all" && cfg.Key != name {
4755
continue
4856
}
4957

58+
// Override profile directory when targeting a specific browser.
5059
if opts.ProfilePath != "" && name != "all" {
5160
if cfg.Kind == types.Firefox {
5261
cfg.UserDataDir = filepath.Dir(filepath.Clean(opts.ProfilePath))
@@ -55,48 +64,60 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
5564
}
5665
}
5766

58-
if opts.KeychainPassword != "" {
59-
cfg.KeychainPassword = opts.KeychainPassword
60-
}
61-
62-
bs, err := newBrowsers(cfg)
67+
found, err := newBrowsers(cfg)
6368
if err != nil {
6469
log.Errorf("browser %s: %v", cfg.Name, err)
6570
continue
6671
}
67-
if len(bs) == 0 {
72+
if len(found) == 0 {
6873
log.Debugf("browser %s not found at %s", cfg.Name, cfg.UserDataDir)
6974
continue
7075
}
71-
browsers = append(browsers, bs...)
76+
77+
// Inject the shared key retriever into browsers that need it.
78+
// Chromium browsers implement retrieverSetter; Firefox does not.
79+
for _, b := range found {
80+
if setter, ok := b.(retrieverSetter); ok {
81+
setter.SetRetriever(retriever)
82+
}
83+
}
84+
browsers = append(browsers, found...)
7285
}
7386
return browsers, nil
7487
}
7588

76-
// newBrowsers dispatches to the correct engine based on BrowserKind.
89+
// retrieverSetter is implemented by browsers that need an external key retriever.
90+
// This allows pickFromConfigs to inject the shared retriever after construction
91+
// without coupling the Browser interface to Chromium-specific concerns.
92+
type retrieverSetter interface {
93+
SetRetriever(keyretriever.KeyRetriever)
94+
}
95+
96+
// newBrowsers dispatches to the correct engine based on BrowserKind
97+
// and converts engine-specific types to the Browser interface.
7798
func newBrowsers(cfg types.BrowserConfig) ([]Browser, error) {
7899
switch cfg.Kind {
79100
case types.Chromium, types.ChromiumYandex, types.ChromiumOpera:
80-
bs, err := chromium.NewBrowsers(cfg)
101+
found, err := chromium.NewBrowsers(cfg)
81102
if err != nil {
82103
return nil, err
83104
}
84-
browsers := make([]Browser, len(bs))
85-
for i, b := range bs {
86-
browsers[i] = b
105+
result := make([]Browser, len(found))
106+
for i, b := range found {
107+
result[i] = b
87108
}
88-
return browsers, nil
109+
return result, nil
89110

90111
case types.Firefox:
91-
bs, err := firefox.NewBrowsers(cfg)
112+
found, err := firefox.NewBrowsers(cfg)
92113
if err != nil {
93114
return nil, err
94115
}
95-
browsers := make([]Browser, len(bs))
96-
for i, b := range bs {
97-
browsers[i] = b
116+
result := make([]Browser, len(found))
117+
for i, b := range found {
118+
result[i] = b
98119
}
99-
return browsers, nil
120+
return result, nil
100121

101122
default:
102123
return nil, fmt.Errorf("unknown browser kind: %d", cfg.Kind)

browser/browser_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,75 @@ func TestPickFromConfigs_ProfilePath(t *testing.T) {
202202
}
203203
}
204204

205+
// ---------------------------------------------------------------------------
206+
// newBrowsers dispatcher
207+
// ---------------------------------------------------------------------------
208+
209+
func TestNewBrowsersDispatch(t *testing.T) {
210+
chromiumDir := t.TempDir()
211+
mkFile(t, chromiumDir, "Default", "Preferences")
212+
mkFile(t, chromiumDir, "Default", "History")
213+
214+
firefoxDir := t.TempDir()
215+
mkFile(t, firefoxDir, "abc.default", "places.sqlite")
216+
217+
emptyDir := t.TempDir()
218+
219+
tests := []struct {
220+
name string
221+
cfg types.BrowserConfig
222+
wantLen int
223+
wantName string
224+
wantProfile string
225+
wantErr string
226+
}{
227+
{
228+
name: "chromium dispatch",
229+
cfg: types.BrowserConfig{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromiumDir},
230+
wantLen: 1,
231+
wantName: "Chrome",
232+
wantProfile: "Default",
233+
},
234+
{
235+
name: "firefox dispatch",
236+
cfg: types.BrowserConfig{Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: firefoxDir},
237+
wantLen: 1,
238+
wantName: "Firefox",
239+
wantProfile: "abc.default",
240+
},
241+
{
242+
name: "unknown kind returns error",
243+
cfg: types.BrowserConfig{Key: "unknown", Name: "Unknown", Kind: types.BrowserKind(99)},
244+
wantErr: "unknown browser kind",
245+
},
246+
{
247+
name: "empty dir returns empty",
248+
cfg: types.BrowserConfig{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: emptyDir},
249+
},
250+
}
251+
252+
for _, tt := range tests {
253+
t.Run(tt.name, func(t *testing.T) {
254+
found, err := newBrowsers(tt.cfg)
255+
if tt.wantErr != "" {
256+
require.Error(t, err)
257+
assert.Contains(t, err.Error(), tt.wantErr)
258+
return
259+
}
260+
require.NoError(t, err)
261+
require.Len(t, found, tt.wantLen)
262+
if tt.wantLen > 0 {
263+
assert.Equal(t, tt.wantName, found[0].BrowserName())
264+
assert.Equal(t, tt.wantProfile, found[0].ProfileName())
265+
}
266+
})
267+
}
268+
}
269+
270+
// ---------------------------------------------------------------------------
271+
// Helpers
272+
// ---------------------------------------------------------------------------
273+
205274
// assertBrowsers verifies browser names and profiles match expectations (order-independent).
206275
func assertBrowsers(t *testing.T, browsers []Browser, wantNames, wantProfiles []string) {
207276
t.Helper()

browser/chromium/chromium.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package chromium
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67
"time"
@@ -16,15 +17,15 @@ import (
1617
type Browser struct {
1718
cfg types.BrowserConfig
1819
profileDir string // absolute path to profile directory
19-
retriever keyretriever.KeyRetriever // shared across profiles of the same browser
20+
retriever keyretriever.KeyRetriever // set via SetRetriever after construction
2021
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
2122
extractors map[types.Category]categoryExtractor // Category → custom extract function override
2223
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
2324
}
2425

2526
// NewBrowsers discovers Chromium profiles under cfg.UserDataDir and returns
26-
// one Browser per profile. Uses ReadDir to find profile directories,
27-
// then Stat to check which data sources exist in each profile.
27+
// one Browser per profile. Call SetRetriever on each returned browser before
28+
// Extract to enable decryption of sensitive data (passwords, cookies, etc.).
2829
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
2930
sources := sourcesForKind(cfg.Kind)
3031
extractors := extractorsForKind(cfg.Kind)
@@ -34,11 +35,6 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
3435
return nil, nil
3536
}
3637

37-
// Create the key retriever once and share it across all profiles.
38-
// This avoids repeated keychain password prompts on macOS, where each
39-
// profile would otherwise trigger a separate `security` command dialog.
40-
retriever := keyretriever.DefaultRetriever(cfg.KeychainPassword)
41-
4238
var browsers []*Browser
4339
for _, profileDir := range profileDirs {
4440
sourcePaths := resolveSourcePaths(sources, profileDir)
@@ -48,7 +44,6 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
4844
browsers = append(browsers, &Browser{
4945
cfg: cfg,
5046
profileDir: profileDir,
51-
retriever: retriever,
5247
sources: sources,
5348
extractors: extractors,
5449
sourcePaths: sourcePaths,
@@ -57,6 +52,13 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
5752
return browsers, nil
5853
}
5954

55+
// SetRetriever sets the key retriever used by Extract to obtain the
56+
// master encryption key. Must be called before Extract if encrypted
57+
// data (passwords, cookies, credit cards) needs to be decrypted.
58+
func (b *Browser) SetRetriever(r keyretriever.KeyRetriever) {
59+
b.retriever = r
60+
}
61+
6062
func (b *Browser) BrowserName() string { return b.cfg.Name }
6163
func (b *Browser) ProfileDir() string { return b.profileDir }
6264
func (b *Browser) ProfileName() string {
@@ -120,6 +122,10 @@ func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.
120122
// The retriever is always called regardless of whether Local State exists,
121123
// because macOS/Linux retrievers don't need it.
122124
func (b *Browser) getMasterKey(session *filemanager.Session) ([]byte, error) {
125+
if b.retriever == nil {
126+
return nil, fmt.Errorf("key retriever not set for %s", b.cfg.Name)
127+
}
128+
123129
// Try to locate and copy Local State (needed on Windows, ignored on macOS/Linux).
124130
// Multi-profile layout: Local State is in the parent of profileDir.
125131
// Flat layout (Opera): Local State is alongside data files in profileDir.

0 commit comments

Comments
 (0)