# 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 "`. 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 " 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 "`) 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.