diff --git a/passKit/Crypto/GopenPGPInterface.swift b/passKit/Crypto/GopenPGPInterface.swift index 34f0622..9ec6a77 100644 --- a/passKit/Crypto/GopenPGPInterface.swift +++ b/passKit/Crypto/GopenPGPInterface.swift @@ -70,7 +70,7 @@ struct GopenPGPInterface: PGPInterface { privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) } } - func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? { + func decrypt(encryptedData: Data, keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { let key: CryptoKey? = { if let keyID { return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value @@ -87,6 +87,7 @@ struct GopenPGPInterface: PGPInterface { try privateKey.isLocked(&isLocked) var unlockedKey: CryptoKey! if isLocked.boolValue { + let passphrase = passPhraseForKey(privateKey.getFingerprint()) unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8)) } else { unlockedKey = privateKey diff --git a/passKit/Crypto/ObjectivePGPInterface.swift b/passKit/Crypto/ObjectivePGPInterface.swift index 768d785..f94de71 100644 --- a/passKit/Crypto/ObjectivePGPInterface.swift +++ b/passKit/Crypto/ObjectivePGPInterface.swift @@ -24,8 +24,13 @@ struct ObjectivePGPInterface: PGPInterface { } } - func decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? { - try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase } + func decrypt(encryptedData: Data, keyID _: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { + try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { selectedKey in + guard let selectedKey else { + return nil + } + return passPhraseForKey(selectedKey.keyID.longIdentifier) + } } func encrypt(plainData: Data, keyID _: String?) throws -> Data { diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 67088ab..d668053 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -74,14 +74,14 @@ public class PGPAgent { latestDecryptStatus = false // Get the PGP key passphrase. - var passphrase = "" - if previousDecryptStatus == false { - passphrase = requestPGPKeyPassphrase(keyID) - } else { - passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: keyID)) ?? requestPGPKeyPassphrase(keyID) + let providePassPhraseForKey = { (selectedKeyID: String) -> String in + if previousDecryptStatus == false { + return requestPGPKeyPassphrase(selectedKeyID) + } + return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) } // Decrypt. - guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else { + guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passPhraseForKey: providePassPhraseForKey) else { return nil } // The decryption step has succeed. @@ -105,21 +105,21 @@ public class PGPAgent { return try pgpInterface.encrypt(plainData: plainData, keyID: keyID) } - public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: (String) -> String) throws -> Data? { + public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: @escaping (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("") + let providePassPhraseForKey = { (selectedKeyID: String) -> String in + if previousDecryptStatus == false { + return requestPGPKeyPassphrase(selectedKeyID) + } + return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) } // Decrypt. - guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passphrase: passphrase) else { + guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passPhraseForKey: providePassPhraseForKey) else { return nil } // The decryption step has succeed. diff --git a/passKit/Crypto/PGPInterface.swift b/passKit/Crypto/PGPInterface.swift index b77831d..cb0d107 100644 --- a/passKit/Crypto/PGPInterface.swift +++ b/passKit/Crypto/PGPInterface.swift @@ -7,7 +7,7 @@ // protocol PGPInterface { - func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? + func decrypt(encryptedData: Data, keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? func encrypt(plainData: Data, keyID: String?) throws -> Data diff --git a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift index 85ed721..3e4b891 100644 --- a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift +++ b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift @@ -67,7 +67,6 @@ final class PGPAgentLowLevelTests: XCTestCase { 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. @@ -81,7 +80,6 @@ final class PGPAgentLowLevelTests: XCTestCase { 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. @@ -94,7 +92,6 @@ final class PGPAgentLowLevelTests: XCTestCase { } // 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. @@ -106,7 +103,6 @@ final class PGPAgentLowLevelTests: XCTestCase { _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass")) XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID]) - XCTAssertEqual(passphraseRequests, [shortID]) } // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Passphrase Resolution @@ -116,12 +112,13 @@ final class PGPAgentLowLevelTests: XCTestCase { 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") - XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "stored-passphrase") } /// On first decrypt, if keystore doesn't have the passphrase, requestPGPKeyPassphrase is called. @@ -130,27 +127,15 @@ final class PGPAgentLowLevelTests: XCTestCase { 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]) - 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, @@ -167,12 +152,14 @@ final class PGPAgentLowLevelTests: XCTestCase { // 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") - XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh-passphrase") } /// After a successful decrypt, the next call uses keystore first (latestDecryptStatus=true). @@ -188,25 +175,14 @@ final class PGPAgentLowLevelTests: XCTestCase { 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, []) - 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 @@ -220,7 +196,6 @@ final class PGPAgentLowLevelTests: XCTestCase { 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 @@ -238,12 +213,14 @@ final class PGPAgentLowLevelTests: XCTestCase { 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(passphraseRequests, [shortID], "After nil return, passphrase should always be requested") - XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "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. @@ -252,11 +229,12 @@ final class PGPAgentLowLevelTests: XCTestCase { 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, [shortID]) + XCTAssertEqual(passphraseRequests, [longFingerprint]) // Verify latestDecryptStatus stayed false: next call should always request passphrase, // even though the keystore has one cached. @@ -264,12 +242,14 @@ final class PGPAgentLowLevelTests: XCTestCase { 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(passphraseRequests, [shortID], "After throw, passphrase should always be requested (latestDecryptStatus=false)") - XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh") + XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"]) + XCTAssertEqual(passphraseRequests, [longFingerprint], "After throw, passphrase should always be requested (latestDecryptStatus=false)") } /// After successful decrypt, latestDecryptStatus is true. @@ -290,12 +270,14 @@ final class PGPAgentLowLevelTests: XCTestCase { // 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") - XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "good") } // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - checkAndInit behavior @@ -323,24 +305,7 @@ final class PGPAgentLowLevelTests: XCTestCase { 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). + /// After failure, the no-keyID overload always requests passphrase. func testDecryptNoKeyID_afterFailure_alwaysRequestsPassphrase() throws { // Force a failure. mockPGP.decryptError = AppError.wrongPassphrase @@ -350,12 +315,14 @@ final class PGPAgentLowLevelTests: XCTestCase { keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) mockPGP.decryptError = nil mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = "" passphraseRequests.removeAll() _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("fresh")) + XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"]) XCTAssertEqual(passphraseRequests, [""]) - XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "fresh") } /// The no-keyID overload returns nil when interface returns nil. @@ -365,8 +332,6 @@ final class PGPAgentLowLevelTests: XCTestCase { 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. @@ -376,7 +341,6 @@ final class PGPAgentLowLevelTests: XCTestCase { 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, @@ -395,20 +359,21 @@ final class PGPAgentLowLevelTests: XCTestCase { // Third call: should try keystore. keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = "" passphraseRequests.removeAll() _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("nope")) + XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached"]) XCTAssertEqual(passphraseRequests, []) - XCTAssertEqual(mockPGP.decryptCalls[0].passphrase, "cached") } - /// The no-keyID overload doesn't check containsPrivateKey and doesn't resolve key. + /// 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) - XCTAssertEqual(passphraseRequests, [""]) } // MARK: - Key resolution error vs decrypt status ordering @@ -427,12 +392,13 @@ final class PGPAgentLowLevelTests: XCTestCase { // 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.decryptCalls[0].passphrase, "cached-pass") + XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached-pass"]) } /// After failure + key fallback: passphrase is always requested using the RESOLVED (fallback) keyID. @@ -451,12 +417,15 @@ final class PGPAgentLowLevelTests: XCTestCase { mockPGP.privateKeyIDs = [] mockPGP.keyIDs = [longFingerprint2] mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = longFingerprint2 passphraseRequests.removeAll() _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: "e9444483", requestPGPKeyPassphrase: passphraseCallback("pass")) - XCTAssertEqual(passphraseRequests, [longFingerprint2]) XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint2) + XCTAssertEqual(mockPGP.resolvedPassphrases, ["pass"]) + XCTAssertEqual(passphraseRequests, [longFingerprint2]) } // MARK: - Cross-overload latestDecryptStatus interaction @@ -475,10 +444,13 @@ final class PGPAgentLowLevelTests: XCTestCase { keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) mockPGP.decryptError = nil mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = "" passphraseRequests.removeAll() _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("fresh")) + XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"]) XCTAssertEqual(passphraseRequests, [""], "Failure in keyID overload should affect no-keyID overload") } @@ -495,19 +467,21 @@ final class PGPAgentLowLevelTests: XCTestCase { // Next call via keyID overload should always request. mockPGP.decryptError = nil mockPGP.decryptCalls.removeAll() + mockPGP.resolvedPassphrases.removeAll() + mockPGP.selectedKeyForPassphrase = shortID keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)) passphraseRequests.removeAll() _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh")) + XCTAssertEqual(mockPGP.resolvedPassphrases, ["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. + /// is forwarded to pgpInterface.decrypt. func testDecryptWithKeyID_shortIDRecognized_shortIDFlowsThrough() throws { let shortID = "a1024dae" let longFingerprint = "4712286271220db299883ea7062e678da1024dae" @@ -516,14 +490,12 @@ final class PGPAgentLowLevelTests: XCTestCase { _ = 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. + /// the long fingerprint from keyID[0] is forwarded instead. func testDecryptWithKeyID_shortIDNotRecognized_singleKey_resolvesToLongFingerprint() throws { let shortID = "a1024dae" let longFingerprint = "4712286271220db299883ea7062e678da1024dae" @@ -534,43 +506,24 @@ final class PGPAgentLowLevelTests: XCTestCase { 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] + 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")) - // Keystore lookup uses the short ID, which doesn't match the long key → falls through to request. + // Backend requests passphrase with short ID — keystore lookup misses, falls through to request. + XCTAssertEqual(mockPGP.resolvedPassphrases, ["from-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. @@ -586,20 +539,6 @@ final class PGPAgentLowLevelTests: XCTestCase { 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. @@ -617,13 +556,15 @@ final class PGPAgentLowLevelTests: XCTestCase { /// encrypt with unknown key and single available key falls back. func testEncryptWithKeyID_keyNotFound_singleKey_fallsBack() throws { + let shortID = "e9444483" let longFingerprint = "4712286271220db299883ea7062e678da1024dae" mockPGP.publicKeyIDs = [] mockPGP.keyIDs = [longFingerprint] - let result = try agent.encrypt(plainData: testDecryptedData, keyID: "e9444483") + let result = try agent.encrypt(plainData: testDecryptedData, keyID: shortID) XCTAssertEqual(result, mockPGP.encryptResult) + XCTAssertEqual(mockPGP.containsPublicKeyCalls, [shortID]) XCTAssertEqual(mockPGP.encryptCalls[0].keyID, longFingerprint) } diff --git a/passKitTests/Mocks/MockPGPInterface.swift b/passKitTests/Mocks/MockPGPInterface.swift index 8ea0b53..95b55e0 100644 --- a/passKitTests/Mocks/MockPGPInterface.swift +++ b/passKitTests/Mocks/MockPGPInterface.swift @@ -19,12 +19,16 @@ class MockPGPInterface: PGPInterface { 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 passphrase: String + let passPhraseForKey: (String) -> String } struct EncryptCall { @@ -33,14 +37,18 @@ class MockPGPInterface: PGPInterface { } var decryptCalls: [DecryptCall] = [] + var resolvedPassphrases: [String] = [] 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)) + func decrypt(encryptedData: Data, 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 }