Identifiers, the AutoFillExtension -> AutoFill rename, and the KDBX library choice had all drifted from the approved spec since it was written in May. Updates the spec to match what was actually built and closes the now-resolved "which KDBX library" open question. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
242 lines
10 KiB
Markdown
242 lines
10 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
|
|
|
|
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
|
|
|
|
### 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
|
|
|
|
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.
|
|
3. **Associated Domains:** For bundle-ID-based AutoFill matching, an associated domains file may be needed for first-party apps. Evaluate at implementation time.
|