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.
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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,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
@@ -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