From 39dab8c6c026b295c8e7e44b118a1e650b1a0220 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Tue, 10 Mar 2026 22:16:42 +0100 Subject: [PATCH] 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)