diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index be7f52d..d8e40f8 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -53,36 +53,13 @@ public class PGPAgent { return pgpInterface?.getShortKeyIDs(type: type).sorted() ?? [] } - 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. - 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, keyIDHint: nil, passPhraseForKey: providePassPhraseForKey) else { - return nil - } - // The decryption step has succeed. - latestDecryptStatus = true - return result - } - - public func decrypt(encryptedData: Data, keyID: String, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { - // Init keys. + public func decrypt(encryptedData: Data, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { try checkAndInit() guard let pgpInterface else { throw AppError.decryption } - if !pgpInterface.containsPrivateKey(with: keyID) { + if let keyID, !pgpInterface.containsPrivateKey(with: keyID) { throw AppError.pgpPrivateKeyNotFound(keyID: keyID) } diff --git a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift index 8b03310..a1e6e99 100644 --- a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift +++ b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift @@ -125,7 +125,7 @@ final class PGPAgentLowLevelTests: XCTestCase { /// After a failed decrypt (latestDecryptStatus=false), requestPGPKeyPassphrase is ALWAYS called, /// even if the keystore has a cached passphrase. - func testDecryptWithKeyID_afterFailure_alwaysRequestsPassphrase() throws { + func testDecrypt_afterFailure_alwaysRequestsPassphrase() throws { let longFingerprint = "4712286271220db299883ea7062e678da1024dae" mockPGP.privateKeyIDs = [longFingerprint] keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) @@ -148,7 +148,7 @@ final class PGPAgentLowLevelTests: XCTestCase { } /// After a successful decrypt, the next call uses keystore first (latestDecryptStatus=true). - func testDecryptWithKeyID_afterSuccess_usesKeystoreFirst() throws { + func testDecrypt_afterSuccess_usesKeystoreFirst() throws { let shortID = "a1024dae" let longFingerprint = "4712286271220db299883ea7062e678da1024dae" mockPGP.privateKeyIDs = [longFingerprint] @@ -173,7 +173,7 @@ final class PGPAgentLowLevelTests: XCTestCase { // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Return Values & Error Propagation /// When pgpInterface.decrypt returns nil, agent.decrypt returns nil. - func testDecryptWithKeyID_interfaceReturnsNil_returnsNil() throws { + func testDecrypt_interfaceReturnsNil_returnsNil() throws { let longFingerprint = "4712286271220db299883ea7062e678da1024dae" mockPGP.privateKeyIDs = [longFingerprint] mockPGP.decryptResult = nil @@ -185,7 +185,7 @@ final class PGPAgentLowLevelTests: XCTestCase { /// When pgpInterface.decrypt returns nil, latestDecryptStatus stays false /// (next call will always request passphrase). - func testDecryptWithKeyID_interfaceReturnsNil_statusStaysFalse() throws { + func testDecrypt_interfaceReturnsNil_statusStaysFalse() throws { let shortID = "d862027e" let longFingerprint = "787eae1a5fa3e749aa34cc6aa0645ebed862027e" mockPGP.privateKeyIDs = [longFingerprint] @@ -209,7 +209,7 @@ final class PGPAgentLowLevelTests: XCTestCase { } /// When pgpInterface.decrypt throws, the error propagates and latestDecryptStatus stays false. - func testDecryptWithKeyID_interfaceThrows_propagatesError() throws { + func testDecrypt_interfaceThrows_propagatesError() throws { let shortID = "a1024dae" let longFingerprint = "4712286271220db299883ea7062e678da1024dae" mockPGP.privateKeyIDs = [longFingerprint] @@ -238,7 +238,7 @@ final class PGPAgentLowLevelTests: XCTestCase { } /// After successful decrypt, latestDecryptStatus is true. - func testDecryptWithKeyID_success_setsStatusTrue() throws { + func testDecrypt_success_setsStatusTrue() throws { let longFingerprint = "4712286271220db299883ea7062e678da1024dae" mockPGP.privateKeyIDs = [longFingerprint] @@ -269,7 +269,7 @@ final class PGPAgentLowLevelTests: XCTestCase { /// 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 { + 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. @@ -279,7 +279,7 @@ final class PGPAgentLowLevelTests: XCTestCase { XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when checkAndInit fails") } - // MARK: - decrypt(encryptedData:requestPGPKeyPassphrase:) - No KeyID Overload + // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - nil keyID /// The no-keyID overload passes nil as keyID to pgpInterface.decrypt func testDecryptNoKeyID_passesNilKeyIDToInterface() throws { @@ -290,70 +290,6 @@ final class PGPAgentLowLevelTests: XCTestCase { XCTAssertNil(mockPGP.decryptCalls[0].keyID) } - /// After failure, the no-keyID overload always requests passphrase. - 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() - mockPGP.resolvedPassphrases.removeAll() - mockPGP.selectedKeyForPassphrase = "" - passphraseRequests.removeAll() - - _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("fresh")) - - XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"]) - XCTAssertEqual(passphraseRequests, [""]) - } - - /// 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) - } - - /// 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) - } - } - - /// 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() - mockPGP.resolvedPassphrases.removeAll() - mockPGP.selectedKeyForPassphrase = "" - passphraseRequests.removeAll() - - _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("nope")) - - XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached"]) - XCTAssertEqual(passphraseRequests, []) - } - /// The no-keyID overload doesn't check containsPrivateKey. func testDecryptNoKeyID_doesNotCheckPrivateKey() throws { _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass")) @@ -384,56 +320,6 @@ final class PGPAgentLowLevelTests: XCTestCase { XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached-pass"]) } - // 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() - 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") - } - - /// 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() - 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