mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-10-13 03:37:12 +02:00
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:
1 parent
9624e50a4f
commit
d2b2955d6c
12 files changed
+245
-16
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,6 +28,7 @@ func extractPasswords(keychainPassword string) ([]types.LoginEntry, error) {
|
||||
Username: p.Account,
|
||||
Password: p.PlainPassword,
|
||||
CreatedAt: p.Created.UTC(),
|
||||
Store: types.PasswordStoreLocal,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user