Compare commits

...

25 commits

Author SHA1 Message Date
Lysann Tranvouez
b1314dd8c3 Merge branch 'feature/multi-key-support' into feature/multi-repo 2026-03-12 22:52:10 +01:00
Lysann Tranvouez
4d564f570b plan updates 2026-03-12 22:51:28 +01:00
Lysann Tranvouez
cd6dd43dae do not search too far for .gpg-id + lots of tests for that 2026-03-12 22:47:39 +01:00
Lysann Tranvouez
5c416bfb21 test .gpg-id support
mostly using mocks
2026-03-12 09:33:26 +01:00
Lysann Tranvouez
38649f96fe mocking prep: PasswordStoreTest has its own KeyStore 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
e3de11f71c PasswordStore: allow to pass in a specific PGPAgent implementation (for testing) 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
77f85ccdd1 support reading several key IDs in .gpg-id files 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
566b7253f5 remove overload, caller can take care of it in 2 step process 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
e32402b807 streamline gpg-id handing
* decrypt should not care about it at all
* PasswordStore.decrypt always forwards the passed in keyID, even when
  gpg-id handling is disabled
* PasswordStore.encrypt: streamlined, but should be same behavior
2026-03-12 09:32:04 +01:00
Lysann Tranvouez
054f333bac deduplicate decrypt logic in PGPAgent 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
b103337083 move function to be closer to related one 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
5a92b6fda7 clarify public vs private keys + make prvate key IDs available 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
4e19d9e714 remove irrelevant properties from mocks 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
9e3e3d1134 remove fallback behavior
this logic should not be relevant anywhere
2026-03-12 09:32:04 +01:00
Lysann Tranvouez
e69e590e36 replace calls to deprecated function
changes PasswordStore.encrypt behavior when .gpg-id support is off
(default):
  old:
    * ignores passed in keyID
    * encrypt with first public key in keychain (gopenPGP), or entire
      keychain (ObjectivePGP)
  new:
    * honor passed in keyID
    * encrypt with all keys in keychain
2026-03-12 09:32:04 +01:00
Lysann Tranvouez
09b0b150ce PGPAgent can encrypt with multiple keys 2026-03-11 14:12:24 +01:00
Lysann Tranvouez
b7873e6d72 move functions around 2026-03-11 14:11:37 +01:00
Lysann Tranvouez
e728f26a20 move and rename test functions 2026-03-11 14:11:29 +01:00
Lysann Tranvouez
84eaf4ad7d PGPInterface can encrypt with multiple keys, PGPAgent can encrypt with all keys 2026-03-11 14:11:07 +01:00
Lysann Tranvouez
8d4f3af475 decryption: GopenPGPInterface tries to identify decryption key from message metadata
So the system can have multiple private keys, and the caller doesn't
need to specify a specific one regardless.

Ideally: If there are several matches we could also take into account
which keys have already been unlocked (or passthrases saved in
keychain). Right now it only grabs the first match.
2026-03-11 13:41:59 +01:00
Lysann Tranvouez
f1cb5d27be reference new version of gopenpgp with a new helper (HelperPassGetHexSubkeyIDsJSON) 2026-03-11 13:39:55 +01:00
Lysann Tranvouez
2ae751044c decryption: always request key passphrase based on key ID 2026-03-11 13:32:21 +01:00
Lysann Tranvouez
d136175d93 add detailed API tests checking how calls to PGPAgent propagate to the underlying interface
this is refactoring support, so that we can notice changes in how the
underlying APIs are called, and make changes intentionally when needed,
instead of accidentally.
2026-03-11 11:36:36 +01:00
Lysann Tranvouez
76db529764 add long fingerprints for test keys 2026-03-11 10:34:54 +01:00
Lysann Tranvouez
c4f81c16eb move variables into smaller scope 2026-03-10 16:51:40 +01:00
24 changed files with 1172 additions and 227 deletions

View file

@ -116,6 +116,8 @@
5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; }; 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; };
8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; }; 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; };
8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */; };
8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */; };
8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; };
9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; };
9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; };
@ -427,6 +429,8 @@
5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; };
8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = "<group>"; }; 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = "<group>"; };
8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = "<group>"; }; 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = "<group>"; };
8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPGPInterface.swift; sourceTree = "<group>"; };
8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PGPAgentLowLevelTests.swift; sourceTree = "<group>"; };
8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = "<group>"; }; 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = "<group>"; };
9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = "<group>"; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = "<group>"; };
9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = "<group>"; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = "<group>"; };
@ -776,6 +780,22 @@
path = Controllers; path = Controllers;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
8AB3AD8E2F615FD70081DE16 /* Mocks */ = {
isa = PBXGroup;
children = (
8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */,
);
path = Mocks;
sourceTree = "<group>";
};
8AB3AD8F2F61600B0081DE16 /* LowLevel */ = {
isa = PBXGroup;
children = (
8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */,
);
path = LowLevel;
sourceTree = "<group>";
};
9A58664F25AADB66006719C2 /* Services */ = { 9A58664F25AADB66006719C2 /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -901,6 +921,8 @@
30697C5521F63F870064FCAC /* Extensions */, 30697C5521F63F870064FCAC /* Extensions */,
8AD8EBF22F5E268D007475AB /* Fixtures */, 8AD8EBF22F5E268D007475AB /* Fixtures */,
301F6464216164670071A4CE /* Helpers */, 301F6464216164670071A4CE /* Helpers */,
8AB3AD8F2F61600B0081DE16 /* LowLevel */,
8AB3AD8E2F615FD70081DE16 /* Mocks */,
30C015A7214ED378005BB6DF /* Models */, 30C015A7214ED378005BB6DF /* Models */,
30C015A6214ED32A005BB6DF /* Parser */, 30C015A6214ED32A005BB6DF /* Parser */,
30B4C7BB24085A3C008B86F7 /* Passwords */, 30B4C7BB24085A3C008B86F7 /* Passwords */,
@ -1644,6 +1666,8 @@
8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */, 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */,
301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */,
9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */,
8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */,
8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */,
30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */,
DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */, DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */,
A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */,

View file

@ -128,7 +128,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni
// alert: cancel or try again // alert: cancel or try again
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: self)) alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
let selectKey = UIAlertAction.selectKey(controller: self) { action in let selectKey = UIAlertAction.selectKey(type: .PRIVATE, controller: self) { action in
self.decryptThenShowPasswordLocalKey(keyID: action.title) self.decryptThenShowPasswordLocalKey(keyID: action.title)
} }
alert.addAction(selectKey) alert.addAction(selectKey)
@ -223,7 +223,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni
SVProgressHUD.dismiss() SVProgressHUD.dismiss()
let alert = UIAlertController(title: "Cannot Edit Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "Cannot Edit Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: self)) alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
let selectKey = UIAlertAction.selectKey(controller: self) { action in let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: self) { action in
self.saveEditPassword(password: password, keyID: action.title) self.saveEditPassword(password: password, keyID: action.title)
} }
alert.addAction(selectKey) alert.addAction(selectKey)

View file

@ -89,10 +89,12 @@ class SettingsTableViewController: UITableViewController, UITabBarControllerDele
private func setPGPKeyTableViewCellDetailText() { private func setPGPKeyTableViewCellDetailText() {
var label = "NotSet".localize() var label = "NotSet".localize()
let keyID = (try? PGPAgent.shared.getShortKeyID()) ?? [] var keyIDs = Set<String>((try? PGPAgent.shared.getShortKeyIDs(type: .PRIVATE)) ?? [])
if keyID.count == 1 { keyIDs.formUnion((try? PGPAgent.shared.getShortKeyIDs(type: .PUBLIC)) ?? [])
label = keyID.first ?? ""
} else if keyID.count > 1 { if keyIDs.count == 1 {
label = keyIDs.first ?? ""
} else if keyIDs.count > 1 {
label = "Multiple" label = "Multiple"
} }
if Defaults.isYubiKeyEnabled { if Defaults.isYubiKeyEnabled {

View file

@ -30,8 +30,11 @@ func decryptPassword(
} }
DispatchQueue.global(qos: .userInteractive).async { DispatchQueue.global(qos: .userInteractive).async {
do { do {
guard let passwordEntity = PasswordStore.shared.fetchPasswordEntity(with: passwordPath) else {
throw AppError.decryption
}
let requestPGPKeyPassphrase = Utils.createRequestPGPKeyPassphraseHandler(controller: controller) let requestPGPKeyPassphrase = Utils.createRequestPGPKeyPassphraseHandler(controller: controller)
let decryptedPassword = try PasswordStore.shared.decrypt(path: passwordPath, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) let decryptedPassword = try PasswordStore.shared.decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
DispatchQueue.main.async { DispatchQueue.main.async {
completion(decryptedPassword) completion(decryptedPassword)
@ -40,7 +43,7 @@ func decryptPassword(
DispatchQueue.main.async { DispatchQueue.main.async {
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: controller)) alert.addAction(UIAlertAction.cancelAndPopView(controller: controller))
let selectKey = UIAlertAction.selectKey(controller: controller) { action in let selectKey = UIAlertAction.selectKey(type: PGPKey.PRIVATE, controller: controller) { action in
decryptPassword(in: controller, with: passwordPath, using: action.title, completion: completion) decryptPassword(in: controller, with: passwordPath, using: action.title, completion: completion)
} }
alert.addAction(selectKey) alert.addAction(selectKey)

View file

@ -19,7 +19,7 @@ func encryptPassword(in controller: UIViewController, with password: Password, k
DispatchQueue.main.async { DispatchQueue.main.async {
let alert = UIAlertController(title: "Cannot Encrypt Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "Cannot Encrypt Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: controller)) alert.addAction(UIAlertAction.cancelAndPopView(controller: controller))
let selectKey = UIAlertAction.selectKey(controller: controller) { action in let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: controller) { action in
encryptPassword(in: controller, with: password, keyID: action.title, completion: completion) encryptPassword(in: controller, with: password, keyID: action.title, completion: completion)
} }
alert.addAction(selectKey) alert.addAction(selectKey)

View file

@ -16,6 +16,7 @@ struct GopenPGPInterface: PGPInterface {
private var publicKeys: [String: CryptoKey] = [:] private var publicKeys: [String: CryptoKey] = [:]
private var privateKeys: [String: CryptoKey] = [:] private var privateKeys: [String: CryptoKey] = [:]
private var privateSubkeyToKeyIDMapping: [String: String] = [:] // value is the key in privateKeys map
init(publicArmoredKey: String, privateArmoredKey: String) throws { init(publicArmoredKey: String, privateArmoredKey: String) throws {
let pubKeys = extractKeysFromArmored(str: publicArmoredKey) let pubKeys = extractKeysFromArmored(str: publicArmoredKey)
@ -40,7 +41,24 @@ struct GopenPGPInterface: PGPInterface {
} }
throw AppError.keyImport throw AppError.keyImport
} }
privateKeys[cryptoKey.getFingerprint().lowercased()] = cryptoKey
let keyID = cryptoKey.getFingerprint().lowercased()
privateKeys[keyID] = cryptoKey
guard let subkeyIDsJSON = HelperPassGetHexSubkeyIDsJSON(cryptoKey) else {
guard error == nil else {
throw error!
}
throw AppError.keyImport
}
do {
let subkeyIDs = try JSONDecoder().decode([String].self, from: subkeyIDsJSON)
for subkeyID in subkeyIDs {
privateSubkeyToKeyIDMapping[subkeyID] = keyID
}
} catch {
throw AppError.keyImport
}
} }
} }
@ -70,15 +88,13 @@ struct GopenPGPInterface: PGPInterface {
privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) } privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) }
} }
func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? { func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
let key: CryptoKey? = { let message = createPGPMessage(from: encryptedData)
if let keyID { guard let message else {
return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value throw AppError.decryption
} }
return privateKeys.first?.value
}()
guard let privateKey = key else { guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else {
throw AppError.decryption throw AppError.decryption
} }
@ -87,6 +103,7 @@ struct GopenPGPInterface: PGPInterface {
try privateKey.isLocked(&isLocked) try privateKey.isLocked(&isLocked)
var unlockedKey: CryptoKey! var unlockedKey: CryptoKey!
if isLocked.boolValue { if isLocked.boolValue {
let passphrase = passPhraseForKey(privateKey.getFingerprint())
unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8)) unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8))
} else { } else {
unlockedKey = privateKey unlockedKey = privateKey
@ -100,33 +117,43 @@ struct GopenPGPInterface: PGPInterface {
throw AppError.decryption throw AppError.decryption
} }
let message = createPGPMessage(from: encryptedData)
return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data
} catch { } catch {
throw Self.errorMapping[error.localizedDescription, default: error] throw Self.errorMapping[error.localizedDescription, default: error]
} }
} }
func encrypt(plainData: Data, keyID: String?) throws -> Data { func encryptWithAllKeys(plainData: Data) throws -> Data {
let key: CryptoKey? = { let keyIDs = publicKeys.keys.filter { key in privateKeys.keys.contains(key) }
if let keyID { return try encrypt(plainData: plainData, keyIDs: keyIDs)
return publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value }
}
return publicKeys.first?.value
}()
guard let publicKey = key else { func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
let keys: [CryptoKey] = try keyIDs.map { keyID in
guard let key = publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value else {
throw AppError.pgpPublicKeyNotFound(keyID: keyID)
}
return key
}
guard let firstKey = keys.first else {
throw AppError.encryption throw AppError.encryption
} }
let otherKeys = keys.dropFirst()
var error: NSError? var error: NSError?
guard let keyRing = CryptoNewKeyRing(firstKey, &error) else {
guard let keyRing = CryptoNewKeyRing(publicKey, &error) else {
guard error == nil else { guard error == nil else {
throw error! throw error!
} }
throw AppError.encryption throw AppError.encryption
} }
do {
try otherKeys.forEach { key in
try keyRing.add(key)
}
} catch {
throw AppError.encryption
}
let encryptedData = try keyRing.encrypt(CryptoNewPlainMessage(plainData.mutable as Data), privateKey: nil) let encryptedData = try keyRing.encrypt(CryptoNewPlainMessage(plainData.mutable as Data), privateKey: nil)
if Defaults.encryptInArmored { if Defaults.encryptInArmored {
@ -140,12 +167,44 @@ struct GopenPGPInterface: PGPInterface {
return encryptedData.getBinary()! return encryptedData.getBinary()!
} }
var keyID: [String] { func getKeyIDs(type: PGPKey) -> [String] {
publicKeys.keys.map { $0.uppercased() } switch type {
case .PUBLIC:
return publicKeys.keys.map { $0.uppercased() }
case .PRIVATE:
return privateKeys.keys.map { $0.uppercased() }
}
} }
var shortKeyID: [String] { func getShortKeyIDs(type: PGPKey) -> [String] {
publicKeys.keys.map { $0.suffix(8).uppercased() } getKeyIDs(type: type).map { $0.suffix(8).uppercased() }
}
private func findDecryptionKey(message: CryptoPGPMessage, keyIDHint: String?) throws -> CryptoKey? {
var keyIDCandidates: any Collection<String> = privateKeys.keys
do {
if let encryptionKeysJSON = message.getHexEncryptionKeyIDsJson() {
// these are the subkeys (encryption keys), not the primaries keys (whose fingerprints we have in the privateKeys map),
// so we need to map them back to the primary keyIDs using privateSubkeyToKeyIDMapping
let validSubkeys = try JSONDecoder().decode([String].self, from: encryptionKeysJSON)
let validKeyIDs = validSubkeys.compactMap { privateSubkeyToKeyIDMapping[$0] }
if #available(iOSApplicationExtension 16.0, *) {
assert(validKeyIDs.isEmpty || !Set(keyIDCandidates).isDisjoint(with: validKeyIDs))
}
keyIDCandidates = validKeyIDs
}
} catch {
// fall back to legacy approach of trying first in privateKeys (or preferring hint)
}
if let keyIDHint {
keyIDCandidates = keyIDCandidates.filter { key in key.hasSuffix(keyIDHint.lowercased()) }
}
guard let selectedKeyID = keyIDCandidates.first else {
throw keyIDHint != nil ? AppError.keyExpiredOrIncompatible : AppError.decryption
}
return privateKeys[selectedKeyID]
} }
} }

View file

