mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-07-06 21:37:47 +02:00
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:
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
|
|
||||||
"github.com/moond4rk/hackbrowserdata/crypto"
|
"github.com/moond4rk/hackbrowserdata/crypto"
|
||||||
"github.com/moond4rk/hackbrowserdata/log"
|
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
|
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
|
||||||
)
|
)
|
||||||
@@ -39,22 +38,14 @@ func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error)
|
|||||||
|
|
||||||
var logins []types.LoginEntry
|
var logins []types.LoginEntry
|
||||||
for _, v := range gjson.GetBytes(data, "logins").Array() {
|
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()
|
url := v.Get("formSubmitURL").String()
|
||||||
if url == "" {
|
if url == "" {
|
||||||
url = v.Get("hostname").String()
|
url = v.Get("hostname").String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, _ := decryptPBE(v.Get("encryptedUsername").String(), masterKey)
|
||||||
|
pwd, _ := decryptPBE(v.Get("encryptedPassword").String(), masterKey)
|
||||||
|
|
||||||
logins = append(logins, types.LoginEntry{
|
logins = append(logins, types.LoginEntry{
|
||||||
URL: url,
|
URL: url,
|
||||||
Username: string(user),
|
Username: string(user),
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ func TestExtractPasswords_FormSubmitURLFallback(t *testing.T) {
|
|||||||
assert.Equal(t, "https://fallback.com", got[0].URL)
|
assert.Equal(t, "https://fallback.com", got[0].URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractPasswords_InvalidBase64Skipped(t *testing.T) {
|
func TestExtractPasswords_DecryptFailureKeepsEntry(t *testing.T) {
|
||||||
// Invalid base64 in encryptedUsername — entry should be skipped
|
// Invalid base64 — decryptPBE fails, but entry is still kept with empty user/pwd
|
||||||
json := `{
|
json := `{
|
||||||
"logins": [
|
"logins": [
|
||||||
{
|
{
|
||||||
@@ -94,7 +94,10 @@ func TestExtractPasswords_InvalidBase64Skipped(t *testing.T) {
|
|||||||
|
|
||||||
got, err := extractPasswords(testGlobalSalt, path)
|
got, err := extractPasswords(testGlobalSalt, path)
|
||||||
require.NoError(t, err)
|
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) {
|
func TestExtractPasswords_EmptyLogins(t *testing.T) {
|
||||||
|
|||||||
+2
-197
@@ -1,20 +1,15 @@
|
|||||||
package firefox
|
package firefox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
_ "modernc.org/sqlite" // sqlite3 driver TODO: replace with chooseable driver
|
_ "modernc.org/sqlite" // sqlite3 driver TODO: replace with chooseable driver
|
||||||
|
|
||||||
"github.com/moond4rk/hackbrowserdata/browserdata"
|
"github.com/moond4rk/hackbrowserdata/browserdata"
|
||||||
"github.com/moond4rk/hackbrowserdata/crypto"
|
|
||||||
"github.com/moond4rk/hackbrowserdata/log"
|
"github.com/moond4rk/hackbrowserdata/log"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
|
"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
|
// GetMasterKey returns master key of Firefox. from key4.db
|
||||||
func (f *Firefox) GetMasterKey() ([]byte, error) {
|
func (f *Firefox) GetMasterKey() ([]byte, error) {
|
||||||
tempFilename := types.FirefoxKey4.TempFilename()
|
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 os.Remove(tempFilename)
|
||||||
defer keyDB.Close()
|
|
||||||
|
|
||||||
metaItem1, metaItem2, err := queryMetaData(keyDB)
|
loginsPath := types.FirefoxPassword.TempFilename()
|
||||||
if err != nil {
|
return retrieveMasterKey(tempFilename, loginsPath)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Firefox) Name() string {
|
func (f *Firefox) Name() string {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -15,19 +15,16 @@ type sourcePath struct {
|
|||||||
|
|
||||||
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: false} }
|
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.
|
// 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.
|
// Firefox does not support SessionStorage or CreditCard extraction.
|
||||||
var firefoxSources = map[types.Category]dataSource{
|
var firefoxSources = map[types.Category][]sourcePath{
|
||||||
types.Password: {candidates: []sourcePath{file("logins.json")}},
|
types.Password: {file("logins.json")},
|
||||||
types.Cookie: {candidates: []sourcePath{file("cookies.sqlite")}},
|
types.Cookie: {file("cookies.sqlite")},
|
||||||
types.History: {candidates: []sourcePath{file("places.sqlite")}},
|
types.History: {file("places.sqlite")},
|
||||||
types.Download: {candidates: []sourcePath{file("places.sqlite")}},
|
types.Download: {file("places.sqlite")},
|
||||||
types.Bookmark: {candidates: []sourcePath{file("places.sqlite")}},
|
types.Bookmark: {file("places.sqlite")},
|
||||||
types.Extension: {candidates: []sourcePath{file("extensions.json")}},
|
types.Extension: {file("extensions.json")},
|
||||||
types.LocalStorage: {candidates: []sourcePath{file("webappsstore.sqlite")}},
|
types.LocalStorage: {file("webappsstore.sqlite")},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user