mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-08-17 23:57:12 +02:00
feat: add Safari password extraction from macOS Keychain (#568)
This commit is contained in:
@@ -86,6 +86,8 @@ linters:
|
|||||||
- "all"
|
- "all"
|
||||||
- "csv"
|
- "csv"
|
||||||
- "json"
|
- "json"
|
||||||
|
- "https"
|
||||||
|
- "http"
|
||||||
gocritic:
|
gocritic:
|
||||||
enabled-tags:
|
enabled-tags:
|
||||||
- diagnostic
|
- diagnostic
|
||||||
|
|||||||
+44
-23
@@ -14,7 +14,8 @@ import (
|
|||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"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 {
|
type Browser interface {
|
||||||
BrowserName() string
|
BrowserName() string
|
||||||
ProfileName() string
|
ProfileName() string
|
||||||
@@ -27,30 +28,57 @@ type Browser interface {
|
|||||||
type PickOptions struct {
|
type PickOptions struct {
|
||||||
Name string // browser name filter: "all"|"chrome"|"firefox"|...
|
Name string // browser name filter: "all"|"chrome"|"firefox"|...
|
||||||
ProfilePath string // custom profile directory override
|
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.
|
// PickBrowsers returns browsers that are fully wired up for Extract: the
|
||||||
// When Name is "all", all known browsers are tried.
|
// key retriever chain and (on macOS) the Keychain password are already
|
||||||
// ProfilePath overrides the default user data directory (only when targeting a specific browser).
|
// 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) {
|
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)
|
return pickFromConfigs(platformBrowsers(), opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickFromConfigs is the testable core of PickBrowsers. It iterates over
|
// pickFromConfigs is the testable core of PickBrowsers: it filters the
|
||||||
// platform browser configs, discovers installed profiles, and injects a
|
// platform browser list and discovers installed profiles for each match.
|
||||||
// shared key retriever into Chromium browsers for decryption.
|
// Dependency injection (key retrievers, keychain credentials) is intentionally
|
||||||
|
// NOT done here — see PrepareExtract.
|
||||||
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
|
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
|
||||||
name := strings.ToLower(opts.Name)
|
name := strings.ToLower(opts.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "all"
|
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)
|
configs = resolveGlobs(configs)
|
||||||
|
|
||||||
var browsers []Browser
|
var browsers []Browser
|
||||||
@@ -78,21 +106,14 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
|
|||||||
continue
|
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...)
|
browsers = append(browsers, found...)
|
||||||
}
|
}
|
||||||
return browsers, nil
|
return browsers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrieverSetter is implemented by browsers that need an external key retriever.
|
// retrieverSetter is an optional capability interface. Chromium variants
|
||||||
// This allows pickFromConfigs to inject the shared retriever after construction
|
// implement it to receive a master-key retriever chain; Firefox and Safari
|
||||||
// without coupling the Browser interface to Chromium-specific concerns.
|
// do not.
|
||||||
type retrieverSetter interface {
|
type retrieverSetter interface {
|
||||||
SetRetriever(keyretriever.KeyRetriever)
|
SetRetriever(keyretriever.KeyRetriever)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
package browser
|
package browser
|
||||||
|
|
||||||
import (
|
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"
|
"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
|
package browser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"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
|
package browser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"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)
|
// Safari has a single flat data directory (no profile subdirectories)
|
||||||
// and stores most data unencrypted (passwords live in macOS Keychain).
|
// and stores most data unencrypted (passwords live in macOS Keychain).
|
||||||
type Browser struct {
|
type Browser struct {
|
||||||
cfg types.BrowserConfig
|
cfg types.BrowserConfig
|
||||||
dataDir string // absolute path to ~/Library/Safari
|
dataDir string // absolute path to ~/Library/Safari
|
||||||
sources map[types.Category][]sourcePath // Category → candidate paths
|
keychainPassword string // macOS login password for Keychain unlock
|
||||||
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
|
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
|
// 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{}
|
data := &types.BrowserData{}
|
||||||
for _, cat := range categories {
|
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]
|
path, ok := tempPaths[cat]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -75,6 +87,10 @@ func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]
|
|||||||
|
|
||||||
counts := make(map[types.Category]int)
|
counts := make(map[types.Category]int)
|
||||||
for _, cat := range categories {
|
for _, cat := range categories {
|
||||||
|
if cat == types.Password {
|
||||||
|
counts[cat] = b.countCategory(cat, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
path, ok := tempPaths[cat]
|
path, ok := tempPaths[cat]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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) {
|
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, path string) {
|
||||||
var err error
|
var err error
|
||||||
switch cat {
|
switch cat {
|
||||||
|
case types.Password:
|
||||||
|
data.Passwords, err = extractPasswords(b.keychainPassword)
|
||||||
case types.History:
|
case types.History:
|
||||||
data.Histories, err = extractHistories(path)
|
data.Histories, err = extractHistories(path)
|
||||||
case types.Cookie:
|
case types.Cookie:
|
||||||
@@ -127,6 +145,8 @@ func (b *Browser) countCategory(cat types.Category, path string) int {
|
|||||||
var count int
|
var count int
|
||||||
var err error
|
var err error
|
||||||
switch cat {
|
switch cat {
|
||||||
|
case types.Password:
|
||||||
|
count, err = countPasswords(b.keychainPassword)
|
||||||
case types.History:
|
case types.History:
|
||||||
count, err = countHistories(path)
|
count, err = countHistories(path)
|
||||||
case types.Cookie:
|
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(&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(&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(&outputDir, "dir", "d", "results", "output directory")
|
||||||
cmd.Flags().StringVarP(&profilePath, "profile-path", "p", "", "custom profile dir path, get with chrome://version")
|
cmd.Flags().StringVarP(&profilePath, "profile-path", "p", "", "custom profile dir path, get with chrome://version")
|
||||||
cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password")
|
cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func listCmd() *cobra.Command {
|
|||||||
Example: ` hack-browser-data list
|
Example: ` hack-browser-data list
|
||||||
hack-browser-data list --detail`,
|
hack-browser-data list --detail`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
package keyretriever
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -8,16 +8,12 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/moond4rk/keychainbreaker"
|
"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
|
// 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)
|
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.
|
// SecurityCmdRetriever uses macOS `security` CLI to query Keychain.
|
||||||
// This may trigger a password dialog on macOS. Results are cached
|
// This may trigger a password dialog on macOS. Results are cached
|
||||||
// per storage name so each browser's key is fetched only once.
|
// 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
|
return darwinParams.deriveKey(secret), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRetriever returns the macOS retriever chain.
|
// DefaultRetriever returns the macOS retriever chain, tried in order:
|
||||||
// The chain tries each method in order until one succeeds:
|
//
|
||||||
// 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only, non-interactive)
|
// 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only)
|
||||||
// 2. KeychainPasswordRetriever — direct unlock with --keychain-pw flag
|
// 2. KeychainPasswordRetriever — direct unlock, skipped when password is empty
|
||||||
// 3. TerminalPasswordRetriever — interactive password prompt via terminal
|
// 3. SecurityCmdRetriever — `security` CLI fallback (may trigger a dialog)
|
||||||
// 4. SecurityCmdRetriever — security CLI fallback (may trigger system dialog)
|
|
||||||
func DefaultRetriever(keychainPassword string) KeyRetriever {
|
func DefaultRetriever(keychainPassword string) KeyRetriever {
|
||||||
retrievers := []KeyRetriever{
|
retrievers := []KeyRetriever{&GcoredumpRetriever{}}
|
||||||
&GcoredumpRetriever{},
|
|
||||||
}
|
|
||||||
if keychainPassword != "" {
|
if keychainPassword != "" {
|
||||||
retrievers = append(retrievers, &KeychainPasswordRetriever{Password: keychainPassword})
|
retrievers = append(retrievers, &KeychainPasswordRetriever{Password: keychainPassword})
|
||||||
}
|
}
|
||||||
retrievers = append(retrievers,
|
retrievers = append(retrievers, &SecurityCmdRetriever{cache: make(map[string]securityResult)})
|
||||||
&TerminalPasswordRetriever{},
|
|
||||||
&SecurityCmdRetriever{cache: make(map[string]securityResult)},
|
|
||||||
)
|
|
||||||
return NewChain(retrievers...)
|
return NewChain(retrievers...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,13 +39,3 @@ func TestKeychainPasswordRetriever_EmptyPassword(t *testing.T) {
|
|||||||
assert.Nil(t, key)
|
assert.Nil(t, key)
|
||||||
assert.Contains(t, err.Error(), "keychain password not provided")
|
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:
|
// DefaultRetriever returns the Linux retriever chain:
|
||||||
// D-Bus Secret Service first, then "peanuts" fallback.
|
// D-Bus Secret Service first, then "peanuts" fallback.
|
||||||
// The keychainPassword parameter is unused on Linux.
|
func DefaultRetriever() KeyRetriever {
|
||||||
func DefaultRetriever(_ string) KeyRetriever {
|
|
||||||
return NewChain(
|
return NewChain(
|
||||||
&DBusRetriever{},
|
&DBusRetriever{},
|
||||||
&FallbackRetriever{},
|
&FallbackRetriever{},
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestFallbackRetriever(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultRetriever_Linux(t *testing.T) {
|
func TestDefaultRetriever_Linux(t *testing.T) {
|
||||||
r := DefaultRetriever("")
|
r := DefaultRetriever()
|
||||||
chain, ok := r.(*ChainRetriever)
|
chain, ok := r.(*ChainRetriever)
|
||||||
require.True(t, ok, "DefaultRetriever should return a *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).
|
// DefaultRetriever returns the Windows retriever (DPAPI only).
|
||||||
// The keychainPassword parameter is unused on Windows.
|
func DefaultRetriever() KeyRetriever {
|
||||||
func DefaultRetriever(_ string) KeyRetriever {
|
|
||||||
return &DPAPIRetriever{}
|
return &DPAPIRetriever{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,43 +65,61 @@ Each category has a corresponding Entry struct with `json` and `csv` struct tags
|
|||||||
|
|
||||||
### 4.1 BrowserKind
|
### 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** (`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.
|
||||||
| `Chromium` | Standard Chromium layout |
|
- **Safari** — macOS only, with direct Keychain-based credential extraction. See RFC-006 §7.
|
||||||
| `ChromiumYandex` | Yandex variant: different file names and SQL queries |
|
|
||||||
| `ChromiumOpera` | Opera variant: different extension key, Roaming path on Windows |
|
See `types/category.go` for the authoritative enum definition.
|
||||||
| `Firefox` | Firefox: NSS encryption, SQLite + JSON files |
|
|
||||||
|
|
||||||
### 4.2 BrowserConfig
|
### 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).
|
`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)
|
PickBrowsers(opts) // used by `dump` — ready to Extract
|
||||||
→ platformBrowsers() // build-tagged: returns []BrowserConfig for this OS
|
→ pickFromConfigs(configs, opts) // shared discovery core
|
||||||
→ pickFromConfigs(configs, opts) // filter by name, apply profile-path/keychain overrides
|
→ platformBrowsers() // build-tagged list for this OS
|
||||||
→ newBrowsers(cfg) // dispatch by Kind to chromium.NewBrowsers or firefox.NewBrowsers
|
→ filter by name / profile path
|
||||||
→ discoverProfiles() // scan for profile subdirectories
|
→ newBrowsers(cfg) // dispatch to chromium/firefox/safari.NewBrowsers
|
||||||
→ resolveSourcePaths() // stat each candidate path, first match wins
|
→ 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:
|
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.
|
- **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.
|
- **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
|
### 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)
|
- **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).
|
||||||
- **Windows** — 16 browsers (all macOS minus Arc, plus 360 Speed, 360 Speed X, QQ, DC, Sogou)
|
- **Firefox** — all three platforms, via internal NSS key derivation (RFC-005).
|
||||||
- **Linux** — 8 browsers (Chrome, Edge, Chromium, Chrome Beta, Opera, Vivaldi, Brave, Firefox)
|
- **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
|
## 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.
|
`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
|
## 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
|
### 3.4 Storage Labels
|
||||||
|
|
||||||
| Browser | Keychain Account |
|
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"`.
|
||||||
|---------|-----------------|
|
|
||||||
| Chrome / Chrome Beta | `"Chrome"` |
|
The authoritative mapping lives in the `Storage` field of each entry in `platformBrowsers()` (`browser/browser_darwin.go`).
|
||||||
| Edge | `"Microsoft Edge"` |
|
|
||||||
| Chromium | `"Chromium"` |
|
|
||||||
| Opera / OperaGX | `"Opera"` |
|
|
||||||
| Vivaldi | `"Vivaldi"` |
|
|
||||||
| Brave | `"Brave"` |
|
|
||||||
| Yandex | `"Yandex"` |
|
|
||||||
| Arc | `"Arc"` |
|
|
||||||
| CocCoc | `"CocCoc"` |
|
|
||||||
|
|
||||||
## 4. Windows Key Retrieval
|
## 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
|
### 5.4 Storage Labels
|
||||||
|
|
||||||
| Browser | D-Bus Label |
|
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.
|
||||||
|---------|-------------|
|
|
||||||
| Chrome / Chrome Beta / Vivaldi | `"Chrome Safe Storage"` |
|
The authoritative mapping lives in the `Storage` field of each entry in `platformBrowsers()` (`browser/browser_linux.go`).
|
||||||
| Chromium / Edge / Opera | `"Chromium Safe Storage"` |
|
|
||||||
| Brave | `"Brave Safe Storage"` |
|
|
||||||
|
|
||||||
## 6. Platform Summary
|
## 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.
|
\* 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
|
## References
|
||||||
|
|
||||||
- **macOS**: [os_crypt_mac.mm](https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_mac.mm;l=157)
|
- **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