@ -24,12 +24,29 @@ struct ObjectivePGPInterface: PGPInterface {
} }
} }
func decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? { func decrypt(encryptedData: Data, keyIDHint _: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase } 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 { func encryptWithAllKeys(plainData: Data) throws -> Data {
let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil) let keys = keyring.keys.filter { $0.isPublic && $0.isSecret }
return try encrypt(plainData: plainData, keyIDs: keys.map(\.keyID.longIdentifier))
}
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
let keys = try keyIDs.map { keyID in
guard let key = keyring.findKey(keyID) else {
throw AppError.pgpPublicKeyNotFound(keyID: keyID)
}
return key
}
let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keys, passphraseForKey: nil)
if Defaults.encryptInArmored { if Defaults.encryptInArmored {
return Armor.armored(encryptedData, as: .message).data(using: .ascii)! return Armor.armored(encryptedData, as: .message).data(using: .ascii)!
} }
@ -44,11 +61,20 @@ struct ObjectivePGPInterface: PGPInterface {
keyring.findKey(keyID)?.isSecret ?? false keyring.findKey(keyID)?.isSecret ?? false
} }
var keyID: [String] { func getKeyIDs(type: PGPKey) -> [String] {
keyring.keys.map(\.keyID.longIdentifier) getKeys(type: type).map(\.keyID.longIdentifier)
} }
var shortKeyID: [String] { func getShortKeyIDs(type: PGPKey) -> [String] {
keyring.keys.map(\.keyID.shortIdentifier) getKeys(type: type).map(\.keyID.shortIdentifier)
}
private func getKeys(type: PGPKey) -> [Key] {
switch type {
case .PUBLIC:
keyring.keys.filter(\.isPublic)
case .PRIVATE:
keyring.keys.filter(\.isSecret)
}
} }
} }

View file

