passforios/plans/02-multi-recipient-encryption-plan.md

102 lines
5.5 KiB
Markdown
Raw Normal View History

2026-03-08 21:09:00 +01:00
# 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.
Options:
- Import additional public keys (alongside the user's own key pair)
- Or fetch them from a keyserver (out of scope for initial implementation)
- The PGP key import flow should allow importing multiple public keys
- `PGPAgent.initKeys()` already supports loading multiple keys from a single armored blob (both `GopenPGPInterface` and `ObjectivePGPInterface` parse multi-key armored input)
---
## 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 |
| T | Tests (see testing section) | Steps 1-6 |
---
## 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.