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.
This commit is contained in:
slimwang authored and GitHub committed 2026-08-25 11:13:10 +08:00
1 parent 9624e50a4f
commit d2b2955d6c
12 files changed
+245 -16

No files matched your search

+7
View File
@@ -59,6 +59,13 @@ func (b *Browser) ArchiveSources(categories []types.Category) []ArchiveSource {
IsDir: rp.isDir, 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 return out
} }
+35
View File
@@ -86,3 +86,38 @@ func TestArchiveSources_FlatLayoutNoExtraLevel(t *testing.T) {
t.Errorf("expected History at archive root, got %+v", srcs) 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)
}
}
+20 -3
View File
@@ -13,6 +13,7 @@ import (
) )
const ( const (
accountLoginData = `Login Data For Account`
defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins` defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins`
countLoginQuery = `SELECT COUNT(*) FROM logins` countLoginQuery = `SELECT COUNT(*) FROM logins`
@@ -20,11 +21,25 @@ const (
password_element, password_value, signon_realm, date_created FROM logins` password_element, password_value, signon_realm, date_created FROM logins`
) )
func extractPasswords(masterKeys masterkey.MasterKeys, path string) ([]types.LoginEntry, error) { // appendPasswords merges another login DB into dst. Entries are kept even when an identical
return extractPasswordsWithQuery(masterKeys, path, defaultLoginQuery) // 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, logins, err := sqliteutil.QueryRows(path, false, query,
func(rows *sql.Rows) (types.LoginEntry, error) { func(rows *sql.Rows) (types.LoginEntry, error) {
var url, username string var url, username string
@@ -39,6 +54,7 @@ func extractPasswordsWithQuery(masterKeys masterkey.MasterKeys, path, query stri
Username: username, Username: username,
Password: string(password), Password: string(password),
CreatedAt: timeEpoch(created), CreatedAt: timeEpoch(created),
Store: store,
}, nil }, nil
}) })
if err != nil { if err != nil {
@@ -75,6 +91,7 @@ func extractYandexPasswords(masterKeys masterkey.MasterKeys, path string) ([]typ
URL: originURL, URL: originURL,
Username: usernameVal, Username: usernameVal,
CreatedAt: timeEpoch(created), CreatedAt: timeEpoch(created),
Store: types.PasswordStoreLocal,
} }
aad := yandexLoginAAD(originURL, usernameElem, usernameVal, passwordElem, signonRealm, nil) aad := yandexLoginAAD(originURL, usernameElem, usernameVal, passwordElem, signonRealm, nil)
plaintext, err := crypto.AESGCMDecryptBlob(dataKey, passwordValue, aad) plaintext, err := crypto.AESGCMDecryptBlob(dataKey, passwordValue, aad)
+19 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types"
) )
func setupLoginDB(t *testing.T) string { func setupLoginDB(t *testing.T) string {
@@ -22,7 +23,7 @@ func setupLoginDB(t *testing.T) string {
func TestExtractPasswords(t *testing.T) { func TestExtractPasswords(t *testing.T) {
path := setupLoginDB(t) path := setupLoginDB(t)
got, err := extractPasswords(masterkey.MasterKeys{}, path) got, err := extractPasswords(masterkey.MasterKeys{}, path, types.PasswordStoreLocal)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, got, 2) require.Len(t, got, 2)
@@ -35,6 +36,23 @@ func TestExtractPasswords(t *testing.T) {
assert.False(t, got[0].CreatedAt.IsZero()) assert.False(t, got[0].CreatedAt.IsZero())
// Password is empty because masterKey is nil (decrypt returns empty) // Password is empty because masterKey is nil (decrypt returns empty)
assert.Empty(t, got[0].Password) 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) { func TestCountPasswords(t *testing.T) {
+60 -1
View File
@@ -1,6 +1,7 @@
package chromium package chromium
import ( import (
"os"
"path/filepath" "path/filepath"
"github.com/moond4rk/hackbrowserdata/filemanager" "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) 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 return data
} }
@@ -68,9 +77,59 @@ func (p *profile) count(categories []types.Category) map[types.Category]int {
} }
counts[cat] = p.countCategory(cat, path) 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 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. // acquireFiles copies source files to the session temp directory.
func (p *profile) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string { func (p *profile) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
tempPaths := make(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 var err error
switch cat { switch cat {
case types.Password: case types.Password:
data.Passwords, err = extractPasswords(masterKeys, path) data.Passwords, err = extractPasswords(masterKeys, path, p.primaryPasswordStore())
case types.Cookie: case types.Cookie:
data.Cookies, err = extractCookies(masterKeys, path) data.Cookies, err = extractCookies(masterKeys, path)
case types.History: case types.History:
+82
View File
@@ -1,6 +1,7 @@
package chromium package chromium
import ( import (
"database/sql"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -13,6 +14,19 @@ import (
"github.com/moond4rk/hackbrowserdata/types" "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 // TestExtractCategory_CustomExtractor verifies that extractCategory dispatches
// through a registered extractor instead of the default switch logic. // through a registered extractor instead of the default switch logic.
func TestExtractCategory_CustomExtractor(t *testing.T) { 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) { func TestCountCategory(t *testing.T) {
t.Run("History", func(t *testing.T) { t.Run("History", func(t *testing.T) {
path := setupHistoryDB(t) path := setupHistoryDB(t)
+1 -1
View File
@@ -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; // Each category maps to one or more candidate paths tried in priority order;
// the first existing path wins. // the first existing path wins.
var chromiumSources = map[types.Category][]sourcePath{ 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.Cookie: {file("Network/Cookies"), file("Cookies")},
types.History: {file("History")}, types.History: {file("History")},
types.Download: {file("History")}, types.Download: {file("History")},
+1
View File
@@ -69,6 +69,7 @@ func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error)
Username: string(user), Username: string(user),
Password: string(pwd), Password: string(pwd),
CreatedAt: firefoxMillis(v.Get("timeCreated").Int()), CreatedAt: firefoxMillis(v.Get("timeCreated").Int()),
Store: types.PasswordStoreLocal,
}) })
} }
if decryptFails > 0 { if decryptFails > 0 {
+1
View File
@@ -28,6 +28,7 @@ func extractPasswords(keychainPassword string) ([]types.LoginEntry, error) {
Username: p.Account, Username: p.Account,
Password: p.PlainPassword, Password: p.PlainPassword,
CreatedAt: p.Created.UTC(), CreatedAt: p.Created.UTC(),
Store: types.PasswordStoreLocal,
}) })
} }
+5 -5
View File
@@ -20,7 +20,7 @@ var testTime = time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)
func chromeData() *types.BrowserData { func chromeData() *types.BrowserData {
return &types.BrowserData{ return &types.BrowserData{
Passwords: []types.LoginEntry{ 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{ Cookies: []types.CookieEntry{
{ {
@@ -38,7 +38,7 @@ func chromeData() *types.BrowserData {
func firefoxData() *types.BrowserData { func firefoxData() *types.BrowserData {
return &types.BrowserData{ return &types.BrowserData{
Passwords: []types.LoginEntry{ 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{ Cookies: []types.CookieEntry{
{ {
@@ -88,9 +88,9 @@ func TestWrite_CSV_Password(t *testing.T) {
records := readCSV(t, filepath.Join(dir, "password.csv")) records := readCSV(t, filepath.Join(dir, "password.csv"))
require.Len(t, records, 3) // header + 2 rows 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{"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"}, records[1]) 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"}, records[2]) 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) { func TestWrite_CSV_Cookie(t *testing.T) {
+5 -5
View File
@@ -53,7 +53,7 @@ func TestStructCSVHeader(t *testing.T) {
entry any entry any
expect []string 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"}}, {"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"}}, {"BookmarkEntry", types.BookmarkEntry{}, []string{"id", "name", "type", "url", "folder", "created_at"}},
{"HistoryEntry", types.HistoryEntry{}, []string{"url", "title", "visit_count", "last_visit"}}, {"HistoryEntry", types.HistoryEntry{}, []string{"url", "title", "visit_count", "last_visit"}},
@@ -77,8 +77,8 @@ func TestStructCSVRow(t *testing.T) {
}{ }{
{ {
"LoginEntry", "LoginEntry",
types.LoginEntry{URL: "https://example.com", Username: "alice", Password: "secret", CreatedAt: refTime}, 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"}, []string{"https://example.com", "alice", "secret", "2026-01-15T10:30:00Z", "local"},
}, },
{ {
"CookieEntry", "CookieEntry",
@@ -107,7 +107,7 @@ func TestStructCSVRow(t *testing.T) {
{ {
"zero_time", "zero_time",
types.LoginEntry{URL: "https://a.com"}, types.LoginEntry{URL: "https://a.com"},
[]string{"https://a.com", "", "", ""}, []string{"https://a.com", "", "", "", ""},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@@ -141,7 +141,7 @@ func TestRowMarshalJSON(t *testing.T) {
assert.Equal(t, "https://example.com", m["url"]) assert.Equal(t, "https://example.com", m["url"])
assert.Equal(t, "alice", m["username"]) assert.Equal(t, "alice", m["username"])
assert.Equal(t, "secret", m["password"]) 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. // Verify field order: browser, profile come before entry fields.
raw := string(data) raw := string(data)
+9
View File
@@ -2,12 +2,21 @@ package types
import "time" 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. // LoginEntry represents a single saved login credential.
type LoginEntry struct { type LoginEntry struct {
URL string `json:"url" csv:"url"` URL string `json:"url" csv:"url"`
Username string `json:"username" csv:"username"` Username string `json:"username" csv:"username"`
Password string `json:"password" csv:"password"` Password string `json:"password" csv:"password"`
CreatedAt time.Time `json:"created_at" csv:"created_at"` CreatedAt time.Time `json:"created_at" csv:"created_at"`
Store string `json:"store" csv:"store"`
} }
// CookieEntry represents a single browser cookie. // CookieEntry represents a single browser cookie.