mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +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
|
||||
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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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) ProfileName() string { return b.profile.name }
|
||||
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
|
||||
|
||||
func (b *Browser) ProfileDir() string {
|
||||
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")
|
||||
|
||||
dump := dumpCmd()
|
||||
root.AddCommand(dump, listCmd(), versionCmd())
|
||||
root.AddCommand(dump, listCmd(), keysCmd(), versionCmd())
|
||||
|
||||
// Default to dump when no subcommand is given.
|
||||
// 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
|
||||
// independently for lossless decryption. A nil tier means that cipher version cannot be decrypted.
|
||||
type MasterKeys struct {
|
||||
V10 []byte
|
||||
V11 []byte
|
||||
V20 []byte
|
||||
V10 []byte `json:"v10,omitempty"`
|
||||
V11 []byte `json:"v11,omitempty"`
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user