decryption: always request key passphrase based on key ID

This commit is contained in:
Lysann Tranvouez 2026-03-10 17:14:11 +01:00
parent d136175d93
commit 2ae751044c
6 changed files with 85 additions and 130 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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.

View file

@ -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

View file

@ -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)
}

View file

@ -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
}