decryption: always request key passphrase based on key ID
This commit is contained in:
parent
d136175d93
commit
2ae751044c
6 changed files with 85 additions and 130 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue