Compare commits

...

1 commit

Author SHA1 Message Date
Lysann Tranvouez
520985e93e feature implementation plans 2026-03-08 21:53:17 +01:00
3 changed files with 713 additions and 0 deletions

View file

@ -0,0 +1,98 @@
# Improve Test Coverage Plan
## Motivation
The passKit codebase has ~100 test methods but critical components that will be heavily refactored (for multi-store support and other changes) have little or no test coverage. Adding regression tests now prevents silent breakage during future work.
This is standalone — it should be done before any other refactoring.
---
## Current Test Coverage
### Well-tested areas
- Password parsing (`Password`, `Parser`, `AdditionField`, OTP, `TokenBuilder`) — ~40 tests
- `PGPAgent` — 8 tests covering multiple key types, error cases, passphrase handling
- `PasswordGenerator` — 8 tests
- `GitRepository` — 8 tests (uses real temp git repos on disk)
- `GitCredential` — 6 tests (SSH test is skipped/"failed in CI")
- `PasswordEntity` Core Data operations — 6 tests (uses in-memory store via `CoreDataTestCase`)
- `KeyFileManager` — 7 tests
- `QRKeyScanner` — 6 tests
- String/Array extensions — 6 tests
### Critical gaps (zero tests)
| Component | Notes |
|-----------|-------|
| **`PasswordStore`** (36 methods) | Only 1 integration test that clones from GitHub. No unit tests for pull, push, add, delete, edit, decrypt, encrypt, reset, erase, eraseStoreData, deleteCoreData, fetchPasswordEntityCoreData, initPasswordEntityCoreData. |
| **`AppKeychain`** | Zero tests. Only exercised indirectly via `DictBasedKeychain` mock. |
| **`PersistenceController` / Core Data stack** | Only the `isUnitTest: true` path is exercised. No tests for `reinitializePersistentStore`, `deletePersistentStore`, error recovery. |
| **Services** (`PasswordDecryptor`, `PasswordEncryptor`, `PasswordManager`, `PasswordNavigationDataSource`) | Zero tests. Core business logic that ties `PasswordStore` + `PGPAgent` together. |
| **All view controllers (28+)** | Zero tests. No UI test target exists. |
| **AutoFill / Share / Shortcuts extensions** | Zero tests. No test targets for extensions. |
| **`PasscodeLock`** | Zero tests. Security-critical. |
### Test infrastructure that already exists
- `CoreDataTestCase` — base class with in-memory `PersistenceController` (reusable)
- `DictBasedKeychain` — in-memory `KeyStore` mock (reusable)
- `TestPGPKeys` — PGP key fixtures for RSA2048, RSA4096, ED25519, NISTP384, multi-key sets
---
## Implementation
### 1. `PasswordStore` unit tests (highest priority)
The single existing test (`testCloneAndDecryptMultiKeys`) depends on network access. Add offline unit tests using a local git repo fixture:
- **Setup/teardown**: Create a temp directory, `git init`, add `.gpg-id` + encrypted `.gpg` files, so tests don't need network.
- **Test `initPasswordEntityCoreData`**: Clone a local fixture repo → verify correct `PasswordEntity` tree in Core Data (names, paths, directories, parent-child relationships).
- **Test `deleteCoreData`**: Populate, then delete, verify empty.
- **Test `eraseStoreData`**: Verify repo directory deleted, Core Data cleared, git handle nil'd.
- **Test `erase`**: Verify full cleanup (keychain, defaults, passcode, PGP state).
- **Test `fetchPasswordEntityCoreData`**: Verify fetch with parent filter, withDir filter.
- **Test encrypt → save → decrypt round-trip**: Using `DictBasedKeychain` + test PGP keys + local repo.
- **Test `add` / `delete` / `edit`**: Verify filesystem + Core Data + git commit.
- **Test `reset`**: Verify Core Data rebuilt to match filesystem after git reset.
### 2. `PasswordEntity` relationship tests
Extend `PasswordEntityTest` (already uses `CoreDataTestCase`):
- **Test `initPasswordEntityCoreData` BFS walk**: Create a temp directory tree with `.gpg` files, call the static method, verify entity tree matches filesystem.
- **Test that `.gpg` extension is stripped** from names but non-`.gpg` files keep their names.
- **Test hidden files are skipped**.
- **Test empty directories**.
### 3. `AppKeychain` tests
Basic tests against the real Keychain API (or a test wrapper):
- **Test `add` / `get` / `removeContent`** round-trip.
- **Test `removeAllContent`**.
- **Test `contains`**.
- **Test `removeAllContent(withPrefix:)`** — this method already exists and will be useful for per-store cleanup.
### 4. `PersistenceController` tests
- **Test `reinitializePersistentStore`** — verify existing data is gone after reinit.
- **Test model loading** — verify the `.momd` loads correctly.
### 5. Test infrastructure: local git repo fixture builder
A helper that creates a temp git repo with configurable `.gpg-id`, encrypted `.gpg` files, and directory structure. Replaces the current network-dependent clone in `PasswordStoreTest`.
---
## Implementation Order
All steps are independent and can be done in parallel:
| Step | Description |
|------|-------------|
| 1 | `PasswordStore` unit tests (offline, local git fixture) |
| 2 | `PasswordEntity` BFS walk + relationship tests |
| 3 | `AppKeychain` tests |
| 4 | `PersistenceController` tests |
| 5 | Local git repo fixture builder (prerequisite for step 1) |

