Files
HackBrowserData/browser/firefox/firefox.go
T
Roger b901f7dff0 refactor(browser): split installation and profile abstractions (#603)
* refactor(browser): split installation and profile abstractions

A Chromium installation shares one master key across its profiles, but
modeling each profile as its own Browser re-derived the key per profile.
Browser now represents one installation holding its profiles and derives
the key once; new types.Profile/ExtractResult/CountResult carry per-profile
results.

* style: gofumpt safari_test.go

* test(chromium): rename shadowed loop var to path
2026-05-31 16:37:23 +08:00

205 lines
5.8 KiB
Go

package firefox
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/moond4rk/hackbrowserdata/types"
)
// Browser is one Firefox installation: the Profiles directory holding one or
// more profiles. Firefox keys are per-profile (each profile's key4.db), so the
// installation does not implement KeyManager.
type Browser struct {
cfg types.BrowserConfig
profiles []*profile
}
// NewBrowser discovers the Firefox profiles under cfg.UserDataDir and returns
// the installation, or nil if no profile with resolvable sources exists.
// Firefox profile directories have random names (e.g. "97nszz88.default-release");
// any subdirectory containing known data files is treated as a valid profile.
func NewBrowser(cfg types.BrowserConfig) (*Browser, error) {
var profiles []*profile
for _, profileDir := range discoverProfiles(cfg.UserDataDir, firefoxSources) {
sourcePaths := resolveSourcePaths(firefoxSources, profileDir)
if len(sourcePaths) == 0 {
continue
}
profiles = append(profiles, &profile{
profileDir: profileDir,
browserName: cfg.Name,
sourcePaths: sourcePaths,
})
}
if len(profiles) == 0 {
return nil, nil
}
return &Browser{cfg: cfg, profiles: profiles}, nil
}
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
// Profiles returns the identity of every profile in this installation.
func (b *Browser) Profiles() []types.Profile {
out := make([]types.Profile, 0, len(b.profiles))
for _, p := range b.profiles {
out = append(out, types.Profile{Name: p.name(), Dir: p.profileDir})
}
return out
}
// Extract extracts every profile, deriving each profile's key independently.
func (b *Browser) Extract(categories []types.Category) ([]types.ExtractResult, error) {
results := make([]types.ExtractResult, 0, len(b.profiles))
for _, p := range b.profiles {
results = append(results, types.ExtractResult{
Profile: types.Profile{Name: p.name(), Dir: p.profileDir},
Data: p.extract(categories),
})
}
return results, nil
}
// CountEntries counts entries per category for every profile without decryption.
func (b *Browser) CountEntries(categories []types.Category) ([]types.CountResult, error) {
results := make([]types.CountResult, 0, len(b.profiles))
for _, p := range b.profiles {
results = append(results, types.CountResult{
Profile: types.Profile{Name: p.name(), Dir: p.profileDir},
Counts: p.count(categories),
})
}
return results, nil
}
// retrieveMasterKey reads key4.db and derives the master key using NSS.
// If loginsPath is non-empty, the derived key is validated against actual
// login data to ensure the correct candidate is selected.
func retrieveMasterKey(key4Path, loginsPath string) ([]byte, error) {
k4, err := readKey4DB(key4Path)
if err != nil {
return nil, err
}
keys, err := k4.deriveKeys()
if err != nil {
return nil, err
}
if len(keys) == 0 {
return nil, errors.New("no valid master key candidates in key4.db")
}
// No logins to validate against — return the first derived key.
if loginsPath == "" {
return keys[0], nil
}
// Validate against actual login data.
if key := validateKeyWithLogins(keys, loginsPath); key != nil {
return key, nil
}
return nil, fmt.Errorf("derived %d key(s) but none could decrypt logins", len(keys))
}
// resolvedPath holds the absolute path and type for a discovered source.
type resolvedPath struct {
absPath string
isDir bool
}
// discoverProfiles lists subdirectories of userDataDir that contain at least
// one known data source. Each such directory is a Firefox profile.
func discoverProfiles(userDataDir string, sources map[types.Category][]sourcePath) []string {
entries, err := os.ReadDir(userDataDir)
if err != nil {
return nil
}
var profiles []string
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(userDataDir, e.Name())
if hasAnySource(sources, dir) {
profiles = append(profiles, dir)
}
}
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
}
// 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
}
// Firefox uses three timestamp units. Helpers emit UTC and return the zero
// time.Time for non-positive or out-of-JSON-range input.
//
// - firefoxMicros: PRTime (μs since Unix epoch) — moz_* tables.
// - firefoxMillis: Date.now() (ms) — logins.json, download endTime.
// - firefoxSeconds: seconds — moz_cookies.expiry only.
func firefoxMicros(us int64) time.Time {
if us <= 0 {
return time.Time{}
}
return clampJSON(time.UnixMicro(us).UTC())
}
func firefoxMillis(ms int64) time.Time {
if ms <= 0 {
return time.Time{}
}
return clampJSON(time.UnixMilli(ms).UTC())
}
func firefoxSeconds(s int64) time.Time {
if s <= 0 {
return time.Time{}
}
return clampJSON(time.Unix(s, 0).UTC())
}
// clampJSON maps years outside time.Time.MarshalJSON's [1, 9999] window
// to the zero time, so JSON export can't crash on sentinel inputs.
func clampJSON(t time.Time) time.Time {
if t.Year() < 1 || t.Year() > 9999 {
return time.Time{}
}
return t
}