@ -9,7 +9,7 @@
public class PGPAgent { public class PGPAgent {
public static let shared = PGPAgent() public static let shared = PGPAgent()
private let keyStore: KeyStore let keyStore: KeyStore
private var pgpInterface: PGPInterface? private var pgpInterface: PGPInterface?
private var latestDecryptStatus = true private var latestDecryptStatus = true
@ -17,6 +17,11 @@ public class PGPAgent {
self.keyStore = keyStore self.keyStore = keyStore
} }
init(keyStore: KeyStore, pgpInterface: PGPInterface) {
self.keyStore = keyStore
self.pgpInterface = pgpInterface
}
public func initKeys() throws { public func initKeys() throws {
guard let publicKey: String = keyStore.get(for: PGPKey.PUBLIC.getKeychainKey()), guard let publicKey: String = keyStore.get(for: PGPKey.PUBLIC.getKeychainKey()),
let privateKey: String = keyStore.get(for: PGPKey.PRIVATE.getKeychainKey()) else { let privateKey: String = keyStore.get(for: PGPKey.PRIVATE.getKeychainKey()) else {
@ -38,30 +43,24 @@ public class PGPAgent {
pgpInterface != nil pgpInterface != nil
} }
public func getKeyID() throws -> [String] { public func getKeyIDs(type: PGPKey) throws -> [String] {
try checkAndInit() try checkAndInit()
return pgpInterface?.keyID ?? [] return pgpInterface?.getKeyIDs(type: type).sorted() ?? []
} }
public func getShortKeyID() throws -> [String] { public func getShortKeyIDs(type: PGPKey) throws -> [String] {
try checkAndInit() try checkAndInit()
return pgpInterface?.shortKeyID.sorted() ?? [] return pgpInterface?.getShortKeyIDs(type: type).sorted() ?? []
} }
public func decrypt(encryptedData: Data, keyID: String, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { public func decrypt(encryptedData: Data, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? {
// Init keys.
try checkAndInit() try checkAndInit()
guard let pgpInterface else { guard let pgpInterface else {
throw AppError.decryption throw AppError.decryption
} }
var keyID = keyID if let keyID, !pgpInterface.containsPrivateKey(with: keyID) {
if !pgpInterface.containsPrivateKey(with: keyID) { throw AppError.pgpPrivateKeyNotFound(keyID: keyID)
if pgpInterface.keyID.count == 1 {
keyID = pgpInterface.keyID.first!
} else {
throw AppError.pgpPrivateKeyNotFound(keyID: keyID)
}
} }
// Remember the previous status and set the current status // Remember the previous status and set the current status
@ -69,52 +68,14 @@ public class PGPAgent {
latestDecryptStatus = false latestDecryptStatus = false
// Get the PGP key passphrase. // Get the PGP key passphrase.
var passphrase = "" let providePassPhraseForKey = { (selectedKeyID: String) -> String in
if previousDecryptStatus == false { if previousDecryptStatus == false {
passphrase = requestPGPKeyPassphrase(keyID) return requestPGPKeyPassphrase(selectedKeyID)
} else {
passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: keyID)) ?? requestPGPKeyPassphrase(keyID)
}
// Decrypt.
guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else {
return nil
}
// The decryption step has succeed.
latestDecryptStatus = true
return result
}
public func encrypt(plainData: Data, keyID: String) throws -> Data {
try checkAndInit()
guard let pgpInterface else {
throw AppError.encryption
}
var keyID = keyID
if !pgpInterface.containsPublicKey(with: keyID) {
if pgpInterface.keyID.count == 1 {
keyID = pgpInterface.keyID.first!
} else {
throw AppError.pgpPublicKeyNotFound(keyID: keyID)
} }
} return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID)
return try pgpInterface.encrypt(plainData: plainData, keyID: keyID)
}
public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: (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("")
} }
// Decrypt. // Decrypt.
guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passphrase: passphrase) else { guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyIDHint: keyID, passPhraseForKey: providePassPhraseForKey) else {
return nil return nil
} }
// The decryption step has succeed. // The decryption step has succeed.
@ -122,12 +83,20 @@ public class PGPAgent {
return result return result
} }
public func encrypt(plainData: Data) throws -> Data { public func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
try checkAndInit() try checkAndInit()
guard let pgpInterface else { guard let pgpInterface else {
throw AppError.encryption throw AppError.encryption
} }
return try pgpInterface.encrypt(plainData: plainData, keyID: nil) return try pgpInterface.encrypt(plainData: plainData, keyIDs: keyIDs)
}
public func encryptWithAllKeys(plainData: Data) throws -> Data {
try checkAndInit()
guard let pgpInterface else {
throw AppError.encryption
}
return try pgpInterface.encryptWithAllKeys(plainData: plainData)
} }
public var isPrepared: Bool { public var isPrepared: Bool {

View file

@ -7,15 +7,15 @@
// //
protocol PGPInterface { protocol PGPInterface {
func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data?
func encrypt(plainData: Data, keyID: String?) throws -> Data // encrypt with all public keys for which we also have a private key
func encryptWithAllKeys(plainData: Data) throws -> Data
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data
func containsPublicKey(with keyID: String) -> Bool func containsPublicKey(with keyID: String) -> Bool
func containsPrivateKey(with keyID: String) -> Bool func containsPrivateKey(with keyID: String) -> Bool
var keyID: [String] { get } func getKeyIDs(type: PGPKey) -> [String]
func getShortKeyIDs(type: PGPKey) -> [String]
var shortKeyID: [String] { get }
} }

View file

@ -38,10 +38,10 @@ public extension UIAlertAction {
} }
} }
static func selectKey(controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction { static func selectKey(type: PGPKey, controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction {
UIAlertAction(title: "Select Key", style: .default) { _ in UIAlertAction(title: "Select Key", style: .default) { _ in
let selectKeyAlert = UIAlertController(title: "Select from imported keys", message: nil, preferredStyle: .actionSheet) let selectKeyAlert = UIAlertController(title: "Select from imported keys", message: nil, preferredStyle: .actionSheet)
try? PGPAgent.shared.getShortKeyID().forEach { keyID in try? PGPAgent.shared.getShortKeyIDs(type: type).forEach { keyID in
let action = UIAlertAction(title: keyID, style: .default, handler: handler) let action = UIAlertAction(title: keyID, style: .default, handler: handler)
selectKeyAlert.addAction(action) selectKeyAlert.addAction(action)
} }

View file

@ -23,6 +23,7 @@ public class PasswordStore {
}() }()
public var storeURL: URL public var storeURL: URL
private let pgpAgent: PGPAgent
public var gitRepository: GitRepository? public var gitRepository: GitRepository?
@ -84,8 +85,9 @@ public class PasswordStore {
gitRepository?.numberOfCommits() gitRepository?.numberOfCommits()
} }
init(url: URL = Globals.repositoryURL) { init(url: URL = Globals.repositoryURL, pgpAgent: PGPAgent = .shared) {
self.storeURL = url self.storeURL = url
self.pgpAgent = pgpAgent
// Migration // Migration
importExistingKeysIntoKeychain() importExistingKeysIntoKeychain()
@ -359,14 +361,14 @@ public class PasswordStore {
eraseStoreData() eraseStoreData()
// Delete PGP key, SSH key and other secrets from the keychain. // Delete PGP key, SSH key and other secrets from the keychain.
AppKeychain.shared.removeAllContent() pgpAgent.keyStore.removeAllContent()
// Delete default settings. // Delete default settings.
Defaults.removeAll() Defaults.removeAll()
// Delete cache explicitly. // Delete cache explicitly.
PasscodeLock.shared.delete() PasscodeLock.shared.delete()
PGPAgent.shared.uninitKeys() pgpAgent.uninitKeys()
} }
// return the number of discarded commits // return the number of discarded commits
@ -395,13 +397,7 @@ public class PasswordStore {
public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password { public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
let url = passwordEntity.fileURL(in: storeURL) let url = passwordEntity.fileURL(in: storeURL)
let encryptedData = try Data(contentsOf: url) let encryptedData = try Data(contentsOf: url)
let data: Data? = try { let data: Data? = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
if Defaults.isEnableGPGIDOn {
let keyID = keyID ?? findGPGID(from: url)
return try PGPAgent.shared.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
}
return try PGPAgent.shared.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
}()
guard let decryptedData = data else { guard let decryptedData = data else {
throw AppError.decryption throw AppError.decryption
} }
@ -409,23 +405,20 @@ public class PasswordStore {
return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText) return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText)
} }
public func decrypt(path: String, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
guard let passwordEntity = fetchPasswordEntity(with: path) else {
throw AppError.decryption
}
if Defaults.isEnableGPGIDOn {
return try decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
}
return try decrypt(passwordEntity: passwordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
}
public func encrypt(password: Password, keyID: String? = nil) throws -> Data { public func encrypt(password: Password, keyID: String? = nil) throws -> Data {
let encryptedDataPath = password.fileURL(in: storeURL) let keyIDs: [String] = {
let keyID = keyID ?? findGPGID(from: encryptedDataPath) if let keyID {
if Defaults.isEnableGPGIDOn { return [keyID]
return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID) }
if Defaults.isEnableGPGIDOn {
return findGPGIDs(underPath: password.path)
}
return []
}()
if !keyIDs.isEmpty {
return try pgpAgent.encrypt(plainData: password.plainData, keyIDs: keyIDs)
} }
return try PGPAgent.shared.encrypt(plainData: password.plainData) return try pgpAgent.encryptWithAllKeys(plainData: password.plainData)
} }
public func removeGitSSHKeys() { public func removeGitSSHKeys() {
@ -436,6 +429,37 @@ public class PasswordStore {
AppKeychain.shared.removeContent(for: SSHKey.PRIVATE.getKeychainKey()) AppKeychain.shared.removeContent(for: SSHKey.PRIVATE.getKeychainKey())
gitSSHPrivateKeyPassphrase = nil gitSSHPrivateKeyPassphrase = nil
} }
func findGPGIDs(underPath relativePath: String) -> [String] {
guard let gpgIDFileURL = findGPGIDFile(atPath: relativePath) else {
return []
}
let allKeysSeparatedByNewline = (try? String(contentsOf: gpgIDFileURL)) ?? ""
return allKeysSeparatedByNewline
.split(separator: "\n")
.map { String($0).trimmed }
.filter { !$0.isEmpty }
}
func findGPGIDFile(atPath relativePath: String) -> URL? {
// Walk up the directory hierarchy, but never escape the store root
let storeRoot = storeURL.absoluteURL.resolvingSymlinksInPath()
var current = storeRoot.appendingPathComponent(relativePath).resolvingSymlinksInPath()
while current.path.hasPrefix(storeRoot.path) {
let candidate = current.appendingPathComponent(".gpg-id")
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDir), !isDir.boolValue {
return candidate.standardizedFileURL
}
let parent = current.deletingLastPathComponent().resolvingSymlinksInPath()
if parent.path == current.path {
break
}
current = parent
}
return nil
}
} }
extension PasswordStore { extension PasswordStore {
@ -468,14 +492,3 @@ extension PasswordStore {
return try gitRepository.commit(signature: gitSignatureForNow, message: message) return try gitRepository.commit(signature: gitSignatureForNow, message: message)
} }
} }
func findGPGID(from url: URL) -> String {
var path = url
while !FileManager.default.fileExists(atPath: path.appendingPathComponent(".gpg-id").path),
path.path != "file:///" {
path = path.deletingLastPathComponent()
}
path = path.appendingPathComponent(".gpg-id")
return (try? String(contentsOf: path))?.trimmed ?? ""
}

View file

@ -31,34 +31,7 @@ final class PGPAgentTest: XCTestCase {
super.tearDown() super.tearDown()
} }
private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? { // - MARK: Basic encrypt and decrypt tests
passKit.Defaults.encryptInArmored = encryptInArmored
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: keyID)
passKit.Defaults.encryptInArmored = decryptFromArmored
return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase)
}
func testMultiKeys() throws {
try [
RSA2048_RSA4096,
ED25519_NISTP384,
].forEach { testKeyInfo in
keychain.removeAllContent()
try importKeys(testKeyInfo.publicKeys, testKeyInfo.privateKeys)
XCTAssert(pgpAgent.isPrepared)
try pgpAgent.initKeys()
try [
(true, true),
(true, false),
(false, true),
(false, false),
].forEach { encryptInArmored, decryptFromArmored in
for id in testKeyInfo.fingerprints {
XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: encryptInArmored, decryptFromArmored: decryptFromArmored), testData)
}
}
}
}
func testBasicEncryptDecrypt() throws { func testBasicEncryptDecrypt() throws {
try [ try [
@ -75,7 +48,8 @@ final class PGPAgentTest: XCTestCase {
try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey) try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey)
XCTAssert(pgpAgent.isPrepared) XCTAssert(pgpAgent.isPrepared)
try pgpAgent.initKeys() try pgpAgent.initKeys()
XCTAssert(try pgpAgent.getKeyID().first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) XCTAssert(try pgpAgent.getKeyIDs(type: .PUBLIC).first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
XCTAssert(try pgpAgent.getKeyIDs(type: .PRIVATE).first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
try [ try [
(true, true), (true, true),
(true, false), (true, false),
@ -161,8 +135,116 @@ final class PGPAgentTest: XCTestCase {
XCTAssertEqual(passphraseRequestCalledCount, 3) XCTAssertEqual(passphraseRequestCalledCount, 3)
} }
func testMultipleKeysLoaded() throws {
try [
RSA2048_RSA4096,
ED25519_NISTP384,
].forEach { testKeyInfo in
keychain.removeAllContent()
try importKeys(testKeyInfo.publicKeys, testKeyInfo.privateKeys)
XCTAssert(pgpAgent.isPrepared)
try pgpAgent.initKeys()
try [
(true, true),
(true, false),
(false, true),
(false, false),
].forEach { encryptInArmored, decryptFromArmored in
for id in testKeyInfo.fingerprints {
XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: encryptInArmored, decryptFromArmored: decryptFromArmored), testData)
}
}
}
}
func testMultiKeysSelectMatchingPrivateKeyToDecrypt() throws {
keychain.removeAllContent()
try importKeys(RSA2048_RSA4096.publicKeys, RSA2048_RSA4096.privateKeys)
try pgpAgent.initKeys()
try [
(true, true),
(true, false),
(false, true),
(false, false),
].forEach { encryptInArmored, decryptFromArmored in
passKit.Defaults.encryptInArmored = encryptInArmored
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: [RSA2048.fingerprint])
passKit.Defaults.encryptInArmored = decryptFromArmored
// Note: not specifying the keyID to decrypt, so that the agent needs to find the matching private key by itself.
let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
XCTAssertEqual(decryptedData, testData)
}
}
// - MARK: Encrypt with multiple keys
func testEncryptWithMultipleKeys() throws {
keychain.removeAllContent()
// no private key for ED25519
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys)
try pgpAgent.initKeys()
XCTAssertEqual(try pgpAgent.getKeyIDs(type: .PUBLIC).map { $0.lowercased() }.sorted(), (RSA2048_RSA4096.longFingerprints + [ED25519.longFingerprint]).sorted())
XCTAssertEqual(try pgpAgent.getKeyIDs(type: .PRIVATE).map { $0.lowercased() }.sorted(), RSA2048_RSA4096.longFingerprints.sorted())
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: RSA2048_RSA4096.fingerprints + [ED25519.fingerprint])
try [RSA2048.fingerprint, RSA4096.fingerprint].forEach { keyID in
let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
XCTAssertEqual(decryptedData, testData)
}
XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) {
XCTAssertEqual($0 as! AppError, AppError.pgpPrivateKeyNotFound(keyID: ED25519.fingerprint))
}
// load private key for ED25519
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys | ED25519.privateKey)
try pgpAgent.initKeys()
let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
XCTAssertEqual(decryptedData, testData)
}
func testEncryptWithAllKeys() throws {
// When multiple keys are imported, the agent should be able to encrypt without specifying the keyID.
// It should use all public keys for which we also have private keys, and the encrypted message should be able to be decrypted by any of the private keys.
keychain.removeAllContent()
// no private key for ED25519
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys)
try pgpAgent.initKeys()
let encryptedData = try pgpAgent.encryptWithAllKeys(plainData: testData)
try [RSA2048.fingerprint, RSA4096.fingerprint].forEach { keyID in
let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
XCTAssertEqual(decryptedData, testData)
}
XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) {
XCTAssertEqual($0 as! AppError, AppError.pgpPrivateKeyNotFound(keyID: ED25519.fingerprint))
}
// load private key for ED25519
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys | ED25519.privateKey)
try pgpAgent.initKeys()
XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) {
XCTAssertEqual($0 as! AppError, AppError.keyExpiredOrIncompatible)
}
}
// - MARK: Helpers
private func importKeys(_ publicKey: String, _ privateKey: String) throws { private func importKeys(_ publicKey: String, _ privateKey: String) throws {
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: publicKey) try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: publicKey)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: privateKey) try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: privateKey)
} }
private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? {
passKit.Defaults.encryptInArmored = encryptInArmored
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: [keyID])
passKit.Defaults.encryptInArmored = decryptFromArmored
return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase)
}
} }

