From 370c5882c4008fd03479b7abc7816c7d88f53a2a Mon Sep 17 00:00:00 2001 From: Roger Date: Mon, 13 Apr 2026 21:34:40 +0800 Subject: [PATCH] feat: add Safari password extraction from macOS Keychain (#568) --- .golangci.yml | 2 + browser/browser.go | 67 ++++++++---- browser/browser_darwin.go | 95 ++++++++++++++++ browser/browser_linux.go | 12 ++ browser/browser_windows.go | 12 ++ browser/safari/extract_password.go | 103 ++++++++++++++++++ browser/safari/extract_password_test.go | 91 ++++++++++++++++ browser/safari/safari.go | 28 ++++- cmd/hack-browser-data/dump.go | 2 +- cmd/hack-browser-data/list.go | 2 +- crypto/keyretriever/keyretriever.go | 9 ++ crypto/keyretriever/keyretriever_darwin.go | 59 ++-------- .../keyretriever/keyretriever_darwin_test.go | 10 -- crypto/keyretriever/keyretriever_linux.go | 3 +- .../keyretriever/keyretriever_linux_test.go | 2 +- crypto/keyretriever/keyretriever_windows.go | 3 +- rfcs/001-project-architecture.md | 56 ++++++---- rfcs/006-key-retrieval-mechanisms.md | 69 +++++++++--- 18 files changed, 493 insertions(+), 132 deletions(-) create mode 100644 browser/safari/extract_password.go create mode 100644 browser/safari/extract_password_test.go diff --git a/.golangci.yml b/.golangci.yml index e039ebc..091d8e7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -86,6 +86,8 @@ linters: - "all" - "csv" - "json" + - "https" + - "http" gocritic: enabled-tags: - diagnostic diff --git a/browser/browser.go b/browser/browser.go index f817a89..bedfa49 100644 --- a/browser/browser.go +++ b/browser/browser.go @@ -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) } diff --git a/browser/browser_darwin.go b/browser/browser_darwin.go index 4196c37..2d1254a 100644 --- a/browser/browser_darwin.go +++ b/browser/browser_darwin.go @@ -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) + } + } +} diff --git a/browser/browser_linux.go b/browser/browser_linux.go index d9d28bc..a8828c5 100644 --- a/browser/browser_linux.go +++ b/browser/browser_linux.go @@ -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) + } + } +} diff --git a/browser/browser_windows.go b/browser/browser_windows.go index 6248b0b..2c550d8 100644 --- a/browser/browser_windows.go +++ b/browser/browser_windows.go @@ -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) + } + } +} diff --git a/browser/safari/extract_password.go b/browser/safari/extract_password.go new file mode 100644 index 0000000..89bb5ea --- /dev/null +++ b/browser/safari/extract_password.go @@ -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 +} diff --git a/browser/safari/extract_password_test.go b/browser/safari/extract_password_test.go new file mode 100644 index 0000000..542ae52 --- /dev/null +++ b/browser/safari/extract_password_test.go @@ -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) + }) + } +} diff --git a/browser/safari/safari.go b/browser/safari/safari.go index 31b3a03..b3f9a08 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -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: diff --git a/cmd/hack-browser-data/dump.go b/cmd/hack-browser-data/dump.go index 15abd1a..b429e67 100644 --- a/cmd/hack-browser-data/dump.go +++ b/cmd/hack-browser-data/dump.go @@ -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") diff --git a/cmd/hack-browser-data/list.go b/cmd/hack-browser-data/list.go index ef9cbe1..c2451f2 100644 --- a/cmd/hack-browser-data/list.go +++ b/cmd/hack-browser-data/list.go @@ -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 } diff --git a/crypto/keyretriever/keyretriever.go b/crypto/keyretriever/keyretriever.go index 7b9da2d..99aa788 100644 --- a/crypto/keyretriever/keyretriever.go +++ b/crypto/keyretriever/keyretriever.go @@ -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 ( diff --git a/crypto/keyretriever/keyretriever_darwin.go b/crypto/keyretriever/keyretriever_darwin.go index dd5a5df..6ae49f8 100644 --- a/crypto/keyretriever/keyretriever_darwin.go +++ b/crypto/keyretriever/keyretriever_darwin.go @@ -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...) } diff --git a/crypto/keyretriever/keyretriever_darwin_test.go b/crypto/keyretriever/keyretriever_darwin_test.go index e4164f7..a91e398 100644 --- a/crypto/keyretriever/keyretriever_darwin_test.go +++ b/crypto/keyretriever/keyretriever_darwin_test.go @@ -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) -} diff --git a/crypto/keyretriever/keyretriever_linux.go b/crypto/keyretriever/keyretriever_linux.go index e934fc8..c77b693 100644 --- a/crypto/keyretriever/keyretriever_linux.go +++ b/crypto/keyretriever/keyretriever_linux.go @@ -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{}, diff --git a/crypto/keyretriever/keyretriever_linux_test.go b/crypto/keyretriever/keyretriever_linux_test.go index 4748e26..8505b4a 100644 --- a/crypto/keyretriever/keyretriever_linux_test.go +++ b/crypto/keyretriever/keyretriever_linux_test.go @@ -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") diff --git a/crypto/keyretriever/keyretriever_windows.go b/crypto/keyretriever/keyretriever_windows.go index 619aa2f..be7581c 100644 --- a/crypto/keyretriever/keyretriever_windows.go +++ b/crypto/keyretriever/keyretriever_windows.go @@ -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{} } diff --git a/rfcs/001-project-architecture.md b/rfcs/001-project-architecture.md index 8d27fa7..8107e1b 100644 --- a/rfcs/001-project-architecture.md +++ b/rfcs/001-project-architecture.md @@ -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 diff --git a/rfcs/006-key-retrieval-mechanisms.md b/rfcs/006-key-retrieval-mechanisms.md index 5df6807..259660f 100644 --- a/rfcs/006-key-retrieval-mechanisms.md +++ b/rfcs/006-key-retrieval-mechanisms.md @@ -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 `" 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)