diff --git a/browser/firefox/extract_password.go b/browser/firefox/extract_password.go index d1f412b..e8c0291 100644 --- a/browser/firefox/extract_password.go +++ b/browser/firefox/extract_password.go @@ -9,7 +9,6 @@ import ( "github.com/tidwall/gjson" "github.com/moond4rk/hackbrowserdata/crypto" - "github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/typeutil" ) @@ -39,22 +38,14 @@ func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) var logins []types.LoginEntry for _, v := range gjson.GetBytes(data, "logins").Array() { - user, err := decryptPBE(v.Get("encryptedUsername").String(), masterKey) - if err != nil { - log.Debugf("decrypt username: %v", err) - continue - } - pwd, err := decryptPBE(v.Get("encryptedPassword").String(), masterKey) - if err != nil { - log.Debugf("decrypt password: %v", err) - continue - } - url := v.Get("formSubmitURL").String() if url == "" { url = v.Get("hostname").String() } + user, _ := decryptPBE(v.Get("encryptedUsername").String(), masterKey) + pwd, _ := decryptPBE(v.Get("encryptedPassword").String(), masterKey) + logins = append(logins, types.LoginEntry{ URL: url, Username: string(user), diff --git a/browser/firefox/extract_password_test.go b/browser/firefox/extract_password_test.go index a673706..cd13934 100644 --- a/browser/firefox/extract_password_test.go +++ b/browser/firefox/extract_password_test.go @@ -77,8 +77,8 @@ func TestExtractPasswords_FormSubmitURLFallback(t *testing.T) { assert.Equal(t, "https://fallback.com", got[0].URL) } -func TestExtractPasswords_InvalidBase64Skipped(t *testing.T) { - // Invalid base64 in encryptedUsername — entry should be skipped +func TestExtractPasswords_DecryptFailureKeepsEntry(t *testing.T) { + // Invalid base64 — decryptPBE fails, but entry is still kept with empty user/pwd json := `{ "logins": [ { @@ -94,7 +94,10 @@ func TestExtractPasswords_InvalidBase64Skipped(t *testing.T) { got, err := extractPasswords(testGlobalSalt, path) require.NoError(t, err) - assert.Empty(t, got) // skipped, not error + require.Len(t, got, 1) + assert.Equal(t, "https://bad.com", got[0].URL) + assert.Empty(t, got[0].Username) // decrypt failed → empty + assert.Empty(t, got[0].Password) // decrypt failed → empty } func TestExtractPasswords_EmptyLogins(t *testing.T) { diff --git a/browser/firefox/firefox.go b/browser/firefox/firefox.go index 7dea102..7b87ff8 100644 --- a/browser/firefox/firefox.go +++ b/browser/firefox/firefox.go @@ -1,20 +1,15 @@ package firefox import ( - "bytes" - "database/sql" - "encoding/base64" "errors" "fmt" "io/fs" "os" "path/filepath" - "github.com/tidwall/gjson" _ "modernc.org/sqlite" // sqlite3 driver TODO: replace with chooseable driver "github.com/moond4rk/hackbrowserdata/browserdata" - "github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/fileutil" @@ -87,200 +82,10 @@ func firefoxWalkFunc(items []types.DataType, multiItemPaths map[string]map[types // GetMasterKey returns master key of Firefox. from key4.db func (f *Firefox) GetMasterKey() ([]byte, error) { tempFilename := types.FirefoxKey4.TempFilename() - - // Open and defer close of the database. - keyDB, err := sql.Open("sqlite", tempFilename) - if err != nil { - return nil, fmt.Errorf("open key4.db error: %w", err) - } defer os.Remove(tempFilename) - defer keyDB.Close() - metaItem1, metaItem2, err := queryMetaData(keyDB) - if err != nil { - return nil, fmt.Errorf("query metadata error: %w", err) - } - - candidates, err := queryNssPrivateCandidates(keyDB) - if err != nil { - return nil, fmt.Errorf("query NSS private error: %w", err) - } - loginCipherPairs, _ := getFirefoxLoginCipherPairs() - - var ( - fallbackKey []byte - lastErr error - ) - for _, c := range candidates { - masterKey, err := processMasterKey(metaItem1, metaItem2, c.a11, c.a102) - if err != nil { - lastErr = err - continue - } - if fallbackKey == nil { - fallbackKey = masterKey - } - - if len(loginCipherPairs) == 0 { - return masterKey, nil - } - if canDecryptAnyLoginCipherPair(masterKey, loginCipherPairs) { - return masterKey, nil - } - } - - if fallbackKey != nil { - return fallbackKey, nil - } - if lastErr != nil { - return nil, lastErr - } - return nil, errors.New("no valid firefox master key found in nssPrivate") -} - -func queryMetaData(db *sql.DB) ([]byte, []byte, error) { - const query = `SELECT item1, item2 FROM metaData WHERE id = 'password'` - var metaItem1, metaItem2 []byte - if err := db.QueryRow(query).Scan(&metaItem1, &metaItem2); err != nil { - return nil, nil, err - } - return metaItem1, metaItem2, nil -} - -type nssPrivateCandidate struct { - a11 []byte - a102 []byte -} - -func queryNssPrivateCandidates(db *sql.DB) ([]nssPrivateCandidate, error) { - const query = `SELECT a11, a102 FROM nssPrivate` - rows, err := db.Query(query) - if err != nil { - return nil, err - } - defer rows.Close() - - var candidates []nssPrivateCandidate - for rows.Next() { - var c nssPrivateCandidate - if err := rows.Scan(&c.a11, &c.a102); err != nil { - return nil, err - } - candidates = append(candidates, c) - } - if err := rows.Err(); err != nil { - return nil, err - } - if len(candidates) == 0 { - return nil, errors.New("nssPrivate is empty") - } - return candidates, nil -} - -func queryNssPrivate(db *sql.DB) ([]byte, []byte, error) { - // Keep this helper for backward compatibility in tests. - candidates, err := queryNssPrivateCandidates(db) - if err != nil { - return nil, nil, err - } - return candidates[0].a11, candidates[0].a102, nil -} - -type loginCipherPair struct { - username []byte - password []byte -} - -func getFirefoxLoginCipherPairs() ([]loginCipherPair, error) { - raw, err := os.ReadFile(types.FirefoxPassword.TempFilename()) - if err != nil { - return nil, err - } - arr := gjson.GetBytes(raw, "logins").Array() - pairs := make([]loginCipherPair, 0, len(arr)) - for _, v := range arr { - uEnc := v.Get("encryptedUsername").String() - pEnc := v.Get("encryptedPassword").String() - if uEnc == "" || pEnc == "" { - continue - } - uRaw, err := base64.StdEncoding.DecodeString(uEnc) - if err != nil { - continue - } - pRaw, err := base64.StdEncoding.DecodeString(pEnc) - if err != nil { - continue - } - pairs = append(pairs, loginCipherPair{username: uRaw, password: pRaw}) - if len(pairs) >= 5 { - break - } - } - return pairs, nil -} - -func canDecryptAnyLoginCipherPair(masterKey []byte, pairs []loginCipherPair) bool { - for _, pair := range pairs { - uPBE, err := crypto.NewASN1PBE(pair.username) - if err != nil { - continue - } - if _, err := uPBE.Decrypt(masterKey); err != nil { - continue - } - - pPBE, err := crypto.NewASN1PBE(pair.password) - if err != nil { - continue - } - if _, err := pPBE.Decrypt(masterKey); err == nil { - return true - } - } - return false -} - -// processMasterKey process master key of Firefox. -// Process the metaBytes and nssA11 with the corresponding cryptographic operations. -func processMasterKey(metaItem1, metaItem2, nssA11, nssA102 []byte) ([]byte, error) { - metaPBE, err := crypto.NewASN1PBE(metaItem2) - if err != nil { - return nil, fmt.Errorf("error creating ASN1PBE from metaItem2: %w", err) - } - - flag, err := metaPBE.Decrypt(metaItem1) - if err != nil { - return nil, fmt.Errorf("error decrypting master key: %w", err) - } - const passwordCheck = "password-check" - - if !bytes.Contains(flag, []byte(passwordCheck)) { - return nil, errors.New("flag verification failed: password-check not found") - } - - keyLin := []byte{248, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} - if !bytes.Equal(nssA102, keyLin) { - return nil, errors.New("master key verification failed: nssA102 not equal to expected value") - } - - nssA11PBE, err := crypto.NewASN1PBE(nssA11) - if err != nil { - return nil, fmt.Errorf("error creating ASN1PBE from nssA11: %w", err) - } - - finallyKey, err := nssA11PBE.Decrypt(metaItem1) - if err != nil { - return nil, fmt.Errorf("error decrypting final key: %w", err) - } - if len(finallyKey) < 24 { - return nil, errors.New("length of final key is less than 24 bytes") - } - // Historically, the derived PBE key was truncated to 24 bytes for 3DES usage. - // Starting from Firefox 144+, NSS switches to AES-256-CBC without changing - // the underlying key derivation logic. The full derived key must be preserved - // to support modern cipher suites. - return finallyKey, nil + loginsPath := types.FirefoxPassword.TempFilename() + return retrieveMasterKey(tempFilename, loginsPath) } func (f *Firefox) Name() string { diff --git a/browser/firefox/firefox_new.go b/browser/firefox/firefox_new.go new file mode 100644 index 0000000..ef72bd3 --- /dev/null +++ b/browser/firefox/firefox_new.go @@ -0,0 +1,235 @@ +package firefox + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "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 Firefox profile ready for extraction. +type Browser struct { + cfg types.BrowserConfig + name string // display name: "Firefox-97nszz88.default-release" + profileDir string // absolute path to profile directory + sources map[types.Category][]sourcePath // Category → candidate paths (priority order) + sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path +} + +// NewBrowsers discovers Firefox profiles under cfg.UserDataDir and returns +// one Browser per profile. Firefox profile directories have random names +// (e.g. "97nszz88.default-release"); any subdirectory containing known +// data files is treated as a valid profile. +func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) { + profileDirs := discoverProfiles(cfg.UserDataDir, firefoxSources) + if len(profileDirs) == 0 { + return nil, nil + } + + var browsers []*Browser + for _, profileDir := range profileDirs { + sourcePaths := resolveSourcePaths(firefoxSources, profileDir) + if len(sourcePaths) == 0 { + continue + } + browsers = append(browsers, &Browser{ + cfg: cfg, + name: cfg.Name + "-" + filepath.Base(profileDir), + profileDir: profileDir, + sources: firefoxSources, + 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, tempPaths) + 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 Firefox master encryption key from key4.db. +// The key is derived via NSS ASN1 PBE decryption (platform-agnostic). +// If logins.json was already acquired by acquireFiles, the derived key +// is validated by attempting to decrypt an actual login entry. +func (b *Browser) getMasterKey(session *filemanager.Session, tempPaths map[types.Category]string) ([]byte, error) { + key4Src := filepath.Join(b.profileDir, "key4.db") + if !fileutil.IsFileExists(key4Src) { + return nil, nil + } + key4Dst := filepath.Join(session.TempDir(), "key4.db") + if err := session.Acquire(key4Src, key4Dst, false); err != nil { + return nil, fmt.Errorf("acquire key4.db: %w", err) + } + + // logins.json is already acquired by acquireFiles as the Password source; + // reuse it for master key validation if available. + loginsPath := tempPaths[types.Password] + return retrieveMasterKey(key4Dst, loginsPath) +} + +// 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)) +} + +// extractCategory calls the appropriate extract function for a category. +func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, masterKey []byte, path string) { + var err error + switch cat { + case types.Password: + data.Passwords, err = extractPasswords(masterKey, path) + case types.Cookie: + data.Cookies, err = extractCookies(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.Extension: + data.Extensions, err = extractExtensions(path) + case types.LocalStorage: + data.LocalStorage, err = extractLocalStorage(path) + case types.CreditCard, types.SessionStorage: + // Firefox does not support CreditCard or SessionStorage extraction. + } + if err != nil { + log.Debugf("extract %s for %s: %v", cat, b.name, err) + } +} + +// 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 { + log.Debugf("read user data dir %s: %v", userDataDir, err) + 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 +} diff --git a/browser/firefox/firefox_new_test.go b/browser/firefox/firefox_new_test.go new file mode 100644 index 0000000..29f56a9 --- /dev/null +++ b/browser/firefox/firefox_new_test.go @@ -0,0 +1,259 @@ +package firefox + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/moond4rk/hackbrowserdata/types" +) + +// Shared fixtures built once for all tests. +var fixture struct { + root string + multiProf string // two Firefox profiles + non-profile dir + singleProf string // one profile with all data files + partial string // profile missing some files + empty string +} + +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "firefox-test-*") + if err != nil { + panic(err) + } + fixture.root = root + buildFixtures() + code := m.Run() + os.RemoveAll(root) + os.Exit(code) +} + +func buildFixtures() { + allFiles := []string{ + "places.sqlite", "cookies.sqlite", "logins.json", + "extensions.json", "webappsstore.sqlite", "key4.db", + } + + // Multi-profile: two valid profiles + one non-profile directory + fixture.multiProf = filepath.Join(fixture.root, "multi") + for _, prof := range []string{"abc123.default-release", "xyz789.default"} { + for _, f := range allFiles { + mkFile(fixture.multiProf, prof, f) + } + } + mkDir(fixture.multiProf, "Crash Reports") + mkDir(fixture.multiProf, "Pending Pings") + + // Single profile: one profile with all files + fixture.singleProf = filepath.Join(fixture.root, "single") + for _, f := range allFiles { + mkFile(fixture.singleProf, "m1n2o3.default-release", f) + } + + // Partial profile: only places.sqlite (no logins, no cookies) + fixture.partial = filepath.Join(fixture.root, "partial") + mkFile(fixture.partial, "p4q5r6.default", "places.sqlite") + + // Empty directory + 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) + } +} + +// TestNewBrowsers is table-driven, covering all profile discovery scenarios. +func TestNewBrowsers(t *testing.T) { + tests := []struct { + name string + dir string + wantProfiles []string // expected profile base names + skipDirs []string // should NOT appear as profiles + }{ + { + name: "multi-profile discovery", + dir: fixture.multiProf, + wantProfiles: []string{"abc123.default-release", "xyz789.default"}, + skipDirs: []string{"Crash Reports", "Pending Pings"}, + }, + { + name: "single profile", + dir: fixture.singleProf, + wantProfiles: []string{"m1n2o3.default-release"}, + }, + { + name: "partial profile", + dir: fixture.partial, + wantProfiles: []string{"p4q5r6.default"}, + }, + { + name: "empty dir", + dir: fixture.empty, + }, + { + name: "nonexistent dir", + dir: "/nonexistent/path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := types.BrowserConfig{Name: "Firefox", Kind: types.KindFirefox, 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)) + + profileNames := make(map[string]bool) + for _, b := range browsers { + profileNames[filepath.Base(b.profileDir)] = true + } + for _, want := range tt.wantProfiles { + assert.True(t, profileNames[want], "should find profile %s", want) + } + for _, skip := range tt.skipDirs { + assert.False(t, profileNames[skip], "should not find %s", skip) + } + }) + } +} + +// TestResolveSourcePaths verifies that source resolution correctly maps +// categories to files, including shared files (places.sqlite). +func TestResolveSourcePaths(t *testing.T) { + profileDir := filepath.Join(fixture.singleProf, "m1n2o3.default-release") + resolved := resolveSourcePaths(firefoxSources, profileDir) + + // All categories should be resolved + for _, cat := range []types.Category{ + types.Password, types.Cookie, types.History, + types.Download, types.Bookmark, types.Extension, types.LocalStorage, + } { + assert.Contains(t, resolved, cat, "should resolve %s", cat) + } + + // History, Download, Bookmark share places.sqlite + assert.Equal(t, resolved[types.History].absPath, resolved[types.Download].absPath) + assert.Equal(t, resolved[types.History].absPath, resolved[types.Bookmark].absPath) + + // Password is a different file + assert.NotEqual(t, resolved[types.Password].absPath, resolved[types.History].absPath) +} + +func TestResolveSourcePaths_Partial(t *testing.T) { + profileDir := filepath.Join(fixture.partial, "p4q5r6.default") + resolved := resolveSourcePaths(firefoxSources, profileDir) + + // Only places.sqlite exists → History, Download, Bookmark resolved + assert.Contains(t, resolved, types.History) + assert.Contains(t, resolved, types.Download) + assert.Contains(t, resolved, types.Bookmark) + + // No logins.json, cookies.sqlite, etc. + assert.NotContains(t, resolved, types.Password) + assert.NotContains(t, resolved, types.Cookie) + assert.NotContains(t, resolved, types.Extension) +} + +// TestExtractCategory verifies that the switch dispatch works for each category. +func TestExtractCategory(t *testing.T) { + t.Run("History", func(t *testing.T) { + path := createTestDB(t, "places.sqlite", + []string{mozPlacesSchema}, + insertMozPlace(1, "https://example.com", "Example", 3, 1000000), + insertMozPlace(2, "https://go.dev", "Go", 1, 2000000), + ) + b := &Browser{name: "Test"} + data := &types.BrowserData{} + b.extractCategory(data, types.History, nil, path) + + require.Len(t, data.Histories, 2) + // Firefox sorts by visit count ascending + assert.Equal(t, 1, data.Histories[0].VisitCount) + assert.Equal(t, 3, data.Histories[1].VisitCount) + }) + + t.Run("Cookie", func(t *testing.T) { + path := createTestDB(t, "cookies.sqlite", + []string{mozCookiesSchema}, + insertMozCookie("session", "abc", ".example.com", "/", 1000000000000, 0, 0, 0), + ) + b := &Browser{name: "Test"} + data := &types.BrowserData{} + b.extractCategory(data, types.Cookie, nil, path) + + require.Len(t, data.Cookies, 1) + assert.Equal(t, "session", data.Cookies[0].Name) + assert.Equal(t, "abc", data.Cookies[0].Value) // Firefox cookies are not encrypted + }) + + t.Run("Bookmark", func(t *testing.T) { + path := createTestDB(t, "places.sqlite", + []string{mozPlacesSchema, mozBookmarksSchema}, + insertMozPlace(1, "https://github.com", "GitHub", 1, 1000000), + insertMozBookmark(1, 1, 1, "GitHub", 1000000), + ) + b := &Browser{name: "Test"} + data := &types.BrowserData{} + b.extractCategory(data, types.Bookmark, nil, path) + + require.Len(t, data.Bookmarks, 1) + assert.Equal(t, "GitHub", data.Bookmarks[0].Name) + }) + + t.Run("Extension", func(t *testing.T) { + path := createTestJSON(t, "extensions.json", `{ + "addons": [ + { + "id": "ublock@example.com", + "location": "app-profile", + "active": true, + "version": "1.0", + "defaultLocale": {"name": "uBlock Origin", "description": "Ad blocker"} + }, + { + "id": "system@mozilla.com", + "location": "app-system-defaults", + "active": true + } + ] + }`) + b := &Browser{name: "Test"} + data := &types.BrowserData{} + b.extractCategory(data, types.Extension, nil, path) + + require.Len(t, data.Extensions, 1) // system extension skipped + assert.Equal(t, "uBlock Origin", data.Extensions[0].Name) + }) + + t.Run("UnsupportedCategory", func(t *testing.T) { + b := &Browser{name: "Test"} + data := &types.BrowserData{} + // CreditCard and SessionStorage are not supported by Firefox + b.extractCategory(data, types.CreditCard, nil, "unused") + b.extractCategory(data, types.SessionStorage, nil, "unused") + assert.Empty(t, data.CreditCards) + assert.Empty(t, data.SessionStorage) + }) +} diff --git a/browser/firefox/firefox_test.go b/browser/firefox/firefox_test.go deleted file mode 100644 index 540a771..0000000 --- a/browser/firefox/firefox_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package firefox - -import ( - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" -) - -func TestQueryMetaData(t *testing.T) { - db, mock, err := sqlmock.New() - assert.NoError(t, err) - defer db.Close() - - rows := sqlmock.NewRows([]string{"item1", "item2"}). - AddRow([]byte("globalSalt"), []byte("metaBytes")) - mock.ExpectQuery("SELECT item1, item2 FROM metaData WHERE id = 'password'").WillReturnRows(rows) - - globalSalt, metaBytes, err := queryMetaData(db) - assert.NoError(t, err) - assert.Equal(t, []byte("globalSalt"), globalSalt) - assert.Equal(t, []byte("metaBytes"), metaBytes) -} - -func TestQueryNssPrivate(t *testing.T) { - db, mock, err := sqlmock.New() - assert.NoError(t, err) - defer db.Close() - - rows := sqlmock.NewRows([]string{"a11", "a102"}). - AddRow([]byte("nssA11"), []byte("nssA102")) - mock.ExpectQuery("SELECT a11, a102 FROM nssPrivate").WillReturnRows(rows) - - nssA11, nssA102, err := queryNssPrivate(db) - assert.NoError(t, err) - assert.Equal(t, []byte("nssA11"), nssA11) - assert.Equal(t, []byte("nssA102"), nssA102) -} diff --git a/browser/firefox/masterkey.go b/browser/firefox/masterkey.go new file mode 100644 index 0000000..7489215 --- /dev/null +++ b/browser/firefox/masterkey.go @@ -0,0 +1,209 @@ +package firefox + +import ( + "bytes" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "os" + + "github.com/tidwall/gjson" + _ "modernc.org/sqlite" + + "github.com/moond4rk/hackbrowserdata/crypto" + "github.com/moond4rk/hackbrowserdata/log" +) + +// key4DB holds the parsed contents of Firefox's key4.db NSS key storage. +// +// Firefox stores the master encryption key in key4.db using two SQLite tables: +// - metaData: contains the global salt and an encrypted "password-check" marker +// - nssPrivate: contains one or more encrypted master key candidates +// +// Reference: https://searchfox.org/mozilla-central/source/security/nss/lib/softoken/ +type key4DB struct { + globalSalt []byte // metaData.item1: salt used as PBE decryption input + passwordCheck []byte // metaData.item2: encrypted marker to verify DB integrity + privateKeys []privateKey // nssPrivate rows: encrypted master key candidates +} + +// privateKey is a single encrypted master key entry from nssPrivate. +type privateKey struct { + encrypted []byte // a11: PBE-encrypted master key blob + typeTag []byte // a102: key type identifier (must match nssKeyTypeTag) +} + +// nssKeyTypeTag identifies valid master key entries in key4.db. +// Only nssPrivate rows where a102 matches this tag contain actual master keys; +// other rows may be certificates or other NSS objects. +// See: https://searchfox.org/mozilla-central/source/security/nss/lib/softoken/pkcs11i.h +var nssKeyTypeTag = []byte{248, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + +// readKey4DB opens key4.db and parses it into a structured key4DB. +func readKey4DB(path string) (*key4DB, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open key4.db: %w", err) + } + defer db.Close() + + var record key4DB + + // Read metaData table + const metaQuery = `SELECT item1, item2 FROM metaData WHERE id = 'password'` + if err := db.QueryRow(metaQuery).Scan(&record.globalSalt, &record.passwordCheck); err != nil { + return nil, fmt.Errorf("query metaData: %w", err) + } + + // Read nssPrivate table + const nssQuery = `SELECT a11, a102 FROM nssPrivate` + rows, err := db.Query(nssQuery) + if err != nil { + return nil, fmt.Errorf("query nssPrivate: %w", err) + } + defer rows.Close() + + for rows.Next() { + var pk privateKey + if err := rows.Scan(&pk.encrypted, &pk.typeTag); err != nil { + return nil, fmt.Errorf("scan nssPrivate row: %w", err) + } + record.privateKeys = append(record.privateKeys, pk) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate nssPrivate: %w", err) + } + if len(record.privateKeys) == 0 { + return nil, errors.New("nssPrivate table is empty") + } + + return &record, nil +} + +// deriveKeys verifies the database integrity via the password-check marker, +// then decrypts all valid master key candidates. +func (k *key4DB) deriveKeys() ([][]byte, error) { + if err := k.verifyPasswordCheck(); err != nil { + return nil, err + } + + var keys [][]byte + for _, pk := range k.privateKeys { + if !bytes.Equal(pk.typeTag, nssKeyTypeTag) { + continue + } + key, err := k.decryptPrivateKey(pk) + if err != nil { + log.Debugf("decrypt nss private key: %v", err) + continue + } + keys = append(keys, key) + } + return keys, nil +} + +// verifyPasswordCheck decrypts the password-check marker from metaData +// to confirm the database is valid and accessible. +func (k *key4DB) verifyPasswordCheck() error { + pbe, err := crypto.NewASN1PBE(k.passwordCheck) + if err != nil { + return fmt.Errorf("parse password check: %w", err) + } + plain, err := pbe.Decrypt(k.globalSalt) + if err != nil { + return fmt.Errorf("decrypt password check: %w", err) + } + if !bytes.Contains(plain, []byte("password-check")) { + return errors.New("password check verification failed") + } + return nil +} + +// decryptPrivateKey decrypts a single master key candidate using the global salt. +func (k *key4DB) decryptPrivateKey(pk privateKey) ([]byte, error) { + pbe, err := crypto.NewASN1PBE(pk.encrypted) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + derivedKey, err := pbe.Decrypt(k.globalSalt) + if err != nil { + return nil, fmt.Errorf("decrypt private key: %w", err) + } + if len(derivedKey) < 24 { + return nil, fmt.Errorf("derived key too short: %d bytes (need >= 24)", len(derivedKey)) + } + // Firefox 144+ uses AES-256-CBC instead of 3DES; the full derived key + // must be preserved to support modern cipher suites. + return derivedKey, nil +} + +// encryptedLogin holds PBE-encrypted credentials from logins.json, +// used as test samples for master key validation. +type encryptedLogin struct { + username []byte // PBE-encrypted username blob + password []byte // PBE-encrypted password blob +} + +// validateKeyWithLogins reads logins.json and returns the first key that +// can successfully decrypt an actual login entry. Returns nil if no key matches. +func validateKeyWithLogins(keys [][]byte, loginsPath string) []byte { + raw, err := os.ReadFile(loginsPath) + if err != nil { + return nil + } + samples := sampleEncryptedLogins(raw) + if len(samples) == 0 { + return nil + } + for _, key := range keys { + if tryDecryptLogins(key, samples) { + return key + } + } + return nil +} + +// sampleEncryptedLogins extracts up to 5 encrypted login entries from +// logins.json as test samples for master key validation. +func sampleEncryptedLogins(raw []byte) []encryptedLogin { + arr := gjson.GetBytes(raw, "logins").Array() + var samples []encryptedLogin + for _, v := range arr { + userRaw, err := base64.StdEncoding.DecodeString(v.Get("encryptedUsername").String()) + if err != nil { + continue + } + pwdRaw, err := base64.StdEncoding.DecodeString(v.Get("encryptedPassword").String()) + if err != nil { + continue + } + samples = append(samples, encryptedLogin{username: userRaw, password: pwdRaw}) + if len(samples) >= 5 { + break + } + } + return samples +} + +// tryDecryptLogins checks if masterKey can decrypt at least one encrypted +// login entry (both username and password). +func tryDecryptLogins(masterKey []byte, samples []encryptedLogin) bool { + for _, login := range samples { + userPBE, err := crypto.NewASN1PBE(login.username) + if err != nil { + continue + } + if _, err := userPBE.Decrypt(masterKey); err != nil { + continue + } + pwdPBE, err := crypto.NewASN1PBE(login.password) + if err != nil { + continue + } + if _, err := pwdPBE.Decrypt(masterKey); err == nil { + return true + } + } + return false +} diff --git a/browser/firefox/masterkey_test.go b/browser/firefox/masterkey_test.go new file mode 100644 index 0000000..3c91a66 --- /dev/null +++ b/browser/firefox/masterkey_test.go @@ -0,0 +1,63 @@ +package firefox + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadKey4DB(t *testing.T) { + // Create a minimal key4.db with metaData and nssPrivate tables + path := createTestDB(t, "key4.db", + []string{ + `CREATE TABLE metaData (id TEXT PRIMARY KEY, item1 BLOB, item2 BLOB)`, + `CREATE TABLE nssPrivate (a11 BLOB, a102 BLOB)`, + }, + `INSERT INTO metaData (id, item1, item2) VALUES ('password', x'aabbccdd', x'11223344')`, + `INSERT INTO nssPrivate (a11, a102) VALUES (x'deadbeef', x'cafebabe')`, + `INSERT INTO nssPrivate (a11, a102) VALUES (x'feedface', x'12345678')`, + ) + + k4, err := readKey4DB(path) + require.NoError(t, err) + + assert.Equal(t, []byte{0xaa, 0xbb, 0xcc, 0xdd}, k4.globalSalt) + assert.Equal(t, []byte{0x11, 0x22, 0x33, 0x44}, k4.passwordCheck) + require.Len(t, k4.privateKeys, 2) + // Don't assume row order — check that both entries exist + encryptedBlobs := map[string]bool{} + for _, pk := range k4.privateKeys { + encryptedBlobs[fmt.Sprintf("%x", pk.encrypted)] = true + } + assert.True(t, encryptedBlobs["deadbeef"]) + assert.True(t, encryptedBlobs["feedface"]) +} + +func TestReadKey4DB_EmptyNssPrivate(t *testing.T) { + path := createTestDB(t, "key4.db", + []string{ + `CREATE TABLE metaData (id TEXT PRIMARY KEY, item1 BLOB, item2 BLOB)`, + `CREATE TABLE nssPrivate (a11 BLOB, a102 BLOB)`, + }, + `INSERT INTO metaData (id, item1, item2) VALUES ('password', x'aa', x'bb')`, + ) + + _, err := readKey4DB(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestSampleEncryptedLogins(t *testing.T) { + raw := []byte(`{"logins":[ + {"encryptedUsername":"dGVzdA==","encryptedPassword":"cGFzcw=="}, + {"encryptedUsername":"!!!invalid","encryptedPassword":"cGFzcw=="}, + {"encryptedUsername":"dGVzdA==","encryptedPassword":"cGFzcw=="} + ]}`) + + samples := sampleEncryptedLogins(raw) + require.Len(t, samples, 2) // second entry skipped (invalid base64) + assert.Equal(t, []byte("test"), samples[0].username) + assert.Equal(t, []byte("pass"), samples[0].password) +} diff --git a/browser/firefox/source.go b/browser/firefox/source.go index c67fd5d..29126c5 100644 --- a/browser/firefox/source.go +++ b/browser/firefox/source.go @@ -15,19 +15,16 @@ type sourcePath struct { 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 { - candidates []sourcePath -} - // firefoxSources defines the Firefox file layout. +// Each category maps to one or more candidate paths tried in priority order; +// the first existing path wins. // Firefox does not support SessionStorage or CreditCard extraction. -var firefoxSources = map[types.Category]dataSource{ - 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")}}, +var firefoxSources = map[types.Category][]sourcePath{ + types.Password: {file("logins.json")}, + types.Cookie: {file("cookies.sqlite")}, + types.History: {file("places.sqlite")}, + types.Download: {file("places.sqlite")}, + types.Bookmark: {file("places.sqlite")}, + types.Extension: {file("extensions.json")}, + types.LocalStorage: {file("webappsstore.sqlite")}, } diff --git a/rfcs/003-crypto-naming-cleanup.md b/rfcs/003-crypto-naming-cleanup.md new file mode 100644 index 0000000..7f1120b --- /dev/null +++ b/rfcs/003-crypto-naming-cleanup.md @@ -0,0 +1,53 @@ +# RFC-003: Crypto Package and Naming Cleanup + +**Author**: moonD4rk +**Status**: Proposed +**Created**: 2026-04-03 + +## Abstract + +The `crypto/` package and cross-browser shared code have accumulated naming +and structural issues over time. This RFC tracks them for a future dedicated +refactoring pass. No code changes are proposed here. + +## 1. crypto/asn1pbe.go + +### Naming + +| Current | Issue | Suggested | +|---------|-------|-----------| +| `nssPBE` | Too generic — "NSS" covers all Firefox crypto | `privateKeyPBE` — decrypts key4.db nssPrivate entries | +| `metaPBE` | "meta" is vague | `passwordCheckPBE` — decrypts key4.db metaData check | +| `loginPBE` | Acceptable but inconsistent | `credentialPBE` — decrypts logins.json credentials | +| `ASN1PBE` interface | Too technical for callers | `Decryptor` or `PBEDecryptor` | +| `SlatAttr` | **Typo** — should be `Salt` | `SaltAttr` | +| `AlgoAttr.Data.Data` | Nested names are meaningless | Flatten with descriptive field names | +| `AES128CBCDecrypt` | Misnomer — supports all AES key lengths | `AESCBCDecrypt` | + +### Structure + +`NewASN1PBE` uses trial-and-error `asn1.Unmarshal` to detect the type. +ASN1 parsing is lenient, so multiple structs may succeed. A safer approach +would be to parse the OID first, then unmarshal into the matching struct. + +## 2. crypto/crypto_*.go + +| Current | Issue | +|---------|-------| +| `DecryptWithChromium` | Platform-specific (AES-CBC on darwin, AES-GCM on windows) — name doesn't reflect this | +| `DecryptWithYandex` | Nearly identical to `DecryptWithChromium` on Windows | + +## 3. Shared code between Chromium and Firefox + +`discoverProfiles`, `hasAnySource`, `resolveSourcePaths`, `resolvedPath` +are nearly identical in both packages (~40 lines duplicated). Currently +each package keeps its own copy for independence. If more browser engines +are added (e.g. Safari WebKit), consider extracting to a shared package. + +## 4. Priority + +1. **SlatAttr typo** — trivial fix, do anytime +2. **AES128CBCDecrypt rename** — grep + rename, low risk +3. **ASN1PBE type/naming cleanup** — medium effort, needs comprehensive tests +4. **NewASN1PBE OID-first detection** — higher effort, must not break any Firefox version +5. **Shared profile discovery** — only when a third browser engine is added