diff --git a/passKit/Crypto/GopenPGPInterface.swift b/passKit/Crypto/GopenPGPInterface.swift index 79e976a..638ce25 100644 --- a/passKit/Crypto/GopenPGPInterface.swift +++ b/passKit/Crypto/GopenPGPInterface.swift @@ -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 { diff --git a/passKit/Crypto/ObjectivePGPInterface.swift b/passKit/Crypto/ObjectivePGPInterface.swift index 06b1c6b..01a5a2b 100644 --- a/passKit/Crypto/ObjectivePGPInterface.swift +++ b/passKit/Crypto/ObjectivePGPInterface.swift @@ -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)! } diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index f96784a..1c49260 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -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()) diff --git a/passKit/Crypto/PGPInterface.swift b/passKit/Crypto/PGPInterface.swift index 88cfd5f..f90c4bd 100644 --- a/passKit/Crypto/PGPInterface.swift +++ b/passKit/Crypto/PGPInterface.swift @@ -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 diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index 4fc1102..522ce1a 100644 --- a/passKitTests/Crypto/PGPAgentTest.swift +++ b/passKitTests/Crypto/PGPAgentTest.swift @@ -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) diff --git a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift index 3e4b891..73253f6 100644 --- a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift +++ b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift @@ -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) + } + } } diff --git a/passKitTests/Mocks/MockPGPInterface.swift b/passKitTests/Mocks/MockPGPInterface.swift index 432f678..481d052 100644 --- a/passKitTests/Mocks/MockPGPInterface.swift +++ b/passKitTests/Mocks/MockPGPInterface.swift @@ -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()) }