feat: add Firefox Browser with new v2 architecture (#536)

* feat: add Firefox Browser implementation with new v2 architecture

Add Firefox NewBrowsers + Extract pipeline following the Chromium v2
pattern. Firefox-specific differences handled:
- Profile discovery: random directory names (e.g. abc123.default-release)
- Master key: NSS/ASN1PBE from key4.db (platform-agnostic, no DPAPI/Keychain)
- Key validation: reuse logins.json from acquireFiles tempPaths
- Extract: only Password needs masterKey; Cookie is plaintext
- No CreditCard or SessionStorage support

Files:
- firefox_new.go: Browser struct, NewBrowsers, Extract, getMasterKey,
  extractCategory, deriveKeys, validateKeyWithLogins, profile discovery
- masterkey.go: extracted shared NSS logic (processMasterKey, queryMetaData,
  queryNssPrivateCandidates, parseLoginCipherPairs, canDecryptAnyLoginCipherPair)
- firefox_new_test.go: table-driven tests with shared fixtures
- source.go: remove dataSource wrapper, use []sourcePath directly
- firefox.go: remove functions moved to masterkey.go

* fix: address Copilot review feedback on Firefox v2

- Fix stale comment referencing removed readLoginCipherPairs
- Rename finallyKey to derivedKey for clarity in processMasterKey
- Add sqlite driver import to masterkey.go for self-containedness

* refactor: rewrite Firefox masterkey and improve naming

Masterkey rewrite:
- Replace raw SQL functions with structured key4DB type (globalSalt,
  passwordCheck, privateKeys) for clear data modeling
- Split processMasterKey into verifyPasswordCheck + decryptPrivateKey
- Add nssKeyTypeTag constant for the magic bytes
- Rename finallyKey to derivedKey
- Add sqlite driver import for self-containedness
- Return error (not fallback) when logins validation explicitly fails

Naming cleanup:
- loginPair → encryptedLogin (clarify these are encrypted blobs)
- parseLoginPairs → sampleEncryptedLogins (clarify sampling purpose)
- canDecryptLogin → tryDecryptLogins (accurate verb, plural alignment)
- Expand abbreviated variables: p→login, uPBE→userPBE, pPBE→pwdPBE

Password extraction:
- Keep entries when decryptPBE fails (URL preserved, user/pwd empty)
- Align with Chromium behavior where decrypt failure doesn't skip records

Old code cleanup:
- firefox.go GetMasterKey now delegates to retrieveMasterKey
- Remove functions moved to masterkey.go

* docs: add RFC-003 for crypto package naming cleanup

Track accumulated naming and structural issues in crypto/asn1pbe.go
and cross-browser shared code for a future dedicated refactoring pass.

* refactor: move masterkey tests to masterkey_test.go

- Rename firefox_test.go to masterkey_test.go since all tests in
  this file test masterkey.go functions (readKey4DB, sampleEncryptedLogins)
- Fix TestReadKey4DB to check nssPrivate rows as a set instead of
  assuming SQLite insertion order
- Future deletion of firefox.go won't accidentally remove masterkey tests
This commit is contained in:
Roger
2026-04-03 12:36:24 +08:00
committed by moonD4rk
parent 1b8bb1df3d
commit 1a3aea553e
10 changed files with 840 additions and 263 deletions
+3 -12
View File
@@ -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),
+6 -3
View File
@@ -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) {
+2 -197
View File
@@ -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 {
+235
View File
@@ -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
}
+259
View File
@@ -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)
})
}
-38
View File
@@ -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)
}
+209
View File
@@ -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
}
+63
View File
@@ -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)
}
+10 -13
View File
@@ -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")},
}