feat(keys): add cross-host master key export (#599)

This commit is contained in:
Roger
2026-05-16 20:24:19 +08:00
committed by GitHub
parent 0234f75495
commit 0fe35542f2
10 changed files with 507 additions and 35 deletions
+2
View File
@@ -20,6 +20,7 @@ type Browser interface {
BrowserName() string
ProfileName() string
ProfileDir() string
UserDataDir() string
Extract(categories []types.Category) (*types.BrowserData, error)
CountEntries(categories []types.Category) (map[types.Category]int, error)
}
@@ -114,6 +115,7 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
// KeyManager is implemented by engines that accept externally-provided master-key retrievers (Chromium family only).
type KeyManager interface {
SetKeyRetrievers(keyretriever.Retrievers)
ExportKeys() (keyretriever.MasterKeys, error)
}
// KeychainPasswordReceiver is implemented by engines that need the macOS login password (Safari only).
+51 -31
View File
@@ -59,6 +59,7 @@ func (b *Browser) SetKeyRetrievers(r keyretriever.Retrievers) {
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) ProfileDir() string { return b.profileDir }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
func (b *Browser) ProfileName() string {
if b.profileDir == "" {
return ""
@@ -66,6 +67,54 @@ func (b *Browser) ProfileName() string {
return filepath.Base(b.profileDir)
}
// ExportKeys derives this profile's master keys without performing extraction.
// Returns whatever tiers succeeded plus a joined error describing any failed
// tiers; callers preserve partial results because a Chrome 127+ profile mixes
// v10 + v20 ciphertexts and a v20-only failure must not erase a usable v10 key.
// Used by cross-host workflows where keys are produced on one host and consumed
// on another.
func (b *Browser) ExportKeys() (keyretriever.MasterKeys, error) {
session, err := filemanager.NewSession()
if err != nil {
return keyretriever.MasterKeys{}, err
}
defer session.Cleanup()
return keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session))
}
// buildHints discovers Local State (acquiring it into session.TempDir so Windows DPAPI/ABE retrievers can
// read it from a path the process owns) and assembles per-tier retriever hints. Shared by Extract and
// ExportKeys so the two stay in lockstep. Multi-profile layout: Local State lives in the parent of
// profileDir. Flat layout (Opera): Local State sits alongside data files inside profileDir.
func (b *Browser) buildHints(session *filemanager.Session) keyretriever.Hints {
label := b.BrowserName() + "/" + b.ProfileName()
var localStateDst string
for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} {
candidate := filepath.Join(dir, "Local State")
if !fileutil.FileExists(candidate) {
continue
}
dst := filepath.Join(session.TempDir(), "Local State")
if err := session.Acquire(candidate, dst, false); err != nil {
log.Debugf("acquire Local State for %s: %v", label, err)
break
}
localStateDst = dst
break
}
abeKey := ""
if b.cfg.WindowsABE {
abeKey = b.cfg.Key
}
return keyretriever.Hints{
KeychainLabel: b.cfg.KeychainLabel,
WindowsABEKey: abeKey,
LocalStatePath: localStateDst,
}
}
// Extract copies browser files to a temp directory, retrieves the master key,
// and extracts data for the requested categories.
func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, error) {
@@ -175,42 +224,13 @@ var warnedMasterKeyFailure sync.Map
// getMasterKeys retrieves master keys for all configured cipher tiers.
func (b *Browser) getMasterKeys(session *filemanager.Session) keyretriever.MasterKeys {
label := b.BrowserName() + "/" + b.ProfileName()
// Locate and copy Local State (needed on Windows, ignored on macOS/Linux). Multi-profile
// layout: Local State is in the parent of profileDir. Flat layout (Opera): Local State is
// alongside data files in profileDir.
var localStateDst string
for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} {
candidate := filepath.Join(dir, "Local State")
if !fileutil.FileExists(candidate) {
continue
}
dst := filepath.Join(session.TempDir(), "Local State")
if err := session.Acquire(candidate, dst, false); err != nil {
log.Debugf("acquire Local State for %s: %v", label, err)
break
}
localStateDst = dst
break
}
abeKey := ""
if b.cfg.WindowsABE {
abeKey = b.cfg.Key
}
hints := keyretriever.Hints{
KeychainLabel: b.cfg.KeychainLabel,
WindowsABEKey: abeKey,
LocalStatePath: localStateDst,
}
keys, err := keyretriever.NewMasterKeys(b.retrievers, hints)
keys, err := keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session))
if err != nil {
installKey := b.BrowserName() + "|" + b.cfg.UserDataDir
if _, already := warnedMasterKeyFailure.LoadOrStore(installKey, struct{}{}); !already {
log.Warnf("%s: master key retrieval: %v", b.BrowserName(), err)
} else {
log.Debugf("%s: master key retrieval: %v", label, err)
log.Debugf("%s/%s: master key retrieval: %v", b.BrowserName(), b.ProfileName(), err)
}
}
return keys
+1
View File
@@ -49,6 +49,7 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) ProfileDir() string { return b.profileDir }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
func (b *Browser) ProfileName() string {
if b.profileDir == "" {
return ""
+71
View File
@@ -0,0 +1,71 @@
package browser
import (
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/log"
)
// BuildDump exports per-installation master keys; profiles sharing (Browser, UserDataDir) collapse into one Vault.
// Browsers without KeyManager (Firefox/Safari) are skipped. ExportKeys is invoked exactly once per installation
// regardless of profile count or success. Partial results (e.g. V10 retrieved, V20 failed) keep the usable tiers
// rather than discarding the vault, matching getMasterKeys' behavior on the extraction path — a Chrome 127+
// profile mixes v10 + v20 ciphertexts and a v20-only failure must not erase a usable v10 key.
func BuildDump(browsers []Browser) keyretriever.Dump {
dump := keyretriever.NewDump()
groups, order := groupByInstallation(browsers)
for _, key := range order {
g := groups[key]
keys, err := g.km.ExportKeys()
if err != nil {
status := "partial"
if !keys.HasAny() {
status = "failed"
}
log.Warnf("dump-keys: %s/%s %s: %v", g.browser, g.profiles[0], status, err)
}
if !keys.HasAny() {
continue
}
dump.Vaults = append(dump.Vaults, keyretriever.Vault{
Browser: g.browser,
UserDataDir: g.userDataDir,
Profiles: g.profiles,
Keys: keys,
})
}
return dump
}
type installGroup struct {
browser, userDataDir string
km KeyManager
profiles []string
}
// groupByInstallation collects browsers into per-installation groups keyed by (BrowserName, UserDataDir),
// preserving the discovery order of the first profile in each group. Non-KeyManager browsers are skipped.
// Doing the grouping up front (rather than checking dump.Vaults profile-by-profile) makes the resulting
// Profiles list complete and order-independent even if the group's ExportKeys later fails.
func groupByInstallation(browsers []Browser) (map[string]*installGroup, []string) {
groups := make(map[string]*installGroup)
var order []string
for _, b := range browsers {
km, ok := b.(KeyManager)
if !ok {
continue
}
key := b.BrowserName() + "|" + b.UserDataDir()
if g, exists := groups[key]; exists {
g.profiles = append(g.profiles, b.ProfileName())
continue
}
groups[key] = &installGroup{
browser: b.BrowserName(),
userDataDir: b.UserDataDir(),
km: km,
profiles: []string{b.ProfileName()},
}
order = append(order, key)
}
return groups, order
}
+228
View File
@@ -0,0 +1,228 @@
package browser
import (
"bytes"
"errors"
"runtime"
"testing"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/types"
)
const (
testProfileDefault = "Default"
testProfile1 = "Profile 1"
testUDD = "/p"
testEdgeName = "Edge"
)
type mockBrowser struct {
name, profile, profileDir, userDataDir string
}
func (m *mockBrowser) BrowserName() string { return m.name }
func (m *mockBrowser) ProfileName() string { return m.profile }
func (m *mockBrowser) ProfileDir() string { return m.profileDir }
func (m *mockBrowser) UserDataDir() string { return m.userDataDir }
func (m *mockBrowser) Extract(_ []types.Category) (*types.BrowserData, error) {
return &types.BrowserData{}, nil
}
func (m *mockBrowser) CountEntries(_ []types.Category) (map[types.Category]int, error) {
return nil, nil
}
type mockChromiumBrowser struct {
mockBrowser
keys keyretriever.MasterKeys
exportErr error
calls int
}
func (m *mockChromiumBrowser) SetKeyRetrievers(_ keyretriever.Retrievers) {}
func (m *mockChromiumBrowser) ExportKeys() (keyretriever.MasterKeys, error) {
m.calls++
return m.keys, m.exportErr
}
func TestBuildDump_Empty(t *testing.T) {
dump := BuildDump(nil)
if dump.Version != keyretriever.DumpVersion {
t.Errorf("Version = %q, want %q", dump.Version, keyretriever.DumpVersion)
}
if dump.Host.OS != runtime.GOOS {
t.Errorf("Host.OS = %q, want %q", dump.Host.OS, runtime.GOOS)
}
if len(dump.Vaults) != 0 {
t.Errorf("Vaults len = %d, want 0", len(dump.Vaults))
}
}
func TestBuildDump_SingleChromium(t *testing.T) {
b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, profileDir: "/p/Default", userDataDir: testUDD},
keys: keyretriever.MasterKeys{V10: []byte("v10-key")},
}
dump := BuildDump([]Browser{b})
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1", len(dump.Vaults))
}
inst := dump.Vaults[0]
if inst.Browser != chromeName || inst.UserDataDir != testUDD {
t.Errorf("inst metadata = %+v", inst)
}
if len(inst.Profiles) != 1 || inst.Profiles[0] != testProfileDefault {
t.Errorf("Profiles = %v", inst.Profiles)
}
if string(inst.Keys.V10) != "v10-key" {
t.Errorf("Keys.V10 = %q", inst.Keys.V10)
}
}
func TestBuildDump_MultipleProfilesSameInstallation(t *testing.T) {
p1 := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD},
keys: keyretriever.MasterKeys{V10: []byte("v10")},
}
p2 := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfile1, userDataDir: testUDD},
exportErr: errors.New("ExportKeys should not be called for second profile"),
}
dump := BuildDump([]Browser{p1, p2})
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (same installation grouping)", len(dump.Vaults))
}
if len(dump.Vaults[0].Profiles) != 2 {
t.Errorf("Profiles = %v, want both profiles", dump.Vaults[0].Profiles)
}
}
func TestBuildDump_SkipsNonKeyManager(t *testing.T) {
chrome := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: "/chrome"},
keys: keyretriever.MasterKeys{V10: []byte("v10")},
}
firefox := &mockBrowser{name: firefoxName, profile: "default-release", userDataDir: "/ff"}
dump := BuildDump([]Browser{chrome, firefox})
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (firefox skipped)", len(dump.Vaults))
}
if dump.Vaults[0].Browser != chromeName {
t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, chromeName)
}
}
func TestBuildDump_SkipsExportError(t *testing.T) {
good := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: "/chrome"},
keys: keyretriever.MasterKeys{V10: []byte("v10")},
}
failing := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: testEdgeName, profile: testProfileDefault, userDataDir: "/edge"},
exportErr: errors.New("retriever failed"),
}
dump := BuildDump([]Browser{good, failing})
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (failing browser skipped)", len(dump.Vaults))
}
if dump.Vaults[0].Browser != chromeName {
t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, chromeName)
}
}
func TestBuildDump_JSONRoundTrip(t *testing.T) {
b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD},
keys: keyretriever.MasterKeys{V10: []byte{0x01, 0x02, 0x03}, V20: []byte{0xff, 0xee}},
}
dump := BuildDump([]Browser{b})
var buf bytes.Buffer
if err := dump.WriteJSON(&buf); err != nil {
t.Fatalf("WriteJSON: %v", err)
}
parsed, err := keyretriever.ReadJSON(&buf)
if err != nil {
t.Fatalf("ReadJSON: %v", err)
}
if parsed.Version != dump.Version {
t.Errorf("Version round-trip: got %q, want %q", parsed.Version, dump.Version)
}
if len(parsed.Vaults) != 1 {
t.Fatalf("Vaults len = %d", len(parsed.Vaults))
}
if !bytes.Equal(parsed.Vaults[0].Keys.V10, dump.Vaults[0].Keys.V10) {
t.Errorf("V10 round-trip mismatch")
}
if !bytes.Equal(parsed.Vaults[0].Keys.V20, dump.Vaults[0].Keys.V20) {
t.Errorf("V20 round-trip mismatch")
}
if parsed.Vaults[0].Keys.V11 != nil {
t.Errorf("V11 should be omitted (nil), got %v", parsed.Vaults[0].Keys.V11)
}
}
func TestBuildDump_PartialKeys(t *testing.T) {
b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD},
keys: keyretriever.MasterKeys{V10: []byte("v10")},
exportErr: errors.New("v20: ABE failed"),
}
dump := BuildDump([]Browser{b})
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (partial result must be preserved)", len(dump.Vaults))
}
if string(dump.Vaults[0].Keys.V10) != "v10" {
t.Errorf("V10 should be preserved despite V20 error, got %q", dump.Vaults[0].Keys.V10)
}
if dump.Vaults[0].Keys.V20 != nil {
t.Errorf("V20 should remain nil, got %v", dump.Vaults[0].Keys.V20)
}
}
func TestBuildDump_GroupingOrderIndependent(t *testing.T) {
for _, name := range []string{"p1 first", "p2 first"} {
t.Run(name, func(t *testing.T) {
p1 := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD},
keys: keyretriever.MasterKeys{V10: []byte("v10")},
}
p2 := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfile1, userDataDir: testUDD},
keys: keyretriever.MasterKeys{V10: []byte("v10")},
}
list := []Browser{p1, p2}
if name == "p2 first" {
list = []Browser{p2, p1}
}
dump := BuildDump(list)
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1", len(dump.Vaults))
}
if len(dump.Vaults[0].Profiles) != 2 {
t.Errorf("Profiles = %v, want 2", dump.Vaults[0].Profiles)
}
if calls := p1.calls + p2.calls; calls != 1 {
t.Errorf("ExportKeys total calls = %d, want 1 (one call per installation)", calls)
}
})
}
}
+1
View File
@@ -45,6 +45,7 @@ func resolveProfilePaths(p profileContext) map[types.Category]resolvedPath {
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) ProfileName() string { return b.profile.name }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
func (b *Browser) ProfileDir() string {
if b.profile.isDefault() {