PGPInterface can encrypt with multiple keys, PGPAgent can encrypt with all keys

This commit is contained in:
Lysann Tranvouez 2026-03-11 00:21:30 +01:00
parent 8d4f3af475
commit 84eaf4ad7d
7 changed files with 158 additions and 19 deletions

View file

@ -123,26 +123,43 @@ struct GopenPGPInterface: PGPInterface {
}
}
@available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) instead.")
func encrypt(plainData: Data, keyID: String?) throws -> Data {
let key: CryptoKey? = {
if let keyID {
return publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value
}
return publicKeys.first?.value
}()
guard let keyID = keyID ?? publicKeys.keys.first else {
// this is invalid, but we want the new function to throw the error for us
return try encrypt(plainData: plainData, keyIDs: [])
}
return try encrypt(plainData: plainData, keyIDs: [keyID])
}
guard let publicKey = key else {
func encryptWithAllKeys(plainData: Data) throws -> Data {
let keyIDs = publicKeys.keys.filter { key in privateKeys.keys.contains(key) }
return try encrypt(plainData: plainData, keyIDs: keyIDs)
}
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
let keys: [CryptoKey] = keyIDs.compactMap { keyID in
publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value
}
guard let firstKey = keys.first else {
throw AppError.encryption
}
let otherKeys = keys.dropFirst()
var error: NSError?
guard let keyRing = CryptoNewKeyRing(publicKey, &error) else {
guard let keyRing = CryptoNewKeyRing(firstKey, &error) else {
guard error == nil else {
throw error!
}
throw AppError.encryption
}
do {
try otherKeys.forEach { key in
try keyRing.add(key)
}
} catch {
throw AppError.encryption
}
let encryptedData = try keyRing.encrypt(CryptoNewPlainMessage(plainData.mutable as Data), privateKey: nil)
if Defaults.encryptInArmored {

View file

@ -33,8 +33,25 @@ struct ObjectivePGPInterface: PGPInterface {
}
}
@available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) instead.")
func encrypt(plainData: Data, keyID _: String?) throws -> Data {
let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil)
// Backwards compatibility: ignore keyID parameter and encrypted with all keys in the keyring
try encryptWithAllKeys(plainData: plainData)
}
func encryptWithAllKeys(plainData: Data) throws -> Data {
try encrypt(plainData: plainData, keyIDs: keyID)
}
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
let keys = try keyIDs.map { keyID in
guard let key = keyring.findKey(keyID) else {
throw AppError.pgpPublicKeyNotFound(keyID: keyID)
}
return key
}
let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keys, passphraseForKey: nil)
if Defaults.encryptInArmored {
return Armor.armored(encryptedData, as: .message).data(using: .ascii)!
}

View file

@ -102,7 +102,7 @@ public class PGPAgent {
throw AppError.pgpPublicKeyNotFound(keyID: keyID)
}
}
return try pgpInterface.encrypt(plainData: plainData, keyID: keyID)
return try pgpInterface.encrypt(plainData: plainData, keyIDs: [keyID])
}
public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? {
@ -127,6 +127,7 @@ public class PGPAgent {
return result
}
@available(*, deprecated, message: "Use encrypt(plainData:keyID:) or encryptWithAllKeys(plainData:) instead.")
public func encrypt(plainData: Data) throws -> Data {
try checkAndInit()
guard let pgpInterface else {
@ -135,6 +136,14 @@ public class PGPAgent {
return try pgpInterface.encrypt(plainData: plainData, keyID: nil)
}
public func encryptWithAllKeys(plainData: Data) throws -> Data {
try checkAndInit()
guard let pgpInterface else {
throw AppError.encryption
}
return try pgpInterface.encryptWithAllKeys(plainData: plainData)
}
public var isPrepared: Bool {
keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())
&& keyStore.contains(key: PGPKey.PRIVATE.getKeychainKey())

View file

@ -9,7 +9,11 @@
protocol PGPInterface {
func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data?
@available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) instead.")
func encrypt(plainData: Data, keyID: String?) throws -> Data
// encrypt with all public keys for which we also have a private key
func encryptWithAllKeys(plainData: Data) throws -> Data
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data
func containsPublicKey(with keyID: String) -> Bool

View file

@ -180,6 +180,35 @@ final class PGPAgentTest: XCTestCase {
XCTAssertEqual(passphraseRequestCalledCount, 3)
}
func testEncryptWithAllKeys() throws {
// When multiple keys are imported, the agent should be able to encrypt without specifying the keyID.
// It should use all public keys for which we also have private keys, and the encrypted message should be able to be decrypted by any of the private keys.
keychain.removeAllContent()
// no private key for ED25519
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys)
try pgpAgent.initKeys()
let encryptedData = try pgpAgent.encryptWithAllKeys(plainData: testData)
try [RSA2048.fingerprint, RSA4096.fingerprint].forEach { keyID in
let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
XCTAssertEqual(decryptedData, testData)
}
XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) {
XCTAssertEqual($0 as! AppError, AppError.pgpPrivateKeyNotFound(keyID: ED25519.fingerprint))
}
// add private key for ED25519
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys | ED25519.privateKey)
try pgpAgent.initKeys()
XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) {
XCTAssertEqual($0 as! AppError, AppError.keyExpiredOrIncompatible)
}
}
private func importKeys(_ publicKey: String, _ privateKey: String) throws {
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: publicKey)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: privateKey)

