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:
moonD4rk
2026-05-17 13:57:01 +08:00
parent 0fe35542f2
commit 2ba10429dc
6 changed files with 297 additions and 10 deletions
+55
View File
@@ -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
View File
@@ -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) {
+45 -5
View File
@@ -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) {
+5 -1
View File
@@ -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
}
+41
View File
@@ -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)
}
}
+23
View File
@@ -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
}