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.
This commit is contained in:
Lysann Tranvouez 2026-03-10 22:16:42 +01:00
parent f1cb5d27be
commit 8d4f3af475
6 changed files with 76 additions and 15 deletions

View file

@ -16,6 +16,7 @@ struct GopenPGPInterface: PGPInterface {
private var publicKeys: [String: CryptoKey] = [:] private var publicKeys: [String: CryptoKey] = [:]
private var privateKeys: [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 { init(publicArmoredKey: String, privateArmoredKey: String) throws {
let pubKeys = extractKeysFromArmored(str: publicArmoredKey) let pubKeys = extractKeysFromArmored(str: publicArmoredKey)
@ -40,7 +41,24 @@ struct GopenPGPInterface: PGPInterface {
} }
throw AppError.keyImport 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()) } privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) }
} }
func decrypt(encryptedData: Data, keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
let key: CryptoKey? = { let message = createPGPMessage(from: encryptedData)
if let keyID { guard let message else {
return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value throw AppError.decryption
} }
return privateKeys.first?.value
}()
guard let privateKey = key else { guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else {
throw AppError.decryption throw AppError.decryption
} }
@ -101,7 +117,6 @@ struct GopenPGPInterface: PGPInterface {
throw AppError.decryption throw AppError.decryption
} }
let message = createPGPMessage(from: encryptedData)
return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data
} catch { } catch {
throw Self.errorMapping[error.localizedDescription, default: error] throw Self.errorMapping[error.localizedDescription, default: error]
@ -148,6 +163,33 @@ struct GopenPGPInterface: PGPInterface {
var shortKeyID: [String] { var shortKeyID: [String] {
publicKeys.keys.map { $0.suffix(8).uppercased() } publicKeys.keys.map { $0.suffix(8).uppercased() }
} }
private func findDecryptionKey(message: CryptoPGPMessage, keyIDHint: String?) throws -> CryptoKey? {
var keyIDCandidates: any Collection<String> = 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? { public func createPGPMessage(from encryptedData: Data) -> CryptoPGPMessage? {

View file

@ -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 try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { selectedKey in
guard let selectedKey else { guard let selectedKey else {
return nil return nil

View file

@ -81,7 +81,7 @@ public class PGPAgent {
return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID)
} }
// Decrypt. // 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 return nil
} }
// The decryption step has succeed. // The decryption step has succeed.
@ -119,7 +119,7 @@ public class PGPAgent {
return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID)
} }
// Decrypt. // 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 return nil
} }
// The decryption step has succeed. // The decryption step has succeed.

View file

@ -7,7 +7,7 @@
// //
protocol PGPInterface { 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 func encrypt(plainData: Data, keyID: String?) throws -> Data

View file

@ -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 { func testNoPrivateKey() throws {
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048.publicKey) try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048.publicKey)
XCTAssertFalse(pgpAgent.isPrepared) XCTAssertFalse(pgpAgent.isPrepared)

View file

@ -44,7 +44,7 @@ class MockPGPInterface: PGPInterface {
// MARK: - PGPInterface // MARK: - PGPInterface
func decrypt(encryptedData: Data, keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { func decrypt(encryptedData: Data, keyIDHint keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
decryptCalls.append(DecryptCall(encryptedData: encryptedData, keyID: keyID, passPhraseForKey: passPhraseForKey)) decryptCalls.append(DecryptCall(encryptedData: encryptedData, keyID: keyID, passPhraseForKey: passPhraseForKey))
if let selectedKey = selectedKeyForPassphrase { if let selectedKey = selectedKeyForPassphrase {
resolvedPassphrases.append(passPhraseForKey(selectedKey)) resolvedPassphrases.append(passPhraseForKey(selectedKey))