Removed the magnifying-glass toolbar button and the isSearching state it drove -- .searchable() now shows the search field unconditionally, which is simpler and was requested after hands-on use showed the extra tap added no value. Updated the spec to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
16 KiB
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-writtenPackage.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
- KissXML (
- 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.
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") togglescolumnVisibilitybetween.alland.detailOnly, showing/hiding the sidebar. - Search is always visible via
.searchable()overEntryListView(no toggle icon -- changed 2026-09-19 after hands-on use showed the extra tap added no value over an always-present search field). - Swipe-to-delete on entry rows; toolbar
+button opensEntryEditViewfor a new entry (see "Add-entry flow" below). - Tapping a password field in
EntryDetailViewcopies to clipboard (clears after 30 s). - TOTP code displays with a countdown ring and refreshes automatically.
- Keyboard shortcuts (macOS):
⌘Ccopies password,⌘⌥Ccopies TOTP code,⌘Eopens 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
iconIndexviaKeePassIconMapper(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
rootgroup (e.g. an entry directly inroot > Work > Emailshows asWork > Email, notRoot > 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 inGroupFilterViewModel, separate fromVaultViewModel— the sidebar's tree-interaction concerns (expand/collapse, checkbox propagation) are distinct from list-filtering concerns.ContentViewwires the resulting set intoVaultViewModel.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
- User taps a login field in any app → iOS shows QuickType bar with MyPass icon
- User taps the icon → iOS instantiates
ASCredentialProviderViewControllerand callsprepareCredentialList(for: [ASCredentialServiceIdentifier]) - Extension checks Keychain for a valid biometric token:
- Token valid (vault was recently unlocked): skip to step 5
- Token missing/expired: show mini
UnlockView→LAContextFace ID / Touch ID → password fallback
- 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 CredentialListViewdisplays entries filtered by matching theserviceIdentifierURL/domain against each entry's URL field. Search bar allows manual lookup across all entries- 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
.kdbxfiles (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 6238ProtectedString: 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
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).macOS AutoFill:Resolved: no scope reduction found. SameASCredentialProviderExtensionon macOS 13+ has reduced scope vs iOS — verify which apps support third-party fill on macOS and document limitations.ASCredentialProviderViewControllerAPI 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.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-basedserviceIdentifierinstead of a bundle-ID one) — not the password manager. MyPass already handles both cases correctly:CredentialMatcherjust matches whateverserviceIdentifierthe system supplies against stored entry URLs, the same approach 1Password/Bitwarden use. No work needed.