feat: add Safari password extraction from macOS Keychain (#568)

This commit is contained in:
Roger
2026-04-13 21:34:40 +08:00
committed by GitHub
parent d105a1f488
commit 370c5882c4
18 changed files with 493 additions and 132 deletions
+103
View File
@@ -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
}
+91
View File
@@ -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)
})
}
}
+24 -4
View File
@@ -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: