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) {