Skip to content

Commit 370c588

Browse files
authored
feat: add Safari password extraction from macOS Keychain (#568)
1 parent d105a1f commit 370c588

18 files changed

Lines changed: 493 additions & 132 deletions

.golangci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ linters:
8686
- "all"
8787
- "csv"
8888
- "json"
89+
- "https"
90+
- "http"
8991
gocritic:
9092
enabled-tags:
9193
- diagnostic

browser/browser.go

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import (
1414
"github.com/moond4rk/hackbrowserdata/types"
1515
)
1616

17-
// Browser is the interface that both chromium.Browser and firefox.Browser implement.
17+
// Browser is the interface implemented by every engine package —
18+
// chromium.Browser, firefox.Browser, and safari.Browser.
1819
type Browser interface {
1920
BrowserName() string
2021
ProfileName() string
@@ -27,30 +28,57 @@ type Browser interface {
2728
type PickOptions struct {
2829
Name string // browser name filter: "all"|"chrome"|"firefox"|...
2930
ProfilePath string // custom profile directory override
30-
KeychainPassword string // macOS keychain password (ignored on other platforms)
31+
KeychainPassword string // macOS only — see browser_darwin.go
3132
}
3233

33-
// PickBrowsers returns browsers matching the given options.
34-
// When Name is "all", all known browsers are tried.
35-
// ProfilePath overrides the default user data directory (only when targeting a specific browser).
34+
// PickBrowsers returns browsers that are fully wired up for Extract: the
35+
// key retriever chain and (on macOS) the Keychain password are already
36+
// injected, so the caller can call b.Extract directly. This is the entry
37+
// point for extraction workflows like `dump`.
38+
//
39+
// On macOS this may trigger an interactive prompt for the login password
40+
// when the target set includes a Chromium variant or Safari. Commands that
41+
// only need metadata (name, profile path, per-category counts) should use
42+
// DiscoverBrowsers instead to skip injection — and thereby the prompt.
43+
//
44+
// When Name is "all", all known browsers are tried. ProfilePath overrides
45+
// the default user data directory (only when targeting a specific browser).
3646
func PickBrowsers(opts PickOptions) ([]Browser, error) {
47+
browsers, err := pickFromConfigs(platformBrowsers(), opts)
48+
if err != nil {
49+
return nil, err
50+
}
51+
inject := newPlatformInjector(opts)
52+
for _, b := range browsers {
53+
inject(b)
54+
}
55+
return browsers, nil
56+
}
57+
58+
// DiscoverBrowsers returns browsers for metadata-only workflows — listing,
59+
// profile paths, per-category counts. Decryption dependencies are NOT
60+
// injected, so calling b.Extract on the returned browsers will not
61+
// successfully decrypt protected data (passwords, cookies, credit cards).
62+
// CountEntries, BrowserName, ProfileName, and ProfileDir all work
63+
// correctly without injection.
64+
//
65+
// Unlike PickBrowsers, DiscoverBrowsers never prompts for the macOS
66+
// Keychain password, making it the correct choice for `list`-style
67+
// commands that have no use for the credential.
68+
func DiscoverBrowsers(opts PickOptions) ([]Browser, error) {
3769
return pickFromConfigs(platformBrowsers(), opts)
3870
}
3971

40-
// pickFromConfigs is the testable core of PickBrowsers. It iterates over
41-
// platform browser configs, discovers installed profiles, and injects a
42-
// shared key retriever into Chromium browsers for decryption.
72+
// pickFromConfigs is the testable core of PickBrowsers: it filters the
73+
// platform browser list and discovers installed profiles for each match.
74+
// Dependency injection (key retrievers, keychain credentials) is intentionally
75+
// NOT done here — see PrepareExtract.
4376
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
4477
name := strings.ToLower(opts.Name)
4578
if name == "" {
4679
name = "all"
4780
}
4881

49-
// Create a single key retriever shared across all Chromium browsers.
50-
// On macOS this avoids repeated password prompts; on other platforms
51-
// it's harmless (DPAPI reads Local State per-profile, D-Bus is stateless).
52-
retriever := keyretriever.DefaultRetriever(opts.KeychainPassword)
53-
5482
configs = resolveGlobs(configs)
5583

5684
var browsers []Browser
@@ -78,21 +106,14 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
78106
continue
79107
}
80108

81-
// Inject the shared key retriever into browsers that need it.
82-
// Chromium browsers implement retrieverSetter; Firefox does not.
83-
for _, b := range found {
84-
if setter, ok := b.(retrieverSetter); ok {
85-
setter.SetRetriever(retriever)
86-
}
87-
}
88109
browsers = append(browsers, found...)
89110
}
90111
return browsers, nil
91112
}
92113

