From d2b2955d6c8748f85ab7c6f24e33c117b3e515b4 Mon Sep 17 00:00:00 2001 From: slimwang Date: Tue, 25 Aug 2026 11:13:10 +0800 Subject: [PATCH] feat(chromium): extract passwords from the account-synced store (#637) Chromium keeps account-synced credentials in a separate "Login Data For Account" DB. Entries carry a Store field because the same credential can legitimately exist in both stores. --- browser/chromium/archive.go | 7 ++ browser/chromium/archive_test.go | 35 ++++++++++ browser/chromium/extract_password.go | 23 ++++++- browser/chromium/extract_password_test.go | 20 +++++- browser/chromium/profile.go | 61 ++++++++++++++++- browser/chromium/profile_test.go | 82 +++++++++++++++++++++++ browser/chromium/source.go | 2 +- browser/firefox/extract_password.go | 1 + browser/safari/extract_password.go | 1 + output/output_test.go | 10 +-- output/reflect_test.go | 10 +-- types/models.go | 9 +++ 12 files changed, 245 insertions(+), 16 deletions(-) diff --git a/browser/chromium/archive.go b/browser/chromium/archive.go index ae1f93c..ab59583 100644 --- a/browser/chromium/archive.go +++ b/browser/chromium/archive.go @@ -59,6 +59,13 @@ func (b *Browser) ArchiveSources(categories []types.Category) []ArchiveSource { IsDir: rp.isDir, }) } + if accountPath, ok := p.accountPasswordPath(categories); ok { + out = append(out, ArchiveSource{ + AbsPath: accountPath, + LayoutRel: path.Join(profileRel, accountLoginData), + IsDir: false, + }) + } } return out } diff --git a/browser/chromium/archive_test.go b/browser/chromium/archive_test.go index ecc13c7..72d3cc5 100644 --- a/browser/chromium/archive_test.go +++ b/browser/chromium/archive_test.go @@ -86,3 +86,38 @@ func TestArchiveSources_FlatLayoutNoExtraLevel(t *testing.T) { t.Errorf("expected History at archive root, got %+v", srcs) } } + +// Restoring an archive must reproduce both password DBs, otherwise account-synced credentials +// are lost on the round trip even though a live extract would have found them. +func TestArchiveSources_IncludesBothPasswordDatabases(t *testing.T) { + udd := t.TempDir() + profileDir := filepath.Join(udd, "Default") + if err := os.MkdirAll(profileDir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"Preferences", "Login Data", accountLoginData} { + if err := os.WriteFile(filepath.Join(profileDir, name), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + + b, err := NewBrowser(types.BrowserConfig{Key: "chrome", Name: "chrome", Kind: types.Chromium, UserDataDir: udd}) + if err != nil || b == nil { + t.Fatalf("NewBrowser: b=%v err=%v", b, err) + } + + srcs := b.ArchiveSources([]types.Category{types.Password}) + + var gotLocal, gotAccount bool + for _, s := range srcs { + switch s.LayoutRel { + case "Default/Login Data": + gotLocal = true + case "Default/" + accountLoginData: + gotAccount = true + } + } + if !gotLocal || !gotAccount { + t.Errorf("expected both password DBs archived (local=%v account=%v), got %+v", gotLocal, gotAccount, srcs) + } +} diff --git a/browser/chromium/extract_password.go b/browser/chromium/extract_password.go index 159c417..ae87fac 100644 --- a/browser/chromium/extract_password.go +++ b/browser/chromium/extract_password.go @@ -13,6 +13,7 @@ import ( ) const ( + accountLoginData = `Login Data For Account` defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins` countLoginQuery = `SELECT COUNT(*) FROM logins` @@ -20,11 +21,25 @@ const ( password_element, password_value, signon_realm, date_created FROM logins` ) -func extractPasswords(masterKeys masterkey.MasterKeys, path string) ([]types.LoginEntry, error) { - return extractPasswordsWithQuery(masterKeys, path, defaultLoginQuery) +// appendPasswords merges another login DB into dst. Entries are kept even when an identical +// credential already exists — the Store field records which DB each one came from. +func appendPasswords(masterKeys masterkey.MasterKeys, path, store string, dst []types.LoginEntry) ([]types.LoginEntry, error) { + logins, err := extractPasswords(masterKeys, path, store) + if err != nil { + return dst, err + } + dst = append(dst, logins...) + sort.SliceStable(dst, func(i, j int) bool { + return dst[i].CreatedAt.After(dst[j].CreatedAt) + }) + return dst, nil } -func extractPasswordsWithQuery(masterKeys masterkey.MasterKeys, path, query string) ([]types.LoginEntry, error) { +func extractPasswords(masterKeys masterkey.MasterKeys, path, store string) ([]types.LoginEntry, error) { + return extractPasswordsWithQuery(masterKeys, path, store, defaultLoginQuery) +} + +func extractPasswordsWithQuery(masterKeys masterkey.MasterKeys, path, store, query string) ([]types.LoginEntry, error) { logins, err := sqliteutil.QueryRows(path, false, query, func(rows *sql.Rows) (types.LoginEntry, error) { var url, username string @@ -39,6 +54,7 @@ func extractPasswordsWithQuery(masterKeys masterkey.MasterKeys, path, query stri Username: username, Password: string(password), CreatedAt: timeEpoch(created), + Store: store, }, nil }) if err != nil { @@ -75,6 +91,7 @@ func extractYandexPasswords(masterKeys masterkey.MasterKeys, path string) ([]typ URL: originURL, Username: usernameVal, CreatedAt: timeEpoch(created), + Store: types.PasswordStoreLocal, } aad := yandexLoginAAD(originURL, usernameElem, usernameVal, passwordElem, signonRealm, nil) plaintext, err := crypto.AESGCMDecryptBlob(dataKey, passwordValue, aad) diff --git a/browser/chromium/extract_password_test.go b/browser/chromium/extract_password_test.go index 8d62711..bdcecb5 100644 --- a/browser/chromium/extract_password_test.go +++ b/browser/chromium/extract_password_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/moond4rk/hackbrowserdata/masterkey" + "github.com/moond4rk/hackbrowserdata/types" ) func setupLoginDB(t *testing.T) string { @@ -22,7 +23,7 @@ func setupLoginDB(t *testing.T) string { func TestExtractPasswords(t *testing.T) { path := setupLoginDB(t) - got, err := extractPasswords(masterkey.MasterKeys{}, path) + got, err := extractPasswords(masterkey.MasterKeys{}, path, types.PasswordStoreLocal) require.NoError(t, err) require.Len(t, got, 2) @@ -35,6 +36,23 @@ func TestExtractPasswords(t *testing.T) { assert.False(t, got[0].CreatedAt.IsZero()) // Password is empty because masterKey is nil (decrypt returns empty) assert.Empty(t, got[0].Password) + assert.Equal(t, types.PasswordStoreLocal, got[0].Store) +} + +// A credential whose blob cannot be decrypted must still be reported, otherwise a missing +// master key would silently shrink the export instead of yielding empty passwords. +func TestExtractPasswords_UndecryptableBlobStillListed(t *testing.T) { + path := createTestDB(t, "Login Data", loginsSchema, + insertLogin("https://enc.com", "https://enc.com/login", "carol", "763130deadbeef", 13350000000000000), + ) + + got, err := extractPasswords(masterkey.MasterKeys{}, path, types.PasswordStoreAccount) + require.NoError(t, err) + require.Len(t, got, 1) + + assert.Equal(t, "carol", got[0].Username) + assert.Empty(t, got[0].Password) + assert.Equal(t, types.PasswordStoreAccount, got[0].Store) } func TestCountPasswords(t *testing.T) { diff --git a/browser/chromium/profile.go b/browser/chromium/profile.go index 8f3d67b..be7c737 100644 --- a/browser/chromium/profile.go +++ b/browser/chromium/profile.go @@ -1,6 +1,7 @@ package chromium import ( + "os" "path/filepath" "github.com/moond4rk/hackbrowserdata/filemanager" @@ -47,6 +48,14 @@ func (p *profile) extract(masterKeys masterkey.MasterKeys, categories []types.Ca } p.extractCategory(data, cat, masterKeys, path) } + if accountDB, ok := p.acquireAccountPasswords(session, categories); ok { + passwords, err := appendPasswords(masterKeys, accountDB, types.PasswordStoreAccount, data.Passwords) + if err != nil { + log.Debugf("extract account passwords for %s: %v", p.label(), err) + } else { + data.Passwords = passwords + } + } return data } @@ -68,9 +77,59 @@ func (p *profile) count(categories []types.Category) map[types.Category]int { } counts[cat] = p.countCategory(cat, path) } + if accountDB, ok := p.acquireAccountPasswords(session, categories); ok { + if count, err := countPasswords(accountDB); err != nil { + log.Debugf("count account passwords for %s: %v", p.label(), err) + } else { + counts[types.Password] += count + } + } return counts } +// acquireAccountPasswords copies the account-synced password DB into the session, when the +// profile has one alongside the local DB. +func (p *profile) acquireAccountPasswords(session *filemanager.Session, categories []types.Category) (string, bool) { + accountPath, ok := p.accountPasswordPath(categories) + if !ok { + return "", false + } + dst := filepath.Join(session.TempDir(), "password_account") + if err := session.Acquire(accountPath, dst, false); err != nil { + log.Debugf("acquire account passwords for %s: %v", p.label(), err) + return "", false + } + return dst, true +} + +// primaryPasswordStore labels the resolved primary login DB: a profile that only ever synced +// passwords has no local Login Data, so the account DB wins candidate resolution. +func (p *profile) primaryPasswordStore() string { + if primary, ok := p.sourcePaths[types.Password]; ok && primary.rel == accountLoginData { + return types.PasswordStoreAccount + } + return types.PasswordStoreLocal +} + +// accountPasswordPath returns the account-synced password DB when the primary source resolved to +// the local Login Data DB. If only Login Data For Account exists, it is already the primary source. +func (p *profile) accountPasswordPath(categories []types.Category) (string, bool) { + requested := false + for _, cat := range categories { + if cat == types.Password { + requested = true + break + } + } + primary, ok := p.sourcePaths[types.Password] + if !requested || !ok || primary.rel == accountLoginData || p.kind == types.ChromiumYandex { + return "", false + } + path := filepath.Join(p.profileDir, accountLoginData) + info, err := os.Stat(path) + return path, err == nil && !info.IsDir() +} + // acquireFiles copies source files to the session temp directory. func (p *profile) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string { tempPaths := make(map[types.Category]string) @@ -102,7 +161,7 @@ func (p *profile) extractCategory(data *types.BrowserData, cat types.Category, m var err error switch cat { case types.Password: - data.Passwords, err = extractPasswords(masterKeys, path) + data.Passwords, err = extractPasswords(masterKeys, path, p.primaryPasswordStore()) case types.Cookie: data.Cookies, err = extractCookies(masterKeys, path) case types.History: diff --git a/browser/chromium/profile_test.go b/browser/chromium/profile_test.go index fdfdab1..5c0bc24 100644 --- a/browser/chromium/profile_test.go +++ b/browser/chromium/profile_test.go @@ -1,6 +1,7 @@ package chromium import ( + "database/sql" "os" "path/filepath" "testing" @@ -13,6 +14,19 @@ import ( "github.com/moond4rk/hackbrowserdata/types" ) +func createLoginDBAt(t *testing.T, path string, inserts ...string) { + t.Helper() + db, err := sql.Open("sqlite", path) + require.NoError(t, err) + defer db.Close() + _, err = db.Exec(loginsSchema) + require.NoError(t, err) + for _, stmt := range inserts { + _, err = db.Exec(stmt) + require.NoError(t, err) + } +} + // TestExtractCategory_CustomExtractor verifies that extractCategory dispatches // through a registered extractor instead of the default switch logic. func TestExtractCategory_CustomExtractor(t *testing.T) { @@ -81,6 +95,74 @@ func TestAcquireFiles(t *testing.T) { } } +func TestExtractAndCountPasswords_MergesLocalAndAccountDatabases(t *testing.T) { + profileDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(profileDir, "Preferences"), []byte(`{}`), 0o600)) + createLoginDBAt(t, filepath.Join(profileDir, "Login Data"), + insertLogin("https://local.example", "", "local-user", "", 13340000000000000)) + createLoginDBAt(t, filepath.Join(profileDir, accountLoginData), + insertLogin("https://account.example", "", "account-user", "", 13360000000000000)) + + p := &profile{ + profileDir: profileDir, + browserName: "Chrome", + kind: types.Chromium, + sourcePaths: resolveSourcePaths(chromiumSources, profileDir), + } + data := p.extract(masterkey.MasterKeys{}, []types.Category{types.Password}) + require.Len(t, data.Passwords, 2) + assert.Equal(t, "account-user", data.Passwords[0].Username) + assert.Equal(t, types.PasswordStoreAccount, data.Passwords[0].Store) + assert.Equal(t, "local-user", data.Passwords[1].Username) + assert.Equal(t, types.PasswordStoreLocal, data.Passwords[1].Store) + assert.Equal(t, 2, p.count([]types.Category{types.Password})[types.Password]) +} + +// A credential synced to both stores is reported twice; the Store field is what tells them +// apart, so it must survive the merge rather than being deduplicated away. +func TestExtractPasswords_IdenticalCredentialInBothStoresKeepsBoth(t *testing.T) { + profileDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(profileDir, "Preferences"), []byte(`{}`), 0o600)) + login := insertLogin("https://same.example", "", "same-user", "", 13340000000000000) + createLoginDBAt(t, filepath.Join(profileDir, "Login Data"), login) + createLoginDBAt(t, filepath.Join(profileDir, accountLoginData), login) + + p := &profile{ + profileDir: profileDir, + browserName: "Chrome", + kind: types.Chromium, + sourcePaths: resolveSourcePaths(chromiumSources, profileDir), + } + data := p.extract(masterkey.MasterKeys{}, []types.Category{types.Password}) + + require.Len(t, data.Passwords, 2) + stores := []string{data.Passwords[0].Store, data.Passwords[1].Store} + assert.ElementsMatch(t, []string{types.PasswordStoreLocal, types.PasswordStoreAccount}, stores) + assert.Equal(t, 2, p.count([]types.Category{types.Password})[types.Password]) +} + +// A profile that only ever synced passwords has no local DB, so the account DB wins candidate +// resolution and must not also be picked up as a second source. +func TestExtractAndCountPasswords_AccountDatabaseOnly(t *testing.T) { + profileDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(profileDir, "Preferences"), []byte(`{}`), 0o600)) + createLoginDBAt(t, filepath.Join(profileDir, accountLoginData), + insertLogin("https://account.example", "", "account-user", "", 13360000000000000)) + + p := &profile{ + profileDir: profileDir, + browserName: "Chrome", + kind: types.Chromium, + sourcePaths: resolveSourcePaths(chromiumSources, profileDir), + } + data := p.extract(masterkey.MasterKeys{}, []types.Category{types.Password}) + + require.Len(t, data.Passwords, 1) + assert.Equal(t, "account-user", data.Passwords[0].Username) + assert.Equal(t, types.PasswordStoreAccount, data.Passwords[0].Store) + assert.Equal(t, 1, p.count([]types.Category{types.Password})[types.Password]) +} + func TestCountCategory(t *testing.T) { t.Run("History", func(t *testing.T) { path := setupHistoryDB(t) diff --git a/browser/chromium/source.go b/browser/chromium/source.go index dada72c..309f368 100644 --- a/browser/chromium/source.go +++ b/browser/chromium/source.go @@ -21,7 +21,7 @@ func dir(rel string) sourcePath { return sourcePath{rel: rel, isDir: true} } // Each category maps to one or more candidate paths tried in priority order; // the first existing path wins. var chromiumSources = map[types.Category][]sourcePath{ - types.Password: {file("Login Data")}, + types.Password: {file("Login Data"), file(accountLoginData)}, types.Cookie: {file("Network/Cookies"), file("Cookies")}, types.History: {file("History")}, types.Download: {file("History")}, diff --git a/browser/firefox/extract_password.go b/browser/firefox/extract_password.go index b881b08..58ef79b 100644 --- a/browser/firefox/extract_password.go +++ b/browser/firefox/extract_password.go @@ -69,6 +69,7 @@ func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) Username: string(user), Password: string(pwd), CreatedAt: firefoxMillis(v.Get("timeCreated").Int()), + Store: types.PasswordStoreLocal, }) } if decryptFails > 0 { diff --git a/browser/safari/extract_password.go b/browser/safari/extract_password.go index aa791c2..cb3d3f6 100644 --- a/browser/safari/extract_password.go +++ b/browser/safari/extract_password.go @@ -28,6 +28,7 @@ func extractPasswords(keychainPassword string) ([]types.LoginEntry, error) { Username: p.Account, Password: p.PlainPassword, CreatedAt: p.Created.UTC(), + Store: types.PasswordStoreLocal, }) } diff --git a/output/output_test.go b/output/output_test.go index 346c3e7..2d42cf1 100644 --- a/output/output_test.go +++ b/output/output_test.go @@ -20,7 +20,7 @@ var testTime = time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC) func chromeData() *types.BrowserData { return &types.BrowserData{ Passwords: []types.LoginEntry{ - {URL: "https://example.com", Username: "alice", Password: "secret", CreatedAt: testTime}, + {URL: "https://example.com", Username: "alice", Password: "secret", CreatedAt: testTime, Store: types.PasswordStoreAccount}, }, Cookies: []types.CookieEntry{ { @@ -38,7 +38,7 @@ func chromeData() *types.BrowserData { func firefoxData() *types.BrowserData { return &types.BrowserData{ Passwords: []types.LoginEntry{ - {URL: "https://reddit.com", Username: "bob", Password: "hunter2", CreatedAt: testTime}, + {URL: "https://reddit.com", Username: "bob", Password: "hunter2", CreatedAt: testTime, Store: types.PasswordStoreLocal}, }, Cookies: []types.CookieEntry{ { @@ -88,9 +88,9 @@ func TestWrite_CSV_Password(t *testing.T) { records := readCSV(t, filepath.Join(dir, "password.csv")) require.Len(t, records, 3) // header + 2 rows - assert.Equal(t, []string{"browser", "profile", "url", "username", "password", "created_at"}, records[0]) - assert.Equal(t, []string{"Chrome", "Default", "https://example.com", "alice", "secret", "2026-01-15T10:30:00Z"}, records[1]) - assert.Equal(t, []string{"Firefox", "abc123", "https://reddit.com", "bob", "hunter2", "2026-01-15T10:30:00Z"}, records[2]) + assert.Equal(t, []string{"browser", "profile", "url", "username", "password", "created_at", "store"}, records[0]) + assert.Equal(t, []string{"Chrome", "Default", "https://example.com", "alice", "secret", "2026-01-15T10:30:00Z", "account"}, records[1]) + assert.Equal(t, []string{"Firefox", "abc123", "https://reddit.com", "bob", "hunter2", "2026-01-15T10:30:00Z", "local"}, records[2]) } func TestWrite_CSV_Cookie(t *testing.T) { diff --git a/output/reflect_test.go b/output/reflect_test.go index 1032dfb..86dd790 100644 --- a/output/reflect_test.go +++ b/output/reflect_test.go @@ -53,7 +53,7 @@ func TestStructCSVHeader(t *testing.T) { entry any expect []string }{ - {"LoginEntry", types.LoginEntry{}, []string{"url", "username", "password", "created_at"}}, + {"LoginEntry", types.LoginEntry{}, []string{"url", "username", "password", "created_at", "store"}}, {"CookieEntry", types.CookieEntry{}, []string{"host", "path", "name", "value", "is_secure", "is_http_only", "has_expire", "is_persistent", "expire_at", "created_at", "same_site"}}, {"BookmarkEntry", types.BookmarkEntry{}, []string{"id", "name", "type", "url", "folder", "created_at"}}, {"HistoryEntry", types.HistoryEntry{}, []string{"url", "title", "visit_count", "last_visit"}}, @@ -77,8 +77,8 @@ func TestStructCSVRow(t *testing.T) { }{ { "LoginEntry", - types.LoginEntry{URL: "https://example.com", Username: "alice", Password: "secret", CreatedAt: refTime}, - []string{"https://example.com", "alice", "secret", "2026-01-15T10:30:00Z"}, + types.LoginEntry{URL: "https://example.com", Username: "alice", Password: "secret", CreatedAt: refTime, Store: types.PasswordStoreLocal}, + []string{"https://example.com", "alice", "secret", "2026-01-15T10:30:00Z", "local"}, }, { "CookieEntry", @@ -107,7 +107,7 @@ func TestStructCSVRow(t *testing.T) { { "zero_time", types.LoginEntry{URL: "https://a.com"}, - []string{"https://a.com", "", "", ""}, + []string{"https://a.com", "", "", "", ""}, }, } for _, tt := range tests { @@ -141,7 +141,7 @@ func TestRowMarshalJSON(t *testing.T) { assert.Equal(t, "https://example.com", m["url"]) assert.Equal(t, "alice", m["username"]) assert.Equal(t, "secret", m["password"]) - assert.Len(t, m, 6) // browser + profile + 4 entry fields + assert.Len(t, m, 7) // browser + profile + 5 entry fields // Verify field order: browser, profile come before entry fields. raw := string(data) diff --git a/types/models.go b/types/models.go index 7f3864b..553c7f6 100644 --- a/types/models.go +++ b/types/models.go @@ -2,12 +2,21 @@ package types import "time" +// Chromium keeps profile-local credentials in "Login Data" and account-synced ones in +// "Login Data For Account". The same credential may exist in both, so exported entries +// carry their origin instead of being silently merged. +const ( + PasswordStoreLocal = "local" + PasswordStoreAccount = "account" +) + // LoginEntry represents a single saved login credential. type LoginEntry struct { URL string `json:"url" csv:"url"` Username string `json:"username" csv:"username"` Password string `json:"password" csv:"password"` CreatedAt time.Time `json:"created_at" csv:"created_at"` + Store string `json:"store" csv:"store"` } // CookieEntry represents a single browser cookie.