Compare commits

..

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

24 changed files with 223 additions and 1168 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,6 @@ struct GopenPGPInterface: PGPInterface {
private var publicKeys: [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 {
let pubKeys = extractKeysFromArmored(str: publicArmoredKey)
@ -41,24 +40,7 @@ struct GopenPGPInterface: PGPInterface {
}
throw AppError.keyImport
}
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
}
privateKeys[cryptoKey.getFingerprint().lowercased()] = cryptoKey
}
}
@ -88,13 +70,15 @@ struct GopenPGPInterface: PGPInterface {
privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) }
}
func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
let message = createPGPMessage(from: encryptedData)
guard let message else {
throw AppError.decryption
}
func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? {
let key: CryptoKey? = {
if let keyID {
return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value
}
return privateKeys.first?.value
}()
guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else {
guard let privateKey = key else {
throw AppError.decryption
}
@ -103,7 +87,6 @@ struct GopenPGPInterface: PGPInterface {
try privateKey.isLocked(&isLocked)
var unlockedKey: CryptoKey!
if isLocked.boolValue {
let passphrase = passPhraseForKey(privateKey.getFingerprint())
unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8))
} else {
unlockedKey = privateKey
@ -117,43 +100,33 @@ struct GopenPGPInterface: PGPInterface {
throw AppError.decryption
}
let message = createPGPMessage(from: encryptedData)
return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data
} catch {
throw Self.errorMapping[error.localizedDescription, default: error]
}
}
func encryptWithAllKeys(plainData: Data) throws -> Data {
let keyIDs = publicKeys.keys.filter { key in privateKeys.keys.contains(key) }
return try encrypt(plainData: plainData, keyIDs: keyIDs)
}
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)
func encrypt(plainData: Data, keyID: String?) throws -> Data {
let key: CryptoKey? = {
if let keyID {
return publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value
}
return key
}
guard let firstKey = keys.first else {
return publicKeys.first?.value
}()
guard let publicKey = key else {
throw AppError.encryption
}
let otherKeys = keys.dropFirst()
var error: NSError?
guard let keyRing = CryptoNewKeyRing(firstKey, &error) else {
guard let keyRing = CryptoNewKeyRing(publicKey, &error) else {
guard error == nil else {
throw error!
}
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)
if Defaults.encryptInArmored {
@ -167,44 +140,12 @@ struct GopenPGPInterface: PGPInterface {
return encryptedData.getBinary()!
}
func getKeyIDs(type: PGPKey) -> [String] {
switch type {
case .PUBLIC:
return publicKeys.keys.map { $0.uppercased() }
case .PRIVATE:
return privateKeys.keys.map { $0.uppercased() }
}
var keyID: [String] {
publicKeys.keys.map { $0.uppercased() }
}
func getShortKeyIDs(type: PGPKey) -> [String] {
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]
var shortKeyID: [String] {
publicKeys.keys.map { $0.suffix(8).uppercased() }
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -23,7 +23,6 @@ public class PasswordStore {
}()
public var storeURL: URL
private let pgpAgent: PGPAgent
public var gitRepository: GitRepository?
@ -85,9 +84,8 @@ public class PasswordStore {
gitRepository?.numberOfCommits()
}
init(url: URL = Globals.repositoryURL, pgpAgent: PGPAgent = .shared) {
init(url: URL = Globals.repositoryURL) {
self.storeURL = url
self.pgpAgent = pgpAgent
// Migration
importExistingKeysIntoKeychain()
@ -361,14 +359,14 @@ public class PasswordStore {
eraseStoreData()
// Delete PGP key, SSH key and other secrets from the keychain.
pgpAgent.keyStore.removeAllContent()
AppKeychain.shared.removeAllContent()
// Delete default settings.
Defaults.removeAll()
// Delete cache explicitly.
PasscodeLock.shared.delete()
pgpAgent.uninitKeys()
PGPAgent.shared.uninitKeys()
}
// return the number of discarded commits
@ -397,7 +395,13 @@ public class PasswordStore {
public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
let url = passwordEntity.fileURL(in: storeURL)
let encryptedData = try Data(contentsOf: url)
let data: Data? = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
let data: Data? = try {
if Defaults.isEnableGPGIDOn {
let keyID = keyID ?? findGPGID(from: url)
return try PGPAgent.shared.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
}
return try PGPAgent.shared.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
}()
guard let decryptedData = data else {
throw AppError.decryption
}
@ -405,20 +409,23 @@ public class PasswordStore {
return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText)
}
public func encrypt(password: Password, keyID: String? = nil) throws -> Data {
let keyIDs: [String] = {
if let keyID {
return [keyID]
}
if Defaults.isEnableGPGIDOn {
return findGPGIDs(underPath: password.path)
}
return []
}()
if !keyIDs.isEmpty {
return try pgpAgent.encrypt(plainData: password.plainData, keyIDs: keyIDs)
public func decrypt(path: String, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
guard let passwordEntity = fetchPasswordEntity(with: path) else {
throw AppError.decryption
}
return try pgpAgent.encryptWithAllKeys(plainData: password.plainData)
if Defaults.isEnableGPGIDOn {
return try decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
}
return try decrypt(passwordEntity: passwordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
}
public func encrypt(password: Password, keyID: String? = nil) throws -> Data {
let encryptedDataPath = password.fileURL(in: storeURL)
let keyID = keyID ?? findGPGID(from: encryptedDataPath)
if Defaults.isEnableGPGIDOn {
return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID)
}
return try PGPAgent.shared.encrypt(plainData: password.plainData)
}
public func removeGitSSHKeys() {
@ -429,37 +436,6 @@ public class PasswordStore {
AppKeychain.shared.removeContent(for: SSHKey.PRIVATE.getKeychainKey())
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 {
@ -492,3 +468,14 @@ extension PasswordStore {
return try gitRepository.commit(signature: gitSignatureForNow, message: message)
}
}
func findGPGID(from url: URL) -> String {
var path = url
while !FileManager.default.fileExists(atPath: path.appendingPathComponent(".gpg-id").path),
path.path != "file:///" {
path = path.deletingLastPathComponent()
}
path = path.appendingPathComponent(".gpg-id")
return (try? String(contentsOf: path))?.trimmed ?? ""
}

View file

@ -31,7 +31,34 @@ final class PGPAgentTest: XCTestCase {
super.tearDown()
}
// - MARK: Basic encrypt and decrypt tests
private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? {
passKit.Defaults.encryptInArmored = encryptInArmored
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: keyID)
passKit.Defaults.encryptInArmored = decryptFromArmored
return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase)
}
func testMultiKeys() throws {
try [
RSA2048_RSA4096,
ED25519_NISTP384,
].forEach { testKeyInfo in
keychain.removeAllContent()
try importKeys(testKeyInfo.publicKeys, testKeyInfo.privateKeys)
XCTAssert(pgpAgent.isPrepared)
try pgpAgent.initKeys()
try [
(true, true),
(true, false),
(false, true),
(false, false),
].forEach { encryptInArmored, decryptFromArmored in
for id in testKeyInfo.fingerprints {
XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: encryptInArmored, decryptFromArmored: decryptFromArmored), testData)
}
}
}
}
func testBasicEncryptDecrypt() throws {
try [
@ -48,8 +75,7 @@ final class PGPAgentTest: XCTestCase {
try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey)
XCTAssert(pgpAgent.isPrepared)
try pgpAgent.initKeys()
XCTAssert(try pgpAgent.getKeyIDs(type: .PUBLIC).first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
XCTAssert(try pgpAgent.getKeyIDs(type: .PRIVATE).first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
XCTAssert(try pgpAgent.getKeyID().first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
try [
(true, true),
(true, false),
@ -135,116 +161,8 @@ final class PGPAgentTest: XCTestCase {
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 {
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: publicKey)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: privateKey)
}
private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? {
passKit.Defaults.encryptInArmored = encryptInArmored
let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: [keyID])
passKit.Defaults.encryptInArmored = decryptFromArmored
return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase)
}
}

View file

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

View file

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

View file

@ -1 +0,0 @@
c7c52ac6962d08d69e5651eedd6cbaf2f8bd05c3

View file

@ -1,414 +0,0 @@
//
// PGPAgentLowLevelTests.swift
// passKitTests
//
// Detailed unit tests tracking the exact API call behavior of PGPAgent.decrypt.
// Uses MockPGPInterface to verify what arguments are passed to the underlying
// PGPInterface methods, and how passphrase resolution interacts with the keystore
// and the requestPGPKeyPassphrase callback.
//
import XCTest
@testable import passKit
final class PGPAgentLowLevelTests: XCTestCase {
private var keychain: DictBasedKeychain!
private var mockPGP: MockPGPInterface!
private var agent: PGPAgent!
private let testEncryptedData = Data("encrypted-payload".utf8)
private let testDecryptedData = Data("decrypted-payload".utf8)
/// Tracks all calls to requestPGPKeyPassphrase closures created via `passphraseCallback(_:)`.
private var passphraseRequests: [String] = []
/// Creates a requestPGPKeyPassphrase closure that records the keyID it's called with
/// into `passphraseRequests` and returns `response`.
private func passphraseCallback(_ response: String) -> (String) -> String {
{ [self] keyID in
passphraseRequests.append(keyID)
return response
}
}
override func setUp() {
super.setUp()
keychain = DictBasedKeychain()
// Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock.
keychain.add(string: "dummy", for: Globals.pgpKeyPassphrase)
mockPGP = MockPGPInterface()
// some defaults
mockPGP.decryptResult = testDecryptedData
mockPGP.encryptResult = Data("mock-encrypted".utf8)
passphraseRequests = []
agent = PGPAgent(keyStore: keychain, pgpInterface: mockPGP)
}
override func tearDown() {
keychain.removeAllContent()
super.tearDown()
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Key Resolution
/// When the private key is found, decrypt is called with the provided keyID.
func testDecryptWithKeyID_keyFound_usesProvidedKeyID() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(result, testDecryptedData)
XCTAssertEqual(mockPGP.decryptCalls.count, 1)
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint)
XCTAssertEqual(mockPGP.decryptCalls[0].encryptedData, testEncryptedData)
}
func testDecryptWithKeyID_keyNotFound_throws() {
mockPGP.privateKeyIDs = []
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
XCTAssertEqual(error as? AppError, AppError.pgpPrivateKeyNotFound(keyID: "UNKNOWN"))
}
// pgpInterface.decrypt should NOT have been called
XCTAssertEqual(mockPGP.decryptCalls.count, 0)
}
/// containsPrivateKey is called with the provided keyID to check membership.
func testDecryptWithKeyID_checksContainsPrivateKey() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID])
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Passphrase Resolution
/// On first decrypt (latestDecryptStatus=true), the passphrase is looked up from keystore first.
/// If found in keystore, requestPGPKeyPassphrase is NOT called.
func testDecryptWithKeyID_firstCall_passphraseFromKeystore() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.selectedKeyForPassphrase = longFingerprint
keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("requested-passphrase"))
XCTAssertEqual(mockPGP.resolvedPassphrases, ["stored-passphrase"])
XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when passphrase is in keystore")
}
/// On first decrypt, if keystore doesn't have the passphrase, requestPGPKeyPassphrase is called.
/// The keyID passed to requestPGPKeyPassphrase is the (possibly resolved) keyID.
func testDecryptWithKeyID_firstCall_passphraseFromRequest() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.selectedKeyForPassphrase = shortID
// No passphrase in keystore for this key.
XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)))
XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)))
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("my-passphrase"))
XCTAssertEqual(mockPGP.resolvedPassphrases, ["my-passphrase"])
XCTAssertEqual(passphraseRequests, [shortID])
}
/// After a failed decrypt (latestDecryptStatus=false), requestPGPKeyPassphrase is ALWAYS called,
/// even if the keystore has a cached passphrase.
func testDecrypt_afterFailure_alwaysRequestsPassphrase() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
// First call: force a failure by making decrypt throw.
mockPGP.decryptError = AppError.wrongPassphrase
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad")))
// Now latestDecryptStatus=false. Second call should always request.
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
mockPGP.resolvedPassphrases.removeAll()
mockPGP.selectedKeyForPassphrase = longFingerprint
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh-passphrase"))
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh-passphrase"])
XCTAssertEqual(passphraseRequests, [longFingerprint], "After failure, passphrase should always be requested")
}
/// After a successful decrypt, the next call uses keystore first (latestDecryptStatus=true).
func testDecrypt_afterSuccess_usesKeystoreFirst() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
// First call succeeds.
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass1"))
// Store a passphrase in keystore under the short ID (matching what PGPAgent used for lookup).
keychain.add(string: "pass1", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
mockPGP.decryptCalls.removeAll()
mockPGP.resolvedPassphrases.removeAll()
mockPGP.selectedKeyForPassphrase = shortID
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("ignored-passphrase"))
XCTAssertEqual(mockPGP.resolvedPassphrases, ["pass1"])
XCTAssertEqual(passphraseRequests, [])
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Return Values & Error Propagation
/// When pgpInterface.decrypt returns nil, agent.decrypt returns nil.
func testDecrypt_interfaceReturnsNil_returnsNil() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.decryptResult = nil
let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertNil(result)
}
/// When pgpInterface.decrypt returns nil, latestDecryptStatus stays false
/// (next call will always request passphrase).
func testDecrypt_interfaceReturnsNil_statusStaysFalse() throws {
let shortID = "d862027e"
let longFingerprint = "787eae1a5fa3e749aa34cc6aa0645ebed862027e"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.decryptResult = nil
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
// Second call - should always request (latestDecryptStatus=false because nil return doesn't set it to true).
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
mockPGP.decryptResult = testDecryptedData
mockPGP.decryptCalls.removeAll()
mockPGP.resolvedPassphrases.removeAll()
mockPGP.selectedKeyForPassphrase = longFingerprint
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
XCTAssertEqual(passphraseRequests, [longFingerprint], "After nil return, passphrase should always be requested")
}
/// When pgpInterface.decrypt throws, the error propagates and latestDecryptStatus stays false.
func testDecrypt_interfaceThrows_propagatesError() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.decryptError = AppError.wrongPassphrase
mockPGP.selectedKeyForPassphrase = longFingerprint
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
XCTAssertEqual(error as? AppError, AppError.wrongPassphrase)
}
XCTAssertEqual(passphraseRequests, [longFingerprint])
// Verify latestDecryptStatus stayed false: next call should always request passphrase,
// even though the keystore has one cached.
keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))
keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
mockPGP.resolvedPassphrases.removeAll()
mockPGP.selectedKeyForPassphrase = longFingerprint
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"])
XCTAssertEqual(passphraseRequests, [longFingerprint], "After throw, passphrase should always be requested (latestDecryptStatus=false)")
}
/// After successful decrypt, latestDecryptStatus is true.
func testDecrypt_success_setsStatusTrue() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
// Force latestDecryptStatus to false first.
mockPGP.decryptError = AppError.wrongPassphrase
_ = try? agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad"))
mockPGP.decryptError = nil
mockPGP.decryptCalls.removeAll()
passphraseRequests.removeAll()
// Now succeed.
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("good"))
// Third call: latestDecryptStatus=true, so should try keystore first.
keychain.add(string: "good", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
mockPGP.decryptCalls.removeAll()
mockPGP.resolvedPassphrases.removeAll()
mockPGP.selectedKeyForPassphrase = longFingerprint
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("should-not-use"))
XCTAssertEqual(mockPGP.resolvedPassphrases, ["good"])
XCTAssertEqual(passphraseRequests, [], "After success, should try keystore first")
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - checkAndInit behavior
/// checkAndInit re-initializes if pgpKeyPassphrase is missing from keystore.
/// Since we're using a mock as pgpInterface, initKeys would overwrite it; verify the precondition holds.
func testDecrypt_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws {
// Remove the pgpKeyPassphrase sentinel, which will trigger checkAndInit -> initKeys.
keychain.removeContent(for: Globals.pgpKeyPassphrase)
// initKeys needs real PGP keys, which we don't have. It should throw keyImport.
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "a1024dae", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in
XCTAssertEqual(error as? AppError, AppError.keyImport)
}
XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when checkAndInit fails")
}
// MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - nil keyID
/// The no-keyID overload passes nil as keyID to pgpInterface.decrypt
func testDecryptNoKeyID_passesNilKeyIDToInterface() throws {
let result = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(result, testDecryptedData)
XCTAssertEqual(mockPGP.decryptCalls.count, 1)
XCTAssertNil(mockPGP.decryptCalls[0].keyID)
}
/// The no-keyID overload doesn't check containsPrivateKey.
func testDecryptNoKeyID_doesNotCheckPrivateKey() throws {
_ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(mockPGP.containsPrivateKeyCalls.count, 0)
}
// MARK: - Key resolution error vs decrypt status ordering
/// When pgpPrivateKeyNotFound is thrown, latestDecryptStatus is NOT changed because the error occurs BEFORE the status update.
func testDecryptWithKeyID_keyNotFound_doesNotChangeDecryptStatus() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = []
// This throws pgpPrivateKeyNotFound without changing latestDecryptStatus.
XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass")))
// latestDecryptStatus should still be true (initial value).
// Next call should try keystore first.
mockPGP.privateKeyIDs = [longFingerprint]
keychain.add(string: "cached-pass", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
mockPGP.selectedKeyForPassphrase = longFingerprint
passphraseRequests.removeAll()
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh"))
XCTAssertEqual(passphraseRequests, [], "After pgpPrivateKeyNotFound, latestDecryptStatus should be unchanged (still true)")
XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached-pass"])
}
// MARK: - Short vs long key ID behavior
/// When caller passes a short ID and containsPrivateKey matches it (via suffix), the short ID
/// is forwarded to pgpInterface.decrypt.
func testDecryptWithKeyID_shortIDRecognized_shortIDFlowsThrough() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))
XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID])
XCTAssertEqual(mockPGP.decryptCalls[0].keyID, shortID)
}
/// Passphrase stored under long fingerprint is NOT found when the short ID is used for lookup
func testDecryptWithKeyID_shortIDRecognized_passphraseStoredUnderLongID_missesKeystore() throws {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.privateKeyIDs = [longFingerprint]
mockPGP.selectedKeyForPassphrase = shortID
// Store passphrase under the LONG fingerprint.
keychain.add(string: "stored-under-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))
_ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("from-request"))
// Backend requests passphrase with short ID keystore lookup misses, falls through to request.
XCTAssertEqual(mockPGP.resolvedPassphrases, ["from-request"])
XCTAssertEqual(passphraseRequests, [shortID])
}
// MARK: - Encrypt passthrough tests (for completeness of mock interaction)
func testEncryptWithKeyIDs_passesThrough() throws {
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = [longFingerprint]
let result = try agent.encrypt(plainData: testDecryptedData, keyIDs: [longFingerprint])
XCTAssertEqual(result, mockPGP.encryptResult)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 1)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].keyIDs, [longFingerprint])
XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].plainData, testDecryptedData)
}
/// encrypt propagates errors from interface.
func testEncryptWithKeyIDs_interfaceThrows_propagatesError() {
let shortID = "a1024dae"
let longFingerprint = "4712286271220db299883ea7062e678da1024dae"
mockPGP.publicKeyIDs = [longFingerprint]
mockPGP.encryptError = AppError.encryption
XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyIDs: [shortID])) { error in
XCTAssertEqual(error as? AppError, AppError.encryption)
}
}
// MARK: - encryptWithAllKeys
/// encryptWithAllKeys delegates to pgpInterface.encryptWithAllKeys.
func testEncryptWithAllKeys_callsInterface() throws {
mockPGP.encryptResult = Data("all-keys-encrypted".utf8)
let result = try agent.encryptWithAllKeys(plainData: testDecryptedData)
XCTAssertEqual(result, Data("all-keys-encrypted".utf8))
XCTAssertEqual(mockPGP.encryptWithAllKeysCalls.count, 1)
XCTAssertEqual(mockPGP.encryptWithAllKeysCalls[0].plainData, testDecryptedData)
// Does not call containsPublicKey or the single/multi-key encrypt methods.
XCTAssertEqual(mockPGP.containsPublicKeyCalls.count, 0)
XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 0)
}
/// encryptWithAllKeys propagates errors from interface.
func testEncryptWithAllKeys_interfaceThrows_propagatesError() {
mockPGP.encryptError = AppError.encryption
XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in
XCTAssertEqual(error as? AppError, AppError.encryption)
}
}
/// encryptWithAllKeys throws keyImport when checkAndInit triggers initKeys without PGP keys.
func testEncryptWithAllKeys_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws {
keychain.removeContent(for: Globals.pgpKeyPassphrase)
XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in
XCTAssertEqual(error as? AppError, AppError.keyImport)
}
}
}

