mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
feat: add Safari password extraction from macOS Keychain (#568)
This commit is contained in:
+44
-23
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user