diff --git a/pass/Controllers/PasswordDetailTableViewController.swift b/pass/Controllers/PasswordDetailTableViewController.swift index 804c614..7885d8e 100644 --- a/pass/Controllers/PasswordDetailTableViewController.swift +++ b/pass/Controllers/PasswordDetailTableViewController.swift @@ -526,11 +526,12 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni } extension PasswordDetailTableViewController { - private func requestYubiKeyPIN(completion: @escaping (String) -> Void) { + private func requestYubiKeyPIN(completion: @escaping (String) -> Void, cancellation: @escaping () -> Void) { let alert = UIAlertController(title: "YubiKey PIN", message: "Verify YubiKey OpenPGP PIN.", preferredStyle: .alert) alert.addAction( UIAlertAction.cancel { _ in self.navigationController!.popViewController(animated: true) + cancellation() } ) alert.addAction( @@ -570,7 +571,7 @@ extension PasswordDetailTableViewController { } } - private func handleCancellation(_: Error) { + private func handleCancellation() { DispatchQueue.main.async { self.navigationController?.popViewController(animated: true) } diff --git a/pass/Services/PasswordDecryptor.swift b/pass/Services/PasswordDecryptor.swift index c97a7c2..aec53d4 100644 --- a/pass/Services/PasswordDecryptor.swift +++ b/pass/Services/PasswordDecryptor.swift @@ -55,7 +55,7 @@ func decryptPassword( } } -public typealias RequestPINAction = (@escaping (String) -> Void) -> Void +public typealias RequestPINAction = (@escaping (String) -> Void, @escaping () -> Void) -> Void let symmetricKeyIDNameDict: [UInt8: String] = [ 2: "3des", @@ -66,124 +66,194 @@ let symmetricKeyIDNameDict: [UInt8: String] = [ ] private func isEncryptKeyAlgoRSA(_ applicationRelatedData: Data) -> Bool { - if #available(iOS 13.0, *) { - let tlv = TKBERTLVRecord.sequenceOfRecords(from: applicationRelatedData)! - // 0x73: Discretionary data objects - for record in TKBERTLVRecord.sequenceOfRecords(from: tlv.first!.value)! where record.tag == 0x73 { - // 0xC2: Algorithm attributes decryption, 0x01: RSA - for record2 in TKBERTLVRecord.sequenceOfRecords(from: record.value)! where record2.tag == 0xC2 && record2.value.first! == 0x01 { - return true - } + let tlv = TKBERTLVRecord.sequenceOfRecords(from: applicationRelatedData)! + // 0x73: Discretionary data objects + for record in TKBERTLVRecord.sequenceOfRecords(from: tlv.first!.value)! where record.tag == 0x73 { + // 0xC2: Algorithm attributes decryption, 0x01: RSA + for record2 in TKBERTLVRecord.sequenceOfRecords(from: record.value)! where record2.tag == 0xC2 && record2.value.first! == 0x01 { + return true } - return false - } else { - // We need CryptoTokenKit (iOS 13.0+) to check if data is RSA, so fail open here. - return true } + return false +} + +private func getCapabilities(_ applicationRelatedData: Data) -> (Bool, Bool) { + let tlv = TKBERTLVRecord.sequenceOfRecords(from: applicationRelatedData)! + // 0x5f52: Historical Bytes + for record in TKBERTLVRecord.sequenceOfRecords(from: tlv.first!.value)! where record.tag == 0x5F52 { + let historical = record.value + if historical.count < 4 { + // log_error ("warning: historical bytes are too short\n"); + return (false, false) + } + + if historical[0] != 0 { + // log_error ("warning: bad category indicator in historical bytes\n"); + return (false, false) + } + + let dos = historical[1 ..< historical.endIndex - 3] + for record2 in TKCompactTLVRecord.sequenceOfRecords(from: dos)! where record2.tag == 7 && record2.value.count == 3 { + let cmd_chaining = (record2.value[2] & 0x80) != 0 + let ext_lc_le = (record2.value[2] & 0x40) != 0 + return (cmd_chaining, ext_lc_le) + } + } + return (false, false) } -// swiftlint:disable cyclomatic_complexity public func yubiKeyDecrypt( passwordEntity: PasswordEntity, requestPIN: @escaping RequestPINAction, errorHandler: @escaping ((AppError) -> Void), - cancellation: @escaping ((_ error: Error) -> Void), + cancellation: @escaping (() -> Void), completion: @escaping ((Password) -> Void) ) { - let encryptedDataPath = PasswordStore.shared.storeURL.appendingPathComponent(passwordEntity.getPath()) + Task { + do { + let encryptedDataPath = PasswordStore.shared.storeURL.appendingPathComponent(passwordEntity.getPath()) - guard let encryptedData = try? Data(contentsOf: encryptedDataPath) else { - errorHandler(AppError.other(message: "PasswordDoesNotExist".localize())) - return - } - - // swiftlint:disable closure_body_length - requestPIN { pin in - // swiftlint:disable closure_body_length - passKit.YubiKeyConnection.shared.connection(cancellation: cancellation) { connection in - guard let smartCard = connection.smartCardInterface else { - errorHandler(AppError.yubiKey(.connection(message: "Failed to get smart card interface."))) + guard let encryptedData = try? Data(contentsOf: encryptedDataPath) else { + errorHandler(AppError.other(message: "PasswordDoesNotExist".localize())) return } - // 1. Select OpenPGP application - let selectOpenPGPAPDU = YubiKeyAPDU.selectOpenPGPApplication() - smartCard.selectApplication(selectOpenPGPAPDU) { _, error in - guard error == nil else { - errorHandler(AppError.yubiKey(.selectApplication(message: "Failed to select application."))) - return - } + guard let pin = await readPin(requestPIN: requestPIN) else { + return + } - // 2. Verify PIN - let verifyApdu = YubiKeyAPDU.verify(password: pin) - smartCard.executeCommand(verifyApdu) { _, error in - guard error == nil else { - errorHandler(AppError.yubiKey(.verify(message: "Failed to verify PIN."))) - return - } + guard let connection = try? await getConnection() else { + cancellation() + return + } - let applicationRelatedDataApdu = YubiKeyAPDU.get_application_related_data() - smartCard.executeCommand(applicationRelatedDataApdu) { data, _ in - guard let data = data else { - errorHandler(AppError.yubiKey(.decipher(message: "Failed to get application related data."))) - return - } + guard let smartCard = connection.smartCardInterface else { + throw AppError.yubiKey(.connection(message: "Failed to get smart card interface.")) + } - if !isEncryptKeyAlgoRSA(data) { - errorHandler(AppError.yubiKey(.decipher(message: "Encryption key algorithm is not supported. Supported algorithm: RSA."))) - return - } + try await selectOpenPGPApplication(smartCard: smartCard) - // 3. Decipher - let ciphertext = encryptedData - var error: NSError? - let message = CryptoNewPGPMessage(ciphertext) - guard let mpi1 = Gopenpgp.HelperPassGetEncryptedMPI1(message, &error) else { - errorHandler(AppError.yubiKey(.decipher(message: "Failed to get encrypted MPI."))) - return - } + try await verifyPin(smartCard: smartCard, pin: pin) - let decipherApdu = YubiKeyAPDU.decipher(data: mpi1) - smartCard.executeCommand(decipherApdu) { data, error in - guard let data = data else { - errorHandler(AppError.yubiKey(.decipher(message: "Failed to execute decipher."))) - return - } + guard let applicationRelatedData = try await getApplicationRelatedData(smartCard: smartCard) else { + throw AppError.yubiKey(.decipher(message: "Failed to get application related data.")) + } - if #available(iOS 13.0, *) { - YubiKitManager.shared.stopNFCConnection() - } - guard let algoByte = data.first, let algo = symmetricKeyIDNameDict[algoByte] else { - errorHandler(AppError.yubiKey(.decipher(message: "Failed to new session key."))) - return - } - guard let session_key = Gopenpgp.CryptoNewSessionKeyFromToken(data[1 ..< data.count - 2], algo) else { - errorHandler(AppError.yubiKey(.decipher(message: "Failed to new session key."))) - return - } + if !isEncryptKeyAlgoRSA(applicationRelatedData) { + throw AppError.yubiKey(.decipher(message: "Encryption key algorithm is not supported. Supported algorithm: RSA.")) + } - var error: NSError? - let message = CryptoNewPGPMessage(ciphertext) + let (cmd_chaining, _) = getCapabilities(applicationRelatedData) - guard let plaintext = Gopenpgp.HelperPassDecryptWithSessionKey(message, session_key, &error)?.data else { - errorHandler(AppError.yubiKey(.decipher(message: "Failed to decrypt with session key."))) - return - } + let deciphered = try await decipher(smartCard: smartCard, ciphertext: encryptedData, chained: cmd_chaining) - guard let plaintext_str = String(data: plaintext, encoding: .utf8) else { - errorHandler(AppError.yubiKey(.decipher(message: "Failed to convert plaintext to string."))) - return - } + YubiKitManager.shared.stopNFCConnection() - guard let password = try? Password(name: passwordEntity.getName(), url: passwordEntity.getURL(), plainText: plaintext_str) else { - errorHandler(AppError.yubiKey(.decipher(message: "Failed to construct password."))) - return - } + let plaintext = try decryptPassword(deciphered: deciphered, ciphertext: encryptedData) + guard let password = try? Password(name: passwordEntity.getName(), url: passwordEntity.getURL(), plainText: plaintext) else { + throw AppError.yubiKey(.decipher(message: "Failed to construct password.")) + } - completion(password) - } - } - } + completion(password) + } catch let error as AppError { + errorHandler(error) + } catch { + errorHandler(AppError.other(message: String(describing: error))) + } + } +} + +func readPin(requestPIN: @escaping RequestPINAction) async -> String? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { + requestPIN({ pin in continuation.resume(returning: pin) }, { continuation.resume(returning: nil) }) + } + } +} + +func getConnection() async throws -> YKFConnectionProtocol? { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + passKit.YubiKeyConnection.shared.connection(cancellation: { error in + continuation.resume(throwing: error) + }, completion: { connection in + continuation.resume(returning: connection) + }) + } +} + +func selectOpenPGPApplication(smartCard: YKFSmartCardInterface) async throws { + if await withCheckedContinuation({ (continuation: CheckedContinuation) in + smartCard.selectApplication(YubiKeyAPDU.selectOpenPGPApplication()) { _, error in + continuation.resume(returning: error) + } + }) != nil { + throw AppError.yubiKey(.selectApplication(message: "Failed to select application.")) + } +} + +func getApplicationRelatedData(smartCard: YKFSmartCardInterface) async throws -> Data? { + try await executeCommandAsync(smartCard: smartCard, apdu: YubiKeyAPDU.get_application_related_data()) +} + +func verifyPin(smartCard: YKFSmartCardInterface, pin: String) async throws { + if await withCheckedContinuation({ (continuation: CheckedContinuation) in + smartCard.executeCommand(YubiKeyAPDU.verify(password: pin)) { _, error in + continuation.resume(returning: error) + }}) != nil { + throw AppError.yubiKey(.selectApplication(message: "Failed to verify PIN.")) + } +} + +func decipher(smartCard: YKFSmartCardInterface, ciphertext: Data, chained: Bool) async throws -> Data { + var error: NSError? + let message = CryptoNewPGPMessage(ciphertext) + guard let mpi1 = Gopenpgp.HelperPassGetEncryptedMPI1(message, &error) else { + throw AppError.yubiKey(.decipher(message: "Failed to get encrypted MPI.")) + } + + let apdus = chained ? YubiKeyAPDU.decipherChained(data: mpi1) : YubiKeyAPDU.decipherExtended(data: mpi1) + + for (idx, apdu) in apdus.enumerated() { + let data = try await executeCommandAsync(smartCard: smartCard, apdu: apdu) + // the last response must have the data + if idx == apdus.endIndex - 1, let data { + return data + } + } + + throw AppError.yubiKey(.verify(message: "Failed to execute decipher.")) +} + +func decryptPassword(deciphered: Data, ciphertext: Data) throws -> String { + let message = CryptoNewPGPMessage(ciphertext) + + guard let algoByte = deciphered.first, let algo = symmetricKeyIDNameDict[algoByte] else { + throw AppError.yubiKey(.decipher(message: "Failed to new session key.")) + } + + guard let session_key = Gopenpgp.CryptoNewSessionKeyFromToken(deciphered[1 ..< deciphered.count - 2], algo) else { + throw AppError.yubiKey(.decipher(message: "Failed to new session key.")) + } + + var error: NSError? + guard let plaintext = Gopenpgp.HelperPassDecryptWithSessionKey(message, session_key, &error)?.data else { + throw AppError.yubiKey(.decipher(message: "Failed to decrypt with session key: \(String(describing: error))")) + } + + guard let plaintext_str = String(data: plaintext, encoding: .utf8) else { + throw AppError.yubiKey(.decipher(message: "Failed to convert plaintext to string.")) + } + + return plaintext_str +} + +func executeCommandAsync(smartCard: YKFSmartCardInterface, apdu: YKFAPDU) async throws -> Data? { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + smartCard.executeCommand(apdu) { data, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: data) } } } diff --git a/passKit/Helpers/YubiKeyAPDU.swift b/passKit/Helpers/YubiKeyAPDU.swift index 7082111..1e38870 100644 --- a/passKit/Helpers/YubiKeyAPDU.swift +++ b/passKit/Helpers/YubiKeyAPDU.swift @@ -26,9 +26,9 @@ public enum YubiKeyAPDU { return verifyApdu } - public static func decipher(data: Data) -> YKFAPDU { + public static func decipherExtended(data: Data) -> [YKFAPDU] { var apdu: [UInt8] = [] - apdu += [0x00] // CLA + apdu += [0x00] // CLA (last or only command of a chain) apdu += [0x2A, 0x80, 0x86] // INS, P1, P2: PSO.DECIPHER // Lc, An extended Lc field consists of three bytes: // one byte set to '00' followed by two bytes not set to '0000' (1 to 65535 dec.). @@ -37,9 +37,32 @@ public enum YubiKeyAPDU { apdu += [0x00] apdu += data apdu += [0x02, 0x00] - let decipherApdu = YKFAPDU(data: Data(apdu))! - return decipherApdu + return [YKFAPDU(data: Data(apdu))!] + } + + public static func decipherChained(data: Data) -> [YKFAPDU] { + var result: [YKFAPDU] = [] + let chunks = chunk(data: data) + + for chunk in chunks.dropLast() { + var apdu: [UInt8] = [] + apdu += [0x10] // CLA (command is not the last command of a chain) + apdu += [0x2A, 0x80, 0x86] // INS, P1, P2: PSO.DECIPHER + apdu += withUnsafeBytes(of: UInt8(chunk.count).bigEndian, Array.init) + apdu += chunk + result += [YKFAPDU(data: Data(apdu))!] + } + + var apdu: [UInt8] = [] + apdu += [0x00] // CLA (last or only command of a chain) + apdu += [0x2A, 0x80, 0x86] // INS, P1, P2: PSO.DECIPHER + apdu += withUnsafeBytes(of: UInt8(chunks.last!.count).bigEndian, Array.init) + apdu += chunks.last! + apdu += [0x00] // Le + result += [YKFAPDU(data: Data(apdu))!] + + return result } public static func get_application_related_data() -> YKFAPDU { @@ -51,4 +74,14 @@ public enum YubiKeyAPDU { apdu += [0x00] return YKFAPDU(data: Data(apdu))! } + + static func chunk(data: Data) -> [[UInt8]] { + // starts with 00 padding + let padded: [UInt8] = [0x00] + data + let MAX_SIZE = 254 + + return stride(from: 0, to: padded.count, by: MAX_SIZE).map { + Array(padded[$0 ..< Swift.min($0 + MAX_SIZE, padded.count)]) + } + } }