View file

@ -1,101 +0,0 @@
//
// MockPGPInterface.swift
// passKitTests
//
import Foundation
@testable import passKit
class MockPGPInterface: PGPInterface {
// MARK: - Configuration
var publicKeyIDs: Set<String> = []
var privateKeyIDs: Set<String> = []
var decryptResult: Data?
var decryptError: Error?
var encryptResult = Data()
var encryptError: Error?
/// When set, the mock calls `passPhraseForKey` with this key ID during `decrypt`,
/// simulating the PGP backend selecting a key and requesting its passphrase.
var selectedKeyForPassphrase: String?
// MARK: - Call tracking
struct DecryptCall {
let encryptedData: Data
let keyID: String?
let passPhraseForKey: (String) -> String
}
struct EncryptCall {
let plainData: Data
let keyID: String?
}
struct EncryptMultiKeyCall {
let plainData: Data
let keyIDs: [String]
}
struct EncryptWithAllKeysCall {
let plainData: Data
}
var decryptCalls: [DecryptCall] = []
var resolvedPassphrases: [String] = []
var encryptMultiKeyCalls: [EncryptMultiKeyCall] = []
var encryptWithAllKeysCalls: [EncryptWithAllKeysCall] = []
var containsPublicKeyCalls: [String] = []
var containsPrivateKeyCalls: [String] = []
// MARK: - PGPInterface
func decrypt(encryptedData: Data, keyIDHint keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
decryptCalls.append(DecryptCall(encryptedData: encryptedData, keyID: keyID, passPhraseForKey: passPhraseForKey))
if let selectedKey = selectedKeyForPassphrase {
resolvedPassphrases.append(passPhraseForKey(selectedKey))
}
if let error = decryptError {
throw error
}
return decryptResult
}
func encryptWithAllKeys(plainData: Data) throws -> Data {
encryptWithAllKeysCalls.append(EncryptWithAllKeysCall(plainData: plainData))
if let error = encryptError {
throw error
}
return encryptResult
}
func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
encryptMultiKeyCalls.append(EncryptMultiKeyCall(plainData: plainData, keyIDs: keyIDs))
if let error = encryptError {
throw error
}
return encryptResult
}
func containsPublicKey(with keyID: String) -> Bool {
containsPublicKeyCalls.append(keyID)
return publicKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) }
}
func containsPrivateKey(with keyID: String) -> Bool {
containsPrivateKeyCalls.append(keyID)
return privateKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) }
}
func getKeyIDs(type _: PGPKey) -> [String] {
// currently irrelevant for the tests
[]
}
func getShortKeyIDs(type _: PGPKey) -> [String] {
// currently irrelevant for the tests
[]
}
}