View file

@ -0,0 +1 @@
xÌ1 @k^áBˆI(9BíŒÿïÔj»]÷³ölEUM×™C{ÏlDˆ+=r¢ŠÚKd¬8.4†u Ùy: nç—ˆKyº1F

View file

@ -0,0 +1,3 @@
x<01>ŽAjÄ0sÖ+æbF#Y¶ „< Ç|@òŒm]ÙHrÀy}ÌB><3E>C_
º»¦í~O ÒS+"@ÆÚyÔÚ…h}`ƒÌÞ±'1èEGãE­öP$7ðÔKÄÙEíi¤ØÏyÄñŠ£h£8f«ƒ
G[·g 9Ãg ù{;ä^oòÞþH'Çèa0d{D ϨÕôðlòÿU×K™<4B>S<EFBFBD>5T ø³BÊÐ-ûò’BfH­ÂÚzÄ9ÝRÉS9÷vÕã qk«úõcJ

View file

@ -0,0 +1 @@
c7c52ac6962d08d69e5651eedd6cbaf2f8bd05c3

View file

@ -0,0 +1,414 @@
//
// 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)
}
func testDecryptWithKeyID_keyNotFound_throws() {
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]
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")
}
/// 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]
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])
}
/// After a failed decrypt (latestDecryptStatus=false), requestPGPKeyPassphrase is ALWAYS called,
/// even if the keystore has a cached passphrase.
func testDecrypt_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()
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")
}
/// After a successful decrypt, the next call uses keystore first (latestDecryptStatus=true).
func testDecrypt_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()
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, [])
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Return Values & Error Propagation
/// When pgpInterface.decrypt returns nil, agent.decrypt returns nil.
func testDecrypt_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 testDecrypt_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()
mockPGP.resolvedPassphrases.removeAll()
mockPGP.selectedKeyForPassphrase = longFingerprint
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("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.
func testDecrypt_interfaceThrows_propagatesError() throws {
let shortID = "a1024dae"
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, [longFingerprint])
// 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()
mockPGP.resolvedPassphrases.removeAll()
mockPGP.selectedKeyForPassphrase = longFingerprint
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
XCTAssertEqual(passphraseRequests, [longFingerprint], "After throw, passphrase should always be requested (latestDecryptStatus=false)")
}
/// After successful decrypt, latestDecryptStatus is true.
func testDecrypt_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()
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")
}
// 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 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.
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:keyID:requestPGPKeyPassphrase:) - nil keyID
/// 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)
}
/// 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)
}
// MARK: - Key resolution error vs decrypt status ordering
/// When pgpPrivateKeyNotFound is thrown, latestDecryptStatus is NOT changed because the error occurs BEFORE the status update.
func testDecryptWithKeyID_keyNotFound_doesNotChangeDecryptStatus() throws {
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))
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.resolvedPassphrases, ["cached-pass"])
}
// MARK: - Short vs long key ID behavior
/// When caller passes a short ID and containsPrivateKey matches it (via suffix), the short ID
/// is forwarded to pgpInterface.decrypt.
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]
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"))
// Backend requests passphrase with short ID keystore lookup misses, falls through to request.
XCTAssertEqual(mockPGP.resolvedPassphrases, ["from-request"])
XCTAssertEqual(passphraseRequests, [shortID])
}
// MARK: - Encrypt passthrough tests (for completeness of mock interaction)
func testEncryptWithKeyIDs_passesThrough() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = [longFingerprint]
let result = try agent.encrypt(plainData: testDecryptedData, keyIDs: [longFingerprint])
XCTAssertEqual(result, mockPGP.encryptResult)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 1)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].keyIDs, [longFingerprint])
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].plainData, testDecryptedData)
}
/// encrypt propagates errors from interface.
func testEncryptWithKeyIDs_interfaceThrows_propagatesError() {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = [longFingerprint]
mockPGP.encryptError = AppError.encryption
XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyIDs: [shortID])) { error in
XCTAssertEqual(error as? AppError, AppError.encryption)
}
}
// 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)
}
}
/// encryptWithAllKeys throws keyImport when checkAndInit triggers initKeys without PGP keys.
func testEncryptWithAllKeys_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws {
keychain.removeContent(for: Globals.pgpKeyPassphrase)
XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in
XCTAssertEqual(error as? AppError, AppError.keyImport)
}
}
}

View file

@ -0,0 +1,101 @@
//
// MockPGPInterface.swift
// passKitTests
//
import Foundation
@testable import passKit
class MockPGPInterface: PGPInterface {
// MARK: - Configuration
var publicKeyIDs: Set<String> = []
var privateKeyIDs: Set<String> = []
var decryptResult: Data?
var decryptError: Error?
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 passPhraseForKey: (String) -> String
}
struct EncryptCall {
let plainData: Data
let keyID: String?
}
struct EncryptMultiKeyCall {
let plainData: Data
let keyIDs: [String]
}
struct EncryptWithAllKeysCall {
let plainData: Data
}
var decryptCalls: [DecryptCall] = []
var resolvedPassphrases: [String] = []
var encryptMultiKeyCalls: [EncryptMultiKeyCall] = []
var encryptWithAllKeysCalls: [EncryptWithAllKeysCall] = []
var containsPublicKeyCalls: [String] = []
var containsPrivateKeyCalls: [String] = []
// MARK: - PGPInterface
func decrypt(encryptedData: Data, keyIDHint 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
}
return decryptResult
}
func encryptWithAllKeys(plainData: Data) throws -> Data {
encryptWithAllKeysCalls.append(EncryptWithAllKeysCall(plainData: plainData))
if let error = encryptError {
throw error
}
return encryptResult
}
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
encryptMultiKeyCalls.append(EncryptMultiKeyCall(plainData: plainData, keyIDs: keyIDs))
if let error = encryptError {
throw error
}
return encryptResult
}
func containsPublicKey(with keyID: String) -> Bool {
containsPublicKeyCalls.append(keyID)
return publicKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) }
}
func containsPrivateKey(with keyID: String) -> Bool {
containsPrivateKeyCalls.append(keyID)
return privateKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) }
}
func getKeyIDs(type _: PGPKey) -> [String] {
// currently irrelevant for the tests
[]
}
func getShortKeyIDs(type _: PGPKey) -> [String] {
// currently irrelevant for the tests
[]
}
}

