feat: add Safari password extraction from macOS Keychain (#568)

This commit is contained in:
Roger
2026-04-13 21:34:40 +08:00
committed by GitHub
parent d105a1f488
commit 370c5882c4
18 changed files with 493 additions and 132 deletions
+37 -19
View File
@@ -65,43 +65,61 @@ Each category has a corresponding Entry struct with `json` and `csv` struct tags
### 4.1 BrowserKind
Four engine kinds determine source paths and extractors:
Each config declares an engine kind that determines source paths and extraction logic. Kinds fall into three engine families:
| Kind | Description |
|------|-------------|
| `Chromium` | Standard Chromium layout |
| `ChromiumYandex` | Yandex variant: different file names and SQL queries |
| `ChromiumOpera` | Opera variant: different extension key, Roaming path on Windows |
| `Firefox` | Firefox: NSS encryption, SQLite + JSON files |
- **Chromium** (`Chromium`, `ChromiumYandex`, `ChromiumOpera`) — the standard Chromium layout plus two variants that override file names or storage paths for Yandex and Opera forks. See RFC-003.
- **Firefox** — NSS-based key derivation from `key4.db`, SQLite + JSON source files. See RFC-005.
- **Safari** — macOS only, with direct Keychain-based credential extraction. See RFC-006 §7.
See `types/category.go` for the authoritative enum definition.
### 4.2 BrowserConfig
`BrowserConfig` is the declarative, platform-specific browser definition containing: Key (CLI matching), Name (display), Kind (engine), Storage (keychain label), UserDataDir (data path).
### 4.3 PickBrowsers() Flow
### 4.3 Browser Selection Flow
There are two entry points, one for extraction and one for discovery:
```
PickBrowsers(opts)
→ platformBrowsers() // build-tagged: returns []BrowserConfig for this OS
→ pickFromConfigs(configs, opts) // filter by name, apply profile-path/keychain overrides
newBrowsers(cfg) // dispatch by Kind to chromium.NewBrowsers or firefox.NewBrowsers
→ discoverProfiles() // scan for profile subdirectories
resolveSourcePaths() // stat each candidate path, first match wins
PickBrowsers(opts) // used by `dump` — ready to Extract
→ pickFromConfigs(configs, opts) // shared discovery core
→ platformBrowsers() // build-tagged list for this OS
filter by name / profile path
→ newBrowsers(cfg) // dispatch to chromium/firefox/safari.NewBrowsers
discoverProfiles() // scan profile subdirectories
→ resolveSourcePaths() // stat candidates, first match wins
→ newPlatformInjector(opts) // build-tagged: returns a func(Browser)
→ for each browser: // closure captures retriever + keychain pw lazily
inject(b) // type-assert retrieverSetter / keychainPasswordSetter
DiscoverBrowsers(opts) // used by `list` / `list --detail`
→ pickFromConfigs(configs, opts) // same shared discovery core, NO injection
```
`PickBrowsers` does discovery + decryption setup in one call; the returned
browsers are ready for `b.Extract`. `DiscoverBrowsers` skips injection
entirely, so list-style commands never trigger the macOS Keychain password
prompt — they have no use for the credential. Both entry points share the
same `pickFromConfigs` core, so filtering/profile-path/glob semantics stay
consistent.
Key design decisions:
- **One KeyRetriever per browser** — created once and shared across all profiles to prevent repeated keychain prompts on macOS.
- **One KeyRetriever chain per process** — built lazily inside `newPlatformInjector` and reused across every Chromium browser and every profile to prevent repeated keychain prompts on macOS.
- **Discovery is decoupled from injection** — `pickFromConfigs` is injection-free; `DiscoverBrowsers` stops after it, `PickBrowsers` continues into injection.
- **Profile discovery differs by engine**: Chromium looks for `Preferences` files in subdirectories; Firefox accepts any subdirectory containing known source files.
- **Flat layout fallback** — Opera-style browsers that store data directly in UserDataDir (no profile subdirectories) are handled by falling back to the base directory.
### 4.4 Platform Browser Lists
Browser configs are defined per-platform via build tags:
Browser configs are defined per-platform via build tags in `platformBrowsers()` (`browser/browser_{darwin,linux,windows}.go`). The supported set groups by engine family:
- **macOS** — 12 browsers (Chrome, Edge, Chromium, Chrome Beta, Opera, OperaGX, Vivaldi, CocCoc, Brave, Yandex, Arc, Firefox)
- **Windows** — 16 browsers (all macOS minus Arc, plus 360 Speed, 360 Speed X, QQ, DC, Sogou)
- **Linux** — 8 browsers (Chrome, Edge, Chromium, Chrome Beta, Opera, Vivaldi, Brave, Firefox)
- **Chromium-based** — the largest family, covering mainstream browsers (Chrome, Edge, Brave, Vivaldi, Opera, Chromium) across all three platforms plus regional variants and forks. Windows carries the longest list because of China-region Chromium forks (360, QQ, Sogou, DC, …) and MSIX-packaged browsers with dynamic install paths (Arc, DuckDuckGo).
- **Firefox** — all three platforms, via internal NSS key derivation (RFC-005).
- **Safari** — macOS only, via direct Keychain `InternetPassword` extraction (RFC-006 §7).
Adding a new browser is a config-only change in `platformBrowsers()`; this section does not need updates for new variants within an existing family.
## 5. Extract() Orchestration
+52 -17
View File
@@ -29,7 +29,7 @@ The return value is the **ready-to-use decryption key** — either the raw AES k
`ChainRetriever` wraps multiple retrievers and tries them in order. The first successful result wins. If all fail, errors from every retriever are combined into a single error.
**Caching**: the retriever is created once per browser and shared across all profiles. macOS retrievers use `sync.Once` internally, so multi-profile browsers only trigger one keychain prompt or memory dump.
**Caching**: the retriever chain is created once per process inside `newPlatformInjector` (see `browser/browser_{darwin,linux,windows}.go`) and shared across every Chromium browser and every profile. macOS retrievers additionally use `sync.Once` internally, so multi-profile browsers only trigger one keychain prompt or memory dump.
## 3. macOS Key Retrieval
@@ -69,17 +69,9 @@ All macOS strategies produce a raw password string from the keychain. This is de
### 3.4 Storage Labels
| Browser | Keychain Account |
|---------|-----------------|
| Chrome / Chrome Beta | `"Chrome"` |
| Edge | `"Microsoft Edge"` |
| Chromium | `"Chromium"` |
| Opera / OperaGX | `"Opera"` |
| Vivaldi | `"Vivaldi"` |
| Brave | `"Brave"` |
| Yandex | `"Yandex"` |
| Arc | `"Arc"` |
| CocCoc | `"CocCoc"` |
Each browser identifies its Keychain entry with a short account string — typically the browser's base name (`"Chrome"`, `"Brave"`, `"Arc"`). Edge uses `"Microsoft Edge"`. Related variants share labels rather than defining their own: Chrome Beta aliases onto `"Chrome"`, Opera GX aliases onto `"Opera"`.
The authoritative mapping lives in the `Storage` field of each entry in `platformBrowsers()` (`browser/browser_darwin.go`).
## 4. Windows Key Retrieval
@@ -148,11 +140,9 @@ A single iteration makes PBKDF2 essentially a keyed HMAC — no real key-stretch
### 5.4 Storage Labels
| Browser | D-Bus Label |
|---------|-------------|
| Chrome / Chrome Beta / Vivaldi | `"Chrome Safe Storage"` |
| Chromium / Edge / Opera | `"Chromium Safe Storage"` |
| Brave | `"Brave Safe Storage"` |
Linux D-Bus labels follow a `"<name> Safe Storage"` convention, but many browsers alias onto a small shared set rather than defining their own. The three distinct labels are `"Chrome Safe Storage"`, `"Chromium Safe Storage"`, and `"Brave Safe Storage"` — everything else maps onto one of these.
The authoritative mapping lives in the `Storage` field of each entry in `platformBrowsers()` (`browser/browser_linux.go`).
## 6. Platform Summary
@@ -164,6 +154,51 @@ A single iteration makes PBKDF2 essentially a keyed HMAC — no real key-stretch
\* Only included when `--keychain-pw` is provided.
## 7. Safari Credential Extraction
Safari is **not** a consumer of the `KeyRetriever` interface. It has its own credential-extraction path in `browser/safari/extract_password.go`, which uses [keychainbreaker](https://github.com/moond4rk/keychainbreaker) directly to list `InternetPassword` records from `login.keychain-db`.
This is a deliberate architectural choice, not an oversight. The following sections explain why.
### 7.1 Why Safari Does Not Share the Chromium Chain
| Aspect | Chromium chain | Safari direct access |
|---|---|---|
| Output | A 16-byte AES-128 key | A list of `InternetPassword` records |
| Use case | Decrypt Login Data DB | Records *are* the credentials |
| Number of consumers | 10+ Chromium variants | 1 (Safari only) |
| Failure mode | Hard fail (no key → cannot decrypt) | Soft fail (degrade to metadata-only) |
| Caching benefit | High (multi-profile, multi-browser) | None (single browser, single call) |
Forcing Safari through the `KeyRetriever` interface would require returning a different type than `[]byte`, contradicting the interface's documented purpose as the *master-key* abstraction. Forcing it through a parallel "InternetPassword chain" would be over-engineering for a single consumer that has no fallback strategies worth chaining.
Note the "failure mode" row in particular: Chromium *must* have a master key or extraction fails entirely, so it needs a chain of escalating strategies. Safari can degrade gracefully — if the keychain cannot be unlocked, metadata-only export (URLs and usernames, no plaintext passwords) is still useful output, so a single "try keychainbreaker, warn on failure" is sufficient.
### 7.2 The General Rule
> **Each browser package owns its own credential-acquisition strategy. `crypto/keyretriever` exists only to share retrieval logic across the Chromium variant family. New browser implementations should follow Safari's and Firefox's example — own your credential code.**
Evidence the rule is already in force:
- **Firefox** (`browser/firefox/firefox.go`) does not import `keyretriever` or `keychainbreaker`. It derives keys from `key4.db` via internal NSS PBE. See RFC-005.
- **Safari** (`browser/safari/extract_password.go`) uses `keychainbreaker` directly for `InternetPassword` records.
- **Chromium variants** all go through `crypto/keyretriever` because they share exactly one chain and benefit from the shared `sync.Once` caching.
Future contributors adding a new macOS browser that reads credentials from the Keychain should add their access logic to that browser's package, not extend `keyretriever`. Only extend `keyretriever` if the new browser is a Chromium variant that fits the existing master-key chain.
### 7.3 Where the `--keychain-pw` Password Goes
The macOS login password is resolved once at startup by `browser/browser_darwin.go::resolveKeychainPassword`, then delivered to both consumers from within a single platform-specific closure, `newPlatformInjector` (defined per platform in `browser/browser_{darwin,linux,windows}.go`). The closure captures both the retriever chain and the raw password, and applies whichever capability interface each Browser happens to satisfy:
| Consumer | Capability interface | Defined in | Payload |
|---|---|---|---|
| Chromium browsers | `retrieverSetter` | `browser/browser.go` | `keyretriever.KeyRetriever` chain |
| Safari | `keychainPasswordSetter` | `browser/browser_darwin.go` | raw `string` |
The two setters are **intentionally not unified**. They carry different abstractions — one hands the browser a pre-assembled retrieval chain, the other hands the browser a credential token to unlock its own access path. Unifying them would create a leaky polymorphic interface with no real shared semantics. Note that `keychainPasswordSetter` is defined in the darwin-only file because Safari (its only implementer) is darwin-only.
`resolveKeychainPassword` additionally performs an early `TryUnlock` against `keychainbreaker` before the chain is built, so a bad password surfaces as a startup warning rather than a mid-extraction failure. The small cost of opening the keychain twice (once for validation, once inside `KeychainPasswordRetriever`) buys meaningful UX.
## References
- **macOS**: [os_crypt_mac.mm](https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_mac.mm;l=157)