From bf96ba8c80e40885e5953b76f825ca1d007474d4 Mon Sep 17 00:00:00 2001 From: Roger Date: Fri, 12 Jun 2026 20:53:00 +0800 Subject: [PATCH] feat(restore): cross-platform restore via dump engine rebuild (#606) (#611) * feat(restore): cross-platform restore via dump engine rebuild (#606) Restore previously required the dump's origin OS, overlaying keys onto locally-discovered browsers. It now rebuilds Chromium engines from the dump's vaults (v2 adds engine kind), so copied data or an archive zip decrypts on any OS. * fix(restore): polish help text, drop dead check, dedup dump kinds pflag treats backticked words in flag usage as the value placeholder, so --data-zip rendered as "--data-zip archive" in help output. --- .golangci.yml | 1 + browser/browser.go | 3 + browser/chromium/chromium.go | 26 ++- browser/chromium/chromium_test.go | 44 +++++ browser/keydump.go | 166 ++++++++++++++---- browser/keydump_test.go | 275 ++++++++++++++++++------------ cmd/hack-browser-data/restore.go | 93 +++++----- masterkey/dump.go | 9 +- masterkey/dump_test.go | 29 ++++ types/category.go | 19 +++ types/category_test.go | 17 ++ 11 files changed, 493 insertions(+), 189 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index e807e45..f68df0c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -94,6 +94,7 @@ linters: - "chrome" - "Chrome" - "firefox" + - "chromium" gocritic: enabled-tags: - diagnostic diff --git a/browser/browser.go b/browser/browser.go index a3b689e..b6be8d7 100644 --- a/browser/browser.go +++ b/browser/browser.go @@ -91,9 +91,12 @@ func discoverFromConfigs(configs []types.BrowserConfig, opts DiscoverOptions) ([ } // KeyManager is implemented by installations accepting external master-key retrievers (Chromium only). +// BrowserKey/Kind expose the identity a portable dump needs to rebuild the engine off the platform table. type KeyManager interface { SetRetrievers(masterkey.Retrievers) ExportKeys() (masterkey.MasterKeys, error) + BrowserKey() string + Kind() types.BrowserKind } // KeychainPasswordReceiver is implemented by installations that need the macOS login password (Safari only). diff --git a/browser/chromium/chromium.go b/browser/chromium/chromium.go index 7527fee..4bdb231 100644 --- a/browser/chromium/chromium.go +++ b/browser/chromium/chromium.go @@ -54,9 +54,10 @@ func NewBrowser(cfg types.BrowserConfig) (*Browser, error) { // Extract; unused tiers stay nil. func (b *Browser) SetRetrievers(r masterkey.Retrievers) { b.retrievers = r } -func (b *Browser) BrowserName() string { return b.cfg.Name } -func (b *Browser) BrowserKey() string { return b.cfg.Key } -func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir } +func (b *Browser) BrowserName() string { return b.cfg.Name } +func (b *Browser) BrowserKey() string { return b.cfg.Key } +func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir } +func (b *Browser) Kind() types.BrowserKind { return b.cfg.Kind } // Profiles returns the identity of every profile in this installation. func (b *Browser) Profiles() []types.Profile { @@ -162,12 +163,25 @@ func discoverProfiles(userDataDir string, sources map[types.Category][]sourcePat } } - // Flat layout fallback (older Opera): data files directly in userDataDir. - // Opera stores data alongside Local State in userDataDir itself, so check - // for any known source file instead of Preferences. + // Flat layout (older Opera): data files directly under userDataDir with no profile subdir. Check the + // root before the subdir fallback so a stray source-bearing subdir can't suppress root discovery. if len(profiles) == 0 && hasAnySource(sources, userDataDir) { profiles = append(profiles, userDataDir) } + + // Restored/copied trees may omit the Preferences marker (it is no extraction source). When the marker + // scan and flat-layout check both find nothing, treat any source-bearing subdir as a profile. + if len(profiles) == 0 { + for _, e := range entries { + if !e.IsDir() || isSkippedDir(e.Name()) { + continue + } + dir := filepath.Join(userDataDir, e.Name()) + if hasAnySource(sources, dir) { + profiles = append(profiles, dir) + } + } + } return profiles } diff --git a/browser/chromium/chromium_test.go b/browser/chromium/chromium_test.go index 1a4fff8..85c472e 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -237,6 +237,50 @@ func TestNewBrowsers(t *testing.T) { } } +// --------------------------------------------------------------------------- +// discoverProfiles: fallback boundaries (marker-less copies, flat-layout precedence) +// --------------------------------------------------------------------------- + +func TestDiscoverProfiles(t *testing.T) { + t.Run("markerless multi-subdir resolves all source-bearing dirs", func(t *testing.T) { + dir := t.TempDir() + mkFile(dir, "Default", "History") + mkFile(dir, "Profile 1", "History") + assert.Len(t, discoverProfiles(dir, chromiumSources), 2) + }) + + t.Run("marker present keeps fallback dormant", func(t *testing.T) { + dir := t.TempDir() + mkFile(dir, "Default", "Preferences") + mkFile(dir, "Default", "History") + mkFile(dir, "NotAProfile", "History") + got := discoverProfiles(dir, chromiumSources) + require.Len(t, got, 1) + assert.Equal(t, filepath.Join(dir, "Default"), got[0]) + }) + + t.Run("skipped dir ignored by markerless fallback", func(t *testing.T) { + dir := t.TempDir() + mkFile(dir, "System Profile", "History") + assert.Empty(t, discoverProfiles(dir, chromiumSources)) + }) + + t.Run("sourceless subdir ignored", func(t *testing.T) { + dir := t.TempDir() + mkDir(dir, "Crashpad") + assert.Empty(t, discoverProfiles(dir, chromiumSources)) + }) + + t.Run("flat-layout root wins over source-bearing subdir", func(t *testing.T) { + dir := t.TempDir() + mkFile(dir, "History") + mkFile(dir, "Subthing", "History") + got := discoverProfiles(dir, chromiumSources) + require.Len(t, got, 1) + assert.Equal(t, dir, got[0], "flat-layout root must win; subdir must not hijack discovery") + }) +} + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- diff --git a/browser/keydump.go b/browser/keydump.go index 3e9c136..a16fba0 100644 --- a/browser/keydump.go +++ b/browser/keydump.go @@ -1,10 +1,14 @@ package browser import ( - "runtime" + "fmt" + "os" + "path/filepath" + "strings" "github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/masterkey" + "github.com/moond4rk/hackbrowserdata/types" ) // BuildDump exports one Vault per installation (Firefox/Safari, lacking KeyManager, are skipped). @@ -28,8 +32,14 @@ func BuildDump(browsers []Browser) masterkey.Dump { if !mk.HasAny() { continue } + kind, err := kindToDump(km.Kind()) + if err != nil { + log.Warnf("dump-keys: %s: %v", b.BrowserName(), err) + continue + } dump.Vaults = append(dump.Vaults, masterkey.Vault{ - Browser: b.BrowserName(), + Browser: km.BrowserKey(), + Kind: kind, UserDataDir: b.UserDataDir(), Profiles: profileNames(b), Keys: mk, @@ -47,46 +57,102 @@ func profileNames(b Browser) []string { return names } -// ApplyDump overlays StaticRetrievers from dump onto matching installations (Firefox/Safari skipped). -// Match is by (BrowserName, UserDataDir); on miss — commonly a cross-host path mismatch (Windows vs -// POSIX, or a relocated dir via -p) — it falls back to the sole vault for that browser name. No match -// → warn and leave the platform retrievers in place. -func ApplyDump(browsers []Browser, dump masterkey.Dump) { - if dump.Host.OS != "" && dump.Host.OS != runtime.GOOS { - log.Infof("apply-keys: dump created on %s/%s; current host is %s/%s", - dump.Host.OS, dump.Host.Arch, runtime.GOOS, runtime.GOARCH) +// BuildFromDump reconstructs Chromium engines straight from a dump's vaults, rooted at copied data +// instead of the local platform table — this is what lets an analyst host decrypt a browser its OS +// never installs. filter is a browser key ("" or "all" = every vault); a filter matching no vault is +// an error rather than silent empty output. +// +// Data layout is resolved two ways. When dataDir holds per-key subdirs (the archive layout), each +// vault is rooted at dataDir/. Otherwise dataDir is treated as one browser's User Data (a +// hand-copied folder), which is unambiguous only for a single vault — so filter must pick one. +func BuildFromDump(dump masterkey.Dump, dataDir, filter string) ([]Browser, error) { + filter = strings.ToLower(filter) + if filter == "all" { + filter = "" } - vaultIndex := make(map[string]*masterkey.Vault, len(dump.Vaults)) - vaultsByBrowser := make(map[string][]*masterkey.Vault) - for i := range dump.Vaults { - v := &dump.Vaults[i] - vaultIndex[v.Browser+"|"+v.UserDataDir] = v - vaultsByBrowser[v.Browser] = append(vaultsByBrowser[v.Browser], v) - } - for _, b := range browsers { - km, ok := b.(KeyManager) - if !ok { + + var selected []masterkey.Vault + for _, v := range dump.Vaults { + if filter != "" && !strings.EqualFold(v.Browser, filter) { continue } - v, found := vaultIndex[b.BrowserName()+"|"+b.UserDataDir()] - if !found { - if candidates := vaultsByBrowser[b.BrowserName()]; len(candidates) == 1 { - v = candidates[0] - log.Infof("apply-keys: %s using sole vault for browser (dump path %q != local %q)", - b.BrowserName(), v.UserDataDir, b.UserDataDir()) - found = true + selected = append(selected, v) + } + if filter != "" && len(selected) == 0 { + return nil, fmt.Errorf("no vault for browser %q in keys (have: %s)", filter, vaultKeys(dump)) + } + + if !dirExists(dataDir) { + return nil, fmt.Errorf("data dir %q does not exist", dataDir) + } + + archiveLayout := isArchiveLayout(dataDir, selected) + if !archiveLayout && len(selected) > 1 { + return nil, fmt.Errorf("--data-dir %q has no per-browser subdir but keys has %d browsers; "+ + "point it at the archive root, or use -b for one browser's User Data (have: %s)", + dataDir, len(selected), vaultKeys(dump)) + } + + var browsers []Browser + for _, v := range selected { + root := dataDir + if archiveLayout { + root = filepath.Join(dataDir, strings.ToLower(v.Browser)) + if !dirExists(root) { + log.Warnf("restore: %s has no data under %s, skipping", v.Browser, root) + continue } } - if !found { - log.Warnf("apply-keys: %s no matching vault in dump", b.BrowserName()) + kind, err := kindFromDump(v.Kind) + if err != nil { + log.Warnf("restore: %s: %v", v.Browser, err) continue } - km.SetRetrievers(masterkey.Retrievers{ - V10: maybeStaticRetriever(v.Keys.V10), - V11: maybeStaticRetriever(v.Keys.V11), - V20: maybeStaticRetriever(v.Keys.V20), - }) + cfg := types.BrowserConfig{ + Key: strings.ToLower(v.Browser), + Name: v.Browser, + Kind: kind, + UserDataDir: root, + } + b, err := newBrowser(cfg) + if err != nil { + log.Errorf("restore: build %s: %v", v.Browser, err) + continue + } + if b == nil { + log.Warnf("restore: %s found no profiles under %s", v.Browser, root) + continue + } + if km, ok := b.(KeyManager); ok { + km.SetRetrievers(retrieversFromKeys(v.Keys)) + } + browsers = append(browsers, b) } + return browsers, nil +} + +// isArchiveLayout reports whether dataDir uses the archive layout — one per-browser subdir named by +// the vault key — rather than a raw single-browser User Data copy. +func isArchiveLayout(dataDir string, vaults []masterkey.Vault) bool { + for _, v := range vaults { + if dirExists(filepath.Join(dataDir, strings.ToLower(v.Browser))) { + return true + } + } + return false +} + +func vaultKeys(dump masterkey.Dump) string { + keys := make([]string, 0, len(dump.Vaults)) + for _, v := range dump.Vaults { + keys = append(keys, strings.ToLower(v.Browser)) + } + return strings.Join(keys, ", ") +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() } // maybeStaticRetriever wraps non-empty key bytes as a StaticRetriever; an empty/nil key returns nil @@ -97,3 +163,35 @@ func maybeStaticRetriever(key []byte) masterkey.Retriever { } return masterkey.NewStaticRetriever(key) } + +// retrieversFromKeys maps a vault's per-tier key bytes to static retrievers; an absent tier stays nil +// so NewMasterKeys keeps treating it as "not applicable". +func retrieversFromKeys(mk masterkey.MasterKeys) masterkey.Retrievers { + return masterkey.Retrievers{ + V10: maybeStaticRetriever(mk.V10), + V11: maybeStaticRetriever(mk.V11), + V20: maybeStaticRetriever(mk.V20), + } +} + +// dumpableKinds are the engine kinds a vault may carry; kindToDump/kindFromDump translate to and from +// the wire form via BrowserKind.String(), keeping the vocabulary single-sourced in the types enum. +var dumpableKinds = []types.BrowserKind{types.Chromium, types.ChromiumYandex, types.ChromiumOpera} + +func kindToDump(k types.BrowserKind) (string, error) { + for _, dk := range dumpableKinds { + if k == dk { + return k.String(), nil + } + } + return "", fmt.Errorf("engine kind %s is not exportable", k) +} + +func kindFromDump(s string) (types.BrowserKind, error) { + for _, k := range dumpableKinds { + if k.String() == s { + return k, nil + } + } + return 0, fmt.Errorf("unknown engine kind %q", s) +} diff --git a/browser/keydump_test.go b/browser/keydump_test.go index 14d4971..ae8e500 100644 --- a/browser/keydump_test.go +++ b/browser/keydump_test.go @@ -3,7 +3,10 @@ package browser import ( "bytes" "errors" + "os" + "path/filepath" "runtime" + "strings" "testing" "github.com/moond4rk/hackbrowserdata/masterkey" @@ -44,6 +47,8 @@ func (m *mockBrowser) CountEntries(_ []types.Category) ([]types.CountResult, err type mockChromiumBrowser struct { mockBrowser + key string + kind types.BrowserKind keys masterkey.MasterKeys exportErr error calls int @@ -59,6 +64,15 @@ func (m *mockChromiumBrowser) ExportKeys() (masterkey.MasterKeys, error) { return m.keys, m.exportErr } +func (m *mockChromiumBrowser) BrowserKey() string { + if m.key != "" { + return m.key + } + return strings.ToLower(m.name) +} + +func (m *mockChromiumBrowser) Kind() types.BrowserKind { return m.kind } + func TestBuildDump_Empty(t *testing.T) { dump := BuildDump(nil) if dump.Version != masterkey.DumpVersion { @@ -84,9 +98,12 @@ func TestBuildDump_SingleChromium(t *testing.T) { t.Fatalf("Vaults len = %d, want 1", len(dump.Vaults)) } inst := dump.Vaults[0] - if inst.Browser != chromeName || inst.UserDataDir != testUDD { + if !strings.EqualFold(inst.Browser, chromeName) || inst.UserDataDir != testUDD { t.Errorf("inst metadata = %+v", inst) } + if inst.Kind != "chromium" { + t.Errorf("Kind = %q, want chromium", inst.Kind) + } if len(inst.Profiles) != 1 || inst.Profiles[0] != testProfileDefault { t.Errorf("Profiles = %v", inst.Profiles) } @@ -129,8 +146,8 @@ func TestBuildDump_SkipsNonKeyManager(t *testing.T) { 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) + if !strings.EqualFold(dump.Vaults[0].Browser, chromeName) { + t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, strings.ToLower(chromeName)) } } @@ -149,8 +166,8 @@ func TestBuildDump_SkipsExportError(t *testing.T) { 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) + if !strings.EqualFold(dump.Vaults[0].Browser, chromeName) { + t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, strings.ToLower(chromeName)) } } @@ -178,6 +195,9 @@ func TestBuildDump_JSONRoundTrip(t *testing.T) { if len(parsed.Vaults) != 1 { t.Fatalf("Vaults len = %d", len(parsed.Vaults)) } + if parsed.Vaults[0].Kind != "chromium" { + t.Errorf("Kind round-trip: got %q, want chromium", parsed.Vaults[0].Kind) + } if !bytes.Equal(parsed.Vaults[0].Keys.V10, dump.Vaults[0].Keys.V10) { t.Errorf("V10 round-trip mismatch") } @@ -209,123 +229,168 @@ func TestBuildDump_PartialKeys(t *testing.T) { } } -func TestApplyDump_Match(t *testing.T) { - b := &mockChromiumBrowser{ - mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, +func TestKindDumpRoundTrip(t *testing.T) { + for _, k := range []types.BrowserKind{types.Chromium, types.ChromiumYandex, types.ChromiumOpera} { + s, err := kindToDump(k) + if err != nil { + t.Fatalf("kindToDump(%d): %v", k, err) + } + got, err := kindFromDump(s) + if err != nil || got != k { + t.Errorf("round trip %d -> %q -> %d (err %v)", k, s, got, err) + } } - dump := masterkey.Dump{ - Vaults: []masterkey.Vault{ - {Browser: chromeName, UserDataDir: testUDD, Keys: masterkey.MasterKeys{V10: []byte("v10-from-dump")}}, - }, + if _, err := kindToDump(types.Firefox); err == nil { + t.Error("kindToDump(Firefox) should error") } - ApplyDump([]Browser{b}, dump) - - if b.receivedRetrievers.V10 == nil { - t.Fatal("V10 retriever should be set from matching vault") - } - got, err := b.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{}) - if err != nil || string(got) != "v10-from-dump" { - t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-from-dump") - } - if b.receivedRetrievers.V11 != nil { - t.Errorf("V11 should be nil (tier not in dump), got %v", b.receivedRetrievers.V11) + if _, err := kindFromDump("nope"); err == nil { + t.Error("kindFromDump(nope) should error") } } -func TestApplyDump_MissingVault(t *testing.T) { - b := &mockChromiumBrowser{ - mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, - } - dump := masterkey.Dump{ - Vaults: []masterkey.Vault{ - {Browser: testEdgeName, UserDataDir: "/edge", Keys: masterkey.MasterKeys{V10: []byte("v10")}}, - }, - } - ApplyDump([]Browser{b}, dump) +func TestRetrieversFromKeys(t *testing.T) { + r := retrieversFromKeys(masterkey.MasterKeys{V10: []byte("k10"), V20: []byte("k20")}) - if b.receivedRetrievers.V10 != nil { - t.Errorf("V10 should remain nil when no matching vault, got %v", b.receivedRetrievers.V10) + if r.V10 == nil || r.V20 == nil { + t.Fatal("V10 and V20 retrievers should be set from non-empty keys") + } + if r.V11 != nil { + t.Error("V11 retriever should be nil when the key is absent") + } + if got, _ := r.V10.RetrieveKey(masterkey.Hints{}); string(got) != "k10" { + t.Errorf("V10 key = %q, want k10", got) + } + if got, _ := r.V20.RetrieveKey(masterkey.Hints{}); string(got) != "k20" { + t.Errorf("V20 key = %q, want k20", got) } } -func TestApplyDump_NonKeyManagerSkipped(t *testing.T) { - firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}} - dump := masterkey.Dump{ - Vaults: []masterkey.Vault{ - {Browser: firefoxName, UserDataDir: "/ff", Keys: masterkey.MasterKeys{V10: []byte("v10")}}, - }, - } - // firefox does not implement KeyManager; ApplyDump must not panic and must not attempt injection. - ApplyDump([]Browser{firefox}, dump) -} - -func TestApplyDump_RoundTrip(t *testing.T) { - src := &mockChromiumBrowser{ - mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, - keys: masterkey.MasterKeys{V10: []byte("v10-rt"), V20: []byte("v20-rt")}, - } - dump := BuildDump([]Browser{src}) - - dst := &mockChromiumBrowser{ - mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, - } - ApplyDump([]Browser{dst}, dump) - - v10, _ := dst.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{}) - if string(v10) != "v10-rt" { - t.Errorf("V10 round-trip: got %q, want v10-rt", v10) - } - v20, _ := dst.receivedRetrievers.V20.RetrieveKey(masterkey.Hints{}) - if string(v20) != "v20-rt" { - t.Errorf("V20 round-trip: got %q, want v20-rt", v20) - } - if dst.receivedRetrievers.V11 != nil { - t.Errorf("V11 should be nil (not in source keys), got %v", dst.receivedRetrievers.V11) +// makeUserData writes a minimal Chromium profile tree: a Preferences marker plus History (a real +// extraction source, so the profile resolves) under each named profile dir. +func makeUserData(t *testing.T, root string, profiles ...string) { + t.Helper() + for _, p := range profiles { + dir := filepath.Join(root, p) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range []string{"Preferences", "History"} { + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } } } -func TestApplyDump_FallbackOnPathMismatch(t *testing.T) { - // Cross-host scenario: dump was created on Windows but is applied on Linux/macOS where the - // UserDataDir literally differs. With a single vault for the browser, ApplyDump should still - // inject — otherwise the primary cross-host use case fails silently. - b := &mockChromiumBrowser{ - mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}}, - } - dump := masterkey.Dump{ - Vaults: []masterkey.Vault{ - { - Browser: chromeName, - UserDataDir: `C:\Users\foo\AppData\Local\Google\Chrome\User Data`, - Keys: masterkey.MasterKeys{V10: []byte("v10-fallback")}, - }, - }, - } - ApplyDump([]Browser{b}, dump) +func TestBuildFromDump_ConventionMultiBrowser(t *testing.T) { + dataDir := t.TempDir() + makeUserData(t, filepath.Join(dataDir, "chrome"), testProfileDefault) + makeUserData(t, filepath.Join(dataDir, "edge"), testProfileDefault) + dump := masterkey.Dump{Vaults: []masterkey.Vault{ + {Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}}, + {Browser: "edge", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("e")}}, + }} - if b.receivedRetrievers.V10 == nil { - t.Fatal("V10 retriever should be set via single-vault fallback") + browsers, err := BuildFromDump(dump, dataDir, "") + if err != nil { + t.Fatalf("BuildFromDump: %v", err) } - got, err := b.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{}) - if err != nil || string(got) != "v10-fallback" { - t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-fallback") + if len(browsers) != 2 { + t.Fatalf("got %d browsers, want 2", len(browsers)) } } -func TestApplyDump_NoFallbackWhenAmbiguous(t *testing.T) { - // Two Chrome vaults in the dump and no exact path match — ApplyDump must not guess which - // installation the local browser corresponds to. - b := &mockChromiumBrowser{ - mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}}, - } - dump := masterkey.Dump{ - Vaults: []masterkey.Vault{ - {Browser: chromeName, UserDataDir: "/path/a", Keys: masterkey.MasterKeys{V10: []byte("a")}}, - {Browser: chromeName, UserDataDir: "/path/b", Keys: masterkey.MasterKeys{V10: []byte("b")}}, - }, - } - ApplyDump([]Browser{b}, dump) +// TestBuildFromDump_ForeignKindNoPlatformTable proves restore never consults platformBrowsers(): +// sogou is Windows-only yet reconstructs from its vault on any OS. +func TestBuildFromDump_ForeignKindNoPlatformTable(t *testing.T) { + dataDir := t.TempDir() + makeUserData(t, filepath.Join(dataDir, "sogou"), testProfileDefault) + dump := masterkey.Dump{Vaults: []masterkey.Vault{ + {Browser: "sogou", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("k")}}, + }} - if b.receivedRetrievers.V10 != nil { - t.Errorf("V10 should remain nil when fallback is ambiguous, got %v", b.receivedRetrievers.V10) + browsers, err := BuildFromDump(dump, dataDir, "") + if err != nil { + t.Fatalf("BuildFromDump: %v", err) + } + if len(browsers) != 1 { + t.Fatalf("got %d browsers, want 1", len(browsers)) + } + if browsers[0].BrowserName() != "sogou" { + t.Errorf("BrowserName = %q, want sogou", browsers[0].BrowserName()) + } +} + +func TestBuildFromDump_RawSingleBrowser(t *testing.T) { + dataDir := t.TempDir() + makeUserData(t, dataDir, testProfileDefault) + dump := masterkey.Dump{Vaults: []masterkey.Vault{ + {Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}}, + }} + + browsers, err := BuildFromDump(dump, dataDir, "chrome") + if err != nil { + t.Fatalf("BuildFromDump: %v", err) + } + if len(browsers) != 1 { + t.Fatalf("got %d browsers, want 1", len(browsers)) + } + if browsers[0].UserDataDir() != dataDir { + t.Errorf("UserDataDir = %q, want %q (raw root)", browsers[0].UserDataDir(), dataDir) + } +} + +func TestBuildFromDump_UnknownBrowserErrors(t *testing.T) { + dataDir := t.TempDir() + dump := masterkey.Dump{Vaults: []masterkey.Vault{ + {Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}}, + }} + if _, err := BuildFromDump(dump, dataDir, "sogou"); err == nil { + t.Fatal("expected error for -b matching no vault") + } +} + +func TestBuildFromDump_RawAmbiguousErrors(t *testing.T) { + dataDir := t.TempDir() + makeUserData(t, dataDir, testProfileDefault) + dump := masterkey.Dump{Vaults: []masterkey.Vault{ + {Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}}, + {Browser: "edge", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("e")}}, + }} + if _, err := BuildFromDump(dump, dataDir, ""); err == nil { + t.Fatal("expected ambiguity error for raw multi-vault restore without -b") + } +} + +func TestBuildFromDump_MissingDataDirErrors(t *testing.T) { + dump := masterkey.Dump{Vaults: []masterkey.Vault{ + {Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}}, + }} + if _, err := BuildFromDump(dump, "/no/such/dir", ""); err == nil { + t.Fatal("expected error when data dir does not exist") + } +} + +// TestBuildFromDump_MarkerlessTreeStillResolves covers an archive/copy that omitted Preferences: +// the source-bearing-subdir fallback in discoverProfiles must still find the profile. +func TestBuildFromDump_MarkerlessTreeStillResolves(t *testing.T) { + dataDir := t.TempDir() + dir := filepath.Join(dataDir, "chrome", testProfileDefault) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "History"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + dump := masterkey.Dump{Vaults: []masterkey.Vault{ + {Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}}, + }} + + browsers, err := BuildFromDump(dump, dataDir, "") + if err != nil { + t.Fatalf("BuildFromDump: %v", err) + } + if len(browsers) != 1 { + t.Fatalf("got %d browsers, want 1 (marker-less profile must resolve via source fallback)", len(browsers)) } } diff --git a/cmd/hack-browser-data/restore.go b/cmd/hack-browser-data/restore.go index 788ca65..4c2b928 100644 --- a/cmd/hack-browser-data/restore.go +++ b/cmd/hack-browser-data/restore.go @@ -4,39 +4,47 @@ import ( "fmt" "io" "os" - "strings" "github.com/spf13/cobra" "github.com/moond4rk/hackbrowserdata/browser" "github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/masterkey" + "github.com/moond4rk/hackbrowserdata/utils/fileutil" ) func restoreCmd() *cobra.Command { var ( keysPath string + dataDir string + dataZip string browserName string category string outputFormat string outputDir string - profilePath string compress bool ) cmd := &cobra.Command{ Use: "restore", - Short: "Decrypt a copied profile using exported master keys", - Example: ` hack-browser-data restore -i keys.json -b chrome -p /path/to/copied/User\ Data - hack-browser-data restore -i keys.json -b edge -p /path -c cookie -f csv - ssh origin "hack-browser-data dumpkeys" | hack-browser-data restore -i - -b chrome -p /path`, + Short: "Decrypt copied profile data using exported master keys", + Example: ` hack-browser-data restore --keys keys.json --data-zip data.zip + hack-browser-data restore --keys keys.json --data-dir ./data -b chrome -c cookie + hack-browser-data restore --keys keys.json --data-dir ./chrome-userdata -b chrome + ssh origin "hack-browser-data dumpkeys" | hack-browser-data restore --keys - --data-zip data.zip`, RunE: func(cmd *cobra.Command, args []string) error { - browsers, err := loadAndApplyKeys(browserName, profilePath, keysPath) + resolvedDir, cleanup, err := resolveDataDir(dataDir, dataZip) + if err != nil { + return err + } + defer cleanup() + + browsers, err := loadRestoreBrowsers(keysPath, resolvedDir, browserName) if err != nil { return err } if len(browsers) == 0 { - log.Warnf("no browsers found") + log.Warnf("no browsers to restore from the supplied keys and data") return nil } categories, err := parseCategories(category) @@ -47,31 +55,24 @@ func restoreCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&keysPath, "input", "i", "", "input keys file (use - for stdin)") - cmd.Flags().StringVarP(&browserName, "browser", "b", "", "target browser (single, required): "+browser.Names()) + cmd.Flags().StringVar(&keysPath, "keys", "", "keys file from dumpkeys (use - for stdin)") + cmd.Flags().StringVar(&dataDir, "data-dir", "", "copied profile data dir (archive layout, or one browser's User Data with -b)") + cmd.Flags().StringVar(&dataZip, "data-zip", "", "zip produced by the archive command (alternative to --data-dir)") + cmd.Flags().StringVarP(&browserName, "browser", "b", "", "restore only this browser (optional; must match a vault in --keys)") cmd.Flags().StringVarP(&category, "category", "c", "all", "data categories (comma-separated): all|"+categoryNames()) 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(&profilePath, "profile-path", "p", "", "copied profile dir path (required)") cmd.Flags().BoolVar(&compress, "zip", false, "compress output to zip") - _ = cmd.MarkFlagRequired("input") - _ = cmd.MarkFlagRequired("browser") - _ = cmd.MarkFlagRequired("profile-path") + _ = cmd.MarkFlagRequired("keys") + cmd.MarkFlagsMutuallyExclusive("data-dir", "data-zip") return cmd } -func loadAndApplyKeys(browserName, profilePath, keysPath string) ([]browser.Browser, error) { - if profilePath == "" { - return nil, fmt.Errorf("requires -p ") - } - name := strings.ToLower(browserName) - if name == "" || name == "all" { - return nil, fmt.Errorf(`requires -b (single, not "all")`) - } +func loadRestoreBrowsers(keysPath, dataDir, browserName string) ([]browser.Browser, error) { if keysPath == "" { - return nil, fmt.Errorf("requires -i (or - for stdin)") + return nil, fmt.Errorf("requires --keys (or - for stdin)") } var r io.Reader = os.Stdin @@ -88,22 +89,32 @@ func loadAndApplyKeys(browserName, profilePath, keysPath string) ([]browser.Brow return nil, fmt.Errorf("read keys file %q: %w", keysPath, err) } - browsers, err := browser.DiscoverBrowsers(browser.DiscoverOptions{ - Name: browserName, - ProfilePath: profilePath, - }) - if err != nil { - return nil, err - } - - browser.ApplyDump(browsers, dump) - - for _, b := range browsers { - if _, ok := b.(browser.KeychainPasswordReceiver); ok { - log.Infof("Safari has no portable master key; run `dump -b safari` separately for full extraction") - break - } - } - - return browsers, nil + return browser.BuildFromDump(dump, dataDir, browserName) +} + +// resolveDataDir returns the directory restore reads from: --data-dir as-is, or --data-zip extracted +// into a temp dir (removed by the returned cleanup). Exactly one of the two must be set. +func resolveDataDir(dataDir, dataZip string) (string, func(), error) { + noop := func() {} + if (dataDir == "") == (dataZip == "") { + return "", noop, fmt.Errorf("exactly one of --data-dir or --data-zip is required") + } + if dataDir != "" { + return dataDir, noop, nil + } + tmp, err := os.MkdirTemp("", "hbd-restore-*") + if err != nil { + return "", noop, fmt.Errorf("create temp dir: %w", err) + } + if err := fileutil.Unzip(dataZip, tmp); err != nil { + removeTempDir(tmp) + return "", noop, fmt.Errorf("extract %s: %w", dataZip, err) + } + return tmp, func() { removeTempDir(tmp) }, nil +} + +func removeTempDir(dir string) { + if err := os.RemoveAll(dir); err != nil { + log.Warnf("restore: remove temp dir %s: %v", dir, err) + } } diff --git a/masterkey/dump.go b/masterkey/dump.go index 13fa924..8395b75 100644 --- a/masterkey/dump.go +++ b/masterkey/dump.go @@ -10,7 +10,7 @@ import ( "time" ) -const DumpVersion = "1" +const DumpVersion = "2" // Dump is the portable, cross-host container for Chromium master keys — produce it on one host to // decrypt copied profile data on another without DPAPI / ABE / Keychain / D-Bus. @@ -30,8 +30,11 @@ type Host struct { } // Vault groups profiles sharing master keys (master keys are per-installation, not per-profile). +// Browser is the lookup key (e.g. "chrome"); Kind is the engine ("chromium"|"chromium-yandex"| +// "chromium-opera") so a consumer can rebuild the engine without the local browser table. type Vault struct { Browser string `json:"browser"` + Kind string `json:"kind"` UserDataDir string `json:"user_data_dir"` Profiles []string `json:"profiles"` Keys MasterKeys `json:"keys"` @@ -66,8 +69,8 @@ func (d Dump) WriteJSON(w io.Writer) error { return nil } -// ReadJSON parses a Dump and rejects versions this build can't interpret — a silent misparse of a -// future v2 schema is worse than a clear error. +// ReadJSON parses a Dump and rejects any version this build can't interpret — a silent misparse of an +// unrecognized schema is worse than a clear error. func ReadJSON(r io.Reader) (Dump, error) { var d Dump dec := json.NewDecoder(r) diff --git a/masterkey/dump_test.go b/masterkey/dump_test.go index c442184..c201997 100644 --- a/masterkey/dump_test.go +++ b/masterkey/dump_test.go @@ -39,3 +39,32 @@ func TestReadJSON_AcceptsCurrentVersion(t *testing.T) { t.Errorf("Version = %q, want %q", parsed.Version, DumpVersion) } } + +func TestDump_VaultKindRoundTrip(t *testing.T) { + d := NewDump() + d.Vaults = append(d.Vaults, Vault{ + Browser: "chrome", + Kind: "chromium", + UserDataDir: "/p", + Profiles: []string{"Default"}, + Keys: MasterKeys{V10: []byte{0x01}}, + }) + + var buf bytes.Buffer + if err := d.WriteJSON(&buf); err != nil { + t.Fatalf("WriteJSON: %v", err) + } + parsed, err := ReadJSON(&buf) + if err != nil { + t.Fatalf("ReadJSON: %v", err) + } + if len(parsed.Vaults) != 1 { + t.Fatalf("Vaults len = %d, want 1", len(parsed.Vaults)) + } + if parsed.Vaults[0].Kind != "chromium" { + t.Errorf("Vault.Kind round-trip: got %q, want %q", parsed.Vaults[0].Kind, "chromium") + } + if parsed.Vaults[0].Browser != "chrome" { + t.Errorf("Vault.Browser round-trip: got %q, want %q", parsed.Vaults[0].Browser, "chrome") + } +} diff --git a/types/category.go b/types/category.go index 23efe96..933b66d 100644 --- a/types/category.go +++ b/types/category.go @@ -81,6 +81,25 @@ const ( Safari ) +// String returns the canonical lowercase name of the engine kind; the chromium-family values are +// also the stable wire form carried in a keys dump, so don't change them lightly. +func (k BrowserKind) String() string { + switch k { + case Chromium: + return "chromium" + case ChromiumYandex: + return "chromium-yandex" + case ChromiumOpera: + return "chromium-opera" + case Firefox: + return "firefox" + case Safari: + return "safari" + default: + return "unknown" + } +} + // BrowserConfig holds the declarative configuration for a browser installation. type BrowserConfig struct { Key string // lookup key; doubles as the Windows ABE / winutil.Table key when WindowsABE is true diff --git a/types/category_test.go b/types/category_test.go index af64f69..44e93aa 100644 --- a/types/category_test.go +++ b/types/category_test.go @@ -27,6 +27,23 @@ func TestCategory_String(t *testing.T) { } } +func TestBrowserKind_String(t *testing.T) { + tests := []struct { + kind BrowserKind + want string + }{ + {Chromium, "chromium"}, + {ChromiumYandex, "chromium-yandex"}, + {ChromiumOpera, "chromium-opera"}, + {Firefox, "firefox"}, + {Safari, "safari"}, + {BrowserKind(999), "unknown"}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, tt.kind.String()) + } +} + func TestCategory_IsSensitive(t *testing.T) { sensitive := []Category{Password, Cookie, CreditCard} for _, c := range sensitive {