23 KiB
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. 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
Add a StoreConfiguration entity to the existing Core Data model (pass.xcdatamodeld), with attributes:
id: UUID— unique identifiername: String— display name (e.g. "Personal", "Work")gitURL: URI(stored as String)gitBranchName: StringgitAuthenticationMethod: String(raw value ofGitAuthenticationMethod)gitUsername: StringpgpKeySource: String?(raw value ofKeySource)isVisibleInPasswords: Bool— shown in the password listisVisibleInAutoFill: Bool— shown in AutoFillsortOrder: Int16— for user-defined orderinglastSyncedTime: 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↔PasswordEntityrelationship 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, 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
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
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, 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 aPGPAgentinstance - Keep a
PasswordStoreManagerthat holds all activePasswordStoreinstances (keyed by store ID), lazily creating them PasswordStoreManagerreplaces allPasswordStore.sharedcall sites
3.3 Core Data: PasswordEntity ↔ StoreConfiguration relationship
→ Testing: T1 — StoreConfiguration entity 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 (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
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
GitRepositorySettingsTableViewControllerlogic, 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
Currently, configuring git settings triggers a clone immediately (GitRepositorySettingsTableViewController.save() → cloneAndSegueIfSuccess()), and the clone rebuilds Core Data from the filesystem. The multi-store equivalent:
- User taps "Add Store" → presented with
StoreSettingsTableViewController - User fills in store name, git URL, branch, username, auth method
- User imports PGP keys (public + private) for this store
- User taps "Save" → creates a
StoreConfigurationentity in Core Data - 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
PasswordEntityrows linked to thisStoreConfigurationvia thestorerelationship - On success: validate
.gpg-idexists (warn if missing, since decryption will fail) - On failure: delete the
StoreConfigurationentity (cascade deletes any partialPasswordEntityrows), clean up the repo directory, remove keychain entries for this store ID
- Compute per-store repo directory:
- Post
.passwordStoreUpdatednotification so the password list refreshes
4.5 Store lifecycle: removing a store
→ Testing: T7 — Store lifecycle integration tests
Currently erase() nukes everything globally. Per-store removal must be scoped:
- User confirms deletion (destructive action sheet)
- Cleanup steps:
- Delete the repo directory:
Library/password-stores/{storeID}/(rm -rf) - Delete
StoreConfigurationentity from Core Data → cascade-deletes all linkedPasswordEntityrows automatically - Remove namespaced keychain entries:
"{storeID}.gitPassword","{storeID}.gitSSHPrivateKeyPassphrase","{storeID}.sshPrivateKey","{storeID}.pgpPublicKey","{storeID}.pgpPrivateKey" - Drop the in-memory
PasswordStoreinstance fromPasswordStoreManager - Post
.passwordStoreUpdatedso the password list refreshes
- Delete the repo directory:
- 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
When the user changes the git URL or branch of an existing store (equivalent to today's "overwrite" flow):
- Delete the existing repo directory for this store
- Delete all
PasswordEntityrows linked to thisStoreConfiguration(but keep theStoreConfigurationentity itself) - Clone the new repo into the store's directory
- Rebuild
PasswordEntityrows from the new clone, linked to the sameStoreConfiguration - Clear and re-prompt for git credentials
4.7 Global "Erase all data"
→ Testing: T7 — Store lifecycle integration tests (test global erase)
The existing "Erase Password Store Data" action in Advanced Settings should:
- Delete all
StoreConfigurationentities (cascade-deletes allPasswordEntityrows) - Delete all repo directories under
Library/password-stores/ - Remove all keychain entries (
AppKeychain.shared.removeAllContent()) - Clear all UserDefaults (
Defaults.removeAll()) - Clear passcode, uninit all PGP agents, drop all
PasswordStoreinstances - 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
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
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 (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. 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) | — |
| 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) | Step 3d |
Testing Plan
For baseline test coverage of existing code, see 01-improve-test-coverage-plan.md.
Testing new multi-store code
T1: StoreConfiguration entity tests
- Test CRUD: Create, read, update, delete
StoreConfigurationentities. - Test cascade delete: Delete a
StoreConfiguration→ verify all linkedPasswordEntityrows are deleted. - Test relationship integrity: Create
PasswordEntityrows 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
StoreConfigurationcreated, app works. - Test upgrade migration from single-store:
- Set up a pre-migration Core Data database (using the old model version) with
PasswordEntityrows, populateDefaultswith git URL/branch/username, and populate keychain with PGP + SSH keys. - Run the migration.
- Verify: one
StoreConfigurationexists with values from Defaults, allPasswordEntityrows are linked to it, keychain entries are namespaced under the new store's ID.
- Set up a pre-migration Core Data database (using the old model version) with
- 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
PasswordStoreon 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}/, verifyPasswordEntityrows are linked to the rightStoreConfiguration. - Test two stores independently: Clone two different repos, verify each store's entities are separate, deleting one doesn't affect the other.
- Test
eraseStoreDatascoped 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
fetchAllfiltered by one store. - Test
fetchAllfiltered by multiple visible stores (the AutoFill / password list scenario). - Test
fetchUnsyncedfiltered 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 →
StoreConfigurationdeleted → 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 == truestores 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: ExtendCoreDataTestCaseto support the new model version withStoreConfiguration. Provide a helper to create aStoreConfiguration+ 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.sqlitefile 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
StoreConfigurationentity and thestorerelationship onPasswordEntityrequires a lightweight migration (new entity + new optional relationship). The post-migration step creates a defaultStoreConfigurationfrom existing Defaults and assigns all existingPasswordEntityrows to it. - Memory: Multiple
PasswordStoreinstances each holding aGTRepositoryandPGPAgent— 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.sharedsingleton referenced from ~20+ call sites (app, AutoFill, passExtension, Shortcuts)PGPAgent.sharedsingleton holds single key pairGlobalshas all paths asstatic let(single repo, single DB, single key paths)DefaultsKeys— all git/PGP settings single-valued in shared UserDefaultsAppKeychain.shared— flat keys, no per-store namespace- Core Data: single
PasswordEntityentity, 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-idper-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)