View file

@ -15,24 +15,46 @@ import XCTest
final class PasswordStoreTest: XCTestCase { final class PasswordStoreTest: XCTestCase {
private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/")
private var keyStore: KeyStore! = nil
private var pgpAgent: PGPAgent! = nil
private var passwordStore: PasswordStore! = nil private var passwordStore: PasswordStore! = nil
override func setUp() { override func setUp() {
passwordStore = PasswordStore(url: localRepoURL) super.setUp()
keyStore = DictBasedKeychain()
pgpAgent = PGPAgent(keyStore: keyStore)
passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent)
}
private func setUpMockedPGPInterface() -> MockPGPInterface {
let mockPGPInterface = MockPGPInterface()
keyStore = DictBasedKeychain()
pgpAgent = PGPAgent(keyStore: keyStore, pgpInterface: mockPGPInterface)
passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent)
// Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock.
keyStore.add(string: "dummy", for: Globals.pgpKeyPassphrase)
return mockPGPInterface
} }
override func tearDown() { override func tearDown() {
passwordStore.erase() passwordStore.erase()
passwordStore = nil passwordStore = nil
pgpAgent = nil
keyStore = nil
Defaults.removeAll() Defaults.removeAll()
super.tearDown()
} }
func testInitPasswordEntityCoreData() throws { func testInitPasswordEntityCoreData() throws {
try cloneRepository(.withGPGID) try cloneRepository(.withGPGID)
XCTAssertEqual(passwordStore.numberOfPasswords, 4) XCTAssertEqual(passwordStore.numberOfPasswords, 4)
XCTAssertEqual(passwordStore.numberOfCommits, 16) XCTAssertEqual(passwordStore.numberOfCommits, 17)
XCTAssertEqual(passwordStore.numberOfLocalCommits, 0) XCTAssertEqual(passwordStore.numberOfLocalCommits, 0)
let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")
@ -75,20 +97,20 @@ final class PasswordStoreTest: XCTestCase {
PasscodeLock.shared.save(passcode: "1234") PasscodeLock.shared.save(passcode: "1234")
XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0)
XCTAssertTrue(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertTrue(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey()))
XCTAssertEqual(Defaults.gitSignatureName, "Test User") XCTAssertEqual(Defaults.gitSignatureName, "Test User")
XCTAssertTrue(PasscodeLock.shared.hasPasscode) XCTAssertTrue(PasscodeLock.shared.hasPasscode)
XCTAssertTrue(PGPAgent.shared.isInitialized()) XCTAssertTrue(pgpAgent.isInitialized())
expectation(forNotification: .passwordStoreUpdated, object: nil) expectation(forNotification: .passwordStoreUpdated, object: nil)
expectation(forNotification: .passwordStoreErased, object: nil) expectation(forNotification: .passwordStoreErased, object: nil)
passwordStore.erase() passwordStore.erase()
XCTAssertEqual(passwordStore.numberOfPasswords, 0) XCTAssertEqual(passwordStore.numberOfPasswords, 0)
XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertFalse(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey()))
XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) XCTAssertFalse(Defaults.hasKey(\.gitSignatureName))
XCTAssertFalse(PasscodeLock.shared.hasPasscode) XCTAssertFalse(PasscodeLock.shared.hasPasscode)
XCTAssertFalse(PGPAgent.shared.isInitialized()) XCTAssertFalse(pgpAgent.isInitialized())
waitForExpectations(timeout: 1, handler: nil) waitForExpectations(timeout: 1, handler: nil)
} }
@ -310,32 +332,248 @@ final class PasswordStoreTest: XCTestCase {
waitForExpectations(timeout: 1, handler: nil) waitForExpectations(timeout: 1, handler: nil)
} }
// MARK: - .gpg-id support // MARK: - Find .gpg-id
func testCloneAndDecryptMultiKeys() throws { func testFindGPGIDFile() throws {
try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data("under root".utf8)))
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("foo/bar/baz"), withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path, contents: Data("under foo".utf8)))
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey"), withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path, contents: Data("under hey".utf8)))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "/")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/..")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
// there is a _drectory_ called .gpg-id in here
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id/hey")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path))
// "foo/bar/../../baz" resolves to "baz" which has no .gpg-id, so should find root's.
// Without path resolution, the walk ["foo","bar","..","..","baz"] remove "baz" remove ".."
// "foo/bar/.." remove ".." "foo/bar" finds foo/.gpg-id (wrong).
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("baz"), withIntermediateDirectories: true)
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/../../baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
}
func testMissingGPGIDFile() throws {
XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent(".gpg-id").path))
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("subdir"), withIntermediateDirectories: true)
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ""))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "subdir"))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "missing"))
}
func testFindGPGIDFileStopsAtRoot() throws {
// Place a .gpg-id file ABOVE the store root, this should not be found
let parentDir = localRepoURL.deletingLastPathComponent()
let escapedGPGIDURL = parentDir.appendingPathComponent(".gpg-id")
XCTAssertTrue(FileManager.default.createFile(atPath: escapedGPGIDURL.path, contents: Data("ESCAPED_KEY".utf8)))
defer { try? FileManager.default.removeItem(at: escapedGPGIDURL) }
// Store has no .gpg-id at all
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("sub/deep"), withIntermediateDirectories: true)
// Direct paths, should not find the escaped .gpg-id since it's outside the store root
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ""))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub"))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep"))
// Path traversal attempts via ".."
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ".."))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "../.."))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/../.."))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../.."))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../../../../etc"))
// Symlink escape: create a symlink inside the store pointing outside
let evilDir = parentDir.appendingPathComponent("evil")
try FileManager.default.createDirectory(at: evilDir, withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: evilDir.appendingPathComponent(".gpg-id").path, contents: Data("EVIL_KEY".utf8)))
defer { try? FileManager.default.removeItem(at: evilDir) }
try FileManager.default.createSymbolicLink(at: localRepoURL.appendingPathComponent("sub/escape"), withDestinationURL: evilDir)
// Following the symlink would find evil/.gpg-id must not happen
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/escape"))
}
// MARK: Parse .gpg-id
func testReadGPGIDFile() throws {
try cloneRepository(.withGPGID) try cloneRepository(.withGPGID)
try importMultiplePGPKeys() [
("", [RSA4096.longFingerprint]),
("family", [String(NISTP384.longFingerprint.suffix(16))]),
("personal", [RSA4096.longFingerprint]),
("shared", [RSA2048.longFingerprint, RSA4096.longFingerprint]),
("work", [RSA2048.longFingerprint]),
].forEach { path, expectedKeyIDs in
let foundKeyIDs = passwordStore.findGPGIDs(underPath: path)
XCTAssertEqual(foundKeyIDs, expectedKeyIDs.map { $0.uppercased() }, "Failed for path: \(path)")
}
}
func testReadEmptyGPGIDFile() throws {
try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil))
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), [])
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8)))
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), [])
}
func testReadGPGIDFileWithWhitespace() throws {
try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil))
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), [])
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8)))
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), [])
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \nbar foo\n\tbaz\n \n".utf8)))
XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), ["bar foo", "baz"])
}
// MARK: Handle .gpg-id
func testAddPasswordInRoot_WithSingleEntryInPGPIDFile_EncryptsWithThatKey() throws {
let mockPGPInterface = setUpMockedPGPInterface()
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
try cloneRepository(.withGPGID)
Defaults.isEnableGPGIDOn = true Defaults.isEnableGPGIDOn = true
[
("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"),
("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"),
].forEach { path, id in
let keyID = findGPGID(from: localRepoURL.appendingPathComponent(path))
XCTAssertEqual(keyID, id)
}
let personal = try decrypt(path: "personal/github.com.gpg")
XCTAssertEqual(personal.plainText, "passwordforpersonal\n")
let work = try decrypt(path: "work/github.com.gpg")
XCTAssertEqual(work.plainText, "passwordforwork\n")
let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword") let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword")
let testPasswordEntity = try passwordStore.add(password: testPassword)! _ = try passwordStore.add(password: testPassword)
let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
XCTAssertEqual(testPasswordPlain.plainText, "testpassword") XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
}
func testEncryptWithSingleKeyViaGPGIDFileInSubDirectory() throws {
let mockPGPInterface = setUpMockedPGPInterface()
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
try cloneRepository(.withGPGID)
Defaults.isEnableGPGIDOn = true
let testPassword = Password(name: "test", path: "family/test.gpg", plainText: "testpassword")
_ = try passwordStore.add(password: testPassword)
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
XCTAssertEqual(encryptCall?.keyIDs, [String(NISTP384.longFingerprint.suffix(16))].map { $0.uppercased() })
}
func testEncryptWithSingleKeyViaGPGIDFileInParentDir() throws {
let mockPGPInterface = setUpMockedPGPInterface()
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
try cloneRepository(.withGPGID)
Defaults.isEnableGPGIDOn = true
// /personal doesn't have its own .gpg-id file, but should inherit from the root .gpg-id file
let testPassword = Password(name: "test", path: "personal/test.gpg", plainText: "testpassword")
_ = try passwordStore.add(password: testPassword)
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
}
func testEncryptWithMultipleKeysViaGPGIDFile() throws {
let mockPGPInterface = setUpMockedPGPInterface()
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
try cloneRepository(.withGPGID)
Defaults.isEnableGPGIDOn = true
// /shared uses both RSA2048 and RSA4096
let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword")
_ = try passwordStore.add(password: testPassword)
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
XCTAssertEqual(encryptCall?.keyIDs, RSA2048_RSA4096.longFingerprints.map { $0.uppercased() })
}
func testEncryptWithSingleKeyViaGPGFile_MissingKey() throws {
try cloneRepository(.withGPGID)
try importSinglePGPKey() // Only import RSA4096, but not RSA2048
Defaults.isEnableGPGIDOn = true
// /work uses RSA2048, but we didn't import that one
let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword")
XCTAssertThrowsError(try passwordStore.add(password: testPassword)) {
XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased()))
}
}
func testEncryptWithMultipleKeysViaGPGFile_MissingKey() throws {
try cloneRepository(.withGPGID)
try importSinglePGPKey() // Only import RSA4096, but not RSA2048
Defaults.isEnableGPGIDOn = true
// /shared uses both RSA2048 and RSA4096, but we only imported RSA4096, so encryption should fail since one of the keys is missing
let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword")
XCTAssertThrowsError(try passwordStore.add(password: testPassword)) {
XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased()))
}
}
func testGPGIDDisabledIgnoresGPGIDFile() throws {
try cloneRepository(.withGPGID)
try importSinglePGPKey() // Only import RSA4096, but not RSA2048
Defaults.isEnableGPGIDOn = false
// /work uses RSA2048, but we didn't import that one
let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword")
// this would throw if isEnableGPGIDOn was true, since we are missing the key to encrypt it
_ = try passwordStore.add(password: testPassword)
// check that we can decrypt it with the key we have, which confirms that it was encrypted without using the .gpg-id file
let decryptedPassword = try decrypt(path: "work/test.gpg", keyID: RSA4096.longFingerprint)
XCTAssertEqual(decryptedPassword.plainText, "testpassword")
// we can't even decrypt it with RSA2048
try importMultiplePGPKeys()
XCTAssertThrowsError(try decrypt(path: "work/test.gpg", keyID: RSA2048.longFingerprint)) {
XCTAssertEqual($0 as? AppError, .keyExpiredOrIncompatible)
}
}
func testEncryptWithExplicitKeyID_OverridesGPGIDFile() throws {
continueAfterFailure = false // avoid index out of bounds error below
let mockPGPInterface = setUpMockedPGPInterface()
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
try cloneRepository(.withGPGID)
Defaults.isEnableGPGIDOn = true
// Even though /personal would normally use RSA4096 from the root .gpg-id file, if we explicitly specify a key ID then that should be used instead
let testPassword1 = Password(name: "test1", path: "personal/test1.gpg", plainText: "testpassword1")
_ = try passwordStore.add(password: testPassword1)
let testPassword2 = Password(name: "test2", path: "personal/test2.gpg", plainText: "testpassword2")
_ = try passwordStore.add(password: testPassword2, keyID: RSA2048.longFingerprint)
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 2)
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].plainData, testPassword1.plainData)
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].plainData, testPassword2.plainData)
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].keyIDs, [RSA2048.longFingerprint])
} }
// MARK: - Helpers // MARK: - Helpers
@ -378,17 +616,15 @@ final class PasswordStoreTest: XCTestCase {
} }
private func importSinglePGPKey() throws { private func importSinglePGPKey() throws {
let keychain = AppKeychain.shared try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.publicKey)
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.publicKey) try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.privateKey)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.privateKey) try pgpAgent.initKeys()
try PGPAgent.shared.initKeys()
} }
private func importMultiplePGPKeys() throws { private func importMultiplePGPKeys() throws {
let keychain = AppKeychain.shared try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.publicKeys)
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.privateKeys)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) try pgpAgent.initKeys()
try PGPAgent.shared.initKeys()
} }
private func decrypt(path: String, keyID: String? = nil) throws -> Password { private func decrypt(path: String, keyID: String? = nil) throws -> Password {

View file

@ -16,6 +16,7 @@ struct PGPTestSet {
let publicKey: String let publicKey: String
let privateKey: String let privateKey: String
let fingerprint: String let fingerprint: String
let longFingerprint: String
let passphrase: String let passphrase: String
fileprivate func collect() -> Self { // swiftlint:disable:this strict_fileprivate fileprivate func collect() -> Self { // swiftlint:disable:this strict_fileprivate
@ -28,6 +29,7 @@ struct MultiKeyPGPTestSet {
let publicKeys: String let publicKeys: String
let privateKeys: String let privateKeys: String
let fingerprints: [String] let fingerprints: [String]
let longFingerprints: [String]
let passphrases: [String] let passphrases: [String]
} }
@ -35,6 +37,7 @@ let RSA2048 = PGPTestSet(
publicKey: PGP_RSA2048_PUBLIC_KEY, publicKey: PGP_RSA2048_PUBLIC_KEY,
privateKey: PGP_RSA2048_PRIVATE_KEY, privateKey: PGP_RSA2048_PRIVATE_KEY,
fingerprint: "a1024dae", fingerprint: "a1024dae",
longFingerprint: "4712286271220db299883ea7062e678da1024dae",
passphrase: "passforios" passphrase: "passforios"
).collect() ).collect()
@ -42,6 +45,7 @@ let RSA2048_SUB = PGPTestSet(
publicKey: PGP_RSA2048_PUBLIC_KEY, publicKey: PGP_RSA2048_PUBLIC_KEY,
privateKey: PGP_RSA2048_PRIVATE_SUBKEY, privateKey: PGP_RSA2048_PRIVATE_SUBKEY,
fingerprint: "a1024dae", fingerprint: "a1024dae",
longFingerprint: "4712286271220db299883ea7062e678da1024dae",
passphrase: "passforios" passphrase: "passforios"
) )
@ -49,6 +53,7 @@ let RSA3072_NO_PASSPHRASE = PGPTestSet(
publicKey: PGP_RSA3072_PUBLIC_KEY_NO_PASSPHRASE, publicKey: PGP_RSA3072_PUBLIC_KEY_NO_PASSPHRASE,
privateKey: PGP_RSA3072_PRIVATE_KEY_NO_PASSPHRASE, privateKey: PGP_RSA3072_PRIVATE_KEY_NO_PASSPHRASE,
fingerprint: "be0f9402", fingerprint: "be0f9402",
longFingerprint: "b37cd5669a03f0d46735a2ba35fba3d0be0f9402",
passphrase: "" passphrase: ""
) )
@ -56,6 +61,7 @@ let RSA4096 = PGPTestSet(
publicKey: PGP_RSA4096_PUBLIC_KEY, publicKey: PGP_RSA4096_PUBLIC_KEY,
privateKey: PGP_RSA4096_PRIVATE_KEY, privateKey: PGP_RSA4096_PRIVATE_KEY,
fingerprint: "d862027e", fingerprint: "d862027e",
longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e",
passphrase: "passforios" passphrase: "passforios"
).collect() ).collect()
@ -63,6 +69,7 @@ let RSA4096_SUB = PGPTestSet(
publicKey: PGP_RSA4096_PUBLIC_KEY, publicKey: PGP_RSA4096_PUBLIC_KEY,
privateKey: PGP_RSA4096_PRIVATE_SUBKEY, privateKey: PGP_RSA4096_PRIVATE_SUBKEY,
fingerprint: "d862027e", fingerprint: "d862027e",
longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e",
passphrase: "passforios" passphrase: "passforios"
) )
@ -70,6 +77,7 @@ let ED25519 = PGPTestSet(
publicKey: PGP_ED25519_PUBLIC_KEY, publicKey: PGP_ED25519_PUBLIC_KEY,
privateKey: PGP_ED25519_PRIVATE_KEY, privateKey: PGP_ED25519_PRIVATE_KEY,
fingerprint: "e9444483", fingerprint: "e9444483",
longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483",
passphrase: "passforios" passphrase: "passforios"
).collect() ).collect()
@ -77,6 +85,7 @@ let ED25519_SUB = PGPTestSet(
publicKey: PGP_ED25519_PUBLIC_KEY, publicKey: PGP_ED25519_PUBLIC_KEY,
privateKey: PGP_ED25519_PRIVATE_SUBKEY, privateKey: PGP_ED25519_PRIVATE_SUBKEY,
fingerprint: "e9444483", fingerprint: "e9444483",
longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483",
passphrase: "passforios" passphrase: "passforios"
) )
@ -84,6 +93,7 @@ let NISTP384 = PGPTestSet(
publicKey: PGP_NISTP384_PUBLIC_KEY, publicKey: PGP_NISTP384_PUBLIC_KEY,
privateKey: PGP_NISTP384_PRIVATE_KEY, privateKey: PGP_NISTP384_PRIVATE_KEY,
fingerprint: "5af3c085", fingerprint: "5af3c085",
longFingerprint: "bcd364c078585c0607e19c67171c07d25af3c085",
passphrase: "soirofssap" passphrase: "soirofssap"
).collect() ).collect()
@ -91,6 +101,7 @@ let RSA2048_RSA4096 = MultiKeyPGPTestSet(
publicKeys: PGP_RSA2048_PUBLIC_KEY | PGP_RSA4096_PUBLIC_KEY, publicKeys: PGP_RSA2048_PUBLIC_KEY | PGP_RSA4096_PUBLIC_KEY,
privateKeys: PGP_RSA2048_PRIVATE_KEY | PGP_RSA4096_PRIVATE_KEY, privateKeys: PGP_RSA2048_PRIVATE_KEY | PGP_RSA4096_PRIVATE_KEY,
fingerprints: ["a1024dae", "d862027e"], fingerprints: ["a1024dae", "d862027e"],
longFingerprints: ["4712286271220db299883ea7062e678da1024dae", "787eae1a5fa3e749aa34cc6aa0645ebed862027e"],
passphrases: ["passforios", "passforios"] passphrases: ["passforios", "passforios"]
) )
@ -98,6 +109,7 @@ let ED25519_NISTP384 = MultiKeyPGPTestSet(
publicKeys: PGP_ED25519_PUBLIC_KEY | PGP_NISTP384_PUBLIC_KEY, publicKeys: PGP_ED25519_PUBLIC_KEY | PGP_NISTP384_PUBLIC_KEY,
privateKeys: PGP_ED25519_PRIVATE_KEY | PGP_NISTP384_PRIVATE_KEY, privateKeys: PGP_ED25519_PRIVATE_KEY | PGP_NISTP384_PRIVATE_KEY,
fingerprints: ["e9444483", "5af3c085"], fingerprints: ["e9444483", "5af3c085"],
longFingerprints: ["5fccb081ab8af48972999e2ae750acbfe9444483", "bcd364c078585c0607e19c67171c07d25af3c085"],
passphrases: ["passforios", "soirofssap"] passphrases: ["passforios", "soirofssap"]
) )

