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:
@@ -86,6 +86,8 @@ linters:
|
||||
- "all"
|
||||
- "csv"
|
||||
- "json"
|
||||
- "https"
|
||||
- "http"
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
|
||||
+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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user