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.
This commit is contained in:
moonD4rk
2026-06-07 22:03:46 +08:00
parent cd0b2daaf3
commit 6d0efadb59
11 changed files with 496 additions and 189 deletions
+1
View File
@@ -94,6 +94,7 @@ linters:
- "chrome" - "chrome"
- "Chrome" - "Chrome"
- "firefox" - "firefox"
- "chromium"
gocritic: gocritic:
enabled-tags: enabled-tags:
- diagnostic - diagnostic
+3
View File
@@ -91,9 +91,12 @@ func discoverFromConfigs(configs []types.BrowserConfig, opts DiscoverOptions) ([
} }
// KeyManager is implemented by installations accepting external master-key retrievers (Chromium only). // 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 { type KeyManager interface {
SetRetrievers(masterkey.Retrievers) SetRetrievers(masterkey.Retrievers)
ExportKeys() (masterkey.MasterKeys, error) ExportKeys() (masterkey.MasterKeys, error)
BrowserKey() string
Kind() types.BrowserKind
} }
// KeychainPasswordReceiver is implemented by installations that need the macOS login password (Safari only). // KeychainPasswordReceiver is implemented by installations that need the macOS login password (Safari only).
+20 -6
View File
@@ -54,9 +54,10 @@ func NewBrowser(cfg types.BrowserConfig) (*Browser, error) {
// Extract; unused tiers stay nil. // Extract; unused tiers stay nil.
func (b *Browser) SetRetrievers(r masterkey.Retrievers) { b.retrievers = r } func (b *Browser) SetRetrievers(r masterkey.Retrievers) { b.retrievers = r }
func (b *Browser) BrowserName() string { return b.cfg.Name } func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) BrowserKey() string { return b.cfg.Key } func (b *Browser) BrowserKey() string { return b.cfg.Key }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir } 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. // Profiles returns the identity of every profile in this installation.
func (b *Browser) Profiles() []types.Profile { 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. // Flat layout (older Opera): data files directly under userDataDir with no profile subdir. Check the
// Opera stores data alongside Local State in userDataDir itself, so check // root before the subdir fallback so a stray source-bearing subdir can't suppress root discovery.
// for any known source file instead of Preferences.
if len(profiles) == 0 && hasAnySource(sources, userDataDir) { if len(profiles) == 0 && hasAnySource(sources, userDataDir) {
profiles = append(profiles, 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 return profiles
} }
+44
View File
@@ -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 // Test helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+132 -34
View File
@@ -1,10 +1,14 @@
package browser package browser
import ( import (
"runtime" "fmt"
"os"
"path/filepath"
"strings"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types"
) )
// BuildDump exports one Vault per installation (Firefox/Safari, lacking KeyManager, are skipped). // 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() { if !mk.HasAny() {
continue 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{ dump.Vaults = append(dump.Vaults, masterkey.Vault{
Browser: b.BrowserName(), Browser: km.BrowserKey(),
Kind: kind,
UserDataDir: b.UserDataDir(), UserDataDir: b.UserDataDir(),
Profiles: profileNames(b), Profiles: profileNames(b),
Keys: mk, Keys: mk,
@@ -47,46 +57,102 @@ func profileNames(b Browser) []string {
return names return names
} }
// ApplyDump overlays StaticRetrievers from dump onto matching installations (Firefox/Safari skipped). // BuildFromDump reconstructs Chromium engines straight from a dump's vaults, rooted at copied data
// Match is by (BrowserName, UserDataDir); on miss — commonly a cross-host path mismatch (Windows vs // instead of the local platform table — this is what lets an analyst host decrypt a browser its OS
// POSIX, or a relocated dir via -p) — it falls back to the sole vault for that browser name. No match // never installs. filter is a browser key ("" or "all" = every vault); a filter matching no vault is
// → warn and leave the platform retrievers in place. // an error rather than silent empty output.
func ApplyDump(browsers []Browser, dump masterkey.Dump) { //
if dump.Host.OS != "" && dump.Host.OS != runtime.GOOS { // Data layout is resolved two ways. When dataDir holds per-key subdirs (the archive layout), each
log.Infof("apply-keys: dump created on %s/%s; current host is %s/%s", // vault is rooted at dataDir/<key>. Otherwise dataDir is treated as one browser's User Data (a
dump.Host.OS, dump.Host.Arch, runtime.GOOS, runtime.GOARCH) // 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) var selected []masterkey.Vault
for i := range dump.Vaults { for _, v := range dump.Vaults {
v := &dump.Vaults[i] if filter != "" && !strings.EqualFold(v.Browser, filter) {
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 continue
} }
v, found := vaultIndex[b.BrowserName()+"|"+b.UserDataDir()] selected = append(selected, v)
if !found { }
if candidates := vaultsByBrowser[b.BrowserName()]; len(candidates) == 1 { if filter != "" && len(selected) == 0 {
v = candidates[0] return nil, fmt.Errorf("no vault for browser %q in keys (have: %s)", filter, vaultKeys(dump))
log.Infof("apply-keys: %s using sole vault for browser (dump path %q != local %q)", }
b.BrowserName(), v.UserDataDir, b.UserDataDir())
found = true 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 <browser> 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 { kind, err := kindFromDump(v.Kind)
log.Warnf("apply-keys: %s no matching vault in dump", b.BrowserName()) if err != nil {
log.Warnf("restore: %s: %v", v.Browser, err)
continue continue
} }
km.SetRetrievers(masterkey.Retrievers{ cfg := types.BrowserConfig{
V10: maybeStaticRetriever(v.Keys.V10), Key: strings.ToLower(v.Browser),
V11: maybeStaticRetriever(v.Keys.V11), Name: v.Browser,
V20: maybeStaticRetriever(v.Keys.V20), 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 // 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) 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),
}
}
func kindToDump(k types.BrowserKind) (string, error) {
switch k {
case types.Chromium, types.ChromiumYandex, types.ChromiumOpera:
return k.String(), nil
default:
return "", fmt.Errorf("engine kind %s is not exportable", k)
}
}
// dumpableKinds are the engine kinds a vault may carry; kindFromDump reverses BrowserKind.String()
// over exactly these, keeping the wire vocabulary single-sourced in the types enum.
var dumpableKinds = []types.BrowserKind{types.Chromium, types.ChromiumYandex, types.ChromiumOpera}
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)
}
+170 -105
View File
@@ -3,7 +3,10 @@ package browser
import ( import (
"bytes" "bytes"
"errors" "errors"
"os"
"path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
"github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/masterkey"
@@ -44,6 +47,8 @@ func (m *mockBrowser) CountEntries(_ []types.Category) ([]types.CountResult, err
type mockChromiumBrowser struct { type mockChromiumBrowser struct {
mockBrowser mockBrowser
key string
kind types.BrowserKind
keys masterkey.MasterKeys keys masterkey.MasterKeys
exportErr error exportErr error
calls int calls int
@@ -59,6 +64,15 @@ func (m *mockChromiumBrowser) ExportKeys() (masterkey.MasterKeys, error) {
return m.keys, m.exportErr 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) { func TestBuildDump_Empty(t *testing.T) {
dump := BuildDump(nil) dump := BuildDump(nil)
if dump.Version != masterkey.DumpVersion { 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)) t.Fatalf("Vaults len = %d, want 1", len(dump.Vaults))
} }
inst := dump.Vaults[0] 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) 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 { if len(inst.Profiles) != 1 || inst.Profiles[0] != testProfileDefault {
t.Errorf("Profiles = %v", inst.Profiles) t.Errorf("Profiles = %v", inst.Profiles)
} }
@@ -129,8 +146,8 @@ func TestBuildDump_SkipsNonKeyManager(t *testing.T) {
if len(dump.Vaults) != 1 { if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (firefox skipped)", len(dump.Vaults)) t.Fatalf("Vaults len = %d, want 1 (firefox skipped)", len(dump.Vaults))
} }
if dump.Vaults[0].Browser != chromeName { if !strings.EqualFold(dump.Vaults[0].Browser, chromeName) {
t.Errorf("Browser = %q, want %q", 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 { if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (failing browser skipped)", len(dump.Vaults)) t.Fatalf("Vaults len = %d, want 1 (failing browser skipped)", len(dump.Vaults))
} }
if dump.Vaults[0].Browser != chromeName { if !strings.EqualFold(dump.Vaults[0].Browser, chromeName) {
t.Errorf("Browser = %q, want %q", 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 { if len(parsed.Vaults) != 1 {
t.Fatalf("Vaults len = %d", len(parsed.Vaults)) 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) { if !bytes.Equal(parsed.Vaults[0].Keys.V10, dump.Vaults[0].Keys.V10) {
t.Errorf("V10 round-trip mismatch") t.Errorf("V10 round-trip mismatch")
} }
@@ -209,123 +229,168 @@ func TestBuildDump_PartialKeys(t *testing.T) {
} }
} }
func TestApplyDump_Match(t *testing.T) { func TestKindDumpRoundTrip(t *testing.T) {
b := &mockChromiumBrowser{ for _, k := range []types.BrowserKind{types.Chromium, types.ChromiumYandex, types.ChromiumOpera} {
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, 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{ if _, err := kindToDump(types.Firefox); err == nil {
Vaults: []masterkey.Vault{ t.Error("kindToDump(Firefox) should error")
{Browser: chromeName, UserDataDir: testUDD, Keys: masterkey.MasterKeys{V10: []byte("v10-from-dump")}},
},
} }
ApplyDump([]Browser{b}, dump) if _, err := kindFromDump("nope"); err == nil {
t.Error("kindFromDump(nope) should error")
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)
} }
} }
func TestApplyDump_MissingVault(t *testing.T) { func TestRetrieversFromKeys(t *testing.T) {
b := &mockChromiumBrowser{ r := retrieversFromKeys(masterkey.MasterKeys{V10: []byte("k10"), V20: []byte("k20")})
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)
if b.receivedRetrievers.V10 != nil { if r.V10 == nil || r.V20 == nil {
t.Errorf("V10 should remain nil when no matching vault, got %v", b.receivedRetrievers.V10) 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) { // makeUserData writes a minimal Chromium profile tree: a Preferences marker plus History (a real
firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}} // extraction source, so the profile resolves) under each named profile dir.
dump := masterkey.Dump{ func makeUserData(t *testing.T, root string, profiles ...string) {
Vaults: []masterkey.Vault{ t.Helper()
{Browser: firefoxName, UserDataDir: "/ff", Keys: masterkey.MasterKeys{V10: []byte("v10")}}, for _, p := range profiles {
}, dir := filepath.Join(root, p)
} if err := os.MkdirAll(dir, 0o755); err != nil {
// firefox does not implement KeyManager; ApplyDump must not panic and must not attempt injection. t.Fatal(err)
ApplyDump([]Browser{firefox}, dump) }
} for _, f := range []string{"Preferences", "History"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
func TestApplyDump_RoundTrip(t *testing.T) { t.Fatal(err)
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)
} }
} }
func TestApplyDump_FallbackOnPathMismatch(t *testing.T) { func TestBuildFromDump_ConventionMultiBrowser(t *testing.T) {
// Cross-host scenario: dump was created on Windows but is applied on Linux/macOS where the dataDir := t.TempDir()
// UserDataDir literally differs. With a single vault for the browser, ApplyDump should still makeUserData(t, filepath.Join(dataDir, "chrome"), testProfileDefault)
// inject — otherwise the primary cross-host use case fails silently. makeUserData(t, filepath.Join(dataDir, "edge"), testProfileDefault)
b := &mockChromiumBrowser{ dump := masterkey.Dump{Vaults: []masterkey.Vault{
mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}}, {Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}},
} {Browser: "edge", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("e")}},
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)
if b.receivedRetrievers.V10 == nil { browsers, err := BuildFromDump(dump, dataDir, "")
t.Fatal("V10 retriever should be set via single-vault fallback") if err != nil {
t.Fatalf("BuildFromDump: %v", err)
} }
got, err := b.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{}) if len(browsers) != 2 {
if err != nil || string(got) != "v10-fallback" { t.Fatalf("got %d browsers, want 2", len(browsers))
t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-fallback")
} }
} }
func TestApplyDump_NoFallbackWhenAmbiguous(t *testing.T) { // TestBuildFromDump_ForeignKindNoPlatformTable proves restore never consults platformBrowsers():
// Two Chrome vaults in the dump and no exact path match — ApplyDump must not guess which // sogou is Windows-only yet reconstructs from its vault on any OS.
// installation the local browser corresponds to. func TestBuildFromDump_ForeignKindNoPlatformTable(t *testing.T) {
b := &mockChromiumBrowser{ dataDir := t.TempDir()
mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}}, makeUserData(t, filepath.Join(dataDir, "sogou"), testProfileDefault)
} dump := masterkey.Dump{Vaults: []masterkey.Vault{
dump := masterkey.Dump{ {Browser: "sogou", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("k")}},
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)
if b.receivedRetrievers.V10 != nil { browsers, err := BuildFromDump(dump, dataDir, "")
t.Errorf("V10 should remain nil when fallback is ambiguous, got %v", b.receivedRetrievers.V10) 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))
} }
} }
+55 -41
View File
@@ -4,39 +4,47 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/moond4rk/hackbrowserdata/browser" "github.com/moond4rk/hackbrowserdata/browser"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
) )
func restoreCmd() *cobra.Command { func restoreCmd() *cobra.Command {
var ( var (
keysPath string keysPath string
dataDir string
dataZip string
browserName string browserName string
category string category string
outputFormat string outputFormat string
outputDir string outputDir string
profilePath string
compress bool compress bool
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "restore", Use: "restore",
Short: "Decrypt a copied profile using exported master keys", Short: "Decrypt copied profile data using exported master keys",
Example: ` hack-browser-data restore -i keys.json -b chrome -p /path/to/copied/User\ Data Example: ` hack-browser-data restore --keys keys.json --data-zip data.zip
hack-browser-data restore -i keys.json -b edge -p /path -c cookie -f csv hack-browser-data restore --keys keys.json --data-dir ./data -b chrome -c cookie
ssh origin "hack-browser-data dumpkeys" | hack-browser-data restore -i - -b chrome -p /path`, 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 { 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 { if err != nil {
return err return err
} }
if len(browsers) == 0 { if len(browsers) == 0 {
log.Warnf("no browsers found") log.Warnf("no browsers to restore from the supplied keys and data")
return nil return nil
} }
categories, err := parseCategories(category) categories, err := parseCategories(category)
@@ -47,31 +55,27 @@ func restoreCmd() *cobra.Command {
}, },
} }
cmd.Flags().StringVarP(&keysPath, "input", "i", "", "input keys file (use - for stdin)") cmd.Flags().StringVar(&keysPath, "keys", "", "keys file from dumpkeys (use - for stdin)")
cmd.Flags().StringVarP(&browserName, "browser", "b", "", "target browser (single, required): "+browser.Names()) 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", "", "archive zip from `archive` (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(&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(&outputFormat, "format", "f", "json", "output format: csv|json|cookie-editor")
cmd.Flags().StringVarP(&outputDir, "dir", "d", "results", "output directory") 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.Flags().BoolVar(&compress, "zip", false, "compress output to zip")
_ = cmd.MarkFlagRequired("input") _ = cmd.MarkFlagRequired("keys")
_ = cmd.MarkFlagRequired("browser") cmd.MarkFlagsMutuallyExclusive("data-dir", "data-zip")
_ = cmd.MarkFlagRequired("profile-path")
return cmd return cmd
} }
func loadAndApplyKeys(browserName, profilePath, keysPath string) ([]browser.Browser, error) { func loadRestoreBrowsers(keysPath, dataDir, browserName string) ([]browser.Browser, error) {
if profilePath == "" {
return nil, fmt.Errorf("requires -p <copied-profile-dir>")
}
name := strings.ToLower(browserName)
if name == "" || name == "all" {
return nil, fmt.Errorf(`requires -b <browser> (single, not "all")`)
}
if keysPath == "" { if keysPath == "" {
return nil, fmt.Errorf("requires -i <keys-file> (or - for stdin)") return nil, fmt.Errorf("requires --keys <file> (or - for stdin)")
}
if dataDir == "" {
return nil, fmt.Errorf("requires --data-dir <dir>")
} }
var r io.Reader = os.Stdin var r io.Reader = os.Stdin
@@ -88,22 +92,32 @@ func loadAndApplyKeys(browserName, profilePath, keysPath string) ([]browser.Brow
return nil, fmt.Errorf("read keys file %q: %w", keysPath, err) return nil, fmt.Errorf("read keys file %q: %w", keysPath, err)
} }
browsers, err := browser.DiscoverBrowsers(browser.DiscoverOptions{ return browser.BuildFromDump(dump, dataDir, browserName)
Name: browserName, }
ProfilePath: profilePath,
}) // resolveDataDir returns the directory restore reads from: --data-dir as-is, or --data-zip extracted
if err != nil { // into a temp dir (removed by the returned cleanup). Exactly one of the two must be set.
return nil, err func resolveDataDir(dataDir, dataZip string) (string, func(), error) {
} noop := func() {}
if (dataDir == "") == (dataZip == "") {
browser.ApplyDump(browsers, dump) return "", noop, fmt.Errorf("exactly one of --data-dir or --data-zip is required")
}
for _, b := range browsers { if dataDir != "" {
if _, ok := b.(browser.KeychainPasswordReceiver); ok { return dataDir, noop, nil
log.Infof("Safari has no portable master key; run `dump -b safari` separately for full extraction") }
break tmp, err := os.MkdirTemp("", "hbd-restore-*")
} if err != nil {
} return "", noop, fmt.Errorf("create temp dir: %w", err)
}
return browsers, nil 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)
}
} }
+6 -3
View File
@@ -10,7 +10,7 @@ import (
"time" "time"
) )
const DumpVersion = "1" const DumpVersion = "2"
// Dump is the portable, cross-host container for Chromium master keys — produce it on one host to // 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. // 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). // 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 { type Vault struct {
Browser string `json:"browser"` Browser string `json:"browser"`
Kind string `json:"kind"`
UserDataDir string `json:"user_data_dir"` UserDataDir string `json:"user_data_dir"`
Profiles []string `json:"profiles"` Profiles []string `json:"profiles"`
Keys MasterKeys `json:"keys"` Keys MasterKeys `json:"keys"`
@@ -66,8 +69,8 @@ func (d Dump) WriteJSON(w io.Writer) error {
return nil return nil
} }
// ReadJSON parses a Dump and rejects versions this build can't interpret — a silent misparse of a // ReadJSON parses a Dump and rejects any version this build can't interpret — a silent misparse of an
// future v2 schema is worse than a clear error. // unrecognized schema is worse than a clear error.
func ReadJSON(r io.Reader) (Dump, error) { func ReadJSON(r io.Reader) (Dump, error) {
var d Dump var d Dump
dec := json.NewDecoder(r) dec := json.NewDecoder(r)
+29
View File
@@ -39,3 +39,32 @@ func TestReadJSON_AcceptsCurrentVersion(t *testing.T) {
t.Errorf("Version = %q, want %q", parsed.Version, DumpVersion) 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")
}
}
+19
View File
@@ -81,6 +81,25 @@ const (
Safari 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. // BrowserConfig holds the declarative configuration for a browser installation.
type BrowserConfig struct { type BrowserConfig struct {
Key string // lookup key; doubles as the Windows ABE / winutil.Table key when WindowsABE is true Key string // lookup key; doubles as the Windows ABE / winutil.Table key when WindowsABE is true
+17
View File
@@ -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) { func TestCategory_IsSensitive(t *testing.T) {
sensitive := []Category{Password, Cookie, CreditCard} sensitive := []Category{Password, Cookie, CreditCard}
for _, c := range sensitive { for _, c := range sensitive {