mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-07-06 21:37:47 +02:00
feat(keys): add cross-host master key export (#599)
This commit is contained in:
@@ -20,6 +20,7 @@ type Browser interface {
|
|||||||
BrowserName() string
|
BrowserName() string
|
||||||
ProfileName() string
|
ProfileName() string
|
||||||
ProfileDir() string
|
ProfileDir() string
|
||||||
|
UserDataDir() string
|
||||||
Extract(categories []types.Category) (*types.BrowserData, error)
|
Extract(categories []types.Category) (*types.BrowserData, error)
|
||||||
CountEntries(categories []types.Category) (map[types.Category]int, 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).
|
// KeyManager is implemented by engines that accept externally-provided master-key retrievers (Chromium family only).
|
||||||
type KeyManager interface {
|
type KeyManager interface {
|
||||||
SetKeyRetrievers(keyretriever.Retrievers)
|
SetKeyRetrievers(keyretriever.Retrievers)
|
||||||
|
ExportKeys() (keyretriever.MasterKeys, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeychainPasswordReceiver is implemented by engines that need the macOS login password (Safari only).
|
// KeychainPasswordReceiver is implemented by engines that need the macOS login password (Safari only).
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func (b *Browser) SetKeyRetrievers(r keyretriever.Retrievers) {
|
|||||||
|
|
||||||
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
||||||
func (b *Browser) ProfileDir() string { return b.profileDir }
|
func (b *Browser) ProfileDir() string { return b.profileDir }
|
||||||
|
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
|
||||||
func (b *Browser) ProfileName() string {
|
func (b *Browser) ProfileName() string {
|
||||||
if b.profileDir == "" {
|
if b.profileDir == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -66,6 +67,54 @@ func (b *Browser) ProfileName() string {
|
|||||||
return filepath.Base(b.profileDir)
|
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,
|
// Extract copies browser files to a temp directory, retrieves the master key,
|
||||||
// and extracts data for the requested categories.
|
// and extracts data for the requested categories.
|
||||||
func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, error) {
|
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.
|
// getMasterKeys retrieves master keys for all configured cipher tiers.
|
||||||
func (b *Browser) getMasterKeys(session *filemanager.Session) keyretriever.MasterKeys {
|
func (b *Browser) getMasterKeys(session *filemanager.Session) keyretriever.MasterKeys {
|
||||||
label := b.BrowserName() + "/" + b.ProfileName()
|
keys, err := keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session))
|
||||||
|
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
installKey := b.BrowserName() + "|" + b.cfg.UserDataDir
|
installKey := b.BrowserName() + "|" + b.cfg.UserDataDir
|
||||||
if _, already := warnedMasterKeyFailure.LoadOrStore(installKey, struct{}{}); !already {
|
if _, already := warnedMasterKeyFailure.LoadOrStore(installKey, struct{}{}); !already {
|
||||||
log.Warnf("%s: master key retrieval: %v", b.BrowserName(), err)
|
log.Warnf("%s: master key retrieval: %v", b.BrowserName(), err)
|
||||||
} else {
|
} 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
|
return keys
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
|||||||
|
|
||||||
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
||||||
func (b *Browser) ProfileDir() string { return b.profileDir }
|
func (b *Browser) ProfileDir() string { return b.profileDir }
|
||||||
|
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
|
||||||
func (b *Browser) ProfileName() string {
|
func (b *Browser) ProfileName() string {
|
||||||
if b.profileDir == "" {
|
if b.profileDir == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ func resolveProfilePaths(p profileContext) map[types.Category]resolvedPath {
|
|||||||
|
|
||||||
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
||||||
func (b *Browser) ProfileName() string { return b.profile.name }
|
func (b *Browser) ProfileName() string { return b.profile.name }
|
||||||
|
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
|
||||||
|
|
||||||
func (b *Browser) ProfileDir() string {
|
func (b *Browser) ProfileDir() string {
|
||||||
if b.profile.isDefault() {
|
if b.profile.isDefault() {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/moond4rk/hackbrowserdata/browser"
|
||||||
|
"github.com/moond4rk/hackbrowserdata/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func keysCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "keys",
|
||||||
|
Short: "Manage cross-host master keys",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(keysExportCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysExportCmd() *cobra.Command {
|
||||||
|
var (
|
||||||
|
browserName string
|
||||||
|
outputPath string
|
||||||
|
keychainPw string
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "export",
|
||||||
|
Short: "Export Chromium master keys as JSON for cross-host decryption",
|
||||||
|
Example: ` hack-browser-data keys export -o dump.json
|
||||||
|
hack-browser-data keys export -b chrome`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
browsers, err := browser.PickBrowsers(browser.PickOptions{
|
||||||
|
Name: browserName,
|
||||||
|
KeychainPassword: keychainPw,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dump := browser.BuildDump(browsers)
|
||||||
|
log.Infof("Exported keys for %d vault(s)", len(dump.Vaults))
|
||||||
|
|
||||||
|
if outputPath == "" {
|
||||||
|
return dump.WriteJSON(os.Stdout)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", outputPath, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return dump.WriteJSON(f)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&browserName, "browser", "b", "all", "target browser: all|"+browser.Names())
|
||||||
|
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output file (default: stdout)")
|
||||||
|
cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ GitHub: https://github.com/moonD4rk/HackBrowserData`,
|
|||||||
root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable debug logging")
|
root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable debug logging")
|
||||||
|
|
||||||
dump := dumpCmd()
|
dump := dumpCmd()
|
||||||
root.AddCommand(dump, listCmd(), versionCmd())
|
root.AddCommand(dump, listCmd(), keysCmd(), versionCmd())
|
||||||
|
|
||||||
// Default to dump when no subcommand is given.
|
// Default to dump when no subcommand is given.
|
||||||
// Copy dump flags to root so that `hack-browser-data -b chrome`
|
// Copy dump flags to root so that `hack-browser-data -b chrome`
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package keyretriever
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DumpVersion = "1"
|
||||||
|
|
||||||
|
// Dump is the cross-host portable container for Chromium master keys. Producing it on one host lets another host skip
|
||||||
|
// platform-native retrieval (DPAPI, ABE injection, Keychain prompt, D-Bus query) when decrypting copied profile data.
|
||||||
|
type Dump struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Host Host `json:"host"`
|
||||||
|
Vaults []Vault `json:"vaults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host OS / Arch always set; Hostname / User best-effort (empty on syscall failure).
|
||||||
|
type Host struct {
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vault groups profiles sharing master keys (master keys are per-installation, not per-profile).
|
||||||
|
type Vault struct {
|
||||||
|
Browser string `json:"browser"`
|
||||||
|
UserDataDir string `json:"user_data_dir"`
|
||||||
|
Profiles []string `json:"profiles"`
|
||||||
|
Keys MasterKeys `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDump returns a Dump initialized with current host metadata and an empty Vaults slice
|
||||||
|
func NewDump() Dump {
|
||||||
|
return Dump{
|
||||||
|
Version: DumpVersion,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
Host: currentHost(),
|
||||||
|
Vaults: []Vault{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentHost collects host identification; Hostname/User are best-effort (syscall failure leaves them empty + omitempty).
|
||||||
|
func currentHost() Host {
|
||||||
|
h := Host{OS: runtime.GOOS, Arch: runtime.GOARCH}
|
||||||
|
if name, err := os.Hostname(); err == nil {
|
||||||
|
h.Hostname = name
|
||||||
|
}
|
||||||
|
if u, err := user.Current(); err == nil {
|
||||||
|
h.User = u.Username
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteJSON writes the Dump as indented JSON to w.
|
||||||
|
func (d Dump) WriteJSON(w io.Writer) error {
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(d); err != nil {
|
||||||
|
return fmt.Errorf("encode dump: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadJSON parses a Dump from r.
|
||||||
|
func ReadJSON(r io.Reader) (Dump, error) {
|
||||||
|
var d Dump
|
||||||
|
dec := json.NewDecoder(r)
|
||||||
|
if err := dec.Decode(&d); err != nil {
|
||||||
|
return Dump{}, fmt.Errorf("decode dump: %w", err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
@@ -9,9 +9,15 @@ import (
|
|||||||
// (Chrome 127+ on Windows mixes v10+v20; Linux can mix v10+v11), so each tier must be populated
|
// (Chrome 127+ on Windows mixes v10+v20; Linux can mix v10+v11), so each tier must be populated
|
||||||
// independently for lossless decryption. A nil tier means that cipher version cannot be decrypted.
|
// independently for lossless decryption. A nil tier means that cipher version cannot be decrypted.
|
||||||
type MasterKeys struct {
|
type MasterKeys struct {
|
||||||
V10 []byte
|
V10 []byte `json:"v10,omitempty"`
|
||||||
V11 []byte
|
V11 []byte `json:"v11,omitempty"`
|
||||||
V20 []byte
|
V20 []byte `json:"v20,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAny reports whether at least one tier carries a usable key. Centralizes the "is this MasterKeys
|
||||||
|
// worth keeping" check so new tiers (V21, V12, …) only need to be added here, not at every caller.
|
||||||
|
func (k MasterKeys) HasAny() bool {
|
||||||
|
return k.V10 != nil || k.V11 != nil || k.V20 != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrievers is the per-tier retriever configuration; unused slots are nil.
|
// Retrievers is the per-tier retriever configuration; unused slots are nil.
|
||||||
|
|||||||
Reference in New Issue
Block a user