mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
feat(keys): add --keys flag to dump for cross-host decryption
Consumer side of the cross-host key workflow (pairs with #599). ApplyDump wires StaticProviders from a dump.json into matching browsers, so dump --keys f.json -p /copied/data decrypts without native retrievers.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+128
-4
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user