From c4f81c16ebf0f61d93cb63db94b20666bf6d8a75 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Tue, 10 Mar 2026 16:51:40 +0100 Subject: [PATCH 1/8] move variables into smaller scope --- passKit/Models/PasswordStore.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index b918ab4..c822ffa 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -420,9 +420,9 @@ public class PasswordStore { } public func encrypt(password: Password, keyID: String? = nil) throws -> Data { - let encryptedDataPath = password.fileURL(in: storeURL) - let keyID = keyID ?? findGPGID(from: encryptedDataPath) if Defaults.isEnableGPGIDOn { + let encryptedDataPath = password.fileURL(in: storeURL) + let keyID = keyID ?? findGPGID(from: encryptedDataPath) return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID) } return try PGPAgent.shared.encrypt(plainData: password.plainData) From 01739e5aecada91cb200fdc032e2b805bfa9f988 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Tue, 10 Mar 2026 17:14:11 +0100 Subject: [PATCH 2/8] decryption: always request key passphrase based on key ID --- passKit/Crypto/GopenPGPInterface.swift | 3 ++- passKit/Crypto/ObjectivePGPInterface.swift | 9 ++++++-- passKit/Crypto/PGPAgent.swift | 26 +++++++++++----------- passKit/Crypto/PGPInterface.swift | 2 +- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/passKit/Crypto/GopenPGPInterface.swift b/passKit/Crypto/GopenPGPInterface.swift index 34f0622..9ec6a77 100644 --- a/passKit/Crypto/GopenPGPInterface.swift +++ b/passKit/Crypto/GopenPGPInterface.swift @@ -70,7 +70,7 @@ struct GopenPGPInterface: PGPInterface { privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) } } - func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? { + func decrypt(encryptedData: Data, keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { let key: CryptoKey? = { if let keyID { return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value @@ -87,6 +87,7 @@ struct GopenPGPInterface: PGPInterface { try privateKey.isLocked(&isLocked) var unlockedKey: CryptoKey! if isLocked.boolValue { + let passphrase = passPhraseForKey(privateKey.getFingerprint()) unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8)) } else { unlockedKey = privateKey diff --git a/passKit/Crypto/ObjectivePGPInterface.swift b/passKit/Crypto/ObjectivePGPInterface.swift index 768d785..f94de71 100644 --- a/passKit/Crypto/ObjectivePGPInterface.swift +++ b/passKit/Crypto/ObjectivePGPInterface.swift @@ -24,8 +24,13 @@ struct ObjectivePGPInterface: PGPInterface { } } - func decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? { - try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase } + func decrypt(encryptedData: Data, keyID _: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { + try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { selectedKey in + guard let selectedKey else { + return nil + } + return passPhraseForKey(selectedKey.keyID.longIdentifier) + } } func encrypt(plainData: Data, keyID _: String?) throws -> Data { diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index f193515..87c9862 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -69,14 +69,14 @@ public class PGPAgent { latestDecryptStatus = false // Get the PGP key passphrase. - var passphrase = "" - if previousDecryptStatus == false { - passphrase = requestPGPKeyPassphrase(keyID) - } else { - passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: keyID)) ?? requestPGPKeyPassphrase(keyID) + let providePassPhraseForKey = { (selectedKeyID: String) -> String in + if previousDecryptStatus == false { + return requestPGPKeyPassphrase(selectedKeyID) + } + return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) } // Decrypt. - guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else { + guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passPhraseForKey: providePassPhraseForKey) else { return nil } // The decryption step has succeed. @@ -100,21 +100,21 @@ public class PGPAgent { return try pgpInterface.encrypt(plainData: plainData, keyID: keyID) } - public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: (String) -> String) throws -> Data? { + public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { // Remember the previous status and set the current status let previousDecryptStatus = latestDecryptStatus latestDecryptStatus = false // Init keys. try checkAndInit() // Get the PGP key passphrase. - var passphrase = "" - if previousDecryptStatus == false { - passphrase = requestPGPKeyPassphrase("") - } else { - passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) ?? requestPGPKeyPassphrase("") + let providePassPhraseForKey = { (selectedKeyID: String) -> String in + if previousDecryptStatus == false { + return requestPGPKeyPassphrase(selectedKeyID) + } + return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) } // Decrypt. - guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passphrase: passphrase) else { + guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passPhraseForKey: providePassPhraseForKey) else { return nil } // The decryption step has succeed. diff --git a/passKit/Crypto/PGPInterface.swift b/passKit/Crypto/PGPInterface.swift index b77831d..cb0d107 100644 --- a/passKit/Crypto/PGPInterface.swift +++ b/passKit/Crypto/PGPInterface.swift @@ -7,7 +7,7 @@ // protocol PGPInterface { - func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? + func decrypt(encryptedData: Data, keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? func encrypt(plainData: Data, keyID: String?) throws -> Data From 510eb8e15e8f2f6bad2d4d94307bde9a87a99e1f Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Tue, 10 Mar 2026 21:55:51 +0100 Subject: [PATCH 3/8] reference new version of gopenpgp with a new helper (HelperPassGetHexSubkeyIDsJSON) --- scripts/gopenpgp_build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gopenpgp_build.sh b/scripts/gopenpgp_build.sh index 98dc8bf..b851cb9 100755 --- a/scripts/gopenpgp_build.sh +++ b/scripts/gopenpgp_build.sh @@ -14,7 +14,7 @@ GOPENPGP_PATH="$CHECKOUT_PATH/gopenpgp" mkdir -p "$OUTPUT_PATH" mkdir -p "$CHECKOUT_PATH" -git clone --depth 1 --branch "$GOPENPGP_VERSION" https://github.com/mssun/gopenpgp.git "$GOPENPGP_PATH" +git clone --depth 1 --branch "$GOPENPGP_VERSION" https://forgejo.tranvouez.eu/lysann/passforios-gopenpgp.git "$GOPENPGP_PATH" pushd "$GOPENPGP_PATH" mkdir -p dist From 39dab8c6c026b295c8e7e44b118a1e650b1a0220 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Tue, 10 Mar 2026 22:16:42 +0100 Subject: [PATCH 4/8] decryption: GopenPGPInterface tries to identify decryption key from message metadata So the system can have multiple private keys, and the caller doesn't need to specify a specific one regardless. Ideally: If there are several matches we could also take into account which keys have already been unlocked (or passthrases saved in keychain). Right now it only grabs the first match. --- passKit/Crypto/GopenPGPInterface.swift | 62 ++++++++++++++++++---- passKit/Crypto/ObjectivePGPInterface.swift | 2 +- passKit/Crypto/PGPAgent.swift | 4 +- passKit/Crypto/PGPInterface.swift | 2 +- passKitTests/Crypto/PGPAgentTest.swift | 19 +++++++ 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/passKit/Crypto/GopenPGPInterface.swift b/passKit/Crypto/GopenPGPInterface.swift index 9ec6a77..79e976a 100644 --- a/passKit/Crypto/GopenPGPInterface.swift +++ b/passKit/Crypto/GopenPGPInterface.swift @@ -16,6 +16,7 @@ struct GopenPGPInterface: PGPInterface { private var publicKeys: [String: CryptoKey] = [:] private var privateKeys: [String: CryptoKey] = [:] + private var privateSubkeyToKeyIDMapping: [String: String] = [:] // value is the key in privateKeys map init(publicArmoredKey: String, privateArmoredKey: String) throws { let pubKeys = extractKeysFromArmored(str: publicArmoredKey) @@ -40,7 +41,24 @@ struct GopenPGPInterface: PGPInterface { } throw AppError.keyImport } - privateKeys[cryptoKey.getFingerprint().lowercased()] = cryptoKey + + let keyID = cryptoKey.getFingerprint().lowercased() + privateKeys[keyID] = cryptoKey + + guard let subkeyIDsJSON = HelperPassGetHexSubkeyIDsJSON(cryptoKey) else { + guard error == nil else { + throw error! + } + throw AppError.keyImport + } + do { + let subkeyIDs = try JSONDecoder().decode([String].self, from: subkeyIDsJSON) + for subkeyID in subkeyIDs { + privateSubkeyToKeyIDMapping[subkeyID] = keyID + } + } catch { + throw AppError.keyImport + } } } @@ -70,15 +88,13 @@ struct GopenPGPInterface: PGPInterface { privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) } } - func decrypt(encryptedData: Data, keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { - let key: CryptoKey? = { - if let keyID { - return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value - } - return privateKeys.first?.value - }() + func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { + let message = createPGPMessage(from: encryptedData) + guard let message else { + throw AppError.decryption + } - guard let privateKey = key else { + guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else { throw AppError.decryption } @@ -101,7 +117,6 @@ struct GopenPGPInterface: PGPInterface { throw AppError.decryption } - let message = createPGPMessage(from: encryptedData) return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data } catch { throw Self.errorMapping[error.localizedDescription, default: error] @@ -148,6 +163,33 @@ struct GopenPGPInterface: PGPInterface { var shortKeyID: [String] { publicKeys.keys.map { $0.suffix(8).uppercased() } } + + private func findDecryptionKey(message: CryptoPGPMessage, keyIDHint: String?) throws -> CryptoKey? { + var keyIDCandidates: any Collection = privateKeys.keys + do { + if let encryptionKeysJSON = message.getHexEncryptionKeyIDsJson() { + // these are the subkeys (encryption keys), not the primaries keys (whose fingerprints we have in the privateKeys map), + // so we need to map them back to the primary keyIDs using privateSubkeyToKeyIDMapping + let validSubkeys = try JSONDecoder().decode([String].self, from: encryptionKeysJSON) + let validKeyIDs = validSubkeys.compactMap { privateSubkeyToKeyIDMapping[$0] } + if #available(iOSApplicationExtension 16.0, *) { + assert(validKeyIDs.isEmpty || !Set(keyIDCandidates).isDisjoint(with: validKeyIDs)) + } + keyIDCandidates = validKeyIDs + } + } catch { + // fall back to legacy approach of trying first in privateKeys (or preferring hint) + } + + if let keyIDHint { + keyIDCandidates = keyIDCandidates.filter { key in key.hasSuffix(keyIDHint.lowercased()) } + } + guard let selectedKeyID = keyIDCandidates.first else { + throw keyIDHint != nil ? AppError.keyExpiredOrIncompatible : AppError.decryption + } + + return privateKeys[selectedKeyID] + } } public func createPGPMessage(from encryptedData: Data) -> CryptoPGPMessage? { diff --git a/passKit/Crypto/ObjectivePGPInterface.swift b/passKit/Crypto/ObjectivePGPInterface.swift index f94de71..06b1c6b 100644 --- a/passKit/Crypto/ObjectivePGPInterface.swift +++ b/passKit/Crypto/ObjectivePGPInterface.swift @@ -24,7 +24,7 @@ struct ObjectivePGPInterface: PGPInterface { } } - func decrypt(encryptedData: Data, keyID _: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { + func decrypt(encryptedData: Data, keyIDHint _: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { selectedKey in guard let selectedKey else { return nil diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 87c9862..5378461 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -76,7 +76,7 @@ public class PGPAgent { return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) } // Decrypt. - guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passPhraseForKey: providePassPhraseForKey) else { + guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyIDHint: keyID, passPhraseForKey: providePassPhraseForKey) else { return nil } // The decryption step has succeed. @@ -114,7 +114,7 @@ public class PGPAgent { return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) } // Decrypt. - guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passPhraseForKey: providePassPhraseForKey) else { + guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyIDHint: nil, passPhraseForKey: providePassPhraseForKey) else { return nil } // The decryption step has succeed. diff --git a/passKit/Crypto/PGPInterface.swift b/passKit/Crypto/PGPInterface.swift index cb0d107..88cfd5f 100644 --- a/passKit/Crypto/PGPInterface.swift +++ b/passKit/Crypto/PGPInterface.swift @@ -7,7 +7,7 @@ // protocol PGPInterface { - func decrypt(encryptedData: Data, keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? + func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? func encrypt(plainData: Data, keyID: String?) throws -> Data diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index 8acb2be..4fc1102 100644 --- a/passKitTests/Crypto/PGPAgentTest.swift +++ b/passKitTests/Crypto/PGPAgentTest.swift @@ -87,6 +87,25 @@ final class PGPAgentTest: XCTestCase { } } + func testMultiKeysSelectMatchingPrivateKeyToDecrypt() throws { + keychain.removeAllContent() + try importKeys(RSA2048_RSA4096.publicKeys, RSA2048_RSA4096.privateKeys) + try pgpAgent.initKeys() + try [ + (true, true), + (true, false), + (false, true), + (false, false), + ].forEach { encryptInArmored, decryptFromArmored in + passKit.Defaults.encryptInArmored = encryptInArmored + let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: RSA2048.fingerprint) + passKit.Defaults.encryptInArmored = decryptFromArmored + // Note: not specifying the keyID to decrypt, so that the agent needs to find the matching private key by itself. + let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + XCTAssertEqual(decryptedData, testData) + } + } + func testNoPrivateKey() throws { try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048.publicKey) XCTAssertFalse(pgpAgent.isPrepared) From a56193dc86772b61c34f613eaa33007ed5827fdd Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Wed, 11 Mar 2026 00:21:30 +0100 Subject: [PATCH 5/8] PGPInterface can encrypt with multiple keys, PGPAgent can encrypt with all keys --- passKit/Crypto/GopenPGPInterface.swift | 35 ++++++++++++++++------ passKit/Crypto/ObjectivePGPInterface.swift | 19 +++++++++++- passKit/Crypto/PGPAgent.swift | 11 ++++++- passKit/Crypto/PGPInterface.swift | 4 +++ passKitTests/Crypto/PGPAgentTest.swift | 29 ++++++++++++++++++ 5 files changed, 87 insertions(+), 11 deletions(-) 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 5378461..33d49de 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -97,7 +97,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? { @@ -122,6 +122,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 { @@ -130,6 +131,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) From db60b526059a6e6487d56344c7e6fdb37f6b6c15 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Wed, 11 Mar 2026 00:26:03 +0100 Subject: [PATCH 6/8] move and rename test functions --- passKitTests/Crypto/PGPAgentTest.swift | 100 +++++++++++++------------ 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index 522ce1a..b7ba158 100644 --- a/passKitTests/Crypto/PGPAgentTest.swift +++ b/passKitTests/Crypto/PGPAgentTest.swift @@ -31,34 +31,7 @@ final class PGPAgentTest: XCTestCase { super.tearDown() } - private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? { - passKit.Defaults.encryptInArmored = encryptInArmored - let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: keyID) - passKit.Defaults.encryptInArmored = decryptFromArmored - return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase) - } - - func testMultiKeys() throws { - try [ - RSA2048_RSA4096, - ED25519_NISTP384, - ].forEach { testKeyInfo in - keychain.removeAllContent() - try importKeys(testKeyInfo.publicKeys, testKeyInfo.privateKeys) - XCTAssert(pgpAgent.isPrepared) - try pgpAgent.initKeys() - try [ - (true, true), - (true, false), - (false, true), - (false, false), - ].forEach { encryptInArmored, decryptFromArmored in - for id in testKeyInfo.fingerprints { - XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: encryptInArmored, decryptFromArmored: decryptFromArmored), testData) - } - } - } - } + // - MARK: Basic encrypt and decrypt tests func testBasicEncryptDecrypt() throws { try [ @@ -87,25 +60,6 @@ final class PGPAgentTest: XCTestCase { } } - func testMultiKeysSelectMatchingPrivateKeyToDecrypt() throws { - keychain.removeAllContent() - try importKeys(RSA2048_RSA4096.publicKeys, RSA2048_RSA4096.privateKeys) - try pgpAgent.initKeys() - try [ - (true, true), - (true, false), - (false, true), - (false, false), - ].forEach { encryptInArmored, decryptFromArmored in - passKit.Defaults.encryptInArmored = encryptInArmored - let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: RSA2048.fingerprint) - passKit.Defaults.encryptInArmored = decryptFromArmored - // Note: not specifying the keyID to decrypt, so that the agent needs to find the matching private key by itself. - let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - XCTAssertEqual(decryptedData, testData) - } - } - func testNoPrivateKey() throws { try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048.publicKey) XCTAssertFalse(pgpAgent.isPrepared) @@ -180,6 +134,49 @@ final class PGPAgentTest: XCTestCase { XCTAssertEqual(passphraseRequestCalledCount, 3) } + func testMultipleKeysLoaded() throws { + try [ + RSA2048_RSA4096, + ED25519_NISTP384, + ].forEach { testKeyInfo in + keychain.removeAllContent() + try importKeys(testKeyInfo.publicKeys, testKeyInfo.privateKeys) + XCTAssert(pgpAgent.isPrepared) + try pgpAgent.initKeys() + try [ + (true, true), + (true, false), + (false, true), + (false, false), + ].forEach { encryptInArmored, decryptFromArmored in + for id in testKeyInfo.fingerprints { + XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: encryptInArmored, decryptFromArmored: decryptFromArmored), testData) + } + } + } + } + + func testMultiKeysSelectMatchingPrivateKeyToDecrypt() throws { + keychain.removeAllContent() + try importKeys(RSA2048_RSA4096.publicKeys, RSA2048_RSA4096.privateKeys) + try pgpAgent.initKeys() + try [ + (true, true), + (true, false), + (false, true), + (false, false), + ].forEach { encryptInArmored, decryptFromArmored in + passKit.Defaults.encryptInArmored = encryptInArmored + let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: RSA2048.fingerprint) + passKit.Defaults.encryptInArmored = decryptFromArmored + // Note: not specifying the keyID to decrypt, so that the agent needs to find the matching private key by itself. + let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + XCTAssertEqual(decryptedData, testData) + } + } + + // - MARK: Encrypt with multiple keys + 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. @@ -209,8 +206,17 @@ final class PGPAgentTest: XCTestCase { } } + // - MARK: Helpers + 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) } + + private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? { + passKit.Defaults.encryptInArmored = encryptInArmored + let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: keyID) + passKit.Defaults.encryptInArmored = decryptFromArmored + return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase) + } } From 05324cebe8aa0ccd447c7122052469a21bf61e0f Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Wed, 11 Mar 2026 00:29:49 +0100 Subject: [PATCH 7/8] move functions around --- passKit/Crypto/PGPAgent.swift | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 33d49de..021742c 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -100,6 +100,23 @@ public class PGPAgent { return try pgpInterface.encrypt(plainData: plainData, keyIDs: [keyID]) } + @available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) or encryptWithAllKeys(plainData:) instead.") + public func encrypt(plainData: Data) throws -> Data { + try checkAndInit() + guard let pgpInterface else { + throw AppError.encryption + } + 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 func decrypt(encryptedData: Data, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { // Remember the previous status and set the current status let previousDecryptStatus = latestDecryptStatus @@ -122,23 +139,6 @@ 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 { - throw AppError.encryption - } - 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()) From ee80f0dbe865b8f32aba9846957a73b80498adfe Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Wed, 11 Mar 2026 00:48:21 +0100 Subject: [PATCH 8/8] PGPAgent can encrypt with multiple keys --- passKit/Crypto/GopenPGPInterface.swift | 7 ++++-- passKit/Crypto/PGPAgent.swift | 9 ++++++++ passKitTests/Crypto/PGPAgentTest.swift | 30 +++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/passKit/Crypto/GopenPGPInterface.swift b/passKit/Crypto/GopenPGPInterface.swift index 638ce25..3d34a58 100644 --- a/passKit/Crypto/GopenPGPInterface.swift +++ b/passKit/Crypto/GopenPGPInterface.swift @@ -138,8 +138,11 @@ struct GopenPGPInterface: PGPInterface { } 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 + let keys: [CryptoKey] = try keyIDs.map { keyID in + guard let key = publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value else { + throw AppError.pgpPublicKeyNotFound(keyID: keyID) + } + return key } guard let firstKey = keys.first else { throw AppError.encryption diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 021742c..97524c4 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -84,6 +84,7 @@ public class PGPAgent { return result } + @available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) instead.") public func encrypt(plainData: Data, keyID: String) throws -> Data { try checkAndInit() guard let pgpInterface else { @@ -100,6 +101,14 @@ public class PGPAgent { return try pgpInterface.encrypt(plainData: plainData, keyIDs: [keyID]) } + public func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { + try checkAndInit() + guard let pgpInterface else { + throw AppError.encryption + } + return try pgpInterface.encrypt(plainData: plainData, keyIDs: keyIDs) + } + @available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) or encryptWithAllKeys(plainData:) instead.") public func encrypt(plainData: Data) throws -> Data { try checkAndInit() diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index b7ba158..b90e17f 100644 --- a/passKitTests/Crypto/PGPAgentTest.swift +++ b/passKitTests/Crypto/PGPAgentTest.swift @@ -167,7 +167,7 @@ final class PGPAgentTest: XCTestCase { (false, false), ].forEach { encryptInArmored, decryptFromArmored in passKit.Defaults.encryptInArmored = encryptInArmored - let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: RSA2048.fingerprint) + let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: [RSA2048.fingerprint]) passKit.Defaults.encryptInArmored = decryptFromArmored // Note: not specifying the keyID to decrypt, so that the agent needs to find the matching private key by itself. let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase) @@ -177,6 +177,30 @@ final class PGPAgentTest: XCTestCase { // - MARK: Encrypt with multiple keys + func testEncryptWithMultipleKeys() throws { + keychain.removeAllContent() + // no private key for ED25519 + try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys) + try pgpAgent.initKeys() + + let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: RSA2048_RSA4096.fingerprints + [ED25519.fingerprint]) + + 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)) + } + + // load private key for ED25519 + try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys | ED25519.privateKey) + try pgpAgent.initKeys() + let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + XCTAssertEqual(decryptedData, testData) + } + 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. @@ -197,7 +221,7 @@ final class PGPAgentTest: XCTestCase { XCTAssertEqual($0 as! AppError, AppError.pgpPrivateKeyNotFound(keyID: ED25519.fingerprint)) } - // add private key for ED25519 + // load private key for ED25519 try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys | ED25519.privateKey) try pgpAgent.initKeys() @@ -215,7 +239,7 @@ final class PGPAgentTest: XCTestCase { private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? { passKit.Defaults.encryptInArmored = encryptInArmored - let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: keyID) + let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: [keyID]) passKit.Defaults.encryptInArmored = decryptFromArmored return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase) }