passforios/plans/03-multi-store-plan.md
2026-03-09 15:02:48 +01:00

423 lines
23 KiB
Markdown

# Multi-Store Support — Implementation Plan
## Concept
Each **store** is an independent password repository with its own git remote, credentials, branch, and (optionally) its own PGP key pair. Users can enable/disable individual stores for the password list and separately for AutoFill. Stores can be shared between users who each decrypt with their own key (leveraging the existing `.gpg-id` per-directory mechanism from `pass`).
---
## Phase 1: Improve Test Coverage Before Refactoring
See [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md). This is standalone and should be done before any refactoring to catch regressions.
---
## Phase 2: Data Model — `StoreConfiguration`
Create a new persistent model for store definitions. This is the foundation everything else builds on.
### 2.1 Define `StoreConfiguration` as a Core Data entity
→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests)
Add a `StoreConfiguration` entity to the existing Core Data model (`pass.xcdatamodeld`), with attributes:
- `id: UUID` — unique identifier
- `name: String` — display name (e.g. "Personal", "Work")
- `gitURL: URI` (stored as String)
- `gitBranchName: String`
- `gitAuthenticationMethod: String` (raw value of `GitAuthenticationMethod`)
- `gitUsername: String`
- `pgpKeySource: String?` (raw value of `KeySource`)
- `isVisibleInPasswords: Bool` — shown in the password list
- `isVisibleInAutoFill: Bool` — shown in AutoFill
- `sortOrder: Int16` — for user-defined ordering
- `lastSyncedTime: Date?`
Relationship: `passwords` → to-many `PasswordEntity` (inverse: `store`; cascade delete rule — deleting a store removes all its password entities).
Using Core Data instead of a separate JSON file because:
- The Core Data stack already exists and is shared across all targets via the app group
- The `StoreConfiguration``PasswordEntity` relationship gives referential integrity and cascade deletes for free
- No second persistence mechanism to maintain
- Built-in concurrency/conflict handling
### 2.2 Define `StoreConfigurationManager`
→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests)
Manages the list of stores via Core Data. Provides CRUD, reordering, and lookup by ID. Observable (via `NotificationCenter` or Combine) so UI updates when stores change.
### 2.3 Migration from single-store
→ Testing: [T2 — Migration tests](#t2-migration-tests)
On first launch after upgrade, create a single `StoreConfiguration` from the current `Defaults.*` values and keychain entries. Assign all existing `PasswordEntity` rows to this store. Existing users see no change.
This is a Core Data model version migration: add the `StoreConfiguration` entity, add the `store` relationship to `PasswordEntity`, and populate it in a post-migration step.
### 2.4 Per-store secrets
→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests)
Per-store secrets go in the keychain with namespaced keys:
- `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"`
- `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"`
- The existing `"pgpKeyPassphrase-{keyID}"` scheme already works across stores since it's keyed by PGP key ID.
---
## Phase 3: De-singleton the Backend
The most invasive but essential change. Requires careful sequencing.
### 3.1 Parameterize `Globals` paths
Add a method to compute the per-store repository directory:
- `repositoryURL(for storeID: UUID) -> URL` — e.g. `Library/password-stores/{storeID}/`
The database path (`dbPath`) stays single since we use one Core Data database with a relationship.
### 3.2 Make `PasswordStore` non-singleton
→ Testing: [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests), [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests)
Convert to a class that takes a `StoreConfiguration` at init:
- Each instance owns its own `storeURL`, `gitRepository`, `context`
- Inject `StoreConfiguration` (for git URL, branch, credentials) and a `PGPAgent` instance
- Keep a **`PasswordStoreManager`** that holds all active `PasswordStore` instances (keyed by store ID), lazily creating them
- `PasswordStoreManager` replaces all `PasswordStore.shared` call sites
### 3.3 Core Data: `PasswordEntity` ↔ `StoreConfiguration` relationship
→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T6 — `PasswordEntity` fetch filtering tests](#t6-passwordentity-fetch-filtering-tests)
Add a `store` relationship (to-one) on `PasswordEntity` pointing to `StoreConfiguration` (inverse: `passwords`, to-many, cascade delete). This replaces the need for a separate `storeID` UUID attribute — the relationship provides referential integrity and cascade deletes.
All `PasswordEntity` fetch requests must be updated to filter by store (or by set of visible stores for the password list / AutoFill). The `initPasswordEntityCoreData(url:in:)` method already takes a URL parameter; pass the per-store URL and set the `store` relationship on each created entity.
### 3.4 Make `PGPAgent` per-store
→ Testing: [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests) (encrypt/decrypt with per-store keys)
Remove the singleton. `PasswordStore` instances each hold an optional `PGPAgent`. Stores sharing the same PGP key pair just load the same keychain entries. Stores using different keys load different ones. The `KeyStore` protocol already supports this — just pass different key names.
### 3.5 Make `GitCredential` per-store
→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests)
Already not a singleton, just reads from `Defaults`. Change it to read from `StoreConfiguration` + namespaced keychain keys instead.
---
## Phase 4: Settings UI — Store Management
### 4.1 New "Stores" settings section
Replace the current single "Password Repository" and "PGP Key" rows with a section listing all configured stores, plus an "Add Store" button:
- Each store row shows: name, git host, sync status indicator
- Tapping a store opens `StoreSettingsTableViewController`
- Swipe-to-delete removes a store (with confirmation)
- Drag-to-reorder for sort order
### 4.2 `StoreSettingsTableViewController`
Per-store settings screen:
- Store name (editable text field)
- **Repository section**: Git URL, branch, username, auth method (reuse existing `GitRepositorySettingsTableViewController` logic, but scoped to this store's config)
- **PGP Key section**: Same import options as today but scoped to this store's keychain namespace. Add an option "Use same key as [other store]" for convenience.
- **Visibility section**: Two toggles — "Show in Passwords", "Show in AutoFill"
- **Sync section**: Last synced time, manual sync button
- **Danger zone**: Delete store (see §4.4 for full cleanup steps)
### 4.3 Migrate existing settings screens
`GitRepositorySettingsTableViewController`, `PGPKeyArmorImportTableViewController`, etc. currently read/write global `Defaults`. Refactor them to accept a `StoreConfiguration` and read/write to that store's Core Data entity and namespaced keychain keys instead.
### 4.4 Store lifecycle: adding a store
→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests)
Currently, configuring git settings triggers a clone immediately (`GitRepositorySettingsTableViewController.save()``cloneAndSegueIfSuccess()`), and the clone rebuilds Core Data from the filesystem. The multi-store equivalent:
1. User taps "Add Store" → presented with `StoreSettingsTableViewController`
2. User fills in store name, git URL, branch, username, auth method
3. User imports PGP keys (public + private) for this store
4. User taps "Save" → creates a `StoreConfiguration` entity in Core Data
5. Clone is triggered for this store:
- Compute per-store repo directory: `Library/password-stores/{storeID}/`
- Call `PasswordStore.cloneRepository()` scoped to that directory
- On success: BFS-walk the cloned repo, create `PasswordEntity` rows linked to this `StoreConfiguration` via the `store` relationship
- On success: validate `.gpg-id` exists (warn if missing, since decryption will fail)
- On failure: delete the `StoreConfiguration` entity (cascade deletes any partial `PasswordEntity` rows), clean up the repo directory, remove keychain entries for this store ID
6. Post `.passwordStoreUpdated` notification so the password list refreshes
### 4.5 Store lifecycle: removing a store
→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests)
Currently `erase()` nukes everything globally. Per-store removal must be scoped:
1. User confirms deletion (destructive action sheet)
2. Cleanup steps:
- Delete the repo directory: `Library/password-stores/{storeID}/` (rm -rf)
- Delete `StoreConfiguration` entity from Core Data → cascade-deletes all linked `PasswordEntity` rows automatically
- Remove namespaced keychain entries: `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"`, `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"`
- Drop the in-memory `PasswordStore` instance from `PasswordStoreManager`
- Post `.passwordStoreUpdated` so the password list refreshes
3. PGP key passphrase entries (`"pgpKeyPassphrase-{keyID}"`) may be shared with other stores using the same key — only remove if no other store references that key ID
### 4.6 Store lifecycle: re-cloning / changing git URL
→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests)
When the user changes the git URL or branch of an existing store (equivalent to today's "overwrite" flow):
1. Delete the existing repo directory for this store
2. Delete all `PasswordEntity` rows linked to this `StoreConfiguration` (but keep the `StoreConfiguration` entity itself)
3. Clone the new repo into the store's directory
4. Rebuild `PasswordEntity` rows from the new clone, linked to the same `StoreConfiguration`
5. Clear and re-prompt for git credentials
### 4.7 Global "Erase all data"
→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) (test global erase)
The existing "Erase Password Store Data" action in Advanced Settings should:
1. Delete all `StoreConfiguration` entities (cascade-deletes all `PasswordEntity` rows)
2. Delete all repo directories under `Library/password-stores/`
3. Remove all keychain entries (`AppKeychain.shared.removeAllContent()`)
4. Clear all UserDefaults (`Defaults.removeAll()`)
5. Clear passcode, uninit all PGP agents, drop all `PasswordStore` instances
6. Post `.passwordStoreErased`
---
## Phase 5: Password List UI — Multi-Store Browsing
### 5.1 Unified password list
`PasswordNavigationViewController` should show passwords from all visible stores together:
- **Folder mode**: Add a top-level grouping by store name, then the folder hierarchy within each store. The store name row could have a distinct style (e.g. bold, with a colored dot or icon).
- **Flat mode**: Show all passwords from all visible stores. Subtitle or accessory showing which store each password belongs to.
- **Search**: Searches across all visible stores simultaneously. Results annotated with store name.
### 5.2 Password detail
`PasswordDetailTableViewController` needs to know which store a password belongs to (to decrypt with the right `PGPAgent` and write changes back to the right repo). Pass the store context through from the list.
### 5.3 Add password flow
`AddPasswordTableViewController` needs a store picker if multiple stores are visible. Default to a "primary" store or the last-used one.
### 5.4 Sync
→ Testing: [T9 — Sync tests](#t9-sync-tests)
Pull-to-refresh in the password list syncs all visible stores (sequentially or in parallel). Show per-store sync status. Allow syncing individual stores from their settings or via long-press.
---
## Phase 6: AutoFill Extension
### 6.1 Multi-store AutoFill
→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests)
`CredentialProviderViewController`:
- Fetch passwords from all stores where `isVisibleInAutoFill == true`
- The "Suggested" section should search across all AutoFill-visible stores
- Each password entry carries its store context for decryption
- No store picker needed — just include all enabled stores transparently
- Consider showing store name in the cell subtitle for disambiguation
### 6.2 QuickType integration
→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests) (store ID in `recordIdentifier`)
`provideCredentialWithoutUserInteraction` needs to try the right store's PGP agent for decryption. Since it gets a `credentialIdentity` (which contains a `recordIdentifier` = password path), the path must now encode or be mappable to a store ID.
---
## Phase 7: Extensions & Shortcuts
### 7.1 passExtension (share extension)
Same multi-store search as AutoFill. Minor.
### 7.2 Shortcuts
`SyncRepositoryIntentHandler`:
- Add a store parameter to the intent (optional — if nil, sync all stores)
- Register each store as a Shortcut parameter option
- Support "Sync All" and "Sync [store name]"
---
## Phase 8: Multi-Recipient Encryption
See [02-multi-recipient-encryption-plan.md](02-multi-recipient-encryption-plan.md). This is standalone and can be implemented before or after multi-store support. In a multi-store context, `isEnableGPGIDOn` becomes a per-store setting.
---
## Implementation Order
| Step | Phase | Description | Depends On |
|------|-------|-------------|------------|
| 1 | 1 | Improve test coverage (see [separate plan](01-improve-test-coverage-plan.md)) | — |
| 2a | 2 | `StoreConfiguration` Core Data entity + relationship to `PasswordEntity` + model migration | Phase 1 |
| 2b | 2 | `StoreConfigurationManager` + single-store migration from existing Defaults/keychain | Step 2a |
| 2t | T | Tests: `StoreConfiguration` CRUD, cascade delete, migration (T1, T2) | Steps 2a+2b |
| 3a | 3 | Parameterize `Globals` paths (per-store repo directory) | Step 2a |
| 3b | 3 | Namespace keychain keys per store | Step 2a |
| 3bt | T | Tests: per-store keychain namespace (T5) | Step 3b |
| 3c | 3 | De-singleton `PGPAgent` | Steps 2a+3a+3b |
| 3d | 3 | De-singleton `PasswordStore``PasswordStoreManager` | Steps 2b-3c |
| 3dt | T | Tests: `PasswordStoreManager`, per-store `PasswordStore`, entity filtering (T3, T4, T6) | Step 3d |
| 3e | 3 | Per-store `GitCredential` | Steps 3b+3d |
| 3f | 3 | Store lifecycle: add/clone, remove/cleanup, re-clone, global erase | Steps 3d+3e |
| 3ft | T | Tests: store lifecycle integration (T7) | Step 3f |
| 4a | 4 | Store management UI (add/edit/delete/reorder) | Step 3f |
| 4b | 4 | Migrate existing settings screens to per-store | Step 4a |
| 5a | 5 | Multi-store password list | Step 3d |
| 5b | 5 | Multi-store add/edit/detail | Step 5a |
| 5c | 5 | Multi-store sync | Steps 3e+5a |
| 5ct | T | Tests: sync (T9) | Step 5c |
| 6a | 6 | Multi-store AutoFill | Step 3d |
| 6t | T | Tests: AutoFill multi-store (T8) | Step 6a |
| 7a | 7 | Multi-store Shortcuts | Step 3d |
| 8a | 8 | Multi-recipient encryption (see [separate plan](02-multi-recipient-encryption-plan.md)) | Step 3d |
---
## Testing Plan
For baseline test coverage of existing code, see [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md).
### Testing new multi-store code
#### T1: `StoreConfiguration` entity tests
- **Test CRUD**: Create, read, update, delete `StoreConfiguration` entities.
- **Test cascade delete**: Delete a `StoreConfiguration` → verify all linked `PasswordEntity` rows are deleted.
- **Test relationship integrity**: Create `PasswordEntity` rows linked to a store → verify fetching by store returns the right entities.
- **Test `StoreConfigurationManager`**: Create, list, reorder, delete stores via the manager.
#### T2: Migration tests
- **Test fresh install**: No existing data → no `StoreConfiguration` created, app works.
- **Test upgrade migration from single-store**:
1. Set up a pre-migration Core Data database (using the old model version) with `PasswordEntity` rows, populate `Defaults` with git URL/branch/username, and populate keychain with PGP + SSH keys.
2. Run the migration.
3. Verify: one `StoreConfiguration` exists with values from Defaults, all `PasswordEntity` rows are linked to it, keychain entries are namespaced under the new store's ID.
- **Test idempotency**: Running migration twice doesn't create duplicate stores.
- **Test migration with empty repo** (no passwords, just settings): Still creates a `StoreConfiguration`.
#### T3: `PasswordStoreManager` tests
- **Test store lookup by ID**.
- **Test lazy instantiation**: Requesting a store creates `PasswordStore` on demand.
- **Test listing visible stores** (filtered by `isVisibleInPasswords` / `isVisibleInAutoFill`).
- **Test adding/removing stores updates the manager**.
#### T4: Per-store `PasswordStore` tests
- **Test clone scoped to per-store directory**: Clone into `Library/password-stores/{storeID}/`, verify `PasswordEntity` rows are linked to the right `StoreConfiguration`.
- **Test two stores independently**: Clone two different repos, verify each store's entities are separate, deleting one doesn't affect the other.
- **Test `eraseStoreData` scoped to one store**: Only that store's directory and entities are deleted.
- **Test encrypt/decrypt with per-store PGP keys**: Store A uses key pair X, store B uses key pair Y, each can only decrypt its own passwords.
- **Test store sharing one PGP key pair**: Two stores referencing the same keychain entries both decrypt correctly.
#### T5: Per-store keychain namespace tests
- **Test namespaced keys don't collide**: Store A's `"{A}.gitPassword"` and store B's `"{B}.gitPassword"` are independent.
- **Test `removeAllContent(withPrefix:)`**: Removing store A's keys doesn't affect store B's.
- **Test `pgpKeyPassphrase-{keyID}`** shared across stores using the same key.
#### T6: `PasswordEntity` fetch filtering tests
- **Test `fetchAll` filtered by one store**.
- **Test `fetchAll` filtered by multiple visible stores** (the AutoFill / password list scenario).
- **Test `fetchUnsynced` filtered by store**.
- **Test search across multiple stores**.
#### T7: Store lifecycle integration tests
- **Test add store flow**: Create config → clone → BFS walk → entities linked → notification posted.
- **Test remove store flow**: Delete config → cascade deletes entities → repo directory removed → keychain cleaned → notification posted.
- **Test re-clone flow**: Change git URL → old entities deleted → new clone → new entities → same `StoreConfiguration`.
- **Test global erase**: Multiple stores → all gone.
- **Test clone failure cleanup**: Clone fails → `StoreConfiguration` deleted → no orphan entities or directories.
#### T8: AutoFill multi-store tests
- **Test credential listing from multiple stores**: Entries from all AutoFill-visible stores appear.
- **Test store ID encoded in `recordIdentifier`**: Can map a credential identity back to the correct store for decryption.
- **Test filtering**: Only `isVisibleInAutoFill == true` stores appear.
#### T9: Sync tests
- **Test pull updates one store's entities without affecting others**.
- **Test sync-all triggers pull for each visible store**.
### Test infrastructure additions needed
- **Multi-store `CoreDataTestCase`**: Extend `CoreDataTestCase` to support the new model version with `StoreConfiguration`. Provide a helper to create a `StoreConfiguration` + linked entities in one call.
- **Pre-migration database fixture**: A snapshot of the old Core Data model (without `StoreConfiguration`) to use in migration tests. Can be a `.sqlite` file committed to the test bundle.
---
## Risks & Considerations
- **Data migration**: Existing users must be migrated seamlessly. The migration (steps 2a-2b) should be idempotent and tested thoroughly.
- **Core Data migration**: Adding the `StoreConfiguration` entity and the `store` relationship on `PasswordEntity` requires a lightweight migration (new entity + new optional relationship). The post-migration step creates a default `StoreConfiguration` from existing Defaults and assigns all existing `PasswordEntity` rows to it.
- **Memory**: Multiple `PasswordStore` instances each holding a `GTRepository` and `PGPAgent` — lazy instantiation is important. Only active/visible stores should be loaded.
- **Concurrency**: Git operations (pull/push) across multiple stores should not block each other. Use per-store serial queues.
- **AutoFill performance**: The extension has strict memory limits (~30MB). Loading all stores' Core Data is fine (single DB), but loading multiple PGP agents may be expensive. Decrypt lazily, only when the user selects a password.
- **Backward compatibility**: Older versions won't understand the new data layout. Consider a one-way migration flag.
---
## Context
### Prompt
I want to add support for several separate password repositories, each with a unique repository connection (url, authnetication), and potentially separate encryption/decryption keys.
Another GUI app that supports this is QtPass. There is information about this its readme: https://raw.githubusercontent.com/IJHack/QtPass/refs/heads/main/README.md
It calls it "profiles". I would probably call it "stores".
I want to be able to configure which stores are enabled when I view the list, and separately also for the autofill feature.
It should be possible to share a store with another user (who would be using a separate key on their end).
Make a plan for what needs to be done to support this in this application.
### Key Architecture Facts
- `PasswordStore.shared` singleton referenced from ~20+ call sites (app, AutoFill, passExtension, Shortcuts)
- `PGPAgent.shared` singleton holds single key pair
- `Globals` has all paths as `static let` (single repo, single DB, single key paths)
- `DefaultsKeys` — all git/PGP settings single-valued in shared UserDefaults
- `AppKeychain.shared` — flat keys, no per-store namespace
- Core Data: single `PasswordEntity` entity, no store discriminator, single SQLite DB
- `PersistenceController.shared` — single NSPersistentContainer
- UI: UITabBarController with 2 tabs (Passwords, Settings). Passwords tab uses PasswordNavigationViewController
- AutoFill: CredentialProviderViewController uses PasswordStore.shared directly
- App group + keychain group shared across all targets
- `.gpg-id` per-directory key selection already exists (closest to multi-key concept)
- QtPass calls them "profiles" — each can have different git repo and GPG key
### User Requirements
- Multiple password stores, each with unique repo connection (URL, auth) and potentially separate PGP keys
- Call them "stores" (not profiles)
- Configure which stores are visible in password list vs AutoFill separately
- Support sharing a store with another user (who uses a different key)