diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index d04dd81..4b5755f 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -116,6 +116,8 @@ 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; }; 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; }; + 8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */; }; + 8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */; }; 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; @@ -427,6 +429,8 @@ 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = ""; }; 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = ""; }; + 8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPGPInterface.swift; sourceTree = ""; }; + 8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PGPAgentLowLevelTests.swift; sourceTree = ""; }; 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; @@ -776,6 +780,22 @@ path = Controllers; sourceTree = ""; }; + 8AB3AD8E2F615FD70081DE16 /* Mocks */ = { + isa = PBXGroup; + children = ( + 8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + 8AB3AD8F2F61600B0081DE16 /* LowLevel */ = { + isa = PBXGroup; + children = ( + 8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */, + ); + path = LowLevel; + sourceTree = ""; + }; 9A58664F25AADB66006719C2 /* Services */ = { isa = PBXGroup; children = ( @@ -901,6 +921,8 @@ 30697C5521F63F870064FCAC /* Extensions */, 8AD8EBF22F5E268D007475AB /* Fixtures */, 301F6464216164670071A4CE /* Helpers */, + 8AB3AD8F2F61600B0081DE16 /* LowLevel */, + 8AB3AD8E2F615FD70081DE16 /* Mocks */, 30C015A7214ED378005BB6DF /* Models */, 30C015A6214ED32A005BB6DF /* Parser */, 30B4C7BB24085A3C008B86F7 /* Passwords */, @@ -1644,6 +1666,8 @@ 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */, 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, + 8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */, + 8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */, A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, diff --git a/pass/Controllers/PasswordDetailTableViewController.swift b/pass/Controllers/PasswordDetailTableViewController.swift index 98aa66a..37efdd1 100644 --- a/pass/Controllers/PasswordDetailTableViewController.swift +++ b/pass/Controllers/PasswordDetailTableViewController.swift @@ -128,7 +128,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni // alert: cancel or try again let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction.cancelAndPopView(controller: self)) - let selectKey = UIAlertAction.selectKey(controller: self) { action in + let selectKey = UIAlertAction.selectKey(type: .PRIVATE, controller: self) { action in self.decryptThenShowPasswordLocalKey(keyID: action.title) } alert.addAction(selectKey) @@ -223,7 +223,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni SVProgressHUD.dismiss() let alert = UIAlertController(title: "Cannot Edit Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction.cancelAndPopView(controller: self)) - let selectKey = UIAlertAction.selectKey(controller: self) { action in + let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: self) { action in self.saveEditPassword(password: password, keyID: action.title) } alert.addAction(selectKey) diff --git a/pass/Controllers/SettingsTableViewController.swift b/pass/Controllers/SettingsTableViewController.swift index b715069..b0a3e61 100644 --- a/pass/Controllers/SettingsTableViewController.swift +++ b/pass/Controllers/SettingsTableViewController.swift @@ -89,10 +89,12 @@ class SettingsTableViewController: UITableViewController, UITabBarControllerDele private func setPGPKeyTableViewCellDetailText() { var label = "NotSet".localize() - let keyID = (try? PGPAgent.shared.getShortKeyID()) ?? [] - if keyID.count == 1 { - label = keyID.first ?? "" - } else if keyID.count > 1 { + var keyIDs = Set((try? PGPAgent.shared.getShortKeyIDs(type: .PRIVATE)) ?? []) + keyIDs.formUnion((try? PGPAgent.shared.getShortKeyIDs(type: .PUBLIC)) ?? []) + + if keyIDs.count == 1 { + label = keyIDs.first ?? "" + } else if keyIDs.count > 1 { label = "Multiple" } if Defaults.isYubiKeyEnabled { diff --git a/pass/Services/PasswordDecryptor.swift b/pass/Services/PasswordDecryptor.swift index 49e7845..e18aaba 100644 --- a/pass/Services/PasswordDecryptor.swift +++ b/pass/Services/PasswordDecryptor.swift @@ -30,8 +30,11 @@ func decryptPassword( } DispatchQueue.global(qos: .userInteractive).async { do { + guard let passwordEntity = PasswordStore.shared.fetchPasswordEntity(with: passwordPath) else { + throw AppError.decryption + } let requestPGPKeyPassphrase = Utils.createRequestPGPKeyPassphraseHandler(controller: controller) - let decryptedPassword = try PasswordStore.shared.decrypt(path: passwordPath, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + let decryptedPassword = try PasswordStore.shared.decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) DispatchQueue.main.async { completion(decryptedPassword) @@ -40,7 +43,7 @@ func decryptPassword( DispatchQueue.main.async { let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction.cancelAndPopView(controller: controller)) - let selectKey = UIAlertAction.selectKey(controller: controller) { action in + let selectKey = UIAlertAction.selectKey(type: PGPKey.PRIVATE, controller: controller) { action in decryptPassword(in: controller, with: passwordPath, using: action.title, completion: completion) } alert.addAction(selectKey) diff --git a/pass/Services/PasswordEncryptor.swift b/pass/Services/PasswordEncryptor.swift index 254f045..6159881 100644 --- a/pass/Services/PasswordEncryptor.swift +++ b/pass/Services/PasswordEncryptor.swift @@ -19,7 +19,7 @@ func encryptPassword(in controller: UIViewController, with password: Password, k DispatchQueue.main.async { let alert = UIAlertController(title: "Cannot Encrypt Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction.cancelAndPopView(controller: controller)) - let selectKey = UIAlertAction.selectKey(controller: controller) { action in + let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: controller) { action in encryptPassword(in: controller, with: password, keyID: action.title, completion: completion) } alert.addAction(selectKey) diff --git a/passKit/Crypto/GopenPGPInterface.swift b/passKit/Crypto/GopenPGPInterface.swift index 34f0622..3917df1 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?, passphrase: 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 } @@ -87,6 +103,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 @@ -100,33 +117,43 @@ 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] } } - 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 - }() + func encryptWithAllKeys(plainData: Data) throws -> Data { + let keyIDs = publicKeys.keys.filter { key in privateKeys.keys.contains(key) } + return try encrypt(plainData: plainData, keyIDs: keyIDs) + } - guard let publicKey = key else { + func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { + 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 } + 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 { @@ -140,12 +167,44 @@ struct GopenPGPInterface: PGPInterface { return encryptedData.getBinary()! } - var keyID: [String] { - publicKeys.keys.map { $0.uppercased() } + func getKeyIDs(type: PGPKey) -> [String] { + switch type { + case .PUBLIC: + return publicKeys.keys.map { $0.uppercased() } + case .PRIVATE: + return privateKeys.keys.map { $0.uppercased() } + } } - var shortKeyID: [String] { - publicKeys.keys.map { $0.suffix(8).uppercased() } + func getShortKeyIDs(type: PGPKey) -> [String] { + getKeyIDs(type: type).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] } } diff --git a/passKit/Crypto/ObjectivePGPInterface.swift b/passKit/Crypto/ObjectivePGPInterface.swift index 768d785..e1425c3 100644 --- a/passKit/Crypto/ObjectivePGPInterface.swift +++ b/passKit/Crypto/ObjectivePGPInterface.swift @@ -24,12 +24,29 @@ 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, 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 + } + return passPhraseForKey(selectedKey.keyID.longIdentifier) + } } - func encrypt(plainData: Data, keyID _: String?) throws -> Data { - let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil) + func encryptWithAllKeys(plainData: Data) throws -> Data { + let keys = keyring.keys.filter { $0.isPublic && $0.isSecret } + return try encrypt(plainData: plainData, keyIDs: keys.map(\.keyID.longIdentifier)) + } + + 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)! } @@ -44,11 +61,20 @@ struct ObjectivePGPInterface: PGPInterface { keyring.findKey(keyID)?.isSecret ?? false } - var keyID: [String] { - keyring.keys.map(\.keyID.longIdentifier) + func getKeyIDs(type: PGPKey) -> [String] { + getKeys(type: type).map(\.keyID.longIdentifier) } - var shortKeyID: [String] { - keyring.keys.map(\.keyID.shortIdentifier) + func getShortKeyIDs(type: PGPKey) -> [String] { + getKeys(type: type).map(\.keyID.shortIdentifier) + } + + private func getKeys(type: PGPKey) -> [Key] { + switch type { + case .PUBLIC: + keyring.keys.filter(\.isPublic) + case .PRIVATE: + keyring.keys.filter(\.isSecret) + } } } diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index f193515..6ee8c2b 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -9,7 +9,7 @@ public class PGPAgent { public static let shared = PGPAgent() - private let keyStore: KeyStore + let keyStore: KeyStore private var pgpInterface: PGPInterface? private var latestDecryptStatus = true @@ -17,6 +17,11 @@ public class PGPAgent { self.keyStore = keyStore } + init(keyStore: KeyStore, pgpInterface: PGPInterface) { + self.keyStore = keyStore + self.pgpInterface = pgpInterface + } + public func initKeys() throws { guard let publicKey: String = keyStore.get(for: PGPKey.PUBLIC.getKeychainKey()), let privateKey: String = keyStore.get(for: PGPKey.PRIVATE.getKeychainKey()) else { @@ -38,30 +43,24 @@ public class PGPAgent { pgpInterface != nil } - public func getKeyID() throws -> [String] { + public func getKeyIDs(type: PGPKey) throws -> [String] { try checkAndInit() - return pgpInterface?.keyID ?? [] + return pgpInterface?.getKeyIDs(type: type).sorted() ?? [] } - public func getShortKeyID() throws -> [String] { + public func getShortKeyIDs(type: PGPKey) throws -> [String] { try checkAndInit() - return pgpInterface?.shortKeyID.sorted() ?? [] + return pgpInterface?.getShortKeyIDs(type: type).sorted() ?? [] } - public func decrypt(encryptedData: Data, keyID: String, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { - // Init keys. + public func decrypt(encryptedData: Data, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { try checkAndInit() guard let pgpInterface else { throw AppError.decryption } - var keyID = keyID - if !pgpInterface.containsPrivateKey(with: keyID) { - if pgpInterface.keyID.count == 1 { - keyID = pgpInterface.keyID.first! - } else { - throw AppError.pgpPrivateKeyNotFound(keyID: keyID) - } + if let keyID, !pgpInterface.containsPrivateKey(with: keyID) { + throw AppError.pgpPrivateKeyNotFound(keyID: keyID) } // Remember the previous status and set the current status @@ -69,52 +68,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) - } - // Decrypt. - guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else { - return nil - } - // The decryption step has succeed. - latestDecryptStatus = true - return result - } - - public func encrypt(plainData: Data, keyID: String) throws -> Data { - try checkAndInit() - guard let pgpInterface else { - throw AppError.encryption - } - var keyID = keyID - if !pgpInterface.containsPublicKey(with: keyID) { - if pgpInterface.keyID.count == 1 { - keyID = pgpInterface.keyID.first! - } else { - throw AppError.pgpPublicKeyNotFound(keyID: keyID) + let providePassPhraseForKey = { (selectedKeyID: String) -> String in + if previousDecryptStatus == false { + return requestPGPKeyPassphrase(selectedKeyID) } - } - return try pgpInterface.encrypt(plainData: plainData, keyID: keyID) - } - - public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: (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("") + 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, keyIDHint: keyID, passPhraseForKey: providePassPhraseForKey) else { return nil } // The decryption step has succeed. @@ -122,12 +83,20 @@ public class PGPAgent { return result } - public func encrypt(plainData: Data) throws -> Data { + public func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { try checkAndInit() guard let pgpInterface else { throw AppError.encryption } - return try pgpInterface.encrypt(plainData: plainData, keyID: nil) + return try pgpInterface.encrypt(plainData: plainData, keyIDs: keyIDs) + } + + 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 { diff --git a/passKit/Crypto/PGPInterface.swift b/passKit/Crypto/PGPInterface.swift index b77831d..2afce6d 100644 --- a/passKit/Crypto/PGPInterface.swift +++ b/passKit/Crypto/PGPInterface.swift @@ -7,15 +7,15 @@ // protocol PGPInterface { - func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? + func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? - 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 - func containsPrivateKey(with keyID: String) -> Bool - var keyID: [String] { get } - - var shortKeyID: [String] { get } + func getKeyIDs(type: PGPKey) -> [String] + func getShortKeyIDs(type: PGPKey) -> [String] } diff --git a/passKit/Extensions/UIAlertActionExtension.swift b/passKit/Extensions/UIAlertActionExtension.swift index b49ba07..d258e3b 100644 --- a/passKit/Extensions/UIAlertActionExtension.swift +++ b/passKit/Extensions/UIAlertActionExtension.swift @@ -38,10 +38,10 @@ public extension UIAlertAction { } } - static func selectKey(controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction { + static func selectKey(type: PGPKey, controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction { UIAlertAction(title: "Select Key", style: .default) { _ in let selectKeyAlert = UIAlertController(title: "Select from imported keys", message: nil, preferredStyle: .actionSheet) - try? PGPAgent.shared.getShortKeyID().forEach { keyID in + try? PGPAgent.shared.getShortKeyIDs(type: type).forEach { keyID in let action = UIAlertAction(title: keyID, style: .default, handler: handler) selectKeyAlert.addAction(action) } diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index c822ffa..742f13e 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -23,6 +23,7 @@ public class PasswordStore { }() public var storeURL: URL + private let pgpAgent: PGPAgent public var gitRepository: GitRepository? @@ -84,8 +85,9 @@ public class PasswordStore { gitRepository?.numberOfCommits() } - init(url: URL = Globals.repositoryURL) { + init(url: URL = Globals.repositoryURL, pgpAgent: PGPAgent = .shared) { self.storeURL = url + self.pgpAgent = pgpAgent // Migration importExistingKeysIntoKeychain() @@ -359,14 +361,14 @@ public class PasswordStore { eraseStoreData() // Delete PGP key, SSH key and other secrets from the keychain. - AppKeychain.shared.removeAllContent() + pgpAgent.keyStore.removeAllContent() // Delete default settings. Defaults.removeAll() // Delete cache explicitly. PasscodeLock.shared.delete() - PGPAgent.shared.uninitKeys() + pgpAgent.uninitKeys() } // return the number of discarded commits @@ -395,13 +397,7 @@ public class PasswordStore { public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password { let url = passwordEntity.fileURL(in: storeURL) let encryptedData = try Data(contentsOf: url) - let data: Data? = try { - if Defaults.isEnableGPGIDOn { - let keyID = keyID ?? findGPGID(from: url) - return try PGPAgent.shared.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - } - return try PGPAgent.shared.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - }() + let data: Data? = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) guard let decryptedData = data else { throw AppError.decryption } @@ -409,23 +405,21 @@ public class PasswordStore { return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText) } - public func decrypt(path: String, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password { - guard let passwordEntity = fetchPasswordEntity(with: path) else { - throw AppError.decryption - } - if Defaults.isEnableGPGIDOn { - return try decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - } - return try decrypt(passwordEntity: passwordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - } - public func encrypt(password: Password, keyID: String? = nil) throws -> Data { - 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) + let keyIDs: [String] = { + if let keyID { + return [keyID] + } + if Defaults.isEnableGPGIDOn { + let encryptedDataPath = password.fileURL(in: storeURL) + return findGPGIDs(from: encryptedDataPath) + } + return [] + }() + if !keyIDs.isEmpty { + return try pgpAgent.encrypt(plainData: password.plainData, keyIDs: keyIDs) } - return try PGPAgent.shared.encrypt(plainData: password.plainData) + return try pgpAgent.encryptWithAllKeys(plainData: password.plainData) } public func removeGitSSHKeys() { @@ -469,7 +463,7 @@ extension PasswordStore { } } -func findGPGID(from url: URL) -> String { +func findGPGIDs(from url: URL) -> [String] { var path = url while !FileManager.default.fileExists(atPath: path.appendingPathComponent(".gpg-id").path), path.path != "file:///" { @@ -477,5 +471,9 @@ func findGPGID(from url: URL) -> String { } path = path.appendingPathComponent(".gpg-id") - return (try? String(contentsOf: path))?.trimmed ?? "" + let allKeysSeparatedByNewline = (try? String(contentsOf: path)) ?? "" + return allKeysSeparatedByNewline + .split(separator: "\n") + .map { String($0).trimmed } + .filter { !$0.isEmpty } } diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index 8acb2be..267742c 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 [ @@ -75,7 +48,8 @@ final class PGPAgentTest: XCTestCase { try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey) XCTAssert(pgpAgent.isPrepared) try pgpAgent.initKeys() - XCTAssert(try pgpAgent.getKeyID().first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) + XCTAssert(try pgpAgent.getKeyIDs(type: .PUBLIC).first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) + XCTAssert(try pgpAgent.getKeyIDs(type: .PRIVATE).first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) try [ (true, true), (true, false), @@ -161,8 +135,116 @@ 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, 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) + XCTAssertEqual(decryptedData, testData) + } + } + + // - 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() + + XCTAssertEqual(try pgpAgent.getKeyIDs(type: .PUBLIC).map { $0.lowercased() }.sorted(), (RSA2048_RSA4096.longFingerprints + [ED25519.longFingerprint]).sorted()) + XCTAssertEqual(try pgpAgent.getKeyIDs(type: .PRIVATE).map { $0.lowercased() }.sorted(), RSA2048_RSA4096.longFingerprints.sorted()) + + 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. + + 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)) + } + + // load 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) + } + } + + // - 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, keyIDs: [keyID]) + passKit.Defaults.encryptInArmored = decryptFromArmored + return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase) + } } diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/23/44f8116ab49ad30dd96d92e306a2b28839ee71 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/23/44f8116ab49ad30dd96d92e306a2b28839ee71 new file mode 100644 index 0000000..d798c2c Binary files /dev/null and b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/23/44f8116ab49ad30dd96d92e306a2b28839ee71 differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/34/22e35e46f42b12fdca050c9bc424028d0e89c8 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/34/22e35e46f42b12fdca050c9bc424028d0e89c8 new file mode 100644 index 0000000..69995d0 Binary files /dev/null and b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/34/22e35e46f42b12fdca050c9bc424028d0e89c8 differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/8b/08bf485e19720eae26df62ddceefbb8b4d8247 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/8b/08bf485e19720eae26df62ddceefbb8b4d8247 new file mode 100644 index 0000000..ea752fb Binary files /dev/null and b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/8b/08bf485e19720eae26df62ddceefbb8b4d8247 differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/bb/1f455127743a2e202840fc39201937a2457ed2 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/bb/1f455127743a2e202840fc39201937a2457ed2 new file mode 100644 index 0000000..61d0371 --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/bb/1f455127743a2e202840fc39201937a2457ed2 @@ -0,0 +1 @@ +x1 @k^BI(9Bj]lEUMC{lD+=rKd8.4uy: n痈Ky1F \ No newline at end of file diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/c7/c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/c7/c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 new file mode 100644 index 0000000..89b191d --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/c7/c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 @@ -0,0 +1,3 @@ +xAj0s+bF#Y < |@m]Hry}B>C_ +~O S+"@yڅh}`ޱ'1EGEP$7KEiyh8f +G[g 9g {;^oH'a0d{D ϨlUKS5T B-BfH’z9RS9v qkcJ \ No newline at end of file diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/refs/heads/master b/passKitTests/Fixtures/password-store-with-gpgid.git/refs/heads/master new file mode 100644 index 0000000..2b6288a --- /dev/null +++ b/passKitTests/Fixtures/password-store-with-gpgid.git/refs/heads/master @@ -0,0 +1 @@ +c7c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 diff --git a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift new file mode 100644 index 0000000..a1e6e99 --- /dev/null +++ b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift @@ -0,0 +1,414 @@ +// +// PGPAgentLowLevelTests.swift +// passKitTests +// +// Detailed unit tests tracking the exact API call behavior of PGPAgent.decrypt. +// Uses MockPGPInterface to verify what arguments are passed to the underlying +// PGPInterface methods, and how passphrase resolution interacts with the keystore +// and the requestPGPKeyPassphrase callback. +// + +import XCTest + +@testable import passKit + +final class PGPAgentLowLevelTests: XCTestCase { + private var keychain: DictBasedKeychain! + private var mockPGP: MockPGPInterface! + private var agent: PGPAgent! + + private let testEncryptedData = Data("encrypted-payload".utf8) + private let testDecryptedData = Data("decrypted-payload".utf8) + + /// Tracks all calls to requestPGPKeyPassphrase closures created via `passphraseCallback(_:)`. + private var passphraseRequests: [String] = [] + + /// Creates a requestPGPKeyPassphrase closure that records the keyID it's called with + /// into `passphraseRequests` and returns `response`. + private func passphraseCallback(_ response: String) -> (String) -> String { + { [self] keyID in + passphraseRequests.append(keyID) + return response + } + } + + override func setUp() { + super.setUp() + + keychain = DictBasedKeychain() + // Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock. + keychain.add(string: "dummy", for: Globals.pgpKeyPassphrase) + + mockPGP = MockPGPInterface() + // some defaults + mockPGP.decryptResult = testDecryptedData + mockPGP.encryptResult = Data("mock-encrypted".utf8) + + passphraseRequests = [] + + agent = PGPAgent(keyStore: keychain, pgpInterface: mockPGP) + } + + override func tearDown() { + keychain.removeAllContent() + super.tearDown() + } + + // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Key Resolution + + /// When the private key is found, decrypt is called with the provided keyID. + func testDecryptWithKeyID_keyFound_usesProvidedKeyID() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + + let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(result, testDecryptedData) + XCTAssertEqual(mockPGP.decryptCalls.count, 1) + XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint) + XCTAssertEqual(mockPGP.decryptCalls[0].encryptedData, testEncryptedData) + } + + func testDecryptWithKeyID_keyNotFound_throws() { + mockPGP.privateKeyIDs = [] + + XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in + XCTAssertEqual(error as? AppError, AppError.pgpPrivateKeyNotFound(keyID: "UNKNOWN")) + } + // pgpInterface.decrypt should NOT have been called + XCTAssertEqual(mockPGP.decryptCalls.count, 0) + } + + /// containsPrivateKey is called with the provided keyID to check membership. + func testDecryptWithKeyID_checksContainsPrivateKey() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID]) + } + + // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Passphrase Resolution + + /// On first decrypt (latestDecryptStatus=true), the passphrase is looked up from keystore first. + /// If found in keystore, requestPGPKeyPassphrase is NOT called. + func testDecryptWithKeyID_firstCall_passphraseFromKeystore() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + mockPGP.selectedKeyForPassphrase = longFingerprint + keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("requested-passphrase")) + + XCTAssertEqual(mockPGP.resolvedPassphrases, ["stored-passphrase"]) + XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when passphrase is in keystore") + } + + /// On first decrypt, if keystore doesn't have the passphrase, requestPGPKeyPassphrase is called. + /// The keyID passed to requestPGPKeyPassphrase is the (possibly resolved) keyID. + func testDecryptWithKeyID_firstCall_passphraseFromRequest() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + mockPGP.selectedKeyForPassphrase = shortID + // No passphrase in keystore for this key. + XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))) + XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))) + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("my-passphrase")) + + XCTAssertEqual(mockPGP.resolvedPassphrases, ["my-passphrase"]) + XCTAssertEqual(passphraseRequests, [shortID]) + } + + /// After a failed decrypt (latestDecryptStatus=false), requestPGPKeyPassphrase is ALWAYS called, + /// even if the keystore has a cached passphrase. + func testDecrypt_afterFailure_alwaysRequestsPassphrase() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + + // First call: force a failure by making decrypt throw. + mockPGP.decryptError = AppError.wrongPassphrase + XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad"))) + + // Now latestDecryptStatus=false. Second call should always request. + mockPGP.decryptError = nil + mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = longFingerprint + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh-passphrase")) + + XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh-passphrase"]) + XCTAssertEqual(passphraseRequests, [longFingerprint], "After failure, passphrase should always be requested") + } + + /// After a successful decrypt, the next call uses keystore first (latestDecryptStatus=true). + func testDecrypt_afterSuccess_usesKeystoreFirst() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + + // First call succeeds. + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass1")) + + // Store a passphrase in keystore under the short ID (matching what PGPAgent used for lookup). + keychain.add(string: "pass1", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)) + + mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = shortID + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("ignored-passphrase")) + + XCTAssertEqual(mockPGP.resolvedPassphrases, ["pass1"]) + XCTAssertEqual(passphraseRequests, []) + } + + // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Return Values & Error Propagation + + /// When pgpInterface.decrypt returns nil, agent.decrypt returns nil. + func testDecrypt_interfaceReturnsNil_returnsNil() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + mockPGP.decryptResult = nil + + let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertNil(result) + } + + /// When pgpInterface.decrypt returns nil, latestDecryptStatus stays false + /// (next call will always request passphrase). + func testDecrypt_interfaceReturnsNil_statusStaysFalse() throws { + let shortID = "d862027e" + let longFingerprint = "787eae1a5fa3e749aa34cc6aa0645ebed862027e" + mockPGP.privateKeyIDs = [longFingerprint] + mockPGP.decryptResult = nil + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass")) + + // Second call - should always request (latestDecryptStatus=false because nil return doesn't set it to true). + keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)) + keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + mockPGP.decryptResult = testDecryptedData + mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = longFingerprint + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh")) + + XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"]) + XCTAssertEqual(passphraseRequests, [longFingerprint], "After nil return, passphrase should always be requested") + } + + /// When pgpInterface.decrypt throws, the error propagates and latestDecryptStatus stays false. + func testDecrypt_interfaceThrows_propagatesError() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + mockPGP.decryptError = AppError.wrongPassphrase + mockPGP.selectedKeyForPassphrase = longFingerprint + + XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in + XCTAssertEqual(error as? AppError, AppError.wrongPassphrase) + } + XCTAssertEqual(passphraseRequests, [longFingerprint]) + + // Verify latestDecryptStatus stayed false: next call should always request passphrase, + // even though the keystore has one cached. + keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)) + keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + mockPGP.decryptError = nil + mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = longFingerprint + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh")) + + XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"]) + XCTAssertEqual(passphraseRequests, [longFingerprint], "After throw, passphrase should always be requested (latestDecryptStatus=false)") + } + + /// After successful decrypt, latestDecryptStatus is true. + func testDecrypt_success_setsStatusTrue() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + + // Force latestDecryptStatus to false first. + mockPGP.decryptError = AppError.wrongPassphrase + _ = try? agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad")) + mockPGP.decryptError = nil + mockPGP.decryptCalls.removeAll() + passphraseRequests.removeAll() + + // Now succeed. + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("good")) + + // Third call: latestDecryptStatus=true, so should try keystore first. + keychain.add(string: "good", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = longFingerprint + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("should-not-use")) + + XCTAssertEqual(mockPGP.resolvedPassphrases, ["good"]) + XCTAssertEqual(passphraseRequests, [], "After success, should try keystore first") + } + + // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - checkAndInit behavior + + /// checkAndInit re-initializes if pgpKeyPassphrase is missing from keystore. + /// Since we're using a mock as pgpInterface, initKeys would overwrite it; verify the precondition holds. + func testDecrypt_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws { + // Remove the pgpKeyPassphrase sentinel, which will trigger checkAndInit -> initKeys. + keychain.removeContent(for: Globals.pgpKeyPassphrase) + // initKeys needs real PGP keys, which we don't have. It should throw keyImport. + XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "a1024dae", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in + XCTAssertEqual(error as? AppError, AppError.keyImport) + } + XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when checkAndInit fails") + } + + // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - nil keyID + + /// The no-keyID overload passes nil as keyID to pgpInterface.decrypt + func testDecryptNoKeyID_passesNilKeyIDToInterface() throws { + let result = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(result, testDecryptedData) + XCTAssertEqual(mockPGP.decryptCalls.count, 1) + XCTAssertNil(mockPGP.decryptCalls[0].keyID) + } + + /// The no-keyID overload doesn't check containsPrivateKey. + func testDecryptNoKeyID_doesNotCheckPrivateKey() throws { + _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(mockPGP.containsPrivateKeyCalls.count, 0) + } + + // MARK: - Key resolution error vs decrypt status ordering + + /// When pgpPrivateKeyNotFound is thrown, latestDecryptStatus is NOT changed because the error occurs BEFORE the status update. + func testDecryptWithKeyID_keyNotFound_doesNotChangeDecryptStatus() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [] + + // This throws pgpPrivateKeyNotFound without changing latestDecryptStatus. + XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass"))) + + // latestDecryptStatus should still be true (initial value). + // Next call should try keystore first. + mockPGP.privateKeyIDs = [longFingerprint] + keychain.add(string: "cached-pass", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + mockPGP.selectedKeyForPassphrase = longFingerprint + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh")) + + XCTAssertEqual(passphraseRequests, [], "After pgpPrivateKeyNotFound, latestDecryptStatus should be unchanged (still true)") + XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached-pass"]) + } + + // MARK: - Short vs long key ID behavior + + /// When caller passes a short ID and containsPrivateKey matches it (via suffix), the short ID + /// is forwarded to pgpInterface.decrypt. + func testDecryptWithKeyID_shortIDRecognized_shortIDFlowsThrough() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID]) + XCTAssertEqual(mockPGP.decryptCalls[0].keyID, shortID) + } + + /// Passphrase stored under long fingerprint is NOT found when the short ID is used for lookup + func testDecryptWithKeyID_shortIDRecognized_passphraseStoredUnderLongID_missesKeystore() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + mockPGP.selectedKeyForPassphrase = shortID + + // Store passphrase under the LONG fingerprint. + keychain.add(string: "stored-under-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("from-request")) + + // Backend requests passphrase with short ID — keystore lookup misses, falls through to request. + XCTAssertEqual(mockPGP.resolvedPassphrases, ["from-request"]) + XCTAssertEqual(passphraseRequests, [shortID]) + } + + // MARK: - Encrypt passthrough tests (for completeness of mock interaction) + + func testEncryptWithKeyIDs_passesThrough() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.publicKeyIDs = [longFingerprint] + + let result = try agent.encrypt(plainData: testDecryptedData, keyIDs: [longFingerprint]) + + XCTAssertEqual(result, mockPGP.encryptResult) + XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 1) + XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].keyIDs, [longFingerprint]) + XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].plainData, testDecryptedData) + } + + /// encrypt propagates errors from interface. + func testEncryptWithKeyIDs_interfaceThrows_propagatesError() { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.publicKeyIDs = [longFingerprint] + mockPGP.encryptError = AppError.encryption + + XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyIDs: [shortID])) { error in + 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.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 keyImport when checkAndInit triggers initKeys without PGP keys. + 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 new file mode 100644 index 0000000..9593669 --- /dev/null +++ b/passKitTests/Mocks/MockPGPInterface.swift @@ -0,0 +1,101 @@ +// +// MockPGPInterface.swift +// passKitTests +// + +import Foundation +@testable import passKit + +class MockPGPInterface: PGPInterface { + // MARK: - Configuration + + var publicKeyIDs: Set = [] + var privateKeyIDs: Set = [] + + var decryptResult: Data? + var decryptError: Error? + var encryptResult = Data() + var encryptError: Error? + + /// When set, the mock calls `passPhraseForKey` with this key ID during `decrypt`, + /// simulating the PGP backend selecting a key and requesting its passphrase. + var selectedKeyForPassphrase: String? + + // MARK: - Call tracking + + struct DecryptCall { + let encryptedData: Data + let keyID: String? + let passPhraseForKey: (String) -> String + } + + struct EncryptCall { + let plainData: Data + let keyID: String? + } + + struct EncryptMultiKeyCall { + let plainData: Data + let keyIDs: [String] + } + + struct EncryptWithAllKeysCall { + let plainData: Data + } + + var decryptCalls: [DecryptCall] = [] + var resolvedPassphrases: [String] = [] + var encryptMultiKeyCalls: [EncryptMultiKeyCall] = [] + var encryptWithAllKeysCalls: [EncryptWithAllKeysCall] = [] + var containsPublicKeyCalls: [String] = [] + var containsPrivateKeyCalls: [String] = [] + + // MARK: - PGPInterface + + func decrypt(encryptedData: Data, keyIDHint keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { + decryptCalls.append(DecryptCall(encryptedData: encryptedData, keyID: keyID, passPhraseForKey: passPhraseForKey)) + if let selectedKey = selectedKeyForPassphrase { + resolvedPassphrases.append(passPhraseForKey(selectedKey)) + } + if let error = decryptError { + throw error + } + return decryptResult + } + + 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()) } + } + + func containsPrivateKey(with keyID: String) -> Bool { + containsPrivateKeyCalls.append(keyID) + return privateKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) } + } + + func getKeyIDs(type _: PGPKey) -> [String] { + // currently irrelevant for the tests + [] + } + + func getShortKeyIDs(type _: PGPKey) -> [String] { + // currently irrelevant for the tests + [] + } +} diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 4fe2c80..e9b3c44 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -15,24 +15,46 @@ import XCTest final class PasswordStoreTest: XCTestCase { private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + private var keyStore: KeyStore! = nil + private var pgpAgent: PGPAgent! = nil private var passwordStore: PasswordStore! = nil override func setUp() { - passwordStore = PasswordStore(url: localRepoURL) + super.setUp() + + keyStore = DictBasedKeychain() + pgpAgent = PGPAgent(keyStore: keyStore) + passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent) + } + + private func setUpMockedPGPInterface() -> MockPGPInterface { + let mockPGPInterface = MockPGPInterface() + keyStore = DictBasedKeychain() + pgpAgent = PGPAgent(keyStore: keyStore, pgpInterface: mockPGPInterface) + passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent) + + // Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock. + keyStore.add(string: "dummy", for: Globals.pgpKeyPassphrase) + + return mockPGPInterface } override func tearDown() { passwordStore.erase() passwordStore = nil + pgpAgent = nil + keyStore = nil Defaults.removeAll() + + super.tearDown() } func testInitPasswordEntityCoreData() throws { try cloneRepository(.withGPGID) XCTAssertEqual(passwordStore.numberOfPasswords, 4) - XCTAssertEqual(passwordStore.numberOfCommits, 16) + XCTAssertEqual(passwordStore.numberOfCommits, 17) XCTAssertEqual(passwordStore.numberOfLocalCommits, 0) let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") @@ -75,20 +97,20 @@ final class PasswordStoreTest: XCTestCase { PasscodeLock.shared.save(passcode: "1234") XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) - XCTAssertTrue(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertTrue(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertEqual(Defaults.gitSignatureName, "Test User") XCTAssertTrue(PasscodeLock.shared.hasPasscode) - XCTAssertTrue(PGPAgent.shared.isInitialized()) + XCTAssertTrue(pgpAgent.isInitialized()) expectation(forNotification: .passwordStoreUpdated, object: nil) expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.erase() XCTAssertEqual(passwordStore.numberOfPasswords, 0) - XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertFalse(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) XCTAssertFalse(PasscodeLock.shared.hasPasscode) - XCTAssertFalse(PGPAgent.shared.isInitialized()) + XCTAssertFalse(pgpAgent.isInitialized()) waitForExpectations(timeout: 1, handler: nil) } @@ -312,30 +334,106 @@ final class PasswordStoreTest: XCTestCase { // MARK: - .gpg-id support - func testCloneAndDecryptMultiKeys() throws { + func testReadGPGIDFile() throws { try cloneRepository(.withGPGID) - try importMultiplePGPKeys() - Defaults.isEnableGPGIDOn = true [ - ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), - ("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"), - ].forEach { path, id in - let keyID = findGPGID(from: localRepoURL.appendingPathComponent(path)) - XCTAssertEqual(keyID, id) + ("", [RSA4096.longFingerprint]), + ("family", [String(NISTP384.longFingerprint.suffix(16))]), + ("personal", [RSA4096.longFingerprint]), + ("shared", [RSA2048.longFingerprint, RSA4096.longFingerprint]), + ("work", [RSA2048.longFingerprint]), + ].forEach { path, expectedKeyIDs in + let foundKeyIDs = findGPGIDs(from: localRepoURL.appendingPathComponent(path)) + XCTAssertEqual(foundKeyIDs, expectedKeyIDs.map { $0.uppercased() }) } + } - let personal = try decrypt(path: "personal/github.com.gpg") - XCTAssertEqual(personal.plainText, "passwordforpersonal\n") - - let work = try decrypt(path: "work/github.com.gpg") - XCTAssertEqual(work.plainText, "passwordforwork\n") + func testAddPasswordInRoot_WithSingleEntryInPGPIDFile_EncryptsWithThatKey() throws { + let mockPGPInterface = setUpMockedPGPInterface() + mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) + try cloneRepository(.withGPGID) + Defaults.isEnableGPGIDOn = true let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword") - let testPasswordEntity = try passwordStore.add(password: testPassword)! - let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - XCTAssertEqual(testPasswordPlain.plainText, "testpassword") + _ = try passwordStore.add(password: testPassword) + + XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1) + let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first + XCTAssertEqual(encryptCall?.plainData, testPassword.plainData) + XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() }) + } + + func testEncryptWithSingleKeyViaGPGIDFileInSubDirectory() throws { + let mockPGPInterface = setUpMockedPGPInterface() + mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) + try cloneRepository(.withGPGID) + Defaults.isEnableGPGIDOn = true + + let testPassword = Password(name: "test", path: "family/test.gpg", plainText: "testpassword") + _ = try passwordStore.add(password: testPassword) + + XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1) + let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first + XCTAssertEqual(encryptCall?.plainData, testPassword.plainData) + XCTAssertEqual(encryptCall?.keyIDs, [String(NISTP384.longFingerprint.suffix(16))].map { $0.uppercased() }) + } + + func testEncryptWithSingleKeyViaGPGIDFileInParentDir() throws { + let mockPGPInterface = setUpMockedPGPInterface() + mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) + try cloneRepository(.withGPGID) + Defaults.isEnableGPGIDOn = true + + // /personal doesn't have its own .gpg-id file, but should inherit from the root .gpg-id file + let testPassword = Password(name: "test", path: "personal/test.gpg", plainText: "testpassword") + _ = try passwordStore.add(password: testPassword) + + XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1) + let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first + XCTAssertEqual(encryptCall?.plainData, testPassword.plainData) + XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() }) + } + + func testEncryptWithMultipleKeysViaGPGIDFile() throws { + let mockPGPInterface = setUpMockedPGPInterface() + mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) + try cloneRepository(.withGPGID) + Defaults.isEnableGPGIDOn = true + + // /shared uses both RSA2048 and RSA4096 + let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword") + _ = try passwordStore.add(password: testPassword) + + XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1) + let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first + XCTAssertEqual(encryptCall?.plainData, testPassword.plainData) + XCTAssertEqual(encryptCall?.keyIDs, RSA2048_RSA4096.longFingerprints.map { $0.uppercased() }) + } + + func testEncryptWithSingleKeyViaGPGFile_MissingKey() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() // Only import RSA4096, but not RSA2048 + Defaults.isEnableGPGIDOn = true + + // /work uses RSA2048, but we didn't import that one + let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword") + XCTAssertThrowsError(try passwordStore.add(password: testPassword)) { + XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased())) + } + } + + func testEncryptWithMultipleKeysViaGPGFile_MissingKey() throws { + try cloneRepository(.withGPGID) + try importSinglePGPKey() // Only import RSA4096, but not RSA2048 + Defaults.isEnableGPGIDOn = true + + // /shared uses both RSA2048 and RSA4096, but we only imported RSA4096, so encryption should fail since one of the keys is missing + let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword") + XCTAssertThrowsError(try passwordStore.add(password: testPassword)) { + XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased())) + } } // MARK: - Helpers @@ -378,17 +476,15 @@ final class PasswordStoreTest: XCTestCase { } private func importSinglePGPKey() throws { - let keychain = AppKeychain.shared - try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.publicKey) - try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.privateKey) - try PGPAgent.shared.initKeys() + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.publicKey) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.privateKey) + try pgpAgent.initKeys() } private func importMultiplePGPKeys() throws { - let keychain = AppKeychain.shared - try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) - try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) - try PGPAgent.shared.initKeys() + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.publicKeys) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.privateKeys) + try pgpAgent.initKeys() } private func decrypt(path: String, keyID: String? = nil) throws -> Password { diff --git a/passKitTests/Testbase/TestPGPKeys.swift b/passKitTests/Testbase/TestPGPKeys.swift index da2177c..fd6d8bf 100644 --- a/passKitTests/Testbase/TestPGPKeys.swift +++ b/passKitTests/Testbase/TestPGPKeys.swift @@ -16,6 +16,7 @@ struct PGPTestSet { let publicKey: String let privateKey: String let fingerprint: String + let longFingerprint: String let passphrase: String fileprivate func collect() -> Self { // swiftlint:disable:this strict_fileprivate @@ -28,6 +29,7 @@ struct MultiKeyPGPTestSet { let publicKeys: String let privateKeys: String let fingerprints: [String] + let longFingerprints: [String] let passphrases: [String] } @@ -35,6 +37,7 @@ let RSA2048 = PGPTestSet( publicKey: PGP_RSA2048_PUBLIC_KEY, privateKey: PGP_RSA2048_PRIVATE_KEY, fingerprint: "a1024dae", + longFingerprint: "4712286271220db299883ea7062e678da1024dae", passphrase: "passforios" ).collect() @@ -42,6 +45,7 @@ let RSA2048_SUB = PGPTestSet( publicKey: PGP_RSA2048_PUBLIC_KEY, privateKey: PGP_RSA2048_PRIVATE_SUBKEY, fingerprint: "a1024dae", + longFingerprint: "4712286271220db299883ea7062e678da1024dae", passphrase: "passforios" ) @@ -49,6 +53,7 @@ let RSA3072_NO_PASSPHRASE = PGPTestSet( publicKey: PGP_RSA3072_PUBLIC_KEY_NO_PASSPHRASE, privateKey: PGP_RSA3072_PRIVATE_KEY_NO_PASSPHRASE, fingerprint: "be0f9402", + longFingerprint: "b37cd5669a03f0d46735a2ba35fba3d0be0f9402", passphrase: "" ) @@ -56,6 +61,7 @@ let RSA4096 = PGPTestSet( publicKey: PGP_RSA4096_PUBLIC_KEY, privateKey: PGP_RSA4096_PRIVATE_KEY, fingerprint: "d862027e", + longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e", passphrase: "passforios" ).collect() @@ -63,6 +69,7 @@ let RSA4096_SUB = PGPTestSet( publicKey: PGP_RSA4096_PUBLIC_KEY, privateKey: PGP_RSA4096_PRIVATE_SUBKEY, fingerprint: "d862027e", + longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e", passphrase: "passforios" ) @@ -70,6 +77,7 @@ let ED25519 = PGPTestSet( publicKey: PGP_ED25519_PUBLIC_KEY, privateKey: PGP_ED25519_PRIVATE_KEY, fingerprint: "e9444483", + longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483", passphrase: "passforios" ).collect() @@ -77,6 +85,7 @@ let ED25519_SUB = PGPTestSet( publicKey: PGP_ED25519_PUBLIC_KEY, privateKey: PGP_ED25519_PRIVATE_SUBKEY, fingerprint: "e9444483", + longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483", passphrase: "passforios" ) @@ -84,6 +93,7 @@ let NISTP384 = PGPTestSet( publicKey: PGP_NISTP384_PUBLIC_KEY, privateKey: PGP_NISTP384_PRIVATE_KEY, fingerprint: "5af3c085", + longFingerprint: "bcd364c078585c0607e19c67171c07d25af3c085", passphrase: "soirofssap" ).collect() @@ -91,6 +101,7 @@ let RSA2048_RSA4096 = MultiKeyPGPTestSet( publicKeys: PGP_RSA2048_PUBLIC_KEY | PGP_RSA4096_PUBLIC_KEY, privateKeys: PGP_RSA2048_PRIVATE_KEY | PGP_RSA4096_PRIVATE_KEY, fingerprints: ["a1024dae", "d862027e"], + longFingerprints: ["4712286271220db299883ea7062e678da1024dae", "787eae1a5fa3e749aa34cc6aa0645ebed862027e"], passphrases: ["passforios", "passforios"] ) @@ -98,6 +109,7 @@ let ED25519_NISTP384 = MultiKeyPGPTestSet( publicKeys: PGP_ED25519_PUBLIC_KEY | PGP_NISTP384_PUBLIC_KEY, privateKeys: PGP_ED25519_PRIVATE_KEY | PGP_NISTP384_PRIVATE_KEY, fingerprints: ["e9444483", "5af3c085"], + longFingerprints: ["5fccb081ab8af48972999e2ae750acbfe9444483", "bcd364c078585c0607e19c67171c07d25af3c085"], passphrases: ["passforios", "soirofssap"] ) 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