View file

@ -536,12 +536,13 @@ final class PGPAgentLowLevelTests: XCTestCase {
_ = try agent.encrypt(plainData: testDecryptedData, keyID: shortID)
XCTAssertEqual(mockPGP.containsPublicKeyCalls, [shortID])
XCTAssertEqual(mockPGP.encryptCalls[0].keyID, shortID)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 1)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].keyIDs, [shortID])
}
// MARK: - Encrypt passthrough tests (for completeness of mock interaction)
/// encrypt(plainData:keyID:) calls containsPublicKey and passes data through.
/// encrypt(plainData:keyID:) calls containsPublicKey and passes data through via encrypt(plainData:keyIDs:).
func testEncryptWithKeyID_keyFound_callsInterface() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = [longFingerprint]
@ -549,9 +550,9 @@ final class PGPAgentLowLevelTests: XCTestCase {
let result = try agent.encrypt(plainData: testDecryptedData, keyID: longFingerprint)
XCTAssertEqual(result, mockPGP.encryptResult)
XCTAssertEqual(mockPGP.encryptCalls.count, 1)
XCTAssertEqual(mockPGP.encryptCalls[0].keyID, longFingerprint)
XCTAssertEqual(mockPGP.encryptCalls[0].plainData, testDecryptedData)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 1)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].keyIDs, [longFingerprint])
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].plainData, testDecryptedData)
}
/// encrypt with unknown key and single available key falls back.
@ -565,7 +566,7 @@ final class PGPAgentLowLevelTests: XCTestCase {
XCTAssertEqual(result, mockPGP.encryptResult)
XCTAssertEqual(mockPGP.containsPublicKeyCalls, [shortID])
XCTAssertEqual(mockPGP.encryptCalls[0].keyID, longFingerprint)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].keyIDs, [longFingerprint])
}
/// encrypt with unknown key and multiple keys throws.
@ -576,10 +577,10 @@ final class PGPAgentLowLevelTests: XCTestCase {
XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyID: "a1024dae")) { error in
XCTAssertEqual(error as? AppError, AppError.pgpPublicKeyNotFound(keyID: "a1024dae"))
}
XCTAssertEqual(mockPGP.encryptCalls.count, 0)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 0)
}
/// encrypt(plainData:) without keyID passes nil to interface.
/// encrypt(plainData:) without keyID passes nil to the deprecated interface method.
func testEncryptNoKeyID_passesNilToInterface() throws {
let result = try agent.encrypt(plainData: testDecryptedData)
@ -599,4 +600,39 @@ final class PGPAgentLowLevelTests: XCTestCase {
XCTAssertEqual(error as? AppError, AppError.encryption)
}
}
// MARK: - encryptWithAllKeys
/// encryptWithAllKeys delegates to pgpInterface.encryptWithAllKeys.
func testEncryptWithAllKeys_callsInterface() throws {
mockPGP.encryptResult = Data("all-keys-encrypted".utf8)
let result = try agent.encryptWithAllKeys(plainData: testDecryptedData)
XCTAssertEqual(result, Data("all-keys-encrypted".utf8))
XCTAssertEqual(mockPGP.encryptWithAllKeysCalls.count, 1)
XCTAssertEqual(mockPGP.encryptWithAllKeysCalls[0].plainData, testDecryptedData)
// Does not call containsPublicKey or the single/multi-key encrypt methods.
XCTAssertEqual(mockPGP.containsPublicKeyCalls.count, 0)
XCTAssertEqual(mockPGP.encryptCalls.count, 0)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 0)
}
/// encryptWithAllKeys propagates errors from interface.
func testEncryptWithAllKeys_interfaceThrows_propagatesError() {
mockPGP.encryptError = AppError.encryption
XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in
XCTAssertEqual(error as? AppError, AppError.encryption)
}
}
/// encryptWithAllKeys throws encryption error when pgpInterface is nil (checkAndInit fails).
func testEncryptWithAllKeys_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws {
keychain.removeContent(for: Globals.pgpKeyPassphrase)
XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in
XCTAssertEqual(error as? AppError, AppError.keyImport)
}
}
}

View file

@ -36,9 +36,20 @@ class MockPGPInterface: PGPInterface {
let keyID: String?
}
struct EncryptMultiKeyCall {
let plainData: Data
let keyIDs: [String]
}
struct EncryptWithAllKeysCall {
let plainData: Data
}
var decryptCalls: [DecryptCall] = []
var resolvedPassphrases: [String] = []
var encryptCalls: [EncryptCall] = []
var encryptMultiKeyCalls: [EncryptMultiKeyCall] = []
var encryptWithAllKeysCalls: [EncryptWithAllKeysCall] = []
var containsPublicKeyCalls: [String] = []
var containsPrivateKeyCalls: [String] = []
@ -63,6 +74,22 @@ class MockPGPInterface: PGPInterface {
return encryptResult
}
func encryptWithAllKeys(plainData: Data) throws -> Data {
encryptWithAllKeysCalls.append(EncryptWithAllKeysCall(plainData: plainData))
if let error = encryptError {
throw error
}
return encryptResult
}
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
encryptMultiKeyCalls.append(EncryptMultiKeyCall(plainData: plainData, keyIDs: keyIDs))
if let error = encryptError {
throw error
}
return encryptResult
}
func containsPublicKey(with keyID: String) -> Bool {
containsPublicKeyCalls.append(keyID)
return publicKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) }