From b7ee00815c61308c6a1cf655ecea98b176c9f420 Mon Sep 17 00:00:00 2001 From: Mingshen Sun Date: Mon, 13 Apr 2020 01:30:00 -0700 Subject: [PATCH] Partially implement multikeys support (decryption) --- passKit/Crypto/GopenPgp.swift | 68 +++++++++++++++---- passKit/Crypto/PGPAgent.swift | 8 +-- passKit/Models/PasswordStore.swift | 30 ++++++-- passKitTests/Crypto/CryptoFrameworkTest.swift | 1 - passKitTests/Crypto/PGPAgentTest.swift | 46 +++++++++---- passKitTests/Models/PasswordStoreTest.swift | 38 ++++++++++- passKitTests/Testbase/TestPGPKeys.swift | 14 ++++ 7 files changed, 165 insertions(+), 40 deletions(-) diff --git a/passKit/Crypto/GopenPgp.swift b/passKit/Crypto/GopenPgp.swift index 58b179c..ee05b25 100644 --- a/passKit/Crypto/GopenPgp.swift +++ b/passKit/Crypto/GopenPgp.swift @@ -15,23 +15,60 @@ struct GopenPgp: PgpInterface { "openpgp: incorrect key": AppError.KeyExpiredOrIncompatible, ] - private let publicKey: CryptoKey - private let privateKey: CryptoKey + private var publicKeys: [String: CryptoKey] = [:] + private var privateKeys: [String: CryptoKey] = [:] init(publicArmoredKey: String, privateArmoredKey: String) throws { - var error: NSError? - guard let publicKey = CryptoNewKeyFromArmored(publicArmoredKey, &error), - let privateKey = CryptoNewKeyFromArmored(privateArmoredKey, &error) else { - guard error == nil else { - throw error! + let pubKeys = extractKeysFromArmored(str: publicArmoredKey) + let prvKeys = extractKeysFromArmored(str: privateArmoredKey) + + for key in pubKeys { + var error: NSError? + guard let k = CryptoNewKeyFromArmored(key, &error) else { + guard error == nil else { + throw error! + } + throw AppError.KeyImport } - throw AppError.KeyImport + publicKeys[k.getFingerprint()] = k } - self.publicKey = publicKey - self.privateKey = privateKey + + for key in prvKeys { + var error: NSError? + guard let k = CryptoNewKeyFromArmored(key, &error) else { + guard error == nil else { + throw error! + } + throw AppError.KeyImport + } + privateKeys[k.getFingerprint()] = k + } + } + func extractKeysFromArmored(str: String) -> [String] { + var keys: [String] = [] + var key: String = "" + for line in str.splitByNewline() { + if line.trimmed.uppercased().hasPrefix("-----BEGIN PGP") { + key = "" + key += line + "\n" + } else if line.trimmed.uppercased().hasPrefix("-----END PGP") { + key += line + keys.append(key) + } else { + key += line + "\n" + } + } + return keys + } + func decrypt(encryptedData: Data, keyID: String, passphrase: String) throws -> Data? { + guard let e = privateKeys.first(where: { (key, _) in key.hasSuffix(keyID.lowercased()) }), + let privateKey = privateKeys[e.key] else { + throw AppError.Decryption + } + do { let unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8)) var error: NSError? @@ -51,6 +88,11 @@ struct GopenPgp: PgpInterface { } func encrypt(plainData: Data, keyID: String) throws -> Data { + guard let e = publicKeys.first(where: { (key, _) in key.hasSuffix(keyID.lowercased()) }), + let publicKey = publicKeys[e.key] else { + throw AppError.Encryption + } + var error: NSError? guard let keyRing = CryptoNewKeyRing(publicKey, &error) else { @@ -73,14 +115,12 @@ struct GopenPgp: PgpInterface { } var keyId: String { - var error: NSError? - let fingerprint = publicKey.getHexKeyID() + let fingerprint = publicKeys.first?.key ?? "" return String(fingerprint).uppercased() } var shortKeyId: String { - var error: NSError? - let fingerprint = publicKey.getHexKeyID() + let fingerprint = publicKeys.first?.key ?? "" return String(fingerprint.suffix(8)).uppercased() } diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 38c180e..de4bf23 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -45,7 +45,7 @@ public class PGPAgent { return pgpInterface?.shortKeyId } - public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: () -> String) throws -> Data? { + public func decrypt(encryptedData: Data, keyID: String, requestPGPKeyPassphrase: () -> String) throws -> Data? { // Remember the previous status and set the current status let previousDecryptStatus = self.latestDecryptStatus self.latestDecryptStatus = false @@ -59,7 +59,7 @@ public class PGPAgent { passphrase = keyStore.get(for: Globals.pgpKeyPassphrase) ?? requestPGPKeyPassphrase() } // Decrypt. - guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: "", passphrase: passphrase) else { + guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else { return nil } // The decryption step has succeed. @@ -67,12 +67,12 @@ public class PGPAgent { return result } - public func encrypt(plainData: Data) throws -> Data { + public func encrypt(plainData: Data, keyID: String) throws -> Data { try checkAndInit() guard let pgpInterface = pgpInterface else { throw AppError.Encryption } - return try pgpInterface.encrypt(plainData: plainData, keyID: "") + return try pgpInterface.encrypt(plainData: plainData, keyID: keyID) } public var isPrepared: Bool { diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index ad665cd..ab3c749 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -194,7 +194,8 @@ public class PasswordStore { options: [AnyHashable : Any]? = nil, branchName: String, transferProgressBlock: @escaping (UnsafePointer, UnsafeMutablePointer) -> Void, - checkoutProgressBlock: @escaping (String, UInt, UInt) -> Void) throws { + checkoutProgressBlock: @escaping (String, UInt, UInt) -> Void, + completion: @escaping () -> Void = {}) throws { try? fm.removeItem(at: storeURL) try? fm.removeItem(at: tempStoreURL) self.gitPassword = nil @@ -221,6 +222,7 @@ public class PasswordStore { DispatchQueue.main.async { self.updatePasswordEntityCoreData() NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil) + completion() } } @@ -285,7 +287,9 @@ public class PasswordStore { if fm.fileExists(atPath: filePath, isDirectory: &isDirectory) { if isDirectory.boolValue { e.isDir = true - let files = try fm.contentsOfDirectory(atPath: filePath).map { (filename) -> PasswordEntity in + let files = try fm.contentsOfDirectory(atPath: filePath).filter { + !$0.hasPrefix(".") + }.map { (filename) -> PasswordEntity in let passwordEntity = NSEntityDescription.insertNewObject(forEntityName: "PasswordEntity", into: context) as! PasswordEntity if filename.hasSuffix(".gpg") { passwordEntity.name = String(filename.prefix(upTo: filename.index(filename.endIndex, offsetBy: -4))) @@ -693,11 +697,12 @@ public class PasswordStore { // get a list of local commits return try storeRepository.localCommitsRelative(toRemoteBranch: remoteBranch) } - + public func decrypt(passwordEntity: PasswordEntity, requestPGPKeyPassphrase: () -> String) throws -> Password? { let encryptedDataPath = storeURL.appendingPathComponent(passwordEntity.getPath()) + let keyID = findGPGID(from: encryptedDataPath) let encryptedData = try Data(contentsOf: encryptedDataPath) - guard let decryptedData = try PGPAgent.shared.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase) else { + guard let decryptedData = try PGPAgent.shared.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) else { throw AppError.Decryption } let plainText = String(data: decryptedData, encoding: .utf8) ?? "" @@ -706,7 +711,7 @@ public class PasswordStore { } public func encrypt(password: Password) throws -> Data { - return try PGPAgent.shared.encrypt(plainData: password.plainData) + return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: "") } public func removeGitSSHKeys() { @@ -718,3 +723,18 @@ public class PasswordStore { gitSSHPrivateKeyPassphrase = nil } } + +public 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") + + do { + return try String(contentsOf: path).trimmed + } catch { + return "" + } +} diff --git a/passKitTests/Crypto/CryptoFrameworkTest.swift b/passKitTests/Crypto/CryptoFrameworkTest.swift index d41308a..c7f5c87 100644 --- a/passKitTests/Crypto/CryptoFrameworkTest.swift +++ b/passKitTests/Crypto/CryptoFrameworkTest.swift @@ -51,7 +51,6 @@ class CryptoFrameworkTest: XCTestCase { return } XCTAssertNil(error) - XCTAssert(publicKey.getHexKeyID().hasSuffix(keyTriple.fingerprint)) XCTAssertNil(error) diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index ae8bd84..5baf3ac 100644 --- a/passKitTests/Crypto/PGPAgentTest.swift +++ b/passKitTests/Crypto/PGPAgentTest.swift @@ -31,11 +31,31 @@ class PGPAgentTest: XCTestCase { super.tearDown() } - func basicEncryptDecrypt(using pgpAgent: PGPAgent, requestPassphrase: () -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, encryptInArmoredNow: Bool = true) throws -> Data? { + func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, requestPassphrase: () -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, encryptInArmoredNow: Bool = true) throws -> Data? { passKit.Defaults.encryptInArmored = encryptInArmored - let encryptedData = try pgpAgent.encrypt(plainData: testData) + let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: keyID) passKit.Defaults.encryptInArmored = encryptInArmoredNow - return try pgpAgent.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPassphrase) + return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPassphrase) + } + + func testMultiKeys() throws { + try [ + RSA2048_RSA4096 + ].forEach { keyTriple in + let keychain = DictBasedKeychain() + let pgpAgent = PGPAgent(keyStore: keychain) + try KeyFileManager(keyType: PgpKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: keyTriple.publicKey) + try KeyFileManager(keyType: PgpKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: keyTriple.privateKey) + XCTAssert(pgpAgent.isPrepared) + try pgpAgent.initKeys() + try [ + (true, true), (true, false), (false, true), (false, false) + ].forEach{ a, b in + for id in keyTriple.fingerprint { + XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: a, encryptInArmoredNow: b), testData) + } + } + } } func testBasicEncryptDecrypt() throws { @@ -57,7 +77,7 @@ class PGPAgentTest: XCTestCase { try [ (true, true), (true, false), (false, true), (false, false) ].forEach{ a, b in - XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, encryptInArmored: a, encryptInArmoredNow: b), testData) + XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: keyTriple.fingerprint, encryptInArmored: a, encryptInArmoredNow: b), testData) } } } @@ -68,7 +88,7 @@ class PGPAgentTest: XCTestCase { XCTAssertThrowsError(try pgpAgent.initKeys()) { XCTAssertEqual($0 as! AppError, AppError.KeyImport) } - XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent)) { + XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent, keyID: RSA2048.fingerprint)) { XCTAssertEqual($0 as! AppError, AppError.KeyImport) } } @@ -76,7 +96,7 @@ class PGPAgentTest: XCTestCase { func testInterchangePublicAndPrivateKey() throws { try importKeys(RSA2048.privateKey, RSA2048.publicKey) XCTAssert(pgpAgent.isPrepared) - XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent)) { + XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent, keyID: RSA2048.fingerprint)) { XCTAssert($0.localizedDescription.contains("gopenpgp: unable to add locked key to a keyring")) } } @@ -84,7 +104,7 @@ class PGPAgentTest: XCTestCase { func testIncompatibleKeyTypes() throws { try importKeys(ED25519.publicKey, RSA2048.privateKey) XCTAssert(pgpAgent.isPrepared) - XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent)) { + XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent, keyID: RSA2048.fingerprint)) { XCTAssertEqual($0 as! AppError, AppError.KeyExpiredOrIncompatible) } } @@ -92,7 +112,7 @@ class PGPAgentTest: XCTestCase { func testCorruptedKey() throws { try importKeys(RSA2048.publicKey.replacingOccurrences(of: "1", with: ""), RSA2048.privateKey) XCTAssert(pgpAgent.isPrepared) - XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent)) { + XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent, keyID: RSA2048.fingerprint)) { XCTAssert($0.localizedDescription.contains("Can't read keys. Invalid input.")) } } @@ -100,10 +120,10 @@ class PGPAgentTest: XCTestCase { func testUnsettKeys() throws { try importKeys(ED25519.publicKey, ED25519.privateKey) XCTAssert(pgpAgent.isPrepared) - XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent), testData) + XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: ED25519.fingerprint), testData) keychain.removeContent(for: PgpKey.PUBLIC.getKeychainKey()) keychain.removeContent(for: PgpKey.PRIVATE.getKeychainKey()) - XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent)) { + XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent, keyID: ED25519.fingerprint)) { XCTAssertEqual($0 as! AppError, AppError.KeyImport) } } @@ -122,17 +142,17 @@ class PGPAgentTest: XCTestCase { } // Provide the correct passphrase. - XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, requestPassphrase: provideCorrectPassphrase), testData) + XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: RSA2048.fingerprint, requestPassphrase: provideCorrectPassphrase), testData) XCTAssertEqual(passphraseRequestCalledCount, 1) // Provide the wrong passphrase. - XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent, requestPassphrase: provideIncorrectPassphrase)) { + XCTAssertThrowsError(try basicEncryptDecrypt(using: pgpAgent, keyID: RSA2048.fingerprint, requestPassphrase: provideIncorrectPassphrase)) { XCTAssertEqual($0 as! AppError, AppError.WrongPassphrase) } XCTAssertEqual(passphraseRequestCalledCount, 2) // Ask for the passphrase because the previous decryption has failed. - XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, requestPassphrase: provideCorrectPassphrase), testData) + XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: RSA2048.fingerprint, requestPassphrase: provideCorrectPassphrase), testData) XCTAssertEqual(passphraseRequestCalledCount, 3) } diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 82529eb..1af9773 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -20,17 +20,49 @@ class PasswordStoreTest: XCTestCase { }() let remoteRepoURL = URL(string: "https://github.com/mssun/passforios-password-store.git")! - func testClone() throws { + + func testCloneAndDecryptMultiKeys() throws { let url = URL(fileURLWithPath: "\(Globals.repositoryPath)-test") let passwordStore = PasswordStore(url: url) - + let expectation = self.expectation(description: "clone") try passwordStore.cloneRepository( remoteRepoURL: remoteRepoURL, options: cloneOptions, branchName: "master", transferProgressBlock: { _, _ in }, checkoutProgressBlock: { _, _, _ in } - ) + ) { + expectation.fulfill() + } + waitForExpectations(timeout: 3, handler: nil) + + [ + ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), + ("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E") + ].forEach {(path, id) in + let keyID = findGPGID(from: url.appendingPathComponent(path)) + XCTAssertEqual(keyID, id) + } + + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PgpKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKey) + try KeyFileManager(keyType: PgpKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKey) + try PGPAgent.shared.initKeys() + + let personal = try decrypt(passwordStore: passwordStore, path: "personal/github.com.gpg", passphrase: "passforios") + XCTAssertEqual(personal.plainText, "passwordforpersonal\n") + + let work = try decrypt(passwordStore: passwordStore, path: "work/github.com.gpg", passphrase: "passforios") + XCTAssertEqual(work.plainText, "passwordforwork\n") + passwordStore.erase() } + + private func decrypt(passwordStore: PasswordStore, path: String, passphrase: String) throws -> Password { + let entity = passwordStore.getPasswordEntity(by: path, isDir: false)! + return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: { passphrase } )! + + } + } + diff --git a/passKitTests/Testbase/TestPGPKeys.swift b/passKitTests/Testbase/TestPGPKeys.swift index 3a1f5ee..57f2b7f 100644 --- a/passKitTests/Testbase/TestPGPKeys.swift +++ b/passKitTests/Testbase/TestPGPKeys.swift @@ -18,6 +18,13 @@ struct PGPKeyTestTriple { let passphrase = "passforios" } +struct MultiPGPKeyTestTriple { + let publicKey: String + let privateKey: String + let fingerprint: [String] + let passphrase: [String] +} + let RSA2048 = PGPKeyTestTriple( publicKey: PGP_RSA2048_PUBLIC_KEY, privateKey: PGP_RSA2048_PRIVATE_KEY, @@ -54,6 +61,13 @@ let ED25519_SUB = PGPKeyTestTriple( fingerprint: "e9444483" ) +let RSA2048_RSA4096 = MultiPGPKeyTestTriple( + publicKey: PGP_RSA2048_PUBLIC_KEY + "\n" + PGP_RSA4096_PUBLIC_KEY, + privateKey: PGP_RSA2048_PRIVATE_KEY + "\n" + PGP_RSA4096_PRIVATE_KEY, + fingerprint: ["a1024dae", "d862027e"], + passphrase: ["passforios", "passforios"] +) + func requestPGPKeyPassphrase() -> String { return "passforios" }