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 profileDir string // absolute path to profile directory retriever keyretriever.KeyRetriever // shared across profiles of the same browser 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 } // 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 for _, profileDir := range profileDirs { sourcePaths := resolveSourcePaths(sources, profileDir) if len(sourcePaths) == 0 { continue } browsers = append(browsers, &Browser{ cfg: cfg, profileDir: profileDir, retriever: retriever, sources: sources, extractors: extractors, sourcePaths: sourcePaths, }) } return browsers, nil } func (b *Browser) BrowserName() string { return b.cfg.Name } func (b *Browser) ProfileDir() string { return b.profileDir } func (b *Browser) ProfileName() string { if b.profileDir == "" { return "" } return filepath.Base(b.profileDir) } // 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.BrowserName()+"/"+b.ProfileName(), 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 } } return b.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.BrowserName()+"/"+b.ProfileName(), 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.BrowserName()+"/"+b.ProfileName(), err) } } // discoverProfiles lists subdirectories of userDataDir that are valid // Chromium profile directories. A directory is considered a profile if it // contains a "Preferences" file, which Chromium creates for every 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 isProfileDir(dir) { profiles = append(profiles, dir) } } // Flat layout fallback (older Opera): data files directly in userDataDir. // Opera stores data alongside Local State in userDataDir itself, so check // for any known source file instead of Preferences. if len(profiles) == 0 && hasAnySource(sources, userDataDir) { profiles = append(profiles, userDataDir) } return profiles } // profileMarkers are filenames that identify a directory as a Chromium profile. // Chromium creates a per-profile preferences file on first use; checking for // its existence filters out non-profile subdirectories (Crashpad, ShaderCache, etc.). // // - "Preferences" — standard Chromium and all major forks (Chrome, Edge, Brave, …) // - "Preferences_02" — Tencent-based browsers (QQ Browser, Sogou Explorer) var profileMarkers = []string{ "Preferences", "Preferences_02", } // isProfileDir reports whether dir is a valid Chromium profile directory. func isProfileDir(dir string) bool { for _, name := range profileMarkers { if _, err := os.Stat(filepath.Join(dir, name)); err == nil { return true } } return false } // 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 }