# MyPass — Design Spec **Date:** 2026-05-21 **Status:** Approved --- ## Overview 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 ├── KeychainStore — read/write master password in shared Keychain group └── KeePassIconMapper — approximates KeePass's standard iconIndex (0-68) as SF Symbols MyPass (Main App Target) ──links──► MyPassCore ├── UnlockView ├── EntryListView — flat, filterable entry list (see UI Navigation) ├── GroupFilterView — sidebar: multi-select group tree, filters EntryListView ├── EntryDetailView ├── EntryEditView ├── FileBookmarkService — security-scoped bookmark management ├── BiometricAuthService — LAContext wrapper └── ClipboardService — copy with 30 s expiry AutoFill (App Extension Target) ──links──► MyPassCore ├── ASCredentialProviderViewController subclass ├── CredentialListView — filtered by serviceIdentifier URL/domain └── UnlockView (mini) — Face ID → password fallback Shared App Group (group.org.antiloop222.mypass) ├── UserDefaults — security-scoped bookmark, last-opened vault, settings └── Keychain Access Group — master password, biometric token (org.antiloop222.mypass) ``` **External dependencies (SPM)** - 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 - Argon2 (nested git submodule inside `Vendor/KeePassKit`) — KDBX4 key derivation - ChaCha20 / TwoFish — cipher implementations already bundled in KeePassKit's own source tree, wired into the SPM target - Apple CryptoKit (built-in) - LocalAuthentication (built-in) - AuthenticationServices (built-in) --- ## Data Model All types are value types (`struct`) defined in `MyPassCore`. `VaultSession` is the single owner of a live `KDBXDatabase`. ```swift struct KDBXDatabase { var metadata: DatabaseMetadata // name, description, cipher, compression var root: Group } struct Group: Identifiable { var id: UUID var name: String var iconIndex: Int var subgroups: [Group] // recursive tree var entries: [Entry] } struct Entry: Identifiable { var id: UUID var title: String var username: String var password: ProtectedString var url: String var notes: String var customFields: [CustomField] var attachments: [Attachment] var totp: TOTPConfig? // parsed from otp:// custom field var tags: [String] var iconIndex: Int var expiryDate: Date? var creationDate: Date var modificationDate: Date var history: [Entry] // read-only previous versions } struct CustomField: Identifiable { var id: UUID var key: String var value: ProtectedString } struct ProtectedString { private(set) var value: String // XOR-obfuscated in memory var isProtected: Bool func reveal() -> String // decode on demand only } struct Attachment: Identifiable { var id: UUID var name: String var data: Data } struct TOTPConfig { var secret: String var period: Int // default 30s var digits: Int // default 6 var algorithm: TOTPAlgorithm // SHA1 / SHA256 / SHA512 } ``` `ProtectedString` XOR-obfuscates its value in memory and only decodes on explicit `reveal()` calls. It never appears in logs or debug descriptions. --- ## UI Navigation **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(columnVisibility:) ├── sidebar: GroupFilterView — multi-select group tree (see below) └── detail: NavigationStack └─► EntryListView (flat, filtered) └─► EntryDetailView └─► EntryEditView (sheet) ``` `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. - **Hamburger button** (leading toolbar item, `"line.3.horizontal"`) toggles `columnVisibility` between `.all` and `.detailOnly`, showing/hiding the sidebar. - **Search icon** (trailing toolbar item, `"magnifyingglass"`) toggles a `.searchable()` bar over `EntryListView`; tapping again hides it and clears the query. - Swipe-to-delete on entry rows; toolbar `+` button opens `EntryEditView` for a new entry (see "Add-entry flow" below). - Tapping a password field in `EntryDetailView` copies to clipboard (clears after 30 s). - TOTP code displays with a countdown ring and refreshes automatically. - Keyboard shortcuts (macOS): `⌘C` copies password, `⌘⌥C` copies TOTP code, `⌘E` opens edit sheet. ### Entry list (`EntryListView`) 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`) 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. --- ## AutoFill Credential Provider Extension **Entitlement:** `com.apple.developer.authentication-services.autofill-credential-provider` **Extension point:** `com.apple.authentication-services-credential-provider-ui` ### Runtime flow 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 ### Onboarding 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. ### URL matching 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 | --- ## Error Handling | Scenario | Behaviour | |---|---| | Wrong master password | Alert: "Incorrect password" — no retry limit | | Corrupt or unsupported KDBX | Alert with file name and format error message | | Bookmark stale (file moved/deleted) | Alert: "Vault file not found" + "Choose file…" button | | Biometrics unavailable | Graceful fallback to password entry field (both main app and extension) | | Extension parse failure | Error screen + "Open MyPass" deep-link button | | Save conflict (file modified externally) | Detect via `NSFileCoordinator`; alert user and offer to reload or overwrite | --- ## Testing ### `MyPassCoreTests` (unit) - Parse fixture `.kdbx` files (KDBX 3.1 and 4.0 with both AES-KDF and Argon2d) - Round-trip: parse → modify entry → write → re-parse → assert equality - `TOTPGenerator`: known test vectors from RFC 6238 - `ProtectedString`: obfuscation / reveal symmetry - AutoFill URL matching: table-driven tests covering exact match, subdomain, no-URL entries - `KeePassIconMapper`: known indices map to expected symbols, unmapped indices fall back correctly ### `MyPassTests` (integration) - Unlock flow with a test KDBX fixture - Add / edit / delete entry persisted to file - Tree-flattening helper: entries paired with correct full `groupPath`, including nested subgroups - Group-filter subtree selection: selecting a parent group selects all descendant subgroup IDs; deselecting a parent deselects the whole subtree - Group filter + search combine as AND: narrowing by group then searching returns only the intersection ### `MyPassUITests` - Unlock → view flat entry list → tap entry → view detail → copy password - Add entry → verify it appears in list with the group picker's chosen group - Search: query returns expected entries - Group filter: selecting a group in the sidebar narrows the list; selecting a parent with subgroups includes descendant entries --- ## Open Questions 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.