diff --git a/passKit/Crypto/GopenPGPInterface.swift b/passKit/Crypto/GopenPGPInterface.swift index 34f0622..3d34a58 100644 --- a/passKit/Crypto/GopenPGPInterface.swift +++ b/passKit/Crypto/GopenPGPInterface.swift @@ -16,6 +16,7 @@ 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) @@ -40,7 +41,24 @@ struct GopenPGPInterface: PGPInterface { } 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()) } } - 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 - }() + func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { + let message = createPGPMessage(from: encryptedData) + guard let message else { + throw AppError.decryption + } - guard let privateKey = key else { + guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else { throw AppError.decryption } @@ -87,6 +103,7 @@ 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 @@ -100,33 +117,52 @@ 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] } } + @available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) instead.") 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 publicKeys.first?.value - }() + guard let keyID = keyID ?? publicKeys.keys.first else { + // this is invalid, but we want the new function to throw the error for us + return try encrypt(plainData: plainData, keyIDs: []) + } + return try encrypt(plainData: plainData, keyIDs: [keyID]) + } - guard let publicKey = key else { + 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) + } + return key + } + guard let firstKey = keys.first else { throw AppError.encryption } + let otherKeys = keys.dropFirst() var error: NSError? - - guard let keyRing = CryptoNewKeyRing(publicKey, &error) else { + guard let keyRing = CryptoNewKeyRing(firstKey, &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 { @@ -147,6 +183,33 @@ struct GopenPGPInterface: PGPInterface { var shortKeyID: [String] { publicKeys.keys.map { $0.suffix(8).uppercased() } } + + private func findDecryptionKey(message: CryptoPGPMessage, keyIDHint: String?) throws -> CryptoKey? { + var keyIDCandidates: any Collection = 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] + } } public func createPGPMessage(from encryptedData: Data) -> CryptoPGPMessage? { diff --git a/passKit/Crypto/ObjectivePGPInterface.swift b/passKit/Crypto/ObjectivePGPInterface.swift index 768d785..01a5a2b 100644 --- a/passKit/Crypto/ObjectivePGPInterface.swift +++ b/passKit/Crypto/ObjectivePGPInterface.swift @@ -24,12 +24,34 @@ struct ObjectivePGPInterface: PGPInterface { } } - func decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? { - try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase } + 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) + } } + @available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) instead.") func encrypt(plainData: Data, keyID _: String?) throws -> Data { - let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil) + // Backwards compatibility: ignore keyID parameter and encrypted with all keys in the keyring + try encryptWithAllKeys(plainData: plainData) + } + + func encryptWithAllKeys(plainData: Data) throws -> Data { + try encrypt(plainData: plainData, keyIDs: keyID) + } + + 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 { return Armor.armored(encryptedData, as: .message).data(using: .ascii)! } diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index f193515..97524c4 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -69,14 +69,14 @@ public class PGPAgent { latestDecryptStatus = false // Get the PGP key passphrase. - var passphrase = "" - if previousDecryptStatus == false { - passphrase = requestPGPKeyPassphrase(keyID) - } else { - passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: keyID)) ?? requestPGPKeyPassphrase(keyID) + let providePassPhraseForKey = { (selectedKeyID: String) -> String in + if previousDecryptStatus == false { + return requestPGPKeyPassphrase(selectedKeyID) + } + return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) } // 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 } // The decryption step has succeed. @@ -84,6 +84,7 @@ public class PGPAgent { return result } + @available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) instead.") public func encrypt(plainData: Data, keyID: String) throws -> Data { try checkAndInit() guard let pgpInterface else { @@ -97,31 +98,18 @@ public class PGPAgent { throw AppError.pgpPublicKeyNotFound(keyID: keyID) } } - return try pgpInterface.encrypt(plainData: plainData, keyID: keyID) + return try pgpInterface.encrypt(plainData: plainData, keyIDs: [keyID]) } - public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: (String) -> String) throws -> Data? { - // Remember the previous status and set the current status - let previousDecryptStatus = latestDecryptStatus - latestDecryptStatus = false - // Init keys. + public func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { try checkAndInit() - // Get the PGP key passphrase. - var passphrase = "" - if previousDecryptStatus == false { - passphrase = requestPGPKeyPassphrase("") - } else { - passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) ?? requestPGPKeyPassphrase("") + guard let pgpInterface else { + throw AppError.encryption } - // 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 + return try pgpInterface.encrypt(plainData: plainData, keyIDs: keyIDs) } + @available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) or encryptWithAllKeys(plainData:) instead.") public func encrypt(plainData: Data) throws -> Data { try checkAndInit() guard let pgpInterface else { @@ -130,6 +118,36 @@ public class PGPAgent { return try pgpInterface.encrypt(plainData: plainData, keyID: nil) } + public func encryptWithAllKeys(plainData: Data) throws -> Data { + try checkAndInit() + guard let pgpInterface else { + throw AppError.encryption + } + return try pgpInterface.encryptWithAllKeys(plainData: plainData) + } + + public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: @escaping (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. + let providePassPhraseForKey = { (selectedKeyID: String) -> String in + if previousDecryptStatus == false { + return requestPGPKeyPassphrase(selectedKeyID) + } + return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) + } + // Decrypt. + guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyIDHint: nil, passPhraseForKey: providePassPhraseForKey) else { + return nil + } + // The decryption step has succeed. + latestDecryptStatus = true + return result + } + public var isPrepared: Bool { keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey()) && keyStore.contains(key: PGPKey.PRIVATE.getKeychainKey()) diff --git a/passKit/Crypto/PGPInterface.swift b/passKit/Crypto/PGPInterface.swift index b77831d..f90c4bd 100644 --- a/passKit/Crypto/PGPInterface.swift +++ b/passKit/Crypto/PGPInterface.swift @@ -7,9 +7,13 @@ // protocol PGPInterface { - func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? + func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? + @available(*, deprecated, message: "Use encrypt(plainData:keyIDs:) instead.") 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 diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index b918ab4..c822ffa 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -420,9 +420,9 @@ public class PasswordStore { } 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 { + let encryptedDataPath = password.fileURL(in: storeURL) + let keyID = keyID ?? findGPGID(from: encryptedDataPath) return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID) } return try PGPAgent.shared.encrypt(plainData: password.plainData) diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index 8acb2be..b90e17f 100644 --- a/passKitTests/Crypto/PGPAgentTest.swift +++ b/passKitTests/Crypto/PGPAgentTest.swift @@ -31,34 +31,7 @@ final class PGPAgentTest: XCTestCase { 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? { - 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) - } - } - } - } + // - MARK: Basic encrypt and decrypt tests func testBasicEncryptDecrypt() throws { try [ @@ -161,8 +134,113 @@ 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() + + 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) + } } diff --git a/scripts/gopenpgp_build.sh b/scripts/gopenpgp_build.sh index 98dc8bf..b851cb9 100755 --- a/scripts/gopenpgp_build.sh +++ b/scripts/gopenpgp_build.sh @@ -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://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" mkdir -p dist