MyPass is a iOS + macOS password manager that reads and writes KeePass KDBX vaults. Users pick an existing `.kdbx` file from anywhere in the Files system (iCloud Drive, Dropbox, local storage) and MyPass becomes their viewer/editor for that vault. A bundled AutoFill Credential Provider extension enables password fill-in across every app on the device — not just Safari.
### Goals
- Open, browse, add, edit, and delete entries in an existing KDBX vault
- Unlock with master password once; Face ID / Touch ID on subsequent opens
- AutoFill credentials into any app via `ASCredentialProviderViewController`
- Support full KeePass entry structure: groups/subgroups, custom fields, TOTP, attachments
### Non-goals
- Creating new KDBX vaults from scratch
- Syncing or hosting vaults (the user owns the file)
- Web browser extension
---
## Architecture
### Option chosen: Local Swift Package + App Extension
A local Swift Package (`MyPassCore`) holds all vault logic. Both targets link to it. A shared App Group bridges the two processes.
```
MyPassCore (Swift Package)
├── KDBXDocument — parse and write KDBX 3.1 / 4.0
├── Entry / Group / Attachment — value-type models
├── TOTPGenerator — RFC 6238 TOTP from otp:// custom field
├── VaultSession — owns the live KDBXDatabase; lock/unlock lifecycle
- KDBX parsing: KeePassKit (Objective-C, MIT). No upstream SPM support, so vendored locally as a git submodule (`Vendor/KeePassKit`) with a hand-written `Package.swift`. This pulled in the same problem three more times — none of the following had clean drop-in SPM support either, so all are vendored the same way:
- KissXML (`Vendor/KissXML`) — XML parsing KeePassKit depends on
**Revised 2026-09-19** — replaces the original group-drilling design (see git history for the prior version). The main area now shows a flat, filterable list of entries instead of requiring the user to navigate into groups one at a time; groups become a filter, not a place you browse into.
### Shared structure (iOS + macOS — same component, one implementation)
`NavigationSplitView` is used on **both** platforms instead of maintaining separate iOS/macOS navigation code. On iPhone-width layouts it automatically collapses the sidebar into an overlay; on iPad/Mac it can sit persistently alongside the detail column. This behavior is built into the component — no platform-specific branching needed for it.
- **Search** is always visible via `.searchable()` over `EntryListView` (no toggle icon -- changed 2026-09-19 after hands-on use showed the extra tap added no value over an always-present search field).
Flat list, one row per entry across the whole vault (or the filtered subset — see below). Each row:
```
[icon] Title
username · Group > Subgroup > Sub-subgroup
```
- **Icon:** always shown, mapped from the entry's `iconIndex` via `KeePassIconMapper` (an approximate SF Symbol per KeePass's standard 0–68 icon palette; unmapped indices fall back to `"key.fill"`). The data model has no custom-icon-image support yet — only the standard palette index — so this is a best-effort visual match, not pixel-accurate.
- **Breadcrumb:** full ancestor path excluding the vault's top-level `root` group (e.g. an entry directly in `root > Work > Email` shows as `Work > Email`, not `Root > Work > Email` — the root node isn't a meaningful group to the user). Truncated in the middle if too long for the row. Computed by a tree-flattening helper (`VaultViewModel.flatEntries: [(entry: Entry, groupPath: [Group])]`) that walks the vault once per access, since entries don't natively carry a back-reference to their parent group. An entry stored directly in the root group (no breadcrumb) just shows the username line with nothing after it.
### Group filter (`GroupFilterView`, the sidebar)
A tree of all groups (never entries), each row a `DisclosureGroup` with a leading checkbox-style tap target (`circle` / `checkmark.circle.fill`) — a real `Toggle` isn't used here since it would conflict with the disclosure triangle's own tap target.
- Selecting a group selects it and its entire subgroup subtree (recursive select-down); deselecting a parent deselects the whole subtree too.
- Selection state (`Set<UUID>`) lives in `GroupFilterViewModel`, separate from `VaultViewModel` — the sidebar's tree-interaction concerns (expand/collapse, checkbox propagation) are distinct from list-filtering concerns. `ContentView` wires the resulting set into `VaultViewModel.selectedGroupIds`.
- Empty selection = no filter (show everything). A "Clear filter" affordance resets it.
### Filtering: group selection and search combine with AND
`VaultViewModel.displayedEntries` applies, in order: (1) group filter — keep only entries whose direct parent group ID is in `selectedGroupIds` (or all entries if empty), then (2) search filter — substring match on title/username/URL, same as the existing behavior. Both active at once narrows the result to their intersection (e.g. select "Work", then search "email" → only Work entries matching "email").
### Add-entry flow
Flattening removes the implicit "current group" that group-drilling used to provide. `EntryEditView` gets an explicit **group picker field** (listing the group tree by full path) instead, defaulting to: the sole selected filter group if exactly one is active, otherwise the vault's root group. The user can change it before saving. `EntryEditViewModel.groupId` becomes a real, always-present field rather than the previous optional-with-silent-failure.
### Dark mode
No new work needed — the codebase already uses only semantic SwiftUI colors (`.secondary`, `.tint`, system backgrounds) with zero hardcoded color values, which follow the system appearance automatically. No in-app override setting for now; this may become a settings item later.
1. User taps a login field in any app → iOS shows QuickType bar with MyPass icon
2. User taps the icon → iOS instantiates `ASCredentialProviderViewController` and calls `prepareCredentialList(for: [ASCredentialServiceIdentifier])`
3. Extension checks Keychain for a valid biometric token:
- **Token valid (vault was recently unlocked):** skip to step 5
- **Token missing/expired:** show mini `UnlockView` → `LAContext` Face ID / Touch ID → password fallback
4. Extension resolves the security-scoped bookmark from shared App Group `UserDefaults`, opens the KDBX file, decrypts using the master password from the shared Keychain group
5.`CredentialListView` displays entries filtered by matching the `serviceIdentifier` URL/domain against each entry's URL field. Search bar allows manual lookup across all entries
6. User taps an entry → `completeRequest(withSelectedCredential: ASPasswordCredential(user:password:))` → field filled → extension dismissed
Neither iOS nor macOS allows the extension to be pre-enabled — the user must turn it on manually (Settings → Passwords → AutoFill Passwords on iOS; System Settings → Extensions on macOS). The main app's first-launch flow should detect this and prompt the user with a deep link to the relevant settings screen.
Entry URL field is matched against the `serviceIdentifier` using host comparison (e.g. `github.com` matches `https://github.com/login` and bundle ID-based identifiers via Associated Domains). Entries with no URL are shown in a separate "All entries" section below suggestions.
### Extension constraints
- Memory limit ~50 MB: KDBX is parsed fresh on each extension invocation; no SwiftData or persistent cache inside the extension
- If KDBX parse fails, show an error alert with an "Open MyPass" deep-link button (`mypass://unlock`)
---
## Security Model
| Concern | Implementation |
|---|---|
| Master password at rest | Keychain item: `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, shared via Keychain Access Group |
| Biometric unlock | `LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)` retrieves master password from Keychain on success |
| File access | Security-scoped bookmark in shared App Group `UserDefaults`; `startAccessingSecurityScopedResource()` / `stopAccessingSecurityScopedResource()` bracket every read/write |
| In-memory passwords | `ProtectedString` XOR-obfuscates values; decoded only on `reveal()` |
| Clipboard | `UIPasteboard` item set with 30-second expiration using `UIPasteboard.setItems(_:options:)` with `UIPasteboardOptionExpirationDate` |
| KDBX crypto | Fully delegated to the KDBX parsing library (AES-256-CBC / ChaCha20, Argon2d / AES-KDF) — no custom crypto |
| Vault lock on background | `VaultSession.lock()` called in `sceneDidEnterBackground` — zeroes master password from memory; biometric re-auth required on next foreground |
1.~~**KDBX library:** Evaluate Swift Package Manager options that support KDBX 3.1 + 4.0 with Argon2. Fallback: KeePassKit (Objective-C via bridging header).~~**Resolved:** no SPM-native option was found; KeePassKit is used, vendored locally (see External dependencies above).
2.~~**macOS AutoFill:** `ASCredentialProviderExtension` on macOS 13+ has reduced scope vs iOS — verify which apps support third-party fill on macOS and document limitations.~~**Resolved:** no scope reduction found. Same `ASCredentialProviderViewController` API and "app or website" coverage as iOS. The only difference is enablement — the user must turn the extension on manually (System Settings → Extensions on macOS, Settings → Passwords → AutoFill on iOS); neither platform allows pre-enabling it via MDM/config profile. Onboarding should prompt the user to do this on first launch, on both platforms.
3.~~**Associated Domains:** For bundle-ID-based AutoFill matching, an associated domains file may be needed for first-party apps. Evaluate at implementation time.~~**Resolved: not applicable.** Associated Domains is the responsibility of the app *being logged into* (so the system can pass a domain-based `serviceIdentifier` instead of a bundle-ID one) — not the password manager. MyPass already handles both cases correctly: `CredentialMatcher` just matches whatever `serviceIdentifier` the system supplies against stored entry URLs, the same approach 1Password/Bitwarden use. No work needed.