mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +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/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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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} }
|
||||
|
||||
// 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")},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user