View file

@ -0,0 +1,192 @@
# Multi-Recipient Encryption Plan
## Concept
The `pass` password store format supports encrypting each password to multiple PGP keys via `.gpg-id` files (one key ID per line). This enables sharing a store with other users — each person imports the same git repository but decrypts with their own private key. When adding or editing a password, it must be encrypted to **all** key IDs listed in `.gpg-id`.
The app currently has a setting (`isEnableGPGIDOn`) that reads `.gpg-id` for per-directory key selection, but it only supports a single key ID. This plan fixes every layer to support multiple recipients.
This is standalone — it can be implemented before or after multi-store support.
---
## Current State
The codebase does **not** support encrypting to multiple public keys. Every layer assumes a single recipient:
| Layer | Current state | What needs to change |
|-------|--------------|---------------------|
| `.gpg-id` file format | Supports multiple key IDs (one per line) | No change needed |
| `findGPGID(from:)` | Returns the **entire file as one trimmed string** — does not split by newline | Split by newline, return `[String]` |
| `PGPInterface.encrypt()` | Signature: `encrypt(plainData:keyID:)` — singular `keyID: String?` | Add `encrypt(plainData:keyIDs:[String])` or change `keyID` to `keyIDs: [String]?` |
| `GopenPGPInterface` | Creates a `CryptoKeyRing` with **one** public key | Add all recipient public keys to the keyring before encrypting |
| `ObjectivePGPInterface` | Passes `keyring.keys` (all keys, including private) — accidentally multi-recipient but not intentionally | Filter to only the specified public keys, pass those to `ObjectivePGP.encrypt()` |
| `PGPAgent.encrypt()` | Routes to a single key via `keyID: String` | Accept `[String]` and pass through to the interface |
| `PasswordStore.encrypt()` | Calls `findGPGID()` for a single key ID string | Call the updated `findGPGID()`, pass the key ID array |
---
## Implementation
### 1. `findGPGID(from:) -> [String]`
Split file contents by newline, trim each line, filter empty lines. Return array of key IDs. Callers that only need a single key (e.g. for decryption routing) can use `.first`.
### 2. `PGPInterface` protocol
Change `encrypt(plainData:keyID:)` to `encrypt(plainData:keyIDs:)` where `keyIDs: [String]?`. When `nil`, encrypt to the first/default key (backward compatible).
### 3. `GopenPGPInterface.encrypt()`
Look up all keys matching the `keyIDs` array from `publicKeys`. Add each to the `CryptoKeyRing` (GopenPGP's `CryptoKeyRing` supports multiple keys via `add()`). Encrypt with the multi-key ring.
### 4. `ObjectivePGPInterface.encrypt()`
Filter `keyring.keys` to only the public keys matching the requested `keyIDs`. Pass the filtered array to `ObjectivePGP.encrypt()`.
### 5. `PGPAgent.encrypt()`
Update both overloads to accept `keyIDs: [String]?` and pass through to the interface.
### 6. `PasswordStore.encrypt()`
Call updated `findGPGID()`, pass the array to `PGPAgent`.
---
## Public Key Management
When a store lists multiple key IDs in `.gpg-id`, the user needs the public keys of all recipients. The user's own private key is sufficient for decryption (since the message is encrypted to all recipients), but all public keys are needed for re-encryption when editing.
### Current state
- The keychain holds exactly **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob.
- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the previous key pair entirely.
- Both `GopenPGPInterface` and `ObjectivePGPInterface` *can* parse multiple keys from a single armored blob (e.g. concatenated armor blocks). So if the user pastes multiple public keys into the single field, they would be parsed — but the encrypt path only uses one key, and the UI doesn't communicate this.
- There is no UI for viewing which key IDs are loaded.
### Key storage approach
Store all public keys as a single concatenated armored blob in the keychain (`pgpPublicKey`). Both interface implementations already parse multi-key blobs into dictionaries/keyrings. This avoids schema changes — we just need to **append** instead of **replace** when importing additional public keys.
The user's own private key stays as a separate single blob (`pgpPrivateKey`).
### 7. UI: Import additional recipient public keys
Add an "Import Recipient Key" action to the PGP key settings (alongside the existing import that sets the user's own key pair). This flow:
- Imports a public-key-only armored blob
- **Appends** it to the existing `pgpPublicKey` keychain entry (concatenating armored blocks)
- Does **not** touch the private key
- On success, shows the newly imported key ID(s)
The existing import flow ("Set PGP Keys") continues to replace the user's own key pair (public + private).
### 8. UI: View loaded key IDs and metadata
PGP keys carry a **User ID** field, typically in the format `"Name <email@example.com>"`. Both GopenPGP (`key.entity.PrimaryIdentity()`) and ObjectivePGP (`key.keyID` + user ID packets) can access this. The app currently doesn't expose it.
Add key metadata to the `PGPInterface` protocol:
```swift
struct PGPKeyInfo {
let fingerprint: String // full fingerprint
let shortKeyID: String // last 8 hex chars
let userID: String? // "Name <email>" from the primary identity
let isPrivate: Bool // has a matching private key
let isExpired: Bool
let isRevoked: Bool
}
var keyInfo: [PGPKeyInfo] { get }
```
Both `GopenPGPInterface` and `ObjectivePGPInterface` should implement this by iterating their loaded keys.
Add a read-only section to the PGP key settings showing all loaded public keys. Each row shows:
- **User ID** (e.g. `"Alice <alice@example.com>"`) as the primary label — this is the human-readable identifier
- **Short key ID** (e.g. `ABCD1234`) as the secondary label
- Badge/icon if it's the user's own key (has matching private key) vs a recipient-only key
- Badge/icon if expired or revoked
- Swipe-to-delete to remove a recipient public key
This also informs the `.gpg-id` editing UI (§9) — when the user adds/removes recipients from `.gpg-id`, they see names and emails, not just opaque hex key IDs.
### 9. UI: View/edit `.gpg-id` files
When `isEnableGPGIDOn` is enabled, add visibility into `.gpg-id`:
- In the password detail view, show which key IDs the password is encrypted to (from the nearest `.gpg-id` file)
- In folder navigation, show an indicator on directories that have their own `.gpg-id`
- Tapping the indicator shows the `.gpg-id` contents (list of key IDs) with an option to edit
- Editing `.gpg-id` triggers re-encryption of all passwords in the directory (see §10)
Note: Viewing `.gpg-id` is low-effort and high-value. Editing is more complex due to re-encryption. These can be split into separate steps.
### 10. Re-encryption when `.gpg-id` changes
When the user edits a `.gpg-id` file (adding/removing a recipient), all `.gpg` files in that directory (and subdirectories without their own `.gpg-id`) must be re-encrypted to the new recipient list. This is equivalent to `pass init -p subfolder KEY1 KEY2`.
Steps:
1. Write the new `.gpg-id` file
2. For each `.gpg` file under the directory:
- Decrypt with the user's private key
- Re-encrypt to the new recipient list
- Overwrite the `.gpg` file
3. Git add all changed files + `.gpg-id`
4. Git commit
This can be expensive for large directories. Show progress and allow cancellation.
---
## Implementation Order
| Step | Description | Depends On |
|------|-------------|------------|
| 1 | `findGPGID` returns `[String]` + update callers | — |
| 2 | `PGPInterface` protocol change (`keyIDs: [String]?`) | — |
| 3 | `GopenPGPInterface` multi-key encryption | Step 2 |
| 4 | `ObjectivePGPInterface` multi-key encryption | Step 2 |
| 5 | `PGPAgent` updated overloads | Steps 2-4 |
| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGID` | Steps 1+5 |
| 7 | UI: import additional recipient public keys | Step 5 |
| 8 | UI: view loaded key IDs | Step 5 |
| 9a | UI: view `.gpg-id` in password detail / folder view | Step 1 |
| 9b | UI: edit `.gpg-id` | Step 9a |
| 10 | Re-encryption when `.gpg-id` changes | Steps 6+9b |
| T | Tests (see testing section) | Steps 1-10 |
---
## Testing
### Pre-work: existing encryption tests
The `PGPAgentTest` already covers single-key encrypt/decrypt with multiple key types. These serve as the regression baseline.
### Multi-recipient encryption tests
- **Test `findGPGID` with multi-line `.gpg-id`**: File with two key IDs on separate lines → returns `[String]` with both.
- **Test `findGPGID` with single-line `.gpg-id`**: Backward compatible → returns `[String]` with one element.
- **Test `findGPGID` with empty lines and whitespace**: Trims and filters correctly.
- **Test `GopenPGPInterface.encrypt` with multiple keys**: Encrypt with two public keys → decrypt succeeds with either private key.
- **Test `ObjectivePGPInterface.encrypt` with multiple keys**: Same as above.
- **Test `PGPAgent.encrypt` with `keyIDs` array**: Routes through correctly to the interface.
- **Test round-trip**: Encrypt with key IDs `[A, B]` → user with private key A can decrypt, user with private key B can decrypt.
- **Test encrypt with single keyID still works**: Backward compatibility — `keyIDs: ["X"]` behaves like the old `keyID: "X"`.
- **Test encrypt with unknown keyID in list**: If one of the key IDs is not in the keyring, appropriate error is thrown.
- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption.
### Key management tests
- **Test appending recipient public key**: Import user's key pair → append a second public key → both key IDs are available. Original private key still works for decryption.
- **Test removing a recipient public key**: Remove one public key from the concatenated blob → only the remaining key IDs are available.
- **Test replacing key pair doesn't lose recipient keys**: Import user's key pair → add recipient key → re-import user's key pair → recipient key is still present (or: design decision — should re-import clear everything?).
### `.gpg-id` and re-encryption tests
- **Test re-encryption**: Edit `.gpg-id` to add a recipient → all passwords in directory are re-encrypted → new recipient can decrypt.
- **Test re-encryption removes access**: Edit `.gpg-id` to remove a recipient → re-encrypted passwords cannot be decrypted with the removed key.
- **Test `.gpg-id` directory scoping**: Subdirectory `.gpg-id` overrides parent. Passwords in subdirectory use subdirectory's recipients.
- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption.

View file

@ -0,0 +1,423 @@
# 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)