93-
// retrieverSetter is implemented by browsers that need an external key retriever.
94-
// This allows pickFromConfigs to inject the shared retriever after construction
95-
// without coupling the Browser interface to Chromium-specific concerns.
114+
// retrieverSetter is an optional capability interface. Chromium variants
115+
// implement it to receive a master-key retriever chain; Firefox and Safari
116+
// do not.
96117
type retrieverSetter interface {
97118
SetRetriever(keyretriever.KeyRetriever)
98119
}

browser/browser_darwin.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
package browser
44

55
import (
6+
"fmt"
7+
"os"
8+
9+
"github.com/moond4rk/keychainbreaker"
10+
"golang.org/x/term"
11+
12+
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
13+
"github.com/moond4rk/hackbrowserdata/log"
614
"github.com/moond4rk/hackbrowserdata/types"
715
)
816

@@ -99,3 +107,90 @@ func platformBrowsers() []types.BrowserConfig {
99107
},
100108
}
101109
}
110+
111+
// resolveKeychainPassword returns the keychain password for macOS.
112+
// If not provided via CLI flag, it prompts interactively when stdin is a TTY.
113+
// After obtaining the password, it verifies against keychainbreaker; on any
114+
// failure it returns "" so downstream code enters "no password" mode rather
115+
// than propagating a known-bad credential. Safari then exports
116+
// keychain-protected entries as metadata-only via keychainbreaker's partial
117+
// extraction mode; Chromium falls back to SecurityCmdRetriever.
118+
func resolveKeychainPassword(flagPassword string) string {
119+
password := flagPassword
120+
if password == "" {
121+
if !term.IsTerminal(int(os.Stdin.Fd())) {
122+
log.Warnf("macOS login password not provided and stdin is not a TTY; keychain-protected data will be exported as metadata only")
123+
return ""
124+
}
125+
fmt.Fprint(os.Stderr, "Enter macOS login password: ")
126+
pwd, err := term.ReadPassword(int(os.Stdin.Fd()))
127+
fmt.Fprintln(os.Stderr)
128+
if err != nil {
129+
log.Warnf("failed to read macOS login password: %v; keychain-protected data will be exported as metadata only", err)
130+
return ""
131+
}
132+
password = string(pwd)
133+
}
134+
135+
if password == "" {
136+
log.Warnf("no macOS login password entered; keychain-protected data will be exported as metadata only")
137+
return ""
138+
}
139+
140+
// Verify early: try to unlock keychain with keychainbreaker. On failure
141+
// return "" so KeychainPasswordRetriever and Safari both skip the credential
142+
// and rely on their respective fallback paths (SecurityCmdRetriever for
143+
// Chromium, metadata-only export for Safari).
144+
kc, err := keychainbreaker.Open()
145+
if err != nil {
146+
log.Warnf("keychain open failed: %v; keychain-protected data will be exported as metadata only", err)
147+
return ""
148+
}
149+
if err := kc.TryUnlock(keychainbreaker.WithPassword(password)); err != nil {
150+
log.Warnf("keychain unlock failed with provided password; keychain-protected data will be exported as metadata only")
151+
log.Debugf("keychain unlock detail: %v", err)
152+
return ""
153+
}
154+
155+
return password
156+
}
157+
158+
// keychainPasswordSetter is an optional capability interface satisfied by
159+
// Safari, which reads InternetPassword records directly from the login keychain.
160+
type keychainPasswordSetter interface {
161+
SetKeychainPassword(string)
162+
}
163+
164+
// newPlatformInjector returns a closure that injects the Chromium master-key
165+
// retriever and the Safari Keychain password into each Browser.
166+
//
167+
// Resolution is lazy: the keychain password prompt and retriever construction
168+
// are deferred until the first Browser that actually needs them passes through
169+
// the closure. Browsers that satisfy neither setter interface (e.g. Firefox)
170+
// short-circuit without ever touching the keychain, so `-b firefox` on macOS
171+
// no longer triggers a password prompt.
172+
func newPlatformInjector(opts PickOptions) func(Browser) {
173+
var (
174+
password string
175+
retriever keyretriever.KeyRetriever
176+
resolved bool
177+
)
178+
return func(b Browser) {
179+
rs, needsRetriever := b.(retrieverSetter)
180+
kps, needsKeychainPassword := b.(keychainPasswordSetter)
181+
if !needsRetriever && !needsKeychainPassword {
182+
return
183+
}
184+
if !resolved {
185+
password = resolveKeychainPassword(opts.KeychainPassword)
186+
retriever = keyretriever.DefaultRetriever(password)
187+
resolved = true
188+
}
189+
if needsRetriever {
190+
rs.SetRetriever(retriever)
191+
}
192+
if needsKeychainPassword {
193+
kps.SetKeychainPassword(password)
194+
}
195+
}
196+
}

browser/browser_linux.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package browser
44

55
import (
6+
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
67
"github.com/moond4rk/hackbrowserdata/types"
78
)
89

