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/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index f193515..67088ab 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -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 { diff --git a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift new file mode 100644 index 0000000..85ed721 --- /dev/null +++ b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift @@ -0,0 +1,661 @@ +// +// 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) + XCTAssertEqual(passphraseRequests, [longFingerprint]) + } + + /// When the private key is NOT found but there's exactly one key, falls back to that key. + func testDecryptWithKeyID_keyNotFound_singleKey_fallsBackToOnlyKey() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [] // requested key not found + mockPGP.keyIDs = [longFingerprint] + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(mockPGP.decryptCalls.count, 1) + // The keyID passed to pgpInterface.decrypt should be the fallback key, not the requested one. + XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint) + XCTAssertEqual(passphraseRequests, [longFingerprint]) + } + + /// When the private key is NOT found and there are multiple keys, throws pgpPrivateKeyNotFound. + func testDecryptWithKeyID_keyNotFound_multipleKeys_throws() { + mockPGP.privateKeyIDs = [] + mockPGP.keyIDs = ["4712286271220db299883ea7062e678da1024dae", "787eae1a5fa3e749aa34cc6aa0645ebed862027e"] + + 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) + XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when key resolution fails") + } + + /// 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]) + XCTAssertEqual(passphraseRequests, [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] + keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("requested-passphrase")) + + XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when passphrase is in keystore") + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "stored-passphrase") + } + + /// 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] + // 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(passphraseRequests, [shortID]) + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "my-passphrase") + } + + /// On first decrypt with key fallback, the resolved keyID is used for passphrase lookup and request. + func testDecryptWithKeyID_firstCall_keyFallback_usesResolvedKeyIDForPassphrase() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [] + mockPGP.keyIDs = [longFingerprint] + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass")) + + // The passphrase and decrypt calls should use the resolved key, not the originally requested one. + XCTAssertEqual(passphraseRequests, [longFingerprint]) + XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint) + } + + /// After a failed decrypt (latestDecryptStatus=false), requestPGPKeyPassphrase is ALWAYS called, + /// even if the keystore has a cached passphrase. + func testDecryptWithKeyID_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() + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh-passphrase")) + + XCTAssertEqual(passphraseRequests, [longFingerprint], "After failure, passphrase should always be requested") + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh-passphrase") + } + + /// After a successful decrypt, the next call uses keystore first (latestDecryptStatus=true). + func testDecryptWithKeyID_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() + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("ignored-passphrase")) + + XCTAssertEqual(passphraseRequests, []) + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "pass1") + } + + /// The passphrase keystore key is constructed with the resolved keyID (after fallback). + func testDecryptWithKeyID_passphraseKeystoreKey_usesResolvedKeyID() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [] + mockPGP.keyIDs = [longFingerprint] + keychain.add(string: "fallback-pass", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: "e9444483", requestPGPKeyPassphrase: passphraseCallback("should-not-use")) + + XCTAssertEqual(passphraseRequests, []) + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fallback-pass") + } + + // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Return Values & Error Propagation + + /// When pgpInterface.decrypt returns nil, agent.decrypt returns nil. + func testDecryptWithKeyID_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) + XCTAssertEqual(passphraseRequests, [longFingerprint]) + } + + /// When pgpInterface.decrypt returns nil, latestDecryptStatus stays false + /// (next call will always request passphrase). + func testDecryptWithKeyID_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() + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh")) + + XCTAssertEqual(passphraseRequests, [shortID], "After nil return, passphrase should always be requested") + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh") + } + + /// When pgpInterface.decrypt throws, the error propagates and latestDecryptStatus stays false. + func testDecryptWithKeyID_interfaceThrows_propagatesError() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + mockPGP.decryptError = AppError.wrongPassphrase + + XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in + XCTAssertEqual(error as? AppError, AppError.wrongPassphrase) + } + XCTAssertEqual(passphraseRequests, [shortID]) + + // 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() + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh")) + + XCTAssertEqual(passphraseRequests, [shortID], "After throw, passphrase should always be requested (latestDecryptStatus=false)") + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh") + } + + /// After successful decrypt, latestDecryptStatus is true. + func testDecryptWithKeyID_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() + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("should-not-use")) + + XCTAssertEqual(passphraseRequests, [], "After success, should try keystore first") + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "good") + } + + // 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 testDecryptWithKeyID_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:requestPGPKeyPassphrase:) - No KeyID Overload + + /// 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 requests passphrase with empty string keyID. + func testDecryptNoKeyID_requestsPassphraseWithEmptyString() throws { + _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(passphraseRequests, [""]) + } + + /// The no-keyID overload uses empty string as the keyID for passphrase lookup in keyStore/AppKeychain. + func testDecryptNoKeyID_usesEmptyStringForPassphraseLookup() throws { + keychain.add(string: "empty-key-pass", for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) + + _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("should-not-use")) + + XCTAssertEqual(passphraseRequests, []) + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "empty-key-pass") + } + + /// After failure, the no-keyID overload always requests passphrase (with empty string). + func testDecryptNoKeyID_afterFailure_alwaysRequestsPassphrase() throws { + // Force a failure. + mockPGP.decryptError = AppError.wrongPassphrase + _ = try? agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("bad")) + + // Next one can succeed + keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) + mockPGP.decryptError = nil + mockPGP.decryptCalls.removeAll() + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("fresh")) + + XCTAssertEqual(passphraseRequests, [""]) + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh") + } + + /// The no-keyID overload returns nil when interface returns nil. + func testDecryptNoKeyID_interfaceReturnsNil_returnsNil() throws { + mockPGP.decryptResult = nil + + let result = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertNil(result) + XCTAssertEqual(passphraseRequests, [""]) + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "pass") + } + + /// The no-keyID overload propagates errors from the interface. + func testDecryptNoKeyID_interfaceThrows_propagatesError() { + mockPGP.decryptError = AppError.wrongPassphrase + + XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in + XCTAssertEqual(error as? AppError, AppError.wrongPassphrase) + } + XCTAssertEqual(passphraseRequests, [""]) + } + + /// The no-keyID overload sets latestDecryptStatus to true on success, + /// which affects subsequent passphrase lookups. + func testDecryptNoKeyID_success_setsStatusTrue() throws { + // Force failure first. + mockPGP.decryptError = AppError.wrongPassphrase + _ = try? agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("bad")) + mockPGP.decryptError = nil + mockPGP.decryptCalls.removeAll() + passphraseRequests.removeAll() + + // Succeed. + _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("good")) + + // Third call: should try keystore. + keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) + mockPGP.decryptCalls.removeAll() + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("nope")) + + XCTAssertEqual(passphraseRequests, []) + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "cached") + } + + /// The no-keyID overload doesn't check containsPrivateKey and doesn't resolve key. + func testDecryptNoKeyID_doesNotCheckPrivateKey() throws { + _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(mockPGP.containsPrivateKeyCalls.count, 0) + XCTAssertEqual(passphraseRequests, [""]) + } + + // MARK: - Key resolution error vs decrypt status ordering + + /// When pgpPrivateKeyNotFound is thrown (key not found, multiple keys), + /// latestDecryptStatus is NOT changed because the error occurs BEFORE the status update. + func testDecryptWithKeyID_keyNotFound_multipleKeys_doesNotChangeDecryptStatus() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [] + mockPGP.keyIDs = [longFingerprint, "787eae1a5fa3e749aa34cc6aa0645ebed862027e"] + + // 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)) + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh")) + + XCTAssertEqual(passphraseRequests, [], "After pgpPrivateKeyNotFound, latestDecryptStatus should be unchanged (still true)") + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "cached-pass") + } + + /// After failure + key fallback: passphrase is always requested using the RESOLVED (fallback) keyID. + func testDecryptWithKeyID_afterFailure_keyFallback_requestsWithResolvedKeyID() throws { + let shortID = "a1024dae" + let longFingerprint1 = "4712286271220db299883ea7062e678da1024dae" + let longFingerprint2 = "5fccb081ab8af48972999e2ae750acbfe9444483" + mockPGP.privateKeyIDs = [longFingerprint1] + + // Force a failure using a short ID that suffix-matches longFingerprint1. + mockPGP.decryptError = AppError.wrongPassphrase + _ = try? agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("bad")) + + // Now try with an unknown key that falls back to a different long fingerprint. + mockPGP.decryptError = nil + mockPGP.privateKeyIDs = [] + mockPGP.keyIDs = [longFingerprint2] + mockPGP.decryptCalls.removeAll() + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: "e9444483", requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(passphraseRequests, [longFingerprint2]) + XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint2) + } + + // MARK: - Cross-overload latestDecryptStatus interaction + + /// latestDecryptStatus is shared between both overloads. + /// A failure in the keyID overload affects the no-keyID overload. + func testDecryptStatusSharedBetweenOverloads_failureInKeyIDOverload() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + + // Fail via keyID overload. + mockPGP.decryptError = AppError.wrongPassphrase + _ = try? agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad")) + + // Next call via no-keyID overload should always request. + keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) + mockPGP.decryptError = nil + mockPGP.decryptCalls.removeAll() + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("fresh")) + + XCTAssertEqual(passphraseRequests, [""], "Failure in keyID overload should affect no-keyID overload") + } + + /// A failure in the no-keyID overload affects the keyID overload. + func testDecryptStatusSharedBetweenOverloads_failureInNoKeyIDOverload() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + + // Fail via no-keyID overload. + mockPGP.decryptError = AppError.wrongPassphrase + _ = try? agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("bad")) + + // Next call via keyID overload should always request. + mockPGP.decryptError = nil + mockPGP.decryptCalls.removeAll() + keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)) + passphraseRequests.removeAll() + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh")) + + XCTAssertEqual(passphraseRequests, [shortID], "Failure in no-keyID overload should affect keyID overload") + } + + // MARK: - Short vs long key ID behavior + + /// When caller passes a short ID and containsPrivateKey matches it (via suffix), the short ID + /// is used for passphrase lookup, request callback, and pgpInterface.decrypt — it is NOT resolved + /// to a longer fingerprint. + func testDecryptWithKeyID_shortIDRecognized_shortIDFlowsThrough() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + mockPGP.keyIDs = [longFingerprint] + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass")) + + // The short ID passes through everywhere — no resolution to the long fingerprint. + XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID]) + XCTAssertEqual(mockPGP.decryptCalls[0].keyID, shortID) + XCTAssertEqual(passphraseRequests, [shortID]) + } + + /// When caller passes a short ID and containsPrivateKey does NOT match, but there's one key, + /// the long fingerprint from keyID[0] is used everywhere instead. + func testDecryptWithKeyID_shortIDNotRecognized_singleKey_resolvesToLongFingerprint() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [] // short ID doesn't match + mockPGP.keyIDs = [longFingerprint] + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass")) + + XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID]) + XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint) + XCTAssertEqual(passphraseRequests, [longFingerprint]) + } + + /// Passphrase stored under long fingerprint is NOT found when the short ID is used for lookup + /// (because PGPAgent uses the caller's short ID as-is when containsPrivateKey matches). + func testDecryptWithKeyID_shortIDRecognized_passphraseStoredUnderLongID_missesKeystore() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [longFingerprint] + mockPGP.keyIDs = [longFingerprint] + + // 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")) + + // Keystore lookup uses the short ID, which doesn't match the long key → falls through to request. + XCTAssertEqual(passphraseRequests, [shortID]) + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "from-request") + } + + /// When short ID is NOT recognized and fallback resolves to long fingerprint, + /// passphrase stored under the long fingerprint IS found. + func testDecryptWithKeyID_shortIDNotRecognized_fallbackToLong_passphraseFoundInKeystore() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.privateKeyIDs = [] + mockPGP.keyIDs = [longFingerprint] + + keychain.add(string: "stored-under-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) + + _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("should-not-use")) + + // Keystore lookup uses the resolved long fingerprint → finds the passphrase. + XCTAssertEqual(passphraseRequests, []) + XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "stored-under-long") + XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint) + } + + /// Encrypt with short ID: when containsPublicKey matches (via suffix), the short ID is passed to interface. + func testEncryptWithKeyID_shortIDRecognized_shortIDFlowsThrough() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.publicKeyIDs = [longFingerprint] + mockPGP.keyIDs = [longFingerprint] + + _ = try agent.encrypt(plainData: testDecryptedData, keyID: shortID) + + XCTAssertEqual(mockPGP.containsPublicKeyCalls, [shortID]) + XCTAssertEqual(mockPGP.encryptCalls[0].keyID, shortID) + } + + /// Encrypt with short ID: when containsPublicKey doesn't match and single key, + /// falls back to long fingerprint. + func testEncryptWithKeyID_shortIDNotRecognized_singleKey_resolvesToLongFingerprint() throws { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.publicKeyIDs = [] + mockPGP.keyIDs = [longFingerprint] + + _ = try agent.encrypt(plainData: testDecryptedData, keyID: shortID) + + XCTAssertEqual(mockPGP.containsPublicKeyCalls, [shortID]) + XCTAssertEqual(mockPGP.encryptCalls[0].keyID, longFingerprint) + } + + // MARK: - Encrypt passthrough tests (for completeness of mock interaction) + + /// encrypt(plainData:keyID:) calls containsPublicKey and passes data through. + func testEncryptWithKeyID_keyFound_callsInterface() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.publicKeyIDs = [longFingerprint] + + let result = try agent.encrypt(plainData: testDecryptedData, keyID: longFingerprint) + + XCTAssertEqual(result, mockPGP.encryptResult) + XCTAssertEqual(mockPGP.encryptCalls.count, 1) + XCTAssertEqual(mockPGP.encryptCalls[0].keyID, longFingerprint) + XCTAssertEqual(mockPGP.encryptCalls[0].plainData, testDecryptedData) + } + + /// encrypt with unknown key and single available key falls back. + func testEncryptWithKeyID_keyNotFound_singleKey_fallsBack() throws { + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.publicKeyIDs = [] + mockPGP.keyIDs = [longFingerprint] + + let result = try agent.encrypt(plainData: testDecryptedData, keyID: "e9444483") + + XCTAssertEqual(result, mockPGP.encryptResult) + XCTAssertEqual(mockPGP.encryptCalls[0].keyID, longFingerprint) + } + + /// encrypt with unknown key and multiple keys throws. + func testEncryptWithKeyID_keyNotFound_multipleKeys_throws() { + mockPGP.publicKeyIDs = [] + mockPGP.keyIDs = ["4712286271220db299883ea7062e678da1024dae", "787eae1a5fa3e749aa34cc6aa0645ebed862027e"] + + XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyID: "a1024dae")) { error in + XCTAssertEqual(error as? AppError, AppError.pgpPublicKeyNotFound(keyID: "a1024dae")) + } + XCTAssertEqual(mockPGP.encryptCalls.count, 0) + } + + /// encrypt(plainData:) without keyID passes nil to interface. + func testEncryptNoKeyID_passesNilToInterface() throws { + let result = try agent.encrypt(plainData: testDecryptedData) + + XCTAssertEqual(result, mockPGP.encryptResult) + XCTAssertEqual(mockPGP.encryptCalls.count, 1) + XCTAssertNil(mockPGP.encryptCalls[0].keyID) + } + + /// encrypt propagates errors from interface. + func testEncrypt_interfaceThrows_propagatesError() { + let shortID = "a1024dae" + let longFingerprint = "4712286271220db299883ea7062e678da1024dae" + mockPGP.publicKeyIDs = [longFingerprint] + mockPGP.encryptError = AppError.encryption + + XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyID: shortID)) { error in + XCTAssertEqual(error as? AppError, AppError.encryption) + } + } +} diff --git a/passKitTests/Mocks/MockPGPInterface.swift b/passKitTests/Mocks/MockPGPInterface.swift new file mode 100644 index 0000000..8ea0b53 --- /dev/null +++ b/passKitTests/Mocks/MockPGPInterface.swift @@ -0,0 +1,70 @@ +// +// MockPGPInterface.swift +// passKitTests +// + +import Foundation +@testable import passKit + +class MockPGPInterface: PGPInterface { + // MARK: - Configuration + + var keyIDs: [String] = [] + var shortKeyIDs: [String] = [] + var publicKeyIDs: Set = [] + var privateKeyIDs: Set = [] + + var decryptResult: Data? + var decryptError: Error? + var encryptResult = Data() + var encryptError: Error? + + // MARK: - Call tracking + + struct DecryptCall { + let encryptedData: Data + let keyID: String? + let passphrase: String + } + + struct EncryptCall { + let plainData: Data + let keyID: String? + } + + var decryptCalls: [DecryptCall] = [] + var encryptCalls: [EncryptCall] = [] + var containsPublicKeyCalls: [String] = [] + var containsPrivateKeyCalls: [String] = [] + + // MARK: - PGPInterface + + func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? { + decryptCalls.append(DecryptCall(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase)) + if let error = decryptError { + throw error + } + return decryptResult + } + + func encrypt(plainData: Data, keyID: String?) throws -> Data { + encryptCalls.append(EncryptCall(plainData: plainData, keyID: keyID)) + 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()) } + } + + var keyID: [String] { keyIDs } + var shortKeyID: [String] { shortKeyIDs } +}