Files
keevault/docs/superpowers/specs/2026-05-21-mypass-design.md
T
krissandClaude Sonnet 5 c5a2e4c3ec docs: redesign main vault UI as flat filterable entry list
Replaces the group-drilling navigation model with a flat entry list,
a multi-select group-filter sidebar (unified NavigationSplitView on
both iOS and macOS instead of separate per-platform navigation code),
and a toggleable search bar. Covers the entry row layout (icon,
title, username, breadcrumb), group-filter subtree-selection
semantics, how group filter and search combine (AND), the new
explicit group-picker field EntryEditView needs now that there's no
implicit "current group", and confirms dark mode needs no new work
since the app already uses only semantic colors.

Brainstormed and approved with the user before writing this up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
2026-09-19 16:40:03 +02:00

283 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 068 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.
---
## 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.