From 5ab04f77e8cd159d3001f6a672760d3830500d1b Mon Sep 17 00:00:00 2001 From: Marek Stransky <77441794+Hopsaheysa@users.noreply.github.com> Date: Fri, 10 Nov 2023 16:56:11 +0100 Subject: [PATCH] Added support of QR Code & Deeplink - Proximity check (#122) * Added support of QR Code & Deeplink - Proximity check * Return incorrectly removed init for backward compatibility * Rename otp to totp * Fix networking serialization tests * Add offline totp * Test totp authorization * Fix lint and missing file * Implement remarks * Add `TOTPParserTests` * Minor fixes * Fix incorrect name of otp in `WMTProximityCheckData` * Bump networking dependency to 1.2.0. * Remove Package.swift from repo * Implement remarks * Remove unused host in TOTPUtils * Comment changed * Bump Networking version to 1.2.0. * Increase minimal ios version to 12 + Fix comment on WMTOperationTOTPData * Minor naming changes * Remove `Packege.resolved` add `Package` * Remove duplicated empty lines * Update podspec * Add docs * Fix placement of the TOTP WMPTProximityCheck * Remove unnecessary info from docs * Add info to PowerAuth compatibility table in SDK-Integration.md --- Cartfile | 2 +- Cartfile.resolved | 2 +- Deploy/WultraMobileTokenSDK.podspec | 6 +- Package.swift | 4 +- WultraMobileTokenSDK.podspec | 6 +- .../project.pbxproj | 14 ++- .../ConfigFiles/Config.xcconfig | 2 +- .../Model/Requests/WMTAuthorizationData.swift | 41 ++++++++- .../UserOperation/WMTProximityCheck.swift | 44 +++++++++ .../UserOperation/WMTUserOperation.swift | 3 + .../Operations/Model/WMTOperation.swift | 8 ++ .../Operations/QR/WMTQROperation.swift | 9 +- .../Operations/QR/WMTQROperationParser.swift | 8 +- .../Service/WMTOperationsImpl.swift | 6 +- .../Operations/Utils/WMTTOTPUtils.swift | 91 +++++++++++++++++++ .../NetworkingObjectsTests.swift | 60 +++++++++++- .../QROperationParserTests.swift | 43 ++++++++- .../TOTPParserTests.swift | 59 ++++++++++++ docs/Error-Handling.md | 1 + docs/SDK-Integration.md | 16 ++-- docs/Using-Operations-Service.md | 62 ++++++++++++- 21 files changed, 456 insertions(+), 31 deletions(-) create mode 100644 WultraMobileTokenSDK/Operations/Model/UserOperation/WMTProximityCheck.swift create mode 100644 WultraMobileTokenSDK/Operations/Utils/WMTTOTPUtils.swift create mode 100644 WultraMobileTokenSDKTests/TOTPParserTests.swift diff --git a/Cartfile b/Cartfile index 6d0ec97..5bcb826 100644 --- a/Cartfile +++ b/Cartfile @@ -1 +1 @@ -github "wultra/networking-apple" "1.1.7" \ No newline at end of file +github "wultra/networking-apple" "1.2.0" diff --git a/Cartfile.resolved b/Cartfile.resolved index 8e488d6..2eecbdd 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,3 +1,3 @@ binary "https://raw.githubusercontent.com/wultra/powerauth-mobile-sdk-spm/1.7.6/PowerAuth2.json" "1.7.6" binary "https://raw.githubusercontent.com/wultra/powerauth-mobile-sdk-spm/1.7.6/PowerAuthCore.json" "1.7.6" -github "wultra/networking-apple" "1.1.7" +github "wultra/networking-apple" "1.2.0" diff --git a/Deploy/WultraMobileTokenSDK.podspec b/Deploy/WultraMobileTokenSDK.podspec index 3e2bbe4..62ed148 100644 --- a/Deploy/WultraMobileTokenSDK.podspec +++ b/Deploy/WultraMobileTokenSDK.podspec @@ -10,7 +10,7 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/wultra/mtoken-sdk-ios.git', :tag => s.version } # Deployment targets s.swift_version = '5.7' - s.ios.deployment_target = '11.0' + s.ios.deployment_target = '12.0' # Sources s.default_subspec = 'Operations' @@ -18,8 +18,8 @@ Pod::Spec.new do |s| # 'Common' subspec s.subspec 'Common' do |sub| sub.source_files = 'WultraMobileTokenSDK/Common/**/*.swift' - sub.dependency 'PowerAuth2', '>= 1.7.3' - sub.dependency 'WultraPowerAuthNetworking', '>= 1.1.7' + sub.dependency 'PowerAuth2', '~> 1.7.3' + sub.dependency 'WultraPowerAuthNetworking', '~> 1.2.0' end # 'Operations' subspec diff --git a/Package.swift b/Package.swift index bd9bf5f..3b75e5f 100644 --- a/Package.swift +++ b/Package.swift @@ -5,14 +5,14 @@ import PackageDescription let package = Package( name: "WultraMobileTokenSDK", platforms: [ - .iOS(.v11) + .iOS(.v12) ], products: [ .library(name: "WultraMobileTokenSDK", targets: ["WultraMobileTokenSDK"]) ], dependencies: [ .package(url: "https://github.com/wultra/powerauth-mobile-sdk-spm.git", .upToNextMinor(from: "1.7.8")), - .package(url: "https://github.com/wultra/networking-apple.git", .upToNextMinor(from: "1.1.7")) + .package(url: "https://github.com/wultra/networking-apple.git", .upToNextMinor(from: "1.2.0")) ], targets: [ .target( diff --git a/WultraMobileTokenSDK.podspec b/WultraMobileTokenSDK.podspec index 515f902..1179b8e 100644 --- a/WultraMobileTokenSDK.podspec +++ b/WultraMobileTokenSDK.podspec @@ -10,7 +10,7 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/wultra/mtoken-sdk-ios.git', :tag => s.version } # Deployment targets s.swift_version = '5.7' - s.ios.deployment_target = '11.0' + s.ios.deployment_target = '12.0' # Sources s.default_subspec = 'Operations' @@ -18,8 +18,8 @@ Pod::Spec.new do |s| # 'Common' subspec s.subspec 'Common' do |sub| sub.source_files = 'WultraMobileTokenSDK/Common/**/*.swift' - sub.dependency 'PowerAuth2', '>= 1.7.3' - sub.dependency 'WultraPowerAuthNetworking', '>= 1.1.7' + sub.dependency 'PowerAuth2', '~> 1.7.3' + sub.dependency 'WultraPowerAuthNetworking', '~> 1.2.0' end # 'Operations' subspec diff --git a/WultraMobileTokenSDK.xcodeproj/project.pbxproj b/WultraMobileTokenSDK.xcodeproj/project.pbxproj index 2bd5988..06ff6ba 100644 --- a/WultraMobileTokenSDK.xcodeproj/project.pbxproj +++ b/WultraMobileTokenSDK.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -71,6 +71,9 @@ EA6DDF0F29F8036B0011E234 /* WMTPreApprovalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6DDF0E29F8036B0011E234 /* WMTPreApprovalScreen.swift */; }; EA6DDF1A29F804D60011E234 /* WMTPostApprovalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6DDF1929F804D60011E234 /* WMTPostApprovalScreen.swift */; }; EA6DDF1C29F807230011E234 /* OperationUIDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6DDF1B29F807230011E234 /* OperationUIDataTests.swift */; }; + EA9CE2BE2AEAA9FD00FE4E35 /* WMTProximityCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9CE2BD2AEAA9FD00FE4E35 /* WMTProximityCheck.swift */; }; + EA9CE2C22AEBDB0D00FE4E35 /* WMTTOTPUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9CE2C12AEBDB0D00FE4E35 /* WMTTOTPUtils.swift */; }; + EAB7054A2AF1161500756AC2 /* TOTPParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB705492AF1161500756AC2 /* TOTPParserTests.swift */; }; EACAF7B02A126B7D0021CA54 /* WMTJsonValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACAF7AF2A126B7D0021CA54 /* WMTJsonValue.swift */; }; /* End PBXBuildFile section */ @@ -154,6 +157,9 @@ EA6DDF0E29F8036B0011E234 /* WMTPreApprovalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WMTPreApprovalScreen.swift; sourceTree = ""; }; EA6DDF1929F804D60011E234 /* WMTPostApprovalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WMTPostApprovalScreen.swift; sourceTree = ""; }; EA6DDF1B29F807230011E234 /* OperationUIDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationUIDataTests.swift; sourceTree = ""; }; + EA9CE2BD2AEAA9FD00FE4E35 /* WMTProximityCheck.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WMTProximityCheck.swift; sourceTree = ""; }; + EA9CE2C12AEBDB0D00FE4E35 /* WMTTOTPUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WMTTOTPUtils.swift; sourceTree = ""; }; + EAB705492AF1161500756AC2 /* TOTPParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPParserTests.swift; sourceTree = ""; }; EACAF7AF2A126B7D0021CA54 /* WMTJsonValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WMTJsonValue.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -223,6 +229,7 @@ DC8CB205244DD007009DDAA3 /* WMTAllowedOperationSignature.swift */, DCE5EAAF26BD81150061861A /* WMTOperationHistoryEntry.swift */, EA294F3C29F6A07A00A0494E /* WMTOperationUIData.swift */, + EA9CE2BD2AEAA9FD00FE4E35 /* WMTProximityCheck.swift */, ); path = UserOperation; sourceTree = ""; @@ -270,6 +277,7 @@ DC395C0924E55B9B0007C36E /* PushParserTests.swift */, DC6EDB7825A49ED900A229E4 /* OperationExpirationTests.swift */, DC616235248508F8000DED17 /* QROperationParserTests.swift */, + EAB705492AF1161500756AC2 /* TOTPParserTests.swift */, ); path = WultraMobileTokenSDKTests; sourceTree = ""; @@ -287,6 +295,7 @@ DC6E52D4259C959900FC25BE /* Utils */ = { isa = PBXGroup; children = ( + EA9CE2C12AEBDB0D00FE4E35 /* WMTTOTPUtils.swift */, DC6E52D5259C964600FC25BE /* WMTOperationExpirationWatcher.swift */, EACAF7AF2A126B7D0021CA54 /* WMTJsonValue.swift */, ); @@ -576,6 +585,7 @@ DC61624224852B6D000DED17 /* NetworkingObjectsTests.swift in Sources */, DC395C0A24E55B9B0007C36E /* PushParserTests.swift in Sources */, DC6EDB7925A49ED900A229E4 /* OperationExpirationTests.swift in Sources */, + EAB7054A2AF1161500756AC2 /* TOTPParserTests.swift in Sources */, DC616236248508F8000DED17 /* QROperationParserTests.swift in Sources */, EA6DDF1C29F807230011E234 /* OperationUIDataTests.swift in Sources */, DCE660D124CEBECA00870E53 /* IntegrationTests.swift in Sources */, @@ -616,6 +626,7 @@ BFEEB2092937A2680047941D /* WMTInboxGetList.swift in Sources */, BFEEB20729379F960047941D /* WMTInboxSetMessageRead.swift in Sources */, EA44366A29F9294600DDEC1C /* WMTPostApprovaScreenReview.swift in Sources */, + EA9CE2C22AEBDB0D00FE4E35 /* WMTTOTPUtils.swift in Sources */, EA294F3D29F6A07A00A0494E /* WMTOperationUIData.swift in Sources */, DCC5CCB32449F8CD004679AC /* WMTOperationAttribute.swift in Sources */, DCC5CCAC2449F765004679AC /* WMTOperationsImpl.swift in Sources */, @@ -632,6 +643,7 @@ DCAB7BCA24580BAC0006989D /* WMTQROperation.swift in Sources */, DCC5CCBF2449F981004679AC /* WMTOperationAttributePartyInfo.swift in Sources */, DC81D1CB244F451E00F80CD6 /* WMTPushImpl.swift in Sources */, + EA9CE2BE2AEAA9FD00FE4E35 /* WMTProximityCheck.swift in Sources */, DC488041292282FF00DB844B /* WMTInboxEndpoints.swift in Sources */, BF53DFC82971905600829814 /* WMTInboxContentType.swift in Sources */, DCA43C6D2993F63E0059A163 /* WMTOperationAttributeImage.swift in Sources */, diff --git a/WultraMobileTokenSDK/ConfigFiles/Config.xcconfig b/WultraMobileTokenSDK/ConfigFiles/Config.xcconfig index 4cac2f0..630288b 100644 --- a/WultraMobileTokenSDK/ConfigFiles/Config.xcconfig +++ b/WultraMobileTokenSDK/ConfigFiles/Config.xcconfig @@ -20,7 +20,7 @@ SWIFT_SWIFT3_OBJC_INFERENCE = Off // SDK SDKROOT = iphoneos -IPHONEOS_DEPLOYMENT_TARGET = 10.0 +IPHONEOS_DEPLOYMENT_TARGET = 12.0 // FRAMEWORKS FRAMEWORK_SEARCH_PATHS = $(inherited) $(PROJECT_DIR)/Carthage/Build/ diff --git a/WultraMobileTokenSDK/Operations/Model/Requests/WMTAuthorizationData.swift b/WultraMobileTokenSDK/Operations/Model/Requests/WMTAuthorizationData.swift index 4da61c0..9c3e48b 100644 --- a/WultraMobileTokenSDK/Operations/Model/Requests/WMTAuthorizationData.swift +++ b/WultraMobileTokenSDK/Operations/Model/Requests/WMTAuthorizationData.swift @@ -25,8 +25,45 @@ class WMTAuthorizationData: Codable { /// Operation id let id: String - init(operationId: String, operationData: String) { + /// Proximity OTP data + let proximityCheck: WMTProximityCheckData? + + init(operationId: String, operationData: String, proximityCheck: WMTProximityCheckData? = nil) { + self.id = operationId self.data = operationData - self.id = operationId + self.proximityCheck = proximityCheck + } + + init(operation: WMTOperation, timestampSigned: Date = Date()) { + self.id = operation.id + self.data = operation.data + + guard let proximityCheck = operation.proximityCheck else { + self.proximityCheck = nil + return + } + + self.proximityCheck = WMTProximityCheckData( + otp: proximityCheck.totp, + type: proximityCheck.type, + timestampRequested: proximityCheck.timestampRequested, + timestampSigned: timestampSigned + ) } } + +/// Internal proximity check data used for authorization +struct WMTProximityCheckData: Codable { + + /// Tha actual OTP code + let otp: String + + /// Type of the Proximity check + let type: WMTProximityCheckType + + /// Timestamp when the operation was delivered to the app + let timestampRequested: Date + + /// Timestamp when the operation was signed + let timestampSigned: Date +} diff --git a/WultraMobileTokenSDK/Operations/Model/UserOperation/WMTProximityCheck.swift b/WultraMobileTokenSDK/Operations/Model/UserOperation/WMTProximityCheck.swift new file mode 100644 index 0000000..e05411e --- /dev/null +++ b/WultraMobileTokenSDK/Operations/Model/UserOperation/WMTProximityCheck.swift @@ -0,0 +1,44 @@ +// +// Copyright 2023 Wultra s.r.o. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions +// and limitations under the License. +// + +import Foundation + +/// Object which is used to hold data about proximity check +/// +/// Data shall be assigned to the operation when obtained +public class WMTProximityCheck: Codable { + + /// Tha actual Time-based one time password + public let totp: String + + /// Type of the Proximity check + public let type: WMTProximityCheckType + + /// Timestamp when the operation was scanned (qrCode) or delivered to the device (deeplink) + public let timestampRequested: Date + + public init(totp: String, type: WMTProximityCheckType, timestampRequested: Date = Date()) { + self.totp = totp + self.type = type + self.timestampRequested = timestampRequested + } +} + +/// Types of possible Proximity Checks +public enum WMTProximityCheckType: String, Codable { + case qrCode = "QR_CODE" + case deeplink = "DEEPLINK" +} diff --git a/WultraMobileTokenSDK/Operations/Model/UserOperation/WMTUserOperation.swift b/WultraMobileTokenSDK/Operations/Model/UserOperation/WMTUserOperation.swift index b9bf8d0..639a1ea 100644 --- a/WultraMobileTokenSDK/Operations/Model/UserOperation/WMTUserOperation.swift +++ b/WultraMobileTokenSDK/Operations/Model/UserOperation/WMTUserOperation.swift @@ -58,4 +58,7 @@ open class WMTUserOperation: WMTOperation, Codable { /// /// Additional UI data such as Pre-Approval Screen or Post-Approval Screen should be presented. public let ui: WMTOperationUIData? + + /// Proximity Check Data to be passed when OTP is handed to the app + public var proximityCheck: WMTProximityCheck? } diff --git a/WultraMobileTokenSDK/Operations/Model/WMTOperation.swift b/WultraMobileTokenSDK/Operations/Model/WMTOperation.swift index 2bf7fc9..1185c56 100644 --- a/WultraMobileTokenSDK/Operations/Model/WMTOperation.swift +++ b/WultraMobileTokenSDK/Operations/Model/WMTOperation.swift @@ -25,4 +25,12 @@ public protocol WMTOperation { /// Data for signing var data: String { get } + + /// Additional information with proximity check data + var proximityCheck: WMTProximityCheck? { get } +} + +/// WMTOperation extension which sets proximityCheck to be nil for backwards compatibility +public extension WMTOperation { + var proximityCheck: WMTProximityCheck? { nil } } diff --git a/WultraMobileTokenSDK/Operations/QR/WMTQROperation.swift b/WultraMobileTokenSDK/Operations/QR/WMTQROperation.swift index 2a11093..f53b5b2 100644 --- a/WultraMobileTokenSDK/Operations/QR/WMTQROperation.swift +++ b/WultraMobileTokenSDK/Operations/QR/WMTQROperation.swift @@ -37,6 +37,9 @@ public struct WMTQROperation { /// Flags associated with the operation public let flags: QROperationFlags + /// Additional Time-based one time password for proximity check + public let totp: String? + /// Data for signature validation public let signedData: Data @@ -52,7 +55,11 @@ public struct WMTQROperation { } internal var dataForOfflineSigning: Data { - return "\(operationId)&\(operationData.sourceString)".data(using: .utf8)! + if let totp = totp { + return "\(operationId)&\(operationData.sourceString)&\(totp)".data(using: .utf8)! + } else { + return "\(operationId)&\(operationData.sourceString)".data(using: .utf8)! + } } } diff --git a/WultraMobileTokenSDK/Operations/QR/WMTQROperationParser.swift b/WultraMobileTokenSDK/Operations/QR/WMTQROperationParser.swift index b779b56..61df439 100644 --- a/WultraMobileTokenSDK/Operations/QR/WMTQROperationParser.swift +++ b/WultraMobileTokenSDK/Operations/QR/WMTQROperationParser.swift @@ -30,10 +30,10 @@ public class WMTQROperationParser { private static let minimumAttributeFields = 7 /// Current number of lines in input string, supported by this parser - private static let currentAttributeFields = 7 + private static let currentAttributeFields = 8 /// Maximum number of operation data fields supported in this version. - private static let maximumDataFields = 5 + private static let maximumDataFields = 6 /// Parses input string into `WMTQROperationData` structure. public func parse(string: String) -> WMTQROperationParseResult { @@ -47,6 +47,7 @@ public class WMTQROperationParser { let message = parseAttributeText(from: String(attributes[2])) let dataString = String(attributes[3]) let flagsString = String(attributes[4]) + let totp = attributes.count > WMTQROperationParser.minimumAttributeFields ? String(attributes[5]) : nil // Signature and nonce are always located at last lines let nonce = String(attributes[attributes.count - 2]) let signatureString = attributes[attributes.count - 1] @@ -74,7 +75,7 @@ public class WMTQROperationParser { // Parse flags let flags = parseOperationFlags(string: flagsString) - let isNewerFormat = attributes.count > WMTQROperationParser.currentAttributeFields + let isNewerFormat = attributes.count > WMTQROperationParser.currentAttributeFields // Build final structure return .success(WMTQROperation( @@ -84,6 +85,7 @@ public class WMTQROperationParser { operationData: formData, nonce: nonce, flags: flags, + totp: totp, signedData: signedData, signature: signature, isNewerFormat: isNewerFormat) diff --git a/WultraMobileTokenSDK/Operations/Service/WMTOperationsImpl.swift b/WultraMobileTokenSDK/Operations/Service/WMTOperationsImpl.swift index ec027bf..9a6d98a 100644 --- a/WultraMobileTokenSDK/Operations/Service/WMTOperationsImpl.swift +++ b/WultraMobileTokenSDK/Operations/Service/WMTOperationsImpl.swift @@ -82,6 +82,8 @@ public extension WMTErrorReason { static let operations_authExpired = WMTErrorReason(rawValue: "operations_authExpired") /// Operation has expired when trying to reject the operation. static let operations_rejectExpired = WMTErrorReason(rawValue: "operations_rejectExpired") + /// Operation action failed. + static let operations_failed = WMTErrorReason(rawValue: "operations_failed") /// Couldn't sign QR operation. static let operations_QROperationFailed = WMTErrorReason(rawValue: "operations_QRFailed") @@ -241,7 +243,7 @@ class WMTOperationsImpl: WMTOperations, WMTService { return nil } - let data = WMTAuthorizationData(operationId: operation.id, operationData: operation.data) + let data = WMTAuthorizationData(operation: operation, timestampSigned: currentServerDate ?? Date()) return networking.post(data: .init(data), signedWith: authentication, to: WMTOperationEndpoints.Authorize.endpoint) { response, error in self.processResult(response: response, error: error) { result in @@ -436,6 +438,8 @@ class WMTOperationsImpl: WMTOperations, WMTService { } else { reason = .operations_rejectExpired } + case .operationFailed: + reason = .operations_failed default: break } diff --git a/WultraMobileTokenSDK/Operations/Utils/WMTTOTPUtils.swift b/WultraMobileTokenSDK/Operations/Utils/WMTTOTPUtils.swift new file mode 100644 index 0000000..0ffb766 --- /dev/null +++ b/WultraMobileTokenSDK/Operations/Utils/WMTTOTPUtils.swift @@ -0,0 +1,91 @@ +// +// Copyright 2023 Wultra s.r.o. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions +// and limitations under the License. +// + +import Foundation + +/// Utility class used for handling TOTP +public class WMTTOTPUtils { + + /// Method accepts deeeplink URL and returns payload data + public static func parseDeeplink(url: URL) -> WMTOperationTOTPData? { + + guard let components = URLComponents(string: url.absoluteString) else { + D.error("Failed to get URLComponents: URLString is malformed") + return nil + } + + guard let queryItems = components.queryItems else { + D.error("Failed to get URLComponents queryItems") + return nil + } + + guard let code = queryItems.first?.value else { + D.error("Failed to get Query Items value for parsing") + return nil + } + + guard let data = parseJWT(code: code) else { return nil } + + return data + } + + /// Method accepts scanned code as a String and returns payload data + public static func parseQRCode(code: String) -> WMTOperationTOTPData? { + return parseJWT(code: code) + } + + private static func parseJWT(code: String) -> WMTOperationTOTPData? { + let jwtParts = code.split(separator: ".") + + // At this moment we dont care about header, we want only payload which is the second part of JWT + let jwtBase64String = jwtParts.count > 1 ? String(jwtParts[1]) : "" + + if let base64EncodedData = jwtBase64String.data(using: .utf8), + let dataPayload = Data(base64Encoded: base64EncodedData) { + do { + return try JSONDecoder().decode(WMTOperationTOTPData.self, from: dataPayload) + } catch { + D.error("Failed to decode JWT from: \(code)") + D.error("With error: \(error)") + return nil + } + } + + D.error("Failed to decode QR JWT from: \(jwtBase64String)") + return nil + } +} + +/// Data payload which is returned from JWT parser +public struct WMTOperationTOTPData: Codable { + + /// The actual Time-based one time password + public let totp: String + + /// The ID of the operations associated with the TOTP + public let operationId: String + + public enum Keys: String, CodingKey { + case totp = "totp" + case operationId = "oid" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Keys.self) + totp = try container.decode(String.self, forKey: .totp) + operationId = try container.decode(String.self, forKey: .operationId) + } +} diff --git a/WultraMobileTokenSDKTests/NetworkingObjectsTests.swift b/WultraMobileTokenSDKTests/NetworkingObjectsTests.swift index e93d30f..9974f2d 100644 --- a/WultraMobileTokenSDKTests/NetworkingObjectsTests.swift +++ b/WultraMobileTokenSDKTests/NetworkingObjectsTests.swift @@ -234,6 +234,34 @@ class NetworkingObjectsTests: XCTestCase { request.testSerialization(expectation: expectation) } + func testTOTPOperationAuthorizationRequest() { + + let response = """ + {"status":"OK","responseObject":[{"id":"47825519-35b8-469d-ad76-e42f85b9a31d","name":"login_preApproval","data":"A2","status":"PENDING","operationCreated":"2023-10-27T11:04:00+0000","operationExpires":"2023-10-27T11:54:00+0000","ui":{"preApprovalScreen":{"type":"QR_SCAN","heading":"Scan the QR code!","message":"To verify that you are close by, please scan the code from the monitor."}},"allowedSignatureType":{"type":"2FA","variants":["possession_knowledge","possession_biometry"]},"formData":{"title":"Login Approval","message":"Are you logging in to the internet banking?","attributes":[]}}],"currentTimestamp":"2023-10-27T11:04:15+0000"} + """ + guard let result = try? jsonDecoder.decode(WMTOperationListResponse.self, from: response.data(using: .utf8)!) else { + XCTFail("Failed to parse JSON data") + return + } + + guard let operations = result.responseObject else { + XCTFail("response object nil") + return + } + + let op = operations[0] + op.proximityCheck = WMTProximityCheck(totp: "12345678", type: .qrCode) + + let request = WMTOperationEndpoints.Authorize.EndpointType.RequestData(.init(operation: op)) + + let proximityCheck = request.requestObject?.proximityCheck + + XCTAssertEqual(request.requestObject?.data, "A2") + XCTAssertEqual(request.requestObject?.id, "47825519-35b8-469d-ad76-e42f85b9a31d") + XCTAssertEqual(proximityCheck?.type, .qrCode) + XCTAssertEqual(proximityCheck?.otp, "12345678") + } + func testOperationRejectionRequest() { let expectation = """ @@ -273,11 +301,39 @@ extension WPNRequestBase { func testSerialization(expectation: String) { - guard let data = try? JSONEncoder().encode(self), let jsonData = String(data: data, encoding: .utf8) else { + // Convert WPNRequestBase object to Data + guard let data = try? JSONEncoder().encode(self) else { XCTFail("Failed to encode request data") return } + + // Convert String to Data + guard let expectationData = expectation.data(using: .utf8) else { + XCTFail("Failed to encode expectation string") + return + } - XCTAssert(jsonData == expectation, "Serialized JSON doesn't match expected string") + // Convert to [String: Any] + guard let expectationDict = try? JSONSerialization.jsonObject(with: expectationData) as? [String: Any], + let jsonDataDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + XCTFail("Failed to cast data to format [String: Any]") + return + } + + // Convert back to data with sorted keys + guard let sortedExpectationData = try? JSONSerialization.data(withJSONObject: expectationDict, options: [.sortedKeys]), + let sortedJsonData = try? JSONSerialization.data(withJSONObject: jsonDataDict, options: [.sortedKeys]) else { + XCTFail("Failed to sort data") + return + } + + // Convert Data back to strings for comparison + guard let sortedExpectationString = String(data: sortedExpectationData, encoding: .utf8), + let sortedJsonDataString = String(data: sortedJsonData, encoding: .utf8) else { + XCTFail("Failed to cast data to string") + return + } + + XCTAssertEqual(sortedExpectationString, sortedJsonDataString, "Serialized Strings doesn't match") } } diff --git a/WultraMobileTokenSDKTests/QROperationParserTests.swift b/WultraMobileTokenSDKTests/QROperationParserTests.swift index d685e8e..59767f5 100644 --- a/WultraMobileTokenSDKTests/QROperationParserTests.swift +++ b/WultraMobileTokenSDKTests/QROperationParserTests.swift @@ -36,7 +36,7 @@ class QROperationParserTests: XCTestCase { // MARK: - Main tests - func testCurrentFormat() { + func testCurrentFormat() { // Without TOTP let parser = WMTQROperationParser() let qrcode = makeCode() let expectedSignedData = @@ -102,15 +102,54 @@ class QROperationParserTests: XCTestCase { } } + func testCurrentFormatWithTOTP() { + let parser = WMTQROperationParser() + let qrcode = makeCode(otherAttrs: ["12345678"]) + let expectedSignedData = + ("5ff1b1ed-a3cc-45a3-8ab0-ed60950312b6\n" + + "Payment\n" + + "Please confirm this payment\n" + + "A1*A100CZK*ICZ2730300000001165254011*D20180425*Thello world\n" + + "BCFX\n" + + "12345678\n" + + "AD8bOO0Df73kNaIGb3Vmpg==\n" + + "0").data(using: .utf8) + + guard case .success(let operation) = parser.parse(string: qrcode) else { + XCTFail("This should be parsed") + return + } + + XCTAssertTrue(operation.operationId == "5ff1b1ed-a3cc-45a3-8ab0-ed60950312b6") + XCTAssertTrue(operation.title == "Payment") + XCTAssertTrue(operation.message == "Please confirm this payment") + XCTAssertTrue(operation.flags.allowBiometryFactor == true) + XCTAssertTrue(operation.flags.flipButtons == true) + XCTAssertTrue(operation.flags.fraudWarning == true) + XCTAssertTrue(operation.flags.blockWhenOnCall == true) + XCTAssertTrue(operation.totp == "12345678") + XCTAssertTrue(operation.nonce == "AD8bOO0Df73kNaIGb3Vmpg==") + XCTAssertTrue(operation.signature.signature == "MEYCIQDby1Uq+MaxiAAGzKmE/McHzNOUrvAP2qqGBvSgcdtyjgIhAMo1sgqNa1pPZTFBhhKvCKFLGDuHuTTYexdmHFjUUIJW") + XCTAssertTrue(operation.signature.signingKey == .master) + XCTAssertTrue(operation.signedData == expectedSignedData) + + // Operation data + XCTAssertTrue(operation.operationData.version == .v1) + XCTAssertTrue(operation.operationData.templateId == 1) + XCTAssertTrue(operation.operationData.fields.count == 4) + XCTAssertTrue(operation.operationData.sourceString == "A1*A100CZK*ICZ2730300000001165254011*D20180425*Thello world") + } + func testForwardCompatibility() { let parser = WMTQROperationParser() - let qrcode = makeCode(operationData:"B2*Xtest", otherAttrs:["Some Additional Information"]) + let qrcode = makeCode(operationData:"B2*Xtest", otherAttrs:["12345678", "Some Additional Information"]) let expectedSignedData = ("5ff1b1ed-a3cc-45a3-8ab0-ed60950312b6\n" + "Payment\n" + "Please confirm this payment\n" + "B2*Xtest\n" + "BCFX\n" + + "12345678\n" + "Some Additional Information\n" + "AD8bOO0Df73kNaIGb3Vmpg==\n" + "0").data(using: .utf8) diff --git a/WultraMobileTokenSDKTests/TOTPParserTests.swift b/WultraMobileTokenSDKTests/TOTPParserTests.swift new file mode 100644 index 0000000..586de14 --- /dev/null +++ b/WultraMobileTokenSDKTests/TOTPParserTests.swift @@ -0,0 +1,59 @@ +// +// Copyright 2023 Wultra s.r.o. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions +// and limitations under the License. +// + +import XCTest +import WultraMobileTokenSDK + +final class TOTPParserTest: XCTestCase { + + func testQRTOTPParserWithEmptyCode() { + let code = "" + + XCTAssertNil(WMTTOTPUtils.parseQRCode(code: code)) + } + + func testQRTOTPParserWithShortCode() { + let code = "abc" + + XCTAssertNil(WMTTOTPUtils.parseQRCode(code: code)) + } + + func testQRTOTPParserWithValidCode() { + let code = "eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9.eyJvaWQiOiI2YTFjYjAwNy1mZjc1LTRmNDAtYTIxYi0wYjU0NmYwZjZjYWQiLCJ0b3RwIjoiNzM3NDMxOTQifQ==" + + XCTAssertEqual(WMTTOTPUtils.parseQRCode(code: code)?.totp, "73743194", "Parsing of totp failed") + XCTAssertEqual(WMTTOTPUtils.parseQRCode(code: code)?.operationId, "6a1cb007-ff75-4f40-a21b-0b546f0f6cad", "Parsing of operationId failed") + } + + + func testDeeplinkTOTPParserWithInvalidURL() { + let url = URL(string: "mtoken://an-invalid-url.com")! + XCTAssertNil(WMTTOTPUtils.parseDeeplink(url: url)) + } + + func testDeeplinkTOTPParserWithInvalidJWTCode() { + let url = URL(string: "mtoken://login?code=abc")! + + XCTAssertNil(WMTTOTPUtils.parseDeeplink(url: url)) + } + + func testDeeplinkTOTPParserWithValidJWTCode() { + let url = URL(string: "mtoken://login?code=eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9.eyJvaWQiOiJkZjYxMjhmYy1jYTUxLTQ0YjctYmVmYS1jYTBlMTQwOGFhNjMiLCJ0b3RwIjoiNTY3MjU0OTQifQ==")! + + XCTAssertEqual(WMTTOTPUtils.parseDeeplink(url: url)?.totp, "56725494", "Parsing of totp failed") + XCTAssertEqual(WMTTOTPUtils.parseDeeplink(url: url)?.operationId, "df6128fc-ca51-44b7-befa-ca0e1408aa63", "Parsing of operationId failed") + } +} diff --git a/docs/Error-Handling.md b/docs/Error-Handling.md index 17fcef0..e89508f 100644 --- a/docs/Error-Handling.md +++ b/docs/Error-Handling.md @@ -24,6 +24,7 @@ Every error produced by this library is of a `WMTError` type. This error contain |`operationAlreadyFailed`|Operation is already failed| |`operationAlreadyCancelled`|Operation is canceled| |`operationExpired`|Operation is expired| +|`operationFailed`|Default operation action failure| ## WMTErrorReason diff --git a/docs/SDK-Integration.md b/docs/SDK-Integration.md index 814887e..45d95f4 100644 --- a/docs/SDK-Integration.md +++ b/docs/SDK-Integration.md @@ -2,7 +2,7 @@ ## Requirements -- iOS 10.0+ +- iOS 12.0+ - [PowerAuth Mobile SDK](https://github.com/wultra/powerauth-mobile-sdk) needs to be available in your project ## Swift Package Manager @@ -17,7 +17,7 @@ import PackageDescription let package = Package( name: "YourLibrary", platforms: [ - .iOS(.v11) + .iOS(.v12) ], products: [ .library( @@ -26,7 +26,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/wultra/mtoken-sdk-ios.git", .from("1.4.1")) + .package(url: "https://github.com/wultra/mtoken-sdk-ios.git", .from("1.7.0")) ], targets: [ .target( @@ -39,15 +39,16 @@ let package = Package( ## Cocoapods -Ddd the following dependencies to your Podfile: +Add the following dependencies to your Podfile: ```rb pod 'WultraMobileTokenSDK/Operations' pod 'WultraMobileTokenSDK/Push' +pod 'WultraMobileTokenSDK/Inbox' ``` -Note: If you want to use only operations, you can omit the Push dependency. +Note: If you want to use only operations, you can omit the Push dependency & Inbox dependency. ## Guaranteed PowerAuth Compatibility @@ -57,7 +58,10 @@ Note: If you want to use only operations, you can omit the Push dependency. | `1.0.x` - `1.2.x` | `1.x.x` | | `1.3.x` | `1.6.x` | | `1.4.x` | `1.6.x` | +| `1.5.x` | `1.6.x` | +| `1.6.x` | `1.7.x` | +| `1.7.x` | `1.7.x` | ## Xcode Compatibility -We recommend using Xcode version 13.2 or newer. +We recommend using Xcode version 15.0 or newer. diff --git a/docs/Using-Operations-Service.md b/docs/Using-Operations-Service.md index e29ae2f..69e9de8 100644 --- a/docs/Using-Operations-Service.md +++ b/docs/Using-Operations-Service.md @@ -9,8 +9,9 @@ - [Reject an Operation](#reject-an-operation) - [Off-line Authorization](#off-line-authorization) - [Operations API Reference](#operations-api-reference) -- [WMTUserOperation](#WMTUserOperation) +- [WMTUserOperation](#wmtuseroperation) - [Creating a Custom Operation](#creating-a-custom-operation) +- [TOTP WMTProximityCheck](#totp-wmtproximitycheck) - [Error handling](#error-handling) ## Introduction @@ -436,6 +437,9 @@ class WMTUserOperation: WMTOperation { /// /// Additional UI data such as Pre-Approval Screen or Post-Approval Screen should be presented. public let ui: WMTOperationUIData? + + /// Proximity Check Data to be passed when OTP is handed to the app + public var proximityCheck: WMTProximityCheck? } ``` @@ -492,7 +496,7 @@ PreApprovalScreen types: - `WARNING` - `INFO` -- `QR_SCAN` +- `QR_SCAN` this type indicates that the `WMTProximityCheck` must be used - `UNKNOWN` PostApprovalScreen types: @@ -502,6 +506,25 @@ PostApprovalScreen types: - `REDIRECT` providing text for button, countdown, and redirection URL - `GENERIC` may contain any object +Definition of `WMTProximityCheck`: + +```swift +public class WMTProximityCheck: Codable { + /// Tha actual Time-based one time password + public let totp: String + /// Type of the Proximity check + public let type: WMTProximityCheckType + /// Timestamp when the operation was scanned (QR Code) or delivered to the device (Deeplink) + public let timestampRequested: Date +} +``` + +WMTProximityCheckType types: + +- `qrCode` TOTP was scanned from QR code +- `deeplink` TOTP was delivered to the app via Deeplink + + ### Subclassing WMTUserOperation `WMTUserOperation` class is `open` and can be subclassed. This is useful when your backend adds additional properties to operations retrieved via the `getOperations` API. @@ -561,9 +584,44 @@ public protocol WMTOperation { /// Data for signing var data: String { get } + + /// Additional information with proximity check data + var proximityCheck: WMTProximityCheck? { get } +} +``` + +### Utilizing the Proximity Check +When creating custom operations, you can now include proximity check data by conforming to the updated WMTOperation protocol. This enables you to enhance the security of your operations by considering proximity information during the authorization process. + +To maintain backward compatibility, a public extension has been added to the WMTOperation protocol. If your existing codebase does not require the use of the proximity check feature, the extension ensures seamless integration: + +```swift +public extension WMTOperation { + var proximityCheck: WMTProximityCheck? { nil } } ``` +## TOTP WMTProximityCheck + +Two-Factor Authentication (2FA) using Time-Based One-Time Passwords (TOTP) in the Operations Service is facilitated through the use of WMTProximityCheck. This allows secure approval of operations through QR code scanning or deeplink handling. + +- QR Code Flow: + +When the `WMTUserOperation` contains a `WMTPreApprovalScreen.qr`, the app should open the camera to scan the QR code before confirming the operation. Use the camera to scan the QR code containing the necessary data payload for the operation. + +- Deeplink Flow: + +When the app is launched via a deeplink, preserve the data from the deeplink and extract the relevant data. When operations are loaded compare the operation ID from the deeplink data to the operations within the app to find a match. + +- Assign TOTP and Type to the Operation +Once the QR code is scanned or match from the deeplink is found, create a `WMTProximityCheck` with: + - `totp`: The actual Time-Based One-Time Password. + - `type`: Set to `WMTProximityCheckType.qrCode` or `WMTProximityCheckType.deeplink`. + - `timestampRequested`: The timestamp when the QR code was scanned (by default, it is created as the current timestamp). + +- Authorizing the WMTProximityCheck +When authorization, the SDK will by default add `timestampSigned` to the `WMTProximityCheck` object. This timestamp indicates when the operation was signed. + ## Error handling Every error produced by the Operations Service is of a `WMTError` type. For more information see detailed [error handling documentation](Error-Handling.md).