Compare commits
25 commits
f0c21dd880
...
b1314dd8c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1314dd8c3 | ||
|
|
4d564f570b | ||
|
|
cd6dd43dae | ||
|
|
5c416bfb21 | ||
|
|
38649f96fe | ||
|
|
e3de11f71c | ||
|
|
77f85ccdd1 | ||
|
|
566b7253f5 | ||
|
|
e32402b807 | ||
|
|
054f333bac | ||
|
|
b103337083 | ||
|
|
5a92b6fda7 | ||
|
|
4e19d9e714 | ||
|
|
9e3e3d1134 | ||
|
|
e69e590e36 | ||
|
|
09b0b150ce | ||
|
|
b7873e6d72 | ||
|
|
e728f26a20 | ||
|
|
84eaf4ad7d | ||
|
|
8d4f3af475 | ||
|
|
f1cb5d27be | ||
|
|
2ae751044c | ||
|
|
d136175d93 | ||
|
|
76db529764 | ||
|
|
c4f81c16eb |
24 changed files with 1172 additions and 227 deletions
|
|
@ -116,6 +116,8 @@
|
||||||
5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
||||||
8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; };
|
8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; };
|
||||||
8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; };
|
8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; };
|
||||||
|
8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */; };
|
||||||
|
8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */; };
|
||||||
8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; };
|
8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; };
|
||||||
9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; };
|
9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; };
|
||||||
9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; };
|
9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; };
|
||||||
|
|
@ -427,6 +429,8 @@
|
||||||
5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; };
|
5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; };
|
||||||
8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = "<group>"; };
|
8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = "<group>"; };
|
||||||
8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = "<group>"; };
|
8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = "<group>"; };
|
||||||
|
8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPGPInterface.swift; sourceTree = "<group>"; };
|
||||||
|
8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PGPAgentLowLevelTests.swift; sourceTree = "<group>"; };
|
||||||
8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = "<group>"; };
|
8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = "<group>"; };
|
||||||
9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = "<group>"; };
|
9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = "<group>"; };
|
||||||
9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = "<group>"; };
|
9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = "<group>"; };
|
||||||
|
|
@ -776,6 +780,22 @@
|
||||||
path = Controllers;
|
path = Controllers;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
8AB3AD8E2F615FD70081DE16 /* Mocks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */,
|
||||||
|
);
|
||||||
|
path = Mocks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8AB3AD8F2F61600B0081DE16 /* LowLevel */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */,
|
||||||
|
);
|
||||||
|
path = LowLevel;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9A58664F25AADB66006719C2 /* Services */ = {
|
9A58664F25AADB66006719C2 /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -901,6 +921,8 @@
|
||||||
30697C5521F63F870064FCAC /* Extensions */,
|
30697C5521F63F870064FCAC /* Extensions */,
|
||||||
8AD8EBF22F5E268D007475AB /* Fixtures */,
|
8AD8EBF22F5E268D007475AB /* Fixtures */,
|
||||||
301F6464216164670071A4CE /* Helpers */,
|
301F6464216164670071A4CE /* Helpers */,
|
||||||
|
8AB3AD8F2F61600B0081DE16 /* LowLevel */,
|
||||||
|
8AB3AD8E2F615FD70081DE16 /* Mocks */,
|
||||||
30C015A7214ED378005BB6DF /* Models */,
|
30C015A7214ED378005BB6DF /* Models */,
|
||||||
30C015A6214ED32A005BB6DF /* Parser */,
|
30C015A6214ED32A005BB6DF /* Parser */,
|
||||||
30B4C7BB24085A3C008B86F7 /* Passwords */,
|
30B4C7BB24085A3C008B86F7 /* Passwords */,
|
||||||
|
|
@ -1644,6 +1666,8 @@
|
||||||
8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */,
|
8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */,
|
||||||
301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */,
|
301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */,
|
||||||
9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */,
|
9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */,
|
||||||
|
8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */,
|
||||||
|
8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */,
|
||||||
30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */,
|
30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */,
|
||||||
DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */,
|
DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */,
|
||||||
A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */,
|
A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni
|
||||||
// alert: cancel or try again
|
// alert: cancel or try again
|
||||||
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
|
alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
|
||||||
let selectKey = UIAlertAction.selectKey(controller: self) { action in
|
let selectKey = UIAlertAction.selectKey(type: .PRIVATE, controller: self) { action in
|
||||||
self.decryptThenShowPasswordLocalKey(keyID: action.title)
|
self.decryptThenShowPasswordLocalKey(keyID: action.title)
|
||||||
}
|
}
|
||||||
alert.addAction(selectKey)
|
alert.addAction(selectKey)
|
||||||
|
|
@ -223,7 +223,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni
|
||||||
SVProgressHUD.dismiss()
|
SVProgressHUD.dismiss()
|
||||||
let alert = UIAlertController(title: "Cannot Edit Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Cannot Edit Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
|
alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
|
||||||
let selectKey = UIAlertAction.selectKey(controller: self) { action in
|
let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: self) { action in
|
||||||
self.saveEditPassword(password: password, keyID: action.title)
|
self.saveEditPassword(password: password, keyID: action.title)
|
||||||
}
|
}
|
||||||
alert.addAction(selectKey)
|
alert.addAction(selectKey)
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,12 @@ class SettingsTableViewController: UITableViewController, UITabBarControllerDele
|
||||||
private func setPGPKeyTableViewCellDetailText() {
|
private func setPGPKeyTableViewCellDetailText() {
|
||||||
var label = "NotSet".localize()
|
var label = "NotSet".localize()
|
||||||
|
|
||||||
let keyID = (try? PGPAgent.shared.getShortKeyID()) ?? []
|
var keyIDs = Set<String>((try? PGPAgent.shared.getShortKeyIDs(type: .PRIVATE)) ?? [])
|
||||||
if keyID.count == 1 {
|
keyIDs.formUnion((try? PGPAgent.shared.getShortKeyIDs(type: .PUBLIC)) ?? [])
|
||||||
label = keyID.first ?? ""
|
|
||||||
} else if keyID.count > 1 {
|
if keyIDs.count == 1 {
|
||||||
|
label = keyIDs.first ?? ""
|
||||||
|
} else if keyIDs.count > 1 {
|
||||||
label = "Multiple"
|
label = "Multiple"
|
||||||
}
|
}
|
||||||
if Defaults.isYubiKeyEnabled {
|
if Defaults.isYubiKeyEnabled {
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,11 @@ func decryptPassword(
|
||||||
}
|
}
|
||||||
DispatchQueue.global(qos: .userInteractive).async {
|
DispatchQueue.global(qos: .userInteractive).async {
|
||||||
do {
|
do {
|
||||||
|
guard let passwordEntity = PasswordStore.shared.fetchPasswordEntity(with: passwordPath) else {
|
||||||
|
throw AppError.decryption
|
||||||
|
}
|
||||||
let requestPGPKeyPassphrase = Utils.createRequestPGPKeyPassphraseHandler(controller: controller)
|
let requestPGPKeyPassphrase = Utils.createRequestPGPKeyPassphraseHandler(controller: controller)
|
||||||
let decryptedPassword = try PasswordStore.shared.decrypt(path: passwordPath, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
let decryptedPassword = try PasswordStore.shared.decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
completion(decryptedPassword)
|
completion(decryptedPassword)
|
||||||
|
|
@ -40,7 +43,7 @@ func decryptPassword(
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction.cancelAndPopView(controller: controller))
|
alert.addAction(UIAlertAction.cancelAndPopView(controller: controller))
|
||||||
let selectKey = UIAlertAction.selectKey(controller: controller) { action in
|
let selectKey = UIAlertAction.selectKey(type: PGPKey.PRIVATE, controller: controller) { action in
|
||||||
decryptPassword(in: controller, with: passwordPath, using: action.title, completion: completion)
|
decryptPassword(in: controller, with: passwordPath, using: action.title, completion: completion)
|
||||||
}
|
}
|
||||||
alert.addAction(selectKey)
|
alert.addAction(selectKey)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ func encryptPassword(in controller: UIViewController, with password: Password, k
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let alert = UIAlertController(title: "Cannot Encrypt Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Cannot Encrypt Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction.cancelAndPopView(controller: controller))
|
alert.addAction(UIAlertAction.cancelAndPopView(controller: controller))
|
||||||
let selectKey = UIAlertAction.selectKey(controller: controller) { action in
|
let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: controller) { action in
|
||||||
encryptPassword(in: controller, with: password, keyID: action.title, completion: completion)
|
encryptPassword(in: controller, with: password, keyID: action.title, completion: completion)
|
||||||
}
|
}
|
||||||
alert.addAction(selectKey)
|
alert.addAction(selectKey)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ struct GopenPGPInterface: PGPInterface {
|
||||||
|
|
||||||
private var publicKeys: [String: CryptoKey] = [:]
|
private var publicKeys: [String: CryptoKey] = [:]
|
||||||
private var privateKeys: [String: CryptoKey] = [:]
|
private var privateKeys: [String: CryptoKey] = [:]
|
||||||
|
private var privateSubkeyToKeyIDMapping: [String: String] = [:] // value is the key in privateKeys map
|
||||||
|
|
||||||
init(publicArmoredKey: String, privateArmoredKey: String) throws {
|
init(publicArmoredKey: String, privateArmoredKey: String) throws {
|
||||||
let pubKeys = extractKeysFromArmored(str: publicArmoredKey)
|
let pubKeys = extractKeysFromArmored(str: publicArmoredKey)
|
||||||
|
|
@ -40,7 +41,24 @@ struct GopenPGPInterface: PGPInterface {
|
||||||
}
|
}
|
||||||
throw AppError.keyImport
|
throw AppError.keyImport
|
||||||
}
|
}
|
||||||
privateKeys[cryptoKey.getFingerprint().lowercased()] = cryptoKey
|
|
||||||
|
let keyID = cryptoKey.getFingerprint().lowercased()
|
||||||
|
privateKeys[keyID] = cryptoKey
|
||||||
|
|
||||||
|
guard let subkeyIDsJSON = HelperPassGetHexSubkeyIDsJSON(cryptoKey) else {
|
||||||
|
guard error == nil else {
|
||||||
|
throw error!
|
||||||
|
}
|
||||||
|
throw AppError.keyImport
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let subkeyIDs = try JSONDecoder().decode([String].self, from: subkeyIDsJSON)
|
||||||
|
for subkeyID in subkeyIDs {
|
||||||
|
privateSubkeyToKeyIDMapping[subkeyID] = keyID
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw AppError.keyImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,15 +88,13 @@ struct GopenPGPInterface: PGPInterface {
|
||||||
privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) }
|
privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? {
|
func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
|
||||||
let key: CryptoKey? = {
|
let message = createPGPMessage(from: encryptedData)
|
||||||
if let keyID {
|
guard let message else {
|
||||||
return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value
|
throw AppError.decryption
|
||||||
}
|
}
|
||||||
return privateKeys.first?.value
|
|
||||||
}()
|
|
||||||
|
|
||||||
guard let privateKey = key else {
|
guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else {
|
||||||
throw AppError.decryption
|
throw AppError.decryption
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +103,7 @@ struct GopenPGPInterface: PGPInterface {
|
||||||
try privateKey.isLocked(&isLocked)
|
try privateKey.isLocked(&isLocked)
|
||||||
var unlockedKey: CryptoKey!
|
var unlockedKey: CryptoKey!
|
||||||
if isLocked.boolValue {
|
if isLocked.boolValue {
|
||||||
|
let passphrase = passPhraseForKey(privateKey.getFingerprint())
|
||||||
unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8))
|
unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8))
|
||||||
} else {
|
} else {
|
||||||
unlockedKey = privateKey
|
unlockedKey = privateKey
|
||||||
|
|
@ -100,33 +117,43 @@ struct GopenPGPInterface: PGPInterface {
|
||||||
throw AppError.decryption
|
throw AppError.decryption
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = createPGPMessage(from: encryptedData)
|
|
||||||
return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data
|
return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data
|
||||||
} catch {
|
} catch {
|
||||||
throw Self.errorMapping[error.localizedDescription, default: error]
|
throw Self.errorMapping[error.localizedDescription, default: error]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func encrypt(plainData: Data, keyID: String?) throws -> Data {
|
func encryptWithAllKeys(plainData: Data) throws -> Data {
|
||||||
let key: CryptoKey? = {
|
let keyIDs = publicKeys.keys.filter { key in privateKeys.keys.contains(key) }
|
||||||
if let keyID {
|
return try encrypt(plainData: plainData, keyIDs: keyIDs)
|
||||||
return publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value
|
|
||||||
}
|
}
|
||||||
return publicKeys.first?.value
|
|
||||||
}()
|
|
||||||
|
|
||||||
guard let publicKey = key else {
|
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
|
||||||
|
let keys: [CryptoKey] = try keyIDs.map { keyID in
|
||||||
|
guard let key = publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value else {
|
||||||
|
throw AppError.pgpPublicKeyNotFound(keyID: keyID)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
guard let firstKey = keys.first else {
|
||||||
throw AppError.encryption
|
throw AppError.encryption
|
||||||
}
|
}
|
||||||
|
let otherKeys = keys.dropFirst()
|
||||||
|
|
||||||
var error: NSError?
|
var error: NSError?
|
||||||
|
guard let keyRing = CryptoNewKeyRing(firstKey, &error) else {
|
||||||
guard let keyRing = CryptoNewKeyRing(publicKey, &error) else {
|
|
||||||
guard error == nil else {
|
guard error == nil else {
|
||||||
throw error!
|
throw error!
|
||||||
}
|
}
|
||||||
throw AppError.encryption
|
throw AppError.encryption
|
||||||
}
|
}
|
||||||
|
do {
|
||||||
|
try otherKeys.forEach { key in
|
||||||
|
try keyRing.add(key)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw AppError.encryption
|
||||||
|
}
|
||||||
|
|
||||||
let encryptedData = try keyRing.encrypt(CryptoNewPlainMessage(plainData.mutable as Data), privateKey: nil)
|
let encryptedData = try keyRing.encrypt(CryptoNewPlainMessage(plainData.mutable as Data), privateKey: nil)
|
||||||
if Defaults.encryptInArmored {
|
if Defaults.encryptInArmored {
|
||||||
|
|
@ -140,12 +167,44 @@ struct GopenPGPInterface: PGPInterface {
|
||||||
return encryptedData.getBinary()!
|
return encryptedData.getBinary()!
|
||||||
}
|
}
|
||||||
|
|
||||||
var keyID: [String] {
|
func getKeyIDs(type: PGPKey) -> [String] {
|
||||||
publicKeys.keys.map { $0.uppercased() }
|
switch type {
|
||||||
|
case .PUBLIC:
|
||||||
|
return publicKeys.keys.map { $0.uppercased() }
|
||||||
|
case .PRIVATE:
|
||||||
|
return privateKeys.keys.map { $0.uppercased() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var shortKeyID: [String] {
|
func getShortKeyIDs(type: PGPKey) -> [String] {
|
||||||
publicKeys.keys.map { $0.suffix(8).uppercased() }
|
getKeyIDs(type: type).map { $0.suffix(8).uppercased() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findDecryptionKey(message: CryptoPGPMessage, keyIDHint: String?) throws -> CryptoKey? {
|
||||||
|
var keyIDCandidates: any Collection<String> = privateKeys.keys
|
||||||
|
do {
|
||||||
|
if let encryptionKeysJSON = message.getHexEncryptionKeyIDsJson() {
|
||||||
|
// these are the subkeys (encryption keys), not the primaries keys (whose fingerprints we have in the privateKeys map),
|
||||||
|
// so we need to map them back to the primary keyIDs using privateSubkeyToKeyIDMapping
|
||||||
|
let validSubkeys = try JSONDecoder().decode([String].self, from: encryptionKeysJSON)
|
||||||
|
let validKeyIDs = validSubkeys.compactMap { privateSubkeyToKeyIDMapping[$0] }
|
||||||
|
if #available(iOSApplicationExtension 16.0, *) {
|
||||||
|
assert(validKeyIDs.isEmpty || !Set(keyIDCandidates).isDisjoint(with: validKeyIDs))
|
||||||
|
}
|
||||||
|
keyIDCandidates = validKeyIDs
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall back to legacy approach of trying first in privateKeys (or preferring hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let keyIDHint {
|
||||||
|
keyIDCandidates = keyIDCandidates.filter { key in key.hasSuffix(keyIDHint.lowercased()) }
|
||||||
|
}
|
||||||
|
guard let selectedKeyID = keyIDCandidates.first else {
|
||||||
|
throw keyIDHint != nil ? AppError.keyExpiredOrIncompatible : AppError.decryption
|
||||||
|
}
|
||||||
|
|
||||||
|
return privateKeys[selectedKeyID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,29 @@ struct ObjectivePGPInterface: PGPInterface {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? {
|
func decrypt(encryptedData: Data, keyIDHint _: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
|
||||||
try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase }
|
try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { selectedKey in
|
||||||
|
guard let selectedKey else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return passPhraseForKey(selectedKey.keyID.longIdentifier)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func encrypt(plainData: Data, keyID _: String?) throws -> Data {
|
func encryptWithAllKeys(plainData: Data) throws -> Data {
|
||||||
let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil)
|
let keys = keyring.keys.filter { $0.isPublic && $0.isSecret }
|
||||||
|
return try encrypt(plainData: plainData, keyIDs: keys.map(\.keyID.longIdentifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
|
||||||
|
let keys = try keyIDs.map { keyID in
|
||||||
|
guard let key = keyring.findKey(keyID) else {
|
||||||
|
throw AppError.pgpPublicKeyNotFound(keyID: keyID)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keys, passphraseForKey: nil)
|
||||||
if Defaults.encryptInArmored {
|
if Defaults.encryptInArmored {
|
||||||
return Armor.armored(encryptedData, as: .message).data(using: .ascii)!
|
return Armor.armored(encryptedData, as: .message).data(using: .ascii)!
|
||||||
}
|
}
|
||||||
|
|
@ -44,11 +61,20 @@ struct ObjectivePGPInterface: PGPInterface {
|
||||||
keyring.findKey(keyID)?.isSecret ?? false
|
keyring.findKey(keyID)?.isSecret ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
var keyID: [String] {
|
func getKeyIDs(type: PGPKey) -> [String] {
|
||||||
keyring.keys.map(\.keyID.longIdentifier)
|
getKeys(type: type).map(\.keyID.longIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
var shortKeyID: [String] {
|
func getShortKeyIDs(type: PGPKey) -> [String] {
|
||||||
keyring.keys.map(\.keyID.shortIdentifier)
|
getKeys(type: type).map(\.keyID.shortIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getKeys(type: PGPKey) -> [Key] {
|
||||||
|
switch type {
|
||||||
|
case .PUBLIC:
|
||||||
|
keyring.keys.filter(\.isPublic)
|
||||||
|
case .PRIVATE:
|
||||||
|
keyring.keys.filter(\.isSecret)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
public class PGPAgent {
|
public class PGPAgent {
|
||||||
public static let shared = PGPAgent()
|
public static let shared = PGPAgent()
|
||||||
|
|
||||||
private let keyStore: KeyStore
|
let keyStore: KeyStore
|
||||||
private var pgpInterface: PGPInterface?
|
private var pgpInterface: PGPInterface?
|
||||||
private var latestDecryptStatus = true
|
private var latestDecryptStatus = true
|
||||||
|
|
||||||
|
|
@ -17,6 +17,11 @@ public class PGPAgent {
|
||||||
self.keyStore = keyStore
|
self.keyStore = keyStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(keyStore: KeyStore, pgpInterface: PGPInterface) {
|
||||||
|
self.keyStore = keyStore
|
||||||
|
self.pgpInterface = pgpInterface
|
||||||
|
}
|
||||||
|
|
||||||
public func initKeys() throws {
|
public func initKeys() throws {
|
||||||
guard let publicKey: String = keyStore.get(for: PGPKey.PUBLIC.getKeychainKey()),
|
guard let publicKey: String = keyStore.get(for: PGPKey.PUBLIC.getKeychainKey()),
|
||||||
let privateKey: String = keyStore.get(for: PGPKey.PRIVATE.getKeychainKey()) else {
|
let privateKey: String = keyStore.get(for: PGPKey.PRIVATE.getKeychainKey()) else {
|
||||||
|
|
@ -38,45 +43,39 @@ public class PGPAgent {
|
||||||
pgpInterface != nil
|
pgpInterface != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getKeyID() throws -> [String] {
|
public func getKeyIDs(type: PGPKey) throws -> [String] {
|
||||||
try checkAndInit()
|
try checkAndInit()
|
||||||
return pgpInterface?.keyID ?? []
|
return pgpInterface?.getKeyIDs(type: type).sorted() ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getShortKeyID() throws -> [String] {
|
public func getShortKeyIDs(type: PGPKey) throws -> [String] {
|
||||||
try checkAndInit()
|
try checkAndInit()
|
||||||
return pgpInterface?.shortKeyID.sorted() ?? []
|
return pgpInterface?.getShortKeyIDs(type: type).sorted() ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
public func decrypt(encryptedData: Data, keyID: String, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? {
|
public func decrypt(encryptedData: Data, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? {
|
||||||
// Init keys.
|
|
||||||
try checkAndInit()
|
try checkAndInit()
|
||||||
guard let pgpInterface else {
|
guard let pgpInterface else {
|
||||||
throw AppError.decryption
|
throw AppError.decryption
|
||||||
}
|
}
|
||||||
|
|
||||||
var keyID = keyID
|
if let keyID, !pgpInterface.containsPrivateKey(with: keyID) {
|
||||||
if !pgpInterface.containsPrivateKey(with: keyID) {
|
|
||||||
if pgpInterface.keyID.count == 1 {
|
|
||||||
keyID = pgpInterface.keyID.first!
|
|
||||||
} else {
|
|
||||||
throw AppError.pgpPrivateKeyNotFound(keyID: keyID)
|
throw AppError.pgpPrivateKeyNotFound(keyID: keyID)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Remember the previous status and set the current status
|
// Remember the previous status and set the current status
|
||||||
let previousDecryptStatus = latestDecryptStatus
|
let previousDecryptStatus = latestDecryptStatus
|
||||||
latestDecryptStatus = false
|
latestDecryptStatus = false
|
||||||
|
|
||||||
// Get the PGP key passphrase.
|
// Get the PGP key passphrase.
|
||||||
var passphrase = ""
|
let providePassPhraseForKey = { (selectedKeyID: String) -> String in
|
||||||
if previousDecryptStatus == false {
|
if previousDecryptStatus == false {
|
||||||
passphrase = requestPGPKeyPassphrase(keyID)
|
return requestPGPKeyPassphrase(selectedKeyID)
|
||||||
} else {
|
}
|
||||||
passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: keyID)) ?? requestPGPKeyPassphrase(keyID)
|
return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID)
|
||||||
}
|
}
|
||||||
// Decrypt.
|
// Decrypt.
|
||||||
guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else {
|
guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyIDHint: keyID, passPhraseForKey: providePassPhraseForKey) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// The decryption step has succeed.
|
// The decryption step has succeed.
|
||||||
|
|
@ -84,50 +83,20 @@ public class PGPAgent {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encrypt(plainData: Data, keyID: String) throws -> Data {
|
public func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
|
||||||
try checkAndInit()
|
try checkAndInit()
|
||||||
guard let pgpInterface else {
|
guard let pgpInterface else {
|
||||||
throw AppError.encryption
|
throw AppError.encryption
|
||||||
}
|
}
|
||||||
var keyID = keyID
|
return try pgpInterface.encrypt(plainData: plainData, keyIDs: keyIDs)
|
||||||
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 decrypt(encryptedData: Data, requestPGPKeyPassphrase: (String) -> String) throws -> Data? {
|
public func encryptWithAllKeys(plainData: Data) 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.encrypt(plainData: plainData, keyID: nil)
|
return try pgpInterface.encryptWithAllKeys(plainData: plainData)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isPrepared: Bool {
|
public var isPrepared: Bool {
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
protocol PGPInterface {
|
protocol PGPInterface {
|
||||||
func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data?
|
func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data?
|
||||||
|
|
||||||
func encrypt(plainData: Data, keyID: String?) throws -> Data
|
// encrypt with all public keys for which we also have a private key
|
||||||
|
func encryptWithAllKeys(plainData: Data) throws -> Data
|
||||||
|
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data
|
||||||
|
|
||||||
func containsPublicKey(with keyID: String) -> Bool
|
func containsPublicKey(with keyID: String) -> Bool
|
||||||
|
|
||||||
func containsPrivateKey(with keyID: String) -> Bool
|
func containsPrivateKey(with keyID: String) -> Bool
|
||||||
|
|
||||||
var keyID: [String] { get }
|
func getKeyIDs(type: PGPKey) -> [String]
|
||||||
|
func getShortKeyIDs(type: PGPKey) -> [String]
|
||||||
var shortKeyID: [String] { get }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,10 @@ public extension UIAlertAction {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func selectKey(controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction {
|
static func selectKey(type: PGPKey, controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction {
|
||||||
UIAlertAction(title: "Select Key", style: .default) { _ in
|
UIAlertAction(title: "Select Key", style: .default) { _ in
|
||||||
let selectKeyAlert = UIAlertController(title: "Select from imported keys", message: nil, preferredStyle: .actionSheet)
|
let selectKeyAlert = UIAlertController(title: "Select from imported keys", message: nil, preferredStyle: .actionSheet)
|
||||||
try? PGPAgent.shared.getShortKeyID().forEach { keyID in
|
try? PGPAgent.shared.getShortKeyIDs(type: type).forEach { keyID in
|
||||||
let action = UIAlertAction(title: keyID, style: .default, handler: handler)
|
let action = UIAlertAction(title: keyID, style: .default, handler: handler)
|
||||||
selectKeyAlert.addAction(action)
|
selectKeyAlert.addAction(action)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public class PasswordStore {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public var storeURL: URL
|
public var storeURL: URL
|
||||||
|
private let pgpAgent: PGPAgent
|
||||||
|
|
||||||
public var gitRepository: GitRepository?
|
public var gitRepository: GitRepository?
|
||||||
|
|
||||||
|
|
@ -84,8 +85,9 @@ public class PasswordStore {
|
||||||
gitRepository?.numberOfCommits()
|
gitRepository?.numberOfCommits()
|
||||||
}
|
}
|
||||||
|
|
||||||
init(url: URL = Globals.repositoryURL) {
|
init(url: URL = Globals.repositoryURL, pgpAgent: PGPAgent = .shared) {
|
||||||
self.storeURL = url
|
self.storeURL = url
|
||||||
|
self.pgpAgent = pgpAgent
|
||||||
|
|
||||||
// Migration
|
// Migration
|
||||||
importExistingKeysIntoKeychain()
|
importExistingKeysIntoKeychain()
|
||||||
|
|
@ -359,14 +361,14 @@ public class PasswordStore {
|
||||||
eraseStoreData()
|
eraseStoreData()
|
||||||
|
|
||||||
// Delete PGP key, SSH key and other secrets from the keychain.
|
// Delete PGP key, SSH key and other secrets from the keychain.
|
||||||
AppKeychain.shared.removeAllContent()
|
pgpAgent.keyStore.removeAllContent()
|
||||||
|
|
||||||
// Delete default settings.
|
// Delete default settings.
|
||||||
Defaults.removeAll()
|
Defaults.removeAll()
|
||||||
|
|
||||||
// Delete cache explicitly.
|
// Delete cache explicitly.
|
||||||
PasscodeLock.shared.delete()
|
PasscodeLock.shared.delete()
|
||||||
PGPAgent.shared.uninitKeys()
|
pgpAgent.uninitKeys()
|
||||||
}
|
}
|
||||||
|
|
||||||
// return the number of discarded commits
|
// return the number of discarded commits
|
||||||
|
|
@ -395,13 +397,7 @@ public class PasswordStore {
|
||||||
public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
|
public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
|
||||||
let url = passwordEntity.fileURL(in: storeURL)
|
let url = passwordEntity.fileURL(in: storeURL)
|
||||||
let encryptedData = try Data(contentsOf: url)
|
let encryptedData = try Data(contentsOf: url)
|
||||||
let data: Data? = try {
|
let data: Data? = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
||||||
if Defaults.isEnableGPGIDOn {
|
|
||||||
let keyID = keyID ?? findGPGID(from: url)
|
|
||||||
return try PGPAgent.shared.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
|
||||||
}
|
|
||||||
return try PGPAgent.shared.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
|
||||||
}()
|
|
||||||
guard let decryptedData = data else {
|
guard let decryptedData = data else {
|
||||||
throw AppError.decryption
|
throw AppError.decryption
|
||||||
}
|
}
|
||||||
|
|
@ -409,23 +405,20 @@ public class PasswordStore {
|
||||||
return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText)
|
return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func decrypt(path: String, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
|
|
||||||
guard let passwordEntity = fetchPasswordEntity(with: path) else {
|
|
||||||
throw AppError.decryption
|
|
||||||
}
|
|
||||||
if Defaults.isEnableGPGIDOn {
|
|
||||||
return try decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
|
||||||
}
|
|
||||||
return try decrypt(passwordEntity: passwordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encrypt(password: Password, keyID: String? = nil) throws -> Data {
|
public func encrypt(password: Password, keyID: String? = nil) throws -> Data {
|
||||||
let encryptedDataPath = password.fileURL(in: storeURL)
|
let keyIDs: [String] = {
|
||||||
let keyID = keyID ?? findGPGID(from: encryptedDataPath)
|
if let keyID {
|
||||||
if Defaults.isEnableGPGIDOn {
|
return [keyID]
|
||||||
return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID)
|
|
||||||
}
|
}
|
||||||
return try PGPAgent.shared.encrypt(plainData: password.plainData)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func removeGitSSHKeys() {
|
public func removeGitSSHKeys() {
|
||||||
|
|
@ -436,6 +429,37 @@ public class PasswordStore {
|
||||||
AppKeychain.shared.removeContent(for: SSHKey.PRIVATE.getKeychainKey())
|
AppKeychain.shared.removeContent(for: SSHKey.PRIVATE.getKeychainKey())
|
||||||
gitSSHPrivateKeyPassphrase = nil
|
gitSSHPrivateKeyPassphrase = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findGPGIDs(underPath relativePath: String) -> [String] {
|
||||||
|
guard let gpgIDFileURL = findGPGIDFile(atPath: relativePath) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let allKeysSeparatedByNewline = (try? String(contentsOf: gpgIDFileURL)) ?? ""
|
||||||
|
return allKeysSeparatedByNewline
|
||||||
|
.split(separator: "\n")
|
||||||
|
.map { String($0).trimmed }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
func findGPGIDFile(atPath relativePath: String) -> URL? {
|
||||||
|
// Walk up the directory hierarchy, but never escape the store root
|
||||||
|
let storeRoot = storeURL.absoluteURL.resolvingSymlinksInPath()
|
||||||
|
var current = storeRoot.appendingPathComponent(relativePath).resolvingSymlinksInPath()
|
||||||
|
|
||||||
|
while current.path.hasPrefix(storeRoot.path) {
|
||||||
|
let candidate = current.appendingPathComponent(".gpg-id")
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDir), !isDir.boolValue {
|
||||||
|
return candidate.standardizedFileURL
|
||||||
|
}
|
||||||
|
let parent = current.deletingLastPathComponent().resolvingSymlinksInPath()
|
||||||
|
if parent.path == current.path {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PasswordStore {
|
extension PasswordStore {
|
||||||
|
|
@ -468,14 +492,3 @@ extension PasswordStore {
|
||||||
return try gitRepository.commit(signature: gitSignatureForNow, message: message)
|
return try gitRepository.commit(signature: gitSignatureForNow, message: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGPGID(from url: URL) -> String {
|
|
||||||
var path = url
|
|
||||||
while !FileManager.default.fileExists(atPath: path.appendingPathComponent(".gpg-id").path),
|
|
||||||
path.path != "file:///" {
|
|
||||||
path = path.deletingLastPathComponent()
|
|
||||||
}
|
|
||||||
path = path.appendingPathComponent(".gpg-id")
|
|
||||||
|
|
||||||
return (try? String(contentsOf: path))?.trimmed ?? ""
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -31,34 +31,7 @@ final class PGPAgentTest: XCTestCase {
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? {
|
// - MARK: Basic encrypt and decrypt tests
|
||||||
passKit.Defaults.encryptInArmored = encryptInArmored
|
|
||||||
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: keyID)
|
|
||||||
passKit.Defaults.encryptInArmored = decryptFromArmored
|
|
||||||
return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMultiKeys() throws {
|
|
||||||
try [
|
|
||||||
RSA2048_RSA4096,
|
|
||||||
ED25519_NISTP384,
|
|
||||||
].forEach { testKeyInfo in
|
|
||||||
keychain.removeAllContent()
|
|
||||||
try importKeys(testKeyInfo.publicKeys, testKeyInfo.privateKeys)
|
|
||||||
XCTAssert(pgpAgent.isPrepared)
|
|
||||||
try pgpAgent.initKeys()
|
|
||||||
try [
|
|
||||||
(true, true),
|
|
||||||
(true, false),
|
|
||||||
(false, true),
|
|
||||||
(false, false),
|
|
||||||
].forEach { encryptInArmored, decryptFromArmored in
|
|
||||||
for id in testKeyInfo.fingerprints {
|
|
||||||
XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: encryptInArmored, decryptFromArmored: decryptFromArmored), testData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testBasicEncryptDecrypt() throws {
|
func testBasicEncryptDecrypt() throws {
|
||||||
try [
|
try [
|
||||||
|
|
@ -75,7 +48,8 @@ final class PGPAgentTest: XCTestCase {
|
||||||
try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey)
|
try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey)
|
||||||
XCTAssert(pgpAgent.isPrepared)
|
XCTAssert(pgpAgent.isPrepared)
|
||||||
try pgpAgent.initKeys()
|
try pgpAgent.initKeys()
|
||||||
XCTAssert(try pgpAgent.getKeyID().first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
|
XCTAssert(try pgpAgent.getKeyIDs(type: .PUBLIC).first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
|
||||||
|
XCTAssert(try pgpAgent.getKeyIDs(type: .PRIVATE).first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
|
||||||
try [
|
try [
|
||||||
(true, true),
|
(true, true),
|
||||||
(true, false),
|
(true, false),
|
||||||
|
|
@ -161,8 +135,116 @@ final class PGPAgentTest: XCTestCase {
|
||||||
XCTAssertEqual(passphraseRequestCalledCount, 3)
|
XCTAssertEqual(passphraseRequestCalledCount, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMultipleKeysLoaded() throws {
|
||||||
|
try [
|
||||||
|
RSA2048_RSA4096,
|
||||||
|
ED25519_NISTP384,
|
||||||
|
].forEach { testKeyInfo in
|
||||||
|
keychain.removeAllContent()
|
||||||
|
try importKeys(testKeyInfo.publicKeys, testKeyInfo.privateKeys)
|
||||||
|
XCTAssert(pgpAgent.isPrepared)
|
||||||
|
try pgpAgent.initKeys()
|
||||||
|
try [
|
||||||
|
(true, true),
|
||||||
|
(true, false),
|
||||||
|
(false, true),
|
||||||
|
(false, false),
|
||||||
|
].forEach { encryptInArmored, decryptFromArmored in
|
||||||
|
for id in testKeyInfo.fingerprints {
|
||||||
|
XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: encryptInArmored, decryptFromArmored: decryptFromArmored), testData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultiKeysSelectMatchingPrivateKeyToDecrypt() throws {
|
||||||
|
keychain.removeAllContent()
|
||||||
|
try importKeys(RSA2048_RSA4096.publicKeys, RSA2048_RSA4096.privateKeys)
|
||||||
|
try pgpAgent.initKeys()
|
||||||
|
try [
|
||||||
|
(true, true),
|
||||||
|
(true, false),
|
||||||
|
(false, true),
|
||||||
|
(false, false),
|
||||||
|
].forEach { encryptInArmored, decryptFromArmored in
|
||||||
|
passKit.Defaults.encryptInArmored = encryptInArmored
|
||||||
|
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: [RSA2048.fingerprint])
|
||||||
|
passKit.Defaults.encryptInArmored = decryptFromArmored
|
||||||
|
// Note: not specifying the keyID to decrypt, so that the agent needs to find the matching private key by itself.
|
||||||
|
let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
||||||
|
XCTAssertEqual(decryptedData, testData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// - MARK: Encrypt with multiple keys
|
||||||
|
|
||||||
|
func testEncryptWithMultipleKeys() throws {
|
||||||
|
keychain.removeAllContent()
|
||||||
|
// no private key for ED25519
|
||||||
|
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys)
|
||||||
|
try pgpAgent.initKeys()
|
||||||
|
|
||||||
|
XCTAssertEqual(try pgpAgent.getKeyIDs(type: .PUBLIC).map { $0.lowercased() }.sorted(), (RSA2048_RSA4096.longFingerprints + [ED25519.longFingerprint]).sorted())
|
||||||
|
XCTAssertEqual(try pgpAgent.getKeyIDs(type: .PRIVATE).map { $0.lowercased() }.sorted(), RSA2048_RSA4096.longFingerprints.sorted())
|
||||||
|
|
||||||
|
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: RSA2048_RSA4096.fingerprints + [ED25519.fingerprint])
|
||||||
|
|
||||||
|
try [RSA2048.fingerprint, RSA4096.fingerprint].forEach { keyID in
|
||||||
|
let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
||||||
|
XCTAssertEqual(decryptedData, testData)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) {
|
||||||
|
XCTAssertEqual($0 as! AppError, AppError.pgpPrivateKeyNotFound(keyID: ED25519.fingerprint))
|
||||||
|
}
|
||||||
|
|
||||||
|
// load private key for ED25519
|
||||||
|
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys | ED25519.privateKey)
|
||||||
|
try pgpAgent.initKeys()
|
||||||
|
let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
||||||
|
XCTAssertEqual(decryptedData, testData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncryptWithAllKeys() throws {
|
||||||
|
// When multiple keys are imported, the agent should be able to encrypt without specifying the keyID.
|
||||||
|
// It should use all public keys for which we also have private keys, and the encrypted message should be able to be decrypted by any of the private keys.
|
||||||
|
|
||||||
|
keychain.removeAllContent()
|
||||||
|
// no private key for ED25519
|
||||||
|
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys)
|
||||||
|
try pgpAgent.initKeys()
|
||||||
|
|
||||||
|
let encryptedData = try pgpAgent.encryptWithAllKeys(plainData: testData)
|
||||||
|
|
||||||
|
try [RSA2048.fingerprint, RSA4096.fingerprint].forEach { keyID in
|
||||||
|
let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
||||||
|
XCTAssertEqual(decryptedData, testData)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) {
|
||||||
|
XCTAssertEqual($0 as! AppError, AppError.pgpPrivateKeyNotFound(keyID: ED25519.fingerprint))
|
||||||
|
}
|
||||||
|
|
||||||
|
// load private key for ED25519
|
||||||
|
try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys | ED25519.privateKey)
|
||||||
|
try pgpAgent.initKeys()
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) {
|
||||||
|
XCTAssertEqual($0 as! AppError, AppError.keyExpiredOrIncompatible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// - MARK: Helpers
|
||||||
|
|
||||||
private func importKeys(_ publicKey: String, _ privateKey: String) throws {
|
private func importKeys(_ publicKey: String, _ privateKey: String) throws {
|
||||||
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: publicKey)
|
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: publicKey)
|
||||||
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: privateKey)
|
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? {
|
||||||
|
passKit.Defaults.encryptInArmored = encryptInArmored
|
||||||
|
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: [keyID])
|
||||||
|
passKit.Defaults.encryptInArmored = decryptFromArmored
|
||||||
|
return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
xÌ1€ @k^áBˆI(9BíŒÿïÔj»]÷³öl›EUM×™C{ÏlDˆ+=r¢ŠÚKd¬8.4†u Ùy: nç—ˆKyº1F
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
x<01>ŽAjÄ0sÖ+æbF#Y¶ „< Ç|@òŒm‘]ÙHrÀy}ÌB><3E>C_
|
||||||
|
º»¦í~O
ÒS+"@ÆÚyÔÚ…h}`ƒÌÞ±'1èEGãEöP$7ðÔKÄÙEíi¤ØÏyÄñŠ£h£8f«ƒ
|
||||||
|
G[·g
9Ãg ù{;ä^oòÞþH'Çèa0d{DϨÕôðlòÿU×K™<4B>S<EFBFBD>5T ø’³BÊÐ-ûò’BfHÂ’ÚzÄ9ÝRÉS9÷vÕã qk«úõ–cJ
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
c7c52ac6962d08d69e5651eedd6cbaf2f8bd05c3
|
||||||
414
passKitTests/LowLevel/PGPAgentLowLevelTests.swift
Normal file
414
passKitTests/LowLevel/PGPAgentLowLevelTests.swift
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
//
|
||||||
|
// PGPAgentLowLevelTests.swift
|
||||||
|
// passKitTests
|
||||||
|
//
|
||||||
|
// Detailed unit tests tracking the exact API call behavior of PGPAgent.decrypt.
|
||||||
|
// Uses MockPGPInterface to verify what arguments are passed to the underlying
|
||||||
|
// PGPInterface methods, and how passphrase resolution interacts with the keystore
|
||||||
|
// and the requestPGPKeyPassphrase callback.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import passKit
|
||||||
|
|
||||||
|
final class PGPAgentLowLevelTests: XCTestCase {
|
||||||
|
private var keychain: DictBasedKeychain!
|
||||||
|
private var mockPGP: MockPGPInterface!
|
||||||
|
private var agent: PGPAgent!
|
||||||
|
|
||||||
|
private let testEncryptedData = Data("encrypted-payload".utf8)
|
||||||
|
private let testDecryptedData = Data("decrypted-payload".utf8)
|
||||||
|
|
||||||
|
/// Tracks all calls to requestPGPKeyPassphrase closures created via `passphraseCallback(_:)`.
|
||||||
|
private var passphraseRequests: [String] = []
|
||||||
|
|
||||||
|
/// Creates a requestPGPKeyPassphrase closure that records the keyID it's called with
|
||||||
|
/// into `passphraseRequests` and returns `response`.
|
||||||
|
private func passphraseCallback(_ response: String) -> (String) -> String {
|
||||||
|
{ [self] keyID in
|
||||||
|
passphraseRequests.append(keyID)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
|
||||||
|
keychain = DictBasedKeychain()
|
||||||
|
// Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock.
|
||||||
|
keychain.add(string: "dummy", for: Globals.pgpKeyPassphrase)
|
||||||
|
|
||||||
|
mockPGP = MockPGPInterface()
|
||||||
|
// some defaults
|
||||||
|
mockPGP.decryptResult = testDecryptedData
|
||||||
|
mockPGP.encryptResult = Data("mock-encrypted".utf8)
|
||||||
|
|
||||||
|
passphraseRequests = []
|
||||||
|
|
||||||
|
agent = PGPAgent(keyStore: keychain, pgpInterface: mockPGP)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
keychain.removeAllContent()
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Key Resolution
|
||||||
|
|
||||||
|
/// When the private key is found, decrypt is called with the provided keyID.
|
||||||
|
func testDecryptWithKeyID_keyFound_usesProvidedKeyID() throws {
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
|
||||||
|
let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass"))
|
||||||
|
|
||||||
|
XCTAssertEqual(result, testDecryptedData)
|
||||||
|
XCTAssertEqual(mockPGP.decryptCalls.count, 1)
|
||||||
|
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint)
|
||||||
|
XCTAssertEqual(mockPGP.decryptCalls[0].encryptedData, testEncryptedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecryptWithKeyID_keyNotFound_throws() {
|
||||||
|
mockPGP.privateKeyIDs = []
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
|
||||||
|
XCTAssertEqual(error as? AppError, AppError.pgpPrivateKeyNotFound(keyID: "UNKNOWN"))
|
||||||
|
}
|
||||||
|
// pgpInterface.decrypt should NOT have been called
|
||||||
|
XCTAssertEqual(mockPGP.decryptCalls.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// containsPrivateKey is called with the provided keyID to check membership.
|
||||||
|
func testDecryptWithKeyID_checksContainsPrivateKey() throws {
|
||||||
|
let shortID = "a1024dae"
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Passphrase Resolution
|
||||||
|
|
||||||
|
/// On first decrypt (latestDecryptStatus=true), the passphrase is looked up from keystore first.
|
||||||
|
/// If found in keystore, requestPGPKeyPassphrase is NOT called.
|
||||||
|
func testDecryptWithKeyID_firstCall_passphraseFromKeystore() throws {
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
||||||
|
keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("requested-passphrase"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["stored-passphrase"])
|
||||||
|
XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when passphrase is in keystore")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On first decrypt, if keystore doesn't have the passphrase, requestPGPKeyPassphrase is called.
|
||||||
|
/// The keyID passed to requestPGPKeyPassphrase is the (possibly resolved) keyID.
|
||||||
|
func testDecryptWithKeyID_firstCall_passphraseFromRequest() throws {
|
||||||
|
let shortID = "a1024dae"
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
mockPGP.selectedKeyForPassphrase = shortID
|
||||||
|
// No passphrase in keystore for this key.
|
||||||
|
XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)))
|
||||||
|
XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)))
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("my-passphrase"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["my-passphrase"])
|
||||||
|
XCTAssertEqual(passphraseRequests, [shortID])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a failed decrypt (latestDecryptStatus=false), requestPGPKeyPassphrase is ALWAYS called,
|
||||||
|
/// even if the keystore has a cached passphrase.
|
||||||
|
func testDecrypt_afterFailure_alwaysRequestsPassphrase() throws {
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
|
||||||
|
|
||||||
|
// First call: force a failure by making decrypt throw.
|
||||||
|
mockPGP.decryptError = AppError.wrongPassphrase
|
||||||
|
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad")))
|
||||||
|
|
||||||
|
// Now latestDecryptStatus=false. Second call should always request.
|
||||||
|
mockPGP.decryptError = nil
|
||||||
|
mockPGP.decryptCalls.removeAll()
|
||||||
|
mockPGP.resolvedPassphrases.removeAll()
|
||||||
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
||||||
|
passphraseRequests.removeAll()
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh-passphrase"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh-passphrase"])
|
||||||
|
XCTAssertEqual(passphraseRequests, [longFingerprint], "After failure, passphrase should always be requested")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a successful decrypt, the next call uses keystore first (latestDecryptStatus=true).
|
||||||
|
func testDecrypt_afterSuccess_usesKeystoreFirst() throws {
|
||||||
|
let shortID = "a1024dae"
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
|
||||||
|
// First call succeeds.
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass1"))
|
||||||
|
|
||||||
|
// Store a passphrase in keystore under the short ID (matching what PGPAgent used for lookup).
|
||||||
|
keychain.add(string: "pass1", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
|
||||||
|
|
||||||
|
mockPGP.decryptCalls.removeAll()
|
||||||
|
mockPGP.resolvedPassphrases.removeAll()
|
||||||
|
mockPGP.selectedKeyForPassphrase = shortID
|
||||||
|
passphraseRequests.removeAll()
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("ignored-passphrase"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["pass1"])
|
||||||
|
XCTAssertEqual(passphraseRequests, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Return Values & Error Propagation
|
||||||
|
|
||||||
|
/// When pgpInterface.decrypt returns nil, agent.decrypt returns nil.
|
||||||
|
func testDecrypt_interfaceReturnsNil_returnsNil() throws {
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
mockPGP.decryptResult = nil
|
||||||
|
|
||||||
|
let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass"))
|
||||||
|
|
||||||
|
XCTAssertNil(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When pgpInterface.decrypt returns nil, latestDecryptStatus stays false
|
||||||
|
/// (next call will always request passphrase).
|
||||||
|
func testDecrypt_interfaceReturnsNil_statusStaysFalse() throws {
|
||||||
|
let shortID = "d862027e"
|
||||||
|
let longFingerprint = "787eae1a5fa3e749aa34cc6aa0645ebed862027e"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
mockPGP.decryptResult = nil
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
|
||||||
|
|
||||||
|
// Second call - should always request (latestDecryptStatus=false because nil return doesn't set it to true).
|
||||||
|
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
|
||||||
|
keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
|
||||||
|
mockPGP.decryptResult = testDecryptedData
|
||||||
|
mockPGP.decryptCalls.removeAll()
|
||||||
|
mockPGP.resolvedPassphrases.removeAll()
|
||||||
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
||||||
|
passphraseRequests.removeAll()
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
|
||||||
|
XCTAssertEqual(passphraseRequests, [longFingerprint], "After nil return, passphrase should always be requested")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When pgpInterface.decrypt throws, the error propagates and latestDecryptStatus stays false.
|
||||||
|
func testDecrypt_interfaceThrows_propagatesError() throws {
|
||||||
|
let shortID = "a1024dae"
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
mockPGP.decryptError = AppError.wrongPassphrase
|
||||||
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
|
||||||
|
XCTAssertEqual(error as? AppError, AppError.wrongPassphrase)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(passphraseRequests, [longFingerprint])
|
||||||
|
|
||||||
|
// Verify latestDecryptStatus stayed false: next call should always request passphrase,
|
||||||
|
// even though the keystore has one cached.
|
||||||
|
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
|
||||||
|
keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
|
||||||
|
mockPGP.decryptError = nil
|
||||||
|
mockPGP.decryptCalls.removeAll()
|
||||||
|
mockPGP.resolvedPassphrases.removeAll()
|
||||||
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
||||||
|
passphraseRequests.removeAll()
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
|
||||||
|
XCTAssertEqual(passphraseRequests, [longFingerprint], "After throw, passphrase should always be requested (latestDecryptStatus=false)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After successful decrypt, latestDecryptStatus is true.
|
||||||
|
func testDecrypt_success_setsStatusTrue() throws {
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
|
||||||
|
// Force latestDecryptStatus to false first.
|
||||||
|
mockPGP.decryptError = AppError.wrongPassphrase
|
||||||
|
_ = try? agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad"))
|
||||||
|
mockPGP.decryptError = nil
|
||||||
|
mockPGP.decryptCalls.removeAll()
|
||||||
|
passphraseRequests.removeAll()
|
||||||
|
|
||||||
|
// Now succeed.
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("good"))
|
||||||
|
|
||||||
|
// Third call: latestDecryptStatus=true, so should try keystore first.
|
||||||
|
keychain.add(string: "good", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
|
||||||
|
mockPGP.decryptCalls.removeAll()
|
||||||
|
mockPGP.resolvedPassphrases.removeAll()
|
||||||
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
||||||
|
passphraseRequests.removeAll()
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("should-not-use"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["good"])
|
||||||
|
XCTAssertEqual(passphraseRequests, [], "After success, should try keystore first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - checkAndInit behavior
|
||||||
|
|
||||||
|
/// checkAndInit re-initializes if pgpKeyPassphrase is missing from keystore.
|
||||||
|
/// Since we're using a mock as pgpInterface, initKeys would overwrite it; verify the precondition holds.
|
||||||
|
func testDecrypt_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws {
|
||||||
|
// Remove the pgpKeyPassphrase sentinel, which will trigger checkAndInit -> initKeys.
|
||||||
|
keychain.removeContent(for: Globals.pgpKeyPassphrase)
|
||||||
|
// initKeys needs real PGP keys, which we don't have. It should throw keyImport.
|
||||||
|
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "a1024dae", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
|
||||||
|
XCTAssertEqual(error as? AppError, AppError.keyImport)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when checkAndInit fails")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - nil keyID
|
||||||
|
|
||||||
|
/// The no-keyID overload passes nil as keyID to pgpInterface.decrypt
|
||||||
|
func testDecryptNoKeyID_passesNilKeyIDToInterface() throws {
|
||||||
|
let result = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))
|
||||||
|
|
||||||
|
XCTAssertEqual(result, testDecryptedData)
|
||||||
|
XCTAssertEqual(mockPGP.decryptCalls.count, 1)
|
||||||
|
XCTAssertNil(mockPGP.decryptCalls[0].keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The no-keyID overload doesn't check containsPrivateKey.
|
||||||
|
func testDecryptNoKeyID_doesNotCheckPrivateKey() throws {
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.containsPrivateKeyCalls.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key resolution error vs decrypt status ordering
|
||||||
|
|
||||||
|
/// When pgpPrivateKeyNotFound is thrown, latestDecryptStatus is NOT changed because the error occurs BEFORE the status update.
|
||||||
|
func testDecryptWithKeyID_keyNotFound_doesNotChangeDecryptStatus() throws {
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = []
|
||||||
|
|
||||||
|
// This throws pgpPrivateKeyNotFound without changing latestDecryptStatus.
|
||||||
|
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass")))
|
||||||
|
|
||||||
|
// latestDecryptStatus should still be true (initial value).
|
||||||
|
// Next call should try keystore first.
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
keychain.add(string: "cached-pass", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
|
||||||
|
mockPGP.selectedKeyForPassphrase = longFingerprint
|
||||||
|
passphraseRequests.removeAll()
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh"))
|
||||||
|
|
||||||
|
XCTAssertEqual(passphraseRequests, [], "After pgpPrivateKeyNotFound, latestDecryptStatus should be unchanged (still true)")
|
||||||
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached-pass"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Short vs long key ID behavior
|
||||||
|
|
||||||
|
/// When caller passes a short ID and containsPrivateKey matches it (via suffix), the short ID
|
||||||
|
/// is forwarded to pgpInterface.decrypt.
|
||||||
|
func testDecryptWithKeyID_shortIDRecognized_shortIDFlowsThrough() throws {
|
||||||
|
let shortID = "a1024dae"
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID])
|
||||||
|
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, shortID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Passphrase stored under long fingerprint is NOT found when the short ID is used for lookup
|
||||||
|
func testDecryptWithKeyID_shortIDRecognized_passphraseStoredUnderLongID_missesKeystore() throws {
|
||||||
|
let shortID = "a1024dae"
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.privateKeyIDs = [longFingerprint]
|
||||||
|
mockPGP.selectedKeyForPassphrase = shortID
|
||||||
|
|
||||||
|
// Store passphrase under the LONG fingerprint.
|
||||||
|
keychain.add(string: "stored-under-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
|
||||||
|
|
||||||
|
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("from-request"))
|
||||||
|
|
||||||
|
// Backend requests passphrase with short ID — keystore lookup misses, falls through to request.
|
||||||
|
XCTAssertEqual(mockPGP.resolvedPassphrases, ["from-request"])
|
||||||
|
XCTAssertEqual(passphraseRequests, [shortID])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encrypt passthrough tests (for completeness of mock interaction)
|
||||||
|
|
||||||
|
func testEncryptWithKeyIDs_passesThrough() throws {
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.publicKeyIDs = [longFingerprint]
|
||||||
|
|
||||||
|
let result = try agent.encrypt(plainData: testDecryptedData, keyIDs: [longFingerprint])
|
||||||
|
|
||||||
|
XCTAssertEqual(result, mockPGP.encryptResult)
|
||||||
|
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 1)
|
||||||
|
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].keyIDs, [longFingerprint])
|
||||||
|
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].plainData, testDecryptedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// encrypt propagates errors from interface.
|
||||||
|
func testEncryptWithKeyIDs_interfaceThrows_propagatesError() {
|
||||||
|
let shortID = "a1024dae"
|
||||||
|
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
|
||||||
|
mockPGP.publicKeyIDs = [longFingerprint]
|
||||||
|
mockPGP.encryptError = AppError.encryption
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyIDs: [shortID])) { error in
|
||||||
|
XCTAssertEqual(error as? AppError, AppError.encryption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - encryptWithAllKeys
|
||||||
|
|
||||||
|
/// encryptWithAllKeys delegates to pgpInterface.encryptWithAllKeys.
|
||||||
|
func testEncryptWithAllKeys_callsInterface() throws {
|
||||||
|
mockPGP.encryptResult = Data("all-keys-encrypted".utf8)
|
||||||
|
|
||||||
|
let result = try agent.encryptWithAllKeys(plainData: testDecryptedData)
|
||||||
|
|
||||||
|
XCTAssertEqual(result, Data("all-keys-encrypted".utf8))
|
||||||
|
XCTAssertEqual(mockPGP.encryptWithAllKeysCalls.count, 1)
|
||||||
|
XCTAssertEqual(mockPGP.encryptWithAllKeysCalls[0].plainData, testDecryptedData)
|
||||||
|
// Does not call containsPublicKey or the single/multi-key encrypt methods.
|
||||||
|
XCTAssertEqual(mockPGP.containsPublicKeyCalls.count, 0)
|
||||||
|
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// encryptWithAllKeys propagates errors from interface.
|
||||||
|
func testEncryptWithAllKeys_interfaceThrows_propagatesError() {
|
||||||
|
mockPGP.encryptError = AppError.encryption
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in
|
||||||
|
XCTAssertEqual(error as? AppError, AppError.encryption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// encryptWithAllKeys throws keyImport when checkAndInit triggers initKeys without PGP keys.
|
||||||
|
func testEncryptWithAllKeys_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws {
|
||||||
|
keychain.removeContent(for: Globals.pgpKeyPassphrase)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in
|
||||||
|
XCTAssertEqual(error as? AppError, AppError.keyImport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
passKitTests/Mocks/MockPGPInterface.swift
Normal file
101
passKitTests/Mocks/MockPGPInterface.swift
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
//
|
||||||
|
// MockPGPInterface.swift
|
||||||
|
// passKitTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import passKit
|
||||||
|
|
||||||
|
class MockPGPInterface: PGPInterface {
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
var publicKeyIDs: Set<String> = []
|
||||||
|
var privateKeyIDs: Set<String> = []
|
||||||
|
|
||||||
|
var decryptResult: Data?
|
||||||
|
var decryptError: Error?
|
||||||
|
var encryptResult = Data()
|
||||||
|
var encryptError: Error?
|
||||||
|
|
||||||
|
/// When set, the mock calls `passPhraseForKey` with this key ID during `decrypt`,
|
||||||
|
/// simulating the PGP backend selecting a key and requesting its passphrase.
|
||||||
|
var selectedKeyForPassphrase: String?
|
||||||
|
|
||||||
|
// MARK: - Call tracking
|
||||||
|
|
||||||
|
struct DecryptCall {
|
||||||
|
let encryptedData: Data
|
||||||
|
let keyID: String?
|
||||||
|
let passPhraseForKey: (String) -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EncryptCall {
|
||||||
|
let plainData: Data
|
||||||
|
let keyID: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EncryptMultiKeyCall {
|
||||||
|
let plainData: Data
|
||||||
|
let keyIDs: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EncryptWithAllKeysCall {
|
||||||
|
let plainData: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
var decryptCalls: [DecryptCall] = []
|
||||||
|
var resolvedPassphrases: [String] = []
|
||||||
|
var encryptMultiKeyCalls: [EncryptMultiKeyCall] = []
|
||||||
|
var encryptWithAllKeysCalls: [EncryptWithAllKeysCall] = []
|
||||||
|
var containsPublicKeyCalls: [String] = []
|
||||||
|
var containsPrivateKeyCalls: [String] = []
|
||||||
|
|
||||||
|
// MARK: - PGPInterface
|
||||||
|
|
||||||
|
func decrypt(encryptedData: Data, keyIDHint keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
|
||||||
|
decryptCalls.append(DecryptCall(encryptedData: encryptedData, keyID: keyID, passPhraseForKey: passPhraseForKey))
|
||||||
|
if let selectedKey = selectedKeyForPassphrase {
|
||||||
|
resolvedPassphrases.append(passPhraseForKey(selectedKey))
|
||||||
|
}
|
||||||
|
if let error = decryptError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return decryptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptWithAllKeys(plainData: Data) throws -> Data {
|
||||||
|
encryptWithAllKeysCalls.append(EncryptWithAllKeysCall(plainData: plainData))
|
||||||
|
if let error = encryptError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return encryptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
|
||||||
|
encryptMultiKeyCalls.append(EncryptMultiKeyCall(plainData: plainData, keyIDs: keyIDs))
|
||||||
|
if let error = encryptError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return encryptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsPublicKey(with keyID: String) -> Bool {
|
||||||
|
containsPublicKeyCalls.append(keyID)
|
||||||
|
return publicKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsPrivateKey(with keyID: String) -> Bool {
|
||||||
|
containsPrivateKeyCalls.append(keyID)
|
||||||
|
return privateKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func getKeyIDs(type _: PGPKey) -> [String] {
|
||||||
|
// currently irrelevant for the tests
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
|
||||||
|
func getShortKeyIDs(type _: PGPKey) -> [String] {
|
||||||
|
// currently irrelevant for the tests
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,24 +15,46 @@ import XCTest
|
||||||
final class PasswordStoreTest: XCTestCase {
|
final class PasswordStoreTest: XCTestCase {
|
||||||
private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/")
|
private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/")
|
||||||
|
|
||||||
|
private var keyStore: KeyStore! = nil
|
||||||
|
private var pgpAgent: PGPAgent! = nil
|
||||||
private var passwordStore: PasswordStore! = nil
|
private var passwordStore: PasswordStore! = nil
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
passwordStore = PasswordStore(url: localRepoURL)
|
super.setUp()
|
||||||
|
|
||||||
|
keyStore = DictBasedKeychain()
|
||||||
|
pgpAgent = PGPAgent(keyStore: keyStore)
|
||||||
|
passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpMockedPGPInterface() -> MockPGPInterface {
|
||||||
|
let mockPGPInterface = MockPGPInterface()
|
||||||
|
keyStore = DictBasedKeychain()
|
||||||
|
pgpAgent = PGPAgent(keyStore: keyStore, pgpInterface: mockPGPInterface)
|
||||||
|
passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent)
|
||||||
|
|
||||||
|
// Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock.
|
||||||
|
keyStore.add(string: "dummy", for: Globals.pgpKeyPassphrase)
|
||||||
|
|
||||||
|
return mockPGPInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
passwordStore.erase()
|
passwordStore.erase()
|
||||||
passwordStore = nil
|
passwordStore = nil
|
||||||
|
pgpAgent = nil
|
||||||
|
keyStore = nil
|
||||||
|
|
||||||
Defaults.removeAll()
|
Defaults.removeAll()
|
||||||
|
|
||||||
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInitPasswordEntityCoreData() throws {
|
func testInitPasswordEntityCoreData() throws {
|
||||||
try cloneRepository(.withGPGID)
|
try cloneRepository(.withGPGID)
|
||||||
|
|
||||||
XCTAssertEqual(passwordStore.numberOfPasswords, 4)
|
XCTAssertEqual(passwordStore.numberOfPasswords, 4)
|
||||||
XCTAssertEqual(passwordStore.numberOfCommits, 16)
|
XCTAssertEqual(passwordStore.numberOfCommits, 17)
|
||||||
XCTAssertEqual(passwordStore.numberOfLocalCommits, 0)
|
XCTAssertEqual(passwordStore.numberOfLocalCommits, 0)
|
||||||
|
|
||||||
let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")
|
let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")
|
||||||
|
|
@ -75,20 +97,20 @@ final class PasswordStoreTest: XCTestCase {
|
||||||
PasscodeLock.shared.save(passcode: "1234")
|
PasscodeLock.shared.save(passcode: "1234")
|
||||||
|
|
||||||
XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0)
|
XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0)
|
||||||
XCTAssertTrue(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey()))
|
XCTAssertTrue(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey()))
|
||||||
XCTAssertEqual(Defaults.gitSignatureName, "Test User")
|
XCTAssertEqual(Defaults.gitSignatureName, "Test User")
|
||||||
XCTAssertTrue(PasscodeLock.shared.hasPasscode)
|
XCTAssertTrue(PasscodeLock.shared.hasPasscode)
|
||||||
XCTAssertTrue(PGPAgent.shared.isInitialized())
|
XCTAssertTrue(pgpAgent.isInitialized())
|
||||||
|
|
||||||
expectation(forNotification: .passwordStoreUpdated, object: nil)
|
expectation(forNotification: .passwordStoreUpdated, object: nil)
|
||||||
expectation(forNotification: .passwordStoreErased, object: nil)
|
expectation(forNotification: .passwordStoreErased, object: nil)
|
||||||
passwordStore.erase()
|
passwordStore.erase()
|
||||||
|
|
||||||
XCTAssertEqual(passwordStore.numberOfPasswords, 0)
|
XCTAssertEqual(passwordStore.numberOfPasswords, 0)
|
||||||
XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey()))
|
XCTAssertFalse(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey()))
|
||||||
XCTAssertFalse(Defaults.hasKey(\.gitSignatureName))
|
XCTAssertFalse(Defaults.hasKey(\.gitSignatureName))
|
||||||
XCTAssertFalse(PasscodeLock.shared.hasPasscode)
|
XCTAssertFalse(PasscodeLock.shared.hasPasscode)
|
||||||
XCTAssertFalse(PGPAgent.shared.isInitialized())
|
XCTAssertFalse(pgpAgent.isInitialized())
|
||||||
waitForExpectations(timeout: 1, handler: nil)
|
waitForExpectations(timeout: 1, handler: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,32 +332,248 @@ final class PasswordStoreTest: XCTestCase {
|
||||||
waitForExpectations(timeout: 1, handler: nil)
|
waitForExpectations(timeout: 1, handler: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - .gpg-id support
|
// MARK: - Find .gpg-id
|
||||||
|
|
||||||
func testCloneAndDecryptMultiKeys() throws {
|
func testFindGPGIDFile() throws {
|
||||||
try cloneRepository(.withGPGID)
|
try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true)
|
||||||
try importMultiplePGPKeys()
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data("under root".utf8)))
|
||||||
|
|
||||||
Defaults.isEnableGPGIDOn = true
|
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)
|
||||||
("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"),
|
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey"), withIntermediateDirectories: true)
|
||||||
("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"),
|
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path, contents: Data("under hey".utf8)))
|
||||||
].forEach { path, id in
|
|
||||||
let keyID = findGPGID(from: localRepoURL.appendingPathComponent(path))
|
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
||||||
XCTAssertEqual(keyID, id)
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
let personal = try decrypt(path: "personal/github.com.gpg")
|
func testMissingGPGIDFile() throws {
|
||||||
XCTAssertEqual(personal.plainText, "passwordforpersonal\n")
|
XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent(".gpg-id").path))
|
||||||
|
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("subdir"), withIntermediateDirectories: true)
|
||||||
|
|
||||||
let work = try decrypt(path: "work/github.com.gpg")
|
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ""))
|
||||||
XCTAssertEqual(work.plainText, "passwordforwork\n")
|
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)
|
||||||
|
[
|
||||||
|
("", [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
|
||||||
|
|
||||||
let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword")
|
let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword")
|
||||||
let testPasswordEntity = try passwordStore.add(password: testPassword)!
|
_ = try passwordStore.add(password: testPassword)
|
||||||
let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
|
||||||
XCTAssertEqual(testPasswordPlain.plainText, "testpassword")
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
|
||||||
|
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
|
||||||
|
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
|
||||||
|
XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncryptWithSingleKeyViaGPGIDFileInSubDirectory() throws {
|
||||||
|
let mockPGPInterface = setUpMockedPGPInterface()
|
||||||
|
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
|
||||||
|
try cloneRepository(.withGPGID)
|
||||||
|
Defaults.isEnableGPGIDOn = true
|
||||||
|
|
||||||
|
let testPassword = Password(name: "test", path: "family/test.gpg", plainText: "testpassword")
|
||||||
|
_ = try passwordStore.add(password: testPassword)
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
|
||||||
|
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
|
||||||
|
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
|
||||||
|
XCTAssertEqual(encryptCall?.keyIDs, [String(NISTP384.longFingerprint.suffix(16))].map { $0.uppercased() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncryptWithSingleKeyViaGPGIDFileInParentDir() throws {
|
||||||
|
let mockPGPInterface = setUpMockedPGPInterface()
|
||||||
|
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
|
||||||
|
try cloneRepository(.withGPGID)
|
||||||
|
Defaults.isEnableGPGIDOn = true
|
||||||
|
|
||||||
|
// /personal doesn't have its own .gpg-id file, but should inherit from the root .gpg-id file
|
||||||
|
let testPassword = Password(name: "test", path: "personal/test.gpg", plainText: "testpassword")
|
||||||
|
_ = try passwordStore.add(password: testPassword)
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
|
||||||
|
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
|
||||||
|
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
|
||||||
|
XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncryptWithMultipleKeysViaGPGIDFile() throws {
|
||||||
|
let mockPGPInterface = setUpMockedPGPInterface()
|
||||||
|
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
|
||||||
|
try cloneRepository(.withGPGID)
|
||||||
|
Defaults.isEnableGPGIDOn = true
|
||||||
|
|
||||||
|
// /shared uses both RSA2048 and RSA4096
|
||||||
|
let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword")
|
||||||
|
_ = try passwordStore.add(password: testPassword)
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
|
||||||
|
let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
|
||||||
|
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
|
||||||
|
XCTAssertEqual(encryptCall?.keyIDs, RSA2048_RSA4096.longFingerprints.map { $0.uppercased() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncryptWithSingleKeyViaGPGFile_MissingKey() throws {
|
||||||
|
try cloneRepository(.withGPGID)
|
||||||
|
try importSinglePGPKey() // Only import RSA4096, but not RSA2048
|
||||||
|
Defaults.isEnableGPGIDOn = true
|
||||||
|
|
||||||
|
// /work uses RSA2048, but we didn't import that one
|
||||||
|
let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword")
|
||||||
|
XCTAssertThrowsError(try passwordStore.add(password: testPassword)) {
|
||||||
|
XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncryptWithMultipleKeysViaGPGFile_MissingKey() throws {
|
||||||
|
try cloneRepository(.withGPGID)
|
||||||
|
try importSinglePGPKey() // Only import RSA4096, but not RSA2048
|
||||||
|
Defaults.isEnableGPGIDOn = true
|
||||||
|
|
||||||
|
// /shared uses both RSA2048 and RSA4096, but we only imported RSA4096, so encryption should fail since one of the keys is missing
|
||||||
|
let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword")
|
||||||
|
XCTAssertThrowsError(try passwordStore.add(password: testPassword)) {
|
||||||
|
XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGPGIDDisabledIgnoresGPGIDFile() throws {
|
||||||
|
try cloneRepository(.withGPGID)
|
||||||
|
try importSinglePGPKey() // Only import RSA4096, but not RSA2048
|
||||||
|
Defaults.isEnableGPGIDOn = false
|
||||||
|
|
||||||
|
// /work uses RSA2048, but we didn't import that one
|
||||||
|
let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword")
|
||||||
|
// this would throw if isEnableGPGIDOn was true, since we are missing the key to encrypt it
|
||||||
|
_ = try passwordStore.add(password: testPassword)
|
||||||
|
|
||||||
|
// check that we can decrypt it with the key we have, which confirms that it was encrypted without using the .gpg-id file
|
||||||
|
let decryptedPassword = try decrypt(path: "work/test.gpg", keyID: RSA4096.longFingerprint)
|
||||||
|
XCTAssertEqual(decryptedPassword.plainText, "testpassword")
|
||||||
|
|
||||||
|
// we can't even decrypt it with RSA2048
|
||||||
|
try importMultiplePGPKeys()
|
||||||
|
XCTAssertThrowsError(try decrypt(path: "work/test.gpg", keyID: RSA2048.longFingerprint)) {
|
||||||
|
XCTAssertEqual($0 as? AppError, .keyExpiredOrIncompatible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncryptWithExplicitKeyID_OverridesGPGIDFile() throws {
|
||||||
|
continueAfterFailure = false // avoid index out of bounds error below
|
||||||
|
|
||||||
|
let mockPGPInterface = setUpMockedPGPInterface()
|
||||||
|
mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints)
|
||||||
|
try cloneRepository(.withGPGID)
|
||||||
|
Defaults.isEnableGPGIDOn = true
|
||||||
|
|
||||||
|
// Even though /personal would normally use RSA4096 from the root .gpg-id file, if we explicitly specify a key ID then that should be used instead
|
||||||
|
let testPassword1 = Password(name: "test1", path: "personal/test1.gpg", plainText: "testpassword1")
|
||||||
|
_ = try passwordStore.add(password: testPassword1)
|
||||||
|
let testPassword2 = Password(name: "test2", path: "personal/test2.gpg", plainText: "testpassword2")
|
||||||
|
_ = try passwordStore.add(password: testPassword2, keyID: RSA2048.longFingerprint)
|
||||||
|
|
||||||
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 2)
|
||||||
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].plainData, testPassword1.plainData)
|
||||||
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
|
||||||
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].plainData, testPassword2.plainData)
|
||||||
|
XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].keyIDs, [RSA2048.longFingerprint])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
@ -378,17 +616,15 @@ final class PasswordStoreTest: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func importSinglePGPKey() throws {
|
private func importSinglePGPKey() throws {
|
||||||
let keychain = AppKeychain.shared
|
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.publicKey)
|
||||||
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.publicKey)
|
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.privateKey)
|
||||||
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.privateKey)
|
try pgpAgent.initKeys()
|
||||||
try PGPAgent.shared.initKeys()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func importMultiplePGPKeys() throws {
|
private func importMultiplePGPKeys() throws {
|
||||||
let keychain = AppKeychain.shared
|
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.publicKeys)
|
||||||
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys)
|
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.privateKeys)
|
||||||
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys)
|
try pgpAgent.initKeys()
|
||||||
try PGPAgent.shared.initKeys()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decrypt(path: String, keyID: String? = nil) throws -> Password {
|
private func decrypt(path: String, keyID: String? = nil) throws -> Password {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ struct PGPTestSet {
|
||||||
let publicKey: String
|
let publicKey: String
|
||||||
let privateKey: String
|
let privateKey: String
|
||||||
let fingerprint: String
|
let fingerprint: String
|
||||||
|
let longFingerprint: String
|
||||||
let passphrase: String
|
let passphrase: String
|
||||||
|
|
||||||
fileprivate func collect() -> Self { // swiftlint:disable:this strict_fileprivate
|
fileprivate func collect() -> Self { // swiftlint:disable:this strict_fileprivate
|
||||||
|
|
@ -28,6 +29,7 @@ struct MultiKeyPGPTestSet {
|
||||||
let publicKeys: String
|
let publicKeys: String
|
||||||
let privateKeys: String
|
let privateKeys: String
|
||||||
let fingerprints: [String]
|
let fingerprints: [String]
|
||||||
|
let longFingerprints: [String]
|
||||||
let passphrases: [String]
|
let passphrases: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,6 +37,7 @@ let RSA2048 = PGPTestSet(
|
||||||
publicKey: PGP_RSA2048_PUBLIC_KEY,
|
publicKey: PGP_RSA2048_PUBLIC_KEY,
|
||||||
privateKey: PGP_RSA2048_PRIVATE_KEY,
|
privateKey: PGP_RSA2048_PRIVATE_KEY,
|
||||||
fingerprint: "a1024dae",
|
fingerprint: "a1024dae",
|
||||||
|
longFingerprint: "4712286271220db299883ea7062e678da1024dae",
|
||||||
passphrase: "passforios"
|
passphrase: "passforios"
|
||||||
).collect()
|
).collect()
|
||||||
|
|
||||||
|
|
@ -42,6 +45,7 @@ let RSA2048_SUB = PGPTestSet(
|
||||||
publicKey: PGP_RSA2048_PUBLIC_KEY,
|
publicKey: PGP_RSA2048_PUBLIC_KEY,
|
||||||
privateKey: PGP_RSA2048_PRIVATE_SUBKEY,
|
privateKey: PGP_RSA2048_PRIVATE_SUBKEY,
|
||||||
fingerprint: "a1024dae",
|
fingerprint: "a1024dae",
|
||||||
|
longFingerprint: "4712286271220db299883ea7062e678da1024dae",
|
||||||
passphrase: "passforios"
|
passphrase: "passforios"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -49,6 +53,7 @@ let RSA3072_NO_PASSPHRASE = PGPTestSet(
|
||||||
publicKey: PGP_RSA3072_PUBLIC_KEY_NO_PASSPHRASE,
|
publicKey: PGP_RSA3072_PUBLIC_KEY_NO_PASSPHRASE,
|
||||||
privateKey: PGP_RSA3072_PRIVATE_KEY_NO_PASSPHRASE,
|
privateKey: PGP_RSA3072_PRIVATE_KEY_NO_PASSPHRASE,
|
||||||
fingerprint: "be0f9402",
|
fingerprint: "be0f9402",
|
||||||
|
longFingerprint: "b37cd5669a03f0d46735a2ba35fba3d0be0f9402",
|
||||||
passphrase: ""
|
passphrase: ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -56,6 +61,7 @@ let RSA4096 = PGPTestSet(
|
||||||
publicKey: PGP_RSA4096_PUBLIC_KEY,
|
publicKey: PGP_RSA4096_PUBLIC_KEY,
|
||||||
privateKey: PGP_RSA4096_PRIVATE_KEY,
|
privateKey: PGP_RSA4096_PRIVATE_KEY,
|
||||||
fingerprint: "d862027e",
|
fingerprint: "d862027e",
|
||||||
|
longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e",
|
||||||
passphrase: "passforios"
|
passphrase: "passforios"
|
||||||
).collect()
|
).collect()
|
||||||
|
|
||||||
|
|
@ -63,6 +69,7 @@ let RSA4096_SUB = PGPTestSet(
|
||||||
publicKey: PGP_RSA4096_PUBLIC_KEY,
|
publicKey: PGP_RSA4096_PUBLIC_KEY,
|
||||||
privateKey: PGP_RSA4096_PRIVATE_SUBKEY,
|
privateKey: PGP_RSA4096_PRIVATE_SUBKEY,
|
||||||
fingerprint: "d862027e",
|
fingerprint: "d862027e",
|
||||||
|
longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e",
|
||||||
passphrase: "passforios"
|
passphrase: "passforios"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -70,6 +77,7 @@ let ED25519 = PGPTestSet(
|
||||||
publicKey: PGP_ED25519_PUBLIC_KEY,
|
publicKey: PGP_ED25519_PUBLIC_KEY,
|
||||||
privateKey: PGP_ED25519_PRIVATE_KEY,
|
privateKey: PGP_ED25519_PRIVATE_KEY,
|
||||||
fingerprint: "e9444483",
|
fingerprint: "e9444483",
|
||||||
|
longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483",
|
||||||
passphrase: "passforios"
|
passphrase: "passforios"
|
||||||
).collect()
|
).collect()
|
||||||
|
|
||||||
|
|
@ -77,6 +85,7 @@ let ED25519_SUB = PGPTestSet(
|
||||||
publicKey: PGP_ED25519_PUBLIC_KEY,
|
publicKey: PGP_ED25519_PUBLIC_KEY,
|
||||||
privateKey: PGP_ED25519_PRIVATE_SUBKEY,
|
privateKey: PGP_ED25519_PRIVATE_SUBKEY,
|
||||||
fingerprint: "e9444483",
|
fingerprint: "e9444483",
|
||||||
|
longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483",
|
||||||
passphrase: "passforios"
|
passphrase: "passforios"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -84,6 +93,7 @@ let NISTP384 = PGPTestSet(
|
||||||
publicKey: PGP_NISTP384_PUBLIC_KEY,
|
publicKey: PGP_NISTP384_PUBLIC_KEY,
|
||||||
privateKey: PGP_NISTP384_PRIVATE_KEY,
|
privateKey: PGP_NISTP384_PRIVATE_KEY,
|
||||||
fingerprint: "5af3c085",
|
fingerprint: "5af3c085",
|
||||||
|
longFingerprint: "bcd364c078585c0607e19c67171c07d25af3c085",
|
||||||
passphrase: "soirofssap"
|
passphrase: "soirofssap"
|
||||||
).collect()
|
).collect()
|
||||||
|
|
||||||
|
|
@ -91,6 +101,7 @@ let RSA2048_RSA4096 = MultiKeyPGPTestSet(
|
||||||
publicKeys: PGP_RSA2048_PUBLIC_KEY | PGP_RSA4096_PUBLIC_KEY,
|
publicKeys: PGP_RSA2048_PUBLIC_KEY | PGP_RSA4096_PUBLIC_KEY,
|
||||||
privateKeys: PGP_RSA2048_PRIVATE_KEY | PGP_RSA4096_PRIVATE_KEY,
|
privateKeys: PGP_RSA2048_PRIVATE_KEY | PGP_RSA4096_PRIVATE_KEY,
|
||||||
fingerprints: ["a1024dae", "d862027e"],
|
fingerprints: ["a1024dae", "d862027e"],
|
||||||
|
longFingerprints: ["4712286271220db299883ea7062e678da1024dae", "787eae1a5fa3e749aa34cc6aa0645ebed862027e"],
|
||||||
passphrases: ["passforios", "passforios"]
|
passphrases: ["passforios", "passforios"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -98,6 +109,7 @@ let ED25519_NISTP384 = MultiKeyPGPTestSet(
|
||||||
publicKeys: PGP_ED25519_PUBLIC_KEY | PGP_NISTP384_PUBLIC_KEY,
|
publicKeys: PGP_ED25519_PUBLIC_KEY | PGP_NISTP384_PUBLIC_KEY,
|
||||||
privateKeys: PGP_ED25519_PRIVATE_KEY | PGP_NISTP384_PRIVATE_KEY,
|
privateKeys: PGP_ED25519_PRIVATE_KEY | PGP_NISTP384_PRIVATE_KEY,
|
||||||
fingerprints: ["e9444483", "5af3c085"],
|
fingerprints: ["e9444483", "5af3c085"],
|
||||||
|
longFingerprints: ["5fccb081ab8af48972999e2ae750acbfe9444483", "bcd364c078585c0607e19c67171c07d25af3c085"],
|
||||||
passphrases: ["passforios", "soirofssap"]
|
passphrases: ["passforios", "soirofssap"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ The codebase does **not** support encrypting to multiple public keys. Every laye
|
||||||
|
|
||||||
### 1. `findGPGID(from:) -> [String]`
|
### 1. `findGPGID(from:) -> [String]`
|
||||||
|
|
||||||
Split file contents by newline, trim each line, filter empty lines. Return array of key IDs. Callers that only need a single key (e.g. for decryption routing) can use `.first`.
|
Split file contents by newline, trim each line, filter empty lines. Return array of key IDs.
|
||||||
|
|
||||||
### 2. `PGPInterface` protocol
|
### 2. `PGPInterface` protocol
|
||||||
|
|
||||||
|
|
@ -60,10 +60,10 @@ When a store lists multiple key IDs in `.gpg-id`, the user needs the public keys
|
||||||
|
|
||||||
### Current state
|
### Current state
|
||||||
|
|
||||||
- The keychain holds exactly **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob.
|
- The keychain holds **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob, but each can contain **multiple concatenated armored key blocks**. Both interface implementations parse all keys from these blobs.
|
||||||
- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the previous key pair entirely.
|
- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the set of keys entirely, there is no append mode for adding additional keys or managing existing keys.
|
||||||
- Both `GopenPGPInterface` and `ObjectivePGPInterface` *can* parse multiple keys from a single armored blob (e.g. concatenated armor blocks). So if the user pastes multiple public keys into the single field, they would be parsed — but the encrypt path only uses one key, and the UI doesn't communicate this.
|
- There is no UI for viewing which key IDs are loaded or for importing additional recipient-only public keys, nor for viewing the key metadata.
|
||||||
- There is no UI for viewing which key IDs are loaded.
|
- There is no UI for viewing or editing `.gpg-id` files, which are the source of truth for which keys are used for encryption.
|
||||||
|
|
||||||
### Key storage approach
|
### Key storage approach
|
||||||
|
|
||||||
|
|
@ -142,20 +142,19 @@ This can be expensive for large directories. Show progress and allow cancellatio
|
||||||
|
|
||||||
## Implementation Order
|
## Implementation Order
|
||||||
|
|
||||||
| Step | Description | Depends On |
|
| Step | Description | Status | Depends On |
|
||||||
|------|-------------|------------|
|
|------|-------------|--------|------------|
|
||||||
| 1 | `findGPGID` returns `[String]` + update callers | — |
|
| 1 | `findGPGIDs` returns `[String]` + update callers | ✅ Done | — |
|
||||||
| 2 | `PGPInterface` protocol change (`keyIDs: [String]?`) | — |
|
| 2 | `PGPInterface` protocol change (`keyIDs: [String]`) | ✅ Done | — |
|
||||||
| 3 | `GopenPGPInterface` multi-key encryption | Step 2 |
|
| 3 | `GopenPGPInterface` multi-key encryption | ✅ Done | Step 2 |
|
||||||
| 4 | `ObjectivePGPInterface` multi-key encryption | Step 2 |
|
| 4 | `ObjectivePGPInterface` multi-key encryption | ✅ Done | Step 2 |
|
||||||
| 5 | `PGPAgent` updated overloads | Steps 2-4 |
|
| 5 | `PGPAgent` updated overloads | ✅ Done | Steps 2-4 |
|
||||||
| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGID` | Steps 1+5 |
|
| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGIDs` | ✅ Done | Steps 1+5 |
|
||||||
| 7 | UI: import additional recipient public keys | Step 5 |
|
| 7 | UI: import additional recipient public keys | Not started | Step 5 |
|
||||||
| 8 | UI: view loaded key IDs | Step 5 |
|
| 8 | UI: view loaded key IDs and metadata | Not started | Step 5 |
|
||||||
| 9a | UI: view `.gpg-id` in password detail / folder view | Step 1 |
|
| 9a | UI: view `.gpg-id` in password detail / folder view | Not started | Step 1 |
|
||||||
| 9b | UI: edit `.gpg-id` | Step 9a |
|
| 9b | UI: edit `.gpg-id` | Not started | Step 9a |
|
||||||
| 10 | Re-encryption when `.gpg-id` changes | Steps 6+9b |
|
| 10 | Re-encryption when `.gpg-id` changes | Not started | Steps 6+9b |
|
||||||
| T | Tests (see testing section) | Steps 1-10 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ GOPENPGP_PATH="$CHECKOUT_PATH/gopenpgp"
|
||||||
mkdir -p "$OUTPUT_PATH"
|
mkdir -p "$OUTPUT_PATH"
|
||||||
mkdir -p "$CHECKOUT_PATH"
|
mkdir -p "$CHECKOUT_PATH"
|
||||||
|
|
||||||
git clone --depth 1 --branch "$GOPENPGP_VERSION" https://github.com/mssun/gopenpgp.git "$GOPENPGP_PATH"
|
git clone --depth 1 --branch "$GOPENPGP_VERSION" https://forgejo.tranvouez.eu/lysann/passforios-gopenpgp.git "$GOPENPGP_PATH"
|
||||||
|
|
||||||
pushd "$GOPENPGP_PATH"
|
pushd "$GOPENPGP_PATH"
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue