diff --git a/cmd/hack-browser-data/dumpkeys.go b/cmd/hack-browser-data/dumpkeys.go new file mode 100644 index 0000000..f9041fe --- /dev/null +++ b/cmd/hack-browser-data/dumpkeys.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/moond4rk/hackbrowserdata/browser" + "github.com/moond4rk/hackbrowserdata/log" +) + +func dumpKeysCmd() *cobra.Command { + var ( + browserName string + outputPath string + keychainPw string + ) + + cmd := &cobra.Command{ + Use: "dumpkeys", + Short: "Export Chromium master keys as JSON for cross-host decryption", + Example: ` hack-browser-data dumpkeys -o keys.json + hack-browser-data dumpkeys -b chrome`, + RunE: func(cmd *cobra.Command, args []string) error { + browsers, err := browser.DiscoverBrowsersWithKeys(browser.DiscoverOptions{ + Name: browserName, + KeychainPassword: keychainPw, + }) + if err != nil { + return err + } + + dump := browser.BuildDump(browsers) + log.Infof("Exported keys for %d vault(s)", len(dump.Vaults)) + + if outputPath == "" { + return dump.WriteJSON(os.Stdout) + } + f, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("create %s: %w", outputPath, err) + } + defer f.Close() + return dump.WriteJSON(f) + }, + } + + cmd.Flags().StringVarP(&browserName, "browser", "b", "all", "target browser: all|"+browser.Names()) + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output file (default: stdout)") + cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password") + + return cmd +} diff --git a/cmd/hack-browser-data/main.go b/cmd/hack-browser-data/main.go index 61a8acf..d8c9c5e 100644 --- a/cmd/hack-browser-data/main.go +++ b/cmd/hack-browser-data/main.go @@ -31,7 +31,7 @@ GitHub: https://github.com/moonD4rk/HackBrowserData`, root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable debug logging") dump := dumpCmd() - root.AddCommand(dump, listCmd(), keysCmd(), versionCmd()) + root.AddCommand(dump, dumpKeysCmd(), restoreCmd(), listCmd(), versionCmd()) // Default to dump when no subcommand is given. // Copy dump flags to root so that `hack-browser-data -b chrome` diff --git a/cmd/hack-browser-data/keys.go b/cmd/hack-browser-data/restore.go similarity index 60% rename from cmd/hack-browser-data/keys.go rename to cmd/hack-browser-data/restore.go index 8e346e2..788ca65 100644 --- a/cmd/hack-browser-data/keys.go +++ b/cmd/hack-browser-data/restore.go @@ -13,59 +13,7 @@ import ( "github.com/moond4rk/hackbrowserdata/masterkey" ) -func keysCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "keys", - Short: "Manage cross-host master keys", - } - cmd.AddCommand(keysExportCmd(), keysImportCmd()) - return cmd -} - -func keysExportCmd() *cobra.Command { - var ( - browserName string - outputPath string - keychainPw string - ) - - cmd := &cobra.Command{ - Use: "export", - Short: "Export Chromium master keys as JSON for cross-host decryption", - Example: ` hack-browser-data keys export -o dump.json - hack-browser-data keys export -b chrome`, - RunE: func(cmd *cobra.Command, args []string) error { - browsers, err := browser.DiscoverBrowsersWithKeys(browser.DiscoverOptions{ - Name: browserName, - KeychainPassword: keychainPw, - }) - if err != nil { - return err - } - - dump := browser.BuildDump(browsers) - log.Infof("Exported keys for %d vault(s)", len(dump.Vaults)) - - if outputPath == "" { - return dump.WriteJSON(os.Stdout) - } - f, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) - if err != nil { - return fmt.Errorf("create %s: %w", outputPath, err) - } - defer f.Close() - return dump.WriteJSON(f) - }, - } - - cmd.Flags().StringVarP(&browserName, "browser", "b", "all", "target browser: all|"+browser.Names()) - cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output file (default: stdout)") - cmd.Flags().StringVar(&keychainPw, "keychain-pw", "", "macOS keychain password") - - return cmd -} - -func keysImportCmd() *cobra.Command { +func restoreCmd() *cobra.Command { var ( keysPath string browserName string @@ -77,11 +25,11 @@ func keysImportCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "import", - Short: "Import master keys from JSON and decrypt a copied profile", - Example: ` hack-browser-data keys import -i dump.json -b chrome -p /path/to/copied/User\ Data - hack-browser-data keys import -i dump.json -b edge -p /path -c cookie -f csv - ssh origin "hack-browser-data keys export" | hack-browser-data keys import -i - -b chrome -p /path`, + 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`, RunE: func(cmd *cobra.Command, args []string) error { browsers, err := loadAndApplyKeys(browserName, profilePath, keysPath) if err != nil { diff --git a/rfcs/013-cli-redesign-cross-host.md b/rfcs/013-cli-redesign-cross-host.md new file mode 100644 index 0000000..d8c7000 --- /dev/null +++ b/rfcs/013-cli-redesign-cross-host.md @@ -0,0 +1,121 @@ +# RFC-013: CLI Redesign — Flat-Verb Surface & Cross-Host Restore + +**Author**: moonD4rk +**Status**: Accepted — implementation pending +**Created**: 2026-06-03 + +## 1. Summary + +The command-line surface has accreted two grammars: flat task-verbs (`dump`, `list`) alongside a noun-grouped `keys export` / `keys import` family. This RFC redesigns the whole surface around one grammar — flat verbs — and specifies the cross-host workflow end to end: export master keys on the origin host, archive the minimal data files, and restore (decrypt) them offline on an analyst host of any platform. It also records the structural problem the redesign exists to solve: the set of browsers a command can act on is platform-specific, yet cross-host restore must work for browsers that were never built for the analyst's OS. Breaking changes are accepted (pre-1.0); the method is CLI-first, deriving the internal data model backwards from the chosen surface. + +## 2. Motivation + +### 2.1 Two grammars in one tool + +`dump` and `list` are flat task-verbs — the verb *is* the action. `keys export` / `keys import` is a noun-then-action grammar that implies a `keys` resource with sub-operations. Mixing the two in one small CLI reads as inconsistent: there is no `data export` or `browser list` to make the noun grammar systematic, so `keys` stands alone as a special case. The aim is a tool like kubectl — predictable because it is uniformly `verb resource` — but without kubectl's complexity. For a tool this size, the matching "simple and consistent" choice is uniformly flat verbs. + +### 2.2 The cross-host workflow is half-automated + +Cross-host decryption (export keys on the origin, decrypt a copied profile on the analyst host) shipped in #599–#605, but only the *keys* half is automated. The analyst still copies the origin's profile data by hand — awkward because the live SQLite files are locked on Windows, the full `User Data` tree is huge (caches), and only a handful of small files actually matter for decryption (#607). + +### 2.3 Restore is bound to the local platform browser table (#606) + +The consumer side reuses `DiscoverBrowsers`, which iterates `platformBrowsers()` — the browser table for the *analyst's* OS, selected by build tag (`browser_{darwin,windows,linux}.go`). A Windows-only fork such as Sogou / QQ / 360 lives only in the Windows table. On macOS, `restore -b sogou` matches nothing and aborts with `no browsers found`, even with a valid `keys.json` and the data supplied explicitly. This is the crux: the browsers a command may act on are platform-specific, but cross-host restore must transcend that — given a key and the data, it should decrypt any Chromium profile regardless of whether that browser exists on the analyst platform. + +## 3. Proposed CLI surface + +Six flat verbs, one grammar: + +``` +hack-browser-data [flags] # default → dump (also Windows double-click) + dump -b -c -f -d -p --zip # local: decrypt this host's browsers → data + dumpkeys -b -o [--keychain-pw] # origin: master keys → keys.json (stdout default) + archive -b -c -o # origin: minimal decryption-relevant files → zip + restore --keys K (--data-dir D | --data-zip Z) [-b] -c -f -d # analyst: keys.json + data → decrypted + list [--detail] + version +``` + +Workflows: + +``` +local : hbd dump -b chrome -c cookie,password +cross-host: origin> hbd dumpkeys -o keys.json + origin> hbd archive -b chrome -o data.zip + analyst> hbd restore --keys keys.json --data-zip data.zip -c cookie +``` + +The `keys` parent command is removed: `keys export` becomes `dumpkeys`, `keys import` becomes `restore`, and a new `archive` fills the missing data-transport step. `dump` / `list` / `version` keep their current behavior; `dump` stays the default when no subcommand is given (which also covers the Windows double-click case). + +## 4. The browser-universe model + +The resolution to §2.3 is a single rule: **the set of browsers a command may act on — its "universe" — matches the nature of that command.** + +| Command | Browser universe | `-b sogou` on macOS | +|---------|------------------|---------------------| +| `dump` / `dumpkeys` / `archive` / `list` | the local `platformBrowsers()` table (what this OS installs) | correctly fails — Sogou is not on macOS | +| `restore` | the `keys.json` itself (whatever the origin exported) | succeeds — the dump contains a Sogou vault | + +`dump`, `dumpkeys`, `archive`, and `list` act on browsers *installed on this host*, so the platform table is the right source and `-b`'s vocabulary is the local set. `restore` acts on *transported artifacts that may have come from any platform*, so its universe is the `keys.json`, and `-b` validates against the dump's vaults, not the local table. Stated plainly: **the browsers you can restore are exactly the browsers in your `keys.json`.** This turns the platform difference from a bug into a property, and it is what makes #606 dissolve rather than be patched. + +## 5. Cross-host artifacts and the restore command + +The cross-host producer emits two independent, composable artifacts; the consumer takes both. + +- `dumpkeys` writes `keys.json` — the portable master keys (stdout by default for `ssh origin hbd dumpkeys | …` pipelines; `-o` for a 0600 file). +- `archive` writes `data.zip` — only the decryption-relevant files for the requested `-c` categories (`Login Data`, `Cookies`, `Web Data`, `History`, … plus `Local State` and `Preferences`), read through the existing locked-file bypass, preserving the relative `User Data` layout so the zip's internal root *is* the `User Data` dir. +- `restore` takes `--keys keys.json` and the data via two explicit flags, `--data-dir