diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 79c6f721..316bf017 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301F646C216166AA0071A4CE /* AdditionFieldTest.swift */; }; 302269B323E634B000F843A3 /* PGPKeyFIleImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302269B223E634B000F843A3 /* PGPKeyFIleImportTableViewController.swift */; }; 302B2C9822C2BDE700D831EE /* AppKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302B2C9722C2BDE700D831EE /* AppKeychain.swift */; }; + 8A7150122F60000100C7A64D /* ObjectiveCExceptionCatcher.h in Headers */ = {isa = PBXBuildFile; fileRef = 8A7150102F60000100C7A64D /* ObjectiveCExceptionCatcher.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 8A7150132F60000100C7A64D /* ObjectiveCExceptionCatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 8A7150112F60000100C7A64D /* ObjectiveCExceptionCatcher.m */; }; 302E85612125ECC70031BA64 /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302E85602125ECC70031BA64 /* Parser.swift */; }; 302E85632125EE550031BA64 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302E85622125EE550031BA64 /* Constants.swift */; }; 3032327422C7F710009EBD9C /* KeyFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3032327322C7F710009EBD9C /* KeyFileManager.swift */; }; @@ -343,6 +345,8 @@ 302202EE222F14E400555236 /* SearchBarScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarScope.swift; sourceTree = ""; }; 302269B223E634B000F843A3 /* PGPKeyFIleImportTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PGPKeyFIleImportTableViewController.swift; sourceTree = ""; }; 302B2C9722C2BDE700D831EE /* AppKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychain.swift; sourceTree = ""; }; + 8A7150102F60000100C7A64D /* ObjectiveCExceptionCatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ObjectiveCExceptionCatcher.h; sourceTree = ""; }; + 8A7150112F60000100C7A64D /* ObjectiveCExceptionCatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjectiveCExceptionCatcher.m; sourceTree = ""; }; 302E85602125ECC70031BA64 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; 302E85622125EE550031BA64 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 3032327322C7F710009EBD9C /* KeyFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyFileManager.swift; sourceTree = ""; }; @@ -976,6 +980,8 @@ 30BAC8CC22E3BB9700438475 /* KeyStore.swift */, 30B331762704DBEE00D64A99 /* NotificationCenterDispatcher.swift */, 30697C2321F63C580064FCAC /* NotificationNames.swift */, + 8A7150102F60000100C7A64D /* ObjectiveCExceptionCatcher.h */, + 8A7150112F60000100C7A64D /* ObjectiveCExceptionCatcher.m */, 302202EE222F14E400555236 /* SearchBarScope.swift */, 30697C2721F63C590064FCAC /* Utils.swift */, ); @@ -1132,6 +1138,7 @@ buildActionMask = 2147483647; files = ( A260758A1EEC6F34005DB03E /* passKit.h in Headers */, + 8A7150122F60000100C7A64D /* ObjectiveCExceptionCatcher.h in Headers */, DCB0EC272846857E00EFEE10 /* Objective-CBridgingHeader.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1603,6 +1610,7 @@ buildActionMask = 2147483647; files = ( 30A1D2A221B2BC6F00E2D1F7 /* TokenBuilder.swift in Sources */, + 8A7150132F60000100C7A64D /* ObjectiveCExceptionCatcher.m in Sources */, 30697C3C21F63C990064FCAC /* UITextFieldExtension.swift in Sources */, 302E85632125EE550031BA64 /* Constants.swift in Sources */, 9A652414244BB33300DA0A41 /* UIAlertActionExtension.swift in Sources */, diff --git a/pass/Controllers/SettingsTableViewController.swift b/pass/Controllers/SettingsTableViewController.swift index b715069a..4016b8b2 100644 --- a/pass/Controllers/SettingsTableViewController.swift +++ b/pass/Controllers/SettingsTableViewController.swift @@ -151,6 +151,10 @@ class SettingsTableViewController: UITableViewController, UITabBarControllerDele UITableView.automaticDimension } + private var hasStoredPGPKeys: Bool { + keychain.contains(key: PGPKey.PUBLIC.getKeychainKey()) || keychain.contains(key: PGPKey.PRIVATE.getKeychainKey()) + } + func showPGPKeyActionSheet() { let optionMenu = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) optionMenu.addAction( @@ -194,12 +198,16 @@ class SettingsTableViewController: UITableViewController, UITabBarControllerDele ) } - if Defaults.pgpKeySource != nil { + // Offer the removal also when no key source is set but keys are still around. The key source + // is gone after reinstalling the app while the keys survive in the keychain. Without this, + // there would be no way to get rid of keys which cannot be used anymore. + if Defaults.pgpKeySource != nil || hasStoredPGPKeys { optionMenu.addAction( UIAlertAction(title: "RemovePgpKeys".localize(), style: .destructive) { _ in let alert = UIAlertController.removeConfirmationAlert(title: "RemovePgpKeys".localize(), message: "") { _ in self.keychain.removeContent(for: PGPKey.PUBLIC.getKeychainKey()) self.keychain.removeContent(for: PGPKey.PRIVATE.getKeychainKey()) + self.keychain.removeAllContent(withPrefix: Globals.pgpKeyPassphrase) PGPAgent.shared.uninitKeys() self.pgpKeyTableViewCell.detailTextLabel?.text = "NotSet".localize() Defaults.pgpKeySource = nil diff --git a/passKit/Crypto/ObjectivePGPInterface.swift b/passKit/Crypto/ObjectivePGPInterface.swift index 768d7855..9b48dcc4 100644 --- a/passKit/Crypto/ObjectivePGPInterface.swift +++ b/passKit/Crypto/ObjectivePGPInterface.swift @@ -15,8 +15,8 @@ struct ObjectivePGPInterface: PGPInterface { guard let publicKeyData = publicArmoredKey.data(using: .ascii), let privateKeyData = privateArmoredKey.data(using: .ascii) else { throw AppError.keyImport } - let publicKeys = try ObjectivePGP.readKeys(from: publicKeyData) - let privateKeys = try ObjectivePGP.readKeys(from: privateKeyData) + let publicKeys = try catchingObjectiveCException(orThrow: .keyImport) { try ObjectivePGP.readKeys(from: publicKeyData) } + let privateKeys = try catchingObjectiveCException(orThrow: .keyImport) { try ObjectivePGP.readKeys(from: privateKeyData) } keyring.import(keys: publicKeys) keyring.import(keys: privateKeys) guard publicKeys.first != nil, privateKeys.first != nil else { @@ -25,15 +25,19 @@ struct ObjectivePGPInterface: PGPInterface { } func decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? { - try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase } + try catchingObjectiveCException(orThrow: .decryption) { + try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase } + } } func encrypt(plainData: Data, keyID _: String?) throws -> Data { - let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil) - if Defaults.encryptInArmored { - return Armor.armored(encryptedData, as: .message).data(using: .ascii)! + try catchingObjectiveCException(orThrow: .encryption) { + let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil) + if Defaults.encryptInArmored { + return Armor.armored(encryptedData, as: .message).data(using: .ascii)! + } + return encryptedData } - return encryptedData } func containsPublicKey(with keyID: String) -> Bool { @@ -52,3 +56,21 @@ struct ObjectivePGPInterface: PGPInterface { keyring.keys.map(\.keyID.shortIdentifier) } } + +/// Runs a block which may raise an Objective-C exception, e.g. when ObjectivePGP is fed a malformed +/// key. Such an exception cannot be caught in Swift and terminates the app, so it is replaced by the +/// given error here. +private func catchingObjectiveCException(orThrow appError: AppError, _ block: () throws -> T) throws -> T { + var result: Result? + do { + try ObjectiveCExceptionCatcher.catchException { + result = Result { try block() } + } + } catch { + throw appError + } + guard let result else { + throw appError + } + return try result.get() +} diff --git a/passKit/Helpers/ObjectiveCExceptionCatcher.h b/passKit/Helpers/ObjectiveCExceptionCatcher.h new file mode 100644 index 00000000..96c6fd1b --- /dev/null +++ b/passKit/Helpers/ObjectiveCExceptionCatcher.h @@ -0,0 +1,23 @@ +// +// ObjectiveCExceptionCatcher.h +// passKit +// +// Copyright © 2026 Bob Sun. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +extern NSString *const ObjectiveCExceptionErrorDomain; + +/// Runs a block and turns any Objective-C exception raised by it into an `NSError`. Swift cannot +/// catch `NSException`s, so calls into Objective-C libraries which raise them (like ObjectivePGP on +/// malformed input) have to be routed through this class to not terminate the app. +@interface ObjectiveCExceptionCatcher : NSObject + ++ (BOOL)catchException:(NS_NOESCAPE void (^)(void))block error:(NSError **)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/passKit/Helpers/ObjectiveCExceptionCatcher.m b/passKit/Helpers/ObjectiveCExceptionCatcher.m new file mode 100644 index 00000000..4ba07144 --- /dev/null +++ b/passKit/Helpers/ObjectiveCExceptionCatcher.m @@ -0,0 +1,29 @@ +// +// ObjectiveCExceptionCatcher.m +// passKit +// +// Copyright © 2026 Bob Sun. All rights reserved. +// + +#import "ObjectiveCExceptionCatcher.h" + +NSString *const ObjectiveCExceptionErrorDomain = @"ObjectiveCExceptionErrorDomain"; + +@implementation ObjectiveCExceptionCatcher + ++ (BOOL)catchException:(NS_NOESCAPE void (^)(void))block error:(NSError **)error { + @try { + block(); + return YES; + } @catch (NSException *exception) { + if (error != NULL) { + NSMutableDictionary *userInfo = [NSMutableDictionary dictionary]; + userInfo[NSLocalizedDescriptionKey] = exception.reason ?: exception.name; + userInfo[NSDebugDescriptionErrorKey] = exception.description; + *error = [NSError errorWithDomain:ObjectiveCExceptionErrorDomain code:0 userInfo:userInfo]; + } + return NO; + } +} + +@end diff --git a/passKit/passKit.h b/passKit/passKit.h index cc3f0698..accee809 100644 --- a/passKit/passKit.h +++ b/passKit/passKit.h @@ -8,6 +8,8 @@ #import +#import + //! Project version number for passKit. FOUNDATION_EXPORT double passKitVersionNumber; diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index 8acb2bee..9b003d24 100644 --- a/passKitTests/Crypto/PGPAgentTest.swift +++ b/passKitTests/Crypto/PGPAgentTest.swift @@ -122,6 +122,37 @@ final class PGPAgentTest: XCTestCase { } } + func testEmptyArmoredKey() throws { + // Reading an armored block without a body raises an Objective-C exception in ObjectivePGP. + // As it cannot be caught in Swift, it used to terminate the app as soon as a key ID was + // requested, e.g. when opening the settings screen. + try importKeys( + "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n-----END PGP PUBLIC KEY BLOCK-----\n", + "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n-----END PGP PRIVATE KEY BLOCK-----\n" + ) + XCTAssert(pgpAgent.isPrepared) + XCTAssertThrowsError(try pgpAgent.initKeys()) { + XCTAssertEqual($0 as! AppError, AppError.keyImport) + } + XCTAssertThrowsError(try pgpAgent.getShortKeyID()) { + XCTAssertEqual($0 as! AppError, AppError.keyImport) + } + XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent, keyID: RSA2048.fingerprint)) { + XCTAssertEqual($0 as! AppError, AppError.keyImport) + } + } + + func testTruncatedKey() throws { + try importKeys( + String(RSA2048.publicKey.prefix(300)) + "\n-----END PGP PUBLIC KEY BLOCK-----\n", + String(RSA2048.privateKey.prefix(300)) + "\n-----END PGP PRIVATE KEY BLOCK-----\n" + ) + XCTAssert(pgpAgent.isPrepared) + XCTAssertThrowsError(try pgpAgent.getShortKeyID()) { + XCTAssert($0.localizedDescription.contains("Can't read keys. Invalid input.")) + } + } + func testUnsetKeys() throws { try importKeys(ED25519.publicKey, ED25519.privateKey) XCTAssert(pgpAgent.isPrepared)