Compare commits

..

No commits in common. "b1314dd8c3934bcbe5b50f4cff9ae1b0238bbf14" and "f0c21dd880fd4fbe1457365bfcc7482ff37813bb" have entirely different histories.

24 changed files with 223 additions and 1168 deletions

View file

@ -116,8 +116,6 @@
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 */; };
@ -429,8 +427,6 @@
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>"; };
@ -780,22 +776,6 @@
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 = (
@ -921,8 +901,6 @@
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 */,
@ -1666,8 +1644,6 @@
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(type: .PRIVATE, controller: self) { action in let selectKey = UIAlertAction.selectKey(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(type: .PUBLIC, controller: self) { action in let selectKey = UIAlertAction.selectKey(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,12 +89,10 @@ class SettingsTableViewController: UITableViewController, UITabBarControllerDele
private func setPGPKeyTableViewCellDetailText() { private func setPGPKeyTableViewCellDetailText() {
var label = "NotSet".localize() var label = "NotSet".localize()
var keyIDs = Set<String>((try? PGPAgent.shared.getShortKeyIDs(type: .PRIVATE)) ?? []) let keyID = (try? PGPAgent.shared.getShortKeyID()) ?? []
keyIDs.formUnion((try? PGPAgent.shared.getShortKeyIDs(type: .PUBLIC)) ?? []) if keyID.count == 1 {
label = keyID.first ?? ""
if keyIDs.count == 1 { } else if keyID.count > 1 {
label = keyIDs.first ?? ""
} else if keyIDs.count > 1 {
label = "Multiple" label = "Multiple"
} }
if Defaults.isYubiKeyEnabled { if Defaults.isYubiKeyEnabled {

View file

@ -30,11 +30,8 @@ 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(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) let decryptedPassword = try PasswordStore.shared.decrypt(path: passwordPath, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
DispatchQueue.main.async { DispatchQueue.main.async {
completion(decryptedPassword) completion(decryptedPassword)
@ -43,7 +40,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(type: PGPKey.PRIVATE, controller: controller) { action in let selectKey = UIAlertAction.selectKey(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(type: .PUBLIC, controller: controller) { action in let selectKey = UIAlertAction.selectKey(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,7 +16,6 @@ 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)
@ -41,24 +40,7 @@ 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
}
} }
} }
@ -88,13 +70,15 @@ 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, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? {
let message = createPGPMessage(from: encryptedData) let key: CryptoKey? = {
guard let message else { if let keyID {
throw AppError.decryption return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value
} }
return privateKeys.first?.value
}()
guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else { guard let privateKey = key else {
throw AppError.decryption throw AppError.decryption
} }
@ -103,7 +87,6 @@ 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
@ -117,43 +100,33 @@ 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 encryptWithAllKeys(plainData: Data) throws -> Data { func encrypt(plainData: Data, keyID: String?) throws -> Data {
let keyIDs = publicKeys.keys.filter { key in privateKeys.keys.contains(key) } let key: CryptoKey? = {
return try encrypt(plainData: plainData, keyIDs: keyIDs) if let keyID {
} return publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value
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 return publicKeys.first?.value
} }()
guard let firstKey = keys.first else {
guard let publicKey = key 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 {
@ -167,44 +140,12 @@ struct GopenPGPInterface: PGPInterface {
return encryptedData.getBinary()! return encryptedData.getBinary()!
} }
func getKeyIDs(type: PGPKey) -> [String] { var keyID: [String] {
switch type { publicKeys.keys.map { $0.uppercased() }
case .PUBLIC:
return publicKeys.keys.map { $0.uppercased() }
case .PRIVATE:
return privateKeys.keys.map { $0.uppercased() }
}
} }
func getShortKeyIDs(type: PGPKey) -> [String] { var shortKeyID: [String] {
getKeyIDs(type: type).map { $0.suffix(8).uppercased() } publicKeys.keys.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,29 +24,12 @@ struct ObjectivePGPInterface: PGPInterface {
} }
} }
func decrypt(encryptedData: Data, keyIDHint _: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { func decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? {
try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { selectedKey in try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase }
guard let selectedKey else {
return nil
}
return passPhraseForKey(selectedKey.keyID.longIdentifier)
}
} }
func encryptWithAllKeys(plainData: Data) throws -> Data { func encrypt(plainData: Data, keyID _: String?) throws -> Data {
let keys = keyring.keys.filter { $0.isPublic && $0.isSecret } let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil)
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)!
} }
@ -61,20 +44,11 @@ struct ObjectivePGPInterface: PGPInterface {
keyring.findKey(keyID)?.isSecret ?? false keyring.findKey(keyID)?.isSecret ?? false
} }
func getKeyIDs(type: PGPKey) -> [String] { var keyID: [String] {
getKeys(type: type).map(\.keyID.longIdentifier) keyring.keys.map(\.keyID.longIdentifier)
} }
func getShortKeyIDs(type: PGPKey) -> [String] { var shortKeyID: [String] {
getKeys(type: type).map(\.keyID.shortIdentifier) keyring.keys.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()
let keyStore: KeyStore private let keyStore: KeyStore
private var pgpInterface: PGPInterface? private var pgpInterface: PGPInterface?
private var latestDecryptStatus = true private var latestDecryptStatus = true
@ -17,11 +17,6 @@ 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 {
@ -43,24 +38,30 @@ public class PGPAgent {
pgpInterface != nil pgpInterface != nil
} }
public func getKeyIDs(type: PGPKey) throws -> [String] { public func getKeyID() throws -> [String] {
try checkAndInit() try checkAndInit()
return pgpInterface?.getKeyIDs(type: type).sorted() ?? [] return pgpInterface?.keyID ?? []
} }
public func getShortKeyIDs(type: PGPKey) throws -> [String] { public func getShortKeyID() throws -> [String] {
try checkAndInit() try checkAndInit()
return pgpInterface?.getShortKeyIDs(type: type).sorted() ?? [] return pgpInterface?.shortKeyID.sorted() ?? []
} }
public func decrypt(encryptedData: Data, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { public func decrypt(encryptedData: Data, keyID: String, 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
} }
if let keyID, !pgpInterface.containsPrivateKey(with: keyID) { var keyID = keyID
throw AppError.pgpPrivateKeyNotFound(keyID: keyID) if !pgpInterface.containsPrivateKey(with: 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
@ -68,14 +69,14 @@ public class PGPAgent {
latestDecryptStatus = false latestDecryptStatus = false
// Get the PGP key passphrase. // Get the PGP key passphrase.
let providePassPhraseForKey = { (selectedKeyID: String) -> String in var passphrase = ""
if previousDecryptStatus == false { if previousDecryptStatus == false {
return requestPGPKeyPassphrase(selectedKeyID) passphrase = requestPGPKeyPassphrase(keyID)
} } else {
return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: keyID)) ?? requestPGPKeyPassphrase(keyID)
} }
// Decrypt. // Decrypt.
guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyIDHint: keyID, passPhraseForKey: providePassPhraseForKey) else { guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else {
return nil return nil
} }
// The decryption step has succeed. // The decryption step has succeed.
@ -83,20 +84,50 @@ public class PGPAgent {
return result return result
} }
public func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { public func encrypt(plainData: Data, keyID: 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, keyIDs: keyIDs) var keyID = keyID
if !pgpInterface.containsPublicKey(with: keyID) {
if pgpInterface.keyID.count == 1 {
keyID = pgpInterface.keyID.first!
} else {
throw AppError.pgpPublicKeyNotFound(keyID: keyID)
}
}
return try pgpInterface.encrypt(plainData: plainData, keyID: keyID)
} }
public func encryptWithAllKeys(plainData: Data) throws -> Data { 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.
guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passphrase: passphrase) else {
return nil
}
// The decryption step has succeed.
latestDecryptStatus = true
return result
}
public func encrypt(plainData: Data) throws -> Data {
try checkAndInit() try checkAndInit()
guard let pgpInterface else { guard let pgpInterface else {
throw AppError.encryption throw AppError.encryption
} }
return try pgpInterface.encryptWithAllKeys(plainData: plainData) return try pgpInterface.encrypt(plainData: plainData, keyID: nil)
} }
public var isPrepared: Bool { public var isPrepared: Bool {

View file

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

View file

@ -38,10 +38,10 @@ public extension UIAlertAction {
} }
} }
static func selectKey(type: PGPKey, controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction { static func selectKey(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.getShortKeyIDs(type: type).forEach { keyID in try? PGPAgent.shared.getShortKeyID().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,7 +23,6 @@ public class PasswordStore {
}() }()
public var storeURL: URL public var storeURL: URL
private let pgpAgent: PGPAgent
public var gitRepository: GitRepository? public var gitRepository: GitRepository?
@ -85,9 +84,8 @@ public class PasswordStore {
gitRepository?.numberOfCommits() gitRepository?.numberOfCommits()
} }
init(url: URL = Globals.repositoryURL, pgpAgent: PGPAgent = .shared) { init(url: URL = Globals.repositoryURL) {
self.storeURL = url self.storeURL = url
self.pgpAgent = pgpAgent
// Migration // Migration
importExistingKeysIntoKeychain() importExistingKeysIntoKeychain()
@ -361,14 +359,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.
pgpAgent.keyStore.removeAllContent() AppKeychain.shared.removeAllContent()
// Delete default settings. // Delete default settings.
Defaults.removeAll() Defaults.removeAll()
// Delete cache explicitly. // Delete cache explicitly.
PasscodeLock.shared.delete() PasscodeLock.shared.delete()
pgpAgent.uninitKeys() PGPAgent.shared.uninitKeys()
} }
// return the number of discarded commits // return the number of discarded commits
@ -397,7 +395,13 @@ 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 pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) let data: Data? = try {
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
} }
@ -405,20 +409,23 @@ 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 encrypt(password: Password, keyID: String? = nil) throws -> Data { public func decrypt(path: String, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
let keyIDs: [String] = { guard let passwordEntity = fetchPasswordEntity(with: path) else {
if let keyID { throw AppError.decryption
return [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.encryptWithAllKeys(plainData: password.plainData) 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 {
let encryptedDataPath = password.fileURL(in: storeURL)
let keyID = keyID ?? findGPGID(from: encryptedDataPath)
if Defaults.isEnableGPGIDOn {
return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID)
}
return try PGPAgent.shared.encrypt(plainData: password.plainData)
} }
public func removeGitSSHKeys() { public func removeGitSSHKeys() {
@ -429,37 +436,6 @@ 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 {
@ -492,3 +468,14 @@ 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,7 +31,34 @@ final class PGPAgentTest: XCTestCase {
super.tearDown() super.tearDown()
} }
// - MARK: Basic encrypt and decrypt tests 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, 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 [
@ -48,8 +75,7 @@ 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.getKeyIDs(type: .PUBLIC).first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) XCTAssert(try pgpAgent.getKeyID().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),
@ -135,116 +161,8 @@ 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

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

View file

@ -1,3 +0,0 @@
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

@ -1 +0,0 @@
c7c52ac6962d08d69e5651eedd6cbaf2f8bd05c3

View file

@ -1,414 +0,0 @@
//
// 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

@ -1,101 +0,0 @@
//
// 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,46 +15,24 @@ 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() {
super.setUp() passwordStore = PasswordStore(url: localRepoURL)
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, 17) XCTAssertEqual(passwordStore.numberOfCommits, 16)
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")
@ -97,20 +75,20 @@ final class PasswordStoreTest: XCTestCase {
PasscodeLock.shared.save(passcode: "1234") PasscodeLock.shared.save(passcode: "1234")
XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0)
XCTAssertTrue(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertTrue(AppKeychain.shared.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.isInitialized()) XCTAssertTrue(PGPAgent.shared.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(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey()))
XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) XCTAssertFalse(Defaults.hasKey(\.gitSignatureName))
XCTAssertFalse(PasscodeLock.shared.hasPasscode) XCTAssertFalse(PasscodeLock.shared.hasPasscode)
XCTAssertFalse(pgpAgent.isInitialized()) XCTAssertFalse(PGPAgent.shared.isInitialized())
waitForExpectations(timeout: 1, handler: nil) waitForExpectations(timeout: 1, handler: nil)
} }
@ -332,248 +310,32 @@ final class PasswordStoreTest: XCTestCase {
waitForExpectations(timeout: 1, handler: nil) waitForExpectations(timeout: 1, handler: nil)
} }
// MARK: - Find .gpg-id // MARK: - .gpg-id support
func testFindGPGIDFile() throws { func testCloneAndDecryptMultiKeys() 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")
_ = try passwordStore.add(password: testPassword) let testPasswordEntity = try passwordStore.add(password: testPassword)!
let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1) XCTAssertEqual(testPasswordPlain.plainText, "testpassword")
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
@ -616,15 +378,17 @@ final class PasswordStoreTest: XCTestCase {
} }
private func importSinglePGPKey() throws { private func importSinglePGPKey() throws {
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.publicKey) let keychain = AppKeychain.shared
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.privateKey) try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.publicKey)
try pgpAgent.initKeys() try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.privateKey)
try PGPAgent.shared.initKeys()
} }
private func importMultiplePGPKeys() throws { private func importMultiplePGPKeys() throws {
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.publicKeys) let keychain = AppKeychain.shared
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.privateKeys) try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys)
try pgpAgent.initKeys() try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys)
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,7 +16,6 @@ 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
@ -29,7 +28,6 @@ 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]
} }
@ -37,7 +35,6 @@ 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()
@ -45,7 +42,6 @@ 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"
) )
@ -53,7 +49,6 @@ 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: ""
) )
@ -61,7 +56,6 @@ 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()
@ -69,7 +63,6 @@ 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"
) )
@ -77,7 +70,6 @@ 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()
@ -85,7 +77,6 @@ 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"
) )
@ -93,7 +84,6 @@ 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()
@ -101,7 +91,6 @@ 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"]
) )
@ -109,7 +98,6 @@ 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. 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`.
### 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 **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 keychain holds exactly **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob.
- 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. - The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the previous key pair entirely.
- 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. - 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 or editing `.gpg-id` files, which are the source of truth for which keys are used for encryption. - There is no UI for viewing which key IDs are loaded.
### Key storage approach ### Key storage approach
@ -142,19 +142,20 @@ This can be expensive for large directories. Show progress and allow cancellatio
## Implementation Order ## Implementation Order
| Step | Description | Status | Depends On | | Step | Description | Depends On |
|------|-------------|--------|------------| |------|-------------|------------|
| 1 | `findGPGIDs` returns `[String]` + update callers | ✅ Done | — | | 1 | `findGPGID` returns `[String]` + update callers | — |
| 2 | `PGPInterface` protocol change (`keyIDs: [String]`) | ✅ Done | — | | 2 | `PGPInterface` protocol change (`keyIDs: [String]?`) | — |
| 3 | `GopenPGPInterface` multi-key encryption | ✅ Done | Step 2 | | 3 | `GopenPGPInterface` multi-key encryption | Step 2 |
| 4 | `ObjectivePGPInterface` multi-key encryption | ✅ Done | Step 2 | | 4 | `ObjectivePGPInterface` multi-key encryption | Step 2 |
| 5 | `PGPAgent` updated overloads | ✅ Done | Steps 2-4 | | 5 | `PGPAgent` updated overloads | Steps 2-4 |
| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGIDs` | ✅ Done | Steps 1+5 | | 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGID` | Steps 1+5 |
| 7 | UI: import additional recipient public keys | Not started | Step 5 | | 7 | UI: import additional recipient public keys | Step 5 |
| 8 | UI: view loaded key IDs and metadata | Not started | Step 5 | | 8 | UI: view loaded key IDs | Step 5 |
| 9a | UI: view `.gpg-id` in password detail / folder view | Not started | Step 1 | | 9a | UI: view `.gpg-id` in password detail / folder view | Step 1 |
| 9b | UI: edit `.gpg-id` | Not started | Step 9a | | 9b | UI: edit `.gpg-id` | Step 9a |
| 10 | Re-encryption when `.gpg-id` changes | Not started | Steps 6+9b | | 10 | Re-encryption when `.gpg-id` changes | 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://forgejo.tranvouez.eu/lysann/passforios-gopenpgp.git "$GOPENPGP_PATH" git clone --depth 1 --branch "$GOPENPGP_VERSION" https://github.com/mssun/gopenpgp.git "$GOPENPGP_PATH"
pushd "$GOPENPGP_PATH" pushd "$GOPENPGP_PATH"
mkdir -p dist mkdir -p dist