diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2c1ad723..10d33d4f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,7 +54,7 @@ jobs: mkdir -p $TSL_FILES_DIRECTORY # SPM packages must exist and need at least 1 file. Mock files are generated in 'Run tests' step - for module in CommonsLib ConfigLib CryptoLib IdCardLib LibdigidocLib MobileIdLib SmartIdLib UtilsLib; do + for module in CommonsLib ConfigLib WebEidLib CryptoLib IdCardLib LibdigidocLib MobileIdLib SmartIdLib UtilsLib; do mock_dir="Modules/${module}/Tests/Mocks/Generated" mkdir -p "$mock_dir" echo "// Placeholder for generated mocks" > "${mock_dir}/__placeholder.swift" diff --git a/.gitignore b/.gitignore index 8041196b..a3eaeeea 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,5 @@ xcuserdata buildServer.json RIADigiDoc/Supporting files/GoogleService-Info.plist -#AASA -apple-app-site-association \ No newline at end of file +# AASA +apple-app-site-association diff --git a/Modules/CryptoLib/Sources/CryptoSwift/Domain/Models/OpenLdapSearchResult.swift b/Modules/CryptoLib/Sources/CryptoSwift/Domain/Models/OpenLdapSearchResult.swift new file mode 100644 index 00000000..eff3c785 --- /dev/null +++ b/Modules/CryptoLib/Sources/CryptoSwift/Domain/Models/OpenLdapSearchResult.swift @@ -0,0 +1,25 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import CryptoObjCWrapper + +public struct OpenLdapSearchResult: Sendable { + public var addressees: [Addressee] + public var tooManyResults: Bool +} diff --git a/Modules/CryptoLib/Sources/CryptoSwift/Ldap/OpenLdap.swift b/Modules/CryptoLib/Sources/CryptoSwift/Ldap/OpenLdap.swift index a1566e9c..c3653623 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/Ldap/OpenLdap.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/Ldap/OpenLdap.swift @@ -83,10 +83,7 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { } } - @MainActor public func search(identityCode: String) async -> ( - addressees: [Addressee], - tooManyResults: Bool - ) { + @MainActor public func search(identityCode: String) async -> OpenLdapSearchResult { var filePath: String? if let ldapCertFilePath = await self.ldapConfiguration.ldapCertsPath() { if fileManager.fileExists(atPath: ldapCertFilePath) { @@ -114,7 +111,10 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { tooManyResults = true } } - return (result, tooManyResults) + return OpenLdapSearchResult( + addressees: result, + tooManyResults: tooManyResults + ) } else { if let ldapCorpURL = await self.ldapConfiguration.getLdapCorpURL() { OpenLdap.logger().info("Searching with corporation keyword from LDAP") @@ -123,9 +123,15 @@ final public class OpenLdap: OpenLdapProtocol, Loggable { url: ldapCorpURL, certificatePath: filePath ) - return (addresses, found >= 50) + return OpenLdapSearchResult( + addressees: addresses, + tooManyResults: found >= 50 + ) } else { - return ([], false) + return OpenLdapSearchResult( + addressees: [], + tooManyResults: false + ) } } } diff --git a/Modules/CryptoLib/Sources/CryptoSwift/Protocol/OpenLdapProtocol.swift b/Modules/CryptoLib/Sources/CryptoSwift/Protocol/OpenLdapProtocol.swift index baa4bfec..86710677 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/Protocol/OpenLdapProtocol.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/Protocol/OpenLdapProtocol.swift @@ -20,8 +20,5 @@ import CryptoObjCWrapper /// @mockable public protocol OpenLdapProtocol: Sendable { - @MainActor func search(identityCode: String) async -> ( - addressees: [Addressee], - tooManyResults: Bool - ) + @MainActor func search(identityCode: String) async -> OpenLdapSearchResult } diff --git a/Modules/Test/CommonsTestShared/Sources/CommonsTestShared/Certificate/TestCertificateUtil.swift b/Modules/Test/CommonsTestShared/Sources/CommonsTestShared/Certificate/TestCertificateUtil.swift index 6e43650a..811344d6 100644 --- a/Modules/Test/CommonsTestShared/Sources/CommonsTestShared/Certificate/TestCertificateUtil.swift +++ b/Modules/Test/CommonsTestShared/Sources/CommonsTestShared/Certificate/TestCertificateUtil.swift @@ -23,15 +23,20 @@ public class TestCertificateUtil { public init() {} - public static func getSampleCertificate() -> Data { - // swiftlint:disable line_length - let cert = """ + // swiftlint:disable line_length + private static let cert = """ MIIEwjCCA6qgAwIBAgIUeYCoFyEHBfraNnsp4BCgKyVYfywwDQYJKoZIhvcNAQELBQAwgbIxCzAJBgNVBAYTAkVFMRIwEAYDVQQIDAlUZXN0U3RhdGUxETAPBgNVBAcMCFRlc3RDaXR5MRkwFwYDVQQKDBBUZXN0T3JnYW5pemF0aW9uMSMwIQYDVQQLDBpUZXN0T3JnYW5pemF0aW9uYWxVbml0TmFtZTEXMBUGA1UEAwwOVGVzdENvbW1vbk5hbWUxIzAhBgkqhkiG9w0BCQEWFHRlc3RAZW1haWwudGVzdGVtYWlsMB4XDTI1MDEzMTE3MDIxMloXDTI3MDUwNjE3MDIxMlowgcwxCzAJBgNVBAYTAkVFMRUwEwYDVQQIDAxTdWJqZWN0U3RhdGUxFDASBgNVBAcMC1N1YmplY3RDaXR5MSAwHgYDVQQKDBdTdWJqZWN0T3JnYW5pemF0aW9uTmFtZTEmMCQGA1UECwwdU3ViamVjdE9yZ2FuaXphdGlvbmFsVW5pdE5hbWUxGjAYBgNVBAMMEVN1YmplY3RDb21tb25OYW1lMSowKAYJKoZIhvcNAQkBFht0ZXN0c3ViamVjdEBlbWFpbC50ZXN0ZW1haWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDjOB/5LMTY7HaIDQtPI0rNszyO1GwNepf2Ol6XayOJOP3V8Y3bC3pcUobnlfXfphTqoNznYKLuV2Tz9bzALI1HCK9TsiAFA9DAJqEII+BhhK0Ei5LlaHwOSHWPBt5Tn8SYvPt37hcIz/fQrRR7Ezbxj8ukW6NV7LMcVr2rsRRFnvPNCkeEjGy7mpzJP2U7Ya4yiBaQpodlcLlYws9QnMrwwnPV5NpPTJ+ko6WwKKeqcuxOUkH1j3lJb5MdNaUtLjHVW++EpeYGRn9Ibz1xda9MgBFT4JMGhgjLx7CYUWt+DD4kcL5N5dpDGs52eRzG/tnOck3zrWHlO3Vf6QWrDX6zAgMBAAGjgbMwgbAwCQYDVR0TBAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwQQYDVR0RBDowOIIYc3ViamVjdC50ZXN0c3ViamVjdC50ZXN0ghx3d3cuc3ViamVjdC50ZXN0c3ViamVjdC50ZXN0MB0GA1UdDgQWBBTWjQtDdX0TrwKP/Tzgtp5x9fKXYjAfBgNVHSMEGDAWgBRxc4NGP1MWIHuIeGrjDN3udJThpzANBgkqhkiG9w0BAQsFAAOCAQEAcK2rdOzhWo6mnlYTjgbZdyCk1nKs4/jvmbx4MfTy/tKMq+OImEvC8TUg2myUzxL284p0WCCVWoALo2hwsYeYLSblfnDAsj90RMZQlDyA7rIEqrfXugqamj+hPLwPoEyZKipTkImT0mAGqakE63BkiP+SSEwveZ8YUr0XG369gHyaP8zv6XTqDkQYHx7kJQCHI87+wN+3XPIiYN5sqrf0Z147w/LO8a+XkOCD5JvTbAZB6sLI9dCvoeifd34l9JhDnlnb4SjnszC2k5gx78CNM1pF9jATS3A7mdz+TyttG4ks/i5/Mor416foXurIEh1oZTKOoxppMowq73c66rrH1g== """ - // swiftlint:enable line_length + // swiftlint:enable line_length + + public static func getSampleCertificate() -> Data { return Data(base64Encoded: cert) ?? Data() } + public static func getSampleCertificateString() -> String { + return cert + } + public static func getSampleCertificateWithHeaders() -> Data? { let certString = """ -----BEGIN CERTIFICATE----- diff --git a/Modules/UtilsLib/Sources/UtilsLib/System/SystemUtil.swift b/Modules/UtilsLib/Sources/UtilsLib/System/SystemUtil.swift index 99b226bc..7268af9c 100644 --- a/Modules/UtilsLib/Sources/UtilsLib/System/SystemUtil.swift +++ b/Modules/UtilsLib/Sources/UtilsLib/System/SystemUtil.swift @@ -44,7 +44,7 @@ public struct SystemUtil: Loggable { var systemInfo = utsname() uname(&systemInfo) let identifier = withUnsafeBytes(of: &systemInfo.machine) { raw in - String(cString: raw.bindMemory(to: CChar.self).baseAddress!) + String(bytes: raw.prefix { $0 != 0 }, encoding: .utf8) ?? "" } return identifier.lowercased() } diff --git a/Modules/WebEidLib/Package.swift b/Modules/WebEidLib/Package.swift new file mode 100644 index 00000000..f453c43e --- /dev/null +++ b/Modules/WebEidLib/Package.swift @@ -0,0 +1,60 @@ +// swift-tools-version: 6.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "WebEidLib", + platforms: [.iOS(.v18)], + products: [ + .library( + name: "WebEidLib", + targets: ["WebEidLib"] + ), + .library(name: "WebEidLibMocks", targets: ["WebEidLibMocks"]) + ], + dependencies: [ + .package(url: "https://github.com/filom/ASN1Decoder", exact: .init(1, 10, 0)), + .package(url: "https://github.com/hmlongco/Factory", exact: .init(3, 3, 2)), + .package(url: "https://github.com/Alamofire/Alamofire.git", exact: .init(5, 12, 0)), + .package(path: "../UtilsLib"), + .package(path: "../CommonsLib"), + .package(path: "../Test/CommonsTestShared") + ], + targets: [ + .target( + name: "WebEidLib", + dependencies: [ + "Alamofire", + "ASN1Decoder", + "UtilsLib", + "CommonsLib", + .product(name: "FactoryKit", package: "Factory") + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + .enableUpcomingFeature("SendableByDefault"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances") + ] + ), + .target( + name: "WebEidLibMocks", + dependencies: ["WebEidLib"], + path: "Tests/Mocks/Generated" + ), + .testTarget( + name: "WebEidLibTests", + dependencies: [ + "WebEidLib", + "WebEidLibMocks", + "UtilsLib", + "CommonsLib", + "CommonsTestShared", + .product(name: "UtilsLibMocks", package: "utilslib"), + .product(name: "CommonsLibMocks", package: "commonslib"), + .product(name: "FactoryTesting", package: "Factory") + ] + ) + ] +) diff --git a/Modules/WebEidLib/Sources/WebEidLib/DI/WebEidLibContainer.swift b/Modules/WebEidLib/Sources/WebEidLib/DI/WebEidLibContainer.swift new file mode 100644 index 00000000..70c93df2 --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/DI/WebEidLibContainer.swift @@ -0,0 +1,35 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import FactoryKit + +extension Container { + public var webEidAuthService: Factory { + self { + WebEidAuthService() + } + } + + public var webEidSignService: Factory { + self { + WebEidSignService() + } + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Error/WebEidError.swift b/Modules/WebEidLib/Sources/WebEidLib/Error/WebEidError.swift new file mode 100644 index 00000000..cce9189d --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Error/WebEidError.swift @@ -0,0 +1,87 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +// swiftlint:disable identifier_name +public enum WebEidErrorCode: String, Codable, Sendable { + case ERR_WEBEID_MOBILE_INVALID_REQUEST + case ERR_WEBEID_MOBILE_UNKNOWN_ERROR + case ERR_WEBEID_USER_CANCELLED +} +// swiftlint:enable identifier_name +public struct WebEidException: Error, LocalizedError, Sendable { + public let code: WebEidErrorCode + public let message: String + public let responseUri: String + + public var errorDescription: String? { + "\(code.rawValue): \(message) (responseUri: \(responseUri))" + } +} + +enum WebEidBuilderError: Error, LocalizedError, Sendable { + case invalidCertificate + case missingPublicKey + case invalidJSON + case invalidBase64 + + var errorDescription: String? { + switch self { + case .invalidCertificate: return "Invalid X.509 certificate" + case .missingPublicKey: return "Certificate public key is missing" + case .invalidJSON: return "Failed to serialize auth token JSON" + case .invalidBase64: return "Invalid Base64" + } + } +} + +enum WebEidAlgorithmUtilError: Error, LocalizedError, Sendable, Equatable { + case unsupportedKeyType + case unsupportedECKeyLength(Int) + case invalidBase64 + case invalidCertificate + case unsupportedHashFunction(String) + + var errorDescription: String? { + switch self { + case .unsupportedKeyType: + return "Unsupported key type" + case .unsupportedECKeyLength(let bits): + return "Unsupported EC key length: \(bits)" + case .invalidBase64: + return "Invalid Base64" + case .invalidCertificate: + return "Invalid X.509 certificate" + case .unsupportedHashFunction(let hashFunction): + return "Unsupported hash function: \(hashFunction)" + } + } +} + +enum WebEidResponseUtilError: Error, LocalizedError { + case invalidResponseURI + case couldNotBuildURL + + var errorDescription: String? { + switch self { + case .invalidResponseURI: return "Invalid response URI" + case .couldNotBuildURL: return "Could not build response URL" + } + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Extension/JSONCodable.swift b/Modules/WebEidLib/Sources/WebEidLib/Extension/JSONCodable.swift new file mode 100644 index 00000000..3307b3d8 --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Extension/JSONCodable.swift @@ -0,0 +1,39 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation + +// MARK: - Shared JSON helpers + +protocol JSONCodable: Codable {} + +extension JSONCodable { + /// Decode from raw JSON Data + static func from(jsonData: Data, decoder: JSONDecoder = JSONDecoder()) throws -> Self { + try decoder.decode(Self.self, from: jsonData) + } + + /// Encode to JSON Data + func toJSONData(pretty: Bool = false, encoder: JSONEncoder = JSONEncoder()) throws -> Data { + if pretty { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + return try encoder.encode(self) + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidAuthRequest.swift b/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidAuthRequest.swift new file mode 100644 index 00000000..7e87705e --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidAuthRequest.swift @@ -0,0 +1,46 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation + +public struct WebEidAuthRequest: JSONCodable, Equatable, Sendable { + public let challenge: String + public let loginUri: String + public let getSigningCertificate: Bool + public let origin: String + + public init( + challenge: String, + loginUri: String, + getSigningCertificate: Bool, + origin: String + ) { + self.challenge = challenge + self.loginUri = loginUri + self.getSigningCertificate = getSigningCertificate + self.origin = origin + } + + enum CodingKeys: String, CodingKey { + case challenge + case loginUri + case getSigningCertificate + case origin + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidCertificateRequest.swift b/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidCertificateRequest.swift new file mode 100644 index 00000000..b60a4e9a --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidCertificateRequest.swift @@ -0,0 +1,50 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation + +// MARK: - WebEidCertificateRequest + +public struct WebEidCertificateRequest: JSONCodable, Equatable, Sendable { + + public let responseUri: String + public let origin: String + + public init( + responseUri: String, + origin: String + ) { + self.responseUri = responseUri + self.origin = origin + } + + enum CodingKeys: String, CodingKey { + case responseUri + case origin + } +} + +// MARK: - Convenience Helpers + +extension WebEidCertificateRequest { + /// Convenience computed URL (safe conversion) + var responseURL: URL? { + URL(string: responseUri) + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidPersonalData.swift b/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidPersonalData.swift new file mode 100644 index 00000000..a093ba3d --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidPersonalData.swift @@ -0,0 +1,35 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation + +// MARK: - WebEidPersonalData + +public struct WebEidPersonalData: JSONCodable, Equatable, Sendable { + + public let givenNames: String + public let surname: String + public let personalCode: String + + enum CodingKeys: String, CodingKey { + case givenNames + case surname + case personalCode + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidSignRequest.swift b/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidSignRequest.swift new file mode 100644 index 00000000..45d02ca8 --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Model/WebEidSignRequest.swift @@ -0,0 +1,105 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Security + +// MARK: - WebEidSignRequest + +public struct WebEidSignRequest: JSONCodable, Equatable, Sendable { + + public let responseUri: String + public let origin: String + public let signingCertificate: SecCertificate + public let hash: String? + public let hashFunction: String? + public let personalData: WebEidPersonalData? + + enum CodingKeys: String, CodingKey { + case responseUri + case origin + case signingCertificate + case hash + case hashFunction + case personalData + } + + public init( + responseUri: String, + origin: String, + signingCertificate: SecCertificate, + hash: String?, + hashFunction: String?, + personalData: WebEidPersonalData? + ) { + self.responseUri = responseUri + self.origin = origin + self.signingCertificate = signingCertificate + self.hash = hash + self.hashFunction = hashFunction + self.personalData = personalData + } + + // MARK: - Codable + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + responseUri = try container.decode(String.self, forKey: .responseUri) + origin = try container.decode(String.self, forKey: .origin) + + let certBase64 = try container.decode(String.self, forKey: .signingCertificate) + guard let certData = Data(base64Encoded: certBase64), + let cert = SecCertificateCreateWithData(nil, certData as CFData) else { + throw DecodingError.dataCorruptedError( + forKey: .signingCertificate, + in: container, + debugDescription: "Invalid Base64 X.509 certificate" + ) + } + signingCertificate = cert + + hash = try container.decodeIfPresent(String.self, forKey: .hash) + hashFunction = try container.decodeIfPresent(String.self, forKey: .hashFunction) + personalData = try container.decodeIfPresent(WebEidPersonalData.self, forKey: .personalData) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(responseUri, forKey: .responseUri) + try container.encode(origin, forKey: .origin) + + let certData = SecCertificateCopyData(signingCertificate) as Data + try container.encode(certData.base64EncodedString(), forKey: .signingCertificate) + + try container.encodeIfPresent(hash, forKey: .hash) + try container.encodeIfPresent(hashFunction, forKey: .hashFunction) + try container.encodeIfPresent(personalData, forKey: .personalData) + } +} + +// MARK: - Convenience Helpers + +extension WebEidSignRequest { + /// Extract public key from certificate + var publicKey: SecKey? { + SecCertificateCopyKey(signingCertificate) + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidAuthService.swift b/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidAuthService.swift new file mode 100644 index 00000000..28040695 --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidAuthService.swift @@ -0,0 +1,80 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Security +import UtilsLib + +public actor WebEidAuthService: WebEidAuthServiceProtocol, Loggable { + + init() {} + + public func buildAuthToken( + authCert: Data, + signingCert: Data?, + signature: Data + ) throws -> Data { + guard let secCert = SecCertificateCreateWithData(nil, authCert as CFData) else { + throw WebEidBuilderError.invalidCertificate + } + guard let publicKey = SecCertificateCopyKey(secCert) else { + throw WebEidBuilderError.missingPublicKey + } + + let algorithm = try WebEidAlgorithmUtil.getAlgorithm(publicKey: publicKey) + + var token: [String: Any] = [ + "algorithm": algorithm, + "unverifiedCertificate": authCert.base64EncodedString(), + // TODO: hardcoded? NB! clarify with RIA + "issuerApp": "https://web-eid.eu/web-eid-mobile-app/releases/v1.0.0", + "signature": signature.base64EncodedString() + ] + + if let signingCert { + guard let signingSecCert = SecCertificateCreateWithData(nil, signingCert as CFData) else { + throw WebEidBuilderError.invalidCertificate + } + guard let signingPublicKey = SecCertificateCopyKey(signingSecCert) else { + throw WebEidBuilderError.missingPublicKey + } + + let supportedSignatureAlgorithms = try WebEidAlgorithmUtil + .buildSupportedSignatureAlgorithms(publicKey: signingPublicKey) + + let signingCertificates: [[String: Any]] = [ + [ + "certificate": signingCert.base64EncodedString(), + "supportedSignatureAlgorithms": supportedSignatureAlgorithms + ] + ] + + token["unverifiedSigningCertificates"] = signingCertificates + token["format"] = "web-eid:1.1" + } else { + token["format"] = "web-eid:1.0" + } + + guard JSONSerialization.isValidJSONObject(token) else { + throw WebEidBuilderError.invalidJSON + } + + return try JSONSerialization.data(withJSONObject: token, options: []) + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidAuthServiceProtocol.swift b/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidAuthServiceProtocol.swift new file mode 100644 index 00000000..adf2096d --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidAuthServiceProtocol.swift @@ -0,0 +1,29 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation + +/// @mockable +public protocol WebEidAuthServiceProtocol: Sendable { + func buildAuthToken( + authCert: Data, + signingCert: Data?, + signature: Data, + ) async throws -> Data +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidSignService.swift b/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidSignService.swift new file mode 100644 index 00000000..c9c25773 --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidSignService.swift @@ -0,0 +1,81 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Security +import UtilsLib + +public actor WebEidSignService: WebEidSignServiceProtocol, Loggable { + + init() {} + + public func buildCertificatePayload(signingCert: Data) async throws -> Data { + + guard let secCert = SecCertificateCreateWithData(nil, signingCert as CFData) else { + throw WebEidBuilderError.invalidCertificate + } + guard let publicKey = SecCertificateCopyKey(secCert) else { + throw WebEidBuilderError.missingPublicKey + } + + let supportedSignatureAlgorithms = try WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms( + publicKey: publicKey + ) + + let payload: [String: Any] = [ + "certificate": signingCert.base64EncodedString(), + "supportedSignatureAlgorithms": supportedSignatureAlgorithms + ] + + guard JSONSerialization.isValidJSONObject(payload) else { + throw WebEidBuilderError.invalidJSON + } + + return try JSONSerialization.data(withJSONObject: payload, options: []) + } + + public func buildSignPayload( + signingCert: String, + signature: Data, + hashFunction: String + ) async throws -> Data { + + let secCert = try WebEidAlgorithmUtil.parseCertificate(signingCertBase64: signingCert) + + guard let publicKey = SecCertificateCopyKey(secCert) else { + throw WebEidBuilderError.missingPublicKey + } + + let signatureAlgorithm = try WebEidAlgorithmUtil.buildSignatureAlgorithm( + publicKey: publicKey, + hashFunction: hashFunction + ) + + let payload: [String: Any] = [ + "signature": signature.base64EncodedString(), + "signatureAlgorithm": signatureAlgorithm + ] + + guard JSONSerialization.isValidJSONObject(payload) else { + throw WebEidBuilderError.invalidJSON + } + + return try JSONSerialization.data(withJSONObject: payload, options: []) + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidSignServiceProtocol.swift b/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidSignServiceProtocol.swift new file mode 100644 index 00000000..f3917968 --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Service/WebEidSignServiceProtocol.swift @@ -0,0 +1,31 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation + +/// @mockable +public protocol WebEidSignServiceProtocol: Sendable { + func buildCertificatePayload(signingCert: Data) async throws -> Data + + func buildSignPayload( + signingCert: String, + signature: Data, + hashFunction: String, + ) async throws -> Data +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Utils/WebEidAlgorithmUtil.swift b/Modules/WebEidLib/Sources/WebEidLib/Utils/WebEidAlgorithmUtil.swift new file mode 100644 index 00000000..eaf589b6 --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Utils/WebEidAlgorithmUtil.swift @@ -0,0 +1,165 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Security +import UtilsLib + +struct WebEidAlgorithmUtil: Loggable { + static let supportedHashFunctions: [String] = [ + "SHA-224", + "SHA-256", + "SHA-384", + "SHA-512", + "SHA3-224", + "SHA3-256", + "SHA3-384", + "SHA3-512" + ] + + /// returns an array of JSON-like dictionaries. + static func buildSupportedSignatureAlgorithms(publicKey: SecKey) throws -> [[String: Any]] { + let descriptor = try algorithmDescriptor(publicKey) + + return supportedHashFunctions.map { hashFunction in + [ + "cryptoAlgorithm": descriptor.cryptoAlgorithm, + "hashFunction": hashFunction, + "paddingScheme": descriptor.paddingScheme + ] + } + } + + private static func algorithmDescriptor( + _ publicKey: SecKey + ) throws -> (cryptoAlgorithm: String, paddingScheme: String) { + if try isEC(publicKey) { + return ("ECC", "NONE") + } + if try isRSA(publicKey) { + return ("RSA", "PKCS1.5") + } + throw WebEidAlgorithmUtilError.unsupportedKeyType + } + + /// Uses EC key size (bits) -> ES256/ES384/ES512 (P-521 -> ES512) + static func getAlgorithm(publicKey: SecKey) throws -> String { + let bits = try getECKeySizeBits(publicKey) + + switch bits { + case 256: return "ES256" + case 384: return "ES384" + case 521: return "ES512" + default: + throw WebEidAlgorithmUtilError.unsupportedECKeyLength(bits) + } + } + + static func buildSignatureAlgorithm( + publicKey: SecKey, + hashFunction: String + ) throws -> [String: Any] { + + let normalizedHashFunction = hashFunction.uppercased() + + guard supportedHashFunctions.contains(normalizedHashFunction) else { + throw WebEidAlgorithmUtilError.unsupportedHashFunction(hashFunction) + } + + let descriptor = try algorithmDescriptor(publicKey) + + return [ + "cryptoAlgorithm": descriptor.cryptoAlgorithm, + "hashFunction": normalizedHashFunction, + "paddingScheme": descriptor.paddingScheme + ] + } + + /// returns SecCertificate created from Base64 DER. + static func parseCertificate(signingCertBase64: String) throws -> SecCertificate { + guard let certBytes = base64DecodeFlexible(signingCertBase64) else { + throw WebEidAlgorithmUtilError.invalidBase64 + } + guard let cert = certificate(from: certBytes) else { + throw WebEidAlgorithmUtilError.invalidCertificate + } + return cert + } + + // MARK: - Helpers + + static func certificate(from data: Data) -> SecCertificate? { + return SecCertificateCreateWithData(nil, data as CFData) + } + + static func base64DecodeFlexible(_ str: String) -> Data? { + var padded = str + .replacing("-", with: "+") + .replacing("_", with: "/") + let remainder = padded.count % 4 + if remainder != 0 { + padded += String(repeating: "=", count: 4 - remainder) + } + return Data(base64Encoded: padded, options: [.ignoreUnknownCharacters]) + } + + private static func getKeyAttributes(_ key: SecKey) throws -> [CFString: Any] { + guard let attrs = SecKeyCopyAttributes(key) as? [CFString: Any] else { + throw WebEidAlgorithmUtilError.unsupportedKeyType + } + return attrs + } + + private static func isEC(_ key: SecKey) throws -> Bool { + let attrs = try getKeyAttributes(key) + return (attrs[kSecAttrKeyType] as? String) == (kSecAttrKeyTypeECSECPrimeRandom as String) + } + + private static func isRSA(_ key: SecKey) throws -> Bool { + let attrs = try getKeyAttributes(key) + return (attrs[kSecAttrKeyType] as? String) == (kSecAttrKeyTypeRSA as String) + } + + /// exposes key size directly (kSecAttrKeySizeInBits). + private static func getECKeySizeBits(_ key: SecKey) throws -> Int { + guard try isEC(key) else { + throw WebEidAlgorithmUtilError.unsupportedKeyType + } + let attrs = try getKeyAttributes(key) + guard let bits = attrs[kSecAttrKeySizeInBits] as? Int else { + throw WebEidAlgorithmUtilError.unsupportedKeyType + } + return bits + } +} + +/// serialize the returned JSON objects into Data/String: +protocol JSONSerializable {} + +extension Array: JSONSerializable {} +extension Dictionary: JSONSerializable {} + +extension JSONSerializable { + func toJSONData(pretty: Bool = false) throws -> Data { + try JSONSerialization.data( + withJSONObject: self, + options: pretty ? [.prettyPrinted] : [] + ) + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Utils/WebEidRequestParser.swift b/Modules/WebEidLib/Sources/WebEidLib/Utils/WebEidRequestParser.swift new file mode 100644 index 00000000..15f1b418 --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Utils/WebEidRequestParser.swift @@ -0,0 +1,337 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Security +import ASN1Decoder +import UtilsLib + +public struct WebEidRequestParser: Loggable { + + private static let minChallengeLength = 44 + private static let maxChallengeLength = 128 + private static let maxOriginLength = 255 + + // MARK: Public API + + public static func parseAuthURL(_ authURL: URL) throws -> WebEidAuthRequest { + let request = try decodeURLFragment(authURL) + + let challenge = (request["challenge"] as? String) ?? "" + let loginUriString = (request["loginUri"] as? String) ?? "" + let responseURL = try validateResponseURL(loginUriString) + + if challenge.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + challenge.count < minChallengeLength || + challenge.count > maxChallengeLength { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid challenge length", + responseUri: responseURL.absoluteString + ) + } + + let getSigningCertificate = (request["getSigningCertificate"] as? Bool) ?? false + + return WebEidAuthRequest( + challenge: challenge, + loginUri: responseURL.absoluteString, + getSigningCertificate: getSigningCertificate, + origin: try parseOrigin(responseURL) + ) + } + + public static func parseCertificateURL(_ url: URL) throws -> WebEidCertificateRequest { + let request = try decodeURLFragment(url) + let responseUriString = (request["responseUri"] as? String) ?? "" + let responseURL = try validateResponseURL(responseUriString) + + return WebEidCertificateRequest( + responseUri: responseURL.absoluteString, + origin: try parseOrigin(responseURL) + ) + } + + public static func parseSignURL(_ url: URL) throws -> WebEidSignRequest { + let request = try decodeURLFragment(url) + let responseUriString = (request["responseUri"] as? String) ?? "" + let responseURL = try validateResponseURL(responseUriString) + + let hash = ((request["hash"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let hashFunction = ((request["hashFunction"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + + if hash.isEmpty || hashFunction.isEmpty { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid signing request: missing hash or hashFunction", + responseUri: responseURL.absoluteString + ) + } + + let hashBytes = try validateAndDecodeHash( + hashBase64: hash, + hashFunction: hashFunction, + responseUri: responseURL.absoluteString + ) + + let signingCertificateB64 = ((request["signingCertificate"] as? String) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + if signingCertificateB64.isEmpty { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid signing request: missing signingCertificate", + responseUri: responseURL.absoluteString + ) + } + + guard let certDER = WebEidAlgorithmUtil.base64DecodeFlexible(signingCertificateB64), + let cert = WebEidAlgorithmUtil.certificate(from: certDER) else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid signingCertificate encoding", + responseUri: responseURL.absoluteString + ) + } + + // Use ASN1Decoder to extract CN + guard let personalData = try? extractPersonalData(from: certDER) else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Failed to extract personal data from certificate", + responseUri: responseURL.absoluteString + ) + } + return WebEidSignRequest( + responseUri: responseURL.absoluteString, + origin: try parseOrigin(responseURL), + signingCertificate: cert, + hash: hashBytes.base64EncodedString(), + hashFunction: hashFunction, + personalData: personalData + ) + } + + // MARK: - Validation / Decoding + + private static func validateResponseURL(_ responseUri: String) throws -> URL { + guard let components = URLComponents(string: responseUri) else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid response URI", + responseUri: responseUri + ) + } + + guard let scheme = components.scheme, !scheme.isEmpty else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid response URI scheme", + responseUri: responseUri + ) + } + + guard scheme.lowercased() == "https" else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Response URI must use HTTPS scheme", + responseUri: responseUri + ) + } + + guard let host = components.host, !host.isEmpty else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid response URI host", + responseUri: responseUri + ) + } + + if components.user != nil || components.password != nil { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Response URI must not contain userinfo", + responseUri: responseUri + ) + } + + guard let url = components.url else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid response URI", + responseUri: responseUri + ) + } + + return url + } + + private static func decodeURLFragment(_ url: URL) throws -> [String: Any] { + guard let fragment = URLComponents(url: url, resolvingAgainstBaseURL: false)?.fragment, + !fragment.isEmpty else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Missing URI fragment", + responseUri: "" + ) + } + + guard let decoded = WebEidAlgorithmUtil.base64DecodeFlexible(fragment) else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid URI fragment", + responseUri: "" + ) + } + + do { + let obj = try JSONSerialization.jsonObject(with: decoded, options: []) + guard let dict = obj as? [String: Any] else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid URI fragment JSON", + responseUri: "" + ) + } + return dict + } catch { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid URI fragment", + responseUri: "" + ) + } + } + + private static func parseOrigin(_ url: URL) throws -> String { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme, + let host = components.host else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid origin", + responseUri: url.absoluteString + ) + } + + let portPart = components.port.map { ":\($0)" } ?? "" + let origin = "\(scheme)://\(host)\(portPart)" + + if origin.count > maxOriginLength { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid origin length", + responseUri: url.absoluteString + ) + } + return origin + } + + private static func validateAndDecodeHash( + hashBase64: String, + hashFunction: String, + responseUri: String + ) throws -> Data { + guard let hashBytes = WebEidAlgorithmUtil.base64DecodeFlexible(hashBase64) else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Invalid hash encoding", + responseUri: responseUri + ) + } + + if hashFunction.count > 8 { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "hashFunction value is invalid", + responseUri: responseUri + ) + } + + let expectedLength: Int + switch hashFunction.uppercased() { + case "SHA-224", "SHA3-224": expectedLength = 28 + case "SHA-256", "SHA3-256": expectedLength = 32 + case "SHA-384", "SHA3-384": expectedLength = 48 + case "SHA-512", "SHA3-512": expectedLength = 64 + default: + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Unsupported hashFunction: \(hashFunction)", + responseUri: responseUri + ) + } + + if hashBytes.count != expectedLength { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "\(hashFunction) hash must be \(expectedLength) bytes long, but is \(hashBytes.count)", + responseUri: responseUri + ) + } + + return hashBytes + } + + // MARK: - Personal Data Extraction using (SwiftASN1/X509) + + private static func getSubjectAttribute(cert: Data, oidString: String) -> String { + do { + let x509 = try X509Certificate(data: cert) + return x509.subject(oidString: oidString)?.first ?? "" + } catch { + logger().error( + "Unable to get subject attribute from certificate: \(String(reflecting: error))" + ) + return "" + } + } + + private static func extractPersonalData(from certDER: Data) throws -> WebEidPersonalData { + let commonNameOID = "2.5.4.3" // CN OID: 2.5.4.3 + let commonName = getSubjectAttribute(cert: certDER, oidString: commonNameOID) + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !commonName.isEmpty else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Signing certificate CN missing", + responseUri: "" + ) + } + + let parts = commonName + .split(separator: ",", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + + guard parts.count >= 3 else { + throw WebEidException( + code: .ERR_WEBEID_MOBILE_INVALID_REQUEST, + message: "Unexpected signing certificate CN format", + responseUri: "" + ) + } + + return WebEidPersonalData( + givenNames: parts[1], + surname: parts[0], + personalCode: parts[2] + ) + } +} diff --git a/Modules/WebEidLib/Sources/WebEidLib/Utils/WebEidResponseUtil.swift b/Modules/WebEidLib/Sources/WebEidLib/Utils/WebEidResponseUtil.swift new file mode 100644 index 00000000..8a028500 --- /dev/null +++ b/Modules/WebEidLib/Sources/WebEidLib/Utils/WebEidResponseUtil.swift @@ -0,0 +1,73 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import UtilsLib + +public struct WebEidResponseUtil: Loggable { + + /// Returns JSON-like dictionary (easy to JSON-serialize). + public static func createErrorPayload( + code: WebEidErrorCode, + message: String + ) -> [String: Any] { + [ + "error": true, + "code": String(describing: code), + "message": message + ] + } + + /// Builds URL with Base64URL-encoded JSON payload placed into URL.fragment. + public static func createResponseURL( + responseUri: String, + payload: [String: Any] + ) throws -> URL { + + guard var components = URLComponents(string: responseUri) else { + throw WebEidResponseUtilError.invalidResponseURI + } + + guard components.scheme?.lowercased() == "https", + let host = components.host, !host.isEmpty else { + throw WebEidResponseUtilError.invalidResponseURI + } + + let jsonData = try JSONSerialization.data(withJSONObject: payload, options: []) + let encodedPayload = base64URLEncodeNoPadding(jsonData) + + components.fragment = encodedPayload + + guard let url = components.url else { + throw WebEidResponseUtilError.couldNotBuildURL + } + return url + } + + // MARK: - Base64URL (URL_SAFE | NO_PADDING | NO_WRAP) + + private static func base64URLEncodeNoPadding(_ data: Data) -> String { + let b64 = data.base64EncodedString() // standard Base64, no wraps by default + // Convert to Base64URL + remove padding + return b64 + .replacing("+", with: "-") + .replacing("/", with: "_") + .replacing("=", with: "") + } +} diff --git a/Modules/WebEidLib/Tests/WebEidLibTests/Service/WebEidAuthServiceTests.swift b/Modules/WebEidLib/Tests/WebEidLibTests/Service/WebEidAuthServiceTests.swift new file mode 100644 index 00000000..a08a9bec --- /dev/null +++ b/Modules/WebEidLib/Tests/WebEidLibTests/Service/WebEidAuthServiceTests.swift @@ -0,0 +1,157 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Testing +import CommonsLib +import CommonsTestShared +import WebEidLibMocks + +@testable import WebEidLib + +struct WebEidAuthServiceTests { + private var service: WebEidAuthServiceProtocol + + // swiftlint:disable line_length + private let testAuthCert = "MIIEDjCCA2+gAwIBAgIQfS1XPVaqF6id70AX3+4UQzAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTI1MDQyMjEwMTg0OFoXDTMwMDQyMTIwNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASJtW601r+uC3ipDFbn4st6lxtAjqICVTUTIQ0Wq/hsxHPjzSUfWDJqhWXuDBg0E9hDnnQlkIiX+c7vYeBOhHG0kbzjhQ+iz9xF3fnuDHVb/QtXBbXrh4fXWu5tVOb6IkejggHNMIIByTAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFMCEmSnETp87AjT2meEKVgAIKT57MHMGCCsGAQUFBwEBBGcwZTA1BggrBgEFBQcwAoYpaHR0cDovL2Muc2suZWUvVGVzdF9vZl9FU1RFSUQyMDE4LmRlci5jcnQwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MB8GA1UdEQQYMBaBFDM4MDAxMDg1NzE4QGVlc3RpLmVlMEcGA1UdIARAMD4wMgYLKwYBBAGDkSEBAQEwIzAhBggrBgEFBQcCARYVaHR0cHM6Ly93d3cuc2suZWUvQ1BTMAgGBgQAj3oBAjAgBgNVHSUBAf8EFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwawYIKwYBBQUHAQMEXzBdMAgGBgQAjkYBATBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAmVuMB0GA1UdDgQWBBRR540dJ/FCuVZGORkQFu/jLdK1PDAOBgNVHQ8BAf8EBAMCA4gwCgYIKoZIzj0EAwQDgYwAMIGIAkIBSoNaxY9V3Z7w0/tKUcLvzHLfJVb0v6OPHPlBm1wXQBw0dXSOoz3b67OFINismuBWLnvSHvIzLWZv73wth37ERIICQgDCQAFgi70IOKSLBbEGJEmJpjPq+r3VcbfBy/lXhuPOxzaIkAaCejOuehBl31gogGSIQp4LmFmR/4OOszWPOvu41w==" + private let testSignCert = "MIID7DCCA02gAwIBAgIQK33iqGajpAnSrLD7w+X3TjAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTI1MDQyMjEwMTg0OVoXDTMwMDQyMTIwNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATYWYk4C8W5+RAMeuvIQVa0sVdobkxXKASvA4lUh5K/whRAT5f3p8n2rw8O3nsCt/1LFyKXVVrZdtWZ1Vh894TA2QHEm6xaXnJs4ZmYo4blrm/nXE1PcEZan9023+73sE+jggGrMIIBpzAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFMCEmSnETp87AjT2meEKVgAIKT57MHMGCCsGAQUFBwEBBGcwZTA1BggrBgEFBQcwAoYpaHR0cDovL2Muc2suZWUvVGVzdF9vZl9FU1RFSUQyMDE4LmRlci5jcnQwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MEgGA1UdIARBMD8wMgYLKwYBBAGDkSEBAQEwIzAhBggrBgEFBQcCARYVaHR0cHM6Ly93d3cuc2suZWUvQ1BTMAkGBwQAi+xAAQIwgYoGCCsGAQUFBwEDBH4wfDAIBgYEAI5GAQEwCAYGBACORgEEMBMGBgQAjkYBBjAJBgcEAI5GAQYBMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCZW4wHQYDVR0OBBYEFFh+R2KDfE2Tdj///kXTCqcz6rRuMA4GA1UdDwEB/wQEAwIGQDAKBggqhkjOPQQDBAOBjAAwgYgCQgD7B3WI1xpXX94+9e3TdaIcUNCj5JkCX15pj1mjRqv/Vx9Hlg3tbgwW2yOhqnTF04+e9rVHCtA8YRINp5BfDFqj/wJCAVuUlCu7GNVSFeU7A6lEORkB6obIALZusUFxT4bsaFWTpKllmvlX6lZm3QEbHgeiD8k7VMPdcw5V51p+B+2WUWBh" + private let testSignature = + "UYyRpzkKNwFgtgcbI1YQc2l1XQQTj7gy+FW/x94TsEberwzS2Rnu4dqC/JhYB3se2iOk1c6FAK2TN5WJTiIcQ9Nt3o/x7kfEsdkc5c39eUXuD83GXfUsyUxR9IQBQrpL" + // swiftlint:enable line_length + + init() async throws { + service = WebEidAuthService() + } + + @Test + func buildAuthToken_returnJSONPayloadData() async throws { + let authCert = Data(base64Encoded: testAuthCert) ?? Data() + let signature = Data(base64Encoded: testSignature) ?? Data() + let token: [String: Any] = [ + "unverifiedCertificate": testAuthCert, + "issuerApp": "https://web-eid.eu/web-eid-mobile-app/releases/v1.0.0", + "algorithm": "ES384", + "format": "web-eid:1.0", + "signature": testSignature] + + let expected = try JSONSerialization.data( + withJSONObject: token, + options: [] + ) + let result = try await service.buildAuthToken( + authCert: authCert, + signingCert: nil, + signature: signature + ) + + #expect(result.count == expected.count) + } + + @Test + func buildAuthToken_returnJSONPayloadData_whenSignCertProvided() async throws { + let authCert = Data(base64Encoded: testAuthCert) ?? Data() + let signCert = Data(base64Encoded: testSignCert) ?? Data() + let signature = Data(base64Encoded: testSignature) ?? Data() + let token: [String: Any] = [ + "issuerApp": "https://web-eid.eu/web-eid-mobile-app/releases/v1.0.0", + "format": "web-eid:1.1", + "algorithm": "ES384", + "unverifiedCertificate": testAuthCert, + "signature": testSignature, + "unverifiedSigningCertificates": [ + [ + "supportedSignatureAlgorithms": [ + ["cryptoAlgorithm": "ECC", "hashFunction": "SHA-224", "paddingScheme": "NONE"], + ["cryptoAlgorithm": "ECC", "paddingScheme": "NONE", "hashFunction": "SHA-256"], + ["cryptoAlgorithm": "ECC", "hashFunction": "SHA-384", "paddingScheme": "NONE"], + ["hashFunction": "SHA-512", "paddingScheme": "NONE", "cryptoAlgorithm": "ECC"], + ["cryptoAlgorithm": "ECC", "hashFunction": "SHA3-224", "paddingScheme": "NONE"], + ["hashFunction": "SHA3-256", "cryptoAlgorithm": "ECC", "paddingScheme": "NONE"], + ["paddingScheme": "NONE", "hashFunction": "SHA3-384", "cryptoAlgorithm": "ECC"], + ["hashFunction": "SHA3-512", "cryptoAlgorithm": "ECC", "paddingScheme": "NONE"] + ], + "certificate": testSignCert + ] + ] + ] + + let expected = try JSONSerialization.data( + withJSONObject: token, + options: [] + ) + let result = try await service.buildAuthToken( + authCert: authCert, + signingCert: signCert, + signature: signature + ) + + #expect(result.count == expected.count) + } + + @Test + func buildAuthToken_throwinvalidCertificateWhenCertIsInvalid() async throws { + let invalidCert = Data([0x00, 0x01, 0x02]) + let signature = Data(base64Encoded: testSignature) ?? Data() + + await #expect(throws: WebEidBuilderError.invalidCertificate) { + try await service.buildAuthToken( + authCert: invalidCert, + signingCert: nil, + signature: signature + ) + } + } + + @Test + func buildAuthToken_throwUnsupportedKeyTypeWhenCertKeyIsUnsupported() async throws { + let cert = TestCertificateUtil.getSampleCertificate() + let signature = Data(base64Encoded: testSignature) ?? Data() + + await #expect(throws: WebEidAlgorithmUtilError.unsupportedKeyType) { + try await service.buildAuthToken( + authCert: cert, + signingCert: nil, + signature: signature + ) + } + } + + @Test + func buildAuthToken_includesRSASigningCertificateInsteadOfFailing() async throws { + let authCert = Data(base64Encoded: testAuthCert) ?? Data() + let rsaSigningCert = TestCertificateUtil.getSampleCertificate() + let signature = Data(base64Encoded: testSignature) ?? Data() + + let data = try await service.buildAuthToken( + authCert: authCert, + signingCert: rsaSigningCert, + signature: signature + ) + + let token = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + #expect(token["format"] as? String == "web-eid:1.1") + + let certificates = try #require(token["unverifiedSigningCertificates"] as? [[String: Any]]) + let algorithms = try #require(certificates.first?["supportedSignatureAlgorithms"] as? [[String: Any]]) + #expect(algorithms.allSatisfy { $0["cryptoAlgorithm"] as? String == "RSA" }) + #expect(algorithms.allSatisfy { $0["paddingScheme"] as? String == "PKCS1.5" }) + } +} diff --git a/Modules/WebEidLib/Tests/WebEidLibTests/Service/WebEidSignServiceTests.swift b/Modules/WebEidLib/Tests/WebEidLibTests/Service/WebEidSignServiceTests.swift new file mode 100644 index 00000000..547b59b6 --- /dev/null +++ b/Modules/WebEidLib/Tests/WebEidLibTests/Service/WebEidSignServiceTests.swift @@ -0,0 +1,149 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Testing +import CommonsLib +import CommonsTestShared +import WebEidLibMocks + +@testable import WebEidLib + +struct WebEidSignServiceTests { + private var service: WebEidSignServiceProtocol + + // swiftlint:disable line_length + private let testCert = "MIID7DCCA02gAwIBAgIQK33iqGajpAnSrLD7w+X3TjAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTI1MDQyMjEwMTg0OVoXDTMwMDQyMTIwNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATYWYk4C8W5+RAMeuvIQVa0sVdobkxXKASvA4lUh5K/whRAT5f3p8n2rw8O3nsCt/1LFyKXVVrZdtWZ1Vh894TA2QHEm6xaXnJs4ZmYo4blrm/nXE1PcEZan9023+73sE+jggGrMIIBpzAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFMCEmSnETp87AjT2meEKVgAIKT57MHMGCCsGAQUFBwEBBGcwZTA1BggrBgEFBQcwAoYpaHR0cDovL2Muc2suZWUvVGVzdF9vZl9FU1RFSUQyMDE4LmRlci5jcnQwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MEgGA1UdIARBMD8wMgYLKwYBBAGDkSEBAQEwIzAhBggrBgEFBQcCARYVaHR0cHM6Ly93d3cuc2suZWUvQ1BTMAkGBwQAi+xAAQIwgYoGCCsGAQUFBwEDBH4wfDAIBgYEAI5GAQEwCAYGBACORgEEMBMGBgQAjkYBBjAJBgcEAI5GAQYBMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCZW4wHQYDVR0OBBYEFFh+R2KDfE2Tdj///kXTCqcz6rRuMA4GA1UdDwEB/wQEAwIGQDAKBggqhkjOPQQDBAOBjAAwgYgCQgD7B3WI1xpXX94+9e3TdaIcUNCj5JkCX15pj1mjRqv/Vx9Hlg3tbgwW2yOhqnTF04+e9rVHCtA8YRINp5BfDFqj/wJCAVuUlCu7GNVSFeU7A6lEORkB6obIALZusUFxT4bsaFWTpKllmvlX6lZm3QEbHgeiD8k7VMPdcw5V51p+B+2WUWBh" + private let testSignature = + "jfrC/H3mn+ySpYCJrzIMm5Wm7sC0VRLyyuA6Jkc7cTt1JwjobbdAleQucJfc71f0MOeGtXouKIjs/HvETPZZNfjtgx/9bzwQCnws9TvZly1XCbscFFYP4rAbz4HNF+wk" + // swiftlint:enable line_length + + init() async throws { + service = WebEidSignService() + } + + @Test + func buildCertificatePayload_returnJSONPayloadData() async throws { + let signingCert = Data(base64Encoded: testCert) ?? Data() + + let payload: [String: Any] = [ + "certificate": testCert, + "supportedSignatureAlgorithms": [ + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-224", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-256", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-384", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-512", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA3-224", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA3-256", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA3-384", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA3-512", + "paddingScheme": "NONE" + ] + ] + ] + + let expected = try JSONSerialization.data(withJSONObject: payload, options: []) + let result = try await service.buildCertificatePayload(signingCert: signingCert) + + #expect(result.count == expected.count) + } + + @Test + func buildCertificatePayload_throwInvalidCertificateWhenCertIsInvalid() async throws { + let invalidCert = Data([0x00, 0x01, 0x02]) + + await #expect(throws: WebEidBuilderError.invalidCertificate) { + try await service.buildCertificatePayload(signingCert: invalidCert) + } + } + + @Test + func buildCertificatePayload_advertisesRSAForAnRSASigningCertificate() async throws { + let cert = TestCertificateUtil.getSampleCertificate() + + let data = try await service.buildCertificatePayload(signingCert: cert) + + let payload = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let algorithms = try #require(payload["supportedSignatureAlgorithms"] as? [[String: Any]]) + #expect(algorithms.allSatisfy { $0["cryptoAlgorithm"] as? String == "RSA" }) + #expect(algorithms.allSatisfy { $0["paddingScheme"] as? String == "PKCS1.5" }) + } + + @Test + func buildSignPayload_returnJSONPayloadData() async throws { + let signature = Data(base64Encoded: testSignature) ?? Data() + let payload: [String: Any] = [ + "signatureAlgorithm": + [ + "hashFunction": "SHA-256", + "paddingScheme": "NONE", + "cryptoAlgorithm": "ECC" + ], + "signature": testSignature + ] + + let expected = try JSONSerialization.data(withJSONObject: payload, options: []) + let result = try await service.buildSignPayload(signingCert: testCert, + signature: signature, + hashFunction: "SHA-256") + + #expect(result.count == expected.count) + } + + @Test + func buildSignPayload_throwUnsupportedHashFunctionWhenHashIsUnsupported() async throws { + let cert = TestCertificateUtil.getSampleCertificateString() + let signature = Data(base64Encoded: testSignature) ?? Data() + await #expect(throws: WebEidAlgorithmUtilError.unsupportedHashFunction("SHA-255")) { + try await service.buildSignPayload(signingCert: cert, + signature: signature, + hashFunction: "SHA-255") + } + } +} diff --git a/Modules/WebEidLib/Tests/WebEidLibTests/Utils/WebEidAlgorithmUtilTests.swift b/Modules/WebEidLib/Tests/WebEidLibTests/Utils/WebEidAlgorithmUtilTests.swift new file mode 100644 index 00000000..2575a404 --- /dev/null +++ b/Modules/WebEidLib/Tests/WebEidLibTests/Utils/WebEidAlgorithmUtilTests.swift @@ -0,0 +1,293 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Testing +import CommonsLib +import CommonsTestShared +import WebEidLibMocks +import Security + +@testable import WebEidLib + +struct WebEidAlgorithmUtilTests { + + // swiftlint:disable line_length + private let testCert = "MIID7DCCA02gAwIBAgIQK33iqGajpAnSrLD7w+X3TjAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTI1MDQyMjEwMTg0OVoXDTMwMDQyMTIwNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATYWYk4C8W5+RAMeuvIQVa0sVdobkxXKASvA4lUh5K/whRAT5f3p8n2rw8O3nsCt/1LFyKXVVrZdtWZ1Vh894TA2QHEm6xaXnJs4ZmYo4blrm/nXE1PcEZan9023+73sE+jggGrMIIBpzAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFMCEmSnETp87AjT2meEKVgAIKT57MHMGCCsGAQUFBwEBBGcwZTA1BggrBgEFBQcwAoYpaHR0cDovL2Muc2suZWUvVGVzdF9vZl9FU1RFSUQyMDE4LmRlci5jcnQwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MEgGA1UdIARBMD8wMgYLKwYBBAGDkSEBAQEwIzAhBggrBgEFBQcCARYVaHR0cHM6Ly93d3cuc2suZWUvQ1BTMAkGBwQAi+xAAQIwgYoGCCsGAQUFBwEDBH4wfDAIBgYEAI5GAQEwCAYGBACORgEEMBMGBgQAjkYBBjAJBgcEAI5GAQYBMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCZW4wHQYDVR0OBBYEFFh+R2KDfE2Tdj///kXTCqcz6rRuMA4GA1UdDwEB/wQEAwIGQDAKBggqhkjOPQQDBAOBjAAwgYgCQgD7B3WI1xpXX94+9e3TdaIcUNCj5JkCX15pj1mjRqv/Vx9Hlg3tbgwW2yOhqnTF04+e9rVHCtA8YRINp5BfDFqj/wJCAVuUlCu7GNVSFeU7A6lEORkB6obIALZusUFxT4bsaFWTpKllmvlX6lZm3QEbHgeiD8k7VMPdcw5V51p+B+2WUWBh" + private let testSignature = + "jfrC/H3mn+ySpYCJrzIMm5Wm7sC0VRLyyuA6Jkc7cTt1JwjobbdAleQucJfc71f0MOeGtXouKIjs/HvETPZZNfjtgx/9bzwQCnws9TvZly1XCbscFFYP4rAbz4HNF+wk" + // swiftlint:enable line_length + + @Test + func buildSupportedSignatureAlgorithms_returnJSONObject() async throws { + let signingCert = try #require(Data(base64Encoded: testCert)) + let secCert = try #require(SecCertificateCreateWithData(nil, signingCert as CFData)) + let publicKey = try #require(SecCertificateCopyKey(secCert)) + + let expected = [ + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-224", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-256", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-384", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA-512", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA3-224", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA3-256", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA3-384", + "paddingScheme": "NONE" + ], + [ + "cryptoAlgorithm": "ECC", + "hashFunction": "SHA3-512", + "paddingScheme": "NONE" + ] + ] + let result = try WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms( + publicKey: publicKey + ) + let resultTyped = result as? [[String: String]] + + #expect(resultTyped == expected) + } + + @Test + func buildSupportedSignatureAlgorithms_advertisesRSAForAnRSASigningKey() async throws { + let cert = TestCertificateUtil.getSampleCertificate() + let secCert = try #require(SecCertificateCreateWithData(nil, cert as CFData)) + let publicKey = try #require(SecCertificateCopyKey(secCert)) + + let result = try WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms(publicKey: publicKey) + + #expect(result.count == WebEidAlgorithmUtil.supportedHashFunctions.count) + #expect(result.allSatisfy { $0["cryptoAlgorithm"] as? String == "RSA" }) + #expect(result.allSatisfy { $0["paddingScheme"] as? String == "PKCS1.5" }) + #expect(result.contains { $0["hashFunction"] as? String == "SHA-256" }) + } + + @Test + func buildSupportedSignatureAlgorithms_advertisesECCForAnECSigningKey() async throws { + let signingCert = try #require(Data(base64Encoded: testCert)) + let secCert = try #require(SecCertificateCreateWithData(nil, signingCert as CFData)) + let publicKey = try #require(SecCertificateCopyKey(secCert)) + + let result = try WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms(publicKey: publicKey) + + #expect(result.allSatisfy { $0["cryptoAlgorithm"] as? String == "ECC" }) + #expect(result.allSatisfy { $0["paddingScheme"] as? String == "NONE" }) + } + + @Test + func getAlgorithm_returnAlgorithmString() async throws { + let signingCert = try #require(Data(base64Encoded: testCert)) + let secCert = try #require(SecCertificateCreateWithData(nil, signingCert as CFData)) + let publicKey = try #require(SecCertificateCopyKey(secCert)) + let result = try WebEidAlgorithmUtil.getAlgorithm(publicKey: publicKey) + #expect(result == "ES384") + } + + @Test + func getAlgorithm_throwUnsupportedKeyTypeWhenCertKeyIsUnsupported() async throws { + let cert = TestCertificateUtil.getSampleCertificate() + let secCert = try #require(SecCertificateCreateWithData(nil, cert as CFData)) + let publicKey = try #require(SecCertificateCopyKey(secCert)) + + #expect(throws: WebEidAlgorithmUtilError.unsupportedKeyType) { + try WebEidAlgorithmUtil.getAlgorithm(publicKey: publicKey) + } + } + + @Test + func buildSignatureAlgorithm_returnJSONObjectWhenECCKey() async throws { + let signingCert = Data(base64Encoded: testCert) ?? Data() + let secCert = try #require(SecCertificateCreateWithData(nil, signingCert as CFData)) + let publicKey = try #require(SecCertificateCopyKey(secCert)) + + let expected = ["cryptoAlgorithm": "ECC", + "hashFunction": "SHA-256", + "paddingScheme": "NONE"] + + let result = try WebEidAlgorithmUtil.buildSignatureAlgorithm( + publicKey: publicKey, + hashFunction: "SHA-256" + ) + let resultTyped = result as? [String: String] + #expect(resultTyped == expected) + } + + @Test + func buildSignatureAlgorithm_returnJSONObjectWhenRSAKey() async throws { + let cert = TestCertificateUtil.getSampleCertificate() + let secCert = try #require(SecCertificateCreateWithData(nil, cert as CFData)) + let publicKey = try #require(SecCertificateCopyKey(secCert)) + + let expected = [ + "cryptoAlgorithm": "RSA", + "hashFunction": "SHA-256", + "paddingScheme": "PKCS1.5" + ] + + let result = try WebEidAlgorithmUtil.buildSignatureAlgorithm( + publicKey: publicKey, + hashFunction: "SHA-256" + ) + let resultTyped = result as? [String: String] + #expect(resultTyped == expected) + } + + @Test + func buildSignatureAlgorithm_throwUnsupportedHashFunctionWhenHashIsUnsupported() async throws { + let cert = TestCertificateUtil.getSampleCertificate() + let secCert = try #require(SecCertificateCreateWithData(nil, cert as CFData)) + let publicKey = try #require(SecCertificateCopyKey(secCert)) + + #expect(throws: WebEidAlgorithmUtilError.unsupportedHashFunction("SHA-255")) { + try WebEidAlgorithmUtil.buildSignatureAlgorithm( + publicKey: publicKey, + hashFunction: "SHA-255" + ) + } + } + + @Test + func parseCertificate_returnSecCertificatedWhenValidBase64String() async throws { + let signingCertBase64 = TestCertificateUtil.getSampleCertificateString() + + let result = try WebEidAlgorithmUtil.parseCertificate(signingCertBase64: signingCertBase64) + + #expect(SecCertificateCopyKey(result) != nil) + } + + @Test + func parseCertificate_throwInvalidCertificateWhenInvalidCertString() async throws { + let invalidCert = "MIIEwjC" + + #expect(throws: WebEidAlgorithmUtilError.invalidCertificate) { + try WebEidAlgorithmUtil.parseCertificate(signingCertBase64: invalidCert) + } + } + + @Test + func parseCertificate_throwInvalidBase64WhenInvalidBase64String() async throws { + let invalidBase64String = "ÖÖÖÖÖÖÖ" + + #expect(throws: WebEidAlgorithmUtilError.invalidBase64) { + try WebEidAlgorithmUtil.parseCertificate(signingCertBase64: invalidBase64String) + } + } + + @Test + func certificate_returnSecCertificatedWhenValidDataBytes() async throws { + let signingCert = TestCertificateUtil.getSampleCertificate() + + let result = try #require(WebEidAlgorithmUtil.certificate(from: signingCert)) + _ = try #require(SecCertificateCopyKey(result)) + } + + @Test + func certificate_returnNilWhenInvalidDataBytes() async throws { + let invalidCert = Data([0x00, 0x01, 0x02]) + + let result = WebEidAlgorithmUtil.certificate(from: invalidCert) + + #expect(result == nil) + } + + @Test + func certificate_returnDataWhenBase64StringValid() async throws { + let expected = Data("test result".utf8) + let base64String = expected.base64EncodedString() + + let result = WebEidAlgorithmUtil.base64DecodeFlexible(base64String) + + #expect(result == expected) + } + + @Test + func certificate_returnNilWhenBase64StringInvalid() async throws { + let base64String = "ÖÖÖÖÖÖÖ" + + let result = WebEidAlgorithmUtil.base64DecodeFlexible(base64String) + + #expect(result == nil) + } + + @Test + func buildSignatureAlgorithm_acceptsLowercaseHashFunctionAndReturnsCanonicalForm() async throws { + let certData = try #require(Data(base64Encoded: testCert)) + let secCert = try #require(SecCertificateCreateWithData(nil, certData as CFData)) + let publicKey = try #require(SecCertificateCopyKey(secCert)) + + let result = try WebEidAlgorithmUtil.buildSignatureAlgorithm( + publicKey: publicKey, + hashFunction: "sha-256" + ) + + #expect(result["hashFunction"] as? String == "SHA-256") + #expect(result["cryptoAlgorithm"] as? String == "ECC") + } + + @Test + func base64DecodeFlexible_decodesBase64UrlAlphabet() async throws { + let expected = Data([0xFB, 0xFF, 0xBE, 0x03, 0xEF, 0xFF]) + let base64Url = expected.base64EncodedString() + .replacing("+", with: "-") + .replacing("/", with: "_") + + let result = WebEidAlgorithmUtil.base64DecodeFlexible(base64Url) + + #expect(result == expected) + } + + @Test + func base64DecodeFlexible_decodesBase64UrlWithoutPadding() async throws { + let expected = Data([0xFB, 0xFF, 0xBE, 0x03, 0xEF]) + let base64Url = expected.base64EncodedString() + .replacing("+", with: "-") + .replacing("/", with: "_") + .replacing("=", with: "") + + let result = WebEidAlgorithmUtil.base64DecodeFlexible(base64Url) + + #expect(result == expected) + } +} diff --git a/Modules/WebEidLib/Tests/WebEidLibTests/Utils/WebEidRequestParserTests.swift b/Modules/WebEidLib/Tests/WebEidLibTests/Utils/WebEidRequestParserTests.swift new file mode 100644 index 00000000..d9d47f72 --- /dev/null +++ b/Modules/WebEidLib/Tests/WebEidLibTests/Utils/WebEidRequestParserTests.swift @@ -0,0 +1,568 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Testing +import Security +import CommonsLib +import CommonsTestShared +import UtilsLib + +@testable import WebEidLib + +struct WebEidRequestParserTests { + + // swiftlint:disable line_length + private let eccCertBase64 = "MIID7DCCA02gAwIBAgIQK33iqGajpAnSrLD7w+X3TjAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTI1MDQyMjEwMTg0OVoXDTMwMDQyMTIwNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATYWYk4C8W5+RAMeuvIQVa0sVdobkxXKASvA4lUh5K/whRAT5f3p8n2rw8O3nsCt/1LFyKXVVrZdtWZ1Vh894TA2QHEm6xaXnJs4ZmYo4blrm/nXE1PcEZan9023+73sE+jggGrMIIBpzAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFMCEmSnETp87AjT2meEKVgAIKT57MHMGCCsGAQUFBwEBBGcwZTA1BggrBgEFBQcwAoYpaHR0cDovL2Muc2suZWUvVGVzdF9vZl9FU1RFSUQyMDE4LmRlci5jcnQwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MEgGA1UdIARBMD8wMgYLKwYBBAGDkSEBAQEwIzAhBggrBgEFBQcCARYVaHR0cHM6Ly93d3cuc2suZWUvQ1BTMAkGBwQAi+xAAQIwgYoGCCsGAQUFBwEDBH4wfDAIBgYEAI5GAQEwCAYGBACORgEEMBMGBgQAjkYBBjAJBgcEAI5GAQYBMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCZW4wHQYDVR0OBBYEFFh+R2KDfE2Tdj///kXTCqcz6rRuMA4GA1UdDwEB/wQEAwIGQDAKBggqhkjOPQQDBAOBjAAwgYgCQgD7B3WI1xpXX94+9e3TdaIcUNCj5JkCX15pj1mjRqv/Vx9Hlg3tbgwW2yOhqnTF04+e9rVHCtA8YRINp5BfDFqj/wJCAVuUlCu7GNVSFeU7A6lEORkB6obIALZusUFxT4bsaFWTpKllmvlX6lZm3QEbHgeiD8k7VMPdcw5V51p+B+2WUWBh" + // swiftlint:enable line_length + + // MARK: - Auth + + @Test + func parseAuthURL_returnsAuthRequest_whenValid() throws { + let challenge = String(repeating: "A", count: 44) + let loginUri = "https://example.com/login" + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": challenge, + "loginUri": loginUri, + "getSigningCertificate": true + ] + ) + + let result = try WebEidRequestParser.parseAuthURL(authURL) + + #expect(result.challenge == challenge) + #expect(result.loginUri == loginUri) + #expect(result.getSigningCertificate == true) + #expect(result.origin == "https://example.com") + } + + @Test + func parseAuthURL_returnsFalseForGetSigningCertificate_whenMissing() throws { + let challenge = String(repeating: "B", count: 44) + let loginUri = "https://example.com/login" + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": challenge, + "loginUri": loginUri + ] + ) + + let result = try WebEidRequestParser.parseAuthURL(authURL) + + #expect(result.challenge == challenge) + #expect(result.loginUri == loginUri) + #expect(result.getSigningCertificate == false) + #expect(result.origin == "https://example.com") + } + + @Test + func parseAuthURL_throws_whenFragmentMissing() throws { + let url = try #require(URL(string: "web-eid://authenticate")) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseAuthURL(url) + } + } + + @Test + func parseAuthURL_throwsWebEidException_whenChallengeTooShort() throws { + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": "short", + "loginUri": "https://example.com/login" + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseAuthURL(authURL) + } + } + + @Test + func parseAuthURL_throwsWebEidException_whenChallengeTooLong() throws { + let challenge = String(repeating: "A", count: 129) + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": challenge, + "loginUri": "https://example.com/login" + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseAuthURL(authURL) + } + } + + @Test + func parseAuthURL_throwsWebEidException_whenChallengeBlank() throws { + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": " ", + "loginUri": "https://example.com/login" + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseAuthURL(authURL) + } + } + + @Test + func parseAuthURL_throwsWebEidException_whenLoginUriMissing() throws { + let challenge = String(repeating: "A", count: 44) + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": challenge + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseAuthURL(authURL) + } + } + + @Test + func parseAuthURL_throwsWebEidException_whenLoginUriIsNotHttps() throws { + let challenge = String(repeating: "A", count: 44) + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": challenge, + "loginUri": "http://example.com/login" + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseAuthURL(authURL) + } + } + + @Test + func parseAuthURL_throwsWebEidException_whenLoginUriContainsUserInfo() throws { + let challenge = String(repeating: "A", count: 44) + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": challenge, + "loginUri": "https://user:pass@example.com/login" + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseAuthURL(authURL) + } + } + + @Test + func parseAuthURL_returnsOriginWithPort_whenResponseUriContainsPort() throws { + let challenge = String(repeating: "A", count: 44) + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": challenge, + "loginUri": "https://example.com:8443/login" + ] + ) + + let result = try WebEidRequestParser.parseAuthURL(authURL) + + #expect(result.origin == "https://example.com:8443") + } + + // MARK: - Certificate + + @Test + func parseCertificateURL_returnsCertificateRequest_whenValid() throws { + let responseUri = "https://example.com/certificate" + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": responseUri + ] + ) + + let result = try WebEidRequestParser.parseCertificateURL(url) + + #expect(result.responseUri == responseUri) + #expect(result.origin == "https://example.com") + } + + @Test + func parseCertificateURL_throwsWebEidException_whenResponseUriMissing() throws { + let url = try makeURL( + scheme: "web-eid", + payload: [:] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseCertificateURL(url) + } + } + + @Test + func parseCertificateURL_throwsWebEidException_whenResponseUriSchemeInvalid() throws { + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "custom://example.com/certificate" + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseCertificateURL(url) + } + } + + // MARK: - Sign + + @Test + func parseSignURL_returnsSignRequest_whenValid() throws { + let responseUri = "https://example.com/sign" + let hashData = Data(repeating: 0xAB, count: 32) + let hashBase64 = hashData.base64EncodedString() + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": responseUri, + "hash": hashBase64, + "hashFunction": "SHA-256", + "signingCertificate": eccCertBase64 + ] + ) + + let result = try WebEidRequestParser.parseSignURL(url) + + #expect(result.responseUri == responseUri) + #expect(result.origin == "https://example.com") + #expect(result.hash == hashBase64) + #expect(result.hashFunction == "SHA-256") + #expect(SecCertificateCopyKey(result.signingCertificate) != nil) + + #expect(result.personalData?.surname == "JÕEORG") + #expect(result.personalData?.givenNames == "JAAK-KRISTJAN") + #expect(result.personalData?.personalCode == "38001085718") + } + + @Test + func parseSignURL_throwsWebEidException_whenHashMissing() throws { + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hashFunction": "SHA-256", + "signingCertificate": eccCertBase64 + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseSignURL(url) + } + } + + @Test + func parseSignURL_throwsWebEidException_whenHashFunctionMissing() throws { + let hashData = Data(repeating: 0xAB, count: 32) + let hashBase64 = hashData.base64EncodedString() + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hash": hashBase64, + "signingCertificate": eccCertBase64 + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseSignURL(url) + } + } + + @Test + func parseSignURL_throwsWebEidException_whenHashEncodingInvalid() throws { + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hash": "ÖÖÖÖÖÖÖ", + "hashFunction": "SHA-256", + "signingCertificate": eccCertBase64 + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseSignURL(url) + } + } + + @Test + func parseSignURL_throwsWebEidException_whenHashFunctionUnsupported() throws { + let hashData = Data(repeating: 0xAB, count: 32) + let hashBase64 = hashData.base64EncodedString() + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hash": hashBase64, + "hashFunction": "SHA-999", + "signingCertificate": eccCertBase64 + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseSignURL(url) + } + } + + @Test + func parseSignURL_throwsWebEidException_whenHashFunctionTooLong() throws { + let hashData = Data(repeating: 0xAB, count: 32) + let hashBase64 = hashData.base64EncodedString() + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hash": hashBase64, + "hashFunction": "SHA3-256X", + "signingCertificate": eccCertBase64 + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseSignURL(url) + } + } + + @Test + func parseSignURL_throwsWebEidException_whenHashLengthDoesNotMatchHashFunction() throws { + let hashData = Data(repeating: 0xAB, count: 31) + let hashBase64 = hashData.base64EncodedString() + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hash": hashBase64, + "hashFunction": "SHA-256", + "signingCertificate": eccCertBase64 + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseSignURL(url) + } + } + + @Test + func parseSignURL_throws_whenSigningCertificateMissing() throws { + let hashData = Data(repeating: 0xAB, count: 32) + let hashBase64 = hashData.base64EncodedString() + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hash": hashBase64, + "hashFunction": "SHA-256" + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseSignURL(url) + } + } + + @Test + func parseSignURL_throwsWebEidException_whenSigningCertificateEncodingInvalid() throws { + let hashData = Data(repeating: 0xAB, count: 32) + let hashBase64 = hashData.base64EncodedString() + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hash": hashBase64, + "hashFunction": "SHA-256", + "signingCertificate": "ÖÖÖÖÖÖÖ" + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseSignURL(url) + } + } + + @Test + func parseSignURL_throwsWebEidException_whenResponseUriInvalid() throws { + let hashData = Data(repeating: 0xAB, count: 32) + let hashBase64 = hashData.base64EncodedString() + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "http://example.com/sign", + "hash": hashBase64, + "hashFunction": "SHA-256", + "signingCertificate": eccCertBase64 + ] + ) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseSignURL(url) + } + } + + // MARK: - Fragment / JSON decoding + + @Test + func parseCertificateURL_throwsWebEidException_whenFragmentIsInvalidBase64() throws { + let url = try #require(URL(string: "web-eid://certificate#ÖÖÖÖÖÖÖ")) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseCertificateURL(url) + } + } + + @Test + func parseCertificateURL_throwsWebEidException_whenFragmentIsNotJSONObject() throws { + let arrayJSON = "[1,2,3]" + let fragment = Data(arrayJSON.utf8).base64EncodedString() + let url = try #require(URL(string: "web-eid://certificate#\(fragment)")) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseCertificateURL(url) + } + } + + @Test + func parseCertificateURL_throwsWebEidException_whenFragmentIsNotJSON() throws { + let fragment = Data("not-json".utf8).base64EncodedString() + let url = try #require(URL(string: "web-eid://certificate#\(fragment)")) + + #expect(throws: WebEidException.self) { + try WebEidRequestParser.parseCertificateURL(url) + } + } + + // MARK: - Edge cases + + @Test + func parseAuthURL_acceptsChallengeAtMaxLength() throws { + let challenge = String(repeating: "C", count: 128) + let authURL = try makeURL( + scheme: "web-eid", + payload: [ + "challenge": challenge, + "loginUri": "https://example.com/login" + ] + ) + + let result = try WebEidRequestParser.parseAuthURL(authURL) + + #expect(result.challenge == challenge) + } + + @Test + func parseSignURL_acceptsLowercaseHashFunction() throws { + let hashData = Data(repeating: 0xCD, count: 32) + let hashBase64 = hashData.base64EncodedString() + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hash": hashBase64, + "hashFunction": "sha-256", + "signingCertificate": eccCertBase64 + ] + ) + + let result = try WebEidRequestParser.parseSignURL(url) + + #expect(result.hashFunction == "sha-256") + } + + // MARK: - Helpers + + @Test + func parseSignURL_normalisesBase64UrlHashToStandardBase64() throws { + let hashData = Data(repeating: 0xFB, count: 32) + let standardBase64 = hashData.base64EncodedString() + let base64UrlHash = standardBase64 + .replacing("+", with: "-") + .replacing("/", with: "_") + .replacing("=", with: "") + + let url = try makeURL( + scheme: "web-eid", + payload: [ + "responseUri": "https://example.com/sign", + "hash": base64UrlHash, + "hashFunction": "SHA-256", + "signingCertificate": eccCertBase64 + ] + ) + + let result = try WebEidRequestParser.parseSignURL(url) + + #expect(base64UrlHash != standardBase64) + #expect(result.hash == standardBase64) + } + + @Test + func parseAuthURL_throwsWithEmptyResponseUriWhenFragmentMissing() throws { + let url = try #require(URL(string: "https://id.eesti.ee/auth")) + + do { + _ = try WebEidRequestParser.parseAuthURL(url) + Issue.record("Expected parseAuthURL to throw") + } catch let error as WebEidException { + #expect(error.responseUri.isEmpty) + } + } + + @Test + func parseAuthURL_throwsWithEmptyResponseUriWhenFragmentUndecodable() throws { + let url = try #require(URL(string: "https://id.eesti.ee/auth#ÖÖÖÖ")) + + do { + _ = try WebEidRequestParser.parseAuthURL(url) + Issue.record("Expected parseAuthURL to throw") + } catch let error as WebEidException { + #expect(error.responseUri.isEmpty) + } + } + + private func makeURL(scheme: String, payload: [String: Any]) throws -> URL { + let jsonData = try JSONSerialization.data(withJSONObject: payload, options: []) + let fragment = jsonData.base64EncodedString() + return try #require(URL(string: "\(scheme)://request#\(fragment)")) + } +} diff --git a/Modules/WebEidLib/Tests/WebEidLibTests/Utils/WebEidResponseUtilTests.swift b/Modules/WebEidLib/Tests/WebEidLibTests/Utils/WebEidResponseUtilTests.swift new file mode 100644 index 00000000..49d9e2ca --- /dev/null +++ b/Modules/WebEidLib/Tests/WebEidLibTests/Utils/WebEidResponseUtilTests.swift @@ -0,0 +1,60 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Testing +import CommonsLib +import CommonsTestShared +import WebEidLibMocks +import Security + +@testable import WebEidLib + +struct WebEidResponseUtilTests { + + @Test + func createErrorPayload_returnJSONObject() async throws { + let code = WebEidErrorCode.ERR_WEBEID_MOBILE_INVALID_REQUEST + let message = "Some error occured!" + + let expected = [ + "error": true, + "code": String(describing: code), + "message": message + ] as [String: Any] + + let result = WebEidResponseUtil.createErrorPayload( + code: code, + message: message + ) + + #expect(result.count == expected.count) + } + + @Test + func createResponseURL_returnURL() async throws { + let result = try WebEidResponseUtil.createResponseURL( + responseUri: "https://id.eesti.ee/auth", + payload: ["test": "test"] + ) + let expected = "https://id.eesti.ee/auth#eyJ0ZXN0IjoidGVzdCJ9" + + #expect(result.absoluteString == expected) + } +} diff --git a/RIADigiDoc.xcodeproj/project.pbxproj b/RIADigiDoc.xcodeproj/project.pbxproj index 353a99b1..03cf38f7 100644 --- a/RIADigiDoc.xcodeproj/project.pbxproj +++ b/RIADigiDoc.xcodeproj/project.pbxproj @@ -7,11 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 040B4A29303DC7EB00E5F598 /* CryptoLib in Frameworks */ = {isa = PBXBuildFile; productRef = 040B4A28303DC7EB00E5F598 /* CryptoLib */; }; + 040B4A2B303DC7EB00E5F598 /* WebEidLib in Frameworks */ = {isa = PBXBuildFile; productRef = 040B4A2A303DC7EB00E5F598 /* WebEidLib */; }; 1A438FD92E72CF1900546B86 /* CryptoLib in Frameworks */ = {isa = PBXBuildFile; productRef = 1A438FD82E72CF1900546B86 /* CryptoLib */; }; 1A4A775E2EF19823001747D8 /* ConfigLib in Frameworks */ = {isa = PBXBuildFile; productRef = 1A4A775D2EF19823001747D8 /* ConfigLib */; }; 1A4A77602EF1983F001747D8 /* ConfigLib in Frameworks */ = {isa = PBXBuildFile; productRef = 1A4A775F2EF1983F001747D8 /* ConfigLib */; }; 1A4A77622EF1983F001747D8 /* ConfigLibMocks in Frameworks */ = {isa = PBXBuildFile; productRef = 1A4A77612EF1983F001747D8 /* ConfigLibMocks */; }; 1A5026ED2E8BFCF400C184D7 /* IdCardLib in Frameworks */ = {isa = PBXBuildFile; productRef = 1A5026EC2E8BFCF400C184D7 /* IdCardLib */; }; + 1ADBD1B62F6D66E500A27C88 /* CryptoLibMocks in Frameworks */ = {isa = PBXBuildFile; productRef = 1ADBD1B52F6D66E500A27C88 /* CryptoLibMocks */; }; + 1ADBD1BE2F6D6A6D00A27C88 /* WebEidLibMocks in Frameworks */ = {isa = PBXBuildFile; productRef = 1ADBD1BD2F6D6A6D00A27C88 /* WebEidLibMocks */; }; + 1ADC32232F59744600D34877 /* WebEidLib in Frameworks */ = {isa = PBXBuildFile; productRef = 1ADC32222F59744600D34877 /* WebEidLib */; }; DF1D595A2F3BF88000855E2E /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = DF1D59592F3BF88000855E2E /* FirebaseCore */; }; DF1D595C2F3BF88000855E2E /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = DF1D595B2F3BF88000855E2E /* FirebaseCrashlytics */; }; DF21D3592E8AA98E00A8E28C /* CommonsTestShared in Frameworks */ = {isa = PBXBuildFile; productRef = DFAABD0A2E82B45700906874 /* CommonsTestShared */; }; @@ -128,6 +133,7 @@ 1A7D36992E79EB8000CAD7A6 /* build-xcframeworks.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-xcframeworks.sh"; sourceTree = ""; }; 1AA9FF6C2E71A14200FFDC35 /* CryptoLib */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = CryptoLib; sourceTree = ""; }; 1AD26AB72E7AB14200A3629D /* build-libcdoc-sim.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-libcdoc-sim.sh"; sourceTree = ""; }; + 1AD5532C2F57274C00A88151 /* WebEidLib */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WebEidLib; sourceTree = ""; }; DF17B64D2CE26949009B1E17 /* UtilsLib */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = UtilsLib; sourceTree = ""; }; DF2B1BF22F524F83008EC06F /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = SDKROOT; }; DF2B1BF42F524F89008EC06F /* ExternalAccessory.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ExternalAccessory.framework; path = System/Library/Frameworks/ExternalAccessory.framework; sourceTree = SDKROOT; }; @@ -201,14 +207,20 @@ Domain/NFC/OperationChangePin.swift, Domain/NFC/OperationDecrypt.swift, Domain/NFC/OperationReadCardData.swift, + Domain/NFC/OperationReadCert.swift, Domain/NFC/OperationReadCertAndSign.swift, Domain/NFC/OperationUnblockPin.swift, + Domain/NFC/OperationWebEidAuth.swift, + Domain/NFC/OperationWebEidSign.swift, Domain/NFC/ProgressBar.swift, Domain/NFC/Protocols/OperationChangePinProtocol.swift, Domain/NFC/Protocols/OperationDecryptProtocol.swift, Domain/NFC/Protocols/OperationReadCardDataProtocol.swift, Domain/NFC/Protocols/OperationReadCertAndSignProtocol.swift, + Domain/NFC/Protocols/OperationReadCertProtocol.swift, Domain/NFC/Protocols/OperationUnblockPinProtocol.swift, + Domain/NFC/Protocols/OperationWebEidAuthProtocol.swift, + Domain/NFC/Protocols/OperationWebEidSignProtocol.swift, Domain/Preferences/DataStore.swift, Domain/Preferences/DataStoreProtocol.swift, Domain/Preferences/KeychainStore.swift, @@ -252,6 +264,7 @@ Util/Signature/SignatureUtilProtocol.swift, Util/Theme/ThemeSettings.swift, Util/Theme/ThemeSettingsProtocol.swift, + Util/WebEid/WebEidUriUtil.swift, ViewModel/AdvancedSettingsViewModel.swift, ViewModel/CertificateDetailViewModel.swift, ViewModel/Crypto/DecryptRootViewModel.swift, @@ -299,6 +312,7 @@ ViewModel/Protocols/SigningViewModelProtocol.swift, ViewModel/Protocols/TimeStampSettingsViewModelProtocol.swift, ViewModel/Protocols/ValidationSettingsViewModelProtocol.swift, + ViewModel/Protocols/WebEid/WebEidViewModelProtocol.swift, ViewModel/ProxySettingsViewModel.swift, ViewModel/RecentDocumentsViewModel.swift, ViewModel/Shared/SharedContainerViewModel.swift, @@ -318,6 +332,7 @@ ViewModel/SigningViewModel.swift, ViewModel/TimeStampSettingsViewModel.swift, ViewModel/ValidationSettingsViewModel.swift, + ViewModel/WebEid/WebEidViewModel.swift, ); target = DFDB148B2CC97B1000153876 /* RIADigiDocTests */; }; @@ -528,6 +543,7 @@ DFB1F1312CF0217C00185A7F /* CommonsLib in Frameworks */, DF9AFE922E00D30A0062C64D /* FactoryKit in Frameworks */, DF3E4A7F2CE269C900137235 /* UtilsLib in Frameworks */, + 1ADC32232F59744600D34877 /* WebEidLib in Frameworks */, 1A5026ED2E8BFCF400C184D7 /* IdCardLib in Frameworks */, DFA23CBD2EB6DC64000403E4 /* MobileIdLib in Frameworks */, DF1D595C2F3BF88000855E2E /* FirebaseCrashlytics in Frameworks */, @@ -548,17 +564,21 @@ DFAABD002E82AF0900906874 /* ConfigLibMocks in Frameworks */, DFBD09F62CE3FBDC006AF9C2 /* LibdigidocLib in Frameworks */, DF2B1BEA2F520815008EC06F /* SmartIdLib in Frameworks */, + 040B4A2B303DC7EB00E5F598 /* WebEidLib in Frameworks */, DF477F472E1B2E9900164471 /* LibdigidocLibSwiftMocks in Frameworks */, DFB1F12C2CF020C200185A7F /* CommonsLib in Frameworks */, + 1ADBD1BE2F6D6A6D00A27C88 /* WebEidLibMocks in Frameworks */, DF5903782ECCC42500D1A278 /* SmartIdLibMocks in Frameworks */, DFAABD052E82B1AB00906874 /* Alamofire in Frameworks */, DFAABCFC2E82AEA900906874 /* UtilsLibMocks in Frameworks */, DF5903762ECCC41F00D1A278 /* MobileIdLibMocks in Frameworks */, + 040B4A29303DC7EB00E5F598 /* CryptoLib in Frameworks */, DFA23CBF2EB6DC73000403E4 /* MobileIdLib in Frameworks */, DF4295B22D39D2F400415C71 /* ConfigLib in Frameworks */, DFAABCFE2E82AF0900906874 /* CommonsLibMocks in Frameworks */, DF9B53982CF7B01B00971F2A /* UtilsLib in Frameworks */, DF2B1BF72F525021008EC06F /* IdCardLib in Frameworks */, + 1ADBD1B62F6D66E500A27C88 /* CryptoLibMocks in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -590,6 +610,7 @@ DFA16AFE2CD18BD00099D34F /* Modules */ = { isa = PBXGroup; children = ( + 1AD5532C2F57274C00A88151 /* WebEidLib */, DFCF22F92E83512D0015AE6B /* Test */, DF64D0E12EC363C200FF73C6 /* SmartIdLib */, DFA23CBB2EB6CBF7000403E4 /* MobileIdLib */, @@ -778,6 +799,7 @@ DF1D59592F3BF88000855E2E /* FirebaseCore */, DF1D595B2F3BF88000855E2E /* FirebaseCrashlytics */, DF54F82E2D431BD50021D05A /* X509 */, + 1ADC32222F59744600D34877 /* WebEidLib */, ); productName = RIADigiDoc; productReference = DFDB147B2CC97B0E00153876 /* RIADigiDoc.app */; @@ -818,6 +840,10 @@ DF5903772ECCC42500D1A278 /* SmartIdLibMocks */, DF2B1BE92F520815008EC06F /* SmartIdLib */, DF2B1BF62F525021008EC06F /* IdCardLib */, + 1ADBD1BD2F6D6A6D00A27C88 /* WebEidLibMocks */, + 1ADBD1B52F6D66E500A27C88 /* CryptoLibMocks */, + 040B4A28303DC7EB00E5F598 /* CryptoLib */, + 040B4A2A303DC7EB00E5F598 /* WebEidLib */, ); productName = RIADigiDocTests; productReference = DFDB148C2CC97B1000153876 /* RIADigiDocTests.xctest */; @@ -1401,6 +1427,8 @@ INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NFCReaderUsageDescription = "This app uses NFC to scan ID-cards"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth card reader is used to read data from ID card"; + INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Bluetooth card reader is used to read data from ID card"; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -1459,6 +1487,8 @@ INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NFCReaderUsageDescription = "This app uses NFC to scan ID-cards"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth card reader is used to read data from ID card"; + INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Bluetooth card reader is used to read data from ID card"; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -1727,6 +1757,14 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 040B4A28303DC7EB00E5F598 /* CryptoLib */ = { + isa = XCSwiftPackageProductDependency; + productName = CryptoLib; + }; + 040B4A2A303DC7EB00E5F598 /* WebEidLib */ = { + isa = XCSwiftPackageProductDependency; + productName = WebEidLib; + }; 1A438FD82E72CF1900546B86 /* CryptoLib */ = { isa = XCSwiftPackageProductDependency; productName = CryptoLib; @@ -1747,6 +1785,18 @@ isa = XCSwiftPackageProductDependency; productName = IdCardLib; }; + 1ADBD1B52F6D66E500A27C88 /* CryptoLibMocks */ = { + isa = XCSwiftPackageProductDependency; + productName = CryptoLibMocks; + }; + 1ADBD1BD2F6D6A6D00A27C88 /* WebEidLibMocks */ = { + isa = XCSwiftPackageProductDependency; + productName = WebEidLibMocks; + }; + 1ADC32222F59744600D34877 /* WebEidLib */ = { + isa = XCSwiftPackageProductDependency; + productName = WebEidLib; + }; DF0B681B2D0779EF00A5457C /* CommonsTestShared */ = { isa = XCSwiftPackageProductDependency; productName = CommonsTestShared; diff --git a/RIADigiDoc/DI/AppContainer.swift b/RIADigiDoc/DI/AppContainer.swift index 980a3524..e73ab849 100644 --- a/RIADigiDoc/DI/AppContainer.swift +++ b/RIADigiDoc/DI/AppContainer.swift @@ -446,6 +446,19 @@ extension Container { .shared } + @MainActor + var webEidViewModel: Factory { + self { @MainActor in + WebEidViewModel( + dataStore: self.dataStore(), + keychainStore: self.keychainStore(), + authService: self.webEidAuthService(), + signService: self.webEidSignService() + ) + } + .shared + } + @MainActor var actionMethodSelectionViewModel: Factory { self { @MainActor in @@ -493,7 +506,10 @@ extension Container { keychainStore: self.keychainStore(), encryptedDataUtil: self.encryptedDataUtil(), operationReadCertAndSign: self.operationReadCertAndSign(), + operationWebEidAuth: self.operationWebEidAuth(), + operationWebEidSign: self.operationWebEidSign(), operationReadCardData: self.operationReadCardData(), + operationReadCert: self.operationReadCert(), operationDecrypt: self.operationDecrypt() ) } @@ -506,6 +522,13 @@ extension Container { } } + @MainActor + var operationWebEidSign: Factory { + self { @MainActor in + OperationWebEidSign() + } + } + @MainActor var operationReadCardData: Factory { self { @MainActor in @@ -513,6 +536,13 @@ extension Container { } } + @MainActor + var operationReadCert: Factory { + self { @MainActor in + OperationReadCert() + } + } + @MainActor var operationDecrypt: Factory { self { @MainActor in @@ -520,6 +550,13 @@ extension Container { } } + @MainActor + var operationWebEidAuth: Factory { + self { @MainActor in + OperationWebEidAuth() + } + } + @MainActor var operationChangePin: Factory { self { @MainActor in diff --git a/RIADigiDoc/Domain/Model/Error/NFC/ReadCertAndSignError.swift b/RIADigiDoc/Domain/Model/Error/NFC/ReadCertAndSignError.swift index b8ccfae4..493fcbe9 100644 --- a/RIADigiDoc/Domain/Model/Error/NFC/ReadCertAndSignError.swift +++ b/RIADigiDoc/Domain/Model/Error/NFC/ReadCertAndSignError.swift @@ -25,6 +25,11 @@ public enum ReadCertAndSignError: Error { case roleDataNil case containerPathNil case userAgentEmpty + case certMismatch + case hashInvalid + case invalidCertificate + case missingPublicKey + case unsupportedAlgorithm case cancelled case unknown(Error) } @@ -40,6 +45,16 @@ extension ReadCertAndSignError: LocalizedError { return "Container path is nil" case .userAgentEmpty: return "User agent is empty" + case .certMismatch: + return "Web eID signing certificate mismatch" + case .hashInvalid: + return "Invalid hash encoding" + case .invalidCertificate: + return "Invalid X.509 certificate" + case .missingPublicKey: + return "Certificate public key is missing" + case .unsupportedAlgorithm: + return "Unsupported algorithm" case .cancelled: return "Operation cancelled by user" case .unknown(let error): diff --git a/RIADigiDoc/Domain/Model/KeychainKey.swift b/RIADigiDoc/Domain/Model/KeychainKey.swift index cc982031..50c6bcc9 100644 --- a/RIADigiDoc/Domain/Model/KeychainKey.swift +++ b/RIADigiDoc/Domain/Model/KeychainKey.swift @@ -19,5 +19,8 @@ public enum KeychainKey: String, CaseIterable, Sendable { case proxyPassword = "proxy_password" + case webEidSessionActive = "web_eid_session_active" case nfcCANKey = "nfc_can_key" + case tempCANKey = "temp_can_key" + case signingCertKey = "signing_cert_key" } diff --git a/RIADigiDoc/Domain/Model/Navigation/NavigationDestination.swift b/RIADigiDoc/Domain/Model/Navigation/NavigationDestination.swift index 2c8b81d5..883478a3 100644 --- a/RIADigiDoc/Domain/Model/Navigation/NavigationDestination.swift +++ b/RIADigiDoc/Domain/Model/Navigation/NavigationDestination.swift @@ -85,4 +85,8 @@ public enum NavigationDestination: Hashable { personalCode: String, actionMethod: ActionMethod ) + + case webEidView( + webEidURL: URL + ) } diff --git a/RIADigiDoc/Domain/Model/Signing/ActionType.swift b/RIADigiDoc/Domain/Model/Signing/ActionType.swift index 65397d4e..cb32302e 100644 --- a/RIADigiDoc/Domain/Model/Signing/ActionType.swift +++ b/RIADigiDoc/Domain/Model/Signing/ActionType.swift @@ -19,8 +19,15 @@ import Foundation -public enum ActionType { +public enum ActionType: Sendable { case signing case myeid case decrypt + case auth + case certificate + case signingWebEid + + var isWebEidFlow: Bool { + self == .auth || self == .certificate || self == .signingWebEid + } } diff --git a/RIADigiDoc/Domain/NFC/NFCOperationBase.swift b/RIADigiDoc/Domain/NFC/NFCOperationBase.swift index 9c0c66ef..200c301e 100644 --- a/RIADigiDoc/Domain/NFC/NFCOperationBase.swift +++ b/RIADigiDoc/Domain/NFC/NFCOperationBase.swift @@ -76,6 +76,8 @@ public class NFCOperationBase: NSObject, Loggable, @MainActor NFCTagReaderSessio nfcError = strings?.sessionErrorMessage ?? "" case .notActivated: nfcError = strings?.courierCardErrorMessage ?? "" + case .pinLocked: + nfcError = strings?.pinLockedErrorMessage ?? "" default: nfcError = strings?.technicalErrorMessage ?? "" } diff --git a/RIADigiDoc/Domain/NFC/NFCSessionStrings.swift b/RIADigiDoc/Domain/NFC/NFCSessionStrings.swift index 2789669b..7fa20b55 100644 --- a/RIADigiDoc/Domain/NFC/NFCSessionStrings.swift +++ b/RIADigiDoc/Domain/NFC/NFCSessionStrings.swift @@ -28,6 +28,7 @@ public struct NFCSessionStrings: Sendable { let pinWrongMultipleErrorMessage: String let pinWrongErrorMessage: String let pinBlockedErrorMessage: String + let pinLockedErrorMessage: String let wrongCardErrorMessage: String let courierCardErrorMessage: String let technicalErrorMessage: String diff --git a/RIADigiDoc/Domain/NFC/NFCSessionStringsUtil.swift b/RIADigiDoc/Domain/NFC/NFCSessionStringsUtil.swift index 7e7e93bc..943a43e7 100644 --- a/RIADigiDoc/Domain/NFC/NFCSessionStringsUtil.swift +++ b/RIADigiDoc/Domain/NFC/NFCSessionStringsUtil.swift @@ -27,7 +27,7 @@ public struct NFCSessionStringsUtil { self.localize = localize } - public func makeDefault(pinName: String = "") -> NFCSessionStrings { + public func makeDefault(pinName: String) -> NFCSessionStrings { customLocalizations(pinName: pinName) } @@ -78,6 +78,7 @@ public struct NFCSessionStringsUtil { pinWrongMultipleErrorMessage: String? = nil, pinWrongErrorMessage: String? = nil, pinBlockedErrorMessage: String? = nil, + pinLockedErrorMessage: String? = nil, wrongCardErrorMessage: String? = nil, courierCardErrorMessage: String? = nil, technicalErrorMessage: String? = nil, @@ -107,6 +108,13 @@ public struct NFCSessionStringsUtil { "PIN blocked", [pinAction == .unblock ? CodeType.puk.name : pinName] ), + pinLockedErrorMessage: pinLockedErrorMessage ?? { + switch pinName { + case CodeType.pin1.name: return localize("PIN1 locked", []) + case CodeType.pin2.name: return localize("PIN2 locked", []) + default: return localize("NFC technical error", []) + } + }(), wrongCardErrorMessage: wrongCardErrorMessage ?? localize("Failed to find lock for cert", []), courierCardErrorMessage: courierCardErrorMessage ?? localize("ID card courier must activate to sign", []), technicalErrorMessage: technicalErrorMessage ?? localize("NFC technical error", []), diff --git a/RIADigiDoc/Domain/NFC/OperationReadCert.swift b/RIADigiDoc/Domain/NFC/OperationReadCert.swift new file mode 100644 index 00000000..7dc1be48 --- /dev/null +++ b/RIADigiDoc/Domain/NFC/OperationReadCert.swift @@ -0,0 +1,124 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import CoreNFC +import nfclib +import UtilsLib + +@MainActor +final public class OperationReadCert: NFCOperationBase, OperationReadCertProtocol { + private var continuation: CheckedContinuation? + private var returnData: String? + + public func startReading( + canNumber: String, + strings: NFCSessionStrings, + ) async throws -> String { + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + guard NFCTagReaderSession.readingAvailable else { + continuation.resume(throwing: IdCardInternalError.nfcNotSupported) + return + } + + self.canNumber = canNumber + self.strings = strings + + session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self) + updateAlertMessage(step: 0) + session?.begin() + } + } + + // MARK: - NFCTagReaderSessionDelegate + + public override func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { + Task { + defer { + self.session = nil + } + + do { + updateAlertMessage(step: 1) + Self.logger().info("Setting up NFC connection...") + let tag = try await connection.setup(session, tags: tags) + + updateAlertMessage(step: 2) + Self.logger().info("Establishing secure channel with CAN...") + let cardCommands = try await connection.getCardCommands(session, tag: tag, CAN: canNumber) + + updateAlertMessage(step: 3) + + Self.logger().info("Reading signature certificate") + let signerCert = try await cardCommands.readSignatureCertificate() + + updateAlertMessage(step: 4) + + returnData = signerCert.base64EncodedString() + + success() + } catch { + if let idCardInternalError = error as? IdCardInternalError { + handleIdCardInternalError(idCardInternalError, session: session) + return + } + + if let nfcIdCardError = error as? nfclib.IdCardInternalError { + handleIdCardInternalError(nfcIdCardError, session: session) + return + } + + handleUnknownError(error, session: session) + } + } + } + + public override func tagReaderSession(_: NFCTagReaderSession, didInvalidateWithError error: Error) { + Self.logger().info("NFC: Reader session finished with error: \(error)") + self.session = nil + + guard let continuationToResume = self.continuation else { return } + self.continuation = nil + + if let returnData, didCompleteSuccessfully { + continuationToResume.resume(with: .success(returnData)) + return + } + + if let storedError = self.operationError { + continuationToResume.resume(throwing: storedError) + return + } + + if let nfcError = error as? NFCReaderError { + switch nfcError.code { + case .readerSessionInvalidationErrorUserCanceled: + continuationToResume.resume(throwing: IdCardInternalError.cancelledByUser) + return + + default: + break + } + } + + continuationToResume.resume(throwing: error) + } +} diff --git a/RIADigiDoc/Domain/NFC/OperationWebEidAuth.swift b/RIADigiDoc/Domain/NFC/OperationWebEidAuth.swift new file mode 100644 index 00000000..6c0c9194 --- /dev/null +++ b/RIADigiDoc/Domain/NFC/OperationWebEidAuth.swift @@ -0,0 +1,260 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import CoreNFC +import CommonCrypto +import CommonsLib +import CryptoKit +import CryptoTokenKit +import Security +import nfclib +import LibdigidocLibSwift +import UtilsLib + +public struct WebEidAuthReturnData: Sendable { + var authCert: Data + var signingCert: Data + var signatureArray: Data +} + +@MainActor +public class OperationWebEidAuth: NFCOperationBase, OperationWebEidAuthProtocol { + private var pin1Number: SecureData = SecureData([0x00]) + private var origin: String = "" + private var challenge: String = "" + private var userAgent: String = "" + private var returnData: WebEidAuthReturnData? + + private var continuation: CheckedContinuation? + + // swiftlint:disable:next function_parameter_count + public func startOperation( + canNumber: String, + pin1Number: SecureData, + origin: String, + challenge: String, + userAgent: String, + strings: NFCSessionStrings + ) async throws -> WebEidAuthReturnData { + + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + guard NFCTagReaderSession.readingAvailable else { + continuation.resume(throwing: IdCardInternalError.nfcNotSupported) + return + } + + self.canNumber = canNumber + self.pin1Number = pin1Number + self.origin = origin + self.challenge = challenge + self.userAgent = userAgent + self.strings = strings + + session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self) + updateAlertMessage(step: 0) + session?.begin() + } + } + + // MARK: - NFCTagReaderSessionDelegate + + // swiftlint:disable:next cyclomatic_complexity + public override func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { + Task { @MainActor in + defer { + self.session = nil + } + + if userAgent.isEmpty { + let error = ReadCertAndSignError.userAgentEmpty + Self.logger().error("NFC: \(error.localizedDescription)") + operationError = error + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? + "Failed to initialize user agent") + return + } + + Self.logger().info("NFC: Checks complete starting authentication") + + do { + updateAlertMessage(step: 1) + let tag = try await connection.setup(session, tags: tags) + + updateAlertMessage(step: 2) + let cardCommands = try await connection.getCardCommands(session, tag: tag, CAN: canNumber) + + updateAlertMessage(step: 3) + + let (retryCount, pinActive) = try await cardCommands.readCodeTryCounterRecord(.pin1) + + if retryCount == 0 { + throw IdCardInternalError.remainingPinRetryCount(Int(retryCount)) + } + if !pinActive { + throw IdCardInternalError.pinLocked + } + let authCert = try await cardCommands.readAuthenticationCertificate() + let signerCert = try await cardCommands.readSignatureCertificate() + + guard let certificate = SecCertificateCreateWithData(nil, authCert as CFData) else { + let error = ReadCertAndSignError.invalidCertificate + Self.logger().error("NFC: \(error.localizedDescription)") + operationError = error + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? + "Invalid certificate") + return + } + + guard let publicKey = SecCertificateCopyKey(certificate) else { + let error = ReadCertAndSignError.missingPublicKey + Self.logger().error("NFC: \(error.localizedDescription)") + operationError = error + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? + "Missing public key") + return + } + + updateAlertMessage(step: 4) + + guard let hashAlgorithm = try resolveHashAlgorithm(from: publicKey) else { + let error = ReadCertAndSignError.missingPublicKey + Self.logger().error("NFC: \(error.localizedDescription)") + operationError = error + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? + "Unsupported algorithm") + + return + } + + let originHash = digest(Data(origin.utf8), using: hashAlgorithm) + let challengeHash = digest(Data(challenge.utf8), using: hashAlgorithm) + + let signedData = originHash + challengeHash + let tbsHash = digest(signedData, using: hashAlgorithm) + + let signatureArray = try await cardCommands.authenticate(for: tbsHash, withPin1: pin1Number) + + returnData = WebEidAuthReturnData( + authCert: authCert, + signingCert: signerCert, + signatureArray: signatureArray + ) + + success() + } catch { + if let idCardInternalError = error as? IdCardInternalError { + handleIdCardInternalError(idCardInternalError, session: session) + return + } + + if let nfcIdCardError = error as? nfclib.IdCardInternalError { + handleIdCardInternalError(nfcIdCardError, session: session) + return + } + + if let readCertSignError = error as? ReadCertAndSignError { + Self.logger() + .error("NFC: ReadCertAndSignError: \(readCertSignError.localizedDescription)") + operationError = readCertSignError + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? "") + return + } + + if let digiDocError = error as? DigiDocError { + handleDigiDocError(digiDocError, session: session) + return + } + + handleUnknownError(error, session: session) + } + } + } + + public override func tagReaderSession(_: NFCTagReaderSession, didInvalidateWithError error: Error) { + Self.logger().info("NFC: Reader session finished with error: \(error)") + self.session = nil + + guard let continuationToResume = self.continuation else { return } + self.continuation = nil + + if let returnData, didCompleteSuccessfully { + continuationToResume.resume(with: .success(returnData)) + return + } + + if let storedError = self.operationError { + continuationToResume.resume(throwing: storedError) + return + } + + if let nfcError = error as? NFCReaderError { + switch nfcError.code { + case .readerSessionInvalidationErrorUserCanceled: + continuationToResume.resume(throwing: IdCardInternalError.cancelledByUser) + return + + default: + break + } + } + + continuationToResume.resume(throwing: error) + } + + // MARK: - Helpers + private func resolveHashAlgorithm(from publicKey: SecKey) throws -> HashAlgorithm? { + guard let attrs = SecKeyCopyAttributes(publicKey) as? [CFString: Any], + let keyType = attrs[kSecAttrKeyType] as? String, + let keySizeBits = attrs[kSecAttrKeySizeInBits] as? Int else { + return nil + } + + guard keyType == (kSecAttrKeyTypeECSECPrimeRandom as String) else { + return nil + } + + switch keySizeBits { + case 256: return .sha256 + case 384: return .sha384 + case 521: return .sha512 + default: + return nil + } + } + + private enum HashAlgorithm { + case sha256 + case sha384 + case sha512 + } + + private func digest(_ data: Data, using algorithm: HashAlgorithm) -> Data { + switch algorithm { + case .sha256: + return Data(SHA256.hash(data: data)) + case .sha384: + return Data(SHA384.hash(data: data)) + case .sha512: + return Data(SHA512.hash(data: data)) + } + } +} diff --git a/RIADigiDoc/Domain/NFC/OperationWebEidSign.swift b/RIADigiDoc/Domain/NFC/OperationWebEidSign.swift new file mode 100644 index 00000000..d340ab98 --- /dev/null +++ b/RIADigiDoc/Domain/NFC/OperationWebEidSign.swift @@ -0,0 +1,232 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import CoreNFC +import CommonCrypto +import CommonsLib +import CryptoTokenKit +import Security +import nfclib +import LibdigidocLibSwift +import UtilsLib + +public struct WebEidSignReturnData: Sendable { + var signerCertB64: String + var signatureArray: Data + var responseUri: String +} + +@MainActor +public class OperationWebEidSign: NFCOperationBase, OperationWebEidSignProtocol { + private var pin2Number: SecureData = SecureData([0x00]) + private var responseUri: String = "" + private var hashToSign: String = "" + private var expectedSigningCertBase64: String? + private var userAgent: String = "" + private var returnData: WebEidSignReturnData? + + private var continuation: CheckedContinuation? + + // swiftlint:disable:next function_parameter_count + public func startOperation( + canNumber: String, + pin2Number: SecureData, + responseUri: String, + hash: String, + expectedSigningCertBase64: String?, + userAgent: String, + strings: NFCSessionStrings + ) async throws -> WebEidSignReturnData { + + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + guard NFCTagReaderSession.readingAvailable else { + continuation.resume(throwing: IdCardInternalError.nfcNotSupported) + return + } + + guard let expectedCert = expectedSigningCertBase64, + !expectedCert.isEmpty, + Data(base64Encoded: expectedCert) != nil else { + continuation.resume(throwing: ReadCertAndSignError.invalidCertificate) + return + } + + guard let hashBytes = Data(base64Encoded: hash), !hashBytes.isEmpty else { + continuation.resume(throwing: ReadCertAndSignError.hashInvalid) + return + } + + self.canNumber = canNumber + self.pin2Number = pin2Number + self.responseUri = responseUri + self.hashToSign = hash + self.expectedSigningCertBase64 = expectedSigningCertBase64 + self.userAgent = userAgent + self.strings = strings + + session = NFCTagReaderSession(pollingOption: .iso14443, delegate: self) + updateAlertMessage(step: 0) + session?.begin() + } + } + + // MARK: - NFCTagReaderSessionDelegate + + // swiftlint:disable:next cyclomatic_complexity + public override func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { + Task { @MainActor in + defer { + self.session = nil + } + + if userAgent.isEmpty { + let error = ReadCertAndSignError.userAgentEmpty + Self.logger().error("NFC: \(error.localizedDescription)") + operationError = error + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? + "Failed to initialize user agent") + return + } + + Self.logger().info("NFC: Checks complete starting signing") + + do { + updateAlertMessage(step: 1) + let tag = try await connection.setup(session, tags: tags) + + updateAlertMessage(step: 2) + let cardCommands = try await connection.getCardCommands(session, tag: tag, CAN: canNumber) + + updateAlertMessage(step: 3) + + let (retryCount, pinActive) = try await cardCommands.readCodeTryCounterRecord(.pin2) + + if retryCount == 0 { + throw IdCardInternalError.remainingPinRetryCount(Int(retryCount)) + } + if !pinActive { + throw IdCardInternalError.pinLocked + } + + let signerCert = try await cardCommands.readSignatureCertificate() + + guard let expectedSigningCertBase64, + !expectedSigningCertBase64.isEmpty, + let expectedCert = Data(base64Encoded: expectedSigningCertBase64) else { + let error = ReadCertAndSignError.invalidCertificate + Self.logger().error("NFC: \(error.localizedDescription)") + operationError = error + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? + "Missing Web eID signing certificate") + return + } + + if expectedCert != signerCert { + let error = ReadCertAndSignError.certMismatch + Self.logger().error("NFC: \(error.localizedDescription)") + operationError = error + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? + "Web eID signing certificate mismatch") + return + } + + let signerCertB64 = signerCert.base64EncodedString() + + guard let hashBytes = Data(base64Encoded: hashToSign), !hashBytes.isEmpty else { + let error = ReadCertAndSignError.hashInvalid + Self.logger().error("NFC: \(error.localizedDescription)") + operationError = error + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? + "Invalid hash encoding") + return + } + + let signatureArray = try await cardCommands.calculateSignature(for: hashBytes, withPin2: pin2Number) + + updateAlertMessage(step: 4) + returnData = WebEidSignReturnData( + signerCertB64: signerCertB64, + signatureArray: signatureArray, + responseUri: responseUri + ) + + success() + } catch { + if let idCardInternalError = error as? IdCardInternalError { + handleIdCardInternalError(idCardInternalError, session: session) + return + } + + if let nfcIdCardError = error as? nfclib.IdCardInternalError { + handleIdCardInternalError(nfcIdCardError, session: session) + return + } + + if let readCertSignError = error as? ReadCertAndSignError { + Self.logger() + .error("NFC: ReadCertAndSignError: \(readCertSignError.localizedDescription)") + operationError = readCertSignError + session.invalidate(errorMessage: strings?.technicalErrorMessage ?? "") + return + } + + if let digiDocError = error as? DigiDocError { + handleDigiDocError(digiDocError, session: session) + return + } + + handleUnknownError(error, session: session) + } + } + } + + public override func tagReaderSession(_: NFCTagReaderSession, didInvalidateWithError error: Error) { + Self.logger().info("NFC: Reader session finished with error: \(error)") + self.session = nil + + guard let continuationToResume = self.continuation else { return } + self.continuation = nil + + if let returnData, didCompleteSuccessfully { + continuationToResume.resume(with: .success(returnData)) + return + } + + if let storedError = self.operationError { + continuationToResume.resume(throwing: storedError) + return + } + + if let nfcError = error as? NFCReaderError { + switch nfcError.code { + case .readerSessionInvalidationErrorUserCanceled: + continuationToResume.resume(throwing: IdCardInternalError.cancelledByUser) + return + + default: + break + } + } + + continuationToResume.resume(throwing: error) + } +} diff --git a/RIADigiDoc/Domain/NFC/Protocols/OperationReadCertProtocol.swift b/RIADigiDoc/Domain/NFC/Protocols/OperationReadCertProtocol.swift new file mode 100644 index 00000000..d7ff7c63 --- /dev/null +++ b/RIADigiDoc/Domain/NFC/Protocols/OperationReadCertProtocol.swift @@ -0,0 +1,27 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +/// @mockable +@MainActor +public protocol OperationReadCertProtocol { + func startReading( + canNumber: String, + strings: NFCSessionStrings, + ) async throws -> String +} diff --git a/RIADigiDoc/Domain/NFC/Protocols/OperationWebEidAuthProtocol.swift b/RIADigiDoc/Domain/NFC/Protocols/OperationWebEidAuthProtocol.swift new file mode 100644 index 00000000..872e1d59 --- /dev/null +++ b/RIADigiDoc/Domain/NFC/Protocols/OperationWebEidAuthProtocol.swift @@ -0,0 +1,37 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import CommonsLib +import Foundation +import nfclib +import LibdigidocLibSwift + +/// @mockable +@MainActor +public protocol OperationWebEidAuthProtocol { + // swiftlint:disable:next function_parameter_count + func startOperation( + canNumber: String, + pin1Number: SecureData, + origin: String, + challenge: String, + userAgent: String, + strings: NFCSessionStrings + ) async throws -> WebEidAuthReturnData +} diff --git a/RIADigiDoc/Domain/NFC/Protocols/OperationWebEidSignProtocol.swift b/RIADigiDoc/Domain/NFC/Protocols/OperationWebEidSignProtocol.swift new file mode 100644 index 00000000..92a1e2c9 --- /dev/null +++ b/RIADigiDoc/Domain/NFC/Protocols/OperationWebEidSignProtocol.swift @@ -0,0 +1,38 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import CommonsLib +import Foundation +import nfclib +import LibdigidocLibSwift + +/// @mockable +@MainActor +public protocol OperationWebEidSignProtocol { + // swiftlint:disable:next function_parameter_count + func startOperation( + canNumber: String, + pin2Number: SecureData, + responseUri: String, + hash: String, + expectedSigningCertBase64: String?, + userAgent: String, + strings: NFCSessionStrings + ) async throws -> WebEidSignReturnData +} diff --git a/RIADigiDoc/Domain/Preferences/DataStore.swift b/RIADigiDoc/Domain/Preferences/DataStore.swift index daf6b1fb..7049c261 100644 --- a/RIADigiDoc/Domain/Preferences/DataStore.swift +++ b/RIADigiDoc/Domain/Preferences/DataStore.swift @@ -524,6 +524,16 @@ public actor DataStore: DataStoreProtocol { userDefaults().set(isDone, forKey: Keys.isRecentDocumentsMigrationDone) } + // MARK: - Web eID + + public func getWebEidRememberMe() async -> Bool { + userDefaults().bool(forKey: Keys.isWebEidRememberMe) + } + + public func setWebEidRememberMe(_ value: Bool) async { + userDefaults().set(value, forKey: Keys.isWebEidRememberMe) + } + // MARK: - Constants private enum DefaultValues { @@ -592,5 +602,6 @@ public actor DataStore: DataStoreProtocol { static let isCrashlyticsAlwaysEnabled = "isCrashlyticsAlwaysEnabled" static let isRecentDocumentsMigrationDone = "isRecentDocumentsMigrationDone" static let isDefaultLTAEnabled = "isDefaultLTAEnabled" + static let isWebEidRememberMe = "isWebEidRememberMe" } } diff --git a/RIADigiDoc/Domain/Preferences/DataStoreProtocol.swift b/RIADigiDoc/Domain/Preferences/DataStoreProtocol.swift index 06f0dd51..74cf4c35 100644 --- a/RIADigiDoc/Domain/Preferences/DataStoreProtocol.swift +++ b/RIADigiDoc/Domain/Preferences/DataStoreProtocol.swift @@ -130,4 +130,8 @@ public protocol DataStoreProtocol: Sendable { // MARK: - Migration func getIsRecentDocumentsMigrationDone() async -> Bool func setIsRecentDocumentsMigrationDone(_ isDone: Bool) async + + // MARK: - Web eID + func getWebEidRememberMe() async -> Bool + func setWebEidRememberMe(_ value: Bool) async } diff --git a/RIADigiDoc/Domain/Preferences/KeychainStore.swift b/RIADigiDoc/Domain/Preferences/KeychainStore.swift index fc666ec7..f62320ee 100644 --- a/RIADigiDoc/Domain/Preferences/KeychainStore.swift +++ b/RIADigiDoc/Domain/Preferences/KeychainStore.swift @@ -27,7 +27,7 @@ public actor KeychainStore: KeychainStoreProtocol, Loggable { self.bundleIdentifier = bundleIdentifier ?? BundleUtil.getBundleIdentifier() } - public func save(key: KeychainKey, info: Data, withPasscodeSetOnly: Bool = false) async -> Bool { + public func save(key: String, info: Data, withPasscodeSetOnly: Bool = false) async -> Bool { let query = baseQuery(key: key) let attributes: [CFString: Any] = [ @@ -48,16 +48,20 @@ public actor KeychainStore: KeychainStoreProtocol, Loggable { let addStatus = SecItemAdd(queryWithAttributes as CFDictionary, nil) return addStatus == errSecSuccess } else { - KeychainStore.logger().error("Unable to save \(key.rawValue): \(status)") + KeychainStore.logger().error("Unable to save keychain item: \(status)") return false } } - public func save(key: KeychainKey, info: Data) async -> Bool { + public func save(key: String, info: Data) async -> Bool { return await save(key: key, info: info, withPasscodeSetOnly: false) } - public func retrieve(key: KeychainKey) async -> Data? { + public func save(key: KeychainKey, info: Data) async -> Bool { + return await save(key: key.rawValue, info: info, withPasscodeSetOnly: false) + } + + public func retrieve(key: String) async -> Data? { var query = baseQuery(key: key) query[kSecReturnData] = true query[kSecMatchLimit] = kSecMatchLimitOne @@ -72,27 +76,45 @@ public actor KeychainStore: KeychainStoreProtocol, Loggable { } } - public func remove(key: KeychainKey) async { + public func retrieve(key: KeychainKey) async -> Data? { + return await retrieve(key: key.rawValue) + } + + public func remove(key: String) async { let query = baseQuery(key: key) let status = SecItemDelete(query as CFDictionary) - if status != errSecSuccess { + if status != errSecSuccess, status != errSecItemNotFound { KeychainStore.logger().error("Error removing key from Keychain: \(status)") } } - public func removeAll() async { - for key in KeychainKey.allCases { - await remove(key: key) + public func remove(key: KeychainKey) async { + await remove(key: key.rawValue) + } + + @discardableResult + public func removeAll() async -> Bool { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: bundleIdentifier + ] + let status = SecItemDelete(query as CFDictionary) + + if status != errSecSuccess, status != errSecItemNotFound { + KeychainStore.logger().error("Error removing all keys from Keychain: \(status)") + return false } + + return true } // MARK: - Helper Methods - private func baseQuery(key: KeychainKey) -> [CFString: Any] { + private func baseQuery(key: String) -> [CFString: Any] { return [ kSecClass: kSecClassGenericPassword, kSecAttrService: bundleIdentifier, - kSecAttrAccount: "\(bundleIdentifier).\(key.rawValue)" + kSecAttrAccount: "\(bundleIdentifier).\(key)" ] } } diff --git a/RIADigiDoc/Domain/Preferences/KeychainStoreProtocol.swift b/RIADigiDoc/Domain/Preferences/KeychainStoreProtocol.swift index 18b627e3..13e60078 100644 --- a/RIADigiDoc/Domain/Preferences/KeychainStoreProtocol.swift +++ b/RIADigiDoc/Domain/Preferences/KeychainStoreProtocol.swift @@ -21,9 +21,13 @@ import Foundation /// @mockable public protocol KeychainStoreProtocol: Sendable { - func save(key: KeychainKey, info: Data, withPasscodeSetOnly: Bool) async -> Bool + func save(key: String, info: Data, withPasscodeSetOnly: Bool) async -> Bool + func save(key: String, info: Data) async -> Bool func save(key: KeychainKey, info: Data) async -> Bool + func retrieve(key: String) async -> Data? func retrieve(key: KeychainKey) async -> Data? + func remove(key: String) async func remove(key: KeychainKey) async - func removeAll() async + @discardableResult + func removeAll() async -> Bool } diff --git a/RIADigiDoc/Info.plist b/RIADigiDoc/Info.plist index badae4ad..fbbc7282 100644 --- a/RIADigiDoc/Info.plist +++ b/RIADigiDoc/Info.plist @@ -213,6 +213,21 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLIconFile + digidoc_icon + CFBundleURLName + ee.ria.digidoc + CFBundleURLSchemes + + web-eid-mobile + + + LSApplicationQueriesSchemes sileo diff --git a/RIADigiDoc/RIADigiDoc.entitlements b/RIADigiDoc/RIADigiDoc.entitlements index cc9ae069..a4ef782b 100644 --- a/RIADigiDoc/RIADigiDoc.entitlements +++ b/RIADigiDoc/RIADigiDoc.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.associated-domains + + applinks:id.eesti.ee + com.apple.developer.nfc.readersession.formats TAG diff --git a/RIADigiDoc/Supporting files/Localizable.xcstrings b/RIADigiDoc/Supporting files/Localizable.xcstrings index 4832bc65..25ba3fcc 100644 --- a/RIADigiDoc/Supporting files/Localizable.xcstrings +++ b/RIADigiDoc/Supporting files/Localizable.xcstrings @@ -199,6 +199,42 @@ } } }, + "authConsentText" : { + "comment" : "WebEid authentication consent text", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "By authenticating, I agree to the transfer of my name and personal identification code to the service provider." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale." + } + } + } + }, + "Authenticate" : { + "comment" : "WebEid authenticate button text", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authenticate" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentimine" + } + } + } + }, "Authentication certificate" : { "comment" : "My eID PIN 1 title", "extractionState" : "manual", @@ -235,6 +271,60 @@ } } }, + "Authentication method" : { + "comment" : "Authentication method selection label", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentication method" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentimise meetod" + } + } + } + }, + "Authentication title" : { + "comment" : "WebEid auth header title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authenticate with ID-card" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentimine ID-kaardiga" + } + } + } + }, + "authRequestFrom" : { + "comment" : "WebEid auth request from field title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentication request from:" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentimispäring:" + } + } + } + }, "Back" : { "extractionState" : "manual", "localizations" : { @@ -468,6 +558,24 @@ } } }, + "Certificate method" : { + "comment" : "Certificate method selection label", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Certificate method" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sertifikaadi meetod" + } + } + } + }, "Certificate not yet valid" : { "comment" : "OperationAuthenticateWithWebEID Certificate validity check", "extractionState" : "manual", @@ -504,6 +612,60 @@ } } }, + "Certificate title" : { + "comment" : "WebEid cert header title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a certificate" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vali sertifikaat" + } + } + } + }, + "certificateConsentText" : { + "comment" : "WebEid certificate consent text", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "By choosing the certificate, I agree to the transfer of my name and personal identification code to the service provider." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sertifikaati valides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale." + } + } + } + }, + "certRequestFrom" : { + "comment" : "WebEid cert request from field title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Certificate request from:" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sertifikaadipäring:" + } + } + } + }, "Change container name" : { "comment" : "Bottom sheet action", "extractionState" : "manual", @@ -827,6 +989,24 @@ } } }, + "Confirm" : { + "comment" : "WebEid certificate button text", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kinnita" + } + } + } + }, "Confirm container removal" : { "comment" : "Recent documents remove container accessibility confirm button", "extractionState" : "manual", @@ -1673,6 +1853,42 @@ } } }, + "details" : { + "comment" : "WebEid details field title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details:" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Andmed:" + } + } + } + }, + "detailsForwarded" : { + "comment" : "WebEid details forwarded field title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details forwarded:" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edastatavad andmed:" + } + } + } + }, "Digi-ID" : { "comment" : "Certificate type", "extractionState" : "manual", @@ -2861,6 +3077,24 @@ } } }, + "Invalid authentication request" : { + "comment" : "WebEid auth request error message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid authentication request" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vigane autentimispäring" + } + } + } + }, "Invalid country code" : { "comment" : "Shown in signing view when country code is not correct", "extractionState" : "manual", @@ -3029,6 +3263,24 @@ } } }, + "Invalid Web eID request" : { + "comment" : "WebEid invalid request error message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid Web eID request" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vigane Web eID päring" + } + } + } + }, "Latvia" : { "comment" : "Country choice in Smart-ID view", "extractionState" : "manual", @@ -5567,6 +5819,24 @@ } } }, + "namePersonalIdentificationCode" : { + "comment" : "WebEid name and personal code text title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAME, PERSONAL IDENTIFICATION CODE" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "NIMI, ISIKUKOOD" + } + } + } + }, "New PIN difference requirement" : { "comment" : "My eID new PIN or PUK difference description", "extractionState" : "manual", @@ -5621,6 +5891,24 @@ } } }, + "NFC certificate mismatch error" : { + "comment" : "NFC certificate mismatch error message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The selected ID card does not match the previously used certificate. Please use the same ID card you authenticated with." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valitud ID-kaart ei vasta varem kasutatud sertifikaadile. Palun kasuta sama ID-kaarti, millega autentisid." + } + } + } + }, "NFC hold card" : { "comment" : "NFC hold card message", "extractionState" : "manual", @@ -6340,6 +6628,40 @@ } } }, + "PIN1 locked" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authenticating with the ID-card isn't possible yet. PIN1 code must be changed in order to authenticate." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selle ID-kaardiga autentimine ei ole veel võimalik. Autentimiseks tuleb PIN1-koodi muuta." + } + } + } + }, + "PIN1 locked URL" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://www.id.ee/en/article/changing-id-card-pin-codes-and-puk-code/" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://www.id.ee/artikkel/id-kaardi-pin-ja-puk-koodide-muutmine/" + } + } + } + }, "PIN2 guideline 1" : { "comment" : "My eID PIN2 guideline", "extractionState" : "manual", @@ -6968,6 +7290,24 @@ } } }, + "rememberMeMessageWebEid" : { + "comment" : "WebEid remember me field message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The entered data will be filled the next time you authenticate." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Järgmisel kasutamisel on andmeväljad eeltäidetud." + } + } + } + }, "Remove" : { "comment" : "Remove text for buttons", "extractionState" : "manual", @@ -7184,6 +7524,24 @@ } } }, + "Request error" : { + "comment" : "WebEid request error message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Request error" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Päringu viga" + } + } + } + }, "Request sending error" : { "comment" : "Mobile-ID error for deliveryError", "extractionState" : "manual", @@ -7884,6 +8242,24 @@ } } }, + "signatureConsentText" : { + "comment" : "WebEid signature consent text", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "By entering your PIN2, you give a handwritten-equivalent digital signature." + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "PIN2 koodi sisestamisega annad omakäelise digiallkirja." + } + } + } + }, "Signatures extended" : { "comment" : "Shown as Toast when extending signatures to LTA succeeds", "extractionState" : "manual", @@ -8118,6 +8494,24 @@ } } }, + "signRequestFrom" : { + "comment" : "WebEid signature request from field title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signing request from:" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allkirjastamispäring:" + } + } + } + }, "SIM error" : { "comment" : "Mobile-ID error for simError", "extractionState" : "manual", diff --git a/RIADigiDoc/UI/Component/Container/Crypto/DecryptRootView.swift b/RIADigiDoc/UI/Component/Container/Crypto/DecryptRootView.swift index 263011c6..cd4cfa28 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/DecryptRootView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/DecryptRootView.swift @@ -47,6 +47,7 @@ struct DecryptRootView: View { actionType: .decrypt, actionMethods: [.idCardViaNFC], pinType: CodeType.pin1, + isWebEidAuthenticating: .constant(false), cryptoContainer: container, onSuccessDecrypt: { container in sharedContainerViewModel.removeLastContainer() diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift index c48d25b4..382b786d 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift @@ -126,8 +126,8 @@ struct EncryptPasswordModalView: View { ([languageSettings.localized("Password requirements")] + EncryptPasswordModalView.requirementKeys.map { languageSettings.localized($0) }) .joined(separator: ". ") - .replacingOccurrences( - of: "–", + .replacing( + "–", with: " \(languageSettings.localized("Password range to accessibility")) " ) } diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift index d4e5a7ab..07dc1e76 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift @@ -168,7 +168,11 @@ struct EncryptRecipientView: View { Button(action: { encryptionButtonEnabled = false pathManager.replaceLast( - to: .encryptView(isWithEncryption: true, cdocOption: cdocOption, selectedTab: .files) + to: .encryptView( + isWithEncryption: true, + cdocOption: cdocOption, + selectedTab: .files + ) ) }, label: { HStack(spacing: Dimensions.Padding.XSPadding) { @@ -325,7 +329,11 @@ struct EncryptRecipientView: View { title: nil, onLeftClick: { pathManager.replaceLast( - to: .encryptView(isWithEncryption: false, cdocOption: cdocOption, selectedTab: .files) + to: .encryptView( + isWithEncryption: false, + cdocOption: cdocOption, + selectedTab: .files + ) ) }, showRightIcons: !isSearchExpanded, diff --git a/RIADigiDoc/UI/Component/Container/Signing/ActionInputScreen.swift b/RIADigiDoc/UI/Component/Container/Signing/ActionInputScreen.swift index b160bdbe..7fe2b506 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/ActionInputScreen.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/ActionInputScreen.swift @@ -26,6 +26,8 @@ struct ActionInputScreen: View { @Environment(LanguageSettings.self) private var languageSettings + @AccessibilityFocusState private var isHeaderFocused: Bool + private let selectedActionMethod: ActionMethod @State private var actionType: ActionType @@ -45,6 +47,12 @@ struct ActionInputScreen: View { languageSettings.localized("Container signing") case .myeid: languageSettings.localized("Identification title") + case .auth: + languageSettings.localized("Authentication title") + case .certificate: + languageSettings.localized("Certificate title") + case .signingWebEid: + languageSettings.localized("Container signing") } } @@ -56,6 +64,12 @@ struct ActionInputScreen: View { languageSettings.localized("Signing method") case .myeid: languageSettings.localized("Identification method") + case .auth: + languageSettings.localized("Authentication method") + case .certificate: + languageSettings.localized("Certificate method") + case .signingWebEid: + languageSettings.localized("Signing method") } } @@ -71,6 +85,12 @@ struct ActionInputScreen: View { languageSettings.localized("Sign") case .myeid: languageSettings.localized("Identify") + case .auth: + languageSettings.localized("Authenticate") + case .certificate: + languageSettings.localized("Confirm") + case .signingWebEid: + languageSettings.localized("Sign") } } @@ -103,6 +123,7 @@ struct ActionInputScreen: View { var body: some View { TopBarContainer( title: nil, + showNavigationIcon: !actionType.isWebEidFlow, onLeftClick: onBackClick, showRightIcons: !isInProgress, content: { @@ -115,6 +136,21 @@ struct ActionInputScreen: View { .padding(.vertical, Dimensions.Padding.SPadding) .accessibilityHeading(.h1) .accessibilityAddTraits([.isHeader]) + .accessibilityFocused($isHeaderFocused) + .task { + guard actionType.isWebEidFlow, UIAccessibility.isVoiceOverRunning else { return } + + // Assert focus, then re-assert after the transition's screen-change + // resets VoiceOver to the TopBar icons (which flips the binding back). + do { + try await Task.sleep(for: .milliseconds(350)) + isHeaderFocused = true + try await Task.sleep(for: .milliseconds(650)) + isHeaderFocused = true + } catch { + return + } + } if !isInProgress { VStack(alignment: .leading, spacing: Dimensions.Padding.SPadding) { @@ -175,6 +211,18 @@ struct ActionInputScreen: View { ) .padding(.vertical, Dimensions.Padding.MPadding) } + + if actionType.isWebEidFlow { + PrimaryButton( + text: languageSettings.localized("Cancel"), + isButtonEnabled: true, + action: onBackClick, + focusedField: nil, + currentFocus: .constant(nil), + backgroundColor: theme.error, + foregroundColor: theme.onError + ) + } } } .padding(.horizontal, Dimensions.Padding.SPadding) diff --git a/RIADigiDoc/UI/Component/Container/Signing/ActionMethodSelectionView.swift b/RIADigiDoc/UI/Component/Container/Signing/ActionMethodSelectionView.swift index dc29056d..6bce0702 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/ActionMethodSelectionView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/ActionMethodSelectionView.swift @@ -45,6 +45,8 @@ struct ActionMethodSelectionView: View { languageSettings.localized("Choose a signing method") case .myeid: languageSettings.localized("Identification method title") + case .auth, .certificate, .signingWebEid: + "" // Web eID flows never present this view } } @@ -56,6 +58,8 @@ struct ActionMethodSelectionView: View { languageSettings.localized("Signing method") case .myeid: languageSettings.localized("Identification method") + case .auth, .certificate, .signingWebEid: + "" // Web eID flows never present this view } } @@ -67,6 +71,8 @@ struct ActionMethodSelectionView: View { languageSettings.localized("Signing method changed") case .myeid: languageSettings.localized("Identification method changed") + case .auth, .certificate, .signingWebEid: + "" // Web eID flows never present this view } } @@ -139,6 +145,8 @@ struct ActionMethodSelectionView: View { selectedMethod = await viewModel.getSelectedSigningMethod() case .myeid: selectedMethod = await viewModel.getSelectedMyEidMethod() + case .auth, .certificate, .signingWebEid: + selectedMethod = .idCardViaNFC // Web eID flows never present this view } } } @@ -159,6 +167,15 @@ struct ActionMethodSelectionView: View { await viewModel.setSelectedSigningMethod(selectedMethod) case .myeid: await viewModel.setSelectedMyEidMethod(selectedMethod) + case .auth: + // Do nothing + break + case .certificate: + // Do nothing + break + case .signingWebEid: + // Do nothing + break } await MainActor.run { diff --git a/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCInputView.swift b/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCInputView.swift index 3a7ad4c5..320cc441 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCInputView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCInputView.swift @@ -38,7 +38,10 @@ struct NFCInputView: View { var pinType: CodeType? let onInputChange: () -> Void - @State private var showPinField: Bool + private let showPinField: Bool + private let isWebEidAuthenticating: Bool + + let isWebEid: Bool private var canNumberTitle: String { languageSettings.localized("CAN number") @@ -56,6 +59,10 @@ struct NFCInputView: View { languageSettings.localized("Remember me") } + private var rememberMeMessage: String { + languageSettings.localized(isWebEid ? "rememberMeMessageWebEid" : "Remember me message") + } + init( canNumber: Binding, rememberMe: Binding, @@ -66,6 +73,8 @@ struct NFCInputView: View { pinType: CodeType?, onInputChange: @escaping () -> Void, showPinField: Bool = true, + isWebEidAuthenticating: Bool = false, + isWebEid: Bool = false, ) { self._canNumber = canNumber self._rememberMe = rememberMe @@ -76,6 +85,8 @@ struct NFCInputView: View { self.pinType = pinType self.onInputChange = onInputChange self.showPinField = showPinField + self.isWebEidAuthenticating = isWebEidAuthenticating + self.isWebEid = isWebEid } var body: some View { @@ -108,19 +119,20 @@ struct NFCInputView: View { } } } - - VStack(spacing: Dimensions.Padding.ZeroPadding) { - ToggleSection(isOn: $rememberMe, label: languageSettings.localized("Remember me")) - .padding(.trailing, Dimensions.Padding.XSPadding) - .padding(.vertical, Dimensions.Padding.ZeroPadding) - .accessibilityLabel(Text(verbatim: "\(rememberMeLabel) \(rememberMe)")) - - if rememberMe { - HStack { - Text(verbatim: languageSettings.localized("Remember me message")) - .font(typography.bodyMedium) - .foregroundStyle(theme.onSurfaceVariant) - Spacer() + if !isWebEidAuthenticating { + VStack(spacing: Dimensions.Padding.ZeroPadding) { + ToggleSection(isOn: $rememberMe, label: languageSettings.localized("Remember me")) + .padding(.trailing, Dimensions.Padding.XSPadding) + .padding(.vertical, Dimensions.Padding.ZeroPadding) + .accessibilityLabel(Text(verbatim: "\(rememberMeLabel) \(rememberMe)")) + + if rememberMe { + HStack { + Text(verbatim: rememberMeMessage) + .font(typography.bodyMedium) + .foregroundStyle(theme.onSurfaceVariant) + Spacer() + } } } } diff --git a/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCView.swift b/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCView.swift index ebc13a49..4a3b693b 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCView.swift @@ -31,6 +31,8 @@ struct NFCView: View { @Environment(LanguageSettings.self) private var languageSettings @Environment(NavigationPathManager.self) private var pathManager + @Binding private var isWebEidAuthenticating: Bool + @State private var actionType: ActionType @State private var actionMethods: [ActionMethod] @State private var canNumber = "" @@ -45,10 +47,15 @@ struct NFCView: View { @State private var nfcActionMessage: String = "NFC hold card" @State private var viewModel: NFCViewModel + @State private var webEidViewModel: WebEidViewModel @State private var taskSign: Task? @State private var taskDecrypt: Task? - @State private var taskMyeid: Task? + @State private var taskMyEid: Task? + + @State private var taskSignWebEid: Task? + @State private var taskAuth: Task? + @State private var taskCertificate: Task? private var isNFCSupported: Bool { viewModel.isNFCSupported() @@ -111,24 +118,36 @@ struct NFCView: View { let onSuccess: (SignedContainerProtocol) -> Void let onSuccessDecrypt: (CryptoContainerProtocol) -> Void + let onSuccessWebEid: () -> Void + let onErrorWebEid: () -> Void init( actionType: ActionType, actionMethods: [ActionMethod], pinType: CodeType? = nil, + isWebEidAuthenticating: Binding, + rememberMe: Bool = true, cryptoContainer: CryptoContainerProtocol? = nil, signedContainer: SignedContainerProtocol? = nil, onSuccess: @escaping (SignedContainerProtocol) -> Void = { _ in }, - onSuccessDecrypt: @escaping (CryptoContainerProtocol) -> Void = { _ in } + onSuccessDecrypt: @escaping (CryptoContainerProtocol) -> Void = { _ in }, + onSuccessWebEid: @escaping () -> Void = { }, + onErrorWebEid: @escaping () -> Void = { }, + webEidViewModel: WebEidViewModel = Container.shared.webEidViewModel() ) { _viewModel = State(wrappedValue: Container.shared.nfcViewModel()) + _webEidViewModel = State(wrappedValue: webEidViewModel) self.actionType = actionType self.pinType = pinType + self._isWebEidAuthenticating = isWebEidAuthenticating + self.rememberMe = rememberMe self.actionMethods = actionMethods self.cryptoContainer = cryptoContainer self.signedContainer = signedContainer self.onSuccess = onSuccess self.onSuccessDecrypt = onSuccessDecrypt + self.onSuccessWebEid = onSuccessWebEid + self.onErrorWebEid = onErrorWebEid } var body: some View { @@ -139,19 +158,70 @@ struct NFCView: View { isActionEnabled: $isActionEnabled, isInProgress: $isInProgress, onBackClick: { + onErrorWebEid() cancelDecrypt() cancelSigning() - cancelMyeid() - guard isInProgress else { + cancelMyEid() + cancelAuth() + cancelCertificate() + cancelSigningWebEid() + + let isWebEidFlow = actionType.isWebEidFlow + let shouldDismiss = !isInProgress + isInProgress = false + + Task { + guard shouldDismiss else { return } + + if isWebEidFlow { + await webEidViewModel.handleUserCancelled() + } + await viewModel.clearTempCAN() + await webEidViewModel.setWebEidSessionActive(false) + dismiss() - return } - isInProgress = false }, onSubmit: { switch actionType { + case .auth: + isInProgress = true + + if !isNFCSupported { + return + } + + isWebEidAuthenticating = true + Task { + auth() + } + case .certificate: + isInProgress = true + + if !isNFCSupported { + return + } + + isWebEidAuthenticating = true + Task { + certificate() + } + case .signingWebEid: + isInProgress = true + if !isNFCSupported { + return + } + Task { + let isRoleDataEnabled = await viewModel.isRoleDataEnabled() + if isRoleDataEnabled { + showRoleView = true + } else { + signWebEid() + } + } case .decrypt: saveInputData() + isInProgress = true if !isNFCSupported { return } @@ -182,6 +252,41 @@ struct NFCView: View { } }, content: { + if webEidViewModel.authRequest != nil { + if !isWebEidAuthenticating { + let origin: String = { + if let authRequest = webEidViewModel.authRequest { + return authRequest.origin + } else { + return "" + } + }() + WebEidAuthInfo(origin: origin) + } + } + if webEidViewModel.certRequest != nil || webEidViewModel.signRequest != nil { + if !isWebEidAuthenticating { + let origin: String = { + if let certRequest = webEidViewModel.certRequest { + return certRequest.origin + } else if let signRequest = webEidViewModel.signRequest { + return signRequest.origin + } else { + return "" + } + }() + + let signingPersonInfo: String? = webEidViewModel.signRequest?.personalData.map { + "\($0.givenNames) \($0.surname), \($0.personalCode)" + } + + WebEidSignOrCertificateInfo( + origin: origin, + isCertificateFlow: webEidViewModel.certRequest != nil, + signingPersonInfo: signingPersonInfo, + ) + } + } if isInProgress { NFCActionView( leftIcon: "ic_m3_phonelink_ring_48pt_wght400", @@ -206,7 +311,9 @@ struct NFCView: View { actionType: actionType ) }, - showPinField: actionType != .myeid + showPinField: actionType != .myeid && actionType != .certificate, + isWebEidAuthenticating: isWebEidAuthenticating, + isWebEid: actionType.isWebEidFlow, ) } } @@ -253,9 +360,10 @@ struct NFCView: View { } .onAppear { Task { - let inputData = await viewModel.getInputData() + let inputData = await viewModel.getInputData(actionType, isWebEidAuthenticating) canNumber = inputData.canNumber rememberMe = inputData.rememberMe + } } .onChange(of: viewModel.nfcErrorKey) { _, newKey in @@ -268,8 +376,25 @@ struct NFCView: View { viewModel.resetErrors() } + .onChange(of: viewModel.certMismatch) { _, mismatch in + if mismatch { + canNumber = "" + } + viewModel.certMismatch = false + } .onDisappear { + Task { + let webEidActive = await webEidViewModel.isWebEidSessionActive() + if !rememberMe && !webEidActive { + await viewModel.clearTempCAN() + } + } + cancelMyEid() + cancelDecrypt() cancelSigning() + cancelAuth() + cancelCertificate() + cancelSigningWebEid() } } @@ -278,7 +403,9 @@ struct NFCView: View { let (inputCANNumber) = rememberMe ? (canNumber) : ("") await viewModel.saveInputData( canNumber: inputCANNumber, - rememberMe: rememberMe + rememberMe: rememberMe, + actionType: actionType, + isWebEidAuthenticating: isWebEidAuthenticating ) } } @@ -291,7 +418,9 @@ struct NFCView: View { await viewModel.saveInputData( canNumber: inputCANNumber, - rememberMe: rememberMe + rememberMe: rememberMe, + actionType: actionType, + isWebEidAuthenticating: isWebEidAuthenticating ) isInProgress = true @@ -320,34 +449,15 @@ struct NFCView: View { } } - private func cancelDecrypt() { - pinNumber.isEmpty ? () : (pinNumber.removeAll()) - isActionEnabled = viewModel - .isActionEnabled(canNumber: canNumber, pinNumber: pinNumber, pinType: pinType) - taskDecrypt?.cancel() - taskDecrypt = nil - } - - private func cancelSigning() { - pinNumber.isEmpty ? () : (pinNumber.removeAll()) - isActionEnabled = viewModel - .isActionEnabled(canNumber: canNumber, pinNumber: pinNumber, pinType: pinType) - taskSign?.cancel() - taskSign = nil - } - - private func cancelMyeid() { - taskMyeid?.cancel() - taskMyeid = nil - } - private func sign(roleData: RoleData? = nil) { taskSign = Task { guard let container = signedContainer else { return } await viewModel.saveInputData( canNumber: rememberMe ? canNumber : "", - rememberMe: rememberMe + rememberMe: rememberMe, + actionType: actionType, + isWebEidAuthenticating: isWebEidAuthenticating ) isInProgress = true @@ -393,16 +503,18 @@ struct NFCView: View { } private func loadMyEid() { - taskMyeid = Task { + taskMyEid = Task { await viewModel.saveInputData( canNumber: rememberMe ? canNumber : "", - rememberMe: rememberMe + rememberMe: rememberMe, + actionType: actionType, + isWebEidAuthenticating: isWebEidAuthenticating ) isInProgress = true nfcActionMessage = "NFC hold card" - let strings = nfcStringsUtil.makeDefault() + let strings = nfcStringsUtil.makeDefault(pinName: CodeType.pin2.name) let cardData = await viewModel.readCardData( CAN: canNumber, strings: strings @@ -410,7 +522,7 @@ struct NFCView: View { isInProgress = false guard let cardData else { - cancelMyeid() + cancelMyEid() return } @@ -425,6 +537,199 @@ struct NFCView: View { } } } + + private func auth() { + taskAuth = Task { + defer { + isInProgress = false + taskAuth = nil + } + + await viewModel.saveInputData( + canNumber: canNumber, + rememberMe: rememberMe, + actionType: actionType, + isWebEidAuthenticating: isWebEidAuthenticating + ) + + isInProgress = true + nfcActionMessage = "NFC hold card" + + let strings = nfcStringsUtil.makeDefault(pinName: CodeType.pin1.name) + + let webEidAuthResult = await viewModel.auth( + canNumber: canNumber, + pin1: pinNumber, + origin: webEidViewModel.authRequest?.origin ?? "", + challenge: webEidViewModel.authRequest?.challenge ?? "", + strings: strings + ) + + guard let result = webEidAuthResult else { + onErrorWebEid() + return + } + + let encodedCert = result.signingCert.base64EncodedString() + await viewModel.setSigningCertificate(encodedCert) + + await webEidViewModel.handleWebEidAuthResult( + authCert: result.authCert, + signingCert: result.signingCert, + signature: result.signatureArray + ) + + onSuccessWebEid() + dismiss() + } + } + + private func certificate() { + taskCertificate = Task { + defer { + isInProgress = false + taskCertificate = nil + } + + await viewModel.saveInputData( + canNumber: canNumber, + rememberMe: rememberMe, + actionType: actionType, + isWebEidAuthenticating: isWebEidAuthenticating + ) + + isInProgress = true + nfcActionMessage = "NFC hold card" + + let strings = nfcStringsUtil.makeDefault(pinName: CodeType.pin2.name) + + let cachedCert = await viewModel.getSigningCertificate() + + let rememberedCan = await viewModel.retrieveCAN() ?? "" + + let canSkipCertificateRead = rememberMe && !cachedCert.isEmpty && + !rememberedCan.isEmpty && canNumber == rememberedCan + + if canSkipCertificateRead { + guard let certBytes = Data(base64Encoded: cachedCert) else { + onErrorWebEid() + return + } + + await webEidViewModel.handleWebEidCertificateResult(signingCert: certBytes) + onSuccessWebEid() + } else { + let webEidCertResult = await viewModel.certificate( + canNumber: canNumber, + strings: strings + ) + + guard let signCert = webEidCertResult else { + onErrorWebEid() + return + } + + await viewModel.setSigningCertificate(signCert) + guard let certBytes = Data(base64Encoded: signCert) else { + onErrorWebEid() + return + } + await webEidViewModel.handleWebEidCertificateResult(signingCert: certBytes) + onSuccessWebEid() + } + + dismiss() + } + } + + private func signWebEid() { + taskSignWebEid = Task { + defer { + isInProgress = false + taskSignWebEid = nil + } + + await viewModel.saveInputData( + canNumber: canNumber, + rememberMe: rememberMe, + actionType: actionType, + isWebEidAuthenticating: isWebEidAuthenticating + ) + + isInProgress = true + nfcActionMessage = "NFC hold card" + + let strings = nfcStringsUtil.makeForSigning(pinName: CodeType.pin2.name) + + let expectedSigningCertBase64 = webEidViewModel.signRequest.map { + (SecCertificateCopyData($0.signingCertificate) as Data).base64EncodedString() + } + let webEidSignResult = await viewModel.signWebEid( + canNumber: canNumber, + pin2: pinNumber, + responseUri: webEidViewModel.signRequest?.responseUri ?? "", + hash: webEidViewModel.signRequest?.hash ?? "", + expectedSigningCertBase64: expectedSigningCertBase64, + strings: strings + ) + + guard let result = webEidSignResult else { + onErrorWebEid() + return + } + + await webEidViewModel.handleWebEidSignResult( + signingCert: result.signerCertB64, + signature: result.signatureArray, + responseUri: result.responseUri + ) + + onSuccessWebEid() + dismiss() + } + } + + private func cancelDecrypt() { + pinNumber.isEmpty ? () : (pinNumber.removeAll()) + isActionEnabled = viewModel + .isActionEnabled(canNumber: canNumber, pinNumber: pinNumber, pinType: pinType) + taskDecrypt?.cancel() + taskDecrypt = nil + } + + private func cancelSigning() { + pinNumber.isEmpty ? () : (pinNumber.removeAll()) + isActionEnabled = viewModel + .isActionEnabled(canNumber: canNumber, pinNumber: pinNumber, pinType: pinType) + taskSign?.cancel() + taskSign = nil + } + + private func cancelMyEid() { + taskMyEid?.cancel() + taskMyEid = nil + } + + private func cancelAuth() { + pinNumber.isEmpty ? () : (pinNumber.removeAll()) + isActionEnabled = viewModel + .isActionEnabled(canNumber: canNumber, pinNumber: pinNumber, pinType: pinType) + taskAuth?.cancel() + taskAuth = nil + } + + private func cancelSigningWebEid() { + pinNumber.isEmpty ? () : (pinNumber.removeAll()) + isActionEnabled = viewModel + .isActionEnabled(canNumber: canNumber, pinNumber: pinNumber, pinType: pinType) + taskSignWebEid?.cancel() + taskSignWebEid = nil + } + + private func cancelCertificate() { + taskCertificate?.cancel() + taskCertificate = nil + } } #Preview { @@ -436,6 +741,7 @@ struct NFCView: View { .smartId ], pinType: CodeType.pin2, + isWebEidAuthenticating: .constant(false), signedContainer: SignedContainer( fileManager: Container.shared.fileManager(), containerUtil: Container.shared.containerUtil() diff --git a/RIADigiDoc/UI/Component/Container/Signing/SigningRootView.swift b/RIADigiDoc/UI/Component/Container/Signing/SigningRootView.swift index 525514b7..eb760c5e 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/SigningRootView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/SigningRootView.swift @@ -56,6 +56,7 @@ struct SigningRootView: View { .smartId ], pinType: CodeType.pin2, + isWebEidAuthenticating: .constant(false), signedContainer: container, onSuccess: { container in isSuccess = true diff --git a/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift b/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift index 71a84457..db75f08d 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/SigningView.swift @@ -542,7 +542,11 @@ struct SigningView: View { Task { @MainActor in let cdocOption = await Container.shared.dataStore().getEncryptionCdocOption(false) pathManager.navigate( - to: .encryptView(isWithEncryption: false, cdocOption: cdocOption, selectedTab: .files) + to: .encryptView( + isWithEncryption: false, + cdocOption: cdocOption, + selectedTab: .files + ) ) } } @@ -630,7 +634,11 @@ struct SigningView: View { let cdocOption = await Container.shared.dataStore().getEncryptionCdocOption(false) await MainActor.run { pathManager.replaceLast( - to: .encryptView(isWithEncryption: false, cdocOption: cdocOption, selectedTab: .files) + to: .encryptView( + isWithEncryption: false, + cdocOption: cdocOption, + selectedTab: .files + ) ) } } diff --git a/RIADigiDoc/UI/Component/CryptoFileOpeningView.swift b/RIADigiDoc/UI/Component/CryptoFileOpeningView.swift index 7b43fa03..e7359f88 100644 --- a/RIADigiDoc/UI/Component/CryptoFileOpeningView.swift +++ b/RIADigiDoc/UI/Component/CryptoFileOpeningView.swift @@ -85,7 +85,8 @@ struct CryptoFileOpeningView: View { } } else { let localizedMessage = languageSettings.localized( - errorMessage?.key ?? "General error", errorMessage?.args ?? [] + errorMessage?.key ?? "General error", + errorMessage?.args ?? [] ) Toast.show(localizedMessage) diff --git a/RIADigiDoc/UI/Component/FileOpeningView.swift b/RIADigiDoc/UI/Component/FileOpeningView.swift index c6372111..c98bebf6 100644 --- a/RIADigiDoc/UI/Component/FileOpeningView.swift +++ b/RIADigiDoc/UI/Component/FileOpeningView.swift @@ -121,7 +121,8 @@ struct FileOpeningView: View { } } else { let localizedMessage = languageSettings.localized( - errorMessage?.key ?? "General error", errorMessage?.args ?? [] + errorMessage?.key ?? "General error", + errorMessage?.args ?? [] ) Toast.show(localizedMessage) diff --git a/RIADigiDoc/UI/Component/HomeView.swift b/RIADigiDoc/UI/Component/HomeView.swift index 2486e762..2e41d79e 100644 --- a/RIADigiDoc/UI/Component/HomeView.swift +++ b/RIADigiDoc/UI/Component/HomeView.swift @@ -232,7 +232,7 @@ struct HomeView: View { ) .bottomSheet(isPresented: $showHomeMenuBottomSheet, actions: homeMenuBottomSheetActions) .onOpenURL { url in - handleFiles([url]) + handleIncoming(url: url) } .onAppear { focusFilesButtonWithDelay() @@ -251,7 +251,11 @@ struct HomeView: View { Task { @MainActor in let cdocOption = await Container.shared.dataStore().getEncryptionCdocOption(false) navigateWithVoiceOverFocusGuard( - to: .encryptView(isWithEncryption: false, cdocOption: cdocOption, selectedTab: .files) + to: .encryptView( + isWithEncryption: false, + cdocOption: cdocOption, + selectedTab: .files + ) ) isNavigatingToEncryptView = false } @@ -312,6 +316,24 @@ struct HomeView: View { viewModel.setChosenFiles(.success(files)) } } + + private func handleIncoming(url: URL) { + let webEidURL = (WebEidUriUtil.isWebEidUri(url)) ? url : nil + + let externalFileURLs: [URL] = (webEidURL != nil) ? [] : getExternalFileURLs(from: url) + handleFiles(externalFileURLs) + + if let webEidUrl = webEidURL { + pathManager.navigate(to: .webEidView(webEidURL: webEidUrl)) + } + } + + private func getExternalFileURLs(from url: URL) -> [URL] { + if url.isFileURL { + return [url] + } + return [] + } } #Preview { diff --git a/RIADigiDoc/UI/Component/My eID/MyEidCertificateCardView.swift b/RIADigiDoc/UI/Component/MyEid/MyEidCertificateCardView.swift similarity index 100% rename from RIADigiDoc/UI/Component/My eID/MyEidCertificateCardView.swift rename to RIADigiDoc/UI/Component/MyEid/MyEidCertificateCardView.swift diff --git a/RIADigiDoc/UI/Component/My eID/MyEidDataView.swift b/RIADigiDoc/UI/Component/MyEid/MyEidDataView.swift similarity index 100% rename from RIADigiDoc/UI/Component/My eID/MyEidDataView.swift rename to RIADigiDoc/UI/Component/MyEid/MyEidDataView.swift diff --git a/RIADigiDoc/UI/Component/My eID/MyEidDetailView.swift b/RIADigiDoc/UI/Component/MyEid/MyEidDetailView.swift similarity index 100% rename from RIADigiDoc/UI/Component/My eID/MyEidDetailView.swift rename to RIADigiDoc/UI/Component/MyEid/MyEidDetailView.swift diff --git a/RIADigiDoc/UI/Component/My eID/MyEidPinChangeView.swift b/RIADigiDoc/UI/Component/MyEid/MyEidPinChangeView.swift similarity index 100% rename from RIADigiDoc/UI/Component/My eID/MyEidPinChangeView.swift rename to RIADigiDoc/UI/Component/MyEid/MyEidPinChangeView.swift diff --git a/RIADigiDoc/UI/Component/My eID/MyEidPinsAndCertificatesView.swift b/RIADigiDoc/UI/Component/MyEid/MyEidPinsAndCertificatesView.swift similarity index 100% rename from RIADigiDoc/UI/Component/My eID/MyEidPinsAndCertificatesView.swift rename to RIADigiDoc/UI/Component/MyEid/MyEidPinsAndCertificatesView.swift diff --git a/RIADigiDoc/UI/Component/My eID/MyEidRootView.swift b/RIADigiDoc/UI/Component/MyEid/MyEidRootView.swift similarity index 96% rename from RIADigiDoc/UI/Component/My eID/MyEidRootView.swift rename to RIADigiDoc/UI/Component/MyEid/MyEidRootView.swift index 8bb4adf9..5e2ae5c6 100644 --- a/RIADigiDoc/UI/Component/My eID/MyEidRootView.swift +++ b/RIADigiDoc/UI/Component/MyEid/MyEidRootView.swift @@ -30,6 +30,7 @@ struct MyEidRootView: View { NFCView( actionType: .myeid, actionMethods: [.idCardViaNFC], + isWebEidAuthenticating: .constant(false), onSuccess: { _ in } ) } diff --git a/RIADigiDoc/UI/Component/My eID/MyEidView.swift b/RIADigiDoc/UI/Component/MyEid/MyEidView.swift similarity index 100% rename from RIADigiDoc/UI/Component/My eID/MyEidView.swift rename to RIADigiDoc/UI/Component/MyEid/MyEidView.swift diff --git a/RIADigiDoc/UI/Component/Recent documents/RecentDocumentsView.swift b/RIADigiDoc/UI/Component/Recent documents/RecentDocumentsView.swift index 9d620769..287c37d4 100644 --- a/RIADigiDoc/UI/Component/Recent documents/RecentDocumentsView.swift +++ b/RIADigiDoc/UI/Component/Recent documents/RecentDocumentsView.swift @@ -207,7 +207,11 @@ struct RecentDocumentsView: View { Task { @MainActor in let cdocOption = await Container.shared.dataStore().getEncryptionCdocOption(false) pathManager.navigate( - to: .encryptView(isWithEncryption: false, cdocOption: cdocOption, selectedTab: .files) + to: .encryptView( + isWithEncryption: false, + cdocOption: cdocOption, + selectedTab: .files + ) ) isNavigatingToEncryptView = false } diff --git a/RIADigiDoc/UI/Component/Shared/ColoredSignedStatusText.swift b/RIADigiDoc/UI/Component/Shared/ColoredSignedStatusText.swift index 5066e8ed..0773915d 100644 --- a/RIADigiDoc/UI/Component/Shared/ColoredSignedStatusText.swift +++ b/RIADigiDoc/UI/Component/Shared/ColoredSignedStatusText.swift @@ -26,7 +26,7 @@ struct ColoredSignedStatusText: View { let text: String let status: SignatureStatus - var archiveTimestampText: String? = nil + var archiveTimestampText: String? var isArchiveTimestampExpired: Bool = false private var isSignatureValidOrWarning: Bool { diff --git a/RIADigiDoc/UI/Component/Shared/PrimaryButton.swift b/RIADigiDoc/UI/Component/Shared/PrimaryButton.swift index 0f25e8f0..e5622780 100644 --- a/RIADigiDoc/UI/Component/Shared/PrimaryButton.swift +++ b/RIADigiDoc/UI/Component/Shared/PrimaryButton.swift @@ -29,6 +29,8 @@ struct PrimaryButton: View { private let text: String private let isButtonEnabled: Bool private let action: () -> Void + private let backgroundColor: Color? + private let foregroundColor: Color? @Binding private var currentFocus: AccessibilityField? @State private var focusedField: AccessibilityField? @@ -41,12 +43,16 @@ struct PrimaryButton: View { action: @escaping () -> Void, focusedField: AccessibilityField?, currentFocus: Binding, + backgroundColor: Color? = nil, + foregroundColor: Color? = nil, ) { self.text = text self.isButtonEnabled = isButtonEnabled self.action = action self.focusedField = focusedField self._currentFocus = currentFocus + self.backgroundColor = backgroundColor + self.foregroundColor = foregroundColor } var body: some View { @@ -54,7 +60,9 @@ struct PrimaryButton: View { action: action, label: { Text(verbatim: text) - .foregroundStyle(isButtonEnabled ? theme.onPrimary : theme.surfaceContainerHighest) + .foregroundStyle(isButtonEnabled + ? (foregroundColor ?? theme.onPrimary) + : theme.surfaceContainerHighest) .font(typography.labelLarge) .lineLimit(nil) .multilineTextAlignment(.center) @@ -64,7 +72,7 @@ struct PrimaryButton: View { .frame(maxWidth: .infinity) .background( Capsule() - .fill(isButtonEnabled ? theme.primary : Color.gray) + .fill(isButtonEnabled ? (backgroundColor ?? theme.primary) : Color.gray) ) }) .disabled(!isButtonEnabled) diff --git a/RIADigiDoc/UI/Component/Shared/TopBar/TopBar.swift b/RIADigiDoc/UI/Component/Shared/TopBar/TopBar.swift index 52ec142d..84a00cb2 100644 --- a/RIADigiDoc/UI/Component/Shared/TopBar/TopBar.swift +++ b/RIADigiDoc/UI/Component/Shared/TopBar/TopBar.swift @@ -30,6 +30,7 @@ struct TopBarContainer: View { var title: String? var titleAccessibility: String? + var showNavigationIcon = true var leftIcon: String = "ic_m3_arrow_back_ios_48pt_wght400" var leftIconAccessibility: String = "Back" @@ -87,6 +88,7 @@ struct TopBarContainer: View { TopBar( title: title, titleAccessibility: titleAccessibility, + showNavigationIcon: showNavigationIcon, leftIcon: leftIcon, leftIconAccessibility: leftIconAccessibility, leftIconAccessibilityInput: leftIconAccessibilityInput, @@ -169,7 +171,7 @@ struct TopBar: View { var title: String? var titleAccessibility: String? - + var showNavigationIcon = true var leftIcon: String var leftIconAccessibility: String var leftIconAccessibilityInput: String? @@ -204,16 +206,17 @@ struct TopBar: View { var body: some View { HStack { - Button(action: onLeftClick) { - Image(leftIcon) - .resizable() - .scaledToFit() - .frame(width: Dimensions.Icon.IconSizeXXS, height: Dimensions.Icon.IconSizeXXS) - .foregroundStyle(theme.onSurfaceVariant) + if showNavigationIcon { + Button(action: onLeftClick) { + Image(leftIcon) + .resizable() + .scaledToFit() + .frame(width: Dimensions.Icon.IconSizeXXS, height: Dimensions.Icon.IconSizeXXS) + .foregroundStyle(theme.onSurfaceVariant) + } + .accessibilityLabel(languageSettings.localized(leftIconAccessibility)) + .accessibilityInputLabels(getInputLabels(leftIconAccessibilityInput, leftIconAccessibility)) } - .accessibilityLabel(languageSettings.localized(leftIconAccessibility)) - .accessibilityInputLabels(getInputLabels(leftIconAccessibilityInput, leftIconAccessibility)) - if let title = title { Text(title) .foregroundStyle(theme.onSurface) diff --git a/RIADigiDoc/UI/Component/WebEid/WebEidAuthInfo.swift b/RIADigiDoc/UI/Component/WebEid/WebEidAuthInfo.swift new file mode 100644 index 00000000..54f76150 --- /dev/null +++ b/RIADigiDoc/UI/Component/WebEid/WebEidAuthInfo.swift @@ -0,0 +1,93 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import SwiftUI + +struct WebEidAuthInfo: View { + + @Environment(LanguageSettings.self) private var languageSettings + + @AppTheme private var theme + @AppTypography private var typography + + let origin: String + + private var titleText: String { + "authRequestFrom" + } + + private var detailsTitleText: String { + "detailsForwarded" + } + + private var personLine: String { + return "namePersonalIdentificationCode" + } + + private var consentText: String { + "authConsentText" + } + + var body: some View { + VStack(alignment: .leading, spacing: Dimensions.Padding.ZeroPadding) { + VStack(alignment: .leading, spacing: Dimensions.Padding.ZeroPadding) { + Text(languageSettings.localized(titleText)) + .font(typography.labelSmall) + .foregroundStyle(theme.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer().frame(height: Dimensions.Padding.XXXSPadding) + + Text(verbatim: WebEidUriUtil.displayOrigin(origin)) + .font(typography.bodyLarge) + .foregroundStyle(theme.onSurface) + .fontWeight(.medium) + .lineLimit(2) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + + Spacer().frame(height: Dimensions.Padding.SPadding) + + VStack(alignment: .leading, spacing: Dimensions.Padding.ZeroPadding) { + Text(languageSettings.localized(detailsTitleText)) + .font(typography.labelSmall) + .foregroundStyle(theme.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer().frame(height: Dimensions.Padding.XXXSPadding) + + Text(languageSettings.localized(personLine)) + .font(typography.bodyLarge) + .foregroundStyle(theme.onSurface) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + + Spacer().frame(height: Dimensions.Padding.SPadding) + + Text(languageSettings.localized(consentText)) + .font(typography.bodyLarge) + .foregroundStyle(theme.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/RIADigiDoc/UI/Component/WebEid/WebEidSignOrCertificateInfo.swift b/RIADigiDoc/UI/Component/WebEid/WebEidSignOrCertificateInfo.swift new file mode 100644 index 00000000..b77ea6fb --- /dev/null +++ b/RIADigiDoc/UI/Component/WebEid/WebEidSignOrCertificateInfo.swift @@ -0,0 +1,113 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import SwiftUI + +struct WebEidSignOrCertificateInfo: View { + + @Environment(LanguageSettings.self) private var languageSettings + + @AppTheme private var theme + @AppTypography private var typography + + let origin: String + let isCertificateFlow: Bool + let signingPersonInfo: String? + + private var titleText: String { + isCertificateFlow + ? "certRequestFrom" + : "signRequestFrom" + } + + private var detailsTitleText: String { + isCertificateFlow + ? "detailsForwarded" + : "details" + } + + private var signingPerson: String? { + guard !isCertificateFlow, + let info = signingPersonInfo?.trimmingCharacters(in: .whitespacesAndNewlines), + !info.isEmpty else { + return nil + } + return info + } + + private var consentText: String { + isCertificateFlow + ? "certificateConsentText" + : "signatureConsentText" + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + VStack(alignment: .leading, spacing: 0) { + Text(languageSettings.localized(titleText)) + .font(typography.labelSmall) + .foregroundStyle(theme.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer().frame(height: Dimensions.Padding.XXXSPadding) + + Text(verbatim: WebEidUriUtil.displayOrigin(origin)) + .font(typography.bodyLarge) + .foregroundStyle(theme.onSurface) + .fontWeight(.medium) + .lineLimit(2) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + + Spacer().frame(height: Dimensions.Padding.SPadding) + + VStack(alignment: .leading, spacing: 0) { + Text(languageSettings.localized(detailsTitleText)) + .font(typography.labelSmall) + .foregroundStyle(theme.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer().frame(height: Dimensions.Padding.XXXSPadding) + + Group { + if let signingPerson { + Text(verbatim: signingPerson) + } else { + Text(languageSettings.localized("namePersonalIdentificationCode")) + } + } + .font(typography.bodyLarge) + .foregroundStyle(theme.onSurface) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + + Spacer().frame(height: Dimensions.Padding.SPadding) + + Text(languageSettings.localized(consentText)) + .font(typography.bodyLarge) + .foregroundStyle(theme.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/RIADigiDoc/UI/Component/WebEid/WebEidView.swift b/RIADigiDoc/UI/Component/WebEid/WebEidView.swift new file mode 100644 index 00000000..07f2d486 --- /dev/null +++ b/RIADigiDoc/UI/Component/WebEid/WebEidView.swift @@ -0,0 +1,208 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import SwiftUI +import FactoryKit +import CommonsLib +import nfclib +import UtilsLib + +struct WebEidView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @Environment(LanguageSettings.self) private var languageSettings + @Environment(NavigationPathManager.self) private var pathManager + + @AppTheme private var theme + @AppTypography private var typography + + @State private var viewModel: WebEidViewModel + @State private var nfcViewModel: NFCViewModel + @State private var isWebEidAuthenticating: Bool = false + + private var webEidUrl: URL + + private var errorMessage: String { + languageSettings.localized( + viewModel.errorKey ?? "", + viewModel.errorExtraArguments + ) + } + + private var alertTitle: String { + languageSettings.localized( + viewModel.alertMessageKey ?? "", + viewModel.alertMessageExtraArguments + ) + } + + private var alertInfoURL: URL? { + guard + let messageUrl = viewModel.alertMessageUrl, + !messageUrl.isEmpty + else { + return nil + } + + let localizedUrl = languageSettings.localized(messageUrl) + guard let url = URL(string: localizedUrl), UIApplication.shared.canOpenURL(url) else { + return nil + } + + return url + } + + private func handleWebEidOperation(for url: URL) { + let operation = WebEidUriUtil.getOperation(from: url) + switch operation { + case .auth: + viewModel.handleAuth(url: url) + case .cert: + viewModel.handleCertificate(url: url) + case .sign: + viewModel.handleSign(url: url) + case .unknown: + viewModel.handleUnknown(url: url) + } + } + + private func activateWebEidSession() { + Task { + if await viewModel.isWebEidSessionActive() { + await nfcViewModel.clearTempCAN() + } + await viewModel.setWebEidSessionActive(true) + } + } + + init( + webEidUrl: URL, + ) { + _viewModel = State(wrappedValue: Container.shared.webEidViewModel()) + _nfcViewModel = State(wrappedValue: Container.shared.nfcViewModel()) + self.webEidUrl = webEidUrl + } + + var body: some View { + ZStack { + if viewModel.authRequest != nil { + NFCView( + actionType: .auth, + actionMethods: [ + .idCardViaNFC + ], + pinType: CodeType.pin1, + isWebEidAuthenticating: $isWebEidAuthenticating, + onSuccessWebEid: { + isWebEidAuthenticating = false + Task { await viewModel.setWebEidSessionActive(false) } + }, + onErrorWebEid: { + isWebEidAuthenticating = false + }, + webEidViewModel: viewModel + ) + } + if viewModel.certRequest != nil || viewModel.signRequest != nil { + if viewModel.certRequest != nil { + NFCView( + actionType: .certificate, + actionMethods: [ + .idCardViaNFC + ], + isWebEidAuthenticating: $isWebEidAuthenticating, + onSuccessWebEid: { + isWebEidAuthenticating = false + }, + onErrorWebEid: { + isWebEidAuthenticating = false + }, + webEidViewModel: viewModel + ) + } else { + NFCView( + actionType: .signingWebEid, + actionMethods: [ + .idCardViaNFC + ], + pinType: CodeType.pin2, + isWebEidAuthenticating: $isWebEidAuthenticating, + onSuccessWebEid: { + isWebEidAuthenticating = false + Task { + await nfcViewModel.clearTempCAN() + await viewModel.setWebEidSessionActive(false) + } + }, + onErrorWebEid: { + isWebEidAuthenticating = false + }, + webEidViewModel: viewModel + ) + } + } + } + .alert( + alertTitle, + isPresented: $viewModel.showAlertMessage + ) { + Button(languageSettings.localized("OK")) { + viewModel.resetErrors() + } + + if let alertInfoURL { + Button(languageSettings.localized("Additional information")) { + openURL(alertInfoURL) + viewModel.resetErrors() + } + } + } + .onAppear { + handleWebEidOperation(for: webEidUrl) + } + .onChange(of: viewModel.relyingPartyResponseEvents) { _, responseURL in + guard let responseURL else { return } + + openURL(responseURL) + + viewModel.relyingPartyResponseEvents = nil + } + .onChange(of: viewModel.errorEventId) { _, _ in + guard viewModel.errorKey != nil else { return } + Toast.show(errorMessage) + } + .onChange(of: webEidUrl) {_, url in + handleWebEidOperation(for: url) + } + .onChange(of: viewModel.authRequest) {_, newRequest in + guard newRequest != nil else { return } + activateWebEidSession() + } + .onChange(of: viewModel.certRequest) {_, newRequest in + guard newRequest != nil else { return } + activateWebEidSession() + } + } +} + +#Preview { + WebEidView( + webEidUrl: URL(fileURLWithPath: "") + ) +} diff --git a/RIADigiDoc/UI/Navigation/NavigationDestinations.swift b/RIADigiDoc/UI/Navigation/NavigationDestinations.swift index a4f2cd38..9c6fec89 100644 --- a/RIADigiDoc/UI/Navigation/NavigationDestinations.swift +++ b/RIADigiDoc/UI/Navigation/NavigationDestinations.swift @@ -123,6 +123,8 @@ struct NavigationDestinations: ViewModifier { personalCode: personalCode, actionMethod: actionMethod ) + case .webEidView(let webEidURL): + WebEidView(webEidUrl: webEidURL) } } } diff --git a/RIADigiDoc/UI/Theme/Dimensions.swift b/RIADigiDoc/UI/Theme/Dimensions.swift index 254346fe..6a46bd06 100644 --- a/RIADigiDoc/UI/Theme/Dimensions.swift +++ b/RIADigiDoc/UI/Theme/Dimensions.swift @@ -43,6 +43,7 @@ enum Dimensions { enum Padding { static let ZeroPadding: CGFloat = 0 + static let XXXSPadding: CGFloat = 2 static let XXSPadding: CGFloat = 4 static let XSPadding: CGFloat = 8 static let MSPadding: CGFloat = 12 diff --git a/RIADigiDoc/Util/EncryptedData/EncryptedDataUtil.swift b/RIADigiDoc/Util/EncryptedData/EncryptedDataUtil.swift index 9a8d482a..1404b39b 100644 --- a/RIADigiDoc/Util/EncryptedData/EncryptedDataUtil.swift +++ b/RIADigiDoc/Util/EncryptedData/EncryptedDataUtil.swift @@ -40,27 +40,6 @@ public struct EncryptedDataUtil: EncryptedDataUtilProtocol, Loggable { // MARK: - Key Management - private func storeKey(_ key: SymmetricKey, to url: URL) throws { - let keyData = key.withUnsafeBytes { Data($0) } - try keyData.write(to: url, options: .atomic) - } - - @discardableResult - public func saveSymmetricKeyToAppSupport(fileName: String) throws -> URL { - guard let appSupportDirectory = applicationSupportDirectory() else { - EncryptedDataUtil.logger().error("Unable to locate Application Support directory") - throw EncryptedDataError.unableToLocateAppSupportDirectory - } - - let symmetricKeyURL = appSupportDirectory.appendingPathComponent(fileName) - let symmetricKey = SymmetricKey(size: .bits256) - - try storeKey(symmetricKey, to: symmetricKeyURL) - - EncryptedDataUtil.logger().info("Symmetric key saved to: \(symmetricKeyURL.path)") - return symmetricKeyURL - } - public func getSymmetricKey(fileName: String) throws -> SymmetricKey { guard let appSupportDirectory = applicationSupportDirectory() else { EncryptedDataUtil.logger().error("Unable to locate Application Support directory") @@ -80,21 +59,6 @@ public struct EncryptedDataUtil: EncryptedDataUtilProtocol, Loggable { // MARK: - Encryption/Decryption - public func encryptSecret(_ secret: String, with key: SymmetricKey) -> Data? { - guard let secretData = secret.data(using: .utf8) else { - EncryptedDataUtil.logger().error("Unable to convert secret to data") - return nil - } - - do { - let sealedBox = try ChaChaPoly.seal(secretData, using: key) - return sealedBox.combined - } catch { - EncryptedDataUtil.logger().error("Unable to encrypt secret: \(error.localizedDescription)") - return nil - } - } - public func decryptSecret(_ data: Data, with symmetricKey: SymmetricKey) -> String? { do { let sealedBox = try ChaChaPoly.SealedBox(combined: data) diff --git a/RIADigiDoc/Util/EncryptedData/EncryptedDataUtilProtocol.swift b/RIADigiDoc/Util/EncryptedData/EncryptedDataUtilProtocol.swift index 2dc18e16..b1778ae3 100644 --- a/RIADigiDoc/Util/EncryptedData/EncryptedDataUtilProtocol.swift +++ b/RIADigiDoc/Util/EncryptedData/EncryptedDataUtilProtocol.swift @@ -22,8 +22,6 @@ import CryptoKit /// @mockable public protocol EncryptedDataUtilProtocol: Sendable { - func saveSymmetricKeyToAppSupport(fileName: String) throws -> URL func getSymmetricKey(fileName: String) throws -> SymmetricKey - func encryptSecret(_ secret: String, with key: SymmetricKey) -> Data? func decryptSecret(_ data: Data, with symmetricKey: SymmetricKey) -> String? } diff --git a/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift b/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift new file mode 100644 index 00000000..50ac4d07 --- /dev/null +++ b/RIADigiDoc/Util/WebEid/WebEidUriUtil.swift @@ -0,0 +1,71 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation + +public enum WebEidOperation: String, CaseIterable, Sendable { + case auth + case cert + case sign + case unknown + + public static func fromOperation(_ operation: String) -> WebEidOperation { + Self.allCases.first { $0.rawValue == operation } ?? WebEidOperation.unknown + } +} + +public enum WebEidUriUtil { + private static let customScheme = "web-eid-mobile" + private static let appLinksHost = "id.eesti.ee" + + public static func isWebEidUri(_ url: URL) -> Bool { + getOperation(from: url) != WebEidOperation.unknown + } + + public static func getOperation(from url: URL) -> WebEidOperation { + var operation: String? + + let scheme = url.scheme?.lowercased() + let host = url.host?.lowercased() + + if scheme == customScheme { + operation = host + } else if scheme == "https", host == appLinksHost { + operation = url.pathComponents.dropFirst().first + } else { + operation = WebEidOperation.unknown.rawValue + } + + guard let operation else { return WebEidOperation.unknown } + return WebEidOperation.fromOperation(operation) + } + + public static func displayOrigin(_ origin: String) -> String { + guard let components = URLComponents(string: origin), + let scheme = components.scheme, + let encodedHost = components.percentEncodedHost else { + return origin.unicodeScalars + .filter { !$0.properties.isBidiControl } + .reduce(into: "") { $0.unicodeScalars.append($1) } + } + + let portPart = components.port.map { ":\($0)" } ?? "" + return "\(scheme)://\(encodedHost)\(portPart)" + } +} diff --git a/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift b/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift index ca206ce9..c16bb348 100644 --- a/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift @@ -81,6 +81,7 @@ class EncryptRecipientViewModel: EncryptRecipientViewModelProtocol, Loggable { func loadRecipients() async { if !searchText.isEmpty { let result = await openLdap.search(identityCode: searchText) + if result.tooManyResults { recipients = [] errorMessage = ToastMessage(key: "Too many results", args: []) diff --git a/RIADigiDoc/ViewModel/Protocols/WebEid/WebEidViewModelProtocol.swift b/RIADigiDoc/ViewModel/Protocols/WebEid/WebEidViewModelProtocol.swift new file mode 100644 index 00000000..4ae2a043 --- /dev/null +++ b/RIADigiDoc/ViewModel/Protocols/WebEid/WebEidViewModelProtocol.swift @@ -0,0 +1,46 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation + +/// @mockable +@MainActor +public protocol WebEidViewModelProtocol: Sendable { + func handleAuth(url: URL) + func handleCertificate(url: URL) + func handleSign(url: URL) + func handleUnknown(url: URL) + + func handleWebEidAuthResult( + authCert: Data, + signingCert: Data, + signature: Data + ) async + func handleWebEidCertificateResult(signingCert: Data) async + func handleWebEidSignResult( + signingCert: String, + signature: Data, + responseUri: String + ) async + + func resetErrors() + + func isWebEidSessionActive() async -> Bool + func setWebEidSessionActive(_ value: Bool) async +} diff --git a/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModel.swift b/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModel.swift index 921e5dfe..9263fb7d 100644 --- a/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModel.swift +++ b/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModel.swift @@ -18,6 +18,7 @@ */ import Foundation +import CryptoKit import CryptoObjCWrapper import CryptoSwift import nfclib @@ -45,6 +46,7 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { var nfcAlertMessageUrl: String? var signatureExtensionFailed = false + var certMismatch: Bool = false private let nfcCANKeyFilename = Constants.File.nfcCANKey @@ -55,7 +57,10 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { private let keychainStore: KeychainStoreProtocol private let encryptedDataUtil: EncryptedDataUtilProtocol private let operationReadCertAndSign: OperationReadCertAndSignProtocol + private let operationWebEidAuth: OperationWebEidAuthProtocol + private let operationWebEidSign: OperationWebEidSignProtocol private let operationReadCardData: OperationReadCardDataProtocol + private let operationReadCert: OperationReadCertProtocol private let operationDecrypt: OperationDecryptProtocol init( @@ -66,7 +71,10 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { keychainStore: KeychainStoreProtocol, encryptedDataUtil: EncryptedDataUtilProtocol, operationReadCertAndSign: OperationReadCertAndSignProtocol, + operationWebEidAuth: OperationWebEidAuthProtocol, + operationWebEidSign: OperationWebEidSignProtocol, operationReadCardData: OperationReadCardDataProtocol, + operationReadCert: OperationReadCertProtocol, operationDecrypt: OperationDecryptProtocol ) { self.dataStore = dataStore @@ -76,7 +84,10 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { self.keychainStore = keychainStore self.encryptedDataUtil = encryptedDataUtil self.operationReadCertAndSign = operationReadCertAndSign + self.operationWebEidAuth = operationWebEidAuth + self.operationWebEidSign = operationWebEidSign self.operationReadCardData = operationReadCardData + self.operationReadCert = operationReadCert self.operationDecrypt = operationDecrypt } @@ -94,7 +105,7 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { ) -> Bool { checkCANNumberValidity(canNumber: canNumber) let canNumberValid = (!canNumber.isEmpty && canNumberErrorKey?.isEmpty == true) - if actionType == .myeid { + if actionType == .myeid || actionType == .certificate { return canNumberValid } @@ -104,105 +115,194 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { return result } - func saveInputData(canNumber: String, rememberMe: Bool) async { - await dataStore.setNFCRememberMe(rememberMe) + func saveInputData( + canNumber: String, + rememberMe: Bool, + actionType: ActionType, + isWebEidAuthenticating _: Bool + ) async { + var isRemembered = rememberMe - if rememberMe && !canNumber.isEmpty { - await saveEncryptedCAN(canNumber) - } else { - await keychainStore.remove(key: .nfcCANKey) + if rememberMe { + isRemembered = await saveCAN(canNumber, key: .nfcCANKey, requiresPasscode: true) } - } + let passcodeWriteFailed = rememberMe && !isRemembered - func getInputData() async -> NFCInputData { - let rememberMe = await dataStore.getNFCRememberMe() + if isRemembered { + await clearTempCAN() + } else { + await setSigningCertificate("") + await clearCAN() - if rememberMe { - if let decryptedCAN = await retrieveEncryptedCAN() { - return NFCInputData( - canNumber: decryptedCAN, - rememberMe: true - ) + if passcodeWriteFailed { + await clearTempCAN() + } else { + await saveTempCAN(canNumber) } } - return NFCInputData(canNumber: "", rememberMe: rememberMe) + if actionType.isWebEidFlow { + await dataStore.setWebEidRememberMe(isRemembered) + } else { + await dataStore.setNFCRememberMe(isRemembered) + } } - private func saveEncryptedCAN(_ can: String) async { - do { - let symmetricKey = try encryptedDataUtil.getSymmetricKey(fileName: self.nfcCANKeyFilename) - - if let encryptedCAN = encryptedDataUtil.encryptSecret(can, with: symmetricKey) { - let saved = await keychainStore.save( - key: .nfcCANKey, - info: encryptedCAN, - withPasscodeSetOnly: true - ) - - if saved { - NFCViewModel.logger().info("CAN encrypted and saved successfully") - } else { - NFCViewModel.logger().error("Failed to save encrypted CAN to keychain") - } + func getInputData(_ actionType: ActionType, _ isWebEidAuthenticating: Bool) async -> NFCInputData { + let rememberMe = await { + if actionType.isWebEidFlow { + return await dataStore.getWebEidRememberMe() } else { - NFCViewModel.logger().error("Encryption failed for CAN string") + return await dataStore.getNFCRememberMe() } - } catch { - do { - let symKeyURL = try encryptedDataUtil.saveSymmetricKeyToAppSupport( - fileName: self.nfcCANKeyFilename - ) - let symKey = try encryptedDataUtil.getSymmetricKey( - fileName: symKeyURL.lastPathComponent - ) - - if let encryptedCAN = encryptedDataUtil.encryptSecret(can, with: symKey) { - let saved = await keychainStore.save( - key: .nfcCANKey, - info: encryptedCAN, - withPasscodeSetOnly: true - ) - - if saved { - NFCViewModel.logger().info("CAN encrypted and saved with new key") - } else { - NFCViewModel.logger().error("Failed to save encrypted CAN after creating new key") - } - } else { - NFCViewModel.logger().error("Encryption failed for CAN after saving new symmetric key") - } - } catch { - NFCViewModel.logger().error("Unable to save or retrieve symmetric key: \(error.localizedDescription)") + }() + + let storedCan = await retrieveCAN() + let tempCan = await retrieveTempCAN() + + let initialCan: String = { + if actionType == .certificate { + return storedCan ?? "" } + if actionType == .signingWebEid, let tempCan, !tempCan.isEmpty { + return tempCan + } + if let storedCan, !storedCan.isEmpty { + return storedCan + } + if isWebEidAuthenticating, let tempCan, !tempCan.isEmpty { + return tempCan + } + return "" + }() + + return NFCInputData( + canNumber: initialCan, + rememberMe: rememberMe + ) + } + + func saveCAN(_ can: String) async { + _ = await saveCAN(can, key: .nfcCANKey, requiresPasscode: true) + } + + func retrieveCAN() async -> String? { + await retrieveCAN(key: .nfcCANKey) + } + + func clearCAN() async { + await keychainStore.remove(key: .nfcCANKey) + } + + func saveTempCAN(_ can: String) async { + _ = await saveCAN(can, key: .tempCANKey, requiresPasscode: false) + } + + func retrieveTempCAN() async -> String? { + await retrieveCAN(key: .tempCANKey) + } + + func clearTempCAN() async { + await keychainStore.remove(key: .tempCANKey) + } + + private func saveCAN(_ can: String, key: KeychainKey, requiresPasscode: Bool) async -> Bool { + let saved = await keychainStore.save( + key: key.rawValue, + info: Data(can.utf8), + withPasscodeSetOnly: requiresPasscode + ) + + if !saved { + NFCViewModel.logger().error("Failed to save CAN for \(key.rawValue)") } + + return saved } - private func retrieveEncryptedCAN() async -> String? { - do { - guard let encryptedCANData = await keychainStore.retrieve(key: .nfcCANKey) else { - NFCViewModel.logger().info("No encrypted CAN found in keychain") - return nil - } + private func retrieveCAN(key: KeychainKey) async -> String? { + guard let storedData = await keychainStore.retrieve(key: key) else { + return nil + } - let symmetricKey = try encryptedDataUtil.getSymmetricKey( - fileName: self.nfcCANKeyFilename - ) + if let can = String(data: storedData, encoding: .utf8), isValidCANFormat(can) { + return can + } - if let decryptedCAN = encryptedDataUtil.decryptSecret(encryptedCANData, with: symmetricKey) { - NFCViewModel.logger().info("CAN decrypted successfully") - return decryptedCAN - } else { - NFCViewModel.logger().error("Failed to decrypt CAN") + return await migrateLegacyEncryptedCAN(storedData, key: key) + } + + private func migrateLegacyEncryptedCAN(_ storedData: Data, key: KeychainKey) async -> String? { + do { + let symmetricKey = try encryptedDataUtil.getSymmetricKey(fileName: nfcCANKeyFilename) + + guard let can = encryptedDataUtil.decryptSecret(storedData, with: symmetricKey), + isValidCANFormat(can) else { + NFCViewModel.logger().error("Unable to migrate stored CAN for \(key.rawValue)") return nil } + + _ = await saveCAN(can, key: key, requiresPasscode: key == .nfcCANKey) + NFCViewModel.logger().info("Migrated stored CAN for \(key.rawValue)") + return can } catch { - NFCViewModel.logger().error("Unable to get stored CAN symmetric key: \(error.localizedDescription)") + NFCViewModel.logger().error( + "Unable to read stored CAN symmetric key: \(error.localizedDescription)" + ) return nil } } + private func isValidCANFormat(_ value: String) -> Bool { + value.isEmpty || ( + value.count == Constants.Validation.CANNumberLength && + value.allSatisfy { $0.isASCII && $0.isWholeNumber } + ) + } + + private struct CachedSigningCertificate: Codable { + let can: String + let certificate: String + } + + func getSigningCertificate() async -> String { + guard let currentCan = await retrieveCAN(), + let storedData = await keychainStore.retrieve(key: .signingCertKey), + let cached = try? JSONDecoder().decode(CachedSigningCertificate.self, from: storedData), + cached.can == currentCan else { + return "" + } + + return cached.certificate + } + + func setSigningCertificate(_ cert: String) async { + if cert.isEmpty { + await keychainStore.remove(key: .signingCertKey) + return + } + + guard let currentCan = await retrieveCAN() else { return } + + guard let data = try? JSONEncoder().encode( + CachedSigningCertificate(can: currentCan, certificate: cert) + ) else { + NFCViewModel.logger().error("Unable to encode cached signing certificate") + return + } + + let saved = await keychainStore.save( + key: KeychainKey.signingCertKey.rawValue, + info: data, + withPasscodeSetOnly: true + ) + + if !saved { + NFCViewModel.logger().error("Failed to cache signing certificate") + } + } + func resetErrors() { canNumberErrorKey = nil canNumberErrorExtraArguments = [] @@ -212,6 +312,10 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { nfcErrorExtraArguments = [] } + func isRoleDataEnabled() async -> Bool { + await dataStore.getIsRoleAndAddressEnabled() + } + func decrypt( CAN: String, pin1: String, @@ -223,7 +327,7 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { let containerFile = await cryptoContainer?.getRawContainerFile() ?? URL(fileURLWithPath: "") let recipients = await cryptoContainer?.getRecipients() ?? [] let pinSecureData = SecureData(Array(pin1.utf8)) - + await clearTempCAN() do { NFCViewModel.logger().info("NFC: Starting decryption operation") let container = try await operationDecrypt.processDecrypt( @@ -264,126 +368,6 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { } } - private func handleIdCardError(_ error: IdCardError, pinType: CodeType) { - NFCViewModel.logger().error("NFC: ID Card error: \(error)") - - switch error { - case .cancelledByUser: - nfcErrorKey = nil - nfcErrorExtraArguments = [] - case .pinLocked: - showNfcAlertMessage = true - nfcAlertMessageKey = "PIN2 locked" - nfcAlertMessageUrl = "PIN2 locked URL" - case .notActivated: - showNfcAlertMessage = true - nfcAlertMessageKey = pinType == .pin2 - ? "ID card courier must activate to sign" - : "ID card courier must activate to decrypt" - nfcAlertMessageUrl = "ID card courier activate URL" - case .wrongCAN: - nfcErrorKey = "Wrong CAN" - nfcErrorExtraArguments = [] - case .wrongPIN(let triesLeft): - if triesLeft > 1 { - nfcErrorKey = "PIN verification error multiple" - nfcErrorExtraArguments = [pinType.name, String(triesLeft)] - } else if triesLeft == 1 { - nfcErrorKey = "PIN verification error one" - nfcErrorExtraArguments = [pinType.name] - } else { - nfcErrorKey = "PIN blocked" - nfcErrorExtraArguments = [pinType.name] - } - case .sessionError: - nfcErrorKey = "NFC session error" - nfcErrorExtraArguments = [] - default: - nfcErrorKey = "NFC technical error" - nfcErrorExtraArguments = [] - } - } - - private func handleReadCertAndSignError(error: ReadCertAndSignError) { - switch error { - case .cancelled: - nfcErrorKey = nil - case .signedContainerNil, .roleDataNil, .containerPathNil, .userAgentEmpty: - NFCViewModel.logger().error("NFC: Configuration error") - nfcErrorKey = "NFC session error" - case .unknown(let underlying): - NFCViewModel.logger().error("NFC: Unknown error - \(underlying)") - nfcErrorKey = "General error" - } - } - - private func handleDigiDocError(error: DigiDocError) { - switch error { - case .signatureAddingFailed(let underlying): - handleDigiDocSignError(errorDetail: underlying) - default: - NFCViewModel.logger().error("NFC: Unknown DigiDoc error - \(error)") - nfcErrorKey = "General error" - } - } - - private func handleDigiDocSignError(errorDetail: ErrorDetail) { - NFCViewModel.logger().error("NFC: DigiDoc signature adding error - \(errorDetail.description)") - switch errorDetail.code { - case 5, 6: - nfcErrorKey = "Certificate status revoked" - case 7: - showNfcAlertMessage = true - nfcAlertMessageKey = "OCSP response not in valid time slot" - nfcAlertMessageUrl = "OCSP response not in valid time slot url" - case 18: - showNfcAlertMessage = true - nfcAlertMessageKey = "Too many requests" - nfcAlertMessageUrl = "Too many requests url" - nfcAlertMessageExtraArguments = ["NFC"] - case 20: - nfcErrorKey = "No Internet connection" - case 101, 102: - nfcErrorKey = "SSL handshake failed" - default: - nfcErrorKey = "General error" - } - } - - private func handleDecryptError(error: DecryptError) { - switch error { - case .cancelled: - nfcErrorKey = nil - case .containerFileInvalid, .recipientsEmpty: - NFCViewModel.logger().error("NFC: Configuration error") - nfcErrorKey = "NFC session error" - case .noCertLock: - NFCViewModel.logger().error("NFC: Failed to find lock for cert") - nfcErrorKey = "Failed to find lock for cert" - case .unknown(let underlying): - NFCViewModel.logger().error("NFC: Unknown error - \(underlying)") - nfcErrorKey = "General error" - } - } - - func isRoleDataEnabled() async -> Bool { - await dataStore.getIsRoleAndAddressEnabled() - } - - private func checkCANNumberValidity(canNumber: String) { - guard canNumber.isEmpty || ( - canNumber.count == Constants.Validation.CANNumberLength && - canNumber.allSatisfy { $0.isNumber } - ) else { - canNumberErrorKey = "CAN length requirement" - canNumberErrorExtraArguments = [String( - Constants.Validation.CANNumberLength - )] - return - } - canNumberErrorKey = "" - } - func sign( canNumber: String, pin2: String, @@ -409,6 +393,7 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { NFCViewModel.logger().info("NFC: Getting User-Agent") let appInfo = userAgentUtil.appInfo(diagnostics: .nfc, language: appLanguage) + await clearTempCAN() do { NFCViewModel.logger().info("NFC: Starting signing operation") @@ -464,31 +449,187 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { } } - private func checkPINNumberValidity(pinNumber: String, pinType: CodeType?) { - let minLen = if pinType == .pin1 { - Constants.Validation.Pin1MinimumLength - } else if pinType == .pin2 { - Constants.Validation.Pin2MinimumLength - } else { - Constants.Validation.PukMinimumLength + func auth( + canNumber: String, + pin1: String, + origin: String, + challenge: String, + strings: NFCSessionStrings + ) async -> WebEidAuthReturnData? { + NFCViewModel.logger().info("NFC: Starting NFC Web eID auth") + let pin1Data = pin1.data(using: .utf8) + guard let pin1Data else { + NFCViewModel.logger().error("NFC: Failed to convert PIN1 to Data") + return nil } - let maxLen = Constants.Validation.PinMaximumLength + NFCViewModel.logger().info("NFC: Getting language") + let appLanguage = await dataStore.getSelectedLanguage() - guard pinNumber.isEmpty || ( - pinNumber.count >= minLen && - pinNumber.count <= maxLen && - pinNumber.allSatisfy { $0.isNumber } - ) else { - pinNumberErrorKey = "PIN length requirement" - pinNumberErrorExtraArguments = [pinType?.name ?? "", String(minLen), String(maxLen)] - return + NFCViewModel.logger().info("NFC: Getting User-Agent") + let userAgent = userAgentUtil.userAgent(diagnostics: .nfc, language: appLanguage) + + do { + NFCViewModel.logger().info("NFC: Starting Web eID auth operation") + let result = try await operationWebEidAuth.startOperation( + canNumber: canNumber, + pin1Number: SecureData(pin1Data), + origin: origin, + challenge: challenge, + userAgent: userAgent, + strings: strings + ) + NFCViewModel.logger().info("NFC: Web eID authenticated successfully") + return result + } catch { + NFCViewModel.logger().error("NFC: Web eID auth operation failed") + + if let idCardInternalError = error as? IdCardInternalError { + let idCardError = idCardInternalError.getIdCardError() + NFCViewModel.logger().error("NFC: IdCardError: \(idCardError)") + handleIdCardError(idCardError, pinType: .pin1) + return nil + } + + if let nfcIdCardError = error as? nfclib.IdCardInternalError { + handleIdCardError(IdCardError(nfcIdCardError.getIdCardError()), pinType: .pin1) + return nil + } + + if let webEidAuthError = error as? ReadCertAndSignError { + NFCViewModel.logger().error("NFC: WebEidAuthError: \(webEidAuthError.localizedDescription)") + handleReadCertAndSignError(error: webEidAuthError) + return nil + } + + if let digiDocError = error as? DigiDocError { + NFCViewModel.logger().error("NFC: DigiDocError: \(digiDocError.localizedDescription)") + handleDigiDocError(error: digiDocError) + return nil + } + + NFCViewModel.logger().error("NFC: Unexpected error type: \(type(of: error))") + NFCViewModel.logger().error("NFC: Error details: \(error)") + nfcErrorKey = "General error" + return nil } - pinNumberErrorKey = "" } - public func saveMyEidCAN(_ can: String) { - sharedMyEidSession.setCAN(can) + func certificate( + canNumber: String, + strings: NFCSessionStrings + ) async -> String? { + NFCViewModel.logger().info("NFC: Starting NFC Web eID certificate") + + do { + NFCViewModel.logger().info("NFC: Starting Web eID certificate operation") + let result = try await operationReadCert.startReading( + canNumber: canNumber, + strings: strings + ) + NFCViewModel.logger().info("NFC: Web eID certificate operation success") + return result + } catch { + NFCViewModel.logger().error("NFC: Web eID certificate operation failed") + + if let idCardInternalError = error as? IdCardInternalError { + let idCardError = idCardInternalError.getIdCardError() + NFCViewModel.logger().error("NFC: IdCardError: \(idCardError)") + handleIdCardError(idCardError, pinType: .pin2) + return nil + } + + if let nfcIdCardError = error as? nfclib.IdCardInternalError { + handleIdCardError(IdCardError(nfcIdCardError.getIdCardError()), pinType: .pin2) + return nil + } + + if let readCertError = error as? ReadCertAndSignError { + NFCViewModel.logger().error("NFC: ReadCertError: \(readCertError.localizedDescription)") + handleReadCertAndSignError(error: readCertError) + return nil + } + + if let digiDocError = error as? DigiDocError { + NFCViewModel.logger().error("NFC: DigiDocError: \(digiDocError.localizedDescription)") + handleDigiDocError(error: digiDocError) + return nil + } + + NFCViewModel.logger().error("NFC: Unexpected error type: \(type(of: error))") + NFCViewModel.logger().error("NFC: Error details: \(error)") + nfcErrorKey = "General error" + return nil + } + } + + // swiftlint:disable:next function_parameter_count + func signWebEid( + canNumber: String, + pin2: String, + responseUri: String, + hash: String, + expectedSigningCertBase64: String?, + strings: NFCSessionStrings + ) async -> WebEidSignReturnData? { + NFCViewModel.logger().info("NFC: Starting NFC Web eID signing") + let pin2Data = pin2.data(using: .utf8) + guard let pin2Data else { + NFCViewModel.logger().error("NFC: Failed to convert PIN2 to Data") + return nil + } + + NFCViewModel.logger().info("NFC: Getting language") + let appLanguage = await dataStore.getSelectedLanguage() + + NFCViewModel.logger().info("NFC: Getting User-Agent") + let userAgent = userAgentUtil.userAgent(diagnostics: .nfc, language: appLanguage) + + do { + NFCViewModel.logger().info("NFC: Starting Web eID signing operation") + let result = try await operationWebEidSign.startOperation( + canNumber: canNumber, + pin2Number: SecureData(pin2Data), + responseUri: responseUri, + hash: hash, + expectedSigningCertBase64: expectedSigningCertBase64, + userAgent: userAgent, + strings: strings + ) + NFCViewModel.logger().info("NFC: Web eID signature added successfully") + return result + } catch { + NFCViewModel.logger().error("NFC: Web eID signing operation failed") + + if let idCardInternalError = error as? IdCardInternalError { + let idCardError = idCardInternalError.getIdCardError() + NFCViewModel.logger().error("NFC: IdCardError: \(idCardError)") + handleIdCardError(idCardError, pinType: .pin2) + return nil + } + + if let nfcIdCardError = error as? nfclib.IdCardInternalError { + handleIdCardError(IdCardError(nfcIdCardError.getIdCardError()), pinType: .pin2) + return nil + } + + if let readCertSignError = error as? ReadCertAndSignError { + NFCViewModel.logger().error("NFC: ReadCertAndSignError: \(readCertSignError.localizedDescription)") + handleReadCertAndSignError(error: readCertSignError) + return nil + } + + if let digiDocError = error as? DigiDocError { + NFCViewModel.logger().error("NFC: DigiDocError: \(digiDocError.localizedDescription)") + handleDigiDocError(error: digiDocError) + return nil + } + + NFCViewModel.logger().error("NFC: Unexpected error type: \(type(of: error))") + NFCViewModel.logger().error("NFC: Error details: \(error)") + nfcErrorKey = "General error" + return nil + } } public func readCardData( @@ -542,4 +683,162 @@ class NFCViewModel: NFCViewModelProtocol, Loggable { return nil } } + + public func saveMyEidCAN(_ can: String) { + sharedMyEidSession.setCAN(can) + } + + private func handleIdCardError(_ error: IdCardError, pinType: CodeType) { + NFCViewModel.logger().error("NFC: ID Card error: \(error)") + + switch error { + case .cancelledByUser: + nfcErrorKey = nil + nfcErrorExtraArguments = [] + case .pinLocked: + showNfcAlertMessage = true + if pinType == .pin2 { + nfcAlertMessageKey = "PIN2 locked" + nfcAlertMessageUrl = "PIN2 locked URL" + } else { + nfcAlertMessageKey = "PIN1 locked" + nfcAlertMessageUrl = "PIN1 locked URL" + } + case .notActivated: + showNfcAlertMessage = true + nfcAlertMessageKey = pinType == .pin2 + ? "ID card courier must activate to sign" + : "ID card courier must activate to decrypt" + nfcAlertMessageUrl = "ID card courier activate URL" + case .wrongCAN: + nfcErrorKey = "Wrong CAN" + nfcErrorExtraArguments = [] + case .wrongPIN(let triesLeft): + if triesLeft > 1 { + nfcErrorKey = "PIN verification error multiple" + nfcErrorExtraArguments = [pinType.name, String(triesLeft)] + } else if triesLeft == 1 { + nfcErrorKey = "PIN verification error one" + nfcErrorExtraArguments = [pinType.name] + } else { + nfcErrorKey = "PIN blocked" + nfcErrorExtraArguments = [pinType.name] + } + case .sessionError: + nfcErrorKey = "NFC session error" + nfcErrorExtraArguments = [] + default: + nfcErrorKey = "NFC technical error" + nfcErrorExtraArguments = [] + } + } + + private func handleReadCertAndSignError(error: ReadCertAndSignError) { + switch error { + case .cancelled: + nfcErrorKey = nil + case .signedContainerNil, + .roleDataNil, + .containerPathNil, + .userAgentEmpty, + .hashInvalid, + .invalidCertificate, + .missingPublicKey, + .unsupportedAlgorithm: + NFCViewModel.logger().error("NFC: Configuration error") + nfcErrorKey = "NFC session error" + case .certMismatch: + certMismatch = true + NFCViewModel.logger().error( + "Web eID signing failed - signing certificate does not match previously used certificate" + ) + nfcErrorKey = "NFC certificate mismatch error" + case .unknown(let underlying): + NFCViewModel.logger().error("NFC: Unknown error - \(underlying)") + nfcErrorKey = "General error" + } + } + + private func handleDigiDocError(error: DigiDocError) { + switch error { + case .signatureAddingFailed(let underlying): + handleDigiDocSignError(errorDetail: underlying) + default: + NFCViewModel.logger().error("NFC: Unknown DigiDoc error - \(error)") + nfcErrorKey = "General error" + } + } + + private func handleDigiDocSignError(errorDetail: ErrorDetail) { + NFCViewModel.logger().error("NFC: DigiDoc signature adding error - \(errorDetail.description)") + switch errorDetail.code { + case 5, 6: + nfcErrorKey = "Certificate status revoked" + case 7: + showNfcAlertMessage = true + nfcAlertMessageKey = "OCSP response not in valid time slot" + nfcAlertMessageUrl = "OCSP response not in valid time slot url" + case 18: + showNfcAlertMessage = true + nfcAlertMessageKey = "Too many requests" + nfcAlertMessageUrl = "Too many requests url" + nfcAlertMessageExtraArguments = ["NFC"] + case 20: + nfcErrorKey = "No Internet connection" + case 101, 102: + nfcErrorKey = "SSL handshake failed" + default: + nfcErrorKey = "General error" + } + } + + private func handleDecryptError(error: DecryptError) { + switch error { + case .cancelled: + nfcErrorKey = nil + case .containerFileInvalid, .recipientsEmpty: + NFCViewModel.logger().error("NFC: Configuration error") + nfcErrorKey = "NFC session error" + case .noCertLock: + NFCViewModel.logger().error("NFC: Failed to find lock for cert") + nfcErrorKey = "Failed to find lock for cert" + case .unknown(let underlying): + NFCViewModel.logger().error("NFC: Unknown error - \(underlying)") + nfcErrorKey = "General error" + } + } + + private func checkCANNumberValidity(canNumber: String) { + guard isValidCANFormat(canNumber) else { + canNumberErrorKey = "CAN length requirement" + canNumberErrorExtraArguments = [String( + Constants.Validation.CANNumberLength + )] + return + } + canNumberErrorKey = "" + } + + private func checkPINNumberValidity(pinNumber: String, pinType: CodeType?) { + let minLen = if pinType == .pin1 { + Constants.Validation.Pin1MinimumLength + } else if pinType == .pin2 { + Constants.Validation.Pin2MinimumLength + } else { + Constants.Validation.PukMinimumLength + } + + let maxLen = Constants.Validation.PinMaximumLength + + guard pinNumber.isEmpty || ( + pinNumber.count >= minLen && + pinNumber.count <= maxLen && + pinNumber.allSatisfy { $0.isNumber } + ) else { + pinNumberErrorKey = "PIN length requirement" + pinNumberErrorExtraArguments = [pinType?.name ?? "", String(minLen), String(maxLen)] + return + } + pinNumberErrorKey = "" + } } diff --git a/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModelProtocol.swift b/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModelProtocol.swift index cf3a93b1..dae493b1 100644 --- a/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModelProtocol.swift +++ b/RIADigiDoc/ViewModel/Signing/NFC/NFCViewModelProtocol.swift @@ -35,13 +35,28 @@ public protocol NFCViewModelProtocol: Sendable { func saveInputData( canNumber: String, - rememberMe: Bool + rememberMe: Bool, + actionType: ActionType, + isWebEidAuthenticating: Bool ) async - func getInputData() async -> NFCInputData + func getInputData(_ actionType: ActionType, _ isWebEidAuthenticating: Bool) async -> NFCInputData + + func saveCAN(_ can: String) async + func retrieveCAN() async -> String? + func clearCAN() async + + func saveTempCAN(_ can: String) async + func retrieveTempCAN() async -> String? + func clearTempCAN() async + + func getSigningCertificate() async -> String + func setSigningCertificate(_ cert: String) async func resetErrors() + func isRoleDataEnabled() async -> Bool + func decrypt( CAN: String, pin1: String, @@ -57,10 +72,33 @@ public protocol NFCViewModelProtocol: Sendable { strings: NFCSessionStrings ) async -> SignedContainerProtocol? + // swiftlint:disable:next function_parameter_count + func signWebEid( + canNumber: String, + pin2: String, + responseUri: String, + hash: String, + expectedSigningCertBase64: String?, + strings: NFCSessionStrings + ) async -> WebEidSignReturnData? + + func auth( + canNumber: String, + pin1: String, + origin: String, + challenge: String, + strings: NFCSessionStrings + ) async -> WebEidAuthReturnData? + + func certificate( + canNumber: String, + strings: NFCSessionStrings + ) async -> String? + + func saveMyEidCAN(_ can: String) + func readCardData( CAN: String, strings: NFCSessionStrings ) async -> IdCardData? - - func isRoleDataEnabled() async -> Bool } diff --git a/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift b/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift index c84049f9..4f2fdc10 100644 --- a/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift +++ b/RIADigiDoc/ViewModel/Signing/SmartId/SmartIdViewModel.swift @@ -119,8 +119,7 @@ class SmartIdViewModel: SmartIdViewModelProtocol, Loggable { smartIdAlertMessageUrl = nil } - // swiftlint:disable:next cyclomatic_complexity - // swiftlint:disable:next function_body_length + // swiftlint:disable:next cyclomatic_complexity function_body_length func sign( country: SmartIdCountry, personalCode: String, diff --git a/RIADigiDoc/ViewModel/WebEid/WebEidViewModel.swift b/RIADigiDoc/ViewModel/WebEid/WebEidViewModel.swift new file mode 100644 index 00000000..886e2cda --- /dev/null +++ b/RIADigiDoc/ViewModel/WebEid/WebEidViewModel.swift @@ -0,0 +1,356 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import UtilsLib +import WebEidLib + +@Observable +@MainActor +class WebEidViewModel: WebEidViewModelProtocol, Loggable { + + var errorKey: String? + var errorExtraArguments: [String] = [] + var errorEventId: Int = 0 + + var showAlertMessage: Bool = false + var alertMessageKey: String? + var alertMessageExtraArguments: [String] = [] + var alertMessageUrl: String? + + var authRequest: WebEidAuthRequest? + var signRequest: WebEidSignRequest? + var certRequest: WebEidCertificateRequest? + + var relyingPartyResponseEvents: URL? + + private var hasRespondedToRelyingParty = false + + private let authService: WebEidAuthServiceProtocol + private let signService: WebEidSignServiceProtocol + private let keychainStore: KeychainStoreProtocol + let dataStore: DataStoreProtocol + + init( + dataStore: DataStoreProtocol, + keychainStore: KeychainStoreProtocol, + authService: WebEidAuthServiceProtocol, + signService: WebEidSignServiceProtocol + ) { + self.dataStore = dataStore + self.keychainStore = keychainStore + self.authService = authService + self.signService = signService + } + + func handleAuth(url: URL) { + hasRespondedToRelyingParty = false + do { + let request = try WebEidRequestParser.parseAuthURL(url) + resetRequests() + authRequest = request + } catch { + handleRequestParsingFailure(error, fallbackErrorKey: "Invalid authentication request") + } + } + + func handleCertificate(url: URL) { + hasRespondedToRelyingParty = false + do { + let request = try WebEidRequestParser.parseCertificateURL(url) + resetRequests() + certRequest = request + } catch { + handleRequestParsingFailure(error, fallbackErrorKey: "Invalid Web eID request") + } + } + + func handleSign(url: URL) { + hasRespondedToRelyingParty = false + do { + let request = try WebEidRequestParser.parseSignURL(url) + resetRequests() + signRequest = request + } catch { + handleRequestParsingFailure(error, fallbackErrorKey: "Invalid Web eID request") + } + } + + func handleUnknown(url: URL) { + hasRespondedToRelyingParty = false + WebEidViewModel.logger().error("Unable to parse Web eID request from \(url.host ?? "-")") + reportError("Invalid Web eID request") + } + + private func sendResponse(_ url: URL) { + guard !hasRespondedToRelyingParty else { + WebEidViewModel.logger().error("Ignoring duplicate Web eID response") + return + } + + hasRespondedToRelyingParty = true + relyingPartyResponseEvents = url + } + + private func reportError(_ key: String, arguments: [String] = []) { + errorKey = key + errorExtraArguments = arguments + errorEventId += 1 + } + + private func resetRequests() { + resetErrors() + authRequest = nil + certRequest = nil + signRequest = nil + } + + private func handleRequestParsingFailure(_ error: Error, fallbackErrorKey: String) { + guard let webEidException = error as? WebEidException else { + WebEidViewModel.logger().error("Unable to parse Web eID request") + reportError(fallbackErrorKey) + return + } + + WebEidViewModel.logger().error("Invalid Web eID request: \(webEidException.code.rawValue)") + + guard !webEidException.responseUri.isEmpty else { + reportError(fallbackErrorKey) + return + } + + let errorPayload = WebEidResponseUtil.createErrorPayload( + code: webEidException.code, + message: webEidException.message + ) + + do { + let errorURL = try WebEidResponseUtil.createResponseURL( + responseUri: webEidException.responseUri, + payload: errorPayload + ) + sendResponse(errorURL) + } catch { + WebEidViewModel.logger().error( + "Unable to build Web eID error response: \(error.localizedDescription)" + ) + reportError(fallbackErrorKey) + } + } + + func handleWebEidAuthResult( + authCert: Data, + signingCert: Data, + signature: Data + ) async { + guard let authRequest else { return } + + let loginUri = authRequest.loginUri + let getSigningCertificate = authRequest.getSigningCertificate + + do { + let tokenData = try await authService.buildAuthToken( + authCert: authCert, + signingCert: getSigningCertificate ? signingCert : nil, + signature: signature + ) + + let tokenObject = try JSONSerialization.jsonObject(with: tokenData, options: []) + + let payload: [String: Any] = [ + "authToken": tokenObject + ] + + let responseURL = try WebEidResponseUtil.createResponseURL( + responseUri: loginUri, + payload: payload + ) + + sendResponse(responseURL) + } catch { + WebEidViewModel.logger().error("Unexpected error building auth token: \(String(reflecting: error))") + + let errorPayload = WebEidResponseUtil.createErrorPayload( + code: .ERR_WEBEID_MOBILE_UNKNOWN_ERROR, + message: "Unexpected error" + ) + + do { + let responseURL = try WebEidResponseUtil.createResponseURL( + responseUri: loginUri, + payload: errorPayload + ) + sendResponse(responseURL) + } catch { + WebEidViewModel.logger().error("Failed to build error response URL: \(String(reflecting: error))") + } + } + } + + func handleWebEidCertificateResult(signingCert: Data) async { + guard let responseUri = certRequest?.responseUri, + !responseUri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + WebEidViewModel.logger().error("Missing responseUri in sign payload for certificate step") + return + } + + do { + let payloadData = try await signService.buildCertificatePayload(signingCert: signingCert) + let payloadObject = try JSONSerialization.jsonObject(with: payloadData, options: []) + + guard let payload = payloadObject as? [String: Any] else { + WebEidViewModel.logger().error("Invalid certificate payload JSON") + return + } + + let responseURL = try WebEidResponseUtil.createResponseURL( + responseUri: responseUri, + payload: payload + ) + sendResponse(responseURL) + + } catch { + WebEidViewModel.logger().error( + "Unexpected error building certificate payload: \(String(reflecting: error))" + ) + + let errorPayload = WebEidResponseUtil.createErrorPayload( + code: .ERR_WEBEID_MOBILE_UNKNOWN_ERROR, + message: "Unexpected error" + ) + + do { + let errorURL = try WebEidResponseUtil.createResponseURL( + responseUri: responseUri, + payload: errorPayload + ) + sendResponse(errorURL) + } catch { + WebEidViewModel.logger().error("Failed to build error response URL: \(String(reflecting: error))") + } + } + } + + func handleWebEidSignResult( + signingCert: String, + signature: Data, + responseUri: String + ) async { + do { + guard let hashFunction = signRequest?.hashFunction, + !hashFunction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + WebEidViewModel.logger().error("Missing signRequest") + return + } + + let payloadData = try await signService.buildSignPayload( + signingCert: signingCert, + signature: signature, + hashFunction: hashFunction + ) + + let payloadObject = try JSONSerialization.jsonObject(with: payloadData, options: []) + + guard let payload = payloadObject as? [String: Any] else { + WebEidViewModel.logger().error("Invalid sign payload JSON") + return + } + + let responseURL = try WebEidResponseUtil.createResponseURL( + responseUri: responseUri, + payload: payload + ) + + sendResponse(responseURL) + + } catch { + WebEidViewModel.logger().error("Unexpected error building sign payload: \(String(reflecting: error))") + + let errorPayload = WebEidResponseUtil.createErrorPayload( + code: .ERR_WEBEID_MOBILE_UNKNOWN_ERROR, + message: "Unexpected error" + ) + + do { + let errorURL = try WebEidResponseUtil.createResponseURL( + responseUri: responseUri, + payload: errorPayload + ) + sendResponse(errorURL) + } catch { + WebEidViewModel.logger().error("Failed to build error response URL: \(String(reflecting: error))") + } + } + } + + func handleUserCancelled() async { + do { + let responseUri = + authRequest?.loginUri ?? + certRequest?.responseUri ?? + signRequest?.responseUri + + guard let responseUri, + !responseUri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + Self.logger().error("Cannot send cancel response — missing response URI") + return + } + + let errorPayload = WebEidResponseUtil.createErrorPayload( + code: .ERR_WEBEID_USER_CANCELLED, + message: "User cancelled" + ) + + let errorURL = try WebEidResponseUtil.createResponseURL( + responseUri: responseUri, + payload: errorPayload + ) + + sendResponse(errorURL) + } catch { + WebEidViewModel.logger().error("Failed to send cancel response: \(String(reflecting: error))") + } + } + + func resetErrors() { + showAlertMessage = false + alertMessageKey = nil + alertMessageExtraArguments = [] + alertMessageUrl = nil + errorKey = nil + errorExtraArguments = [] + } + + // MARK: - WebEid KeyChainStore + + func isWebEidSessionActive() async -> Bool { + if let data = await keychainStore.retrieve(key: .webEidSessionActive) { + let value = data.first == 1 + return value + } + + return false + } + + func setWebEidSessionActive(_ value: Bool) async { + let data = Data([value ? 1 : 0]) + + _ = await keychainStore.save(key: .webEidSessionActive, info: data) + } +} diff --git a/RIADigiDocTests/Domain/Preferences/KeychainStoreTests.swift b/RIADigiDocTests/Domain/Preferences/KeychainStoreTests.swift index 4171c00a..2a2cbd4e 100644 --- a/RIADigiDocTests/Domain/Preferences/KeychainStoreTests.swift +++ b/RIADigiDocTests/Domain/Preferences/KeychainStoreTests.swift @@ -88,4 +88,20 @@ final class KeychainStoreTests { } } + @Test + func removeAll_removesDynamicallyNamedKeys() async throws { + let dynamicKey = "\(KeychainKey.signingCertKey.rawValue)_123456" + + let status = await keychainStore.save(key: dynamicKey, info: Data("test-cert".utf8)) + #expect(status == true) + + let storedBeforeRemoval = await keychainStore.retrieve(key: dynamicKey) + #expect(storedBeforeRemoval != nil) + + await keychainStore.removeAll() + + let storedAfterRemoval = await keychainStore.retrieve(key: dynamicKey) + #expect(storedAfterRemoval == nil) + } + } diff --git a/RIADigiDocTests/TestPlans/AllTests.xctestplan b/RIADigiDocTests/TestPlans/AllTests.xctestplan index e3e01fed..f5638842 100644 --- a/RIADigiDocTests/TestPlans/AllTests.xctestplan +++ b/RIADigiDocTests/TestPlans/AllTests.xctestplan @@ -3,14 +3,10 @@ { "id" : "7E76CDC9-F140-47FA-8B69-CBE16A2F858C", "name" : "Configuration 1", - "options" : { - - } + "options" : {} } ], - "defaultOptions" : { - - }, + "defaultOptions" : {}, "testTargets" : [ { "target" : { @@ -67,6 +63,13 @@ "identifier" : "LibdigidocLibTests", "name" : "LibdigidocLibTests" } + }, + { + "target" : { + "containerPath" : "container:Modules\/WebEidLib", + "identifier" : "WebEidLibTests", + "name" : "WebEidLibTests" + } } ], "version" : 1 diff --git a/RIADigiDocTests/Util/EncryptedData/EncryptedDataUtilTests.swift b/RIADigiDocTests/Util/EncryptedData/EncryptedDataUtilTests.swift index c30c759b..85e2ed2a 100644 --- a/RIADigiDocTests/Util/EncryptedData/EncryptedDataUtilTests.swift +++ b/RIADigiDocTests/Util/EncryptedData/EncryptedDataUtilTests.swift @@ -39,40 +39,6 @@ final class EncryptedDataUtilTests { ) } - // MARK: - saveSymmetricKeyToAppSupport tests - - @Test - func saveSymmetricKeyToAppSupport_throwsWhenDirectoryNotFound() async throws { - mockFileManager.urlsHandler = { _, _ in - return [] - } - - #expect(throws: EncryptedDataError.self) { - try encryptedDataUtil.saveSymmetricKeyToAppSupport(fileName: "test.key") - } - } - - @Test - func saveSymmetricKeyToAppSupport_success() async throws { - let tempDirectory = try TestFileUtil.getTemporaryDirectory(subfolder: "keys") - defer { - try? FileManager.default.removeItem(at: tempDirectory) - } - - mockFileManager.urlsHandler = { _, _ in - return [tempDirectory] - } - - let fileName = "test_\(UUID().uuidString).key" - let resultURL = try encryptedDataUtil.saveSymmetricKeyToAppSupport(fileName: fileName) - defer { - try? FileManager.default.removeItem(at: resultURL) - } - - #expect(resultURL.lastPathComponent == fileName) - #expect(mockFileManager.urlsCallCount == 1) - } - // MARK: - getSymmetricKey tests @Test @@ -128,33 +94,18 @@ final class EncryptedDataUtilTests { } } - // MARK: - encryptSecret tests - - @Test - func encryptSecret_success() async throws { - let testSecret = "mySecretPassword123" - let key = SymmetricKey(size: .bits256) - - let encryptedData = encryptedDataUtil.encryptSecret(testSecret, with: key) + // MARK: - decryptSecret tests - #expect(encryptedData != nil) - guard let encryptedData else { return } - #expect(encryptedData.count > 0) + private func seal(_ secret: String, with key: SymmetricKey) throws -> Data { + try ChaChaPoly.seal(Data(secret.utf8), using: key).combined } - // MARK: - decryptSecret tests - @Test func decryptSecret_success() async throws { let testSecret = "mySecretData" let key = SymmetricKey(size: .bits256) - guard let encryptedData = encryptedDataUtil.encryptSecret(testSecret, with: key) else { - Issue.record("Failed to encrypt secret") - return - } - - let decryptedSecret = encryptedDataUtil.decryptSecret(encryptedData, with: key) + let decryptedSecret = encryptedDataUtil.decryptSecret(try seal(testSecret, with: key), with: key) #expect(decryptedSecret == testSecret) } @@ -171,43 +122,31 @@ final class EncryptedDataUtilTests { @Test func decryptSecret_returnsNilForWrongKey() async throws { - let testSecret = "secretData" let key1 = SymmetricKey(size: .bits256) let key2 = SymmetricKey(size: .bits256) - guard let encryptedData = encryptedDataUtil.encryptSecret(testSecret, with: key1) else { - Issue.record("Failed to encrypt secret") - return - } - - let result = encryptedDataUtil.decryptSecret(encryptedData, with: key2) + let result = encryptedDataUtil.decryptSecret(try seal("secretData", with: key1), with: key2) #expect(result == nil) } @Test - func encryptDecrypt_worksWithSpecialChars() async throws { + func decryptSecret_worksWithSpecialChars() async throws { let testSecret = "This is a test secret with special chars: !@#$%^&*()" let key = SymmetricKey(size: .bits256) - let encrypted = encryptedDataUtil.encryptSecret(testSecret, with: key) - #expect(encrypted != nil) - guard let encrypted else { return } + let decrypted = encryptedDataUtil.decryptSecret(try seal(testSecret, with: key), with: key) - let decrypted = encryptedDataUtil.decryptSecret(encrypted, with: key) #expect(decrypted == testSecret) } @Test - func encryptDecrypt_worksWithEmptyString() async throws { + func decryptSecret_worksWithEmptyString() async throws { let testSecret = "" let key = SymmetricKey(size: .bits256) - let encrypted = encryptedDataUtil.encryptSecret(testSecret, with: key) - #expect(encrypted != nil) - guard let encrypted else { return } + let decrypted = encryptedDataUtil.decryptSecret(try seal(testSecret, with: key), with: key) - let decrypted = encryptedDataUtil.decryptSecret(encrypted, with: key) #expect(decrypted == testSecret) } } diff --git a/RIADigiDocTests/Util/Proxy/ProxyUtilTests.swift b/RIADigiDocTests/Util/Proxy/ProxyUtilTests.swift index e47462a9..540e41c2 100644 --- a/RIADigiDocTests/Util/Proxy/ProxyUtilTests.swift +++ b/RIADigiDocTests/Util/Proxy/ProxyUtilTests.swift @@ -61,7 +61,7 @@ final class ProxyUtilTests { testInfo } - mockKeychainStore.retrieveHandler = { _ in + mockKeychainStore.retrieveKeyHandler = { _ in Data(expectedPassword.utf8) } let result = await proxyUtil.getProxyInfo() @@ -87,7 +87,7 @@ final class ProxyUtilTests { testInfo } - mockKeychainStore.retrieveHandler = { _ in + mockKeychainStore.retrieveKeyHandler = { _ in Data(expectedPassword.utf8) } let result = await proxyUtil.getProxyInfo() @@ -113,7 +113,7 @@ final class ProxyUtilTests { testInfo } - mockKeychainStore.retrieveHandler = { _ in + mockKeychainStore.retrieveKeyHandler = { _ in Data(expectedPassword.utf8) } let result = await proxyUtil.getProxyInfo() @@ -163,6 +163,6 @@ final class ProxyUtilTests { ) await proxyUtil.saveSetting(proxyInfo: testInfo) #expect(mockDataStore.setProxyInfoCallCount == 1) - #expect(mockKeychainStore.saveKeyCallCount == 1) + #expect(mockKeychainStore.saveKeyInfoCallCount == 1) } } diff --git a/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift b/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift new file mode 100644 index 00000000..f3b14f21 --- /dev/null +++ b/RIADigiDocTests/Util/WebEid/WebEidUriUtilTests.swift @@ -0,0 +1,160 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Testing + +struct WebEidUriUtilTests { + + private func makeURL(_ string: String) throws -> URL { + try #require(URL(string: string)) + } + + @Test + func isWebEidUri_appLinks_auth() throws { + #expect(WebEidUriUtil.isWebEidUri(try makeURL("https://id.eesti.ee/auth"))) + } + + @Test + func isWebEidUri_appLinks_cert() throws { + #expect(WebEidUriUtil.isWebEidUri(try makeURL("https://id.eesti.ee/cert"))) + } + + @Test + func isWebEidUri_appLinks_sign() throws { + #expect(WebEidUriUtil.isWebEidUri(try makeURL("https://id.eesti.ee/sign"))) + } + + @Test + func isWebEidUri_appLinks_unknownOperation() throws { + #expect(!WebEidUriUtil.isWebEidUri(try makeURL("https://id.eesti.ee/unknown"))) + } + + @Test + func isWebEidUri_wrongHost() throws { + #expect(!WebEidUriUtil.isWebEidUri(try makeURL("https://evil.com/auth"))) + } + + @Test + func isWebEidUri_contentScheme() throws { + #expect(!WebEidUriUtil.isWebEidUri(try makeURL("content://some/path"))) + } + + @Test + func isWebEidUri_fileScheme() throws { + #expect(!WebEidUriUtil.isWebEidUri(try makeURL("file:///some/path"))) + } + + @Test + func getOperation_appLinks_auth() throws { + #expect(WebEidUriUtil.getOperation(from: try makeURL("https://id.eesti.ee/auth#dGVzdA")) == .auth) + } + + @Test + func getOperation_appLinks_cert() throws { + #expect(WebEidUriUtil.getOperation(from: try makeURL("https://id.eesti.ee/cert#dGVzdA")) == .cert) + } + + @Test + func getOperation_appLinks_sign() throws { + #expect(WebEidUriUtil.getOperation(from: try makeURL("https://id.eesti.ee/sign#dGVzdA")) == .sign) + } + + @Test + func getOperation_appLinks_unknownOperation_returnsNil() throws { + #expect(WebEidUriUtil.getOperation(from: try makeURL("https://id.eesti.ee/unknown")) == .unknown) + } + + @Test + func getOperation_unrelatedUri_returnsNil() throws { + #expect(WebEidUriUtil.getOperation(from: try makeURL("https://example.com/auth")) == .unknown) + } + + @Test + func isWebEidUri_customScheme_auth() throws { + #expect(WebEidUriUtil.isWebEidUri(try makeURL("web-eid-mobile://auth"))) + } + + @Test + func isWebEidUri_customScheme_cert() throws { + #expect(WebEidUriUtil.isWebEidUri(try makeURL("web-eid-mobile://cert"))) + } + + @Test + func isWebEidUri_customScheme_sign() throws { + #expect(WebEidUriUtil.isWebEidUri(try makeURL("web-eid-mobile://sign"))) + } + + @Test + func isWebEidUri_customScheme_unknownOperation() throws { + #expect(!WebEidUriUtil.isWebEidUri(try makeURL("web-eid-mobile://unknown"))) + } + + @Test + func getOperation_customScheme_auth() throws { + #expect(WebEidUriUtil.getOperation(from: try makeURL("web-eid-mobile://auth#dGVzdA")) == .auth) + } + + @Test + func getOperation_customScheme_cert() throws { + #expect(WebEidUriUtil.getOperation(from: try makeURL("web-eid-mobile://cert#dGVzdA")) == .cert) + } + + @Test + func getOperation_customScheme_sign() throws { + #expect(WebEidUriUtil.getOperation(from: try makeURL("web-eid-mobile://sign#dGVzdA")) == .sign) + } + + @Test + func getOperation_unknownOperation_returnsNil() throws { + #expect(WebEidUriUtil.getOperation(from: try makeURL("web-eid-mobile://unknown")) == .unknown) + } + + // MARK: - displayOrigin + + @Test + func displayOrigin_leavesAPlainASCIIOriginUnchanged() { + #expect(WebEidUriUtil.displayOrigin("https://id.eesti.ee") == "https://id.eesti.ee") + #expect(WebEidUriUtil.displayOrigin("https://id.eesti.ee:8443") == "https://id.eesti.ee:8443") + } + + @Test + func displayOrigin_doesNotLetABidiOverrideReorderTheHost() { + let spoofed = "https://evil.com%E2%80%AEmoc.knab" + + let shown = WebEidUriUtil.displayOrigin(spoofed) + + #expect(shown == "https://evil.com%E2%80%AEmoc.knab") + #expect(!shown.unicodeScalars.contains { $0.properties.isBidiControl }) + } + + @Test + func displayOrigin_doesNotRenderAPunycodeHostAsItsUnicodeHomograph() { + let shown = WebEidUriUtil.displayOrigin("https://xn--80ak6aa92e.com") + + #expect(shown != "https://аррӏе.com") + #expect(shown.allSatisfy { $0.isASCII }) + } + + @Test + func displayOrigin_keepsAnEncodedSlashEncoded() { + #expect(WebEidUriUtil.displayOrigin("https://a%2Fb.com") == "https://a%2Fb.com") + } + +} diff --git a/RIADigiDocTests/ViewModel/Signing/NFC/NFCViewModelTests.swift b/RIADigiDocTests/ViewModel/Signing/NFC/NFCViewModelTests.swift index 146f3f3a..96f50af8 100644 --- a/RIADigiDocTests/ViewModel/Signing/NFC/NFCViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/Signing/NFC/NFCViewModelTests.swift @@ -18,7 +18,9 @@ */ import CommonsLib +import CryptoKit import CryptoSwift +import CryptoLibMocks import Foundation import nfclib import LibdigidocLibSwift @@ -40,6 +42,9 @@ final class NFCViewModelTests { private let mockOperationReadCertAndSign: OperationReadCertAndSignProtocolMock private let mockOperationReadCardData: OperationReadCardDataProtocolMock private let mockOperationDecrypt: OperationDecryptProtocolMock + private let mockOperationWebEidSign: OperationWebEidSignProtocolMock + private let mockOperationReadCert: OperationReadCertProtocolMock + private let mockOperationWebEidAuth: OperationWebEidAuthProtocolMock private let mockNFCSessionStrings: NFCSessionStrings! @@ -53,6 +58,9 @@ final class NFCViewModelTests { mockOperationReadCertAndSign = OperationReadCertAndSignProtocolMock() mockOperationReadCardData = OperationReadCardDataProtocolMock() mockOperationDecrypt = OperationDecryptProtocolMock() + mockOperationWebEidSign = OperationWebEidSignProtocolMock() + mockOperationReadCert = OperationReadCertProtocolMock() + mockOperationWebEidAuth = OperationWebEidAuthProtocolMock() let mockLanguageSettings = LanguageSettingsProtocolMock() var mockNFCStringsUtil: NFCSessionStringsUtil { @@ -60,13 +68,41 @@ final class NFCViewModelTests { mockLanguageSettings.localized(key, args) } } - mockNFCSessionStrings = mockNFCStringsUtil.makeDefault() + mockNFCSessionStrings = mockNFCStringsUtil.makeDefault(pinName: CodeType.pin2.name) + + let defaultSymmetricKey = SymmetricKey(size: .bits256) + mockEncryptedDataUtil.getSymmetricKeyHandler = { _ in defaultSymmetricKey } + mockEncryptedDataUtil.decryptSecretHandler = { data, _ in String(data: data, encoding: .utf8) } + mockKeychainStore.saveHandler = { _, _, _ in true } mockOperationReadCertAndSign.startOperationHandler = { _, _, _, _, _, _, _ in return SignedContainerProtocolMock() } + mockOperationWebEidAuth.startOperationHandler = + { _, _, _, _, _, _ in + return WebEidAuthReturnData( + authCert: Data(), + signingCert: Data(), + signatureArray: Data() + ) + } + + mockOperationReadCert.startReadingHandler = + { _, _ in + return "ouput" + } + + mockOperationWebEidSign.startOperationHandler = + { _, _, _, _, _, _, _ in + return WebEidSignReturnData( + signerCertB64: "", + signatureArray: Data(), + responseUri: "" + ) + } + viewModel = NFCViewModel( dataStore: mockDataStore, userAgentUtil: mockUserAgentUtil, @@ -75,7 +111,10 @@ final class NFCViewModelTests { keychainStore: mockKeychainStore, encryptedDataUtil: mockEncryptedDataUtil, operationReadCertAndSign: mockOperationReadCertAndSign, + operationWebEidAuth: mockOperationWebEidAuth, + operationWebEidSign: mockOperationWebEidSign, operationReadCardData: mockOperationReadCardData, + operationReadCert: mockOperationReadCert, operationDecrypt: mockOperationDecrypt ) } @@ -351,6 +390,393 @@ final class NFCViewModelTests { #expect(mockSharedMyEidSession.setCANArgValues.first == "123456") } + // MARK: - saveInputData + + @Test + func saveInputData_forAuthWithRememberMeTrue_savesWebEidRememberMeAndCANAndClearsTempCAN() async { + mockDataStore.setWebEidRememberMeHandler = { _ in } + mockKeychainStore.saveHandler = { key, info, withPasscodeSetOnly in + #expect(key == KeychainKey.nfcCANKey.rawValue) + #expect(String(data: info, encoding: .utf8) == "123456") + #expect(withPasscodeSetOnly == true) + return true + } + mockKeychainStore.removeKeyHandler = { key in + #expect(key == .tempCANKey) + } + + await viewModel.saveInputData( + canNumber: "123456", + rememberMe: true, + actionType: .auth, + isWebEidAuthenticating: true + ) + #expect(mockDataStore.setWebEidRememberMeCallCount == 1) + #expect(mockKeychainStore.saveCallCount >= 1) + #expect(mockKeychainStore.removeKeyCallCount >= 1) + } + + @Test + func saveInputData_forSigningWithRememberMeFalse_clearsCANAndSavesTempCAN() async { + mockDataStore.setNFCRememberMeHandler = { _ in } + + mockKeychainStore.saveHandler = { key, info, withPasscodeSetOnly in + #expect(key == KeychainKey.tempCANKey.rawValue) + #expect(String(data: info, encoding: .utf8) == "123456") + #expect(withPasscodeSetOnly == false) + return true + } + mockKeychainStore.removeKeyHandler = { _ in } + + await viewModel.saveInputData( + canNumber: "123456", + rememberMe: false, + actionType: .signing, + isWebEidAuthenticating: false + ) + + #expect(mockDataStore.setNFCRememberMeCallCount == 1) + #expect(mockKeychainStore.saveCallCount == 1) + #expect(mockKeychainStore.removeKeyArgValues.contains(.nfcCANKey)) + #expect(mockKeychainStore.removeKeyArgValues.contains(.signingCertKey)) + } + + @Test + func saveInputData_forAuthWithRememberMeFalse_clearsStoredSigningCertificate() async { + mockDataStore.setWebEidRememberMeHandler = { _ in } + mockKeychainStore.removeKeyHandler = { _ in } + + mockKeychainStore.retrieveKeyHandler = { key in + if key == .nfcCANKey { + return Data("123456".utf8) + } + return nil + } + + await viewModel.saveInputData( + canNumber: "123456", + rememberMe: false, + actionType: .auth, + isWebEidAuthenticating: true + ) + + #expect(mockKeychainStore.saveCallCount >= 1) + #expect(mockKeychainStore.removeKeyArgValues.contains(.signingCertKey)) + #expect(mockKeychainStore.saveKeyCallCount == 0) + } + + // MARK: - getInputData + + @Test + func getInputData_forCertificate_returnsStoredCanOnly() async { + mockDataStore.getWebEidRememberMeHandler = { true } + mockKeychainStore.retrieveKeyHandler = { key in + switch key { + case .nfcCANKey: + return Data("654321".utf8) + case .tempCANKey: + return Data("123456".utf8) + default: + return nil + } + } + + let result = await viewModel.getInputData(.certificate, false) + + #expect(result.canNumber == "654321") + #expect(result.rememberMe == true) + } + + @Test + func getInputData_forSigningWebEid_prefersTempCanWhenPresent() async { + mockDataStore.getWebEidRememberMeHandler = { false } + mockKeychainStore.retrieveKeyHandler = { key in + switch key { + case .nfcCANKey: + return Data("654321".utf8) + case .tempCANKey: + return Data("123456".utf8) + default: + return nil + } + } + + let result = await viewModel.getInputData(.signingWebEid, false) + + #expect(result.canNumber == "123456") + #expect(result.rememberMe == false) + } + + @Test + func getInputData_whenStoredCanMissingAndWebEidAuthenticating_usesTempCan() async { + mockDataStore.getNFCRememberMeHandler = { false } + mockKeychainStore.retrieveKeyHandler = { key in + switch key { + case .nfcCANKey: + return nil + case .tempCANKey: + return Data("123456".utf8) + default: + return nil + } + } + + let result = await viewModel.getInputData(.signing, true) + + #expect(result.canNumber == "123456") + } + + @Test + func getInputData_returnsEmptyCanWhenNothingStored() async { + mockDataStore.getNFCRememberMeHandler = { false } + mockKeychainStore.retrieveKeyHandler = { _ in nil } + + let result = await viewModel.getInputData(.signing, false) + + #expect(result.canNumber == "") + #expect(result.rememberMe == false) + } + + // MARK: - keychain CAN helpers + + @Test + func retrieveCAN_returnsStoredCAN() async { + mockKeychainStore.retrieveKeyHandler = { key in + #expect(key == .nfcCANKey) + return Data("123456".utf8) + } + + let result = await viewModel.retrieveCAN() + + #expect(result == "123456") + #expect(mockEncryptedDataUtil.decryptSecretCallCount == 0) + } + + @Test + func retrieveCAN_migratesLegacyEncryptedValue() async { + let legacyCiphertext = Data([0x01, 0x02, 0x03]) + + mockKeychainStore.retrieveKeyHandler = { _ in legacyCiphertext } + mockEncryptedDataUtil.decryptSecretHandler = { data, _ in + #expect(data == legacyCiphertext) + return "123456" + } + mockKeychainStore.saveHandler = { key, info, withPasscodeSetOnly in + #expect(key == KeychainKey.nfcCANKey.rawValue) + #expect(String(data: info, encoding: .utf8) == "123456") + #expect(withPasscodeSetOnly == true) + return true + } + + let result = await viewModel.retrieveCAN() + + #expect(result == "123456") + #expect(mockEncryptedDataUtil.decryptSecretCallCount == 1) + #expect(mockKeychainStore.saveCallCount == 1) + } + + @Test + func retrieveCAN_returnsNilWhenValueCannotBeMigrated() async { + mockKeychainStore.retrieveKeyHandler = { key in + #expect(key == .nfcCANKey) + return Data([0xFF, 0xFE]) + } + mockEncryptedDataUtil.getSymmetricKeyHandler = { _ in + SymmetricKey(size: .bits256) + } + mockEncryptedDataUtil.decryptSecretHandler = { _, _ in + nil + } + + let result = await viewModel.retrieveCAN() + + #expect(result == nil) + } + + @Test + func retrieveCAN_returnsNilWhenNothingStored() async { + mockKeychainStore.retrieveKeyHandler = { _ in nil } + + let result = await viewModel.retrieveCAN() + + #expect(result == nil) + #expect(mockEncryptedDataUtil.decryptSecretCallCount == 0) + } + + @Test + func saveCAN_storesCANWithPasscodeProtection() async { + mockKeychainStore.saveHandler = { key, info, withPasscodeSetOnly in + #expect(key == KeychainKey.nfcCANKey.rawValue) + #expect(String(data: info, encoding: .utf8) == "123456") + #expect(withPasscodeSetOnly == true) + return true + } + + await viewModel.saveCAN("123456") + + #expect(mockKeychainStore.saveCallCount == 1) + } + + @Test + func clearCAN_removesNfcCANKey() async { + mockKeychainStore.removeKeyHandler = { key in + #expect(key == .nfcCANKey) + } + + await viewModel.clearCAN() + + #expect(mockKeychainStore.removeKeyCallCount == 1) + } + + @Test + func retrieveTempCAN_returnsDecodedString() async { + mockKeychainStore.retrieveKeyHandler = { key in + #expect(key == .tempCANKey) + return Data("123456".utf8) + } + + let result = await viewModel.retrieveTempCAN() + + #expect(result == "123456") + } + + @Test + func clearTempCAN_removesTempCANKey() async { + mockKeychainStore.removeKeyHandler = { key in + #expect(key == .tempCANKey) + } + + await viewModel.clearTempCAN() + + #expect(mockKeychainStore.removeKeyCallCount == 1) + } + + @Test + func saveInputData_whenPasscodeProtectedWriteFails_storesNoCANAndRecordsNotRemembered() async { + mockDataStore.setWebEidRememberMeHandler = { _ in } + mockKeychainStore.removeKeyHandler = { _ in } + mockKeychainStore.removeHandler = { _ in } + mockKeychainStore.retrieveKeyHandler = { _ in nil } + + mockKeychainStore.saveHandler = { _, _, requiresPasscode in + return !requiresPasscode + } + + await viewModel.saveInputData( + canNumber: "123456", + rememberMe: true, + actionType: .auth, + isWebEidAuthenticating: false + ) + + #expect(mockDataStore.setWebEidRememberMeArgValues == [false]) + #expect(mockKeychainStore.removeKeyArgValues.contains(.nfcCANKey)) + #expect(mockKeychainStore.removeKeyArgValues.contains(.tempCANKey)) + #expect(!mockKeychainStore.saveArgValues.contains { $0.key == KeychainKey.tempCANKey.rawValue }) + } + + @Test + func auth_pinLockedRaisesActivationAlertForPIN1RatherThanPINBlocked() async { + mockDataStore.getSelectedLanguageHandler = { "en" } + mockUserAgentUtil.userAgentHandler = { _, _ in "TestUserAgent" } + + mockOperationWebEidAuth.startOperationHandler = { _, _, _, _, _, _ in + throw IdCardInternalError.pinLocked + } + + _ = await viewModel.auth( + canNumber: "123456", + pin1: "12345", + origin: "origin", + challenge: "challenge", + strings: mockNFCSessionStrings + ) + + #expect(viewModel.showNfcAlertMessage) + #expect(viewModel.nfcAlertMessageKey == "PIN1 locked") + #expect(viewModel.nfcAlertMessageUrl == "PIN1 locked URL") + #expect(viewModel.nfcErrorKey != "PIN blocked") + } + + // MARK: - signing certificate helpers + + @Test + func getSigningCertificate_returnsStoredCertificateForCurrentCAN() async throws { + let blob = try JSONEncoder().encode(["can": "123456", "certificate": "cert-data"]) + + mockKeychainStore.retrieveKeyHandler = { key in + switch key { + case .nfcCANKey: return Data("123456".utf8) + case .signingCertKey: return blob + default: return nil + } + } + + let result = await viewModel.getSigningCertificate() + + #expect(result == "cert-data") + } + + @Test + func getSigningCertificate_returnsEmptyStringWhenStoredCANDiffers() async throws { + let blob = try JSONEncoder().encode(["can": "999999", "certificate": "cert-data"]) + + mockKeychainStore.retrieveKeyHandler = { key in + switch key { + case .nfcCANKey: return Data("123456".utf8) + case .signingCertKey: return blob + default: return nil + } + } + + let result = await viewModel.getSigningCertificate() + + #expect(result == "") + } + + @Test + func getSigningCertificate_neverLooksUpAKeyContainingTheCAN() async { + mockKeychainStore.retrieveKeyHandler = { key in + key == .nfcCANKey ? Data("123456".utf8) : nil + } + + _ = await viewModel.getSigningCertificate() + + #expect(mockKeychainStore.retrieveCallCount == 0) + #expect(!mockKeychainStore.retrieveKeyArgValues.contains { $0.rawValue.contains("123456") }) + } + + @Test + func getSigningCertificate_returnsEmptyStringWhenNoCAN() async { + mockKeychainStore.retrieveHandler = { _ in nil } + + let result = await viewModel.getSigningCertificate() + + #expect(result == "") + } + + @Test + func setSigningCertificate_storesTheCANInsideTheValueNotTheKey() async { + mockKeychainStore.retrieveKeyHandler = { key in + key == .nfcCANKey ? Data("123456".utf8) : nil + } + + mockKeychainStore.saveHandler = { key, info, requiresPasscode in + #expect(key == KeychainKey.signingCertKey.rawValue) + #expect(!key.contains("123456")) + #expect(requiresPasscode) + + let cached = try? JSONDecoder().decode([String: String].self, from: info) + #expect(cached?["can"] == "123456") + #expect(cached?["certificate"] == "cert-data") + return true + } + + await viewModel.setSigningCertificate("cert-data") + + #expect(mockKeychainStore.saveCallCount == 1) + } + // MARK: - Sign Tests @Test @@ -512,51 +938,380 @@ final class NFCViewModelTests { } @Test - func sign_showsCourierAlertWhenCardNotActivated() async { - let mockContainer = SignedContainerProtocolMock() + func auth_success() async { + mockDataStore.getSelectedLanguageHandler = { + "et" + } - mockContainer.getRawContainerFileHandler = { - URL(fileURLWithPath: "/test/container.asice") + mockUserAgentUtil.userAgentHandler = { _, language in + #expect(language == "et") + return "TestUserAgent" } - mockDataStore.getSelectedLanguageHandler = { "et" } - mockUserAgentUtil.appInfoHandler = { _, _ in "TestUserAgent" } - mockOperationReadCertAndSign.startOperationHandler = + _ = await viewModel.auth( + canNumber: "123456", + pin1: "12345", + origin: "origin", + challenge: "challenge", + strings: mockNFCSessionStrings + ) + + #expect(mockDataStore.getSelectedLanguageCallCount == 1) + #expect(mockUserAgentUtil.userAgentCallCount == 1) + #expect(mockOperationWebEidAuth.startOperationCallCount == 1) + #expect(viewModel.nfcErrorKey == nil) + } + + @Test + func auth_setsNfcErrorKeyOnOperationFailure() async { + mockDataStore.getSelectedLanguageHandler = { + "et" + } + + mockUserAgentUtil.userAgentHandler = { _, language in + #expect(language == "et") + return "TestUserAgent" + } + + mockOperationWebEidAuth.startOperationHandler = + { _, _, _, _, _, _ in + throw NSError(domain: "TestError", code: 1, userInfo: nil) + } + + await #expect(throws: Never.self) { + _ = await viewModel.auth( + canNumber: "123456", + pin1: "12345", + origin: "origin", + challenge: "challenge", + strings: mockNFCSessionStrings + ) + + #expect(mockDataStore.getSelectedLanguageCallCount == 1) + #expect(mockUserAgentUtil.userAgentCallCount == 1) + #expect(viewModel.nfcErrorKey != nil) + } + } + + @Test + func auth_showsPinRetryCountWhenNfclibReportsRemainingRetries() async { + mockDataStore.getSelectedLanguageHandler = { + "et" + } + + mockUserAgentUtil.userAgentHandler = { _, _ in + "TestUserAgent" + } + + mockOperationWebEidAuth.startOperationHandler = + { _, _, _, _, _, _ in + throw nfclib.IdCardInternalError.remainingPinRetryCount(2) + } + + _ = await viewModel.auth( + canNumber: "123456", + pin1: "12345", + origin: "origin", + challenge: "challenge", + strings: mockNFCSessionStrings + ) + + #expect(viewModel.nfcErrorKey == "PIN verification error multiple") + #expect(viewModel.nfcErrorExtraArguments == [CodeType.pin1.name, "2"]) + } + + @Test + func auth_errorsChangeBetweenSignCalls() async { + viewModel.nfcErrorKey = "Previous error" + + mockDataStore.getSelectedLanguageHandler = { + "en" + } + + mockUserAgentUtil.userAgentHandler = { _, _ in + "TestUserAgent" + } + + mockOperationWebEidAuth.startOperationHandler = + { _, _, _, _, _, _ in + throw NSError(domain: "TestError", code: 1, userInfo: nil) + } + + await #expect(throws: Never.self) { + _ = await viewModel.auth( + canNumber: "123456", + pin1: "12345", + origin: "origin", + challenge: "challenge", + strings: mockNFCSessionStrings + ) + + #expect(viewModel.nfcErrorKey != "Previous error") + } + } + + @Test + func certificate_success() async { + _ = await viewModel.certificate( + canNumber: "123456", + strings: mockNFCSessionStrings + ) + + #expect(mockOperationReadCert.startReadingCallCount == 1) + #expect(viewModel.nfcErrorKey == nil) + } + + @Test + func certificate_setsNfcErrorKeyOnOperationFailure() async { + mockOperationReadCert.startReadingHandler = + { _, _ in + throw NSError(domain: "TestError", code: 1, userInfo: nil) + } + + await #expect(throws: Never.self) { + _ = await viewModel.certificate( + canNumber: "123456", + strings: mockNFCSessionStrings + ) + + #expect(viewModel.nfcErrorKey != nil) + } + } + + @Test + func certificate_showsPinRetryCountWhenNfclibReportsRemainingRetries() async { + mockOperationReadCert.startReadingHandler = + { _, _ in + throw nfclib.IdCardInternalError.remainingPinRetryCount(1) + } + + _ = await viewModel.certificate( + canNumber: "123456", + strings: mockNFCSessionStrings + ) + + #expect(viewModel.nfcErrorKey == "PIN verification error one") + #expect(viewModel.nfcErrorExtraArguments == [CodeType.pin2.name]) + } + + @Test + func certificate_errorsChangeBetweenSignCalls() async { + viewModel.nfcErrorKey = "Previous error" + + mockOperationReadCert.startReadingHandler = + { _, _ in + throw NSError(domain: "TestError", code: 1, userInfo: nil) + } + + await #expect(throws: Never.self) { + _ = await viewModel.certificate( + canNumber: "123456", + strings: mockNFCSessionStrings + ) + + #expect(viewModel.nfcErrorKey != "Previous error") + } + } + + @Test + func signWebEid_success() async { + mockDataStore.getSelectedLanguageHandler = { + "et" + } + + mockUserAgentUtil.userAgentHandler = { _, language in + #expect(language == "et") + return "TestUserAgent" + } + + _ = await viewModel.signWebEid( + canNumber: "123456", + pin2: "12345", + responseUri: "url", + hash: "hash", + expectedSigningCertBase64: "cert", + strings: mockNFCSessionStrings + ) + + #expect(mockDataStore.getSelectedLanguageCallCount == 1) + #expect(mockUserAgentUtil.userAgentCallCount == 1) + #expect(mockOperationWebEidSign.startOperationCallCount == 1) + #expect(viewModel.nfcErrorKey == nil) + } + + @Test + func signWebEid_setsNfcErrorKeyOnOperationFailure() async { + mockDataStore.getSelectedLanguageHandler = { + "et" + } + + mockUserAgentUtil.userAgentHandler = { _, language in + #expect(language == "et") + return "TestUserAgent" + } + + mockOperationWebEidSign.startOperationHandler = { _, _, _, _, _, _, _ in - throw IdCardInternalError.notActivated + throw NSError(domain: "TestError", code: 1, userInfo: nil) } - let result = await viewModel.sign( + await #expect(throws: Never.self) { + _ = await viewModel.signWebEid( + canNumber: "123456", + pin2: "12345", + responseUri: "url", + hash: "hash", + expectedSigningCertBase64: "cert", + strings: mockNFCSessionStrings + ) + + #expect(mockDataStore.getSelectedLanguageCallCount == 1) + #expect(mockUserAgentUtil.userAgentCallCount == 1) + #expect(viewModel.nfcErrorKey != nil) + } + } + + @Test + func signWebEid_showsPinBlockedWhenNfclibReportsNoRetriesLeft() async { + mockDataStore.getSelectedLanguageHandler = { + "et" + } + + mockUserAgentUtil.userAgentHandler = { _, _ in + "TestUserAgent" + } + + mockOperationWebEidSign.startOperationHandler = + { _, _, _, _, _, _, _ in + throw nfclib.IdCardInternalError.pinVerificationFailed + } + + _ = await viewModel.signWebEid( canNumber: "123456", pin2: "12345", - roleData: RoleData(roles: [], city: "", state: "", country: "", zipCode: ""), - signedContainer: mockContainer, + responseUri: "url", + hash: "hash", + expectedSigningCertBase64: "cert", + strings: mockNFCSessionStrings + ) + + #expect(viewModel.nfcErrorKey == "PIN blocked") + #expect(viewModel.nfcErrorExtraArguments == [CodeType.pin2.name]) + } + + @Test + func signWebEid_errorsChangeBetweenSignCalls() async { + viewModel.nfcErrorKey = "Previous error" + + mockDataStore.getSelectedLanguageHandler = { + "en" + } + + mockUserAgentUtil.userAgentHandler = { _, _ in + "TestUserAgent" + } + + mockOperationWebEidSign.startOperationHandler = + { _, _, _, _, _, _, _ in + throw NSError(domain: "TestError", code: 1, userInfo: nil) + } + + await #expect(throws: Never.self) { + _ = await viewModel.signWebEid( + canNumber: "123456", + pin2: "12345", + responseUri: "url", + hash: "hash", + expectedSigningCertBase64: "cert", + strings: mockNFCSessionStrings + ) + + #expect(viewModel.nfcErrorKey != "Previous error") + } + } + + // MARK: - decrypt + + @Test + func decrypt_success() async { + let mockContainer = CryptoContainerProtocolMock() + let expectedResult = CryptoContainerProtocolMock() + + mockContainer.getRawContainerFileHandler = { + URL(fileURLWithPath: "/tmp/test.cdoc") + } + mockContainer.getRecipientsHandler = { + [] + } + mockKeychainStore.removeKeyHandler = { key in + #expect(key == .tempCANKey) + } + mockOperationDecrypt.processDecryptHandler = { _, _, _, _, _ in + expectedResult + } + + let result = await viewModel.decrypt( + CAN: "123456", + pin1: "1234", + cryptoContainer: mockContainer, + strings: mockNFCSessionStrings + ) + + #expect(result != nil) + #expect(mockOperationDecrypt.processDecryptCallCount == 1) + #expect(viewModel.nfcErrorKey == nil) + } + + @Test + func decrypt_setsGeneralErrorOnUnexpectedFailure() async { + let mockContainer = CryptoContainerProtocolMock() + + mockContainer.getRawContainerFileHandler = { + URL(fileURLWithPath: "/tmp/test.cdoc") + } + mockContainer.getRecipientsHandler = { + [] + } + mockKeychainStore.removeHandler = { _ in } + mockOperationDecrypt.processDecryptHandler = { _, _, _, _, _ in + throw NSError(domain: "Test", code: 1) + } + + let result = await viewModel.decrypt( + CAN: "123456", + pin1: "1234", + cryptoContainer: mockContainer, strings: mockNFCSessionStrings ) #expect(result == nil) - #expect(viewModel.showNfcAlertMessage) - #expect(viewModel.nfcAlertMessageKey == "ID card courier must activate to sign") - #expect(viewModel.nfcAlertMessageUrl == "ID card courier activate URL") + #expect(viewModel.nfcErrorKey == "NFC session error") } @Test - func decrypt_showsCourierAlertWhenCardNotActivated() async { + func decrypt_handlesDecryptCancelledWithoutErrorKey() async { + let mockContainer = CryptoContainerProtocolMock() + + mockContainer.getRawContainerFileHandler = { + URL(fileURLWithPath: "/tmp/test.cdoc") + } + mockContainer.getRecipientsHandler = { + [] + } + mockKeychainStore.removeHandler = { _ in } mockOperationDecrypt.processDecryptHandler = { _, _, _, _, _ in - throw IdCardInternalError.notActivated + throw DecryptError.cancelled } let result = await viewModel.decrypt( CAN: "123456", pin1: "1234", - cryptoContainer: nil, + cryptoContainer: mockContainer, strings: mockNFCSessionStrings ) #expect(result == nil) - #expect(viewModel.showNfcAlertMessage) - #expect(viewModel.nfcAlertMessageKey == "ID card courier must activate to decrypt") - #expect(viewModel.nfcAlertMessageUrl == "ID card courier activate URL") + #expect(viewModel.nfcErrorKey == nil) } // MARK: - readCardData tests @@ -624,4 +1379,66 @@ final class NFCViewModelTests { #expect(mockCertificateUtil.getNotValidDateCallCount == 2) } + @Test + func readCardData_setsGeneralErrorOnUnexpectedFailure() async { + mockOperationReadCardData.startReadingHandler = { _, _ in + throw NSError(domain: "Test", code: 1) + } + + let result = await viewModel.readCardData( + CAN: "123456", + strings: mockNFCSessionStrings + ) + + #expect(result == nil) + #expect(viewModel.nfcErrorKey == "General error") + } + + @Test + func sign_showsCourierAlertWhenCardNotActivated() async { + let mockContainer = SignedContainerProtocolMock() + + mockContainer.getRawContainerFileHandler = { + URL(fileURLWithPath: "/test/container.asice") + } + mockDataStore.getSelectedLanguageHandler = { "et" } + mockUserAgentUtil.appInfoHandler = { _, _ in "TestUserAgent" } + + mockOperationReadCertAndSign.startOperationHandler = + { _, _, _, _, _, _, _ in + throw IdCardInternalError.notActivated + } + + let result = await viewModel.sign( + canNumber: "123456", + pin2: "12345", + roleData: RoleData(roles: [], city: "", state: "", country: "", zipCode: ""), + signedContainer: mockContainer, + strings: mockNFCSessionStrings + ) + + #expect(result == nil) + #expect(viewModel.showNfcAlertMessage) + #expect(viewModel.nfcAlertMessageKey == "ID card courier must activate to sign") + #expect(viewModel.nfcAlertMessageUrl == "ID card courier activate URL") + } + + @Test + func decrypt_showsCourierAlertWhenCardNotActivated() async { + mockOperationDecrypt.processDecryptHandler = { _, _, _, _, _ in + throw IdCardInternalError.notActivated + } + + let result = await viewModel.decrypt( + CAN: "123456", + pin1: "1234", + cryptoContainer: nil, + strings: mockNFCSessionStrings + ) + + #expect(result == nil) + #expect(viewModel.showNfcAlertMessage) + #expect(viewModel.nfcAlertMessageKey == "ID card courier must activate to decrypt") + #expect(viewModel.nfcAlertMessageUrl == "ID card courier activate URL") + } } diff --git a/RIADigiDocTests/ViewModel/WebEid/WebEidViewModelTests.swift b/RIADigiDocTests/ViewModel/WebEid/WebEidViewModelTests.swift new file mode 100644 index 00000000..62014ed7 --- /dev/null +++ b/RIADigiDocTests/ViewModel/WebEid/WebEidViewModelTests.swift @@ -0,0 +1,695 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Security +import Testing +import UtilsLib +import UtilsLibMocks +import WebEidLib +import WebEidLibMocks + +@MainActor +final class WebEidViewModelTests { + + private let viewModel: WebEidViewModel + + private let mockDataStore: DataStoreProtocolMock + private let mockKeychainStore: KeychainStoreProtocolMock + private let mockAuthService: WebEidAuthServiceProtocolMock + private let mockSignService: WebEidSignServiceProtocolMock + + init() async throws { + mockDataStore = DataStoreProtocolMock() + mockKeychainStore = KeychainStoreProtocolMock() + mockAuthService = WebEidAuthServiceProtocolMock() + mockSignService = WebEidSignServiceProtocolMock() + + mockAuthService.buildAuthTokenHandler = { _, _, _ in + try JSONSerialization.data(withJSONObject: [ + "token": "test-token", + "expires": 123456 + ]) + } + + mockSignService.buildCertificatePayloadHandler = { _ in + try JSONSerialization.data(withJSONObject: [ + "certificate": "base64cert" + ]) + } + + mockSignService.buildSignPayloadHandler = { _, _, _ in + try JSONSerialization.data(withJSONObject: [ + "signature": "base64signature", + "algorithm": "SHA-256" + ]) + } + + viewModel = WebEidViewModel( + dataStore: mockDataStore, + keychainStore: mockKeychainStore, + authService: mockAuthService, + signService: mockSignService + ) + } + + // MARK: - Helpers + + private func makeTestCertificate() throws -> SecCertificate { + // swiftlint:disable line_length + let base64 = "MIID7DCCA02gAwIBAgIQK33iqGajpAnSrLD7w+X3TjAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTI1MDQyMjEwMTg0OVoXDTMwMDQyMTIwNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATYWYk4C8W5+RAMeuvIQVa0sVdobkxXKASvA4lUh5K/whRAT5f3p8n2rw8O3nsCt/1LFyKXVVrZdtWZ1Vh894TA2QHEm6xaXnJs4ZmYo4blrm/nXE1PcEZan9023+73sE+jggGrMIIBpzAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFMCEmSnETp87AjT2meEKVgAIKT57MHMGCCsGAQUFBwEBBGcwZTA1BggrBgEFBQcwAoYpaHR0cDovL2Muc2suZWUvVGVzdF9vZl9FU1RFSUQyMDE4LmRlci5jcnQwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MEgGA1UdIARBMD8wMgYLKwYBBAGDkSEBAQEwIzAhBggrBgEFBQcCARYVaHR0cHM6Ly93d3cuc2suZWUvQ1BTMAkGBwQAi+xAAQIwgYoGCCsGAQUFBwEDBH4wfDAIBgYEAI5GAQEwCAYGBACORgEEMBMGBgQAjkYBBjAJBgcEAI5GAQYBMFEGBgQAjkYBBTBHMEUWP2h0dHBzOi8vc2suZWUvZW4vcmVwb3NpdG9yeS9jb25kaXRpb25zLWZvci11c2Utb2YtY2VydGlmaWNhdGVzLxMCZW4wHQYDVR0OBBYEFFh+R2KDfE2Tdj///kXTCqcz6rRuMA4GA1UdDwEB/wQEAwIGQDAKBggqhkjOPQQDBAOBjAAwgYgCQgD7B3WI1xpXX94+9e3TdaIcUNCj5JkCX15pj1mjRqv/Vx9Hlg3tbgwW2yOhqnTF04+e9rVHCtA8YRINp5BfDFqj/wJCAVuUlCu7GNVSFeU7A6lEORkB6obIALZusUFxT4bsaFWTpKllmvlX6lZm3QEbHgeiD8k7VMPdcw5V51p+B+2WUWBh" + // swiftlint:enable line_length + guard + let data = Data(base64Encoded: base64), + let cert = SecCertificateCreateWithData(nil, data as CFData) + else { + throw NSError(domain: "WebEidViewModelTests", code: 1) + } + + return cert + } + + private func makeAuthRequest(getSigningCertificate: Bool = false) -> WebEidAuthRequest { + WebEidAuthRequest( + challenge: "challenge", + loginUri: "https://example.com/auth-callback", + getSigningCertificate: getSigningCertificate, + origin: "https://example.com" + ) + } + + private func makeCertificateRequest(responseUri: String = "https://example.com/certificate-callback") + -> WebEidCertificateRequest { + WebEidCertificateRequest( + responseUri: responseUri, + origin: "https://example.com" + ) + } + + private func makeSignRequest( + responseUri: String = "https://example.com/sign-callback", + hash: String? = "hash", + hashFunction: String? = "SHA-256" + ) throws -> WebEidSignRequest { + try WebEidSignRequest( + responseUri: responseUri, + origin: "https://example.com", + signingCertificate: makeTestCertificate(), + hash: hash, + hashFunction: hashFunction, + personalData: nil + ) + } + + // MARK: - handleUnknown + + @Test + func handleUnknown_setsInvalidRequestError() throws { + let url = try #require(URL(string: "web-eid://unknown")) + + viewModel.handleUnknown(url: url) + + #expect(viewModel.errorKey == "Invalid Web eID request") + #expect(viewModel.errorExtraArguments.isEmpty) + } + + // MARK: - handleCertificate + + @Test + func handleCertificate_setsErrorOnInvalidURL() throws { + let url = try #require(URL(string: "https://example.com/not-a-valid-certificate-request")) + + viewModel.handleCertificate(url: url) + + #expect(viewModel.certRequest == nil) + #expect(viewModel.errorKey == "Invalid Web eID request") + #expect(viewModel.errorExtraArguments.isEmpty) + } + + // MARK: - handleAuth + + @Test + func handleAuth_setsLocalErrorAndNoResponseWhenFragmentMissing() throws { + let url = try #require(URL(string: "https://example.com/not-a-valid-auth-request")) + + viewModel.handleAuth(url: url) + + #expect(viewModel.authRequest == nil) + #expect(viewModel.errorKey == "Invalid authentication request") + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleAuth_clearsPreviousCertificateRequest() throws { + let certURL = try makeWebEidURL(payload: ["responseUri": "https://example.com/cert"]) + let authURL = try makeWebEidURL(payload: [ + "challenge": String(repeating: "A", count: 44), + "loginUri": "https://example.com/login" + ]) + + viewModel.handleCertificate(url: certURL) + #expect(viewModel.certRequest != nil) + + viewModel.handleAuth(url: authURL) + + #expect(viewModel.certRequest == nil) + #expect(viewModel.authRequest != nil) + } + + // MARK: - handleSign + + @Test + func handleSign_setsLocalErrorAndNoResponseWhenFragmentMissing() throws { + let url = try #require(URL(string: "https://example.com/not-a-valid-sign-request")) + + viewModel.handleSign(url: url) + + #expect(viewModel.signRequest == nil) + #expect(viewModel.errorKey == "Invalid Web eID request") + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleSign_preservesPreviousRequestWhenTheNewLinkIsMalformed() throws { + let certURL = try makeWebEidURL(payload: ["responseUri": "https://example.com/cert"]) + let invalidURL = try #require(URL(string: "https://example.com/not-a-valid-sign-request")) + + viewModel.handleCertificate(url: certURL) + #expect(viewModel.certRequest != nil) + + viewModel.handleSign(url: invalidURL) + + #expect(viewModel.certRequest != nil) + #expect(viewModel.signRequest == nil) + #expect(viewModel.errorKey == "Invalid Web eID request") + } + + private func makeWebEidURL(payload: [String: Any]) throws -> URL { + let jsonData = try JSONSerialization.data(withJSONObject: payload, options: []) + let fragment = jsonData.base64EncodedString() + return try #require(URL(string: "web-eid://request#\(fragment)")) + } + + // MARK: - error signalling and the one-response latch + + @Test + func handleUnknown_bumpsErrorEventIdEachTimeSoARepeatedFailureStillNotifies() throws { + let url = try #require(URL(string: "web-eid://unknown")) + + viewModel.handleUnknown(url: url) + let first = viewModel.errorEventId + + viewModel.handleUnknown(url: url) + + #expect(viewModel.errorKey == "Invalid Web eID request") + #expect(viewModel.errorEventId == first + 1) + } + + @Test + func onlyTheFirstResponseForOneRequestReachesTheRelyingParty() async throws { + viewModel.certRequest = makeCertificateRequest() + + await viewModel.handleWebEidCertificateResult(signingCert: Data("cert".utf8)) + let firstResponse = try #require(viewModel.relyingPartyResponseEvents) + + viewModel.relyingPartyResponseEvents = nil + await viewModel.handleUserCancelled() + + #expect(viewModel.relyingPartyResponseEvents == nil) + #expect(firstResponse.fragment?.isEmpty == false) + } + + @Test + func aNewIncomingLinkReArmsTheResponseLatch() async throws { + viewModel.certRequest = makeCertificateRequest() + await viewModel.handleWebEidCertificateResult(signingCert: Data("cert".utf8)) + #expect(viewModel.relyingPartyResponseEvents != nil) + viewModel.relyingPartyResponseEvents = nil + + let certURL = try makeWebEidURL(payload: ["responseUri": "https://example.com/cert"]) + viewModel.handleCertificate(url: certURL) + await viewModel.handleWebEidCertificateResult(signingCert: Data("cert".utf8)) + + #expect(viewModel.relyingPartyResponseEvents != nil) + } + + // MARK: - handleWebEidAuthResult + + @Test + func handleWebEidAuthResult_returnsEarlyWhenAuthRequestMissing() async { + viewModel.authRequest = nil + + await viewModel.handleWebEidAuthResult( + authCert: Data("auth".utf8), + signingCert: Data("sign".utf8), + signature: Data("sig".utf8) + ) + + #expect(mockAuthService.buildAuthTokenCallCount == 0) + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleWebEidAuthResult_success_withoutSigningCertificate() async throws { + viewModel.authRequest = makeAuthRequest(getSigningCertificate: false) + + mockAuthService.buildAuthTokenHandler = { authCert, signingCert, signature in + #expect(authCert == Data("auth".utf8)) + #expect(signingCert == nil) + #expect(signature == Data("sig".utf8)) + + return try JSONSerialization.data(withJSONObject: [ + "token": "auth-token" + ]) + } + + await viewModel.handleWebEidAuthResult( + authCert: Data("auth".utf8), + signingCert: Data("sign".utf8), + signature: Data("sig".utf8) + ) + + #expect(mockAuthService.buildAuthTokenCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents != nil) + } + + @Test + func handleWebEidAuthResult_success_withSigningCertificate() async throws { + viewModel.authRequest = makeAuthRequest(getSigningCertificate: true) + + mockAuthService.buildAuthTokenHandler = { authCert, signingCert, signature in + #expect(authCert == Data("auth".utf8)) + #expect(signingCert == Data("sign".utf8)) + #expect(signature == Data("sig".utf8)) + + return try JSONSerialization.data(withJSONObject: [ + "token": "auth-token" + ]) + } + + await viewModel.handleWebEidAuthResult( + authCert: Data("auth".utf8), + signingCert: Data("sign".utf8), + signature: Data("sig".utf8) + ) + + #expect(mockAuthService.buildAuthTokenCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents != nil) + } + + @Test + func handleWebEidAuthResult_buildsErrorResponseWhenAuthServiceThrows() async { + viewModel.authRequest = makeAuthRequest(getSigningCertificate: true) + + mockAuthService.buildAuthTokenHandler = { _, _, _ in + throw NSError(domain: "TestError", code: 1) + } + + await viewModel.handleWebEidAuthResult( + authCert: Data("auth".utf8), + signingCert: Data("sign".utf8), + signature: Data("sig".utf8) + ) + + #expect(mockAuthService.buildAuthTokenCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents != nil) + } + + @Test + func handleWebEidAuthResult_buildsErrorResponseWhenReturnedJsonIsInvalid() async { + viewModel.authRequest = makeAuthRequest(getSigningCertificate: true) + + mockAuthService.buildAuthTokenHandler = { _, _, _ in + Data("not-json".utf8) + } + + await viewModel.handleWebEidAuthResult( + authCert: Data("auth".utf8), + signingCert: Data("sign".utf8), + signature: Data("sig".utf8) + ) + + #expect(mockAuthService.buildAuthTokenCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents != nil) + } + + // MARK: - handleWebEidCertificateResult + + @Test + func handleWebEidCertificateResult_returnsEarlyWhenRequestMissing() async { + viewModel.certRequest = nil + + await viewModel.handleWebEidCertificateResult( + signingCert: Data("cert".utf8) + ) + + #expect(mockSignService.buildCertificatePayloadCallCount == 0) + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleWebEidCertificateResult_returnsEarlyWhenResponseUriBlank() async { + viewModel.certRequest = makeCertificateRequest(responseUri: " ") + + await viewModel.handleWebEidCertificateResult( + signingCert: Data("cert".utf8) + ) + + #expect(mockSignService.buildCertificatePayloadCallCount == 0) + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleWebEidCertificateResult_success() async throws { + viewModel.certRequest = makeCertificateRequest() + + mockSignService.buildCertificatePayloadHandler = { signingCert in + #expect(signingCert == Data("cert".utf8)) + return try JSONSerialization.data(withJSONObject: [ + "certificate": "base64cert" + ]) + } + + await viewModel.handleWebEidCertificateResult( + signingCert: Data("cert".utf8) + ) + + #expect(mockSignService.buildCertificatePayloadCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents != nil) + } + + @Test + func handleWebEidCertificateResult_returnsEarlyWhenPayloadJsonIsNotDictionary() async throws { + viewModel.certRequest = makeCertificateRequest() + + mockSignService.buildCertificatePayloadHandler = { _ in + try JSONSerialization.data(withJSONObject: ["one", "two"]) + } + + await viewModel.handleWebEidCertificateResult( + signingCert: Data("cert".utf8) + ) + + #expect(mockSignService.buildCertificatePayloadCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleWebEidCertificateResult_buildsErrorResponseWhenServiceThrows() async { + viewModel.certRequest = makeCertificateRequest() + + mockSignService.buildCertificatePayloadHandler = { _ in + throw NSError(domain: "TestError", code: 1) + } + + await viewModel.handleWebEidCertificateResult( + signingCert: Data("cert".utf8) + ) + + #expect(mockSignService.buildCertificatePayloadCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents != nil) + } + + // MARK: - handleWebEidSignResult + + @Test + func handleWebEidSignResult_returnsEarlyWhenSignRequestMissing() async { + viewModel.signRequest = nil + + await viewModel.handleWebEidSignResult( + signingCert: "cert", + signature: Data("sig".utf8), + responseUri: "https://example.com/sign-callback" + ) + + #expect(mockSignService.buildSignPayloadCallCount == 0) + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleWebEidSignResult_returnsEarlyWhenHashFunctionMissing() async throws { + viewModel.signRequest = try makeSignRequest(hashFunction: nil) + + await viewModel.handleWebEidSignResult( + signingCert: "cert", + signature: Data("sig".utf8), + responseUri: "https://example.com/sign-callback" + ) + + #expect(mockSignService.buildSignPayloadCallCount == 0) + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleWebEidSignResult_returnsEarlyWhenHashFunctionBlank() async throws { + viewModel.signRequest = try makeSignRequest(hashFunction: " ") + + await viewModel.handleWebEidSignResult( + signingCert: "cert", + signature: Data("sig".utf8), + responseUri: "https://example.com/sign-callback" + ) + + #expect(mockSignService.buildSignPayloadCallCount == 0) + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleWebEidSignResult_success() async throws { + viewModel.signRequest = try makeSignRequest(hashFunction: "SHA-256") + + mockSignService.buildSignPayloadHandler = { signingCert, signature, hashFunction in + #expect(signingCert == "cert") + #expect(signature == Data("sig".utf8)) + #expect(hashFunction == "SHA-256") + + return try JSONSerialization.data(withJSONObject: [ + "signature": "base64signature", + "algorithm": "SHA-256" + ]) + } + + await viewModel.handleWebEidSignResult( + signingCert: "cert", + signature: Data("sig".utf8), + responseUri: "https://example.com/sign-callback" + ) + + #expect(mockSignService.buildSignPayloadCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents != nil) + } + + @Test + func handleWebEidSignResult_returnsEarlyWhenPayloadJsonIsNotDictionary() async throws { + viewModel.signRequest = try makeSignRequest(hashFunction: "SHA-256") + + mockSignService.buildSignPayloadHandler = { _, _, _ in + try JSONSerialization.data(withJSONObject: ["one", "two"]) + } + + await viewModel.handleWebEidSignResult( + signingCert: "cert", + signature: Data("sig".utf8), + responseUri: "https://example.com/sign-callback" + ) + + #expect(mockSignService.buildSignPayloadCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleWebEidSignResult_buildsErrorResponseWhenServiceThrows() async throws { + viewModel.signRequest = try makeSignRequest(hashFunction: "SHA-256") + + mockSignService.buildSignPayloadHandler = { _, _, _ in + throw NSError(domain: "TestError", code: 1) + } + + await viewModel.handleWebEidSignResult( + signingCert: "cert", + signature: Data("sig".utf8), + responseUri: "https://example.com/sign-callback" + ) + + #expect(mockSignService.buildSignPayloadCallCount == 1) + #expect(viewModel.relyingPartyResponseEvents != nil) + } + + // MARK: - handleUserCancelled + + @Test + func handleUserCancelled_usesAuthRequestLoginUriFirst() async throws { + viewModel.authRequest = makeAuthRequest() + viewModel.certRequest = makeCertificateRequest(responseUri: "https://example.com/cert-callback") + viewModel.signRequest = try makeSignRequest(responseUri: "https://example.com/sign-callback") + + await viewModel.handleUserCancelled() + + #expect(viewModel.relyingPartyResponseEvents != nil) + #expect(viewModel.relyingPartyResponseEvents?.absoluteString.contains("auth-callback") == true) + } + + @Test + func handleUserCancelled_usesCertificateUriWhenAuthRequestMissing() async throws { + viewModel.authRequest = nil + viewModel.certRequest = makeCertificateRequest(responseUri: "https://example.com/cert-callback") + viewModel.signRequest = try makeSignRequest(responseUri: "https://example.com/sign-callback") + + await viewModel.handleUserCancelled() + + #expect(viewModel.relyingPartyResponseEvents != nil) + #expect(viewModel.relyingPartyResponseEvents?.absoluteString.contains("cert-callback") == true) + } + + @Test + func handleUserCancelled_usesSignUriWhenOthersMissing() async throws { + viewModel.authRequest = nil + viewModel.certRequest = nil + viewModel.signRequest = try makeSignRequest(responseUri: "https://example.com/sign-callback") + + await viewModel.handleUserCancelled() + + #expect(viewModel.relyingPartyResponseEvents != nil) + #expect(viewModel.relyingPartyResponseEvents?.absoluteString.contains("sign-callback") == true) + } + + @Test + func handleUserCancelled_returnsEarlyWhenNoResponseUriAvailable() async { + viewModel.authRequest = nil + viewModel.certRequest = nil + viewModel.signRequest = nil + + await viewModel.handleUserCancelled() + + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + @Test + func handleUserCancelled_returnsEarlyWhenResponseUriBlank() async { + viewModel.authRequest = nil + viewModel.certRequest = makeCertificateRequest(responseUri: " ") + viewModel.signRequest = nil + + await viewModel.handleUserCancelled() + + #expect(viewModel.relyingPartyResponseEvents == nil) + } + + // MARK: - resetErrors + + @Test + func resetErrors_clearsAllErrorAndAlertState() { + viewModel.showAlertMessage = true + viewModel.alertMessageKey = "Alert" + viewModel.alertMessageExtraArguments = ["arg1"] + viewModel.alertMessageUrl = "https://example.com" + + viewModel.errorKey = "Error" + viewModel.errorExtraArguments = ["arg2"] + + viewModel.resetErrors() + + #expect(viewModel.showAlertMessage == false) + #expect(viewModel.alertMessageKey == nil) + #expect(viewModel.alertMessageExtraArguments.isEmpty) + #expect(viewModel.alertMessageUrl == nil) + #expect(viewModel.errorKey == nil) + #expect(viewModel.errorExtraArguments.isEmpty) + } + + // MARK: - WebEid session active + + @Test + func isWebEidSessionActive_returnsFalseWhenValueMissing() async { + mockKeychainStore.retrieveKeyHandler = { key in + #expect(key == .webEidSessionActive) + return nil + } + + let result = await viewModel.isWebEidSessionActive() + + #expect(result == false) + #expect(mockKeychainStore.retrieveKeyCallCount == 1) + } + + @Test + func isWebEidSessionActive_returnsTrueWhenStoredByteIsOne() async { + mockKeychainStore.retrieveKeyHandler = { key in + #expect(key == .webEidSessionActive) + return Data([1]) + } + + let result = await viewModel.isWebEidSessionActive() + + #expect(result == true) + #expect(mockKeychainStore.retrieveKeyCallCount == 1) + } + + @Test + func isWebEidSessionActive_returnsFalseWhenStoredByteIsZero() async { + mockKeychainStore.retrieveKeyHandler = { key in + #expect(key == .webEidSessionActive) + return Data([0]) + } + + let result = await viewModel.isWebEidSessionActive() + + #expect(result == false) + #expect(mockKeychainStore.retrieveKeyCallCount == 1) + } + + @Test + func isWebEidSessionActive_returnsFalseWhenStoredDataIsEmpty() async { + mockKeychainStore.retrieveKeyHandler = { key in + #expect(key == .webEidSessionActive) + return Data() + } + + let result = await viewModel.isWebEidSessionActive() + + #expect(result == false) + #expect(mockKeychainStore.retrieveKeyCallCount == 1) + } + + @Test + func setWebEidSessionActive_savesTrueAsOneByte() async { + mockKeychainStore.saveKeyInfoHandler = { key, info in + #expect(key == .webEidSessionActive) + #expect(info == Data([1])) + return true + } + + await viewModel.setWebEidSessionActive(true) + + #expect(mockKeychainStore.saveKeyInfoCallCount == 1) + } + + @Test + func setWebEidSessionActive_savesFalseAsZeroByte() async { + mockKeychainStore.saveKeyInfoHandler = { key, info in + #expect(key == .webEidSessionActive) + #expect(info == Data([0])) + return true + } + + await viewModel.setWebEidSessionActive(false) + + #expect(mockKeychainStore.saveKeyInfoCallCount == 1) + } +} diff --git a/codemagic.yaml b/codemagic.yaml index e63f2aaa..0393112f 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -81,7 +81,7 @@ workflows: mkdir -p $TSL_FILES_DIRECTORY # SPM packages must exist and need at least 1 file. Mock files are generated in 'Run tests' step - for module in CommonsLib ConfigLib CryptoLib IdCardLib LibdigidocLib MobileIdLib SmartIdLib UtilsLib; do + for module in CommonsLib ConfigLib WebEidLib CryptoLib IdCardLib LibdigidocLib MobileIdLib SmartIdLib UtilsLib; do mock_dir="Modules/${module}/Tests/Mocks/Generated" mkdir -p "$mock_dir" echo "// Placeholder for generated mocks" > "${mock_dir}/__placeholder.swift" @@ -239,7 +239,7 @@ workflows: mkdir -p $TSL_FILES_DIRECTORY # SPM packages must exist and need at least 1 file. Mock files are generated in 'Run tests' step - for module in CommonsLib ConfigLib CryptoLib IdCardLib LibdigidocLib MobileIdLib SmartIdLib UtilsLib; do + for module in CommonsLib ConfigLib WebEidLib CryptoLib IdCardLib LibdigidocLib MobileIdLib SmartIdLib UtilsLib; do mock_dir="Modules/${module}/Tests/Mocks/Generated" mkdir -p "$mock_dir" echo "// Placeholder for generated mocks" > "${mock_dir}/__placeholder.swift" @@ -291,7 +291,9 @@ workflows: --clean \ --project "$CM_BUILD_DIR/RIADigiDoc.xcodeproj" \ --scheme "$XCODE_SCHEME" \ - --archive-xcargs="-skipPackagePluginValidation" + --archive-xcargs="-skipPackagePluginValidation" \ + --log-stream stdout \ + -v - name: Rename and move IPA script: | mv -v build/ios/ipa/RIADigiDoc.ipa build/ios/ipa/"RIA_DigiDoc_release_$APP_VERSION.$LATEST_BUILD_NUMBER.ipa" diff --git a/scripts/generate-mocks.sh b/scripts/generate-mocks.sh index ffe49bb2..d61f7b4a 100755 --- a/scripts/generate-mocks.sh +++ b/scripts/generate-mocks.sh @@ -13,6 +13,7 @@ modules=( "CryptoLib" "MobileIdLib" "SmartIdLib" + "WebEidLib" ) extensions=( @@ -59,6 +60,10 @@ for module in "${modules[@]}"; do custom_imports=("SmartIdLib") testable_imports="" ;; + "WebEidLib") + custom_imports=("WebEidLib") + testable_imports="" + ;; *) custom_imports=() testable_imports=""