passforios/plans/03-multi-store-plan.md
2026-03-08 21:53:17 +01:00

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 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 StoreConfigurationPasswordEntity 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, 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 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: PasswordEntityStoreConfiguration 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 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

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

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

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 (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

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 PasswordStorePasswordStoreManager 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 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)