refactor(keys): extract master-key package to top-level keys/

Master-key acquisition and the cross-host dump format are a concern distinct from the raw crypto primitives, so crypto/keyretriever moves to an importable top-level keys/. KeyRetriever→Retriever drops the keys.KeyRetriever stutter.
This commit is contained in:
moonD4rk
2026-06-01 00:38:42 +08:00
parent b901f7dff0
commit c951d7ac16
43 changed files with 365 additions and 439 deletions
+2 -1
View File
@@ -164,6 +164,7 @@ linters:
- gosec - gosec
- errcheck - errcheck
- lll - lll
- goconst
- source: "defer" - source: "defer"
linters: linters:
- errcheck - errcheck
@@ -173,7 +174,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: "keys/gcoredump_darwin.go"
linters: linters:
- gocognit - gocognit
+3 -3
View File
@@ -9,7 +9,7 @@ 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/keys"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -118,8 +118,8 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
// KeyManager is implemented by installations that accept externally-provided master-key retrievers (Chromium family only). // KeyManager is implemented by installations that accept externally-provided master-key retrievers (Chromium family only).
type KeyManager interface { type KeyManager interface {
SetKeyRetrievers(keyretriever.Retrievers) SetRetrievers(keys.Retrievers)
ExportKeys() (keyretriever.MasterKeys, error) ExportKeys() (keys.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).
+4 -4
View File
@@ -9,7 +9,7 @@ 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/keys"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -160,7 +160,7 @@ func resolveKeychainPassword(flagPassword string) string {
func newCredentialInjector(opts PickOptions) browserInjector { func newCredentialInjector(opts PickOptions) browserInjector {
var ( var (
password string password string
retrievers keyretriever.Retrievers retrievers keys.Retrievers
resolved bool resolved bool
) )
return func(b Browser) { return func(b Browser) {
@@ -171,11 +171,11 @@ func newCredentialInjector(opts PickOptions) browserInjector {
} }
if !resolved { if !resolved {
password = resolveKeychainPassword(opts.KeychainPassword) password = resolveKeychainPassword(opts.KeychainPassword)
retrievers = keyretriever.DefaultRetrievers(password) retrievers = keys.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)
+3 -3
View File
@@ -3,7 +3,7 @@
package browser package browser
import ( import (
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/keys"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -73,10 +73,10 @@ func platformBrowsers() []types.BrowserConfig {
// only. Both V10 and V11 run independently so a profile carrying mixed cipher prefixes decrypts // only. Both V10 and V11 run independently so a profile carrying mixed cipher prefixes decrypts
// both tiers. // both tiers.
func newCredentialInjector(_ PickOptions) browserInjector { func newCredentialInjector(_ PickOptions) browserInjector {
retrievers := keyretriever.DefaultRetrievers() retrievers := keys.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)
} }
} }
} }
+3 -3
View File
@@ -3,7 +3,7 @@
package browser package browser
import ( import (
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/keys"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -130,10 +130,10 @@ func platformBrowsers() []types.BrowserConfig {
// Chrome profile upgraded from pre-127 carries v20 cookies alongside v10 passwords — so both // Chrome profile upgraded from pre-127 carries v20 cookies alongside v10 passwords — so both
// retrievers run independently rather than as a first-success chain. // retrievers run independently rather than as a first-success chain.
func newCredentialInjector(_ PickOptions) browserInjector { func newCredentialInjector(_ PickOptions) browserInjector {
retrievers := keyretriever.DefaultRetrievers() retrievers := keys.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,8 +6,8 @@ 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/keys"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"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 keys.Retrievers
profiles []*profile profiles []*profile
keysOnce sync.Once keysOnce sync.Once
keys keyretriever.MasterKeys keys keys.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 keys.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() (keys.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 keys.MasterKeys{}, err
} }
defer session.Cleanup() defer session.Cleanup()
return keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session)) return keys.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() keys.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) keys.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 keys.Hints{
KeychainLabel: b.cfg.KeychainLabel, KeychainLabel: b.cfg.KeychainLabel,
WindowsABEKey: abeKey, WindowsABEKey: abeKey,
LocalStatePath: localStateDst, LocalStatePath: localStateDst,
+24 -24
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/keys"
"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 keys.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 keys.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 keys.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 keys.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(keys.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 keys 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(keys.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(keys.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 keys.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(keys.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.pickFromConfigs 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(keys.Retrievers)
} = (*Browser)(nil) } = (*Browser)(nil)
} }
+10 -10
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/keys"
) )
// 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 keys.MasterKeys, ciphertext []byte) ([]byte, error) {
if len(ciphertext) == 0 { if len(ciphertext) == 0 {
return nil, nil return nil, nil
} }
@@ -26,18 +26,18 @@ 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 SecretPortalKeyRetriever (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
// to surface an actionable "known gap" error rather than the generic "unsupported" one. // to surface an actionable "known gap" error rather than the generic "unsupported" one.
return nil, fmt.Errorf("unsupported cipher version v12 (Chromium SecretPortal / Flatpak; not yet implemented)") return nil, fmt.Errorf("unsupported cipher version v12 (Chromium SecretPortal / Flatpak; not yet implemented)")
+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/keys"
) )
// 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(keys.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(keys.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(keys.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(keys.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/keys"
) )
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(keys.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(keys.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 := keys.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 := keys.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/keys"
) )
// 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(keys.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(keys.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/keys"
) )
// 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(keys.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(keys.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/keys"
"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 keys.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/keys"
) )
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(keys.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,7 +6,7 @@ import (
"errors" "errors"
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/keys"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"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 keys.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 keys.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/keys"
) )
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(keys.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(keys.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(keys.MasterKeys{V10: wrongKey}, path)
require.Error(t, err) require.Error(t, err)
} }
+7 -7
View File
@@ -6,7 +6,7 @@ import (
"sort" "sort"
"github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/keys"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"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 keys.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 keys.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 keys.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/keys"
) )
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(keys.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(keys.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(keys.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(keys.MasterKeys{V10: wrongKey}, path)
require.Error(t, err) require.Error(t, err)
} }
+8 -8
View File
@@ -3,8 +3,8 @@ 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/keys"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
"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 keys.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 keys.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/keys"
"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, keys.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, keys.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/keys"
"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 keys.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 keys.MasterKeys, path string) ([]types.LoginEntry, error)
} }
func (e passwordExtractor) extract(keys keyretriever.MasterKeys, path string, data *types.BrowserData) error { func (e passwordExtractor) extract(masterKeys keys.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(_ keys.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 keys.MasterKeys, path string) ([]types.CreditCardEntry, error)
} }
func (e creditCardExtractor) extract(keys keyretriever.MasterKeys, path string, data *types.BrowserData) error { func (e creditCardExtractor) extract(masterKeys keys.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/keys"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
) )
// 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) keys.Dump {
// discarding the vault — a Chrome 127+ profile mixes v10 + v20 ciphertexts and a dump := keys.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, keys.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 keys.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]*keys.Vault, len(dump.Vaults))
vaultsByBrowser := make(map[string][]*keyretriever.Vault) vaultsByBrowser := make(map[string][]*keys.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(keys.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) keys.Retriever {
if len(key) == 0 { if len(key) == 0 {
return nil return nil
} }
return keyretriever.NewStaticProvider(key) return keys.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/keys"
"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 keys.MasterKeys
exportErr error exportErr error
calls int calls int
receivedRetrievers keyretriever.Retrievers receivedRetrievers keys.Retrievers
} }
func (m *mockChromiumBrowser) SetKeyRetrievers(r keyretriever.Retrievers) { func (m *mockChromiumBrowser) SetRetrievers(r keys.Retrievers) {
m.receivedRetrievers = r m.receivedRetrievers = r
} }
func (m *mockChromiumBrowser) ExportKeys() (keyretriever.MasterKeys, error) { func (m *mockChromiumBrowser) ExportKeys() (keys.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 != keys.DumpVersion {
t.Errorf("Version = %q, want %q", dump.Version, keyretriever.DumpVersion) t.Errorf("Version = %q, want %q", dump.Version, keys.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: keys.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: keys.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: keys.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: keys.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: keys.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 := keys.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: keys.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 := keys.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keys.Vault{
{Browser: chromeName, UserDataDir: testUDD, Keys: keyretriever.MasterKeys{V10: []byte("v10-from-dump")}}, {Browser: chromeName, UserDataDir: testUDD, Keys: keys.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(keys.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 := keys.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keys.Vault{
{Browser: testEdgeName, UserDataDir: "/edge", Keys: keyretriever.MasterKeys{V10: []byte("v10")}}, {Browser: testEdgeName, UserDataDir: "/edge", Keys: keys.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 := keys.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keys.Vault{
{Browser: firefoxName, UserDataDir: "/ff", Keys: keyretriever.MasterKeys{V10: []byte("v10")}}, {Browser: firefoxName, UserDataDir: "/ff", Keys: keys.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: keys.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(keys.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(keys.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 := keys.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keys.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: keys.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(keys.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 := keys.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keys.Vault{
{Browser: chromeName, UserDataDir: "/path/a", Keys: keyretriever.MasterKeys{V10: []byte("a")}}, {Browser: chromeName, UserDataDir: "/path/a", Keys: keys.MasterKeys{V10: []byte("a")}},
{Browser: chromeName, UserDataDir: "/path/b", Keys: keyretriever.MasterKeys{V10: []byte("b")}}, {Browser: chromeName, UserDataDir: "/path/b", Keys: keys.MasterKeys{V10: []byte("b")}},
}, },
} }
ApplyDump([]Browser{b}, dump) ApplyDump([]Browser{b}, dump)
+1 -1
View File
@@ -47,7 +47,7 @@ func countPasswords(keychainPassword string) (int, error) {
// getInternetPasswords reads InternetPassword records directly from the // getInternetPasswords reads InternetPassword records directly from the
// macOS login keychain. See rfcs/006-key-retrieval-mechanisms.md §7 for why // macOS login keychain. See rfcs/006-key-retrieval-mechanisms.md §7 for why
// Safari owns this path instead of routing through crypto/keyretriever. // Safari owns this path instead of routing through the keys package.
// //
// TryUnlock is always invoked — with the user-supplied password when one is // TryUnlock is always invoked — with the user-supplied password when one is
// available, otherwise with no options — to enable keychainbreaker's partial // available, otherwise with no options — to enable keychainbreaker's partial
+2 -2
View File
@@ -9,7 +9,7 @@ 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/keys"
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
) )
@@ -135,7 +135,7 @@ 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 := keys.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)
} }
-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 keys
import ( import (
"encoding/base64" "encoding/base64"
+5 -8
View File
@@ -1,4 +1,4 @@
package keyretriever package keys
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 keys
import ( import (
"bytes" "bytes"
@@ -1,16 +1,10 @@
//go:build darwin //go:build darwin
package keyretriever package keys
// 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 keys
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 keys
import ( import (
"bytes" "bytes"
@@ -1,6 +1,6 @@
//go:build darwin || linux //go:build darwin || linux
package keyretriever package keys
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 keys 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 keys
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: gcoredump→password→security; Linux: D-Bus→peanuts).
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 keys
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 keys
import ( import (
"testing" "testing"
@@ -1,6 +1,6 @@
//go:build linux //go:build linux
package keyretriever package keys
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 keys
import ( import (
"testing" "testing"
@@ -1,4 +1,4 @@
package keyretriever package keys
import ( import (
"errors" "errors"
@@ -1,6 +1,6 @@
//go:build windows //go:build windows
package keyretriever package keys
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 keys
// 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
}