View file

@ -15,46 +15,24 @@ import XCTest
final class PasswordStoreTest: XCTestCase {
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
override func setUp() {
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
passwordStore = PasswordStore(url: localRepoURL)
}
override func tearDown() {
passwordStore.erase()
passwordStore = nil
pgpAgent = nil
keyStore = nil
Defaults.removeAll()
super.tearDown()
}
func testInitPasswordEntityCoreData() throws {
try cloneRepository(.withGPGID)
XCTAssertEqual(passwordStore.numberOfPasswords, 4)
XCTAssertEqual(passwordStore.numberOfCommits, 17)
XCTAssertEqual(passwordStore.numberOfCommits, 16)
XCTAssertEqual(passwordStore.numberOfLocalCommits, 0)
let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")
@ -97,20 +75,20 @@ final class PasswordStoreTest: XCTestCase {
PasscodeLock.shared.save(passcode: "1234")
XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0)
XCTAssertTrue(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey()))
XCTAssertTrue(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey()))
XCTAssertEqual(Defaults.gitSignatureName, "Test User")
XCTAssertTrue(PasscodeLock.shared.hasPasscode)
XCTAssertTrue(pgpAgent.isInitialized())
XCTAssertTrue(PGPAgent.shared.isInitialized())
expectation(forNotification: .passwordStoreUpdated, object: nil)
expectation(forNotification: .passwordStoreErased, object: nil)
passwordStore.erase()
XCTAssertEqual(passwordStore.numberOfPasswords, 0)
XCTAssertFalse(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey()))
XCTAssertFalse(AppKeychain.shared.contains(key: PGPKey.PUBLIC.getKeychainKey()))
XCTAssertFalse(Defaults.hasKey(\.gitSignatureName))
XCTAssertFalse(PasscodeLock.shared.hasPasscode)
XCTAssertFalse(pgpAgent.isInitialized())
XCTAssertFalse(PGPAgent.shared.isInitialized())
waitForExpectations(timeout: 1, handler: nil)
}
@ -332,248 +310,32 @@ final class PasswordStoreTest: XCTestCase {
waitForExpectations(timeout: 1, handler: nil)
}
// MARK: - Find .gpg-id
// MARK: - .gpg-id support
func testFindGPGIDFile() throws {
try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data("under root".utf8)))
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("foo/bar/baz"), withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path, contents: Data("under foo".utf8)))
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/"), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey"), withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path, contents: Data("under hey".utf8)))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "/")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/..")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path))
// there is a _drectory_ called .gpg-id in here
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id/hey")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path))
// "foo/bar/../../baz" resolves to "baz" which has no .gpg-id, so should find root's.
// Without path resolution, the walk ["foo","bar","..","..","baz"] remove "baz" remove ".."
// "foo/bar/.." remove ".." "foo/bar" finds foo/.gpg-id (wrong).
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("baz"), withIntermediateDirectories: true)
XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/../../baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path))
}
func testMissingGPGIDFile() throws {
XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent(".gpg-id").path))
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("subdir"), withIntermediateDirectories: true)
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ""))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "subdir"))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "missing"))
}
func testFindGPGIDFileStopsAtRoot() throws {
// Place a .gpg-id file ABOVE the store root, this should not be found
let parentDir = localRepoURL.deletingLastPathComponent()
let escapedGPGIDURL = parentDir.appendingPathComponent(".gpg-id")
XCTAssertTrue(FileManager.default.createFile(atPath: escapedGPGIDURL.path, contents: Data("ESCAPED_KEY".utf8)))
defer { try? FileManager.default.removeItem(at: escapedGPGIDURL) }
// Store has no .gpg-id at all
try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("sub/deep"), withIntermediateDirectories: true)
// Direct paths, should not find the escaped .gpg-id since it's outside the store root
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ""))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub"))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep"))
// Path traversal attempts via ".."
XCTAssertNil(passwordStore.findGPGIDFile(atPath: ".."))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "../.."))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/../.."))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../.."))
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../../../../etc"))
// Symlink escape: create a symlink inside the store pointing outside
let evilDir = parentDir.appendingPathComponent("evil")
try FileManager.default.createDirectory(at: evilDir, withIntermediateDirectories: true)
XCTAssertTrue(FileManager.default.createFile(atPath: evilDir.appendingPathComponent(".gpg-id").path, contents: Data("EVIL_KEY".utf8)))
defer { try? FileManager.default.removeItem(at: evilDir) }
try FileManager.default.createSymbolicLink(at: localRepoURL.appendingPathComponent("sub/escape"), withDestinationURL: evilDir)
// Following the symlink would find evil/.gpg-id must not happen
XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/escape"))
}
// MARK: Parse .gpg-id
func testReadGPGIDFile() throws {
func testCloneAndDecryptMultiKeys() 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)")
}
}
try importMultiplePGPKeys()
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
[
("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"),
("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"),
].forEach { path, id in
let keyID = findGPGID(from: localRepoURL.appendingPathComponent(path))
XCTAssertEqual(keyID, id)
}
let personal = try decrypt(path: "personal/github.com.gpg")
XCTAssertEqual(personal.plainText, "passwordforpersonal\n")
let work = try decrypt(path: "work/github.com.gpg")
XCTAssertEqual(work.plainText, "passwordforwork\n")
let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword")
_ = 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 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])
let testPasswordEntity = try passwordStore.add(password: testPassword)!
let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
XCTAssertEqual(testPasswordPlain.plainText, "testpassword")
}
// MARK: - Helpers
@ -616,15 +378,17 @@ final class PasswordStoreTest: XCTestCase {
}
private func importSinglePGPKey() throws {
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.publicKey)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.privateKey)
try pgpAgent.initKeys()
let keychain = AppKeychain.shared
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.publicKey)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA4096.privateKey)
try PGPAgent.shared.initKeys()
}
private func importMultiplePGPKeys() throws {
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.publicKeys)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.privateKeys)
try pgpAgent.initKeys()
let keychain = AppKeychain.shared
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys)
try PGPAgent.shared.initKeys()
}
private func decrypt(path: String, keyID: String? = nil) throws -> Password {

View file

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

View file

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

View file

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