feat(restore): cross-platform restore via dump engine rebuild (#606)

Restore previously required the dump's origin OS, overlaying keys onto locally-discovered browsers. It now rebuilds Chromium engines from the dump's vaults (v2 adds engine kind), so copied data or an archive zip decrypts on any OS.
This commit is contained in:
moonD4rk
2026-06-07 22:03:46 +08:00
parent cd0b2daaf3
commit 6d0efadb59
11 changed files with 496 additions and 189 deletions
+55 -41
View File
@@ -4,39 +4,47 @@ import (
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/moond4rk/hackbrowserdata/browser"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
)
func restoreCmd() *cobra.Command {
var (
keysPath string
dataDir string
dataZip string
browserName string
category string
outputFormat string
outputDir string
profilePath string
compress bool
)
cmd := &cobra.Command{
Use: "restore",
Short: "Decrypt a copied profile using exported master keys",
Example: ` hack-browser-data restore -i keys.json -b chrome -p /path/to/copied/User\ Data
hack-browser-data restore -i keys.json -b edge -p /path -c cookie -f csv
ssh origin "hack-browser-data dumpkeys" | hack-browser-data restore -i - -b chrome -p /path`,
Short: "Decrypt copied profile data using exported master keys",
Example: ` hack-browser-data restore --keys keys.json --data-zip data.zip
hack-browser-data restore --keys keys.json --data-dir ./data -b chrome -c cookie
hack-browser-data restore --keys keys.json --data-dir ./chrome-userdata -b chrome
ssh origin "hack-browser-data dumpkeys" | hack-browser-data restore --keys - --data-zip data.zip`,
RunE: func(cmd *cobra.Command, args []string) error {
browsers, err := loadAndApplyKeys(browserName, profilePath, keysPath)
resolvedDir, cleanup, err := resolveDataDir(dataDir, dataZip)
if err != nil {
return err
}
defer cleanup()
browsers, err := loadRestoreBrowsers(keysPath, resolvedDir, browserName)
if err != nil {
return err
}
if len(browsers) == 0 {
log.Warnf("no browsers found")
log.Warnf("no browsers to restore from the supplied keys and data")
return nil
}
categories, err := parseCategories(category)
@@ -47,31 +55,27 @@ func restoreCmd() *cobra.Command {
},
}
cmd.Flags().StringVarP(&keysPath, "input", "i", "", "input keys file (use - for stdin)")
cmd.Flags().StringVarP(&browserName, "browser", "b", "", "target browser (single, required): "+browser.Names())
cmd.Flags().StringVar(&keysPath, "keys", "", "keys file from dumpkeys (use - for stdin)")
cmd.Flags().StringVar(&dataDir, "data-dir", "", "copied profile data dir (archive layout, or one browser's User Data with -b)")
cmd.Flags().StringVar(&dataZip, "data-zip", "", "archive zip from `archive` (alternative to --data-dir)")
cmd.Flags().StringVarP(&browserName, "browser", "b", "", "restore only this browser (optional; must match a vault in --keys)")
cmd.Flags().StringVarP(&category, "category", "c", "all", "data categories (comma-separated): all|"+categoryNames())
cmd.Flags().StringVarP(&outputFormat, "format", "f", "json", "output format: csv|json|cookie-editor")
cmd.Flags().StringVarP(&outputDir, "dir", "d", "results", "output directory")
cmd.Flags().StringVarP(&profilePath, "profile-path", "p", "", "copied profile dir path (required)")
cmd.Flags().BoolVar(&compress, "zip", false, "compress output to zip")
_ = cmd.MarkFlagRequired("input")
_ = cmd.MarkFlagRequired("browser")
_ = cmd.MarkFlagRequired("profile-path")
_ = cmd.MarkFlagRequired("keys")
cmd.MarkFlagsMutuallyExclusive("data-dir", "data-zip")
return cmd
}
func loadAndApplyKeys(browserName, profilePath, keysPath string) ([]browser.Browser, error) {
if profilePath == "" {
return nil, fmt.Errorf("requires -p <copied-profile-dir>")
}
name := strings.ToLower(browserName)
if name == "" || name == "all" {
return nil, fmt.Errorf(`requires -b <browser> (single, not "all")`)
}
func loadRestoreBrowsers(keysPath, dataDir, browserName string) ([]browser.Browser, error) {
if keysPath == "" {
return nil, fmt.Errorf("requires -i <keys-file> (or - for stdin)")
return nil, fmt.Errorf("requires --keys <file> (or - for stdin)")
}
if dataDir == "" {
return nil, fmt.Errorf("requires --data-dir <dir>")
}
var r io.Reader = os.Stdin
@@ -88,22 +92,32 @@ func loadAndApplyKeys(browserName, profilePath, keysPath string) ([]browser.Brow
return nil, fmt.Errorf("read keys file %q: %w", keysPath, err)
}
browsers, err := browser.DiscoverBrowsers(browser.DiscoverOptions{
Name: browserName,
ProfilePath: profilePath,
})
if err != nil {
return nil, err
}
browser.ApplyDump(browsers, dump)
for _, b := range browsers {
if _, ok := b.(browser.KeychainPasswordReceiver); ok {
log.Infof("Safari has no portable master key; run `dump -b safari` separately for full extraction")
break
}
}
return browsers, nil
return browser.BuildFromDump(dump, dataDir, browserName)
}
// resolveDataDir returns the directory restore reads from: --data-dir as-is, or --data-zip extracted
// into a temp dir (removed by the returned cleanup). Exactly one of the two must be set.
func resolveDataDir(dataDir, dataZip string) (string, func(), error) {
noop := func() {}
if (dataDir == "") == (dataZip == "") {
return "", noop, fmt.Errorf("exactly one of --data-dir or --data-zip is required")
}
if dataDir != "" {
return dataDir, noop, nil
}
tmp, err := os.MkdirTemp("", "hbd-restore-*")
if err != nil {
return "", noop, fmt.Errorf("create temp dir: %w", err)
}
if err := fileutil.Unzip(dataZip, tmp); err != nil {
removeTempDir(tmp)
return "", noop, fmt.Errorf("extract %s: %w", dataZip, err)
}
return tmp, func() { removeTempDir(tmp) }, nil
}
func removeTempDir(dir string) {
if err := os.RemoveAll(dir); err != nil {
log.Warnf("restore: remove temp dir %s: %v", dir, err)
}
}