2026-03-11 11:36:36 +01:00
|
|
|
//
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 16:10:08 +01:00
|
|
|
func testDecryptWithKeyID_keyNotFound_throws() {
|
2026-03-11 11:36:36 +01:00
|
|
|
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]
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
2026-03-11 11:36:36 +01:00
|
|
|
keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("requested-passphrase"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["stored-passphrase"])
|
2026-03-11 11:36:36 +01:00
|
|
|
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]
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.selectedKeyForPassphrase = shortID
|
2026-03-11 11:36:36 +01:00
|
|
|
// 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"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["my-passphrase"])
|
2026-03-11 11:36:36 +01:00
|
|
|
XCTAssertEqual(passphraseRequests, [shortID])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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()
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.resolvedPassphrases.removeAll()
|
|
|
|
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
2026-03-11 11:36:36 +01:00
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh-passphrase"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh-passphrase"])
|
2026-03-11 11:36:36 +01:00
|
|
|
XCTAssertEqual(passphraseRequests, [longFingerprint], "After failure, passphrase should always be requested")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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()
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.resolvedPassphrases.removeAll()
|
|
|
|
|
mockPGP.selectedKeyForPassphrase = shortID
|
2026-03-11 11:36:36 +01:00
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("ignored-passphrase"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["pass1"])
|
2026-03-11 11:36:36 +01:00
|
|
|
XCTAssertEqual(passphraseRequests, [])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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()
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.resolvedPassphrases.removeAll()
|
|
|
|
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
2026-03-11 11:36:36 +01:00
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
|
|
|
|
|
XCTAssertEqual(passphraseRequests, [longFingerprint], "After nil return, passphrase should always be requested")
|
2026-03-11 11:36:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
2026-03-11 11:36:36 +01:00
|
|
|
|
|
|
|
|
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
|
|
|
|
|
XCTAssertEqual(error as? AppError, AppError.wrongPassphrase)
|
|
|
|
|
}
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(passphraseRequests, [longFingerprint])
|
2026-03-11 11:36:36 +01:00
|
|
|
|
|
|
|
|
// 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()
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.resolvedPassphrases.removeAll()
|
|
|
|
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
2026-03-11 11:36:36 +01:00
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
|
|
|
|
|
XCTAssertEqual(passphraseRequests, [longFingerprint], "After throw, passphrase should always be requested (latestDecryptStatus=false)")
|
2026-03-11 11:36:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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()
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.resolvedPassphrases.removeAll()
|
|
|
|
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
2026-03-11 11:36:36 +01:00
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("should-not-use"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["good"])
|
2026-03-11 11:36:36 +01:00
|
|
|
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 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
/// After failure, the no-keyID overload always requests passphrase.
|
2026-03-11 11:36:36 +01:00
|
|
|
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()
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.resolvedPassphrases.removeAll()
|
|
|
|
|
mockPGP.selectedKeyForPassphrase = ""
|
2026-03-11 11:36:36 +01:00
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("fresh"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
|
2026-03-11 11:36:36 +01:00
|
|
|
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()
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.resolvedPassphrases.removeAll()
|
|
|
|
|
mockPGP.selectedKeyForPassphrase = ""
|
2026-03-11 11:36:36 +01:00
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("nope"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached"])
|
2026-03-11 11:36:36 +01:00
|
|
|
XCTAssertEqual(passphraseRequests, [])
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
/// The no-keyID overload doesn't check containsPrivateKey.
|
2026-03-11 11:36:36 +01:00
|
|
|
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
|
|
|
|
|
|
2026-03-11 16:10:08 +01:00
|
|
|
/// When pgpPrivateKeyNotFound is thrown, latestDecryptStatus is NOT changed because the error occurs BEFORE the status update.
|
|
|
|
|
func testDecryptWithKeyID_keyNotFound_doesNotChangeDecryptStatus() throws {
|
2026-03-11 11:36:36 +01:00
|
|
|
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))
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
2026-03-11 11:36:36 +01:00
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh"))
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(passphraseRequests, [], "After pgpPrivateKeyNotFound, latestDecryptStatus should be unchanged (still true)")
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached-pass"])
|
2026-03-11 11:36:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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()
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.resolvedPassphrases.removeAll()
|
|
|
|
|
mockPGP.selectedKeyForPassphrase = ""
|
2026-03-11 11:36:36 +01:00
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("fresh"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
|
2026-03-11 11:36:36 +01:00
|
|
|
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()
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.resolvedPassphrases.removeAll()
|
|
|
|
|
mockPGP.selectedKeyForPassphrase = shortID
|
2026-03-11 11:36:36 +01:00
|
|
|
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
|
|
|
|
|
passphraseRequests.removeAll()
|
|
|
|
|
|
|
|
|
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
|
2026-03-11 11:36:36 +01:00
|
|
|
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
|
2026-03-10 17:14:11 +01:00
|
|
|
/// is forwarded to pgpInterface.decrypt.
|
2026-03-11 11:36:36 +01:00
|
|
|
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]
|
2026-03-10 17:14:11 +01:00
|
|
|
mockPGP.selectedKeyForPassphrase = shortID
|
2026-03-11 11:36:36 +01:00
|
|
|
|
|
|
|
|
// 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"))
|
|
|
|
|
|
2026-03-10 17:14:11 +01:00
|
|
|
// Backend requests passphrase with short ID — keystore lookup misses, falls through to request.
|
|
|
|
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["from-request"])
|
2026-03-11 11:36:36 +01:00
|
|
|
XCTAssertEqual(passphraseRequests, [shortID])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Encrypt passthrough tests (for completeness of mock interaction)
|
|
|
|
|
|
2026-03-11 09:01:12 +01:00
|
|
|
func testEncryptWithKeyIDs_passesThrough() throws {
|
2026-03-11 11:36:36 +01:00
|
|
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
|
|
|
|
mockPGP.publicKeyIDs = [longFingerprint]
|
|
|
|
|
|
2026-03-11 09:01:12 +01:00
|
|
|
let result = try agent.encrypt(plainData: testDecryptedData, keyIDs: [longFingerprint])
|
2026-03-11 11:36:36 +01:00
|
|
|
|
|
|
|
|
XCTAssertEqual(result, mockPGP.encryptResult)
|
2026-03-11 00:21:30 +01:00
|
|
|
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 1)
|
|
|
|
|
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].keyIDs, [longFingerprint])
|
|
|
|
|
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].plainData, testDecryptedData)
|
2026-03-11 11:36:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// encrypt propagates errors from interface.
|
2026-03-11 09:01:12 +01:00
|
|
|
func testEncryptWithKeyIDs_interfaceThrows_propagatesError() {
|
2026-03-11 11:36:36 +01:00
|
|
|
let shortID = "a1024dae"
|
|
|
|
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
|
|
|
|
mockPGP.publicKeyIDs = [longFingerprint]
|
|
|
|
|
mockPGP.encryptError = AppError.encryption
|
|
|
|
|
|
2026-03-11 09:01:12 +01:00
|
|
|
XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyIDs: [shortID])) { error in
|
2026-03-11 11:36:36 +01:00
|
|
|
XCTAssertEqual(error as? AppError, AppError.encryption)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-11 00:21:30 +01:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 09:01:12 +01:00
|
|
|
/// encryptWithAllKeys throws keyImport when checkAndInit triggers initKeys without PGP keys.
|
2026-03-11 00:21:30 +01:00
|
|
|
func testEncryptWithAllKeys_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws {
|
|
|
|
|
keychain.removeContent(for: Globals.pgpKeyPassphrase)
|
|
|
|
|
|
|
|
|
|
XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in
|
|
|
|
|
XCTAssertEqual(error as? AppError, AppError.keyImport)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-11 11:36:36 +01:00
|
|
|
}
|