mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-07-04 21:37:47 +02:00
* 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. * fix(restore): polish help text, drop dead check, dedup dump kinds pflag treats backticked words in flag usage as the value placeholder, so --data-zip rendered as "--data-zip archive" in help output.
This commit is contained in:
@@ -94,6 +94,7 @@ linters:
|
|||||||
- "chrome"
|
- "chrome"
|
||||||
- "Chrome"
|
- "Chrome"
|
||||||
- "firefox"
|
- "firefox"
|
||||||
|
- "chromium"
|
||||||
gocritic:
|
gocritic:
|
||||||
enabled-tags:
|
enabled-tags:
|
||||||
- diagnostic
|
- diagnostic
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpableKinds are the engine kinds a vault may carry; kindToDump/kindFromDump translate to and from
|
||||||
|
// the wire form via BrowserKind.String(), keeping the vocabulary single-sourced in the types enum.
|
||||||
|
var dumpableKinds = []types.BrowserKind{types.Chromium, types.ChromiumYandex, types.ChromiumOpera}
|
||||||
|
|
||||||
|
func kindToDump(k types.BrowserKind) (string, error) {
|
||||||
|
for _, dk := range dumpableKinds {
|
||||||
|
if k == dk {
|
||||||
|
return k.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("engine kind %s is not exportable", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 @@ 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", "", "zip produced by the archive command (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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
var r io.Reader = os.Stdin
|
var r io.Reader = os.Stdin
|
||||||
@@ -88,22 +89,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
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user