diff --git a/browser/keydump.go b/browser/keydump.go index 6a4dd30..21a1124 100644 --- a/browser/keydump.go +++ b/browser/keydump.go @@ -1,6 +1,8 @@ package browser import ( + "runtime" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/log" ) @@ -69,3 +71,56 @@ func groupByInstallation(browsers []Browser) (map[string]*installGroup, []string } return groups, order } + +// ApplyDump installs master keys from dump onto matching browsers, replacing each browser's default +// platform-native retrievers with StaticProviders backed by the Dump's bytes. Matching is by +// (BrowserName, UserDataDir) — the same key BuildDump groups by. When exact match fails (commonly a +// cross-host path mismatch: Windows backslash vs POSIX, or a relocated User Data dir via -p), falls +// back to the sole vault for that browser name when one exists. Browsers without a matching vault +// are warned and left untouched; non-KeyManager browsers (Firefox/Safari) are skipped silently. +func ApplyDump(browsers []Browser, dump keyretriever.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) + } + vaultIndex := make(map[string]*keyretriever.Vault, len(dump.Vaults)) + vaultsByBrowser := make(map[string][]*keyretriever.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 { + 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/%s using sole vault for browser (dump path %q != local %q)", + b.BrowserName(), b.ProfileName(), v.UserDataDir, b.UserDataDir()) + found = true + } + } + if !found { + log.Warnf("apply-keys: %s/%s no matching vault in dump", b.BrowserName(), b.ProfileName()) + continue + } + km.SetKeyRetrievers(keyretriever.Retrievers{ + V10: maybeStaticProvider(v.Keys.V10), + V11: maybeStaticProvider(v.Keys.V11), + V20: maybeStaticProvider(v.Keys.V20), + }) + } +} + +// maybeStaticProvider wraps non-empty key bytes as a StaticProvider; an empty/nil key returns nil +// to preserve the "tier not applicable" signal NewMasterKeys expects. +func maybeStaticProvider(key []byte) keyretriever.KeyRetriever { + if len(key) == 0 { + return nil + } + return keyretriever.NewStaticProvider(key) +} diff --git a/browser/keydump_test.go b/browser/keydump_test.go index c854fa4..3eb26be 100644 --- a/browser/keydump_test.go +++ b/browser/keydump_test.go @@ -36,12 +36,15 @@ func (m *mockBrowser) CountEntries(_ []types.Category) (map[types.Category]int, type mockChromiumBrowser struct { mockBrowser - keys keyretriever.MasterKeys - exportErr error - calls int + keys keyretriever.MasterKeys + exportErr error + calls int + receivedRetrievers keyretriever.Retrievers } -func (m *mockChromiumBrowser) SetKeyRetrievers(_ keyretriever.Retrievers) {} +func (m *mockChromiumBrowser) SetKeyRetrievers(r keyretriever.Retrievers) { + m.receivedRetrievers = r +} func (m *mockChromiumBrowser) ExportKeys() (keyretriever.MasterKeys, error) { m.calls++ @@ -196,6 +199,127 @@ func TestBuildDump_PartialKeys(t *testing.T) { } } +func TestApplyDump_Match(t *testing.T) { + b := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, + } + dump := keyretriever.Dump{ + Vaults: []keyretriever.Vault{ + {Browser: chromeName, UserDataDir: testUDD, Keys: keyretriever.MasterKeys{V10: []byte("v10-from-dump")}}, + }, + } + 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(keyretriever.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) + } +} + +func TestApplyDump_MissingVault(t *testing.T) { + b := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, + } + dump := keyretriever.Dump{ + Vaults: []keyretriever.Vault{ + {Browser: testEdgeName, UserDataDir: "/edge", Keys: keyretriever.MasterKeys{V10: []byte("v10")}}, + }, + } + ApplyDump([]Browser{b}, dump) + + if b.receivedRetrievers.V10 != nil { + t.Errorf("V10 should remain nil when no matching vault, got %v", b.receivedRetrievers.V10) + } +} + +func TestApplyDump_NonKeyManagerSkipped(t *testing.T) { + firefox := &mockBrowser{name: firefoxName, profile: "default-release", userDataDir: "/ff"} + dump := keyretriever.Dump{ + Vaults: []keyretriever.Vault{ + {Browser: firefoxName, UserDataDir: "/ff", Keys: keyretriever.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, profile: testProfileDefault, userDataDir: testUDD}, + keys: keyretriever.MasterKeys{V10: []byte("v10-rt"), V20: []byte("v20-rt")}, + } + dump := BuildDump([]Browser{src}) + + dst := &mockChromiumBrowser{ + mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, + } + ApplyDump([]Browser{dst}, dump) + + v10, _ := dst.receivedRetrievers.V10.RetrieveKey(keyretriever.Hints{}) + if string(v10) != "v10-rt" { + t.Errorf("V10 round-trip: got %q, want v10-rt", v10) + } + v20, _ := dst.receivedRetrievers.V20.RetrieveKey(keyretriever.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) + } +} + +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, profile: testProfileDefault, userDataDir: "/local/chrome"}, + } + dump := keyretriever.Dump{ + Vaults: []keyretriever.Vault{ + { + Browser: chromeName, + UserDataDir: `C:\Users\foo\AppData\Local\Google\Chrome\User Data`, + Keys: keyretriever.MasterKeys{V10: []byte("v10-fallback")}, + }, + }, + } + ApplyDump([]Browser{b}, dump) + + if b.receivedRetrievers.V10 == nil { + t.Fatal("V10 retriever should be set via single-vault fallback") + } + got, err := b.receivedRetrievers.V10.RetrieveKey(keyretriever.Hints{}) + if err != nil || string(got) != "v10-fallback" { + t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-fallback") + } +} + +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, profile: testProfileDefault, userDataDir: "/local/chrome"}, + } + dump := keyretriever.Dump{ + Vaults: []keyretriever.Vault{ + {Browser: chromeName, UserDataDir: "/path/a", Keys: keyretriever.MasterKeys{V10: []byte("a")}}, + {Browser: chromeName, UserDataDir: "/path/b", Keys: keyretriever.MasterKeys{V10: []byte("b")}}, + }, + } + ApplyDump([]Browser{b}, dump) + + if b.receivedRetrievers.V10 != nil { + t.Errorf("V10 should remain nil when fallback is ambiguous, got %v", b.receivedRetrievers.V10) + } +} + func TestBuildDump_GroupingOrderIndependent(t *testing.T) { for _, name := range []string{"p1 first", "p2 first"} { t.Run(name, func(t *testing.T) { diff --git a/cmd/hack-browser-data/dump.go b/cmd/hack-browser-data/dump.go index b429e67..41276b7 100644 --- a/cmd/hack-browser-data/dump.go +++ b/cmd/hack-browser-data/dump.go @@ -2,12 +2,14 @@ package main import ( "fmt" + "os" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/moond4rk/hackbrowserdata/browser" + "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/output" "github.com/moond4rk/hackbrowserdata/types" @@ -22,6 +24,7 @@ func dumpCmd() *cobra.Command { outputDir string profilePath string keychainPw string + keysPath string compress bool ) @@ -32,13 +35,10 @@ func dumpCmd() *cobra.Command { hack-browser-data dump -b chrome -c password,cookie hack-browser-data dump -b chrome -f json -d output hack-browser-data dump -f cookie-editor + hack-browser-data dump --keys dump.json -p /path/to/copied/User\ Data hack-browser-data dump --zip`, RunE: func(cmd *cobra.Command, args []string) error { - browsers, err := browser.PickBrowsers(browser.PickOptions{ - Name: browserName, - ProfilePath: profilePath, - KeychainPassword: keychainPw, - }) + browsers, err := selectBrowsers(browserName, profilePath, keychainPw, keysPath) if err != nil { return err } @@ -86,11 +86,51 @@ func dumpCmd() *cobra.Command { cmd.Flags().StringVarP(&outputDir, "dir", "d", "results", "output directory") cmd.Flags().StringVarP(&profilePath, "profile-path", "p", "", "custom profile dir path, get with chrome://version") cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password") + cmd.Flags().StringVar(&keysPath, "keys", "", "import master keys from JSON file (from `keys export`), skipping platform retrieval") cmd.Flags().BoolVar(&compress, "zip", false, "compress output to zip") return cmd } +// selectBrowsers returns wired-up browsers for either platform-native key retrieval (default) or +// dump-based key injection (when keysPath is non-empty). The dump path uses DiscoverBrowsers so it +// never triggers a keychain prompt or platform retrievers. +func selectBrowsers(browserName, profilePath, keychainPw, keysPath string) ([]browser.Browser, error) { + if keysPath == "" { + return browser.PickBrowsers(browser.PickOptions{ + Name: browserName, + ProfilePath: profilePath, + KeychainPassword: keychainPw, + }) + } + + if keychainPw != "" { + log.Warnf("--keychain-pw is ignored when --keys is set; platform key retrieval is skipped") + } + + browsers, err := browser.DiscoverBrowsers(browser.PickOptions{ + Name: browserName, + ProfilePath: profilePath, + }) + if err != nil { + return nil, err + } + + f, err := os.Open(keysPath) + if err != nil { + return nil, fmt.Errorf("open keys file %s: %w", keysPath, err) + } + defer f.Close() + + dump, err := keyretriever.ReadJSON(f) + if err != nil { + return nil, fmt.Errorf("read keys file %s: %w", keysPath, err) + } + + browser.ApplyDump(browsers, dump) + return browsers, nil +} + // parseCategories converts a comma-separated string into a Category slice. // "all" returns all categories. func parseCategories(s string) ([]types.Category, error) { diff --git a/crypto/keyretriever/dump.go b/crypto/keyretriever/dump.go index 4f1f394..0224be2 100644 --- a/crypto/keyretriever/dump.go +++ b/crypto/keyretriever/dump.go @@ -69,12 +69,16 @@ func (d Dump) WriteJSON(w io.Writer) error { return nil } -// ReadJSON parses a Dump from r. +// ReadJSON parses a Dump from r and rejects schema versions this build cannot interpret — +// silent misparse of a future v2 schema is worse than a clear error. 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) } + if d.Version != DumpVersion { + return Dump{}, fmt.Errorf("unsupported dump version %q (this build expects %q)", d.Version, DumpVersion) + } return d, nil } diff --git a/crypto/keyretriever/dump_test.go b/crypto/keyretriever/dump_test.go new file mode 100644 index 0000000..9df693c --- /dev/null +++ b/crypto/keyretriever/dump_test.go @@ -0,0 +1,41 @@ +package keyretriever + +import ( + "bytes" + "strings" + "testing" +) + +func TestReadJSON_RejectsUnknownVersion(t *testing.T) { + input := bytes.NewBufferString(`{"version":"99","created_at":"2026-05-16T00:00:00Z","host":{"os":"linux","arch":"amd64"},"vaults":[]}`) + _, err := ReadJSON(input) + if err == nil { + t.Fatal("ReadJSON should reject unknown version, got nil error") + } + if !strings.Contains(err.Error(), "unsupported dump version") { + t.Errorf("error should mention unsupported version, got: %v", err) + } +} + +func TestReadJSON_RejectsMissingVersion(t *testing.T) { + input := bytes.NewBufferString(`{"created_at":"2026-05-16T00:00:00Z","host":{"os":"linux","arch":"amd64"},"vaults":[]}`) + _, err := ReadJSON(input) + if err == nil { + t.Fatal("ReadJSON should reject empty version, got nil error") + } +} + +func TestReadJSON_AcceptsCurrentVersion(t *testing.T) { + d := NewDump() + 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 parsed.Version != DumpVersion { + t.Errorf("Version = %q, want %q", parsed.Version, DumpVersion) + } +} diff --git a/crypto/keyretriever/static.go b/crypto/keyretriever/static.go new file mode 100644 index 0000000..4d1876f --- /dev/null +++ b/crypto/keyretriever/static.go @@ -0,0 +1,23 @@ +package keyretriever + +// StaticProvider returns pre-supplied master-key bytes; used by cross-host workflows where keys come +// from a Dump rather than platform-native retrieval. RetrieveKey ignores Hints and returns the stored +// bytes verbatim; an empty StaticProvider returns (nil, nil), the "not applicable" signal accepted +// by NewMasterKeys when a tier was not present in the source Dump. +type StaticProvider struct { + key []byte +} + +// NewStaticProvider wraps key bytes as a KeyRetriever. A nil/empty key produces a provider that +// reports the tier as unavailable (nil, nil) rather than returning a zero-length key. +func NewStaticProvider(key []byte) *StaticProvider { + return &StaticProvider{key: key} +} + +// RetrieveKey returns the stored key bytes, ignoring Hints. +func (p *StaticProvider) RetrieveKey(_ Hints) ([]byte, error) { + if len(p.key) == 0 { + return nil, nil + } + return p.key, nil +}