diff --git a/.gitignore b/.gitignore index 55567ba2..8041196b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,8 @@ build-cdoc-debug/ Packages/ xcuserdata -# Module lockfiles -Modules/**/Package.resolved +# Module lockfiles (SPM packages and the Xcode workspace's own copy) +**/Package.resolved # Mockolo **/Mocks/** diff --git a/Modules/CryptoLib/Package.swift b/Modules/CryptoLib/Package.swift index 89d8585e..4ea74b6d 100644 --- a/Modules/CryptoLib/Package.swift +++ b/Modules/CryptoLib/Package.swift @@ -13,7 +13,8 @@ let package = Package( .library( name: "CryptoLib", targets: ["CryptoSwift"] - ) + ), + .library(name: "CryptoLibMocks", targets: ["CryptoLibMocks"]) ], dependencies: [ .package(url: "https://github.com/filom/ASN1Decoder", exact: .init(1, 10, 0)), @@ -87,6 +88,23 @@ let package = Package( .enableUpcomingFeature("NonisolatedNonsendingByDefault"), .enableUpcomingFeature("InferIsolatedConformances") ] + ), + .target( + name: "CryptoLibMocks", + dependencies: ["CryptoSwift"], + path: "Tests/Mocks" + ), + .testTarget( + name: "CryptoSwiftTests", + dependencies: [ + "ConfigLib", + "CryptoLibMocks", + "CryptoObjCWrapper", + "CommonsLib", + "UtilsLib", + .product(name: "FactoryTesting", package: "Factory"), + .product(name: "CommonsLibMocks", package: "commonslib") + ] ) ] ) diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm index 7613c22d..11678f5a 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm @@ -27,40 +27,56 @@ #include #include +static CertType certTypeFromLabel(NSString * _Nullable type) { + if (type == nil) return CertTypeESealType; + if ([type isEqualToString:@"ID-card"] || + [type isEqualToString:@"cert"]) return CertTypeIDCardType; + if ([type isEqualToString:@"Digi-ID"]) return CertTypeDigiIDType; + if ([type isEqualToString:@"Digi-ID E-RESIDENT"]) return CertTypeEResidentType; + return CertTypeUnknownType; +} + @implementation Addressee (label) - (instancetype)initWithLabel:(const std::string &)label pub:(NSData*)pub concatKDFAlgorithmURI:(NSString *)concatKDFAlgorithmURI { std::map info = libcdoc::Lock::parseLabel(label); - id cn = info.contains("cn") ? [NSString stringWithStdString:info["cn"]] : [NSString stringWithStdString:label]; - id type = info.contains("type") ? [NSString stringWithStdString:info["type"]] : nil; - id serial = info.contains("serial_number") ? [NSString stringWithStdString:info["serial_number"]] : nil; - CertType certType = CertTypeUnknownType; + NSString *cn = info.contains("cn") ? [NSString stringWithStdString:info["cn"]] : [NSString stringWithStdString:label]; + NSString *type = info.contains("type") ? [NSString stringWithStdString:info["type"]] : nil; + NSString *serial = info.contains("serial_number") ? [NSString stringWithStdString:info["serial_number"]] : nil; + + // A single-segment CN with no explicit last_name key is an e-seal, not a person. NSArray *split = [cn componentsSeparatedByString:@","]; if (!info.contains("last_name") && split.count == 1) { type = nil; } - - if ([type isEqualToString:@"ID-card"] || [type isEqualToString:@"cert"]) { - certType = CertTypeIDCardType; - } else if ([type isEqualToString:@"Digi-ID"]) { - certType = CertTypeDigiIDType; - } else if ([type isEqualToString:@"Digi-ID E-RESIDENT"]) { - certType = CertTypeEResidentType; - } else if (type == nil) { - certType = CertTypeESealType; - } - id validTo = nil; + + NSDate *validTo = nil; if (info.contains("server_exp")) { long long epochTime = [[NSString stringWithStdString:info["server_exp"]] longLongValue]; validTo = [NSDate dateWithTimeIntervalSince1970:epochTime]; } - if (self = [self initWithCnVal:cn serialNumber:serial certType:certType validTo:validTo data:pub concatKDFAlgorithmURI:concatKDFAlgorithmURI]) { + + if (self = [self initWithCnVal:cn serialNumber:serial certType:certTypeFromLabel(type) validTo:validTo data:pub concatKDFAlgorithmURI:concatKDFAlgorithmURI lockLabel:@"" lockType:@""]) { } return self; } @end +static NSString *lockTypeName(libcdoc::Lock::Type type) { + switch (type) { + case libcdoc::Lock::Type::PASSWORD: return @"PASSWORD"; + case libcdoc::Lock::Type::SYMMETRIC_KEY: return @"SYMMETRIC_KEY"; + case libcdoc::Lock::Type::PUBLIC_KEY: return @"PUBLIC_KEY"; + case libcdoc::Lock::Type::CDOC1: return @"CDOC1"; + case libcdoc::Lock::Type::SERVER: return @"SERVER"; +#ifdef HAS_KEYSHARES + case libcdoc::Lock::Type::SHARE_SERVER: return @"SHARE_SERVER"; +#endif + default: return @"UNKNOWN"; + } +} + @implementation Decrypt + (void)setCerts:(nullable NSArray *)certs { @@ -114,13 +130,16 @@ + (CdocInfo*)cdocInfo:(NSString *)fullPath error:(NSError**)error { NSString *cnVal = info.contains("label") ? [NSString stringWithStdString:info["label"]] : @""; + NSString *rawLockLabel = [NSString stringWithStdString:lock.label] ?: @""; [addressees addObject:[[Addressee alloc] initWithCnVal:cnVal serialNumber:nil certType:CertTypePasswordType validTo:nil data:[NSData data] - concatKDFAlgorithmURI:@""]]; + concatKDFAlgorithmURI:@"" + lockLabel:rawLockLabel + lockType:lockTypeName(lock.type)]]; } else { [addressees addObject:[[Addressee alloc] initWithData:[NSData data] cnVal:@"Unknown capsule"]]; } @@ -198,12 +217,27 @@ + (void)decryptFile:(NSString *)fullPath withCert:(NSData *)certData withToken:( } } crypto {password}; std::unique_ptr reader(libcdoc::CDocReader::createReader(fullPath.UTF8String, nullptr, &crypto, nullptr)); + if (!reader) { + return [NSError cryptoError:@"Failed to create CDocReader" error:error]; + } - auto idx = 0; // TODO: reader->getLockForCert(network.cert); - if(idx < 0) + int idx = -1; + const auto& locks = reader->getLocks(); + for (size_t i = 0; i < locks.size(); i++) { + if (locks[i].type == libcdoc::Lock::Type::PASSWORD) { + idx = (int)i; + break; + } + } + if (idx < 0) { return [NSError cryptoError:@"Decrypting failed" error:error]; + } std::vector fmk; - if(reader->getFMK(fmk, unsigned(idx)) != 0 || fmk.empty()) { + auto fmkResult = reader->getFMK(fmk, unsigned(idx)); + if (fmkResult == libcdoc::WRONG_KEY) { + return [NSError cryptoWrongKeyError:error]; + } + if (fmkResult != libcdoc::OK || fmk.empty()) { return [NSError cryptoError:@"Decrypting failed" error:error]; } return [self decryptReader:*reader withFMK:fmk error:error]; diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Encrypt.mm b/Modules/CryptoLib/Sources/CryptoObjC/include/Encrypt.mm index 8b4d1223..57875d1b 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Encrypt.mm +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Encrypt.mm @@ -213,12 +213,14 @@ + (void)encryptFile:(NSString *)fullPath withDataFiles:(NSArray return completion([NSError cryptoError:@"Failed to create writer"]); } - if (writer->beginEncryption() != 0) { - return completion([NSError cryptoError:@"Failed to start encryption"]); + auto passwordRecipient = libcdoc::Recipient::makeSymmetric("", 65536); + passwordRecipient.setLabelValue("label", std::string(label.UTF8String)); + if (writer->addRecipient(passwordRecipient) != 0) { + return completion([NSError cryptoError:@"Failed to create key"]); } - if (writer->addRecipient(libcdoc::Recipient::makeSymmetric(label.UTF8String, 65536))) { - return completion([NSError cryptoError:@"Failed to create key"]); + if (writer->beginEncryption() != 0) { + return completion([NSError cryptoError:@"Failed to start encryption"]); } for (CryptoDataFile *dataFile in dataFiles) { diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h b/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h index 46c286fa..5c8af62d 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Extensions.h @@ -22,9 +22,12 @@ #include #include +static const NSInteger CryptoLibWrongKeyErrorCode = -109; // libcdoc::WRONG_KEY + @interface NSError (CryptoLib) + (NSError*)cryptoError:(NSString*)msg; + (id)cryptoError:(NSString*)msg error:(NSError**)error; ++ (id)cryptoWrongKeyError:(NSError**)error; @end @interface NSString (std_string) @@ -77,4 +80,13 @@ } return nil; } + ++ (id)cryptoWrongKeyError:(NSError**)error { + if (error) { + *error = [[NSError alloc] initWithDomain:@"ee.ria.digidoc.CryptoLib" + code:CryptoLibWrongKeyErrorCode + userInfo:@{NSLocalizedDescriptionKey: @"Wrong password"}]; + } + return nil; +} @end diff --git a/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/Addressee.swift b/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/Addressee.swift index 8ead66de..693fea23 100644 --- a/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/Addressee.swift +++ b/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/Addressee.swift @@ -30,6 +30,8 @@ import Foundation public let validTo: Date? @MainActor @objc public var concatKDFAlgorithmURI: String + @objc public let lockLabel: String + @objc public let lockType: String @objc public init( data: Data, @@ -39,7 +41,9 @@ import Foundation serialNumber: String?, certType: CertType, validTo: Date?, - concatKDFAlgorithmURI: String = "" + concatKDFAlgorithmURI: String = "", + lockLabel: String = "", + lockType: String = "" ) { self.identifier = cnVal self.data = data @@ -49,6 +53,8 @@ import Foundation self.certType = certType self.validTo = validTo self.concatKDFAlgorithmURI = concatKDFAlgorithmURI + self.lockLabel = lockLabel + self.lockType = lockType } @objc public convenience init(data: Data, cnVal: String) { @@ -69,7 +75,9 @@ import Foundation certType: CertType, validTo: Date?, data: Data, - concatKDFAlgorithmURI: String = "" + concatKDFAlgorithmURI: String = "", + lockLabel: String = "", + lockType: String = "" ) { let split = cnVal.split(separator: ",").map { String($0) } if split.count >= 3 { @@ -86,6 +94,8 @@ import Foundation self.validTo = validTo self.data = data self.concatKDFAlgorithmURI = concatKDFAlgorithmURI + self.lockLabel = lockLabel + self.lockType = lockType } public init(cert: Data, x509: X509Certificate?) { @@ -105,6 +115,8 @@ import Foundation certType = x509?.certType() ?? .unknownType validTo = x509?.notAfter concatKDFAlgorithmURI = "" + lockLabel = "" + lockType = "" } convenience public init(cert: Data) { diff --git a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift index 0e389e0b..8de16e6c 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift @@ -343,30 +343,7 @@ extension CryptoContainer { withCert: cert, withToken: SmartToken(card: cardCommands, pin1: pin) ) - var cryptoDataFiles: [CryptoDataFile] = [] - var urlDataFiles: [URL] = [] - cryptoDataFiles.removeAll() - for dataFile in decryptedData { - - let sanitizedName = dataFile.key.sanitized() - - let destinationPath = try Directories.getCacheDirectory( - subfolders: [Constants.Folder.ContainerFolder, Constants.Folder.Temp], - fileManager: fileManager - ) - - let fileUrl = destinationPath.appending(path: sanitizedName, directoryHint: .notDirectory) - - cryptoDataFiles.append(CryptoDataFile(filename: dataFile.key, filePath: destinationPath.resolvedPath)) - urlDataFiles.append(fileUrl) - let isCreated = fileManager.createFile( - atPath: fileUrl.resolvedPath, contents: dataFile.value, attributes: nil - ) - - if !isCreated { - CryptoContainer.logger().error("Unable to create file at path: \(destinationPath.resolvedPath)") - } - } + let urlDataFiles = try writeDecryptedFiles(decryptedData, fileManager: fileManager) return try await create( containerFile: containerFile, @@ -377,6 +354,8 @@ extension CryptoContainer { ) } + private static let libcdocWrongKeyCode = -109 // libcdoc::WRONG_KEY + @MainActor public static func decryptWithPassword( containerFile: URL, @@ -384,34 +363,20 @@ extension CryptoContainer { password: String, fileManager: FileManagerProtocol = Container.shared.fileManager() ) async throws -> CryptoContainerProtocol { - let path = containerFile.resolvedPath let decryptedData: [String: Data] = try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { do { - let result = try Decrypt.decryptFile(path, withPassword: password) + let result = try Decrypt.decryptFile(containerFile.resolvedPath, withPassword: password) continuation.resume(returning: result) + } catch let nsError as NSError where nsError.code == libcdocWrongKeyCode { + continuation.resume(throwing: CryptoError.wrongDecryptionKey) } catch { continuation.resume(throwing: error) } } } - var urlDataFiles: [URL] = [] - for dataFile in decryptedData { - let sanitizedName = dataFile.key.sanitized() - let destinationPath = try Directories.getCacheDirectory( - subfolders: [Constants.Folder.ContainerFolder, Constants.Folder.Temp], - fileManager: fileManager - ) - let fileUrl = destinationPath.appending(path: sanitizedName, directoryHint: .notDirectory) - urlDataFiles.append(fileUrl) - let isCreated = fileManager.createFile( - atPath: fileUrl.resolvedPath, contents: dataFile.value, attributes: nil - ) - if !isCreated { - CryptoContainer.logger().error("Unable to create file at path: \(destinationPath.resolvedPath)") - } - } + let urlDataFiles = try writeDecryptedFiles(decryptedData, fileManager: fileManager) return try await create( containerFile: containerFile, @@ -422,6 +387,55 @@ extension CryptoContainer { ) } + private static func writeDecryptedFiles( + _ decryptedData: [String: Data], + fileManager: FileManagerProtocol + ) throws -> [URL] { + let destinationPath = try Directories.getCacheDirectory( + subfolders: [Constants.Folder.ContainerFolder, Constants.Folder.Temp], + fileManager: fileManager + ) + return try decryptedData.map { name, data in + let fileUrl = destinationPath.appending(path: name.sanitized(), directoryHint: .notDirectory) + guard fileManager.createFile(atPath: fileUrl.resolvedPath, contents: data, attributes: nil) else { + throw CryptoError.containerDataFileSavingFailed( + CryptoErrorDetail(message: "Unable to create decrypted file", userInfo: ["fileName": name]) + ) + } + return fileUrl + } + } + + @MainActor + public static func encryptWithPassword( + containerFile: URL, + dataFiles: [URL], + label: String, + password: String + ) async throws -> CryptoContainerProtocol { + if dataFiles.isEmpty { + throw CryptoError.containerCreationFailed( + CryptoErrorDetail(message: "Cannot create an empty crypto container") + ) + } + + var cryptoDataFiles: [CryptoDataFile] = [] + for dataFile in dataFiles { + cryptoDataFiles.append( + CryptoDataFile(filename: dataFile.lastPathComponent, filePath: dataFile.resolvedPath) + ) + } + + try await Encrypt.encryptFile( + containerFile.resolvedPath, + withDataFiles: cryptoDataFiles, + withLabel: label, + withPassword: password + ) + + return try await open(containerFile: containerFile) + } + @MainActor public static func encrypt( containerFile: URL, diff --git a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainerProtocol.swift b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainerProtocol.swift index 64a6cc8c..5b88f04c 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainerProtocol.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainerProtocol.swift @@ -22,6 +22,7 @@ import CommonsLib import CryptoObjC import CryptoObjCWrapper +/// @mockable public protocol CryptoContainerProtocol: GeneralContainer, Sendable { func isDecrypted() async -> Bool func isEncrypted() async -> Bool diff --git a/Modules/CryptoLib/Sources/CryptoSwift/Errors/CryptoError.swift b/Modules/CryptoLib/Sources/CryptoSwift/Errors/CryptoError.swift index f353a5cc..bd311181 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/Errors/CryptoError.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/Errors/CryptoError.swift @@ -27,6 +27,7 @@ public enum CryptoError: Error { case containerSavingFailed(CryptoErrorDetail) case containerRenamingFailed(CryptoErrorDetail) case containerDataFileSavingFailed(CryptoErrorDetail) + case wrongDecryptionKey public var errorDetail: CryptoErrorDetail { switch self { @@ -38,6 +39,8 @@ public enum CryptoError: Error { .containerRenamingFailed(let errorDetail), .containerDataFileSavingFailed(let errorDetail): return errorDetail + case .wrongDecryptionKey: + return CryptoErrorDetail(message: "Wrong decryption key") } } diff --git a/Modules/CryptoLib/Tests/CryptoSwiftTests/AddresseeTests.swift b/Modules/CryptoLib/Tests/CryptoSwiftTests/AddresseeTests.swift new file mode 100644 index 00000000..9cb1949e --- /dev/null +++ b/Modules/CryptoLib/Tests/CryptoSwiftTests/AddresseeTests.swift @@ -0,0 +1,194 @@ +/* + * 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 +import Foundation +import Testing + +struct AddresseeTests { + + @Test + func init_defaultLockLabelAndLockTypeAreEmpty() { + let addressee = Addressee( + data: Data(), + cnVal: "SMITH,JOHN,38001010001", + givenName: "JOHN", + surname: "SMITH", + serialNumber: nil, + certType: .iDCardType, + validTo: nil + ) + + #expect(addressee.lockLabel == "") + #expect(addressee.lockType == "") + } + + @Test + func init_lockLabelAndLockTypeSetCorrectly() { + let addressee = Addressee( + data: Data(), + cnVal: "myKey", + givenName: nil, + surname: nil, + serialNumber: nil, + certType: .passwordType, + validTo: nil, + concatKDFAlgorithmURI: "", + lockLabel: "data:v=1&label=myKey&type=pw", + lockType: "PASSWORD" + ) + + #expect(addressee.lockLabel == "data:v=1&label=myKey&type=pw") + #expect(addressee.lockType == "PASSWORD") + #expect(addressee.identifier == "myKey") + #expect(addressee.certType == .passwordType) + } + + @Test + func init_passwordTypePreservesEmptyLockLabelAndLockType() { + let addressee = Addressee( + data: Data(), + cnVal: "", + givenName: nil, + surname: nil, + serialNumber: nil, + certType: .passwordType, + validTo: nil + ) + + #expect(addressee.lockLabel == "") + #expect(addressee.lockType == "") + #expect(addressee.identifier == "") + } + + @Test + func cnValInit_with3Segments_parsesSurnameGivenNameIdentifier() { + let addressee = Addressee( + cnVal: "SMITH,JOHN,38001010001", + serialNumber: nil, + certType: .iDCardType, + validTo: nil, + data: Data() + ) + + #expect(addressee.surname == "SMITH") + #expect(addressee.givenName == "JOHN") + #expect(addressee.identifier == "38001010001") + } + + @Test + func cnValInit_withMoreThan3Segments_usesFirst3() { + let addressee = Addressee( + cnVal: "SMITH,JOHN,38001010001,EXTRA", + serialNumber: nil, + certType: .iDCardType, + validTo: nil, + data: Data() + ) + + #expect(addressee.surname == "SMITH") + #expect(addressee.givenName == "JOHN") + #expect(addressee.identifier == "38001010001") + } + + @Test + func cnValInit_with2Segments_doesNotSplitAndUsesFullStringAsIdentifier() { + let addressee = Addressee( + cnVal: "ACME OÜ,12345678", + serialNumber: nil, + certType: .eSealType, + validTo: nil, + data: Data() + ) + + #expect(addressee.surname == nil) + #expect(addressee.givenName == nil) + #expect(addressee.identifier == "ACME OÜ,12345678") + } + + @Test + func cnValInit_with1Segment_usesFullStringAsIdentifier() { + let addressee = Addressee( + cnVal: "SomeCompany", + serialNumber: nil, + certType: .eSealType, + validTo: nil, + data: Data() + ) + + #expect(addressee.surname == nil) + #expect(addressee.givenName == nil) + #expect(addressee.identifier == "SomeCompany") + } + + @Test + func cnValInit_withEmptyString_usesEmptyIdentifier() { + let addressee = Addressee( + cnVal: "", + serialNumber: nil, + certType: .unknownType, + validTo: nil, + data: Data() + ) + + #expect(addressee.surname == nil) + #expect(addressee.givenName == nil) + #expect(addressee.identifier == "") + } + + @Test + func dataConvenienceInit_setsIdentifierToCnVal() { + let addressee = Addressee(data: Data([1, 2, 3]), cnVal: "TestLabel") + + #expect(addressee.identifier == "TestLabel") + #expect(addressee.certType == .unknownType) + #expect(addressee.lockLabel == "") + #expect(addressee.lockType == "") + } + + @Test + func cnValInit_lockLabelAndLockTypeSetCorrectly() { + let addressee = Addressee( + cnVal: "SMITH,JOHN,38001010001", + serialNumber: nil, + certType: .iDCardType, + validTo: nil, + data: Data(), + lockLabel: "rawLockLabel", + lockType: "PUBLIC_KEY" + ) + + #expect(addressee.lockLabel == "rawLockLabel") + #expect(addressee.lockType == "PUBLIC_KEY") + } + + @Test + func cnValInit_defaultLockLabelAndLockTypeAreEmpty() { + let addressee = Addressee( + cnVal: "SMITH,JOHN,38001010001", + serialNumber: nil, + certType: .iDCardType, + validTo: nil, + data: Data() + ) + + #expect(addressee.lockLabel == "") + #expect(addressee.lockType == "") + } +} diff --git a/RIADigiDoc.xcodeproj/project.pbxproj b/RIADigiDoc.xcodeproj/project.pbxproj index a7138d34..353a99b1 100644 --- a/RIADigiDoc.xcodeproj/project.pbxproj +++ b/RIADigiDoc.xcodeproj/project.pbxproj @@ -267,6 +267,7 @@ ViewModel/HomeViewModel.swift, ViewModel/InitViewModel.swift, ViewModel/LanguageChooserViewModel.swift, + ViewModel/LTASettingsViewModel.swift, ViewModel/MobileIDSmartIDSettingsViewModel.swift, ViewModel/MyEid/MyEidPinChangeViewModel.swift, ViewModel/MyEid/MyEidRootViewModel.swift, @@ -286,6 +287,7 @@ ViewModel/Protocols/HomeViewModelProtocol.swift, ViewModel/Protocols/InitViewModelProtocol.swift, ViewModel/Protocols/LanguageChooserViewModelProtocol.swift, + ViewModel/Protocols/LTASettingsViewModelProtocol.swift, ViewModel/Protocols/MobileIDSmartIDSettingsViewModelProtocol.swift, ViewModel/Protocols/MyEid/MyEidPinChangeViewModelProtocol.swift, ViewModel/Protocols/MyEid/MyEidRootViewModelProtocol.swift, diff --git a/RIADigiDoc/Domain/Model/Crypto/RecipientDetailViewTab.swift b/RIADigiDoc/Domain/Model/Crypto/RecipientDetailViewTab.swift deleted file mode 100644 index 29b7d7b3..00000000 --- a/RIADigiDoc/Domain/Model/Crypto/RecipientDetailViewTab.swift +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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 - * - */ - -enum RecipientDetailViewTab: Int, Sendable { - case recipientDetails = 0 -} diff --git a/RIADigiDoc/Supporting files/Localizable.xcstrings b/RIADigiDoc/Supporting files/Localizable.xcstrings index fcc7fcfa..e47bad20 100644 --- a/RIADigiDoc/Supporting files/Localizable.xcstrings +++ b/RIADigiDoc/Supporting files/Localizable.xcstrings @@ -1475,6 +1475,24 @@ } } }, + "Crypto password repeat mismatch" : { + "comment" : "Password dialog — repeat password does not match error", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passwords do not match" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paroolid ei kattu" + } + } + } + }, "Crypto password save warning" : { "comment" : "Password dialog — info box warning text", "extractionState" : "manual", @@ -1835,6 +1853,42 @@ } } }, + "Decrypt general error" : { + "comment" : "CryptoContainer password decrypt error message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Container decryption was unsuccessful" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ümbriku dekrüpteerimine ebaõnnestus" + } + } + } + }, + "Decrypt wrong password error" : { + "comment" : "CryptoContainer password decrypt — wrong password error", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wrong password" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vale parool" + } + } + } + }, "Encrypt general error" : { "comment" : "CryptoContainer encrypt error message", "extractionState" : "manual", @@ -6734,6 +6788,24 @@ } } }, + "Lock type" : { + "comment" : "Recipient detail view — lock type label for password recipients", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lock type" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luku tüüp" + } + } + } + }, "Recipient" : { "comment" : "Home title, signer details title, accessibility recipient prefix", "extractionState" : "manual", diff --git a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift index 684f2ddc..09cec498 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift @@ -489,8 +489,8 @@ struct EncryptView: View { if showDecryptPasswordModal { DecryptPasswordModalView( keyLabel: passwordDecryptKeyLabel, - onDecrypt: { _ in - // Add password decryption functionality + onDecrypt: { password in + Task { await handlePasswordDecrypt(password) } }, onCancel: { showDecryptPasswordModal = false @@ -577,6 +577,33 @@ struct EncryptView: View { } } + private func handlePasswordDecrypt(_ password: String) async { + guard let containerFile = viewModel.containerURL else { + Toast.show(languageSettings.localized("Decrypt general error")) + return + } + do { + let decryptedContainer = try await CryptoContainer.decryptWithPassword( + containerFile: containerFile, + recipients: viewModel.recipients, + password: password + ) + let sharedVM = Container.shared.sharedContainerViewModel() + sharedVM.removeLastContainer() + sharedVM.setCryptoContainer(decryptedContainer) + await viewModel.loadContainerData(cryptoContainer: decryptedContainer) + showDecryptPasswordModal = false + selectedTab = .files + await updateAsyncLabels() + await viewModel.updateAsyncProperties() + Toast.show(languageSettings.localized("Container successfully decrypted"), type: .success) + } catch CryptoError.wrongDecryptionKey { + Toast.show(languageSettings.localized("Decrypt wrong password error")) + } catch { + Toast.show(languageSettings.localized("Decrypt general error")) + } + } + func updateAsyncLabels() async { let containerTitle = await containerTitle() let encryptDecryptLabel = await self.encryptDecryptLabel() diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift index 6aee5128..c48d25b4 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Modal/EncryptPasswordModalView.swift @@ -29,13 +29,24 @@ struct EncryptPasswordModalView: View { @State private var password: String = "" @State private var repeatPassword: String = "" - let onEncrypt: (String, String, String) -> Void + let onEncrypt: (String, String) -> Void let onCancel: () -> Void private var keyLabelTitle: String { languageSettings.localized("Crypto password key label") } private var passwordTitle: String { languageSettings.localized("Crypto password field label") } private var repeatTitle: String { languageSettings.localized("Crypto password repeat label") } + private var isPasswordValid: Bool { + let len = password.count + return len >= 20 && len <= 64 + && password.contains(where: { $0.isNumber }) + && password.contains(where: { $0.isUppercase }) + && password.contains(where: { $0.isLowercase }) + } + + private var showPasswordError: Bool { !password.isEmpty && !isPasswordValid } + private var showRepeatError: Bool { !repeatPassword.isEmpty && repeatPassword != password } + var body: some View { PasswordModalCard { VStack(alignment: .leading, spacing: Dimensions.Padding.ZeroPadding) { @@ -52,8 +63,9 @@ struct EncryptPasswordModalView: View { PasswordModalButtonRow( cancelLabel: languageSettings.localized("Cancel"), confirmLabel: languageSettings.localized("Encrypt"), + isConfirmEnabled: isPasswordValid && !repeatPassword.isEmpty && password == repeatPassword, onCancel: onCancel, - onConfirm: { onEncrypt(keyLabel, password, repeatPassword) } + onConfirm: { onEncrypt(keyLabel, password) } ) } .frame(maxHeight: .infinity) @@ -103,7 +115,7 @@ struct EncryptPasswordModalView: View { ) } - private let requirementKeys = [ + private static let requirementKeys = [ "Crypto password length requirement", "Crypto password number requirement", "Crypto password uppercase requirement", @@ -112,7 +124,7 @@ struct EncryptPasswordModalView: View { private var requirementsAccessibilityLabel: String { ([languageSettings.localized("Password requirements")] - + requirementKeys.map { languageSettings.localized($0) }) + + EncryptPasswordModalView.requirementKeys.map { languageSettings.localized($0) }) .joined(separator: ". ") .replacingOccurrences( of: "–", @@ -127,12 +139,13 @@ struct EncryptPasswordModalView: View { placeholder: passwordTitle, text: $password, isSecure: true, + isError: showPasswordError, submitLabel: .next, identifier: "passwordInput", sortPriority: 0 ) VStack(alignment: .leading, spacing: Dimensions.Padding.ZeroPadding) { - ForEach(requirementKeys, id: \.self) { key in + ForEach(EncryptPasswordModalView.requirementKeys, id: \.self) { key in requirementRow(key) } } @@ -149,6 +162,10 @@ struct EncryptPasswordModalView: View { placeholder: repeatTitle, text: $repeatPassword, isSecure: true, + isError: showRepeatError, + errorText: showRepeatError + ? languageSettings.localized("Crypto password repeat mismatch") + : "", submitLabel: .done, identifier: "repeatPasswordInput" ) @@ -157,14 +174,14 @@ struct EncryptPasswordModalView: View { private func requirementRow(_ key: String) -> some View { Text(verbatim: "• \(languageSettings.localized(key))") .font(typography.labelMedium) - .foregroundStyle(theme.onSecondaryContainer) + .foregroundStyle(showPasswordError ? theme.error : theme.onSecondaryContainer) .frame(maxWidth: .infinity, alignment: .leading) } } #Preview { EncryptPasswordModalView( - onEncrypt: { _, _, _ in }, + onEncrypt: { _, _ in }, onCancel: {} ) .environment(Container.shared.languageSettings()) diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Modal/PasswordModalCard.swift b/RIADigiDoc/UI/Component/Container/Crypto/Modal/PasswordModalCard.swift index e1c238c6..4760abd2 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Modal/PasswordModalCard.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Modal/PasswordModalCard.swift @@ -100,6 +100,7 @@ struct PasswordModalButtonRow: View { let cancelLabel: String let confirmLabel: String + var isConfirmEnabled: Bool = true let onCancel: () -> Void let onConfirm: () -> Void @@ -111,8 +112,9 @@ struct PasswordModalButtonRow: View { .minimumScaleFactor(0.5) Button(confirmLabel) { onConfirm() } .font(typography.labelLarge) - .foregroundStyle(theme.primary) + .foregroundStyle(isConfirmEnabled ? theme.primary : theme.onSurfaceVariant) .minimumScaleFactor(0.5) + .disabled(!isConfirmEnabled) } .frame(maxWidth: .infinity, alignment: .trailing) .padding(.vertical, Dimensions.Padding.MSPadding) diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift index 82ad5f16..d4e5a7ab 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift @@ -373,19 +373,10 @@ struct EncryptRecipientView: View { if showPasswordEncryptModal { EncryptPasswordModalView( - onEncrypt: { _, _, _ in - showPasswordEncryptModal = false - pathManager.replaceLast( - to: .encryptView( - isWithEncryption: false, - cdocOption: cdocOption, - selectedTab: .recipients - ) - ) + onEncrypt: { keyLabel, password in + Task { await handlePasswordEncrypt(label: keyLabel, password: password) } }, - onCancel: { - showPasswordEncryptModal = false - } + onCancel: { showPasswordEncryptModal = false } ) } @@ -510,6 +501,19 @@ struct EncryptRecipientView: View { .background(theme.surface) } + private func handlePasswordEncrypt(label: String, password: String) async { + do { + try await viewModel.encryptWithPassword(label: label, password: password) + showPasswordEncryptModal = false + pathManager.replaceLast( + to: .encryptView(isWithEncryption: false, cdocOption: cdocOption, selectedTab: .recipients) + ) + Toast.show(languageSettings.localized("Container successfully encrypted"), type: .success) + } catch { + Toast.show(languageSettings.localized("Encrypt general error")) + } + } + private func emptyStateView(_ text: String) -> some View { ContentUnavailableView { Text(verbatim: text) diff --git a/RIADigiDoc/UI/Component/Container/Crypto/RecipientDetailView.swift b/RIADigiDoc/UI/Component/Container/Crypto/RecipientDetailView.swift index 8b5c9a8e..80c2dfc6 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/RecipientDetailView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/RecipientDetailView.swift @@ -30,42 +30,29 @@ struct RecipientDetailView: View { @Environment(LanguageSettings.self) private var languageSettings @Environment(\.openURL) var openURL - @State private var selectedTab: RecipientDetailViewTab = .recipientDetails - @State private var viewModel: SignatureDetailViewModel private let recipient: Addressee - private let nameUtil: NameUtilProtocol - var recipientDetailsTitle: String { - return languageSettings.localized("Recipient") + private var nameText: String { + if PersonalCodeValidator.isPersonalCodeValid(recipient.identifier) { + return nameUtil.formatName( + surname: recipient.surname, + givenName: recipient.givenName, + identifier: recipient.identifier + ) + } else { + return nameUtil.formatCompanyName( + identifier: recipient.identifier, + serialNumber: recipient.serialNumber + ) + } } - var nameText: String { - return { - if PersonalCodeValidator.isPersonalCodeValid(recipient.identifier) { - return nameUtil.formatName( - surname: recipient.surname, - givenName: recipient.givenName, - identifier: recipient.identifier - ) - } else { - return nameUtil.formatCompanyName( - identifier: recipient.identifier, - serialNumber: recipient.serialNumber - ) - } - }() - } - - var validToDate: String { + private var validToDate: String { guard let validToDate = recipient.validTo else { return "" } - - return DateUtil.getFormattedDateTime( - date: validToDate, - isUTC: false - ).date + return DateUtil.getFormattedDateTime(date: validToDate, isUTC: false).date } init( @@ -74,7 +61,6 @@ struct RecipientDetailView: View { ) { _viewModel = State(wrappedValue: Container.shared.signatureDetailViewModel()) self.recipient = recipient - self.nameUtil = nameUtil } @@ -92,74 +78,65 @@ struct RecipientDetailView: View { ) VStack(alignment: .leading) { - TabView( - selectedTab: $selectedTab, - titles: [recipientDetailsTitle], - content: { - VStack(alignment: .leading) { - if selectedTab == .recipientDetails { - let issuerName = viewModel.getIssuerName(cert: recipient.data) - if !issuerName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: languageSettings.localized("Recipient certificate issuer"), - value: viewModel.getIssuerName(cert: recipient.data) - ) - ) - } - - if !nameText.isEmpty { - NavigationLink( - value: NavigationDestination - .certificateDetailView(certificate: recipient.data) - ) { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: languageSettings.localized("Recipient certificate"), - value: nameText, - extraIcon: "ic_m3_expand_content_48pt_wght400", - ) - ) - } - .buttonStyle(.plain) - } - if !recipient.concatKDFAlgorithmURI.isEmpty { - Button { - if let url = URL(string: recipient.concatKDFAlgorithmURI) { - openURL(url) - } - } label: { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: languageSettings.localized("ConcatKDF reference method"), - value: recipient.concatKDFAlgorithmURI, - extraIcon: "ic_m3_open_in_new_48pt_wght400", - ) - ) - } - .buttonStyle(.plain) - .accessibilityRemoveTraits([.isButton]) - .accessibilityAddTraits([.isLink]) - } - if !validToDate.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: languageSettings.localized( - "Recipient certificate expiry date" - ), - value: validToDate - ) - ) - } - } - } - }) - .padding(.top, Dimensions.Padding.LPadding) + recipientDetails } + .padding(.top, Dimensions.Padding.LPadding) } .padding(Dimensions.Padding.SPadding) }) } + + @ViewBuilder + private var recipientDetails: some View { + if recipient.certType == .passwordType { + passwordRecipientDetails + } else { + certificateRecipientDetails + } + } + + @ViewBuilder + private var passwordRecipientDetails: some View { + detailRow("Recipient", value: recipient.lockLabel) + detailRow("Lock type", value: recipient.lockType) + } + + @ViewBuilder + private var certificateRecipientDetails: some View { + detailRow("Recipient certificate issuer", value: viewModel.getIssuerName(cert: recipient.data)) + if !nameText.isEmpty { + NavigationLink(value: NavigationDestination.certificateDetailView(certificate: recipient.data)) { + detailRow("Recipient certificate", value: nameText, extraIcon: "ic_m3_expand_content_48pt_wght400") + } + .buttonStyle(.plain) + } + if let uri = URL(string: recipient.concatKDFAlgorithmURI), !recipient.concatKDFAlgorithmURI.isEmpty { + Button { openURL(uri) } label: { + detailRow( + "ConcatKDF reference method", + value: recipient.concatKDFAlgorithmURI, + extraIcon: "ic_m3_open_in_new_48pt_wght400" + ) + } + .buttonStyle(.plain) + .accessibilityRemoveTraits([.isButton]) + .accessibilityAddTraits([.isLink]) + } + detailRow("Recipient certificate expiry date", value: validToDate) + } + + @ViewBuilder + private func detailRow(_ titleKey: String, value: String, extraIcon: String? = nil) -> some View { + if !value.isEmpty { + SignerDetailView( + signatureDataItem: SignatureDataItem( + title: languageSettings.localized(titleKey), + value: value, + extraIcon: extraIcon + ) + ) + } + } } #Preview { @@ -173,10 +150,8 @@ struct RecipientDetailView: View { validTo: Date.distantFuture ) - RecipientDetailView( - recipient: recipient - ) - .environment(Container.shared.languageSettings()) - .environment(Container.shared.themeSettings()) - .environment(NavigationPathManager()) + RecipientDetailView(recipient: recipient) + .environment(Container.shared.languageSettings()) + .environment(Container.shared.themeSettings()) + .environment(NavigationPathManager()) } diff --git a/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift b/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift index 95c46004..bf979736 100644 --- a/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift +++ b/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift @@ -116,6 +116,11 @@ struct FloatingLabelTextField: View { !title.isEmpty && !text.isEmpty && isFocused } + // Dont show password saving options + private var fieldContentType: UITextContentType? { + isSecure ? .oneTimeCode : .init(rawValue: "") + } + private var isInteractionEnabled: Bool { !isDisabled && !isDropdown } @@ -315,6 +320,7 @@ struct FloatingLabelTextField: View { keyboardType: keyboardType, submitLabel: submitLabel, spellOut: spellOutCharacters && isPasswordVisible, + contentType: fieldContentType, isAccessibilityFocused: $isAccessibilityFocused, onAppear: {}, onSubmit: { @@ -342,6 +348,7 @@ struct FloatingLabelTextField: View { keyboardType: keyboardType, submitLabel: submitLabel, spellOut: spellOutCharacters && !isSecure && isPasswordVisible, + contentType: fieldContentType, isAccessibilityFocused: $isAccessibilityFocused, onAppear: { selection = TextSelection(insertionPoint: text.endIndex) @@ -527,6 +534,7 @@ private extension View { keyboardType: UIKeyboardType, submitLabel: SubmitLabel, spellOut: Bool, + contentType: UITextContentType?, isAccessibilityFocused: AccessibilityFocusState.Binding, onAppear: @escaping () -> Void, onSubmit: @escaping () -> Void @@ -536,7 +544,7 @@ private extension View { .disabled(isDisabled) .keyboardType(keyboardType) .submitLabel(submitLabel) - .textContentType(.init(rawValue: "")) + .textContentType(contentType) .autocorrectionDisabled(true) .textInputAutocapitalization(.never) .speechSpellsOutCharacters(spellOut) diff --git a/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift b/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift index d28cfb6f..ca206ce9 100644 --- a/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift @@ -36,6 +36,8 @@ class EncryptRecipientViewModel: EncryptRecipientViewModelProtocol, Loggable { private let sharedContainerViewModel: SharedContainerViewModelProtocol private let openLdap: OpenLdapProtocol + var encryptWithPasswordAction: (URL, [URL], String, String) async throws -> any CryptoContainerProtocol = + CryptoContainer.encryptWithPassword init( sharedContainerViewModel: SharedContainerViewModelProtocol, @@ -129,6 +131,22 @@ class EncryptRecipientViewModel: EncryptRecipientViewModelProtocol, Loggable { } } + func encryptWithPassword(label: String, password: String) async throws { + let cryptoContainer = sharedContainerViewModel.currentContainer() as? any CryptoContainerProtocol + guard let cryptoContainer else { + EncryptRecipientViewModel.logger().error("Cannot encrypt: crypto container is nil") + throw CryptoError.containerCreationFailed(CryptoErrorDetail(message: "Container is nil")) + } + guard let containerFile = await cryptoContainer.getRawContainerFile() else { + EncryptRecipientViewModel.logger().error("Cannot encrypt: container file URL is nil") + throw CryptoError.containerCreationFailed(CryptoErrorDetail(message: "Container file URL is nil")) + } + let dataFiles = await cryptoContainer.getDataFiles() + let encryptedContainer = try await encryptWithPasswordAction(containerFile, dataFiles, label, password) + sharedContainerViewModel.clearContainers() + sharedContainerViewModel.setCryptoContainer(encryptedContainer) + } + func resetErrorMessage() { errorMessage = nil } diff --git a/RIADigiDoc/ViewModel/Protocols/EncryptRecipientViewModelProtocol.swift b/RIADigiDoc/ViewModel/Protocols/EncryptRecipientViewModelProtocol.swift index 93271bb9..caf67374 100644 --- a/RIADigiDoc/ViewModel/Protocols/EncryptRecipientViewModelProtocol.swift +++ b/RIADigiDoc/ViewModel/Protocols/EncryptRecipientViewModelProtocol.swift @@ -30,6 +30,7 @@ public protocol EncryptRecipientViewModelProtocol: Sendable { func loadRecipients() async func getContainerRecipientList() async -> [Addressee] func deleteRecipient(_ recipient: Addressee) async + func encryptWithPassword(label: String, password: String) async throws func resetErrorMessage() func resetSuccessMessage() } diff --git a/RIADigiDocTests/TestPlans/AllTests.xctestplan b/RIADigiDocTests/TestPlans/AllTests.xctestplan index 34e2c4a1..e3e01fed 100644 --- a/RIADigiDocTests/TestPlans/AllTests.xctestplan +++ b/RIADigiDocTests/TestPlans/AllTests.xctestplan @@ -26,6 +26,13 @@ "name" : "SmartIdLibTests" } }, + { + "target" : { + "containerPath" : "container:Modules\/CryptoLib", + "identifier" : "CryptoSwiftTests", + "name" : "CryptoSwiftTests" + } + }, { "target" : { "containerPath" : "container:RIADigiDoc.xcodeproj", diff --git a/RIADigiDocTests/ViewModel/EncryptRecipientViewModelTests.swift b/RIADigiDocTests/ViewModel/EncryptRecipientViewModelTests.swift new file mode 100644 index 00000000..8843c09e --- /dev/null +++ b/RIADigiDocTests/ViewModel/EncryptRecipientViewModelTests.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 CommonsLib +import CryptoObjCWrapper +import CryptoSwift +import Foundation +import Testing + + +@MainActor +struct EncryptRecipientViewModelTests { + + private let mockSharedContainerViewModel: SharedContainerViewModelProtocolMock + private let mockOpenLdap: OpenLdapProtocolMock + private let viewModel: EncryptRecipientViewModel + + init() { + mockSharedContainerViewModel = SharedContainerViewModelProtocolMock() + mockOpenLdap = OpenLdapProtocolMock() + viewModel = EncryptRecipientViewModel( + sharedContainerViewModel: mockSharedContainerViewModel, + openLdap: mockOpenLdap + ) + } + + @Test + func encryptWithPassword_successClearsAndSetsNewContainer() async throws { + let mockContainer = CryptoContainerProtocolMock() + let containerFile = URL(fileURLWithPath: "/tmp/test.cdoc") + let dataFile = URL(fileURLWithPath: "/tmp/doc.pdf") + mockSharedContainerViewModel.currentContainerHandler = { mockContainer } + mockContainer.getRawContainerFileHandler = { containerFile } + mockContainer.getDataFilesHandler = { [dataFile] } + + let resultContainer = CryptoContainerProtocolMock() + viewModel.encryptWithPasswordAction = { _, _, _, _ in resultContainer } + + try await viewModel.encryptWithPassword(label: "testKey", password: "Abcdefgh1234567890123") + + #expect(mockSharedContainerViewModel.clearContainersCallCount == 1) + #expect(mockSharedContainerViewModel.setCryptoContainerCallCount == 1) + } + + @Test + func encryptWithPassword_throwsWhenContainerIsNil() async { + mockSharedContainerViewModel.currentContainerHandler = { nil } + + await #expect(throws: (any Error).self) { + try await viewModel.encryptWithPassword(label: "testKey", password: "Abcdefgh1234567890123") + } + } + + @Test + func encryptWithPassword_throwsWhenContainerFileIsNil() async { + let mockContainer = CryptoContainerProtocolMock() + mockSharedContainerViewModel.currentContainerHandler = { mockContainer } + mockContainer.getRawContainerFileHandler = { nil } + + await #expect(throws: (any Error).self) { + try await viewModel.encryptWithPassword(label: "testKey", password: "Abcdefgh1234567890123") + } + } + +} diff --git a/scripts/generate-mocks.sh b/scripts/generate-mocks.sh index e8d51658..ffe49bb2 100755 --- a/scripts/generate-mocks.sh +++ b/scripts/generate-mocks.sh @@ -107,6 +107,11 @@ main_output_file="${main_output_dir}/${main_module_name}+Mocks.swift" mkdir -p "$main_output_dir" echo "\n\nGenerating mocks for $main_module_name...\n" -mockolo -s "$main_src_dir" -d "$main_output_file" --enable-args-history +mockolo \ + -s "$main_src_dir" \ + -s "Modules/CryptoLib/Sources/CryptoSwift" \ + -d "$main_output_file" \ + --custom-imports "CryptoSwift" "CryptoObjCWrapper" \ + --enable-args-history echo "\n\nDone\n\n"