View file

@ -30,7 +30,7 @@ The codebase does **not** support encrypting to multiple public keys. Every laye
### 1. `findGPGID(from:) -> [String]` ### 1. `findGPGID(from:) -> [String]`
Split file contents by newline, trim each line, filter empty lines. Return array of key IDs. Callers that only need a single key (e.g. for decryption routing) can use `.first`. Split file contents by newline, trim each line, filter empty lines. Return array of key IDs.
### 2. `PGPInterface` protocol ### 2. `PGPInterface` protocol
@ -60,10 +60,10 @@ When a store lists multiple key IDs in `.gpg-id`, the user needs the public keys
### Current state ### Current state
- The keychain holds exactly **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob. - The keychain holds **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob, but each can contain **multiple concatenated armored key blocks**. Both interface implementations parse all keys from these blobs.
- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the previous key pair entirely. - The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the set of keys entirely, there is no append mode for adding additional keys or managing existing keys.
- Both `GopenPGPInterface` and `ObjectivePGPInterface` *can* parse multiple keys from a single armored blob (e.g. concatenated armor blocks). So if the user pastes multiple public keys into the single field, they would be parsed — but the encrypt path only uses one key, and the UI doesn't communicate this. - There is no UI for viewing which key IDs are loaded or for importing additional recipient-only public keys, nor for viewing the key metadata.
- There is no UI for viewing which key IDs are loaded. - There is no UI for viewing or editing `.gpg-id` files, which are the source of truth for which keys are used for encryption.
### Key storage approach ### Key storage approach
@ -142,20 +142,19 @@ This can be expensive for large directories. Show progress and allow cancellatio
## Implementation Order ## Implementation Order
| Step | Description | Depends On | | Step | Description | Status | Depends On |
|------|-------------|------------| |------|-------------|--------|------------|
| 1 | `findGPGID` returns `[String]` + update callers | — | | 1 | `findGPGIDs` returns `[String]` + update callers | ✅ Done | — |
| 2 | `PGPInterface` protocol change (`keyIDs: [String]?`) | — | | 2 | `PGPInterface` protocol change (`keyIDs: [String]`) | ✅ Done | — |
| 3 | `GopenPGPInterface` multi-key encryption | Step 2 | | 3 | `GopenPGPInterface` multi-key encryption | ✅ Done | Step 2 |
| 4 | `ObjectivePGPInterface` multi-key encryption | Step 2 | | 4 | `ObjectivePGPInterface` multi-key encryption | ✅ Done | Step 2 |
| 5 | `PGPAgent` updated overloads | Steps 2-4 | | 5 | `PGPAgent` updated overloads | ✅ Done | Steps 2-4 |
| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGID` | Steps 1+5 | | 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGIDs` | ✅ Done | Steps 1+5 |
| 7 | UI: import additional recipient public keys | Step 5 | | 7 | UI: import additional recipient public keys | Not started | Step 5 |
| 8 | UI: view loaded key IDs | Step 5 | | 8 | UI: view loaded key IDs and metadata | Not started | Step 5 |
| 9a | UI: view `.gpg-id` in password detail / folder view | Step 1 | | 9a | UI: view `.gpg-id` in password detail / folder view | Not started | Step 1 |
| 9b | UI: edit `.gpg-id` | Step 9a | | 9b | UI: edit `.gpg-id` | Not started | Step 9a |
| 10 | Re-encryption when `.gpg-id` changes | Steps 6+9b | | 10 | Re-encryption when `.gpg-id` changes | Not started | Steps 6+9b |
| T | Tests (see testing section) | Steps 1-10 |
--- ---

View file

@ -14,7 +14,7 @@ GOPENPGP_PATH="$CHECKOUT_PATH/gopenpgp"
mkdir -p "$OUTPUT_PATH" mkdir -p "$OUTPUT_PATH"
mkdir -p "$CHECKOUT_PATH" mkdir -p "$CHECKOUT_PATH"
git clone --depth 1 --branch "$GOPENPGP_VERSION" https://github.com/mssun/gopenpgp.git "$GOPENPGP_PATH" git clone --depth 1 --branch "$GOPENPGP_VERSION" https://forgejo.tranvouez.eu/lysann/passforios-gopenpgp.git "$GOPENPGP_PATH"
pushd "$GOPENPGP_PATH" pushd "$GOPENPGP_PATH"
mkdir -p dist mkdir -p dist