diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 4b5755f..d04dd81 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -116,8 +116,6 @@ 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 */; }; @@ -429,8 +427,6 @@ 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 = ""; }; @@ -780,22 +776,6 @@ 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 = ( @@ -921,8 +901,6 @@ 30697C5521F63F870064FCAC /* Extensions */, 8AD8EBF22F5E268D007475AB /* Fixtures */, 301F6464216164670071A4CE /* Helpers */, - 8AB3AD8F2F61600B0081DE16 /* LowLevel */, - 8AB3AD8E2F615FD70081DE16 /* Mocks */, 30C015A7214ED378005BB6DF /* Models */, 30C015A6214ED32A005BB6DF /* Parser */, 30B4C7BB24085A3C008B86F7 /* Passwords */, @@ -1666,8 +1644,6 @@ 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 37efdd1..98aa66a 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(type: .PRIVATE, controller: self) { action in + let selectKey = UIAlertAction.selectKey(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(type: .PUBLIC, controller: self) { action in + let selectKey = UIAlertAction.selectKey(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 b0a3e61..b715069 100644 --- a/pass/Controllers/SettingsTableViewController.swift +++ b/pass/Controllers/SettingsTableViewController.swift @@ -89,12 +89,10 @@ class SettingsTableViewController: UITableViewController, UITabBarControllerDele private func setPGPKeyTableViewCellDetailText() { var label = "NotSet".localize() - 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 { + let keyID = (try? PGPAgent.shared.getShortKeyID()) ?? [] + if keyID.count == 1 { + label = keyID.first ?? "" + } else if keyID.count > 1 { label = "Multiple" } if Defaults.isYubiKeyEnabled { diff --git a/pass/Services/PasswordDecryptor.swift b/pass/Services/PasswordDecryptor.swift index e18aaba..49e7845 100644 --- a/pass/Services/PasswordDecryptor.swift +++ b/pass/Services/PasswordDecryptor.swift @@ -30,11 +30,8 @@ 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(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + let decryptedPassword = try PasswordStore.shared.decrypt(path: passwordPath, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) DispatchQueue.main.async { completion(decryptedPassword) @@ -43,7 +40,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(type: PGPKey.PRIVATE, controller: controller) { action in + let selectKey = UIAlertAction.selectKey(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 6159881..254f045 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(type: .PUBLIC, controller: controller) { action in + let selectKey = UIAlertAction.selectKey(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 3917df1..34f0622 100644 --- a/passKit/Crypto/GopenPGPInterface.swift +++ b/passKit/Crypto/GopenPGPInterface.swift @@ -16,7 +16,6 @@ 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) @@ -41,24 +40,7 @@ struct GopenPGPInterface: PGPInterface { } throw AppError.keyImport } - - 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 - } + privateKeys[cryptoKey.getFingerprint().lowercased()] = cryptoKey } } @@ -88,13 +70,15 @@ struct GopenPGPInterface: PGPInterface { privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) } } - func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { - let message = createPGPMessage(from: encryptedData) - guard let message else { - throw AppError.decryption - } + 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 + }() - guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else { + guard let privateKey = key else { throw AppError.decryption } @@ -103,7 +87,6 @@ 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 @@ -117,43 +100,33 @@ 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 encryptWithAllKeys(plainData: Data) throws -> Data { - let keyIDs = publicKeys.keys.filter { key in privateKeys.keys.contains(key) } - return try encrypt(plainData: plainData, keyIDs: keyIDs) - } - - func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { - let keys: [CryptoKey] = try keyIDs.map { keyID in - guard let key = publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value else { - throw AppError.pgpPublicKeyNotFound(keyID: keyID) + 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 key - } - guard let firstKey = keys.first else { + return publicKeys.first?.value + }() + + guard let publicKey = key else { throw AppError.encryption } - let otherKeys = keys.dropFirst() var error: NSError? - guard let keyRing = CryptoNewKeyRing(firstKey, &error) else { + + guard let keyRing = CryptoNewKeyRing(publicKey, &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 { @@ -167,44 +140,12 @@ struct GopenPGPInterface: PGPInterface { return encryptedData.getBinary()! } - func getKeyIDs(type: PGPKey) -> [String] { - switch type { - case .PUBLIC: - return publicKeys.keys.map { $0.uppercased() } - case .PRIVATE: - return privateKeys.keys.map { $0.uppercased() } - } + var keyID: [String] { + publicKeys.keys.map { $0.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] + var shortKeyID: [String] { + publicKeys.keys.map { $0.suffix(8).uppercased() } } } diff --git a/passKit/Crypto/ObjectivePGPInterface.swift b/passKit/Crypto/ObjectivePGPInterface.swift index e1425c3..768d785 100644 --- a/passKit/Crypto/ObjectivePGPInterface.swift +++ b/passKit/Crypto/ObjectivePGPInterface.swift @@ -24,29 +24,12 @@ struct ObjectivePGPInterface: PGPInterface { } } - 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 decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? { + try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase } } - 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) + 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)! } @@ -61,20 +44,11 @@ struct ObjectivePGPInterface: PGPInterface { keyring.findKey(keyID)?.isSecret ?? false } - func getKeyIDs(type: PGPKey) -> [String] { - getKeys(type: type).map(\.keyID.longIdentifier) + var keyID: [String] { + keyring.keys.map(\.keyID.longIdentifier) } - 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) - } + var shortKeyID: [String] { + keyring.keys.map(\.keyID.shortIdentifier) } } diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 6ee8c2b..f193515 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -9,7 +9,7 @@ public class PGPAgent { public static let shared = PGPAgent() - let keyStore: KeyStore + private let keyStore: KeyStore private var pgpInterface: PGPInterface? private var latestDecryptStatus = true @@ -17,11 +17,6 @@ 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 { @@ -43,24 +38,30 @@ public class PGPAgent { pgpInterface != nil } - public func getKeyIDs(type: PGPKey) throws -> [String] { + public func getKeyID() throws -> [String] { try checkAndInit() - return pgpInterface?.getKeyIDs(type: type).sorted() ?? [] + return pgpInterface?.keyID ?? [] } - public func getShortKeyIDs(type: PGPKey) throws -> [String] { + public func getShortKeyID() throws -> [String] { try checkAndInit() - return pgpInterface?.getShortKeyIDs(type: type).sorted() ?? [] + return pgpInterface?.shortKeyID.sorted() ?? [] } - public func decrypt(encryptedData: Data, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { + public func decrypt(encryptedData: Data, keyID: String, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { + // Init keys. try checkAndInit() guard let pgpInterface else { throw AppError.decryption } - if let keyID, !pgpInterface.containsPrivateKey(with: keyID) { - throw AppError.pgpPrivateKeyNotFound(keyID: keyID) + var keyID = keyID + if !pgpInterface.containsPrivateKey(with: keyID) { + if pgpInterface.keyID.count == 1 { + keyID = pgpInterface.keyID.first! + } else { + throw AppError.pgpPrivateKeyNotFound(keyID: keyID) + } } // Remember the previous status and set the current status @@ -68,14 +69,14 @@ public class PGPAgent { latestDecryptStatus = false // Get the PGP key passphrase. - let providePassPhraseForKey = { (selectedKeyID: String) -> String in - if previousDecryptStatus == false { - return requestPGPKeyPassphrase(selectedKeyID) - } - return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) + 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, keyIDHint: keyID, passPhraseForKey: providePassPhraseForKey) else { + guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else { return nil } // The decryption step has succeed. @@ -83,20 +84,50 @@ public class PGPAgent { return result } - public func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { + public func encrypt(plainData: Data, keyID: String) throws -> Data { try checkAndInit() guard let pgpInterface else { throw AppError.encryption } - return try pgpInterface.encrypt(plainData: plainData, keyIDs: keyIDs) + var keyID = keyID + if !pgpInterface.containsPublicKey(with: keyID) { + if pgpInterface.keyID.count == 1 { + keyID = pgpInterface.keyID.first! + } else { + throw AppError.pgpPublicKeyNotFound(keyID: keyID) + } + } + return try pgpInterface.encrypt(plainData: plainData, keyID: keyID) } - public func encryptWithAllKeys(plainData: Data) throws -> Data { + 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("") + } + // Decrypt. + guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passphrase: passphrase) else { + return nil + } + // The decryption step has succeed. + latestDecryptStatus = true + return result + } + + public func encrypt(plainData: Data) throws -> Data { try checkAndInit() guard let pgpInterface else { throw AppError.encryption } - return try pgpInterface.encryptWithAllKeys(plainData: plainData) + return try pgpInterface.encrypt(plainData: plainData, keyID: nil) } public var isPrepared: Bool { diff --git a/passKit/Crypto/PGPInterface.swift b/passKit/Crypto/PGPInterface.swift index 2afce6d..b77831d 100644 --- a/passKit/Crypto/PGPInterface.swift +++ b/passKit/Crypto/PGPInterface.swift @@ -7,15 +7,15 @@ // protocol PGPInterface { - func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? + func decrypt(encryptedData: Data, keyID: String?, passphrase: 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 encrypt(plainData: Data, keyID: String?) throws -> Data func containsPublicKey(with keyID: String) -> Bool + func containsPrivateKey(with keyID: String) -> Bool - func getKeyIDs(type: PGPKey) -> [String] - func getShortKeyIDs(type: PGPKey) -> [String] + var keyID: [String] { get } + + var shortKeyID: [String] { get } } diff --git a/passKit/Extensions/UIAlertActionExtension.swift b/passKit/Extensions/UIAlertActionExtension.swift index d258e3b..b49ba07 100644 --- a/passKit/Extensions/UIAlertActionExtension.swift +++ b/passKit/Extensions/UIAlertActionExtension.swift @@ -38,10 +38,10 @@ public extension UIAlertAction { } } - static func selectKey(type: PGPKey, controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction { + static func selectKey(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.getShortKeyIDs(type: type).forEach { keyID in + try? PGPAgent.shared.getShortKeyID().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 65bf31c..b918ab4 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -23,7 +23,6 @@ public class PasswordStore { }() public var storeURL: URL - private let pgpAgent: PGPAgent public var gitRepository: GitRepository? @@ -85,9 +84,8 @@ public class PasswordStore { gitRepository?.numberOfCommits() } - init(url: URL = Globals.repositoryURL, pgpAgent: PGPAgent = .shared) { + init(url: URL = Globals.repositoryURL) { self.storeURL = url - self.pgpAgent = pgpAgent // Migration importExistingKeysIntoKeychain() @@ -361,14 +359,14 @@ public class PasswordStore { eraseStoreData() // Delete PGP key, SSH key and other secrets from the keychain. - pgpAgent.keyStore.removeAllContent() + AppKeychain.shared.removeAllContent() // Delete default settings. Defaults.removeAll() // Delete cache explicitly. PasscodeLock.shared.delete() - pgpAgent.uninitKeys() + PGPAgent.shared.uninitKeys() } // return the number of discarded commits @@ -397,7 +395,13 @@ 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 pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + 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) + }() guard let decryptedData = data else { throw AppError.decryption } @@ -405,20 +409,23 @@ public class PasswordStore { return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText) } - public func encrypt(password: Password, keyID: String? = nil) throws -> Data { - let keyIDs: [String] = { - if let keyID { - return [keyID] - } - if Defaults.isEnableGPGIDOn { - return findGPGIDs(underPath: password.path) - } - return [] - }() - if !keyIDs.isEmpty { - return try pgpAgent.encrypt(plainData: password.plainData, keyIDs: keyIDs) + public func decrypt(path: String, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password { + guard let passwordEntity = fetchPasswordEntity(with: path) else { + throw AppError.decryption } - return try pgpAgent.encryptWithAllKeys(plainData: password.plainData) + 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 { + let encryptedDataPath = password.fileURL(in: storeURL) + let keyID = keyID ?? findGPGID(from: encryptedDataPath) + if Defaults.isEnableGPGIDOn { + return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID) + } + return try PGPAgent.shared.encrypt(plainData: password.plainData) } public func removeGitSSHKeys() { @@ -429,37 +436,6 @@ public class PasswordStore { AppKeychain.shared.removeContent(for: SSHKey.PRIVATE.getKeychainKey()) gitSSHPrivateKeyPassphrase = nil } - - func findGPGIDs(underPath relativePath: String) -> [String] { - guard let gpgIDFileURL = findGPGIDFile(atPath: relativePath) else { - return [] - } - let allKeysSeparatedByNewline = (try? String(contentsOf: gpgIDFileURL)) ?? "" - return allKeysSeparatedByNewline - .split(separator: "\n") - .map { String($0).trimmed } - .filter { !$0.isEmpty } - } - - func findGPGIDFile(atPath relativePath: String) -> URL? { - // Walk up the directory hierarchy, but never escape the store root - let storeRoot = storeURL.absoluteURL.resolvingSymlinksInPath() - var current = storeRoot.appendingPathComponent(relativePath).resolvingSymlinksInPath() - - while current.path.hasPrefix(storeRoot.path) { - let candidate = current.appendingPathComponent(".gpg-id") - var isDir: ObjCBool = false - if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDir), !isDir.boolValue { - return candidate.standardizedFileURL - } - let parent = current.deletingLastPathComponent().resolvingSymlinksInPath() - if parent.path == current.path { - break - } - current = parent - } - return nil - } } extension PasswordStore { @@ -492,3 +468,14 @@ extension PasswordStore { return try gitRepository.commit(signature: gitSignatureForNow, message: message) } } + +func findGPGID(from url: URL) -> String { + var path = url + while !FileManager.default.fileExists(atPath: path.appendingPathComponent(".gpg-id").path), + path.path != "file:///" { + path = path.deletingLastPathComponent() + } + path = path.appendingPathComponent(".gpg-id") + + return (try? String(contentsOf: path))?.trimmed ?? "" +} diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index 267742c..8acb2be 100644 --- a/passKitTests/Crypto/PGPAgentTest.swift +++ b/passKitTests/Crypto/PGPAgentTest.swift @@ -31,7 +31,34 @@ final class PGPAgentTest: XCTestCase { super.tearDown() } - // - MARK: Basic encrypt and decrypt tests + 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) + } + } + } + } func testBasicEncryptDecrypt() throws { try [ @@ -48,8 +75,7 @@ final class PGPAgentTest: XCTestCase { try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey) XCTAssert(pgpAgent.isPrepared) try pgpAgent.initKeys() - XCTAssert(try pgpAgent.getKeyIDs(type: .PUBLIC).first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) - XCTAssert(try pgpAgent.getKeyIDs(type: .PRIVATE).first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) + XCTAssert(try pgpAgent.getKeyID().first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) try [ (true, true), (true, false), @@ -135,116 +161,8 @@ 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 deleted file mode 100644 index d798c2c..0000000 Binary files a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/23/44f8116ab49ad30dd96d92e306a2b28839ee71 and /dev/null 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 deleted file mode 100644 index 69995d0..0000000 Binary files a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/34/22e35e46f42b12fdca050c9bc424028d0e89c8 and /dev/null 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 deleted file mode 100644 index ea752fb..0000000 Binary files a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/8b/08bf485e19720eae26df62ddceefbb8b4d8247 and /dev/null 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 deleted file mode 100644 index 61d0371..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/bb/1f455127743a2e202840fc39201937a2457ed2 +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 89b191d..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/c7/c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 2b6288a..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/refs/heads/master +++ /dev/null @@ -1 +0,0 @@ -c7c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 diff --git a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift deleted file mode 100644 index a1e6e99..0000000 --- a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift +++ /dev/null @@ -1,414 +0,0 @@ -// -// 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 deleted file mode 100644 index 9593669..0000000 --- a/passKitTests/Mocks/MockPGPInterface.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// 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 52b683c..4fe2c80 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -15,46 +15,24 @@ 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() { - 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 + passwordStore = PasswordStore(url: localRepoURL) } 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, 17) + XCTAssertEqual(passwordStore.numberOfCommits, 16) XCTAssertEqual(passwordStore.numberOfLocalCommits, 0) let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") @@ -97,20 +75,20 @@ final class PasswordStoreTest: XCTestCase { PasscodeLock.shared.save(passcode: "1234") XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) - XCTAssertTrue(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertTrue(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertEqual(Defaults.gitSignatureName, "Test User") XCTAssertTrue(PasscodeLock.shared.hasPasscode) - XCTAssertTrue(pgpAgent.isInitialized()) + XCTAssertTrue(PGPAgent.shared.isInitialized()) expectation(forNotification: .passwordStoreUpdated, object: nil) expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.erase() XCTAssertEqual(passwordStore.numberOfPasswords, 0) - XCTAssertFalse(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) + XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) XCTAssertFalse(PasscodeLock.shared.hasPasscode) - XCTAssertFalse(pgpAgent.isInitialized()) + XCTAssertFalse(PGPAgent.shared.isInitialized()) waitForExpectations(timeout: 1, handler: nil) } @@ -332,248 +310,32 @@ final class PasswordStoreTest: XCTestCase { waitForExpectations(timeout: 1, handler: nil) } - // MARK: - Find .gpg-id + // MARK: - .gpg-id support - func testFindGPGIDFile() throws { - try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true) - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data("under root".utf8))) - - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("foo/bar/baz"), withIntermediateDirectories: true) - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path, contents: Data("under foo".utf8))) - - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/"), withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey"), withIntermediateDirectories: true) - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path, contents: Data("under hey".utf8))) - - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "/")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/..")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) - - // there is a _drectory_ called .gpg-id in here - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id/hey")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path)) - - // "foo/bar/../../baz" resolves to "baz" which has no .gpg-id, so should find root's. - // Without path resolution, the walk ["foo","bar","..","..","baz"] → remove "baz" → remove ".." → - // "foo/bar/.." → remove ".." → "foo/bar" → finds foo/.gpg-id (wrong). - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("baz"), withIntermediateDirectories: true) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/../../baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - } - - func testMissingGPGIDFile() throws { - XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("subdir"), withIntermediateDirectories: true) - - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "subdir")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "missing")) - } - - func testFindGPGIDFileStopsAtRoot() throws { - // Place a .gpg-id file ABOVE the store root, this should not be found - let parentDir = localRepoURL.deletingLastPathComponent() - let escapedGPGIDURL = parentDir.appendingPathComponent(".gpg-id") - XCTAssertTrue(FileManager.default.createFile(atPath: escapedGPGIDURL.path, contents: Data("ESCAPED_KEY".utf8))) - defer { try? FileManager.default.removeItem(at: escapedGPGIDURL) } - - // Store has no .gpg-id at all - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("sub/deep"), withIntermediateDirectories: true) - // Direct paths, should not find the escaped .gpg-id since it's outside the store root - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep")) - - // Path traversal attempts via ".." - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "..")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "../..")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/../..")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../..")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../../../../etc")) - - // Symlink escape: create a symlink inside the store pointing outside - let evilDir = parentDir.appendingPathComponent("evil") - try FileManager.default.createDirectory(at: evilDir, withIntermediateDirectories: true) - XCTAssertTrue(FileManager.default.createFile(atPath: evilDir.appendingPathComponent(".gpg-id").path, contents: Data("EVIL_KEY".utf8))) - defer { try? FileManager.default.removeItem(at: evilDir) } - try FileManager.default.createSymbolicLink(at: localRepoURL.appendingPathComponent("sub/escape"), withDestinationURL: evilDir) - // Following the symlink would find evil/.gpg-id — must not happen - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/escape")) - } - - // MARK: Parse .gpg-id - - func testReadGPGIDFile() throws { + func testCloneAndDecryptMultiKeys() throws { try cloneRepository(.withGPGID) - [ - ("", [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 = passwordStore.findGPGIDs(underPath: path) - XCTAssertEqual(foundKeyIDs, expectedKeyIDs.map { $0.uppercased() }, "Failed for path: \(path)") - } - } + try importMultiplePGPKeys() - func testReadEmptyGPGIDFile() throws { - try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil)) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8))) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) - } - - func testReadGPGIDFileWithWhitespace() throws { - try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil)) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8))) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \nbar foo\n\tbaz\n \n".utf8))) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), ["bar foo", "baz"]) - } - - // MARK: Handle .gpg-id - - func testAddPasswordInRoot_WithSingleEntryInPGPIDFile_EncryptsWithThatKey() throws { - let mockPGPInterface = setUpMockedPGPInterface() - mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) - try cloneRepository(.withGPGID) 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) + } + + 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") + let testPassword = Password(name: "test", path: "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 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())) - } - } - - func testGPGIDDisabledIgnoresGPGIDFile() throws { - try cloneRepository(.withGPGID) - try importSinglePGPKey() // Only import RSA4096, but not RSA2048 - Defaults.isEnableGPGIDOn = false - - // /work uses RSA2048, but we didn't import that one - let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword") - // this would throw if isEnableGPGIDOn was true, since we are missing the key to encrypt it - _ = try passwordStore.add(password: testPassword) - - // check that we can decrypt it with the key we have, which confirms that it was encrypted without using the .gpg-id file - let decryptedPassword = try decrypt(path: "work/test.gpg", keyID: RSA4096.longFingerprint) - XCTAssertEqual(decryptedPassword.plainText, "testpassword") - - // we can't even decrypt it with RSA2048 - try importMultiplePGPKeys() - XCTAssertThrowsError(try decrypt(path: "work/test.gpg", keyID: RSA2048.longFingerprint)) { - XCTAssertEqual($0 as? AppError, .keyExpiredOrIncompatible) - } - } - - func testEncryptWithExplicitKeyID_OverridesGPGIDFile() throws { - continueAfterFailure = false // avoid index out of bounds error below - - let mockPGPInterface = setUpMockedPGPInterface() - mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) - try cloneRepository(.withGPGID) - Defaults.isEnableGPGIDOn = true - - // Even though /personal would normally use RSA4096 from the root .gpg-id file, if we explicitly specify a key ID then that should be used instead - let testPassword1 = Password(name: "test1", path: "personal/test1.gpg", plainText: "testpassword1") - _ = try passwordStore.add(password: testPassword1) - let testPassword2 = Password(name: "test2", path: "personal/test2.gpg", plainText: "testpassword2") - _ = try passwordStore.add(password: testPassword2, keyID: RSA2048.longFingerprint) - - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 2) - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].plainData, testPassword1.plainData) - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() }) - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].plainData, testPassword2.plainData) - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].keyIDs, [RSA2048.longFingerprint]) + let testPasswordEntity = try passwordStore.add(password: testPassword)! + let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + XCTAssertEqual(testPasswordPlain.plainText, "testpassword") } // MARK: - Helpers @@ -616,15 +378,17 @@ final class PasswordStoreTest: XCTestCase { } private func importSinglePGPKey() throws { - 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() + 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() } private func importMultiplePGPKeys() throws { - 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() + 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() } private func decrypt(path: String, keyID: String? = nil) throws -> Password { diff --git a/passKitTests/Testbase/TestPGPKeys.swift b/passKitTests/Testbase/TestPGPKeys.swift index fd6d8bf..da2177c 100644 --- a/passKitTests/Testbase/TestPGPKeys.swift +++ b/passKitTests/Testbase/TestPGPKeys.swift @@ -16,7 +16,6 @@ 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 @@ -29,7 +28,6 @@ struct MultiKeyPGPTestSet { let publicKeys: String let privateKeys: String let fingerprints: [String] - let longFingerprints: [String] let passphrases: [String] } @@ -37,7 +35,6 @@ let RSA2048 = PGPTestSet( publicKey: PGP_RSA2048_PUBLIC_KEY, privateKey: PGP_RSA2048_PRIVATE_KEY, fingerprint: "a1024dae", - longFingerprint: "4712286271220db299883ea7062e678da1024dae", passphrase: "passforios" ).collect() @@ -45,7 +42,6 @@ let RSA2048_SUB = PGPTestSet( publicKey: PGP_RSA2048_PUBLIC_KEY, privateKey: PGP_RSA2048_PRIVATE_SUBKEY, fingerprint: "a1024dae", - longFingerprint: "4712286271220db299883ea7062e678da1024dae", passphrase: "passforios" ) @@ -53,7 +49,6 @@ let RSA3072_NO_PASSPHRASE = PGPTestSet( publicKey: PGP_RSA3072_PUBLIC_KEY_NO_PASSPHRASE, privateKey: PGP_RSA3072_PRIVATE_KEY_NO_PASSPHRASE, fingerprint: "be0f9402", - longFingerprint: "b37cd5669a03f0d46735a2ba35fba3d0be0f9402", passphrase: "" ) @@ -61,7 +56,6 @@ let RSA4096 = PGPTestSet( publicKey: PGP_RSA4096_PUBLIC_KEY, privateKey: PGP_RSA4096_PRIVATE_KEY, fingerprint: "d862027e", - longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e", passphrase: "passforios" ).collect() @@ -69,7 +63,6 @@ let RSA4096_SUB = PGPTestSet( publicKey: PGP_RSA4096_PUBLIC_KEY, privateKey: PGP_RSA4096_PRIVATE_SUBKEY, fingerprint: "d862027e", - longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e", passphrase: "passforios" ) @@ -77,7 +70,6 @@ let ED25519 = PGPTestSet( publicKey: PGP_ED25519_PUBLIC_KEY, privateKey: PGP_ED25519_PRIVATE_KEY, fingerprint: "e9444483", - longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483", passphrase: "passforios" ).collect() @@ -85,7 +77,6 @@ let ED25519_SUB = PGPTestSet( publicKey: PGP_ED25519_PUBLIC_KEY, privateKey: PGP_ED25519_PRIVATE_SUBKEY, fingerprint: "e9444483", - longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483", passphrase: "passforios" ) @@ -93,7 +84,6 @@ let NISTP384 = PGPTestSet( publicKey: PGP_NISTP384_PUBLIC_KEY, privateKey: PGP_NISTP384_PRIVATE_KEY, fingerprint: "5af3c085", - longFingerprint: "bcd364c078585c0607e19c67171c07d25af3c085", passphrase: "soirofssap" ).collect() @@ -101,7 +91,6 @@ 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"] ) @@ -109,7 +98,6 @@ 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/plans/02-multi-recipient-encryption-plan.md b/plans/02-multi-recipient-encryption-plan.md index 132cfce..28defd4 100644 --- a/plans/02-multi-recipient-encryption-plan.md +++ b/plans/02-multi-recipient-encryption-plan.md @@ -30,7 +30,7 @@ The codebase does **not** support encrypting to multiple public keys. Every laye ### 1. `findGPGID(from:) -> [String]` -Split file contents by newline, trim each line, filter empty lines. Return array of key IDs. +Split file contents by newline, trim each line, filter empty lines. Return array of key IDs. Callers that only need a single key (e.g. for decryption routing) can use `.first`. ### 2. `PGPInterface` protocol @@ -60,10 +60,10 @@ When a store lists multiple key IDs in `.gpg-id`, the user needs the public keys ### Current state -- The keychain holds **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob, but each can contain **multiple concatenated armored key blocks**. Both interface implementations parse all keys from these blobs. -- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the set of keys entirely, there is no append mode for adding additional keys or managing existing keys. -- There is no UI for viewing which key IDs are loaded or for importing additional recipient-only public keys, nor for viewing the key metadata. -- There is no UI for viewing or editing `.gpg-id` files, which are the source of truth for which keys are used for encryption. +- The keychain holds exactly **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob. +- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the previous key pair entirely. +- Both `GopenPGPInterface` and `ObjectivePGPInterface` *can* parse multiple keys from a single armored blob (e.g. concatenated armor blocks). So if the user pastes multiple public keys into the single field, they would be parsed — but the encrypt path only uses one key, and the UI doesn't communicate this. +- There is no UI for viewing which key IDs are loaded. ### Key storage approach @@ -142,19 +142,20 @@ This can be expensive for large directories. Show progress and allow cancellatio ## Implementation Order -| Step | Description | Status | Depends On | -|------|-------------|--------|------------| -| 1 | `findGPGIDs` returns `[String]` + update callers | ✅ Done | — | -| 2 | `PGPInterface` protocol change (`keyIDs: [String]`) | ✅ Done | — | -| 3 | `GopenPGPInterface` multi-key encryption | ✅ Done | Step 2 | -| 4 | `ObjectivePGPInterface` multi-key encryption | ✅ Done | Step 2 | -| 5 | `PGPAgent` updated overloads | ✅ Done | Steps 2-4 | -| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGIDs` | ✅ Done | Steps 1+5 | -| 7 | UI: import additional recipient public keys | Not started | Step 5 | -| 8 | UI: view loaded key IDs and metadata | Not started | Step 5 | -| 9a | UI: view `.gpg-id` in password detail / folder view | Not started | Step 1 | -| 9b | UI: edit `.gpg-id` | Not started | Step 9a | -| 10 | Re-encryption when `.gpg-id` changes | Not started | Steps 6+9b | +| Step | Description | Depends On | +|------|-------------|------------| +| 1 | `findGPGID` returns `[String]` + update callers | — | +| 2 | `PGPInterface` protocol change (`keyIDs: [String]?`) | — | +| 3 | `GopenPGPInterface` multi-key encryption | Step 2 | +| 4 | `ObjectivePGPInterface` multi-key encryption | Step 2 | +| 5 | `PGPAgent` updated overloads | Steps 2-4 | +| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGID` | Steps 1+5 | +| 7 | UI: import additional recipient public keys | Step 5 | +| 8 | UI: view loaded key IDs | Step 5 | +| 9a | UI: view `.gpg-id` in password detail / folder view | Step 1 | +| 9b | UI: edit `.gpg-id` | Step 9a | +| 10 | Re-encryption when `.gpg-id` changes | Steps 6+9b | +| T | Tests (see testing section) | Steps 1-10 | --- diff --git a/scripts/gopenpgp_build.sh b/scripts/gopenpgp_build.sh index b851cb9..98dc8bf 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://forgejo.tranvouez.eu/lysann/passforios-gopenpgp.git "$GOPENPGP_PATH" +git clone --depth 1 --branch "$GOPENPGP_VERSION" https://github.com/mssun/gopenpgp.git "$GOPENPGP_PATH" pushd "$GOPENPGP_PATH" mkdir -p dist