Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 139 additions & 23 deletions internal/chrome/chrome_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ import (

// https://gist.github.com/dacort/bd6a5116224c594b14db

const (
secretServiceName = `org.freedesktop.secrets`
secretServicePath = `/org/freedesktop/secrets`
// defaultCollectionAlias is where gnome-keyring points the "default" alias.
// Chrome Safe Storage usually lives here ("Default keyring"), not in "login".
defaultCollectionAlias = `/org/freedesktop/secrets/aliases/default`
loginCollectionPath = `/org/freedesktop/secrets/collection/login`
)

// getKeyringPassword retrieves the Chrome Safe Storage password,
// caching it for future calls.
func (s *CookieStore) getKeyringPassword(useSaved bool) ([]byte, error) {
Expand All @@ -45,21 +54,30 @@ func (s *CookieStore) getKeyringPassword(useSaved bool) ([]byte, error) {
// the freedesktop Secret Service search does not find keys stored there.
// https://chromium.googlesource.com/chromium/src/+/master/docs/linux/password_storage.md
var pw []byte
var err error
var primaryErr, fallbackErr error
kdeVer, isKDE := os.LookupEnv(`KDE_SESSION_VERSION`)
if isKDE {
pw, err = s.getKWalletPassword(kdeVer)
if err != nil {
pw, err = s.getSecretServicePassword(browser)
pw, primaryErr = s.getKWalletPassword(kdeVer)
if primaryErr != nil {
pw, fallbackErr = s.getSecretServicePassword(browser)
}
} else {
pw, err = s.getSecretServicePassword(browser)
if err != nil {
pw, err = s.getKWalletPassword(``)
pw, primaryErr = s.getSecretServicePassword(browser)
if primaryErr != nil {
pw, fallbackErr = s.getKWalletPassword(``)
}
}
if err != nil {
return nil, err
if len(pw) == 0 {
switch {
case primaryErr != nil && fallbackErr != nil:
return nil, fmt.Errorf("%w; fallback: %v", primaryErr, fallbackErr)
case primaryErr != nil:
return nil, primaryErr
case fallbackErr != nil:
return nil, fallbackErr
default:
return nil, errors.New(`keyring password not found`)
}
}

s.KeyringPasswordBytes = pw
Expand All @@ -71,33 +89,95 @@ func (s *CookieStore) getKeyringPassword(useSaved bool) ([]byte, error) {

func (s *CookieStore) getSecretServicePassword(browser string) ([]byte, error) {
// chromium --password-store=gnome

// this is mostly a copy from github.com/zalando/go-keyring (MIT License)
// Get() from https://github.com/zalando/go-keyring/blob/07372e614fb45baa337eaca014ed232b7b196200/keyring_linux.go#L77
// findItem() from https://github.com/zalando/go-keyring/blob/07372e614fb45baa337eaca014ed232b7b196200/keyring_linux.go#L51
//
// Historically this only searched go-keyring's "login" collection. That
// misses Chrome Safe Storage on common gnome-keyring setups where the
// secret lives in the default keyring (aliases/default → Default_5fkeyring)
// while a separate empty/unrelated "login" collection still exists.
// secret-tool uses Service.SearchItems (all collections); we do the same,
// then fall back to per-collection search for older implementations.

svc, err := secret_service.NewSecretService()
if err != nil {
return nil, err
}

collection := svc.GetLoginCollection()
if err := svc.Unlock(collection.Path()); err != nil {
return nil, err
}

search := map[string]string{
"application": browser,
}
results, err := svc.SearchItems(collection, search)
if err != nil {

if pw, err := secretServicePasswordFromServiceSearch(svc, search); err == nil && len(pw) > 0 {
return pw, nil
}

var lastErr error
for _, collection := range secretServiceCollections(svc) {
// Best-effort unlock. go-keyring's Unlock is strict about path identity
// (alias vs resolved path) and errors when a collection is already
// unlocked; SearchItems/GetSecret still work on unlocked collections.
_ = svc.Unlock(collection.Path())

results, err := svc.SearchItems(collection, search)
if err != nil {
lastErr = err
continue
}
if len(results) == 0 {
continue
}

pw, err := secretServiceReadItem(svc, results[0])
if err != nil {
lastErr = err
continue
}
if len(pw) > 0 {
return pw, nil
}
}

if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("secret not found in keyring for application %q", browser)
}

// secretServicePasswordFromServiceSearch uses org.freedesktop.Secret.Service.SearchItems,
// which searches every collection (same as secret-tool).
func secretServicePasswordFromServiceSearch(svc *secret_service.SecretService, search map[string]string) ([]byte, error) {
obj := svc.Object(secretServiceName, secretServicePath)

var unlocked, locked []dbus.ObjectPath
if err := obj.Call(`org.freedesktop.Secret.Service.SearchItems`, 0, search).Store(&unlocked, &locked); err != nil {
return nil, err
}
if len(results) == 0 {

if len(locked) > 0 {
// Best-effort unlock of locked matches; ignore prompt failures so we
// can still try already-unlocked results.
var unlockedPaths []dbus.ObjectPath
var prompt dbus.ObjectPath
_ = obj.Call(`org.freedesktop.Secret.Service.Unlock`, 0, locked).Store(&unlockedPaths, &prompt)
}

candidates := make([]dbus.ObjectPath, 0, len(unlocked)+len(locked))
candidates = append(candidates, unlocked...)
candidates = append(candidates, locked...)
if len(candidates) == 0 {
return nil, fmt.Errorf("secret not found in keyring")
}
item := results[0]

for _, item := range candidates {
pw, err := secretServiceReadItem(svc, item)
if err != nil || len(pw) == 0 {
continue
}
return pw, nil
}
return nil, fmt.Errorf("secret not found in keyring")
}

func secretServiceReadItem(svc *secret_service.SecretService, item dbus.ObjectPath) ([]byte, error) {
session, err := svc.OpenSession()
if err != nil {
return nil, err
Expand All @@ -108,10 +188,46 @@ func (s *CookieStore) getSecretServicePassword(browser string) ([]byte, error) {
if err != nil {
return nil, err
}

return secret.Value, nil
}

// secretServiceCollections returns Secret Service collections to search, in
// priority order: default alias (Chrome's usual home), login, then any others.
func secretServiceCollections(svc *secret_service.SecretService) []dbus.BusObject {
preferred := []dbus.ObjectPath{
defaultCollectionAlias,
loginCollectionPath,
}

var out []dbus.BusObject
seen := map[string]struct{}{}
add := func(path dbus.ObjectPath) {
if path == `` || path == `/` {
return
}
key := string(path)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, svc.Object(secretServiceName, path))
}

for _, p := range preferred {
add(p)
}

obj := svc.Object(secretServiceName, secretServicePath)
if val, err := obj.GetProperty(`org.freedesktop.Secret.Service.Collections`); err == nil {
if paths, ok := val.Value().([]dbus.ObjectPath); ok {
for _, p := range paths {
add(p)
}
}
}
return out
}

// getKWalletPassword retrieves the safe storage password via the KWallet D-Bus API.
// Chromium stores the key under folder "<Account> Keys",
// entry "<Account> Safe Storage" (e.g. "Chromium Keys"/"Chromium Safe Storage").
Expand Down
29 changes: 29 additions & 0 deletions internal/chrome/chrome_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//go:build linux && !android

package chrome

import (
"strings"
"testing"
)

// TestSecretServiceCollectionPriority documents the search order used when
// falling back from Service.SearchItems: default alias before login.
// Regression: Chrome Safe Storage on gnome-keyring is often in the default
// keyring while a separate "login" collection exists and is empty for Chrome.
func TestSecretServiceCollectionPriority(t *testing.T) {
preferred := []string{
defaultCollectionAlias,
loginCollectionPath,
}
if preferred[0] != `/org/freedesktop/secrets/aliases/default` {
t.Fatalf("default alias should be first, got %q", preferred[0])
}
if preferred[1] != `/org/freedesktop/secrets/collection/login` {
t.Fatalf("login collection should be second, got %q", preferred[1])
}
// Ensure go-keyring's old hard-coded login path is not the only target.
if strings.Contains(defaultCollectionAlias, `login`) {
t.Fatal("default alias must not be the login collection")
}
}
79 changes: 40 additions & 39 deletions internal/chrome/find/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ func FindBraveCookieStoreFiles() iter.Seq2[*chromeCookieStoreFile, error] {
return FindCookieStoreFiles(braveRoots, `brave`)
}

// cookieDBCandidates returns Chromium cookie DB paths for a profile directory,
// in preference order (Network/Cookies for Chrome 96+, then legacy Cookies).
// Only paths that currently exist are returned so callers do not surface
// spurious open errors for the missing layout.
func cookieDBCandidates(root, profDir string) []string {
candidates := []string{
filepath.Join(root, profDir, `Network`, `Cookies`), // Chrome 96+
filepath.Join(root, profDir, `Cookies`),
}
var existing []string
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
existing = append(existing, p)
}
}
return existing
}

func FindCookieStoreFiles(rootsFunc iter.Seq2[string, error], browserName string) iter.Seq2[*chromeCookieStoreFile, error] {
return func(yield func(*chromeCookieStoreFile, error) bool) {
if rootsFunc == nil {
Expand Down Expand Up @@ -63,49 +81,32 @@ func FindCookieStoreFiles(rootsFunc iter.Seq2[string, error], browserName string
if !yield(nil, err) {
return
}
st := &chromeCookieStoreFile{
Browser: browserName,
Profile: `Profile 1`,
IsDefaultProfile: true,
Path: filepath.Join(root, `Default`, `Network`, `Cookies`), // Chrome 96
OS: runtime.GOOS,
}
if !yield(st, nil) {
return
}
st = &chromeCookieStoreFile{
Browser: browserName,
Profile: `Profile 1`,
IsDefaultProfile: true,
Path: filepath.Join(root, `Default`, `Cookies`),
OS: runtime.GOOS,
}
if !yield(st, nil) {
return
for _, cookiePath := range cookieDBCandidates(root, `Default`) {
st := &chromeCookieStoreFile{
Browser: browserName,
Profile: `Profile 1`,
IsDefaultProfile: true,
Path: cookiePath,
OS: runtime.GOOS,
}
if !yield(st, nil) {
return
}
}
continue
}
for profDir, profStr := range localState.Profile.InfoCache {
st := &chromeCookieStoreFile{

Browser: browserName,
Profile: profStr.Name,
IsDefaultProfile: profStr.IsUsingDefaultName,
Path: filepath.Join(root, profDir, `Network`, `Cookies`), // Chrome 96
OS: runtime.GOOS,
}
if !yield(st, nil) {
return
}
st = &chromeCookieStoreFile{
Browser: browserName,
Profile: profStr.Name,
IsDefaultProfile: profStr.IsUsingDefaultName,
Path: filepath.Join(root, profDir, `Cookies`),
OS: runtime.GOOS,
}
if !yield(st, nil) {
return
for _, cookiePath := range cookieDBCandidates(root, profDir) {
st := &chromeCookieStoreFile{
Browser: browserName,
Profile: profStr.Name,
IsDefaultProfile: profStr.IsUsingDefaultName,
Path: cookiePath,
OS: runtime.GOOS,
}
if !yield(st, nil) {
return
}
}
}
}
Expand Down
55 changes: 55 additions & 0 deletions internal/chrome/find/find_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package find

import (
"os"
"path/filepath"
"testing"
)

func TestCookieDBCandidatesPrefersExistingPaths(t *testing.T) {
root := t.TempDir()
prof := `Default`

// Neither path exists → empty.
if got := cookieDBCandidates(root, prof); len(got) != 0 {
t.Fatalf("expected no candidates, got %v", got)
}

// Legacy Cookies only (common on some Chrome/Linux layouts).
legacy := filepath.Join(root, prof, `Cookies`)
if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
got := cookieDBCandidates(root, prof)
if len(got) != 1 || got[0] != legacy {
t.Fatalf("legacy only: got %v, want [%s]", got, legacy)
}

// Network/Cookies present → preferred first; both returned if both exist.
network := filepath.Join(root, prof, `Network`, `Cookies`)
if err := os.MkdirAll(filepath.Dir(network), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(network, []byte("y"), 0o600); err != nil {
t.Fatal(err)
}
got = cookieDBCandidates(root, prof)
if len(got) != 2 {
t.Fatalf("both exist: got %v", got)
}
if got[0] != network || got[1] != legacy {
t.Fatalf("order: got %v, want network then legacy", got)
}

// Network only.
if err := os.Remove(legacy); err != nil {
t.Fatal(err)
}
got = cookieDBCandidates(root, prof)
if len(got) != 1 || got[0] != network {
t.Fatalf("network only: got %v, want [%s]", got, network)
}
}