feat: add Safari password extraction from macOS Keychain (#568)

This commit is contained in:
Roger
2026-04-13 21:34:40 +08:00
committed by GitHub
parent d105a1f488
commit 370c5882c4
18 changed files with 493 additions and 132 deletions
+2
View File
@@ -86,6 +86,8 @@ linters:
- "all"
- "csv"
- "json"
- "https"
- "http"
gocritic:
enabled-tags:
- diagnostic
+44 -23
View File
@@ -14,7 +14,8 @@ import (
"github.com/moond4rk/hackbrowserdata/types"
)
// Browser is the interface that both chromium.Browser and firefox.Browser implement.
// Browser is the interface implemented by every engine package —
// chromium.Browser, firefox.Browser, and safari.Browser.
type Browser interface {
BrowserName() string
ProfileName() string
@@ -27,30 +28,57 @@ type Browser interface {
type PickOptions struct {
Name string // browser name filter: "all"|"chrome"|"firefox"|...
ProfilePath string // custom profile directory override
KeychainPassword string // macOS keychain password (ignored on other platforms)
KeychainPassword string // macOS only — see browser_darwin.go
}
// PickBrowsers returns browsers matching the given options.
// When Name is "all", all known browsers are tried.
// ProfilePath overrides the default user data directory (only when targeting a specific browser).
// PickBrowsers returns browsers that are fully wired up for Extract: the
// key retriever chain and (on macOS) the Keychain password are already
// injected, so the caller can call b.Extract directly. This is the entry
// point for extraction workflows like `dump`.
//
// On macOS this may trigger an interactive prompt for the login password
// when the target set includes a Chromium variant or Safari. Commands that
// only need metadata (name, profile path, per-category counts) should use
// DiscoverBrowsers instead to skip injection — and thereby the prompt.
//
// When Name is "all", all known browsers are tried. ProfilePath overrides
// the default user data directory (only when targeting a specific browser).
func PickBrowsers(opts PickOptions) ([]Browser, error) {
browsers, err := pickFromConfigs(platformBrowsers(), opts)
if err != nil {
return nil, err
}
inject := newPlatformInjector(opts)
for _, b := range browsers {
inject(b)
}
return browsers, nil
}
// DiscoverBrowsers returns browsers for metadata-only workflows — listing,
// profile paths, per-category counts. Decryption dependencies are NOT
// injected, so calling b.Extract on the returned browsers will not
// successfully decrypt protected data (passwords, cookies, credit cards).
// CountEntries, BrowserName, ProfileName, and ProfileDir all work
// correctly without injection.
//
// Unlike PickBrowsers, DiscoverBrowsers never prompts for the macOS
// Keychain password, making it the correct choice for `list`-style
// commands that have no use for the credential.
func DiscoverBrowsers(opts PickOptions) ([]Browser, error) {
return pickFromConfigs(platformBrowsers(), opts)
}
// pickFromConfigs is the testable core of PickBrowsers. It iterates over
// platform browser configs, discovers installed profiles, and injects a
// shared key retriever into Chromium browsers for decryption.
// pickFromConfigs is the testable core of PickBrowsers: it filters the
// platform browser list and discovers installed profiles for each match.
// Dependency injection (key retrievers, keychain credentials) is intentionally
// NOT done here — see PrepareExtract.
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
name := strings.ToLower(opts.Name)
if name == "" {
name = "all"
}
// Create a single key retriever shared across all Chromium browsers.
// On macOS this avoids repeated password prompts; on other platforms
// it's harmless (DPAPI reads Local State per-profile, D-Bus is stateless).
retriever := keyretriever.DefaultRetriever(opts.KeychainPassword)
configs = resolveGlobs(configs)
var browsers []Browser
@@ -78,21 +106,14 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
continue
}
// Inject the shared key retriever into browsers that need it.
// Chromium browsers implement retrieverSetter; Firefox does not.
for _, b := range found {
if setter, ok := b.(retrieverSetter); ok {
setter.SetRetriever(retriever)
}
}
browsers = append(browsers, found...)
}
return browsers, nil
}
// retrieverSetter is implemented by browsers that need an external key retriever.
// This allows pickFromConfigs to inject the shared retriever after construction
// without coupling the Browser interface to Chromium-specific concerns.
// retrieverSetter is an optional capability interface. Chromium variants
// implement it to receive a master-key retriever chain; Firefox and Safari
// do not.
type retrieverSetter interface {
SetRetriever(keyretriever.KeyRetriever)
}
+95
View File
@@ -3,6 +3,14 @@
package browser
import (
"fmt"
"os"
"github.com/moond4rk/keychainbreaker"
"golang.org/x/term"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types"
)
@@ -99,3 +107,90 @@ func platformBrowsers() []types.BrowserConfig {
},
}
}
// resolveKeychainPassword returns the keychain password for macOS.
// If not provided via CLI flag, it prompts interactively when stdin is a TTY.
// After obtaining the password, it verifies against keychainbreaker; on any
// failure it returns "" so downstream code enters "no password" mode rather
// than propagating a known-bad credential. Safari then exports
// keychain-protected entries as metadata-only via keychainbreaker's partial
// extraction mode; Chromium falls back to SecurityCmdRetriever.
func resolveKeychainPassword(flagPassword string) string {
password := flagPassword
if password == "" {
if !term.IsTerminal(int(os.Stdin.Fd())) {
log.Warnf("macOS login password not provided and stdin is not a TTY; keychain-protected data will be exported as metadata only")
return ""
}
fmt.Fprint(os.Stderr, "Enter macOS login password: ")
pwd, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
log.Warnf("failed to read macOS login password: %v; keychain-protected data will be exported as metadata only", err)
return ""
}
password = string(pwd)
}
if password == "" {
log.Warnf("no macOS login password entered; keychain-protected data will be exported as metadata only")
return ""
}
// Verify early: try to unlock keychain with keychainbreaker. On failure
// return "" so KeychainPasswordRetriever and Safari both skip the credential
// and rely on their respective fallback paths (SecurityCmdRetriever for
// Chromium, metadata-only export for Safari).
kc, err := keychainbreaker.Open()
if err != nil {
log.Warnf("keychain open failed: %v; keychain-protected data will be exported as metadata only", err)
return ""
}
if err := kc.TryUnlock(keychainbreaker.WithPassword(password)); err != nil {
log.Warnf("keychain unlock failed with provided password; keychain-protected data will be exported as metadata only")
log.Debugf("keychain unlock detail: %v", err)
return ""
}
return password
}
// keychainPasswordSetter is an optional capability interface satisfied by
// Safari, which reads InternetPassword records directly from the login keychain.
type keychainPasswordSetter interface {
SetKeychainPassword(string)
}
// newPlatformInjector returns a closure that injects the Chromium master-key
// retriever and the Safari Keychain password into each Browser.
//
// Resolution is lazy: the keychain password prompt and retriever construction
// are deferred until the first Browser that actually needs them passes through
// the closure. Browsers that satisfy neither setter interface (e.g. Firefox)
// short-circuit without ever touching the keychain, so `-b firefox` on macOS
// no longer triggers a password prompt.
func newPlatformInjector(opts PickOptions) func(Browser) {
var (
password string
retriever keyretriever.KeyRetriever
resolved bool
)
return func(b Browser) {
rs, needsRetriever := b.(retrieverSetter)
kps, needsKeychainPassword := b.(keychainPasswordSetter)
if !needsRetriever && !needsKeychainPassword {
return
}
if !resolved {
password = resolveKeychainPassword(opts.KeychainPassword)
retriever = keyretriever.DefaultRetriever(password)
resolved = true
}
if needsRetriever {
rs.SetRetriever(retriever)
}
if needsKeychainPassword {
kps.SetKeychainPassword(password)
}
}
}
+12
View File
@@ -3,6 +3,7 @@
package browser
import (
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/types"
)
@@ -65,3 +66,14 @@ func platformBrowsers() []types.BrowserConfig {
},
}
}
// newPlatformInjector returns a closure that injects the Chromium master-key
// retriever chain into each Browser.
func newPlatformInjector(_ PickOptions) func(Browser) {
retriever := keyretriever.DefaultRetriever()
return func(b Browser) {
if s, ok := b.(retrieverSetter); ok {
s.SetRetriever(retriever)
}
}
}
+12
View File
@@ -3,6 +3,7 @@
package browser
import (
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/types"
)
@@ -118,3 +119,14 @@ func platformBrowsers() []types.BrowserConfig {
},
}
}
// newPlatformInjector returns a closure that injects the Chromium master-key
// retriever chain into each Browser.
func newPlatformInjector(_ PickOptions) func(Browser) {
retriever := keyretriever.DefaultRetriever()
return func(b Browser) {
if s, ok := b.(retrieverSetter); ok {
s.SetRetriever(retriever)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package safari
import (
"fmt"
"sort"
"strings"
"github.com/moond4rk/keychainbreaker"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types"
)
func extractPasswords(keychainPassword string) ([]types.LoginEntry, error) {
passwords, err := getInternetPasswords(keychainPassword)
if err != nil {
return nil, err
}
var logins []types.LoginEntry
for _, p := range passwords {
url := buildURL(p.Protocol, p.Server, p.Port, p.Path)
if url == "" || p.Account == "" {
continue
}
logins = append(logins, types.LoginEntry{
URL: url,
Username: p.Account,
Password: p.PlainPassword,
CreatedAt: p.Created,
})
}
sort.Slice(logins, func(i, j int) bool {
return logins[i].CreatedAt.After(logins[j].CreatedAt)
})
return logins, nil
}
func countPasswords(keychainPassword string) (int, error) {
passwords, err := extractPasswords(keychainPassword)
if err != nil {
return 0, err
}
return len(passwords), nil
}
// getInternetPasswords reads InternetPassword records directly from the
// macOS login keychain. See rfcs/006-key-retrieval-mechanisms.md §7 for why
// Safari owns this path instead of routing through crypto/keyretriever.
//
// TryUnlock is always invoked — with the user-supplied password when one is
// available, otherwise with no options — to enable keychainbreaker's partial
// extraction mode. With a valid password we get fully decrypted entries; with
// empty or wrong password we still get metadata records (URL, account,
// timestamps) and PlainPassword left blank, which Safari can export as
// metadata-only output instead of failing with ErrLocked.
func getInternetPasswords(keychainPassword string) ([]keychainbreaker.InternetPassword, error) {
kc, err := keychainbreaker.Open()
if err != nil {
return nil, fmt.Errorf("open keychain: %w", err)
}
var unlockOpts []keychainbreaker.UnlockOption
if keychainPassword != "" {
unlockOpts = append(unlockOpts, keychainbreaker.WithPassword(keychainPassword))
}
if err := kc.TryUnlock(unlockOpts...); err != nil {
log.Debugf("keychain unlock detail: %v", err)
}
passwords, err := kc.InternetPasswords()
if err != nil {
return nil, fmt.Errorf("extract internet passwords: %w", err)
}
return passwords, nil
}
// buildURL constructs a URL from InternetPassword fields.
func buildURL(protocol, server string, port uint32, path string) string {
if server == "" {
return ""
}
// Convert macOS Keychain FourCC protocol code to URL scheme.
// Only "htps" needs special mapping; others just need space trimming.
scheme := strings.TrimRight(protocol, " ")
if scheme == "" || scheme == "htps" {
scheme = "https"
}
url := scheme + "://" + server
defaultPorts := map[string]uint32{"https": 443, "http": 80, "ftp": 21}
if port > 0 && port != defaultPorts[scheme] {
url += fmt.Sprintf(":%d", port)
}
if path != "" && path != "/" {
url += path
}
return url
}
+91
View File
@@ -0,0 +1,91 @@
package safari
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildURL(t *testing.T) {
tests := []struct {
name string
protocol string
server string
port uint32
path string
want string
}{
{
name: "https default port",
protocol: "htps",
server: "github.com",
port: 443,
want: "https://github.com",
},
{
name: "https custom port",
protocol: "htps",
server: "example.com",
port: 8443,
want: "https://example.com:8443",
},
{
name: "http with path",
protocol: "http",
server: "192.168.1.1",
port: 80,
path: "/admin",
want: "http://192.168.1.1/admin",
},
{
name: "http non-default port",
protocol: "http",
server: "localhost",
port: 8080,
want: "http://localhost:8080",
},
{
name: "empty server returns empty",
protocol: "htps",
server: "",
port: 443,
want: "",
},
{
name: "empty protocol defaults to https",
protocol: "",
server: "example.com",
port: 0,
want: "https://example.com",
},
{
name: "smb protocol",
protocol: "smb ",
server: "fileserver",
port: 445,
want: "smb://fileserver:445",
},
{
name: "ftp default port",
protocol: "ftp ",
server: "ftp.example.com",
port: 21,
want: "ftp://ftp.example.com",
},
{
name: "root path ignored",
protocol: "htps",
server: "example.com",
port: 443,
path: "/",
want: "https://example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildURL(tt.protocol, tt.server, tt.port, tt.path)
assert.Equal(t, tt.want, got)
})
}
}
+24 -4
View File
@@ -14,10 +14,17 @@ import (
// Safari has a single flat data directory (no profile subdirectories)
// and stores most data unencrypted (passwords live in macOS Keychain).
type Browser struct {
cfg types.BrowserConfig
dataDir string // absolute path to ~/Library/Safari
sources map[types.Category][]sourcePath // Category → candidate paths
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
cfg types.BrowserConfig
dataDir string // absolute path to ~/Library/Safari
keychainPassword string // macOS login password for Keychain unlock
sources map[types.Category][]sourcePath // Category → candidate paths
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
}
// SetKeychainPassword sets the macOS login password used to unlock
// the Keychain for Safari password extraction.
func (b *Browser) SetKeychainPassword(password string) {
b.keychainPassword = password
}
// NewBrowsers checks whether Safari data exists at cfg.UserDataDir and returns
@@ -53,6 +60,11 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro
data := &types.BrowserData{}
for _, cat := range categories {
// Password is stored in macOS Keychain, not in a file.
if cat == types.Password {
b.extractCategory(data, cat, "")
continue
}
path, ok := tempPaths[cat]
if !ok {
continue
@@ -75,6 +87,10 @@ func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]
counts := make(map[types.Category]int)
for _, cat := range categories {
if cat == types.Password {
counts[cat] = b.countCategory(cat, "")
continue
}
path, ok := tempPaths[cat]
if !ok {
continue
@@ -106,6 +122,8 @@ func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, path string) {
var err error
switch cat {
case types.Password:
data.Passwords, err = extractPasswords(b.keychainPassword)
case types.History:
data.Histories, err = extractHistories(path)
case types.Cookie:
@@ -127,6 +145,8 @@ func (b *Browser) countCategory(cat types.Category, path string) int {
var count int
var err error
switch cat {
case types.Password:
count, err = countPasswords(b.keychainPassword)
case types.History:
count, err = countHistories(path)
case types.Cookie:
+1 -1
View File
@@ -82,7 +82,7 @@ func dumpCmd() *cobra.Command {
cmd.Flags().StringVarP(&browserName, "browser", "b", "all", "target browser: all|"+browser.Names())
cmd.Flags().StringVarP(&category, "category", "c", "all", "data categories (comma-separated): all|"+categoryNames())
cmd.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format: csv|json|cookie-editor")
cmd.Flags().StringVarP(&outputFormat, "format", "f", "json", "output format: csv|json|cookie-editor")
cmd.Flags().StringVarP(&outputDir, "dir", "d", "results", "output directory")
cmd.Flags().StringVarP(&profilePath, "profile-path", "p", "", "custom profile dir path, get with chrome://version")
cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password")
+1 -1
View File
@@ -20,7 +20,7 @@ func listCmd() *cobra.Command {
Example: ` hack-browser-data list
hack-browser-data list --detail`,
RunE: func(cmd *cobra.Command, args []string) error {
browsers, err := browser.PickBrowsers(browser.PickOptions{Name: "all"})
browsers, err := browser.DiscoverBrowsers(browser.PickOptions{Name: "all"})
if err != nil {
return err
}
+9
View File
@@ -1,3 +1,12 @@
// Package keyretriever owns the master-key acquisition chain shared by all
// Chromium variants (Chrome, Edge, Brave, Arc, Opera, Vivaldi, Yandex, …).
// The chain is built once per process and reused for every profile.
//
// Firefox and Safari do not route through this package — Firefox derives
// its own keys from key4.db via NSS PBE, and Safari reads InternetPassword
// records directly from login.keychain-db. Each browser package owns its
// own credential-acquisition strategy; see rfcs/006-key-retrieval-mechanisms.md
// §7 for the rationale.
package keyretriever
import (
+7 -52
View File
@@ -8,16 +8,12 @@ import (
"crypto/sha1"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/moond4rk/keychainbreaker"
"golang.org/x/term"
"github.com/moond4rk/hackbrowserdata/log"
)
// https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_mac.mm;l=157
@@ -106,41 +102,6 @@ func (r *KeychainPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, erro
return findStorageKey(r.records, storage)
}
// TerminalPasswordRetriever prompts for the keychain password interactively
// via the terminal using golang.org/x/term (with echo disabled).
// Automatically skipped when stdin is not a TTY.
type TerminalPasswordRetriever struct {
once sync.Once
records []keychainbreaker.GenericPassword
err error
}
func (r *TerminalPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil, fmt.Errorf("terminal: stdin is not a TTY")
}
r.once.Do(func() {
fmt.Fprint(os.Stderr, "Enter macOS login password: ")
pwd, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
r.err = fmt.Errorf("terminal: read password: %w", err)
return
}
r.records, r.err = loadKeychainRecords(string(pwd))
if r.err != nil {
log.Warnf("keychain unlock failed with provided password")
log.Debugf("keychain unlock detail: %v", r.err)
}
})
if r.err != nil {
return nil, r.err
}
return findStorageKey(r.records, storage)
}
// SecurityCmdRetriever uses macOS `security` CLI to query Keychain.
// This may trigger a password dialog on macOS. Results are cached
// per storage name so each browser's key is fetched only once.
@@ -194,22 +155,16 @@ func (r *SecurityCmdRetriever) retrieveKeyOnce(storage string) ([]byte, error) {
return darwinParams.deriveKey(secret), nil
}
// DefaultRetriever returns the macOS retriever chain.
// The chain tries each method in order until one succeeds:
// 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only, non-interactive)
// 2. KeychainPasswordRetriever — direct unlock with --keychain-pw flag
// 3. TerminalPasswordRetriever — interactive password prompt via terminal
// 4. SecurityCmdRetriever — security CLI fallback (may trigger system dialog)
// DefaultRetriever returns the macOS retriever chain, tried in order:
//
// 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only)
// 2. KeychainPasswordRetriever — direct unlock, skipped when password is empty
// 3. SecurityCmdRetriever — `security` CLI fallback (may trigger a dialog)
func DefaultRetriever(keychainPassword string) KeyRetriever {
retrievers := []KeyRetriever{
&GcoredumpRetriever{},
}
retrievers := []KeyRetriever{&GcoredumpRetriever{}}
if keychainPassword != "" {
retrievers = append(retrievers, &KeychainPasswordRetriever{Password: keychainPassword})
}
retrievers = append(retrievers,
&TerminalPasswordRetriever{},
&SecurityCmdRetriever{cache: make(map[string]securityResult)},
)
retrievers = append(retrievers, &SecurityCmdRetriever{cache: make(map[string]securityResult)})
return NewChain(retrievers...)
}
@@ -39,13 +39,3 @@ func TestKeychainPasswordRetriever_EmptyPassword(t *testing.T) {
assert.Nil(t, key)
assert.Contains(t, err.Error(), "keychain password not provided")
}
func TestTerminalPasswordRetriever_NonTTY(t *testing.T) {
// In CI/test environments, stdin is not a TTY.
// The retriever should return an error so the chain can log it and continue.
r := &TerminalPasswordRetriever{}
key, err := r.RetrieveKey("Chrome", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "stdin is not a TTY")
assert.Nil(t, key)
}
+1 -2
View File
@@ -78,8 +78,7 @@ func (r *FallbackRetriever) RetrieveKey(_, _ string) ([]byte, error) {
// DefaultRetriever returns the Linux retriever chain:
// D-Bus Secret Service first, then "peanuts" fallback.
// The keychainPassword parameter is unused on Linux.
func DefaultRetriever(_ string) KeyRetriever {
func DefaultRetriever() KeyRetriever {
return NewChain(
&DBusRetriever{},
&FallbackRetriever{},
@@ -35,7 +35,7 @@ func TestFallbackRetriever(t *testing.T) {
}
func TestDefaultRetriever_Linux(t *testing.T) {
r := DefaultRetriever("")
r := DefaultRetriever()
chain, ok := r.(*ChainRetriever)
require.True(t, ok, "DefaultRetriever should return a *ChainRetriever")
+1 -2
View File
@@ -49,7 +49,6 @@ func (r *DPAPIRetriever) RetrieveKey(_, localStatePath string) ([]byte, error) {
}
// DefaultRetriever returns the Windows retriever (DPAPI only).
// The keychainPassword parameter is unused on Windows.
func DefaultRetriever(_ string) KeyRetriever {
func DefaultRetriever() KeyRetriever {
return &DPAPIRetriever{}
}
+37 -19
View File
@@ -65,43 +65,61 @@ Each category has a corresponding Entry struct with `json` and `csv` struct tags
### 4.1 BrowserKind
Four engine kinds determine source paths and extractors:
Each config declares an engine kind that determines source paths and extraction logic. Kinds fall into three engine families:
| Kind | Description |
|------|-------------|
| `Chromium` | Standard Chromium layout |
| `ChromiumYandex` | Yandex variant: different file names and SQL queries |
| `ChromiumOpera` | Opera variant: different extension key, Roaming path on Windows |
| `Firefox` | Firefox: NSS encryption, SQLite + JSON files |
- **Chromium** (`Chromium`, `ChromiumYandex`, `ChromiumOpera`) — the standard Chromium layout plus two variants that override file names or storage paths for Yandex and Opera forks. See RFC-003.
- **Firefox** — NSS-based key derivation from `key4.db`, SQLite + JSON source files. See RFC-005.
- **Safari** — macOS only, with direct Keychain-based credential extraction. See RFC-006 §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).
### 4.3 PickBrowsers() Flow
### 4.3 Browser Selection Flow
There are two entry points, one for extraction and one for discovery:
```
PickBrowsers(opts)
→ platformBrowsers() // build-tagged: returns []BrowserConfig for this OS
→ pickFromConfigs(configs, opts) // filter by name, apply profile-path/keychain overrides
newBrowsers(cfg) // dispatch by Kind to chromium.NewBrowsers or firefox.NewBrowsers
→ discoverProfiles() // scan for profile subdirectories
resolveSourcePaths() // stat each candidate path, first match wins
PickBrowsers(opts) // used by `dump` — ready to Extract
→ pickFromConfigs(configs, opts) // shared discovery core
→ platformBrowsers() // build-tagged list for this OS
filter by name / profile path
→ newBrowsers(cfg) // dispatch to chromium/firefox/safari.NewBrowsers
discoverProfiles() // scan profile subdirectories
→ resolveSourcePaths() // stat candidates, first match wins
→ newPlatformInjector(opts) // build-tagged: returns a func(Browser)
→ for each browser: // closure captures retriever + keychain pw lazily
inject(b) // type-assert retrieverSetter / keychainPasswordSetter
DiscoverBrowsers(opts) // used by `list` / `list --detail`
→ pickFromConfigs(configs, opts) // same shared discovery core, NO injection
```
`PickBrowsers` does discovery + decryption setup in one call; the returned
browsers are ready for `b.Extract`. `DiscoverBrowsers` skips injection
entirely, so list-style commands never trigger the macOS Keychain password
prompt — they have no use for the credential. Both entry points share the
same `pickFromConfigs` core, so filtering/profile-path/glob semantics stay
consistent.
Key design decisions:
- **One KeyRetriever per browser** — created once and shared across all profiles to prevent repeated keychain prompts on macOS.
- **One KeyRetriever chain per process** — built lazily inside `newPlatformInjector` and reused across every Chromium browser and every profile to prevent repeated keychain prompts on macOS.
- **Discovery is decoupled from injection** — `pickFromConfigs` is injection-free; `DiscoverBrowsers` stops after it, `PickBrowsers` continues into injection.
- **Profile discovery differs by engine**: Chromium looks for `Preferences` files in subdirectories; Firefox accepts any subdirectory containing known source files.
- **Flat layout fallback** — Opera-style browsers that store data directly in UserDataDir (no profile subdirectories) are handled by falling back to the base directory.
### 4.4 Platform Browser Lists
Browser configs are defined per-platform via build tags:
Browser configs are defined per-platform via build tags in `platformBrowsers()` (`browser/browser_{darwin,linux,windows}.go`). The supported set groups by engine family:
- **macOS** — 12 browsers (Chrome, Edge, Chromium, Chrome Beta, Opera, OperaGX, Vivaldi, CocCoc, Brave, Yandex, Arc, Firefox)
- **Windows** — 16 browsers (all macOS minus Arc, plus 360 Speed, 360 Speed X, QQ, DC, Sogou)
- **Linux** — 8 browsers (Chrome, Edge, Chromium, Chrome Beta, Opera, Vivaldi, Brave, Firefox)
- **Chromium-based** — the largest family, covering mainstream browsers (Chrome, Edge, Brave, Vivaldi, Opera, Chromium) across all three platforms plus regional variants and forks. Windows carries the longest list because of China-region Chromium forks (360, QQ, Sogou, DC, …) and MSIX-packaged browsers with dynamic install paths (Arc, DuckDuckGo).
- **Firefox** — all three platforms, via internal NSS key derivation (RFC-005).
- **Safari** — macOS only, via direct Keychain `InternetPassword` extraction (RFC-006 §7).
Adding a new browser is a config-only change in `platformBrowsers()`; this section does not need updates for new variants within an existing family.
## 5. Extract() Orchestration
+52 -17
View File
@@ -29,7 +29,7 @@ The return value is the **ready-to-use decryption key** — either the raw AES k
`ChainRetriever` wraps multiple retrievers and tries them in order. The first successful result wins. If all fail, errors from every retriever are combined into a single error.
**Caching**: the retriever is created once per browser and shared across all profiles. macOS retrievers use `sync.Once` internally, so multi-profile browsers only trigger one keychain prompt or memory dump.
**Caching**: the retriever chain is created once per process inside `newPlatformInjector` (see `browser/browser_{darwin,linux,windows}.go`) and shared across every Chromium browser and every profile. macOS retrievers additionally use `sync.Once` internally, so multi-profile browsers only trigger one keychain prompt or memory dump.
## 3. macOS Key Retrieval
@@ -69,17 +69,9 @@ All macOS strategies produce a raw password string from the keychain. This is de
### 3.4 Storage Labels
| Browser | Keychain Account |
|---------|-----------------|
| Chrome / Chrome Beta | `"Chrome"` |
| Edge | `"Microsoft Edge"` |
| Chromium | `"Chromium"` |
| Opera / OperaGX | `"Opera"` |
| Vivaldi | `"Vivaldi"` |
| Brave | `"Brave"` |
| Yandex | `"Yandex"` |
| Arc | `"Arc"` |
| CocCoc | `"CocCoc"` |
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`).
## 4. Windows Key Retrieval
@@ -148,11 +140,9 @@ A single iteration makes PBKDF2 essentially a keyed HMAC — no real key-stretch
### 5.4 Storage Labels
| Browser | D-Bus Label |
|---------|-------------|
| Chrome / Chrome Beta / Vivaldi | `"Chrome Safe Storage"` |
| Chromium / Edge / Opera | `"Chromium Safe Storage"` |
| Brave | `"Brave Safe Storage"` |
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`).
## 6. Platform Summary
@@ -164,6 +154,51 @@ A single iteration makes PBKDF2 essentially a keyed HMAC — no real key-stretch
\* Only included when `--keychain-pw` is provided.
## 7. Safari Credential Extraction
Safari is **not** a consumer of the `KeyRetriever` interface. It has its own credential-extraction path in `browser/safari/extract_password.go`, which uses [keychainbreaker](https://github.com/moond4rk/keychainbreaker) directly to list `InternetPassword` records from `login.keychain-db`.
This is a deliberate architectural choice, not an oversight. The following sections explain why.
### 7.1 Why Safari Does Not Share the Chromium Chain
| Aspect | Chromium chain | Safari direct access |
|---|---|---|
| Output | A 16-byte AES-128 key | A list of `InternetPassword` records |
| Use case | Decrypt Login Data DB | Records *are* the credentials |
| Number of consumers | 10+ Chromium variants | 1 (Safari only) |
| Failure mode | Hard fail (no key → cannot decrypt) | Soft fail (degrade to metadata-only) |
| Caching benefit | High (multi-profile, multi-browser) | None (single browser, single call) |
Forcing Safari through the `KeyRetriever` interface would require returning a different type than `[]byte`, contradicting the interface's documented purpose as the *master-key* abstraction. Forcing it through a parallel "InternetPassword chain" would be over-engineering for a single consumer that has no fallback strategies worth chaining.
Note the "failure mode" row in particular: Chromium *must* have a master key or extraction fails entirely, so it needs a chain of escalating strategies. Safari can degrade gracefully — if the keychain cannot be unlocked, metadata-only export (URLs and usernames, no plaintext passwords) is still useful output, so a single "try keychainbreaker, warn on failure" is sufficient.
### 7.2 The General Rule
> **Each browser package owns its own credential-acquisition strategy. `crypto/keyretriever` exists only to share retrieval logic across the Chromium variant family. New browser implementations should follow Safari's and Firefox's example — own your credential code.**
Evidence the rule is already in force:
- **Firefox** (`browser/firefox/firefox.go`) does not import `keyretriever` or `keychainbreaker`. It derives keys from `key4.db` via internal NSS PBE. See RFC-005.
- **Safari** (`browser/safari/extract_password.go`) uses `keychainbreaker` directly for `InternetPassword` records.
- **Chromium variants** all go through `crypto/keyretriever` because they share exactly one chain and benefit from the shared `sync.Once` caching.
Future contributors adding a new macOS browser that reads credentials from the Keychain should add their access logic to that browser's package, not extend `keyretriever`. Only extend `keyretriever` if the new browser is a Chromium variant that fits the existing master-key chain.
### 7.3 Where the `--keychain-pw` Password Goes
The macOS login password is resolved once at startup by `browser/browser_darwin.go::resolveKeychainPassword`, then delivered to both consumers from within a single platform-specific closure, `newPlatformInjector` (defined per platform in `browser/browser_{darwin,linux,windows}.go`). The closure captures both the retriever chain and the raw password, and applies whichever capability interface each Browser happens to satisfy:
| Consumer | Capability interface | Defined in | Payload |
|---|---|---|---|
| Chromium browsers | `retrieverSetter` | `browser/browser.go` | `keyretriever.KeyRetriever` chain |
| Safari | `keychainPasswordSetter` | `browser/browser_darwin.go` | raw `string` |
The two setters are **intentionally not unified**. They carry different abstractions — one hands the browser a pre-assembled retrieval chain, the other hands the browser a credential token to unlock its own access path. Unifying them would create a leaky polymorphic interface with no real shared semantics. Note that `keychainPasswordSetter` is defined in the darwin-only file because Safari (its only implementer) is darwin-only.
`resolveKeychainPassword` additionally performs an early `TryUnlock` against `keychainbreaker` before the chain is built, so a bad password surfaces as a startup warning rather than a mid-extraction failure. The small cost of opening the keychain twice (once for validation, once inside `KeychainPasswordRetriever`) buys meaningful UX.
## References
- **macOS**: [os_crypt_mac.mm](https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_mac.mm;l=157)