refactor(browser): simplify credential storage config (#593)

This commit is contained in:
Roger
2026-05-14 16:29:35 +08:00
committed by GitHub
parent 5d67d3c303
commit ecf8ba0585
22 changed files with 286 additions and 243 deletions
+55 -55
View File
@@ -17,81 +17,81 @@ import (
func platformBrowsers() []types.BrowserConfig {
return []types.BrowserConfig{
{
Key: "chrome",
Name: chromeName,
Kind: types.Chromium,
Storage: "Chrome",
UserDataDir: homeDir + "/Library/Application Support/Google/Chrome",
Key: "chrome",
Name: chromeName,
Kind: types.Chromium,
KeychainLabel: "Chrome",
UserDataDir: homeDir + "/Library/Application Support/Google/Chrome",
},
{
Key: "edge",
Name: edgeName,
Kind: types.Chromium,
Storage: "Microsoft Edge",
UserDataDir: homeDir + "/Library/Application Support/Microsoft Edge",
Key: "edge",
Name: edgeName,
Kind: types.Chromium,
KeychainLabel: "Microsoft Edge",
UserDataDir: homeDir + "/Library/Application Support/Microsoft Edge",
},
{
Key: "chromium",
Name: chromiumName,
Kind: types.Chromium,
Storage: "Chromium",
UserDataDir: homeDir + "/Library/Application Support/Chromium",
Key: "chromium",
Name: chromiumName,
Kind: types.Chromium,
KeychainLabel: "Chromium",
UserDataDir: homeDir + "/Library/Application Support/Chromium",
},
{
Key: "chrome-beta",
Name: chromeBetaName,
Kind: types.Chromium,
Storage: "Chrome",
UserDataDir: homeDir + "/Library/Application Support/Google/Chrome Beta",
Key: "chrome-beta",
Name: chromeBetaName,
Kind: types.Chromium,
KeychainLabel: "Chrome",
UserDataDir: homeDir + "/Library/Application Support/Google/Chrome Beta",
},
{
Key: "opera",
Name: operaName,
Kind: types.ChromiumOpera,
Storage: "Opera",
UserDataDir: homeDir + "/Library/Application Support/com.operasoftware.Opera",
Key: "opera",
Name: operaName,
Kind: types.ChromiumOpera,
KeychainLabel: "Opera",
UserDataDir: homeDir + "/Library/Application Support/com.operasoftware.Opera",
},
{
Key: "opera-gx",
Name: operaGXName,
Kind: types.ChromiumOpera,
Storage: "Opera",
UserDataDir: homeDir + "/Library/Application Support/com.operasoftware.OperaGX",
Key: "opera-gx",
Name: operaGXName,
Kind: types.ChromiumOpera,
KeychainLabel: "Opera",
UserDataDir: homeDir + "/Library/Application Support/com.operasoftware.OperaGX",
},
{
Key: "vivaldi",
Name: vivaldiName,
Kind: types.Chromium,
Storage: "Vivaldi",
UserDataDir: homeDir + "/Library/Application Support/Vivaldi",
Key: "vivaldi",
Name: vivaldiName,
Kind: types.Chromium,
KeychainLabel: "Vivaldi",
UserDataDir: homeDir + "/Library/Application Support/Vivaldi",
},
{
Key: "coccoc",
Name: coccocName,
Kind: types.Chromium,
Storage: "CocCoc",
UserDataDir: homeDir + "/Library/Application Support/Coccoc",
Key: "coccoc",
Name: coccocName,
Kind: types.Chromium,
KeychainLabel: "CocCoc",
UserDataDir: homeDir + "/Library/Application Support/Coccoc",
},
{
Key: "brave",
Name: braveName,
Kind: types.Chromium,
Storage: "Brave",
UserDataDir: homeDir + "/Library/Application Support/BraveSoftware/Brave-Browser",
Key: "brave",
Name: braveName,
Kind: types.Chromium,
KeychainLabel: "Brave",
UserDataDir: homeDir + "/Library/Application Support/BraveSoftware/Brave-Browser",
},
{
Key: "yandex",
Name: yandexName,
Kind: types.ChromiumYandex,
Storage: "Yandex",
UserDataDir: homeDir + "/Library/Application Support/Yandex/YandexBrowser",
Key: "yandex",
Name: yandexName,
Kind: types.ChromiumYandex,
KeychainLabel: "Yandex",
UserDataDir: homeDir + "/Library/Application Support/Yandex/YandexBrowser",
},
{
Key: "arc",
Name: arcName,
Kind: types.Chromium,
Storage: "Arc",
UserDataDir: homeDir + "/Library/Application Support/Arc/User Data",
Key: "arc",
Name: arcName,
Kind: types.Chromium,
KeychainLabel: "Arc",
UserDataDir: homeDir + "/Library/Application Support/Arc/User Data",
},
{
Key: "firefox",
+35 -35
View File
@@ -10,53 +10,53 @@ import (
func platformBrowsers() []types.BrowserConfig {
return []types.BrowserConfig{
{
Key: "chrome",
Name: chromeName,
Kind: types.Chromium,
Storage: "Chrome Safe Storage",
UserDataDir: homeDir + "/.config/google-chrome",
Key: "chrome",
Name: chromeName,
Kind: types.Chromium,
KeychainLabel: "Chrome Safe Storage",
UserDataDir: homeDir + "/.config/google-chrome",
},
{
Key: "edge",
Name: edgeName,
Kind: types.Chromium,
Storage: "Chromium Safe Storage",
UserDataDir: homeDir + "/.config/microsoft-edge",
Key: "edge",
Name: edgeName,
Kind: types.Chromium,
KeychainLabel: "Chromium Safe Storage",
UserDataDir: homeDir + "/.config/microsoft-edge",
},
{
Key: "chromium",
Name: chromiumName,
Kind: types.Chromium,
Storage: "Chromium Safe Storage",
UserDataDir: homeDir + "/.config/chromium",
Key: "chromium",
Name: chromiumName,
Kind: types.Chromium,
KeychainLabel: "Chromium Safe Storage",
UserDataDir: homeDir + "/.config/chromium",
},
{
Key: "chrome-beta",
Name: chromeBetaName,
Kind: types.Chromium,
Storage: "Chrome Safe Storage",
UserDataDir: homeDir + "/.config/google-chrome-beta",
Key: "chrome-beta",
Name: chromeBetaName,
Kind: types.Chromium,
KeychainLabel: "Chrome Safe Storage",
UserDataDir: homeDir + "/.config/google-chrome-beta",
},
{
Key: "opera",
Name: operaName,
Kind: types.ChromiumOpera,
Storage: "Chromium Safe Storage",
UserDataDir: homeDir + "/.config/opera",
Key: "opera",
Name: operaName,
Kind: types.ChromiumOpera,
KeychainLabel: "Chromium Safe Storage",
UserDataDir: homeDir + "/.config/opera",
},
{
Key: "vivaldi",
Name: vivaldiName,
Kind: types.Chromium,
Storage: "Chrome Safe Storage",
UserDataDir: homeDir + "/.config/vivaldi",
Key: "vivaldi",
Name: vivaldiName,
Kind: types.Chromium,
KeychainLabel: "Chrome Safe Storage",
UserDataDir: homeDir + "/.config/vivaldi",
},
{
Key: "brave",
Name: braveName,
Kind: types.Chromium,
Storage: "Brave Safe Storage",
UserDataDir: homeDir + "/.config/BraveSoftware/Brave-Browser",
Key: "brave",
Name: braveName,
Kind: types.Chromium,
KeychainLabel: "Brave Safe Storage",
UserDataDir: homeDir + "/.config/BraveSoftware/Brave-Browser",
},
{
Key: "firefox",
+5 -5
View File
@@ -13,14 +13,14 @@ func platformBrowsers() []types.BrowserConfig {
Key: "chrome",
Name: chromeName,
Kind: types.Chromium,
Storage: "chrome",
WindowsABE: true,
UserDataDir: homeDir + "/AppData/Local/Google/Chrome/User Data",
},
{
Key: "edge",
Name: edgeName,
Kind: types.Chromium,
Storage: "edge",
WindowsABE: true,
UserDataDir: homeDir + "/AppData/Local/Microsoft/Edge/User Data",
},
{
@@ -33,7 +33,7 @@ func platformBrowsers() []types.BrowserConfig {
Key: "chrome-beta",
Name: chromeBetaName,
Kind: types.Chromium,
Storage: "chrome-beta",
WindowsABE: true,
UserDataDir: homeDir + "/AppData/Local/Google/Chrome Beta/User Data",
},
{
@@ -58,14 +58,14 @@ func platformBrowsers() []types.BrowserConfig {
Key: "coccoc",
Name: coccocName,
Kind: types.Chromium,
Storage: "coccoc",
WindowsABE: true,
UserDataDir: homeDir + "/AppData/Local/CocCoc/Browser/User Data",
},
{
Key: "brave",
Name: braveName,
Kind: types.Chromium,
Storage: "brave",
WindowsABE: true,
UserDataDir: homeDir + "/AppData/Local/BraveSoftware/Brave-Browser/User Data",
},
{
+25 -12
View File
@@ -8,25 +8,38 @@ import (
"github.com/moond4rk/hackbrowserdata/utils/winutil"
)
// TestWinUtilTableCoversABEBrowsers verifies that every Windows browser
// with ABE support in winutil.Table has a matching Storage key in
// platformBrowsers(). A mismatch means adding a new Chromium fork was
// incomplete: either the BrowserConfig row lacks Storage: "<key>", or
// winutil.Table has a stale entry nobody retrieves keys for.
// TestWinUtilTableCoversABEBrowsers verifies that the set of Windows browsers
// with WindowsABE: true in platformBrowsers() exactly matches the set of
// winutil.Table entries that declare ABE support (keyed by BrowserConfig.Key ==
// winutil.Entry.Key). A mismatch means adding a new Chromium fork was
// incomplete: either a BrowserConfig row is missing WindowsABE: true, or
// winutil.Table has a stale/missing entry.
func TestWinUtilTableCoversABEBrowsers(t *testing.T) {
storages := make(map[string]struct{})
abeConfigs := make(map[string]struct{})
for _, b := range platformBrowsers() {
if b.Storage != "" {
storages[b.Storage] = struct{}{}
if b.WindowsABE {
abeConfigs[b.Key] = struct{}{}
}
}
abeTable := make(map[string]struct{})
for key, entry := range winutil.Table {
if entry.ABE == winutil.ABENone {
continue
if entry.Key != key {
t.Errorf("winutil.Table[%q].Key = %q; map key and Entry.Key must match (winutil.Entry doc invariant)", key, entry.Key)
}
if _, ok := storages[key]; !ok {
t.Errorf("winutil.Table[%q] declares ABE support but no BrowserConfig.Storage matches — either fix the table or set Storage: %q in platformBrowsers()", key, key)
if entry.ABE != winutil.ABENone {
abeTable[key] = struct{}{}
}
}
for key := range abeTable {
if _, ok := abeConfigs[key]; !ok {
t.Errorf("winutil.Table[%q] declares ABE support but no BrowserConfig with Key %q sets WindowsABE: true — either fix the table or set WindowsABE: true in platformBrowsers()", key, key)
}
}
for key := range abeConfigs {
if _, ok := abeTable[key]; !ok {
t.Errorf("BrowserConfig with Key %q sets WindowsABE: true but winutil.Table[%q] is missing or declares no ABE — add the table entry", key, key)
}
}
}
+10 -1
View File
@@ -195,7 +195,16 @@ func (b *Browser) getMasterKeys(session *filemanager.Session) keyretriever.Maste
break
}
keys, err := keyretriever.NewMasterKeys(b.retrievers, b.cfg.Storage, localStateDst)
abeKey := ""
if b.cfg.WindowsABE {
abeKey = b.cfg.Key
}
hints := keyretriever.Hints{
KeychainLabel: b.cfg.KeychainLabel,
WindowsABEKey: abeKey,
LocalStatePath: localStateDst,
}
keys, err := keyretriever.NewMasterKeys(b.retrievers, hints)
if err != nil {
installKey := b.BrowserName() + "|" + b.cfg.UserDataDir
if _, already := warnedMasterKeyFailure.LoadOrStore(installKey, struct{}{}); !already {
+70 -36
View File
@@ -448,17 +448,15 @@ func TestLocalStatePath(t *testing.T) {
// mockRetriever records the arguments passed to RetrieveKey.
type mockRetriever struct {
storage string
localState string
key []byte
err error
called bool
hints keyretriever.Hints
key []byte
err error
called bool
}
func (m *mockRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) {
func (m *mockRetriever) RetrieveKey(hints keyretriever.Hints) ([]byte, error) {
m.called = true
m.storage = storage
m.localState = localStatePath
m.hints = hints
return m.key, m.err
}
@@ -472,41 +470,41 @@ func TestGetMasterKeys(t *testing.T) {
mkFile(dirNoLocalState, "Default", "History")
tests := []struct {
name string
dir string
storage string
retriever keyretriever.KeyRetriever // nil → don't call SetKeyRetrievers
wantV10 []byte
wantStorage string
wantLocalState bool // whether localStatePath passed to retriever is non-empty
name string
dir string
keychainLabel string
retriever keyretriever.KeyRetriever // nil → don't call SetKeyRetrievers
wantV10 []byte
wantKeychainLabel string
wantLocalState bool // whether localStatePath passed to retriever is non-empty
}{
{
name: "nil retriever yields empty keys",
dir: fixture.chrome,
},
{
name: "with Local State passes path to retriever",
dir: fixture.chrome,
storage: "Chrome",
retriever: &mockRetriever{key: []byte("fake-master-key")},
wantV10: []byte("fake-master-key"),
wantStorage: "Chrome",
wantLocalState: true,
name: "with Local State passes path to retriever",
dir: fixture.chrome,
keychainLabel: "Chrome",
retriever: &mockRetriever{key: []byte("fake-master-key")},
wantV10: []byte("fake-master-key"),
wantKeychainLabel: "Chrome",
wantLocalState: true,
},
{
name: "without Local State passes empty path",
dir: dirNoLocalState,
storage: "Chromium",
retriever: &mockRetriever{key: []byte("derived-key")},
wantV10: []byte("derived-key"),
wantStorage: "Chromium",
name: "without Local State passes empty path",
dir: dirNoLocalState,
keychainLabel: "Chromium",
retriever: &mockRetriever{key: []byte("derived-key")},
wantV10: []byte("derived-key"),
wantKeychainLabel: "Chromium",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
browsers, err := NewBrowsers(types.BrowserConfig{
Name: "Test", Kind: types.Chromium, UserDataDir: tt.dir, Storage: tt.storage,
Name: "Test", Kind: types.Chromium, UserDataDir: tt.dir, KeychainLabel: tt.keychainLabel,
})
require.NoError(t, err)
require.NotEmpty(t, browsers)
@@ -531,11 +529,11 @@ func TestGetMasterKeys(t *testing.T) {
mock, ok := tt.retriever.(*mockRetriever)
require.True(t, ok)
assert.True(t, mock.called)
assert.Equal(t, tt.wantStorage, mock.storage)
assert.Equal(t, tt.wantKeychainLabel, mock.hints.KeychainLabel)
if tt.wantLocalState {
assert.NotEmpty(t, mock.localState)
assert.NotEmpty(t, mock.hints.LocalStatePath)
} else {
assert.Empty(t, mock.localState)
assert.Empty(t, mock.hints.LocalStatePath)
}
})
}
@@ -553,7 +551,7 @@ func TestGetMasterKeys_AllTiersInvoked(t *testing.T) {
v20mock := &mockRetriever{key: []byte("fake-v20-key")}
browsers, err := NewBrowsers(types.BrowserConfig{
Name: "Test", Kind: types.Chromium, UserDataDir: fixture.chrome, Storage: "Chrome",
Name: "Test", Kind: types.Chromium, UserDataDir: fixture.chrome, KeychainLabel: "Chrome",
})
require.NoError(t, err)
require.NotEmpty(t, browsers)
@@ -573,8 +571,44 @@ func TestGetMasterKeys_AllTiersInvoked(t *testing.T) {
assert.True(t, v11mock.called, "V11 retriever must be called — no silent bypass")
assert.True(t, v20mock.called, "V20 retriever must be called — no silent bypass")
for _, m := range []*mockRetriever{v10mock, v11mock, v20mock} {
assert.Equal(t, "Chrome", m.storage)
assert.NotEmpty(t, m.localState, "Local State path must be passed to every retriever")
assert.Equal(t, "Chrome", m.hints.KeychainLabel)
assert.NotEmpty(t, m.hints.LocalStatePath, "Local State path must be passed to every retriever")
}
}
// TestGetMasterKeys_WindowsABEThreading pins cfg.WindowsABE → hints.WindowsABEKey threading. A
// regression here silently disables Windows ABE decryption with no dev-box-test signal — only the
// windows-tunnel sandbox 574-cookie regression would catch it — so it must be pinned at unit level.
func TestGetMasterKeys_WindowsABEThreading(t *testing.T) {
tests := []struct {
name string
key string
windowsABE bool
wantABEKey string
}{
{"WindowsABE=true threads cfg.Key into hints.WindowsABEKey", "chrome", true, "chrome"},
{"WindowsABE=false leaves hints.WindowsABEKey empty", "opera", false, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockRetriever{key: []byte("k")}
browsers, err := NewBrowsers(types.BrowserConfig{
Key: tt.key, Name: "Test", Kind: types.Chromium,
UserDataDir: fixture.chrome, WindowsABE: tt.windowsABE,
})
require.NoError(t, err)
require.NotEmpty(t, browsers)
b := browsers[0]
b.SetKeyRetrievers(keyretriever.Retrievers{V20: mock})
session, err := filemanager.NewSession()
require.NoError(t, err)
defer session.Cleanup()
b.getMasterKeys(session)
assert.Equal(t, tt.wantABEKey, mock.hints.WindowsABEKey)
})
}
}
@@ -605,7 +639,7 @@ func TestExtract(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
browsers, err := NewBrowsers(types.BrowserConfig{
Name: "Test", Kind: types.Chromium, UserDataDir: dir, Storage: "Chrome",
Name: "Test", Kind: types.Chromium, UserDataDir: dir, KeychainLabel: "Chrome",
})
require.NoError(t, err)
require.Len(t, browsers, 1)
+5 -7
View File
@@ -25,17 +25,15 @@ var errNoABEKey = errors.New("abe: Local State has no app_bound_encrypted_key")
type ABERetriever struct{}
func (r *ABERetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) {
// Non-ABE Chromium forks (Opera/Vivaldi/Yandex/...) call this with an empty storage key; pre-v20
// Chrome profiles have no app_bound_encrypted_key in Local State. Both are "ABE not applicable" —
// return (nil, nil) so ChainRetriever falls through to DPAPI silently instead of emitting a Warnf
// for every non-ABE browser.
browserKey := strings.TrimSpace(storage)
func (r *ABERetriever) RetrieveKey(hints Hints) ([]byte, error) {
// Non-ABE forks (Opera/Vivaldi/Yandex) supply no WindowsABEKey — treat as "not applicable".
// (Pre-v20 Chrome takes the errNoABEKey path below.)
browserKey := strings.TrimSpace(hints.WindowsABEKey)
if browserKey == "" {
return nil, nil
}
encKey, err := loadEncryptedKey(localStatePath)
encKey, err := loadEncryptedKey(hints.LocalStatePath)
if errors.Is(err, errNoABEKey) {
return nil, nil
}
+11 -8
View File
@@ -20,13 +20,16 @@ import (
// has no storage lookup.
var errStorageNotFound = errors.New("not found in credential store") //nolint:unused // only used on darwin and linux
// KeyRetriever retrieves the master encryption key for a Chromium-based browser. Each platform has
// different implementations:
// - macOS: Keychain access (security command) or gcoredump exploit
// - Windows: DPAPI decryption of Local State file
// - Linux: D-Bus Secret Service or fallback to "peanuts" password
// Hints bundles inputs for KeyRetriever; each retriever reads only the field that applies to it.
type Hints struct {
KeychainLabel string // macOS Keychain account / Linux D-Bus Secret Service label
WindowsABEKey string // Windows ABE browser key (e.g. "chrome"); "" → ABE not applicable
LocalStatePath string // path to (temp-copied) Local State JSON; only used on Windows
}
// KeyRetriever retrieves the master encryption key for a Chromium-based browser.
type KeyRetriever interface {
RetrieveKey(storage, localStatePath string) ([]byte, error)
RetrieveKey(hints Hints) ([]byte, error)
}
// ChainRetriever tries multiple retrievers in order, returning the first success. Used on macOS
@@ -40,10 +43,10 @@ func NewChain(retrievers ...KeyRetriever) KeyRetriever {
return &ChainRetriever{retrievers: retrievers}
}
func (c *ChainRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) {
func (c *ChainRetriever) RetrieveKey(hints Hints) ([]byte, error) {
var errs []error
for _, r := range c.retrievers {
key, err := r.RetrieveKey(storage, localStatePath)
key, err := r.RetrieveKey(hints)
if err == nil && len(key) > 0 {
return key, nil
}
+6 -5
View File
@@ -43,7 +43,7 @@ type GcoredumpRetriever struct {
// through to the next retriever silently. The most common failure ("requires root privileges")
// is documented expected behavior, not a warning-worthy condition; surfacing it on every profile
// would drown out genuine warnings. The same pattern is used by ABERetriever (see abe_windows.go).
func (r *GcoredumpRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
func (r *GcoredumpRetriever) RetrieveKey(hints Hints) ([]byte, error) {
r.once.Do(func() {
r.records, r.err = DecryptKeychainRecords()
})
@@ -52,7 +52,7 @@ func (r *GcoredumpRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
return nil, nil //nolint:nilerr // intentional silent fallthrough
}
key, err := findStorageKey(r.records, storage)
key, err := findStorageKey(r.records, hints.KeychainLabel)
if err != nil {
log.Debugf("gcoredump: %v", err)
return nil, nil //nolint:nilerr // intentional silent fallthrough
@@ -96,7 +96,7 @@ type KeychainPasswordRetriever struct {
err error
}
func (r *KeychainPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
func (r *KeychainPasswordRetriever) RetrieveKey(hints Hints) ([]byte, error) {
if r.Password == "" {
return nil, fmt.Errorf("keychain password not provided")
}
@@ -108,7 +108,7 @@ func (r *KeychainPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, erro
return nil, r.err
}
return findStorageKey(r.records, storage)
return findStorageKey(r.records, hints.KeychainLabel)
}
// SecurityCmdRetriever uses macOS `security` CLI to query Keychain.
@@ -124,7 +124,8 @@ type securityResult struct {
err error
}
func (r *SecurityCmdRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
func (r *SecurityCmdRetriever) RetrieveKey(hints Hints) ([]byte, error) {
storage := hints.KeychainLabel
r.mu.Lock()
defer r.mu.Unlock()
@@ -34,7 +34,7 @@ func TestFindStorageKey_NotFound(t *testing.T) {
func TestKeychainPasswordRetriever_EmptyPassword(t *testing.T) {
r := &KeychainPasswordRetriever{Password: ""}
key, err := r.RetrieveKey("Chrome", "")
key, err := r.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.Error(t, err)
assert.Nil(t, key)
assert.Contains(t, err.Error(), "keychain password not provided")
+3 -2
View File
@@ -21,7 +21,8 @@ var linuxParams = pbkdf2Params{
// DBusRetriever queries GNOME Keyring / KDE Wallet via D-Bus Secret Service.
type DBusRetriever struct{}
func (r *DBusRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
func (r *DBusRetriever) RetrieveKey(hints Hints) ([]byte, error) {
storage := hints.KeychainLabel
conn, err := dbus.SessionBus()
if err != nil {
return nil, fmt.Errorf("dbus session: %w", err)
@@ -74,7 +75,7 @@ func (r *DBusRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
// the "v10" prefix when no keyring is available (headless servers, Docker, CI).
type PosixRetriever struct{}
func (r *PosixRetriever) RetrieveKey(_, _ string) ([]byte, error) {
func (r *PosixRetriever) RetrieveKey(_ Hints) ([]byte, error) {
return linuxParams.deriveKey([]byte("peanuts")), nil
}
@@ -12,7 +12,7 @@ import (
func TestPosixRetriever(t *testing.T) {
r := &PosixRetriever{}
key, err := r.RetrieveKey("Chrome", "")
key, err := r.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.NoError(t, err)
assert.Equal(t, linuxParams.deriveKey([]byte("peanuts")), key)
assert.Len(t, key, linuxParams.keySize)
@@ -27,9 +27,9 @@ func TestPosixRetriever(t *testing.T) {
}
assert.False(t, allZero, "derived key should not be all zeros")
// "peanuts" is a hardcoded password, so the result should be the same regardless of storage
// name or number of calls.
key2, err := r.RetrieveKey("Brave", "")
// "peanuts" is a hardcoded password, so the result should be the same regardless of the hints
// or number of calls.
key2, err := r.RetrieveKey(Hints{KeychainLabel: "Brave"})
require.NoError(t, err)
assert.Equal(t, key, key2, "kV10Key should be constant across any storage label")
}
@@ -42,7 +42,7 @@ func TestPosixRetriever_MatchesChromiumKV10Key(t *testing.T) {
0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78,
}
r := &PosixRetriever{}
key, err := r.RetrieveKey("", "")
key, err := r.RetrieveKey(Hints{})
require.NoError(t, err)
assert.Equal(t, want, key)
}
+6 -6
View File
@@ -13,7 +13,7 @@ type mockRetriever struct {
err error
}
func (m *mockRetriever) RetrieveKey(_, _ string) ([]byte, error) {
func (m *mockRetriever) RetrieveKey(_ Hints) ([]byte, error) {
return m.key, m.err
}
@@ -22,7 +22,7 @@ func TestChainRetriever_FirstSuccess(t *testing.T) {
&mockRetriever{key: []byte("first-key")},
&mockRetriever{key: []byte("second-key")},
)
key, err := chain.RetrieveKey("Chrome", "")
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.NoError(t, err)
assert.Equal(t, []byte("first-key"), key)
}
@@ -32,7 +32,7 @@ func TestChainRetriever_FallbackOnError(t *testing.T) {
&mockRetriever{err: errors.New("first failed")},
&mockRetriever{key: []byte("fallback-key")},
)
key, err := chain.RetrieveKey("Chrome", "")
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.NoError(t, err)
assert.Equal(t, []byte("fallback-key"), key)
}
@@ -42,7 +42,7 @@ func TestChainRetriever_AllFail(t *testing.T) {
&mockRetriever{err: errors.New("first failed")},
&mockRetriever{err: errors.New("second failed")},
)
key, err := chain.RetrieveKey("Chrome", "")
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.Error(t, err)
assert.Nil(t, key)
assert.Contains(t, err.Error(), "all retrievers failed")
@@ -56,14 +56,14 @@ func TestChainRetriever_SkipEmptyKey(t *testing.T) {
&mockRetriever{key: nil, err: nil},
&mockRetriever{key: []byte("real-key")},
)
key, err := chain.RetrieveKey("Chrome", "")
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.NoError(t, err)
assert.Equal(t, []byte("real-key"), key)
}
func TestChainRetriever_Empty(t *testing.T) {
chain := NewChain()
key, err := chain.RetrieveKey("Chrome", "")
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.Error(t, err)
assert.Nil(t, key)
}
+2 -2
View File
@@ -16,8 +16,8 @@ import (
// and decrypts it using Windows DPAPI.
type DPAPIRetriever struct{}
func (r *DPAPIRetriever) RetrieveKey(_, localStatePath string) ([]byte, error) {
data, err := os.ReadFile(localStatePath)
func (r *DPAPIRetriever) RetrieveKey(hints Hints) ([]byte, error) {
data, err := os.ReadFile(hints.LocalStatePath)
if err != nil {
return nil, fmt.Errorf("read Local State: %w", err)
}
+9 -30
View File
@@ -5,47 +5,26 @@ import (
"fmt"
)
// MasterKeys bundles the per-cipher-version Chromium master keys used to decrypt data from a
// single profile. decryptValue dispatches on the ciphertext's version prefix and picks the
// matching key; a missing (nil) key for a tier means "that cipher version cannot be decrypted",
// but the other tiers remain usable — a Chrome 127+ profile upgraded from pre-127 carries mixed
// v10+v20 ciphertexts, and Linux profiles may carry mixed v10+v11 for analogous reasons.
//
// - V10: Chrome 80+ key with "v10" cipher prefix.
// - Windows: os_crypt.encrypted_key decrypted by user-level DPAPI (AES-GCM ciphertexts).
// - macOS: derived from Keychain via PBKDF2(1003, SHA-1) (AES-CBC ciphertexts).
// - Linux: derived from "peanuts" hardcoded password (Chromium's kV10Key, AES-CBC).
// - V11: Chrome Linux key with "v11" cipher prefix, derived from D-Bus Secret Service
// (KWallet / GNOME Keyring) via PBKDF2. Nil on Windows and macOS (v11 prefix not used there).
// - V20: Chrome 127+ Windows key with "v20" cipher prefix, retrieved via reflective injection
// into the browser's elevation service. Nil on non-Windows platforms.
// MasterKeys holds per-cipher-version Chromium master keys. A profile may carry mixed prefixes
// (Chrome 127+ on Windows mixes v10+v20; Linux can mix v10+v11), so each tier must be populated
// independently for lossless decryption. A nil tier means that cipher version cannot be decrypted.
type MasterKeys struct {
V10 []byte
V11 []byte
V20 []byte
}
// Retrievers is the per-tier retriever configuration passed to NewMasterKeys. Each slot runs
// independently — failure or absence of one tier does not affect others. Platform injectors set
// only the slots that apply to their platform and leave the rest nil (e.g. Linux populates
// V10+V11, leaves V20 nil).
// Retrievers is the per-tier retriever configuration; unused slots are nil.
type Retrievers struct {
V10 KeyRetriever
V11 KeyRetriever
V20 KeyRetriever
}
// NewMasterKeys fetches every configured tier in r independently and returns the assembled
// MasterKeys together with any per-tier errors joined into one. Nil retrievers and retrievers
// returning (nil, nil) (the "not applicable" signal — e.g. ABERetriever on a non-ABE fork)
// contribute nil keys silently; only non-nil errors propagate.
//
// The returned error, when non-nil, is an errors.Join of per-tier failures formatted as
// "<tier>: <err>" (e.g. "v10: dpapi decrypt: ..."). Callers are expected to log it at whatever
// severity fits their context — this function itself never logs, leaving logging policy to its
// callers. Other pieces of the keyretriever package (e.g. ChainRetriever) may still log on their
// own failures; the "no-logging" guarantee is scoped to NewMasterKeys.
func NewMasterKeys(r Retrievers, storage, localStatePath string) (MasterKeys, error) {
// NewMasterKeys fetches each non-nil tier in r and returns the assembled MasterKeys with per-tier
// errors joined. A retriever returning (nil, nil) signals "not applicable" and contributes no key
// silently. This function never logs; the caller decides severity.
func NewMasterKeys(r Retrievers, hints Hints) (MasterKeys, error) {
var keys MasterKeys
var errs []error
@@ -61,7 +40,7 @@ func NewMasterKeys(r Retrievers, storage, localStatePath string) (MasterKeys, er
if t.r == nil {
continue
}
k, err := t.r.RetrieveKey(storage, localStatePath)
k, err := t.r.RetrieveKey(hints)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", t.name, err))
continue
+11 -14
View File
@@ -10,20 +10,18 @@ import (
)
// recordingRetriever captures call count and arguments so tests can verify each tier's retriever
// is invoked exactly once with the expected storage and localStatePath.
// is invoked exactly once with the expected hints.
type recordingRetriever struct {
key []byte
err error
calls int
gotStorage string
gotPath string
calls int
gotHints Hints
}
func (r *recordingRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) {
func (r *recordingRetriever) RetrieveKey(hints Hints) ([]byte, error) {
r.calls++
r.gotStorage = storage
r.gotPath = localStatePath
r.gotHints = hints
return r.key, r.err
}
@@ -115,7 +113,7 @@ func TestNewMasterKeys_Matrix(t *testing.T) {
r.V20 = tt.v20
}
keys, err := NewMasterKeys(r, "chrome", "/tmp/Local State")
keys, err := NewMasterKeys(r, Hints{KeychainLabel: "chrome", LocalStatePath: "/tmp/Local State"})
assert.Equal(t, tt.wantV10, keys.V10)
assert.Equal(t, tt.wantV11, keys.V11)
assert.Equal(t, tt.wantV20, keys.V20)
@@ -136,8 +134,7 @@ func TestNewMasterKeys_Matrix(t *testing.T) {
continue
}
assert.Equal(t, 1, mock.calls, "%s retriever should be called exactly once", name)
assert.Equal(t, "chrome", mock.gotStorage)
assert.Equal(t, "/tmp/Local State", mock.gotPath)
assert.Equal(t, Hints{KeychainLabel: "chrome", LocalStatePath: "/tmp/Local State"}, mock.gotHints)
}
})
}
@@ -145,7 +142,7 @@ func TestNewMasterKeys_Matrix(t *testing.T) {
func TestNewMasterKeys_AllNilRetrievers(t *testing.T) {
// All slots nil — macOS/Linux with no retriever wiring, or Windows with neither tier set up.
keys, err := NewMasterKeys(Retrievers{}, "chrome", "/tmp/Local State")
keys, err := NewMasterKeys(Retrievers{}, Hints{KeychainLabel: "chrome", LocalStatePath: "/tmp/Local State"})
require.NoError(t, err)
assert.Nil(t, keys.V10)
assert.Nil(t, keys.V11)
@@ -156,14 +153,14 @@ func TestNewMasterKeys_PartialNil(t *testing.T) {
// Only V10 wired — typical macOS shape. V11/V20 left nil.
k10 := []byte("v10-key-bytes-for-testing")
r := &recordingRetriever{key: k10}
keys, err := NewMasterKeys(Retrievers{V10: r}, "Chrome", "")
keys, err := NewMasterKeys(Retrievers{V10: r}, Hints{KeychainLabel: "Chrome"})
require.NoError(t, err)
assert.Equal(t, k10, keys.V10)
assert.Nil(t, keys.V11)
assert.Nil(t, keys.V20)
assert.Equal(t, 1, r.calls)
assert.Equal(t, "Chrome", r.gotStorage)
assert.Equal(t, Hints{KeychainLabel: "Chrome"}, r.gotHints)
}
func TestNewMasterKeys_ErrorWrapping(t *testing.T) {
@@ -172,7 +169,7 @@ func TestNewMasterKeys_ErrorWrapping(t *testing.T) {
sentinel := errors.New("sentinel")
r := Retrievers{V20: &recordingRetriever{err: sentinel}}
_, err := NewMasterKeys(r, "chrome", "")
_, err := NewMasterKeys(r, Hints{KeychainLabel: "chrome"})
require.Error(t, err)
assert.ErrorIs(t, err, sentinel, "errors.Is should find wrapped sentinel error")
}
+1 -1
View File
@@ -75,7 +75,7 @@ See `types/category.go` for the authoritative enum definition.
### 4.2 BrowserConfig
`BrowserConfig` is the declarative, platform-specific browser definition containing: Key (CLI matching), Name (display), Kind (engine), Storage (keychain label), UserDataDir (data path).
`BrowserConfig` is the declarative, platform-specific browser definition containing: Key (CLI matching; also the Windows ABE / winutil.Table identifier when WindowsABE is true), Name (display), Kind (engine), KeychainLabel (macOS Keychain / Linux D-Bus Secret Service label), WindowsABE (bool — enable Windows App-Bound Encryption v20 path), UserDataDir (data path).
### 4.3 Browser Selection Flow
+15 -8
View File
@@ -20,10 +20,17 @@ For Chromium encryption details (cipher versions, AES-CBC/GCM), see [RFC-003](00
## 2. KeyRetriever Interface
The interface takes two parameters:
The interface takes a single `Hints` struct so caller intent is explicit rather than positional:
- **`storage`** — platform-dependent identifier. On macOS it's the Keychain account label (e.g. `"Chrome"`); on Linux it's the D-Bus collection label (e.g. `"Chrome Safe Storage"`); on Windows it's the browser key used by `ABERetriever` to locate the elevation-service COM interface (e.g. `"chrome"`, `"edge"`). Ignored by `DPAPIRetriever`.
- **`localStatePath`** — path to `Local State` JSON file. Only used on Windows (DPAPI + ABE both read it).
```go
type Hints struct {
KeychainLabel string // macOS Keychain account / Linux D-Bus Secret Service item label (e.g. "Chrome", "Chrome Safe Storage")
WindowsABEKey string // Windows ABE browser key used by ABERetriever to locate the elevation-service COM interface (e.g. "chrome", "edge"). "" → ABE not applicable; ABERetriever returns (nil, nil) silently.
LocalStatePath string // path to Local State JSON. Only used on Windows (DPAPI + ABE both read it).
}
```
Each retriever reads only the field that applies to its platform; the others are ignored. Callers populate `Hints` from `BrowserConfig`: `KeychainLabel` is copied directly, `WindowsABEKey` is set to `cfg.Key` when `cfg.WindowsABE` is true.
The return value is the **ready-to-use decryption key** — either the raw AES key (Windows) or the PBKDF2-derived key (macOS/Linux).
@@ -67,11 +74,11 @@ All macOS strategies produce a raw password string from the keychain. This is de
| Key length | 16 bytes (AES-128) | |
| Hash | HMAC-SHA1 | |
### 3.4 Storage Labels
### 3.4 Keychain Labels
Each browser identifies its Keychain entry with a short account string — typically the browser's base name (`"Chrome"`, `"Brave"`, `"Arc"`). Edge uses `"Microsoft Edge"`. Related variants share labels rather than defining their own: Chrome Beta aliases onto `"Chrome"`, Opera GX aliases onto `"Opera"`.
The authoritative mapping lives in the `Storage` field of each entry in `platformBrowsers()` (`browser/browser_darwin.go`).
The authoritative mapping lives in the `KeychainLabel` field of each entry in `platformBrowsers()` (`browser/browser_darwin.go`).
## 4. Windows Key Retrieval
@@ -119,7 +126,7 @@ Windows populates two slots of the `keyretriever.Retrievers` struct — V10 (leg
**Why not a ChainRetriever?** `ChainRetriever` has first-success semantics: once ABE returns a key, DPAPI is never called. That semantics is wrong for orthogonal tiers — it was the root cause of issue #578, where upgraded profiles' v10-encrypted passwords silently failed because only the v20 key was retrieved. `NewMasterKeys` evaluates each tier independently and returns an `errors.Join` of per-tier failures; log severity is a caller-side decision. `browser/chromium::getMasterKeys` currently logs all tier errors uniformly at `Warnf` — the distinction between "partial" and "total" failure was judged low-value for a short-lived CLI where all warn lines are visible in the default output.
**Non-ABE Chromium forks** (Opera, Vivaldi, Yandex, 360, QQ, Sogou) have `Storage: ""` in `platformBrowsers()`. `ABERetriever` returns `(nil, nil)` for empty storage, which `NewMasterKeys` treats silently as "not applicable" — so attempting ABE on these forks is a no-op, not a failure. Their V10 DPAPI key continues to work unchanged.
**Non-ABE Chromium forks** (Opera, Vivaldi, Yandex, 360, QQ, Sogou) omit `WindowsABE` in `platformBrowsers()` (default false). The caller leaves `Hints.WindowsABEKey` empty, and `ABERetriever` returns `(nil, nil)` for empty `WindowsABEKey`, which `NewMasterKeys` treats silently as "not applicable" — so attempting ABE on these forks is a no-op, not a failure. Their V10 DPAPI key continues to work unchanged.
## 5. Linux Key Retrieval
@@ -157,11 +164,11 @@ Both strategies produce a password, derived via PBKDF2 with notably weaker param
A single iteration makes PBKDF2 essentially a keyed HMAC — no real key-stretching. Combined with the well-known fallback password `"peanuts"`, Linux Chromium encryption is trivial to break without the keyring.
### 5.4 Storage Labels
### 5.4 Keychain Labels
Linux D-Bus labels follow a `"<name> Safe Storage"` convention, but many browsers alias onto a small shared set rather than defining their own. The three distinct labels are `"Chrome Safe Storage"`, `"Chromium Safe Storage"`, and `"Brave Safe Storage"` — everything else maps onto one of these.
The authoritative mapping lives in the `Storage` field of each entry in `platformBrowsers()` (`browser/browser_linux.go`).
The authoritative mapping lives in the `KeychainLabel` field of each entry in `platformBrowsers()` (`browser/browser_linux.go`).
## 6. Platform Summary
+1 -1
View File
@@ -295,7 +295,7 @@ Three steps.
2. **Mine IIDs from TypeLib** — the interface IIDs live in the TypeLib resource of `<InstallDir>\Application\<version>\elevation_service.exe`. PowerShell + `ITypeLib.GetTypeInfo` enumerates them. Map `IElevator<Vendor>` → v1 IID, `IElevator2<Vendor>` → v2 IID (absent for older vendors).
3. **Determine vtable slot** — count `IElevator` methods in the TypeLib. Chrome-family has 3 methods (slot 5). Edge prepends 3 placeholders (slot 8). Avast extends the interface further (slot 13).
Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/browser_meta_windows.go` (add a matching `winutil.Entry` with the right `ABEKind` and install-path fallbacks), `browser/browser_windows.go` (set `Storage: "<key>"` for the new `BrowserConfig`), then `make payload-clean && make build-windows` and redeploy.
Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/browser_meta_windows.go` (add a matching `winutil.Entry` with the right `ABEKind` and install-path fallbacks; `Entry.Key` must equal the `BrowserConfig.Key` of the matching ABE-enabled config), `browser/browser_windows.go` (set `WindowsABE: true` on the new `BrowserConfig` so its `Key` doubles as the ABE / winutil.Table identifier), then `make payload-clean && make build-windows` and redeploy.
## 12. Known issues & future work
+6 -5
View File
@@ -83,11 +83,12 @@ const (
// BrowserConfig holds the declarative configuration for a browser installation.
type BrowserConfig struct {
Key string // lookup key: "chrome", "edge", "firefox"
Name string // display name: "Chrome", "Edge", "Firefox"
Kind BrowserKind // engine type
Storage string // macOS/Linux: keychain/GNOME label. Windows: ABE browser key (triggers reflective injection when populated).
UserDataDir string // base browser directory
Key string // lookup key; doubles as the Windows ABE / winutil.Table key when WindowsABE is true
Name string // display name
Kind BrowserKind // engine type
KeychainLabel string // macOS Keychain account / Linux D-Bus Secret Service label; "" = none
WindowsABE bool // enable Windows App-Bound Encryption v20 (reflective injection)
UserDataDir string // base browser directory
}
// BrowserData holds all extracted browser data with typed slices.
+3 -3
View File
@@ -35,9 +35,9 @@ const (
// Entry is the per-browser Windows metadata record.
//
// Key must match browser.BrowserConfig.Storage so retrievers and path
// resolvers share a single lookup identifier. CLSID/IID bytes are *not*
// stored here; see the package doc for why.
// Key must match browser.BrowserConfig.Key for every config that sets
// WindowsABE: true, so retrievers and path resolvers share a single lookup
// identifier. CLSID/IID bytes are *not* stored here; see the package doc for why.
type Entry struct {
Key string
ExeName string
+1 -1
View File
@@ -29,7 +29,7 @@ var ErrExecutableNotFound = errors.New("browser executable not found")
// is not running and the registry is missing the entry).
//
// browserKey must match an Entry in Table; keys align with
// browser.BrowserConfig.Storage.
// browser.BrowserConfig.Key (for configs that set WindowsABE: true).
func ExecutablePath(browserKey string) (string, error) {
entry, ok := Table[browserKey]
if !ok {