@@ -65,3 +66,14 @@ func platformBrowsers() []types.BrowserConfig {
6566
},
6667
}
6768
}
69+
70+
// newPlatformInjector returns a closure that injects the Chromium master-key
71+
// retriever chain into each Browser.
72+
func newPlatformInjector(_ PickOptions) func(Browser) {
73+
retriever := keyretriever.DefaultRetriever()
74+
return func(b Browser) {
75+
if s, ok := b.(retrieverSetter); ok {
76+
s.SetRetriever(retriever)
77+
}
78+
}
79+
}

browser/browser_windows.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package browser
44

55
import (
6+
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
67
"github.com/moond4rk/hackbrowserdata/types"
78
)
89

@@ -118,3 +119,14 @@ func platformBrowsers() []types.BrowserConfig {
118119
},
119120
}
120121
}
122+
123+
// newPlatformInjector returns a closure that injects the Chromium master-key
124+
// retriever chain into each Browser.
125+
func newPlatformInjector(_ PickOptions) func(Browser) {
126+
retriever := keyretriever.DefaultRetriever()
127+
return func(b Browser) {
128+
if s, ok := b.(retrieverSetter); ok {
129+
s.SetRetriever(retriever)
130+
}
131+
}
132+
}

browser/safari/extract_password.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package safari
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
8+
"github.com/moond4rk/keychainbreaker"
9+
10+
"github.com/moond4rk/hackbrowserdata/log"
11+
"github.com/moond4rk/hackbrowserdata/types"
12+
)
13+
14+
func extractPasswords(keychainPassword string) ([]types.LoginEntry, error) {
15+
passwords, err := getInternetPasswords(keychainPassword)
16+
if err != nil {
17+
return nil, err
18+
}
19+
20+
var logins []types.LoginEntry
21+
for _, p := range passwords {
22+
url := buildURL(p.Protocol, p.Server, p.Port, p.Path)
23+
if url == "" || p.Account == "" {
24+
continue
25+
}
26+
logins = append(logins, types.LoginEntry{
27+
URL: url,
28+
Username: p.Account,
29+
Password: p.PlainPassword,
30+
CreatedAt: p.Created,
31+
})
32+
}
33+
34+
sort.Slice(logins, func(i, j int) bool {
35+
return logins[i].CreatedAt.After(logins[j].CreatedAt)
36+
})
37+
return logins, nil
38+
}
39+
40+
func countPasswords(keychainPassword string) (int, error) {
41+
passwords, err := extractPasswords(keychainPassword)
42+
if err != nil {
43+
return 0, err
44+
}
45+
return len(passwords), nil
46+
}
47+
48+
// getInternetPasswords reads InternetPassword records directly from the
49+
// macOS login keychain. See rfcs/006-key-retrieval-mechanisms.md §7 for why
50+
// Safari owns this path instead of routing through crypto/keyretriever.
51+
//
52+
// TryUnlock is always invoked — with the user-supplied password when one is
53+
// available, otherwise with no options — to enable keychainbreaker's partial
54+
// extraction mode. With a valid password we get fully decrypted entries; with
55+
// empty or wrong password we still get metadata records (URL, account,
56+
// timestamps) and PlainPassword left blank, which Safari can export as
57+
// metadata-only output instead of failing with ErrLocked.
58+
func getInternetPasswords(keychainPassword string) ([]keychainbreaker.InternetPassword, error) {
59+
kc, err := keychainbreaker.Open()
60+
if err != nil {
61+
return nil, fmt.Errorf("open keychain: %w", err)
62+
}
63+
64+
var unlockOpts []keychainbreaker.UnlockOption
65+
if keychainPassword != "" {
66+
unlockOpts = append(unlockOpts, keychainbreaker.WithPassword(keychainPassword))
67+
}
68+
if err := kc.TryUnlock(unlockOpts...); err != nil {
69+
log.Debugf("keychain unlock detail: %v", err)
70+
}
71+
72+
passwords, err := kc.InternetPasswords()
73+
if err != nil {
74+
return nil, fmt.Errorf("extract internet passwords: %w", err)
75+
}
76+
return passwords, nil
77+
}
78+
79+
// buildURL constructs a URL from InternetPassword fields.
80+
func buildURL(protocol, server string, port uint32, path string) string {
81+
if server == "" {
82+
return ""
83+
}
84+
85+
// Convert macOS Keychain FourCC protocol code to URL scheme.
86+
// Only "htps" needs special mapping; others just need space trimming.
87+
scheme := strings.TrimRight(protocol, " ")
88+
if scheme == "" || scheme == "htps" {
89+
scheme = "https"
90+
}
91+
92+
url := scheme + "://" + server
93+
94+
defaultPorts := map[string]uint32{"https": 443, "http": 80, "ftp": 21}
95+
if port > 0 && port != defaultPorts[scheme] {
96+
url += fmt.Sprintf(":%d", port)
97+
}
98+
99+
if path != "" && path != "/" {
100+
url += path
101+
}
102+
return url
103+
}

0 commit comments

Comments
 (0)