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"
- "firefox"
- "chromium"
gocritic:
enabled-tags:
- 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).
// BrowserKey/Kind expose the identity a portable dump needs to rebuild the engine off the platform table.
type KeyManager interface {
SetRetrievers(masterkey.Retrievers)
ExportKeys() (masterkey.MasterKeys, error)
BrowserKey() string
Kind() types.BrowserKind
}
// 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.
func (b *Browser) SetRetrievers(r masterkey.Retrievers) { b.retrievers = r }
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) BrowserKey() string { return b.cfg.Key }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) BrowserKey() string { return b.cfg.Key }
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.
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.
// Opera stores data alongside Local State in userDataDir itself, so check
// for any known source file instead of Preferences.
// Flat layout (older Opera): data files directly under userDataDir with no profile subdir. Check the
// root before the subdir fallback so a stray source-bearing subdir can't suppress root discovery.
if len(profiles) == 0 && hasAnySource(sources, 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
}
+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
// ---------------------------------------------------------------------------
+132 -34
View File
@@ -1,10 +1,14 @@
package browser
import (
"runtime"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types"
)
// 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() {
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{
Browser: b.BrowserName(),
Browser: km.BrowserKey(),
Kind: kind,
UserDataDir: b.UserDataDir(),
Profiles: profileNames(b),
Keys: mk,
@@ -47,46 +57,102 @@ func profileNames(b Browser) []string {
return names
}
// ApplyDump overlays StaticRetrievers from dump onto matching installations (Firefox/Safari skipped).
// Match is by (BrowserName, UserDataDir); on miss — commonly a cross-host path mismatch (Windows vs
// POSIX, or a relocated dir via -p) — it falls back to the sole vault for that browser name. No match
// → warn and leave the platform retrievers in place.
func ApplyDump(browsers []Browser, dump masterkey.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)
// BuildFromDump reconstructs Chromium engines straight from a dump's vaults, rooted at copied data
// instead of the local platform table — this is what lets an analyst host decrypt a browser its OS
// never installs. filter is a browser key ("" or "all" = every vault); a filter matching no vault is
// an error rather than silent empty output.
//
// Data layout is resolved two ways. When dataDir holds per-key subdirs (the archive layout), each
// vault is rooted at dataDir/<key>. Otherwise dataDir is treated as one browser's User Data (a
// 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)
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 {
var selected []masterkey.Vault
for _, v := range dump.Vaults {
if filter != "" && !strings.EqualFold(v.Browser, filter) {
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 using sole vault for browser (dump path %q != local %q)",
b.BrowserName(), v.UserDataDir, b.UserDataDir())
found = true
selected = append(selected, v)
}
if filter != "" && len(selected) == 0 {
return nil, fmt.Errorf("no vault for browser %q in keys (have: %s)", filter, vaultKeys(dump))
}
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 {
log.Warnf("apply-keys: %s no matching vault in dump", b.BrowserName())
kind, err := kindFromDump(v.Kind)
if err != nil {
log.Warnf("restore: %s: %v", v.Browser, err)
continue
}
km.SetRetrievers(masterkey.Retrievers{
V10: maybeStaticRetriever(v.Keys.V10),
V11: maybeStaticRetriever(v.Keys.V11),
V20: maybeStaticRetriever(v.Keys.V20),
})
cfg := types.BrowserConfig{
Key: strings.ToLower(v.Browser),
Name: v.Browser,
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
@@ -97,3 +163,35 @@ func maybeStaticRetriever(key []byte) masterkey.Retriever {
}
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 (
"bytes"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/moond4rk/hackbrowserdata/masterkey"
@@ -44,6 +47,8 @@ func (m *mockBrowser) CountEntries(_ []types.Category) ([]types.CountResult, err
type mockChromiumBrowser struct {
mockBrowser
key string
kind types.BrowserKind
keys masterkey.MasterKeys
exportErr error
calls int
@@ -59,6 +64,15 @@ func (m *mockChromiumBrowser) ExportKeys() (masterkey.MasterKeys, error) {
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) {
dump := BuildDump(nil)
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))
}
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)
}
if inst.Kind != "chromium" {
t.Errorf("Kind = %q, want chromium", inst.Kind)
}
if len(inst.Profiles) != 1 || inst.Profiles[0] != testProfileDefault {
t.Errorf("Profiles = %v", inst.Profiles)
}
@@ -129,8 +146,8 @@ func TestBuildDump_SkipsNonKeyManager(t *testing.T) {
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (firefox skipped)", len(dump.Vaults))
}
if dump.Vaults[0].Browser != chromeName {
t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, chromeName)
if !strings.EqualFold(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 {
t.Fatalf("Vaults len = %d, want 1 (failing browser skipped)", len(dump.Vaults))
}
if dump.Vaults[0].Browser != chromeName {
t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, chromeName)
if !strings.EqualFold(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 {
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) {
t.Errorf("V10 round-trip mismatch")
}
@@ -209,123 +229,168 @@ func TestBuildDump_PartialKeys(t *testing.T) {
}
}
func TestApplyDump_Match(t *testing.T) {
b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
func TestKindDumpRoundTrip(t *testing.T) {
for _, k := range []types.BrowserKind{types.Chromium, types.ChromiumYandex, types.ChromiumOpera} {
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{
Vaults: []masterkey.Vault{
{Browser: chromeName, UserDataDir: testUDD, Keys: masterkey.MasterKeys{V10: []byte("v10-from-dump")}},
},
if _, err := kindToDump(types.Firefox); err == nil {
t.Error("kindToDump(Firefox) should error")
}
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(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)
if _, err := kindFromDump("nope"); err == nil {
t.Error("kindFromDump(nope) should error")
}
}
func TestApplyDump_MissingVault(t *testing.T) {
b := &mockChromiumBrowser{
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)
func TestRetrieversFromKeys(t *testing.T) {
r := retrieversFromKeys(masterkey.MasterKeys{V10: []byte("k10"), V20: []byte("k20")})
if b.receivedRetrievers.V10 != nil {
t.Errorf("V10 should remain nil when no matching vault, got %v", b.receivedRetrievers.V10)
if r.V10 == nil || r.V20 == nil {
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) {
firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}}
dump := masterkey.Dump{
Vaults: []masterkey.Vault{
{Browser: firefoxName, UserDataDir: "/ff", Keys: masterkey.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, 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)
// makeUserData writes a minimal Chromium profile tree: a Preferences marker plus History (a real
// extraction source, so the profile resolves) under each named profile dir.
func makeUserData(t *testing.T, root string, profiles ...string) {
t.Helper()
for _, p := range profiles {
dir := filepath.Join(root, p)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, f := range []string{"Preferences", "History"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
}
}
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, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}},
}
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)
func TestBuildFromDump_ConventionMultiBrowser(t *testing.T) {
dataDir := t.TempDir()
makeUserData(t, filepath.Join(dataDir, "chrome"), testProfileDefault)
makeUserData(t, filepath.Join(dataDir, "edge"), 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 b.receivedRetrievers.V10 == nil {
t.Fatal("V10 retriever should be set via single-vault fallback")
browsers, err := BuildFromDump(dump, dataDir, "")
if err != nil {
t.Fatalf("BuildFromDump: %v", err)
}
got, err := b.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{})
if err != nil || string(got) != "v10-fallback" {
t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-fallback")
if len(browsers) != 2 {
t.Fatalf("got %d browsers, want 2", len(browsers))
}
}
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, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}},
}
dump := masterkey.Dump{
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)
// TestBuildFromDump_ForeignKindNoPlatformTable proves restore never consults platformBrowsers():
// sogou is Windows-only yet reconstructs from its vault on any OS.
func TestBuildFromDump_ForeignKindNoPlatformTable(t *testing.T) {
dataDir := t.TempDir()
makeUserData(t, filepath.Join(dataDir, "sogou"), testProfileDefault)
dump := masterkey.Dump{Vaults: []masterkey.Vault{
{Browser: "sogou", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("k")}},
}}
if b.receivedRetrievers.V10 != nil {
t.Errorf("V10 should remain nil when fallback is ambiguous, got %v", b.receivedRetrievers.V10)
browsers, err := BuildFromDump(dump, dataDir, "")
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"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/moond4rk/hackbrowserdata/browser"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
)
func restoreCmd() *cobra.Command {
var (
keysPath string
dataDir string
dataZip string
browserName string
category string
outputFormat string
outputDir string
profilePath string
compress bool
)
cmd := &cobra.Command{
Use: "restore",
Short: "Decrypt a copied profile using exported master keys",
Example: ` hack-browser-data restore -i keys.json -b chrome -p /path/to/copied/User\ Data
hack-browser-data restore -i keys.json -b edge -p /path -c cookie -f csv
ssh origin "hack-browser-data dumpkeys" | hack-browser-data restore -i - -b chrome -p /path`,
Short: "Decrypt copied profile data using exported master keys",
Example: ` hack-browser-data restore --keys keys.json --data-zip data.zip
hack-browser-data restore --keys keys.json --data-dir ./data -b chrome -c cookie
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 {
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 {
return err
}
if len(browsers) == 0 {
log.Warnf("no browsers found")
log.Warnf("no browsers to restore from the supplied keys and data")
return nil
}
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().StringVarP(&browserName, "browser", "b", "", "target browser (single, required): "+browser.Names())
cmd.Flags().StringVar(&keysPath, "keys", "", "keys file from dumpkeys (use - for stdin)")
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(&outputFormat, "format", "f", "json", "output format: csv|json|cookie-editor")
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.MarkFlagRequired("input")
_ = cmd.MarkFlagRequired("browser")
_ = cmd.MarkFlagRequired("profile-path")
_ = cmd.MarkFlagRequired("keys")
cmd.MarkFlagsMutuallyExclusive("data-dir", "data-zip")
return cmd
}
func loadAndApplyKeys(browserName, profilePath, keysPath 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")`)
}
func loadRestoreBrowsers(keysPath, dataDir, browserName string) ([]browser.Browser, error) {
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
@@ -88,22 +92,32 @@ func loadAndApplyKeys(browserName, profilePath, keysPath string) ([]browser.Brow
return nil, fmt.Errorf("read keys file %q: %w", keysPath, err)
}
browsers, err := browser.DiscoverBrowsers(browser.DiscoverOptions{
Name: browserName,
ProfilePath: profilePath,
})
if err != nil {
return nil, err
}
browser.ApplyDump(browsers, dump)
for _, b := range browsers {
if _, ok := b.(browser.KeychainPasswordReceiver); ok {
log.Infof("Safari has no portable master key; run `dump -b safari` separately for full extraction")
break
}
}
return browsers, nil
return browser.BuildFromDump(dump, dataDir, browserName)
}
// resolveDataDir returns the directory restore reads from: --data-dir as-is, or --data-zip extracted
// into a temp dir (removed by the returned cleanup). Exactly one of the two must be set.
func resolveDataDir(dataDir, dataZip string) (string, func(), error) {
noop := func() {}
if (dataDir == "") == (dataZip == "") {
return "", noop, fmt.Errorf("exactly one of --data-dir or --data-zip is required")
}
if dataDir != "" {
return dataDir, noop, nil
}
tmp, err := os.MkdirTemp("", "hbd-restore-*")
if err != nil {
return "", noop, fmt.Errorf("create temp dir: %w", err)
}
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"
)
const DumpVersion = "1"
const DumpVersion = "2"
// 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.
@@ -30,8 +30,11 @@ type Host struct {
}
// 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 {
Browser string `json:"browser"`
Kind string `json:"kind"`
UserDataDir string `json:"user_data_dir"`
Profiles []string `json:"profiles"`
Keys MasterKeys `json:"keys"`
@@ -66,8 +69,8 @@ func (d Dump) WriteJSON(w io.Writer) error {
return nil
}
// ReadJSON parses a Dump and rejects versions this build can't interpret — a silent misparse of a
// future v2 schema is worse than a clear error.
// ReadJSON parses a Dump and rejects any version this build can't interpret — a silent misparse of an
// unrecognized schema is worse than a clear error.
func ReadJSON(r io.Reader) (Dump, error) {
var d Dump
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)
}
}
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
)
// 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.
type BrowserConfig struct {
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) {
sensitive := []Category{Password, Cookie, CreditCard}
for _, c := range sensitive {