diff --git a/rfcs/001-architecture-refactoring.md b/rfcs/001-architecture-refactoring.md index 4b0711c..7247424 100644 --- a/rfcs/001-architecture-refactoring.md +++ b/rfcs/001-architecture-refactoring.md @@ -41,6 +41,7 @@ hackbrowserdata/ │ │ ├── chromium_windows.go # platform key retriever wiring │ │ ├── chromium_linux.go # platform key retriever wiring │ │ ├── source.go # chromiumSources, yandexSources maps +│ │ ├── decrypt.go # decryptValue() — Chromium-specific DPAPI/AES fallback │ │ ├── extract_password.go # extractPasswords() + default SQL query │ │ ├── extract_cookie.go # extractCookies() + default SQL query │ │ ├── extract_history.go # extractHistories() + default SQL query @@ -70,11 +71,6 @@ hackbrowserdata/ │ ├── browserdata.go # BrowserData struct (typed slices) │ ├── output.go # BrowserData.Output() — CSV/JSON writer │ ├── output_test.go -│ │ -│ └── datautil/ -│ ├── sqlite.go # QuerySQLite() helper -│ ├── query.go # queryRows[T]() generic helper (Go 1.20) -│ └── decrypt.go # DecryptChromiumValue() helper │ ├── crypto/ │ ├── crypto.go # AESCBCDecrypt, AESGCMDecrypt, DES3, PKCS5 @@ -114,6 +110,9 @@ hackbrowserdata/ ├── fileutil/ │ ├── fileutil.go # renamed from filetutil.go │ └── fileutil_test.go + ├── sqliteutil/ + │ ├── sqlite.go # QuerySQLite() helper + │ └── query.go # QueryRows[T]() generic helper (Go 1.20) ├── typeutil/ │ ├── typeutil.go │ └── typeutil_test.go @@ -126,7 +125,7 @@ hackbrowserdata/ | Change | Current | Target | |--------|---------|--------| -| **New** `browserdata/datautil/` | — | SQLite + decrypt helpers | +| **New** `utils/sqliteutil/` | — | QuerySQLite + QueryRows[T] helpers | | **New** `filemanager/` | — | Session-based temp file management | | **New** `crypto/keyretriever/` | — | Master key retrieval abstraction | | **New** `crypto/version.go` | — | Cipher version detection | @@ -155,9 +154,9 @@ hackbrowserdata/ | Strategy chain | `keyretriever` | `ChainRetriever` | `keyretriever.go` | | Cipher version | `crypto` | `CipherVersion` | `version.go` | | Temp file session | `filemanager` | `Session` | `session.go` | -| SQLite helper | `datautil` | `QuerySQLite` (func) | `sqlite.go` | -| Generic query helper | `datautil` | `queryRows[T]` (func) | `query.go` | -| Decrypt helper | `datautil` | `DecryptChromiumValue` (func) | `decrypt.go` | +| SQLite helper | `sqliteutil` | `QuerySQLite` (func) | `sqlite.go` | +| Generic query helper | `sqliteutil` | `QueryRows[T]` (func) | `query.go` | +| Chromium decrypt | `chromium` | `decryptValue` (unexported func) | `decrypt.go` | ### Public vs private @@ -751,7 +750,7 @@ data.Output(dir, b.Name(), format) // output whatever succeeded | Phase | Scope | Risk | |-------|-------|------| | 1 | `types/category.go` + `types/models.go` + `browserdata/browserdata.go` | Zero — new files only | -| 2 | `browserdata/datautil/sqlite.go` + `decrypt.go` | Zero — new files only | +| 2 | `utils/sqliteutil/sqlite.go` + `query.go` | Zero — new files only | | 3 | `crypto/version.go`, rename `AESCBCDecrypt` | Low — internal crypto changes | | 4 | `crypto/keyretriever/` | Low — new package | | 5 | `browser/chromium/source.go` + `extract_*.go` | Medium — new extract methods | diff --git a/rfcs/002-browserdata-and-file-acquisition-refactoring.md b/rfcs/002-browserdata-and-file-acquisition-refactoring.md index 0c269d9..66c6a54 100644 --- a/rfcs/002-browserdata-and-file-acquisition-refactoring.md +++ b/rfcs/002-browserdata-and-file-acquisition-refactoring.md @@ -289,12 +289,12 @@ func platformBrowsers() []Config { --- -## 3. Shared Helpers: `browserdata/datautil/` +## 3. Shared Helpers: `utils/sqliteutil/` ### 3.1 SQLite query helper ```go -// browserdata/datautil/sqlite.go +// utils/sqliteutil/sqlite.go func QuerySQLite(dbPath string, journalOff bool, query string, scanFn func(*sql.Rows) error) error { db, err := sql.Open("sqlite", dbPath) @@ -322,7 +322,7 @@ func QuerySQLite(dbPath string, journalOff bool, query string, scanFn func(*sql. ### 3.2 Generic query helper — `datautil/query.go` ```go -package datautil +package sqliteutil // queryRows is a generic helper (Go 1.20) that wraps QuerySQLite // and collects results into a typed slice. Each extract method @@ -341,21 +341,7 @@ func QueryRows[T any](path string, journalOff bool, query string, scanRow func(* ### 3.3 Chromium decrypt helper -```go -// browserdata/datautil/decrypt.go - -func DecryptChromiumValue(masterKey, encrypted []byte) ([]byte, error) { - if len(encrypted) == 0 { return nil, nil } - if len(masterKey) == 0 { - return crypto.DecryptWithDPAPI(encrypted) - } - value, err := crypto.DecryptWithDPAPI(encrypted) - if err != nil { - value, err = crypto.DecryptWithChromium(masterKey, encrypted) - } - return value, err -} -``` +Moved to `browser/chromium/decrypt.go` as an unexported function `decryptValue()`. It is Chromium-specific (DPAPI → AES-GCM/CBC fallback) and only used by Chromium extract methods. See RFC-001 for details. --- @@ -371,7 +357,7 @@ Each extract method lives in its own `extract_*.go` file inside the browser engi const defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins` func (c *Chromium) extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) { - logins, err := datautil.QueryRows(path, false, c.query(types.Password), + logins, err := sqliteutil.QueryRows(path, false, c.query(types.Password), func(rows *sql.Rows) (types.LoginEntry, error) { var url, username string var pwd []byte @@ -379,7 +365,7 @@ func (c *Chromium) extractPasswords(masterKey []byte, path string) ([]types.Logi if err := rows.Scan(&url, &username, &pwd, &created); err != nil { return types.LoginEntry{}, err } - password, _ := datautil.DecryptChromiumValue(masterKey, pwd) + password, _ := decryptValue(masterKey, pwd) return types.LoginEntry{ URL: url, Username: username, @@ -406,7 +392,7 @@ const defaultCookieQuery = `SELECT name, encrypted_value, host_key, path, has_expires, is_persistent FROM cookies` func (c *Chromium) extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error) { - cookies, err := datautil.QueryRows(path, false, c.query(types.Cookie), + cookies, err := sqliteutil.QueryRows(path, false, c.query(types.Cookie), func(rows *sql.Rows) (types.CookieEntry, error) { var ( name, host, path string @@ -420,7 +406,7 @@ func (c *Chromium) extractCookies(masterKey []byte, path string) ([]types.Cookie return types.CookieEntry{}, err } - value, _ := datautil.DecryptChromiumValue(masterKey, encryptedValue) + value, _ := decryptValue(masterKey, encryptedValue) return types.CookieEntry{ Name: name, Host: host, @@ -503,7 +489,7 @@ const firefoxCookieQuery = `SELECT name, value, host, path, creationTime, expiry, isSecure, isHttpOnly FROM moz_cookies` func (f *Firefox) extractCookies(path string) ([]types.CookieEntry, error) { - cookies, err := datautil.QueryRows(path, true, firefoxCookieQuery, + cookies, err := sqliteutil.QueryRows(path, true, firefoxCookieQuery, func(rows *sql.Rows) (types.CookieEntry, error) { var ( name, value, host, path string @@ -777,8 +763,8 @@ func formatFilename(browserName, dataName, format string) string { 1. `types/category.go` — Category enum 2. `types/models.go` — all *Entry structs 3. `browserdata/browserdata.go` — BrowserData struct -4. `browserdata/datautil/sqlite.go` — QuerySQLite() -5. `browserdata/datautil/decrypt.go` — DecryptChromiumValue() +4. `utils/sqliteutil/sqlite.go` — QuerySQLite() +5. `browser/chromium/decrypt.go` — decryptValue() (Chromium-specific, unexported) 6. `filemanager/session.go` — Session ### Phase 2: Extract methods (new files, coexist with old code) @@ -839,5 +825,5 @@ GOOS=darwin GOARCH=amd64 go build ./cmd/hack-browser-data/ | File source mapping | — | covered | | File acquisition | — | covered | | Extract methods | — | covered | -| datautil helpers | — | covered | +| sqliteutil helpers | — | covered | | Output | — | covered | diff --git a/utils/sqliteutil/query.go b/utils/sqliteutil/query.go new file mode 100644 index 0000000..4b58008 --- /dev/null +++ b/utils/sqliteutil/query.go @@ -0,0 +1,21 @@ +package sqliteutil + +import "database/sql" + +// QueryRows is a generic helper (Go 1.18+) that wraps QuerySQLite and collects +// results into a typed slice. Each extract method only needs to provide the +// scan function that converts one database row into a typed value. +// +// Rows that fail to scan are skipped (logged at debug level by QuerySQLite). +func QueryRows[T any](dbPath string, journalOff bool, query string, scanRow func(*sql.Rows) (T, error)) ([]T, error) { + var items []T + err := QuerySQLite(dbPath, journalOff, query, func(rows *sql.Rows) error { + item, err := scanRow(rows) + if err != nil { + return err + } + items = append(items, item) + return nil + }) + return items, err +} diff --git a/utils/sqliteutil/sqlite.go b/utils/sqliteutil/sqlite.go new file mode 100644 index 0000000..f367031 --- /dev/null +++ b/utils/sqliteutil/sqlite.go @@ -0,0 +1,51 @@ +package sqliteutil + +import ( + "database/sql" + "fmt" + "os" + + // sqlite3 driver for database/sql + _ "modernc.org/sqlite" + + "github.com/moond4rk/hackbrowserdata/log" +) + +// QuerySQLite opens a SQLite database, optionally disables journal mode (required +// for Firefox databases), runs the query, and calls scanFn for each row. +// +// It validates the database file exists before opening to prevent sql.Open from +// silently creating an empty database. +// +// scanFn should return nil to continue iteration, or an error to skip the current +// row (the error is logged at debug level and iteration continues). +func QuerySQLite(dbPath string, journalOff bool, query string, scanFn func(*sql.Rows) error) error { + if _, err := os.Stat(dbPath); err != nil { + return fmt.Errorf("database file: %w", err) + } + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return err + } + defer db.Close() + + if journalOff { + if _, err := db.Exec("PRAGMA journal_mode=off"); err != nil { + return err + } + } + + rows, err := db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + if err := scanFn(rows); err != nil { + log.Debugf("scan row error: %v", err) + continue + } + } + return rows.Err() +} diff --git a/utils/sqliteutil/sqlite_test.go b/utils/sqliteutil/sqlite_test.go new file mode 100644 index 0000000..2fe63b1 --- /dev/null +++ b/utils/sqliteutil/sqlite_test.go @@ -0,0 +1,138 @@ +package sqliteutil + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQuerySQLite(t *testing.T) { + // Create a temp SQLite database + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + + _, err = db.Exec("CREATE TABLE items (id INTEGER, name TEXT)") + require.NoError(t, err) + _, err = db.Exec("INSERT INTO items VALUES (1, 'alpha'), (2, 'beta'), (3, 'gamma')") + require.NoError(t, err) + require.NoError(t, db.Close()) + + // Query using our helper + var names []string + err = QuerySQLite(dbPath, false, "SELECT name FROM items ORDER BY id", func(rows *sql.Rows) error { + var name string + if err := rows.Scan(&name); err != nil { + return err + } + names = append(names, name) + return nil + }) + + assert.NoError(t, err) + assert.Equal(t, []string{"alpha", "beta", "gamma"}, names) +} + +func TestQuerySQLite_JournalOff(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + _, err = db.Exec("CREATE TABLE t (v TEXT)") + require.NoError(t, err) + _, err = db.Exec("INSERT INTO t VALUES ('ok')") + require.NoError(t, err) + require.NoError(t, db.Close()) + + var values []string + err = QuerySQLite(dbPath, true, "SELECT v FROM t", func(rows *sql.Rows) error { + var v string + if err := rows.Scan(&v); err != nil { + return err + } + values = append(values, v) + return nil + }) + assert.NoError(t, err) + assert.Equal(t, []string{"ok"}, values) +} + +func TestQuerySQLite_FileNotFound(t *testing.T) { + err := QuerySQLite("/nonexistent/path.db", false, "SELECT 1", func(rows *sql.Rows) error { + return nil + }) + assert.Error(t, err) +} + +func TestQuerySQLite_BadQuery(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + _, err = db.Exec("CREATE TABLE t (v TEXT)") + require.NoError(t, err) + require.NoError(t, db.Close()) + + err = QuerySQLite(dbPath, false, "SELECT nonexistent FROM t", func(rows *sql.Rows) error { + return nil + }) + assert.Error(t, err) +} + +func TestQueryRows(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + _, err = db.Exec("CREATE TABLE users (name TEXT, age INTEGER)") + require.NoError(t, err) + _, err = db.Exec("INSERT INTO users VALUES ('alice', 30), ('bob', 25)") + require.NoError(t, err) + require.NoError(t, db.Close()) + + type user struct { + Name string + Age int + } + + users, err := QueryRows(dbPath, false, "SELECT name, age FROM users ORDER BY name", + func(rows *sql.Rows) (user, error) { + var u user + err := rows.Scan(&u.Name, &u.Age) + return u, err + }) + + assert.NoError(t, err) + assert.Equal(t, []user{{"alice", 30}, {"bob", 25}}, users) +} + +func TestQueryRows_Empty(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + _, err = db.Exec("CREATE TABLE empty (v TEXT)") + require.NoError(t, err) + require.NoError(t, db.Close()) + + results, err := QueryRows(dbPath, false, "SELECT v FROM empty", + func(rows *sql.Rows) (string, error) { + var v string + if err := rows.Scan(&v); err != nil { + return "", err + } + return v, nil + }) + + assert.NoError(t, err) + assert.Nil(t, results) +}