Add support for Yubikey command chaining
This commit is contained in:
parent
e6c9440bff
commit
f6b2316324
3 changed files with 204 additions and 100 deletions
|
|
@ -526,11 +526,12 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PasswordDetailTableViewController {
|
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)
|
let alert = UIAlertController(title: "YubiKey PIN", message: "Verify YubiKey OpenPGP PIN.", preferredStyle: .alert)
|
||||||
alert.addAction(
|
alert.addAction(
|
||||||
UIAlertAction.cancel { _ in
|
UIAlertAction.cancel { _ in
|
||||||
self.navigationController!.popViewController(animated: true)
|
self.navigationController!.popViewController(animated: true)
|
||||||
|
cancellation()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
alert.addAction(
|
alert.addAction(
|
||||||
|
|
@ -570,7 +571,7 @@ extension PasswordDetailTableViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleCancellation(_: Error) {
|
private func handleCancellation() {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.navigationController?.popViewController(animated: true)
|
self.navigationController?.popViewController(animated: true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] = [
|
let symmetricKeyIDNameDict: [UInt8: String] = [
|
||||||
2: "3des",
|
2: "3des",
|
||||||
|
|
@ -66,124 +66,194 @@ let symmetricKeyIDNameDict: [UInt8: String] = [
|
||||||
]
|
]
|
||||||
|
|
||||||
private func isEncryptKeyAlgoRSA(_ applicationRelatedData: Data) -> Bool {
|
private func isEncryptKeyAlgoRSA(_ applicationRelatedData: Data) -> Bool {
|
||||||
if #available(iOS 13.0, *) {
|
let tlv = TKBERTLVRecord.sequenceOfRecords(from: applicationRelatedData)!
|
||||||
let tlv = TKBERTLVRecord.sequenceOfRecords(from: applicationRelatedData)!
|
// 0x73: Discretionary data objects
|
||||||
// 0x73: Discretionary data objects
|
for record in TKBERTLVRecord.sequenceOfRecords(from: tlv.first!.value)! where record.tag == 0x73 {
|
||||||
for record in TKBERTLVRecord.sequenceOfRecords(from: tlv.first!.value)! where record.tag == 0x73 {
|
// 0xC2: Algorithm attributes decryption, 0x01: RSA
|
||||||
// 0xC2: Algorithm attributes decryption, 0x01: RSA
|
for record2 in TKBERTLVRecord.sequenceOfRecords(from: record.value)! where record2.tag == 0xC2 && record2.value.first! == 0x01 {
|
||||||
for record2 in TKBERTLVRecord.sequenceOfRecords(from: record.value)! where record2.tag == 0xC2 && record2.value.first! == 0x01 {
|
return true
|
||||||
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(
|
public func yubiKeyDecrypt(
|
||||||
passwordEntity: PasswordEntity,
|
passwordEntity: PasswordEntity,
|
||||||
requestPIN: @escaping RequestPINAction,
|
requestPIN: @escaping RequestPINAction,
|
||||||
errorHandler: @escaping ((AppError) -> Void),
|
errorHandler: @escaping ((AppError) -> Void),
|
||||||
cancellation: @escaping ((_ error: Error) -> Void),
|
cancellation: @escaping (() -> Void),
|
||||||
completion: @escaping ((Password) -> 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 {
|
guard let encryptedData = try? Data(contentsOf: encryptedDataPath) else {
|
||||||
errorHandler(AppError.other(message: "PasswordDoesNotExist".localize()))
|
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.")))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Select OpenPGP application
|
guard let pin = await readPin(requestPIN: requestPIN) else {
|
||||||
let selectOpenPGPAPDU = YubiKeyAPDU.selectOpenPGPApplication()
|
return
|
||||||
smartCard.selectApplication(selectOpenPGPAPDU) { _, error in
|
}
|
||||||
guard error == nil else {
|
|
||||||
errorHandler(AppError.yubiKey(.selectApplication(message: "Failed to select application.")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Verify PIN
|
guard let connection = try? await getConnection() else {
|
||||||
let verifyApdu = YubiKeyAPDU.verify(password: pin)
|
cancellation()
|
||||||
smartCard.executeCommand(verifyApdu) { _, error in
|
return
|
||||||
guard error == nil else {
|
}
|
||||||
errorHandler(AppError.yubiKey(.verify(message: "Failed to verify PIN.")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let applicationRelatedDataApdu = YubiKeyAPDU.get_application_related_data()
|
guard let smartCard = connection.smartCardInterface else {
|
||||||
smartCard.executeCommand(applicationRelatedDataApdu) { data, _ in
|
throw AppError.yubiKey(.connection(message: "Failed to get smart card interface."))
|
||||||
guard let data = data else {
|
}
|
||||||
errorHandler(AppError.yubiKey(.decipher(message: "Failed to get application related data.")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isEncryptKeyAlgoRSA(data) {
|
try await selectOpenPGPApplication(smartCard: smartCard)
|
||||||
errorHandler(AppError.yubiKey(.decipher(message: "Encryption key algorithm is not supported. Supported algorithm: RSA.")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Decipher
|
try await verifyPin(smartCard: smartCard, pin: pin)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
let decipherApdu = YubiKeyAPDU.decipher(data: mpi1)
|
guard let applicationRelatedData = try await getApplicationRelatedData(smartCard: smartCard) else {
|
||||||
smartCard.executeCommand(decipherApdu) { data, error in
|
throw AppError.yubiKey(.decipher(message: "Failed to get application related data."))
|
||||||
guard let data = data else {
|
}
|
||||||
errorHandler(AppError.yubiKey(.decipher(message: "Failed to execute decipher.")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if #available(iOS 13.0, *) {
|
if !isEncryptKeyAlgoRSA(applicationRelatedData) {
|
||||||
YubiKitManager.shared.stopNFCConnection()
|
throw AppError.yubiKey(.decipher(message: "Encryption key algorithm is not supported. Supported algorithm: RSA."))
|
||||||
}
|
}
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var error: NSError?
|
let (cmd_chaining, _) = getCapabilities(applicationRelatedData)
|
||||||
let message = CryptoNewPGPMessage(ciphertext)
|
|
||||||
|
|
||||||
guard let plaintext = Gopenpgp.HelperPassDecryptWithSessionKey(message, session_key, &error)?.data else {
|
let deciphered = try await decipher(smartCard: smartCard, ciphertext: encryptedData, chained: cmd_chaining)
|
||||||
errorHandler(AppError.yubiKey(.decipher(message: "Failed to decrypt with session key.")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let plaintext_str = String(data: plaintext, encoding: .utf8) else {
|
YubiKitManager.shared.stopNFCConnection()
|
||||||
errorHandler(AppError.yubiKey(.decipher(message: "Failed to convert plaintext to string.")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let password = try? Password(name: passwordEntity.getName(), url: passwordEntity.getURL(), plainText: plaintext_str) else {
|
let plaintext = try decryptPassword(deciphered: deciphered, ciphertext: encryptedData)
|
||||||
errorHandler(AppError.yubiKey(.decipher(message: "Failed to construct password.")))
|
guard let password = try? Password(name: passwordEntity.getName(), url: passwordEntity.getURL(), plainText: plaintext) else {
|
||||||
return
|
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<String?, Never>) 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<YKFConnectionProtocol?, Error>) 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<Error?, Never>) 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<Error?, Never>) 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<Data?, Error>) in
|
||||||
|
smartCard.executeCommand(apdu) { data, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
} else {
|
||||||
|
continuation.resume(returning: data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ public enum YubiKeyAPDU {
|
||||||
return verifyApdu
|
return verifyApdu
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decipher(data: Data) -> YKFAPDU {
|
public static func decipherExtended(data: Data) -> [YKFAPDU] {
|
||||||
var apdu: [UInt8] = []
|
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
|
apdu += [0x2A, 0x80, 0x86] // INS, P1, P2: PSO.DECIPHER
|
||||||
// Lc, An extended Lc field consists of three bytes:
|
// 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.).
|
// 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 += [0x00]
|
||||||
apdu += data
|
apdu += data
|
||||||
apdu += [0x02, 0x00]
|
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 {
|
public static func get_application_related_data() -> YKFAPDU {
|
||||||
|
|
@ -51,4 +74,14 @@ public enum YubiKeyAPDU {
|
||||||
apdu += [0x00]
|
apdu += [0x00]
|
||||||
return YKFAPDU(data: Data(apdu))!
|
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)])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue