239 lines
9.7 KiB
Markdown
239 lines
9.7 KiB
Markdown
# 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
|
||
|
|
|
||
|
|
MyPass (Main App Target) ──links──► MyPassCore
|
||
|
|
├── UnlockView
|
||
|
|
├── GroupBrowserView
|
||
|
|
├── EntryDetailView
|
||
|
|
├── EntryEditView
|
||
|
|
├── SearchView
|
||
|
|
├── FileBookmarkService — security-scoped bookmark management
|
||
|
|
├── BiometricAuthService — LAContext wrapper
|
||
|
|
└── ClipboardService — copy with 30 s expiry
|
||
|
|
|
||
|
|
AutoFillExtension (App Extension Target) ──links──► MyPassCore
|
||
|
|
├── ASCredentialProviderViewController subclass
|
||
|
|
├── CredentialListView — filtered by serviceIdentifier URL/domain
|
||
|
|
└── UnlockView (mini) — Face ID → password fallback
|
||
|
|
|
||
|
|
Shared App Group (group.com.christophevila.mypass)
|
||
|
|
├── UserDefaults — security-scoped bookmark, last-opened vault, settings
|
||
|
|
└── Keychain Access Group — master password, biometric token
|
||
|
|
```
|
||
|
|
|
||
|
|
**External dependencies (SPM)**
|
||
|
|
- A KDBX parsing library (to be confirmed during implementation — evaluate available Swift/ObjC options supporting KDBX 3.1 and 4.0 with Argon2 KDF)
|
||
|
|
- 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
|
||
|
|
|
||
|
|
### iOS — NavigationStack
|
||
|
|
|
||
|
|
```
|
||
|
|
UnlockView
|
||
|
|
└─► GroupBrowserView (root group)
|
||
|
|
├─► GroupBrowserView (subgroup, pushed recursively)
|
||
|
|
└─► EntryDetailView
|
||
|
|
└─► EntryEditView (sheet)
|
||
|
|
```
|
||
|
|
|
||
|
|
- Search bar at the top of every `GroupBrowserView` filters entries globally across all groups
|
||
|
|
- Swipe-to-delete on entries; toolbar `+` button adds an entry in the current group
|
||
|
|
- Tapping a password field in `EntryDetailView` copies to clipboard (clears after 30 s)
|
||
|
|
- TOTP code displays with a countdown ring and refreshes automatically
|
||
|
|
|
||
|
|
### macOS — NavigationSplitView (3 columns)
|
||
|
|
|
||
|
|
| Sidebar | Middle | Detail |
|
||
|
|
|---|---|---|
|
||
|
|
| Group tree (expandable) | Entry list for selected group + search bar | Entry fields + Edit button |
|
||
|
|
|
||
|
|
Keyboard shortcuts: `⌘C` copies password, `⌘⌥C` copies TOTP code, `⌘E` opens edit sheet.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 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
|
||
|
|
|
||
|
|
### 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
|
||
|
|
|
||
|
|
### `MyPassTests` (integration)
|
||
|
|
- Unlock flow with a test KDBX fixture
|
||
|
|
- Add / edit / delete entry persisted to file
|
||
|
|
|
||
|
|
### `MyPassUITests`
|
||
|
|
- Unlock → browse groups → view entry detail → copy password
|
||
|
|
- Add entry → verify it appears in list
|
||
|
|
- Search: query returns expected entries
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Open Questions (resolved at implementation time)
|
||
|
|
|
||
|
|
1. **KDBX library:** Evaluate Swift Package Manager options that support KDBX 3.1 + 4.0 with Argon2. Fallback: KeePassKit (Objective-C via bridging header).
|
||
|
|
2. **macOS AutoFill:** `ASCredentialProviderExtension` on macOS 13+ has reduced scope vs iOS — verify which apps support third-party fill on macOS and document limitations.
|
||
|
|
3. **Associated Domains:** For bundle-ID-based AutoFill matching, an associated domains file may be needed for first-party apps. Evaluate at implementation time.
|