From f16984e742e00364f68a9461864bdc396aeac667 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Wed, 4 Nov 2026 18:49:46 +0200 Subject: [PATCH] Add support for containers with multiple recipients and passwords --- .../Sources/CryptoObjC/include/Decrypt.h | 2 +- .../Sources/CryptoObjC/include/Decrypt.mm | 63 ++- .../CryptoObjCWrapper/Domain/Addressee.swift | 10 +- .../Sources/CryptoSwift/CryptoContainer.swift | 7 +- .../CryptoSwiftTests/AddresseeTests.swift | 32 ++ RIADigiDoc/Info.plist | 70 ++-- .../Supporting files/Localizable.xcstrings | 36 ++ .../RecipientBottomSheetActions.swift | 9 + .../Container/CertificateDetailView.swift | 363 ++++++------------ .../Container/Crypto/EncryptView.swift | 69 +++- .../Modal/DecryptKeyLabelModalView.swift | 77 ++++ .../Crypto/RecipientDetailView.swift | 1 + .../Container/Crypto/RecipientView.swift | 17 +- .../Container/Crypto/RecipientsListView.swift | 10 + RIADigiDoc/ViewModel/EncryptViewModel.swift | 29 ++ .../ViewModel/EncryptViewModelTests.swift | 112 ++++++ 16 files changed, 586 insertions(+), 321 deletions(-) create mode 100644 RIADigiDoc/UI/Component/Container/Crypto/Modal/DecryptKeyLabelModalView.swift diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.h b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.h index 977703cc..e1aa4201 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.h +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.h @@ -18,7 +18,7 @@ NS_ASSUME_NONNULL_BEGIN + (nullable id)cdocInfo:(NSString *)fullPath error:(NSError **)error; + (void)decryptFile:(NSString *)fullPath withCert:(NSData *)certData withToken:(id)smartToken completion:(void (^)(NSDictionary * _Nullable, NSError * _Nullable))completion; -+ (NSDictionary * _Nullable)decryptFile:(NSString *)fullPath withPassword:(NSString*)password error:(NSError**)error; ++ (NSDictionary * _Nullable)decryptFile:(NSString *)fullPath withPassword:(NSString*)password lockIndex:(NSInteger)lockIndex error:(NSError**)error NS_SWIFT_NAME(decryptFile(_:withPassword:lockIndex:)); @end NS_ASSUME_NONNULL_END diff --git a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm index c1bd6995..7ec287f9 100644 --- a/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm +++ b/Modules/CryptoLib/Sources/CryptoObjC/include/Decrypt.mm @@ -24,7 +24,7 @@ static CertType certTypeFromLabel(NSString * _Nullable type) { @implementation Addressee (label) -- (instancetype)initWithLabel:(const std::string &)label pub:(NSData*)pub concatKDFAlgorithmURI:(NSString *)concatKDFAlgorithmURI { +- (instancetype)initWithLabel:(const std::string &)label pub:(NSData*)pub concatKDFAlgorithmURI:(NSString *)concatKDFAlgorithmURI lockIndex:(NSInteger)lockIndex { std::map info = libcdoc::Lock::parseLabel(label); NSString *cn = info.contains("cn") ? [NSString stringWithStdString:info["cn"]] : [NSString stringWithStdString:label]; NSString *type = info.contains("type") ? [NSString stringWithStdString:info["type"]] : nil; @@ -46,7 +46,7 @@ - (instancetype)initWithLabel:(const std::string &)label pub:(NSData*)pub concat } } - if (self = [self initWithCnVal:cn serialNumber:serial certType:certTypeFromLabel(type) validTo:validTo data:pub concatKDFAlgorithmURI:concatKDFAlgorithmURI lockLabel:@"" lockType:@""]) { + if (self = [self initWithCnVal:cn serialNumber:serial certType:certTypeFromLabel(type) validTo:validTo data:pub concatKDFAlgorithmURI:concatKDFAlgorithmURI lockLabel:([NSString stringWithStdString:label] ?: @"") lockType:@"" lockIndex:lockIndex]) { } return self; } @@ -105,17 +105,19 @@ + (CdocInfo*)cdocInfo:(NSString *)fullPath error:(NSError**)error { return [NSError cryptoError:@"Parsing failed" error:error]; } NSMutableArray *addressees = [[NSMutableArray alloc] init]; - for(const libcdoc::Lock &lock: reader->getLocks()) + const auto &locks = reader->getLocks(); + for(NSInteger lockIndex = 0; lockIndex < (NSInteger)locks.size(); lockIndex++) { + const libcdoc::Lock &lock = locks[lockIndex]; if(lock.isCDoc1()) { NSString* concatKDFAlgorithmURI = @""; if (!lock.isRSA()) { concatKDFAlgorithmURI = [NSString stringWithStdString:lock.getString(libcdoc::Lock::CONCAT_DIGEST)]; } - [addressees addObject:[[Addressee alloc] initWithLabel:lock.label pub:[NSData dataFromVector:lock.getBytes(libcdoc::Lock::CERT)] concatKDFAlgorithmURI:concatKDFAlgorithmURI]]; + [addressees addObject:[[Addressee alloc] initWithLabel:lock.label pub:[NSData dataFromVector:lock.getBytes(libcdoc::Lock::CERT)] concatKDFAlgorithmURI:concatKDFAlgorithmURI lockIndex:lockIndex]]; } else if(lock.isPKI()) { - [addressees addObject:[[Addressee alloc] initWithLabel:lock.label pub:[NSData dataFromVector:lock.getBytes(libcdoc::Lock::RCPT_KEY)] concatKDFAlgorithmURI:@""]]; - } else if(lock.isSymmetric()) { + [addressees addObject:[[Addressee alloc] initWithLabel:lock.label pub:[NSData dataFromVector:lock.getBytes(libcdoc::Lock::RCPT_KEY)] concatKDFAlgorithmURI:@"" lockIndex:lockIndex]]; + } else if(lock.type == libcdoc::Lock::Type::PASSWORD) { std::map info = libcdoc::Lock::parseLabel(lock.label); NSString *cnVal = info.contains("label") ? [NSString stringWithStdString:info["label"]] @@ -129,9 +131,30 @@ + (CdocInfo*)cdocInfo:(NSString *)fullPath error:(NSError**)error { data:[NSData data] concatKDFAlgorithmURI:@"" lockLabel:rawLockLabel - lockType:lockTypeName(lock.type)]]; + lockType:lockTypeName(lock.type) + lockIndex:lockIndex]]; + } else if(lock.isSymmetric()) { + [addressees addObject:[[Addressee alloc] + initWithCnVal:[NSString stringWithStdString:lock.label] ?: @"" + serialNumber:nil + certType:CertTypeUnknownType + validTo:nil + data:[NSData data] + concatKDFAlgorithmURI:@"" + lockLabel:@"" + lockType:@"" + lockIndex:lockIndex]]; } else { - [addressees addObject:[[Addressee alloc] initWithData:[NSData data] cnVal:@"Unknown capsule"]]; + [addressees addObject:[[Addressee alloc] + initWithCnVal:@"Unknown capsule" + serialNumber:nil + certType:CertTypeUnknownType + validTo:nil + data:[NSData data] + concatKDFAlgorithmURI:@"" + lockLabel:@"" + lockType:@"" + lockIndex:lockIndex]]; } } if (cdocInfo != nil) { @@ -199,7 +222,7 @@ + (void)decryptFile:(NSString *)fullPath withCert:(NSData *)certData withToken:( }); } -+ (NSDictionary *)decryptFile:(NSString *)fullPath withPassword:(NSString*)password error:(NSError**)error { ++ (NSDictionary *)decryptFile:(NSString *)fullPath withPassword:(NSString*)password lockIndex:(NSInteger)lockIndex error:(NSError**)error { struct PasswordBackend: public libcdoc::CryptoBackend { NSString *password; PasswordBackend(NSString *pass) : password(pass) {} @@ -213,12 +236,24 @@ + (void)decryptFile:(NSString *)fullPath withCert:(NSData *)certData withToken:( return [NSError cryptoError:@"Failed to create CDocReader" error:error]; } - 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; + BOOL isSelectedLockUsable = lockIndex >= 0 + && lockIndex < (NSInteger)locks.size() + && locks[lockIndex].type == libcdoc::Lock::Type::PASSWORD; + if (lockIndex >= 0 && !isSelectedLockUsable) { + NSLog(@"Lock %ld is not a password lock (%zu lock(s) in container), falling back to the first password lock", + (long)lockIndex, locks.size()); + } + + NSInteger idx = -1; + if (isSelectedLockUsable) { + idx = lockIndex; + } else { + for (size_t i = 0; i < locks.size(); i++) { + if (locks[i].type == libcdoc::Lock::Type::PASSWORD) { + idx = (NSInteger)i; + break; + } } } if (idx < 0) { diff --git a/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/Addressee.swift b/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/Addressee.swift index a5c12963..898c3dd0 100644 --- a/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/Addressee.swift +++ b/Modules/CryptoLib/Sources/CryptoObjCWrapper/Domain/Addressee.swift @@ -16,6 +16,7 @@ import Foundation @objc public var concatKDFAlgorithmURI: String @objc public let lockLabel: String @objc public let lockType: String + @objc public let lockIndex: Int @objc public init( data: Data, @@ -27,7 +28,8 @@ import Foundation validTo: Date?, concatKDFAlgorithmURI: String = "", lockLabel: String = "", - lockType: String = "" + lockType: String = "", + lockIndex: Int = -1 ) { self.identifier = cnVal self.data = data @@ -39,6 +41,7 @@ import Foundation self.concatKDFAlgorithmURI = concatKDFAlgorithmURI self.lockLabel = lockLabel self.lockType = lockType + self.lockIndex = lockIndex } @objc public convenience init(data: Data, cnVal: String) { @@ -61,7 +64,8 @@ import Foundation data: Data, concatKDFAlgorithmURI: String = "", lockLabel: String = "", - lockType: String = "" + lockType: String = "", + lockIndex: Int = -1 ) { let split = cnVal.split(separator: ",").map { String($0) } if split.count >= 3 { @@ -80,6 +84,7 @@ import Foundation self.concatKDFAlgorithmURI = concatKDFAlgorithmURI self.lockLabel = lockLabel self.lockType = lockType + self.lockIndex = lockIndex } public init(cert: Data, x509: X509Certificate?) { @@ -101,6 +106,7 @@ import Foundation concatKDFAlgorithmURI = "" lockLabel = "" lockType = "" + lockIndex = -1 } convenience public init(cert: Data) { diff --git a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift index c90b6c76..f5244e58 100644 --- a/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift +++ b/Modules/CryptoLib/Sources/CryptoSwift/CryptoContainer.swift @@ -402,12 +402,17 @@ extension CryptoContainer { containerFile: URL, recipients: [Addressee], password: String, + lockIndex: Int = -1, fileManager: FileManagerProtocol = Container.shared.fileManager() ) async throws -> CryptoContainerProtocol { let decryptedData: [String: Data] = try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { do { - let result = try Decrypt.decryptFile(containerFile.resolvedPath, withPassword: password) + let result = try Decrypt.decryptFile( + containerFile.resolvedPath, + withPassword: password, + lockIndex: lockIndex + ) continuation.resume(returning: result) } catch let nsError as NSError where nsError.code == libcdocWrongKeyCode { continuation.resume(throwing: CryptoError.wrongDecryptionKey) diff --git a/Modules/CryptoLib/Tests/CryptoSwiftTests/AddresseeTests.swift b/Modules/CryptoLib/Tests/CryptoSwiftTests/AddresseeTests.swift index 4c843c9a..fba12426 100644 --- a/Modules/CryptoLib/Tests/CryptoSwiftTests/AddresseeTests.swift +++ b/Modules/CryptoLib/Tests/CryptoSwiftTests/AddresseeTests.swift @@ -175,4 +175,36 @@ struct AddresseeTests { #expect(addressee.lockLabel == "") #expect(addressee.lockType == "") } + + @Test + func init_lockIndexDefaultsToNoLock() { + let addressee = Addressee( + data: Data(), + cnVal: "USER,TEST,38001010001", + givenName: "TEST", + surname: "USER", + serialNumber: nil, + certType: .iDCardType, + validTo: nil + ) + + #expect(addressee.lockIndex == -1) + } + + @Test + func cnValInit_keepsTheLockIndexItWasBuiltFrom() { + let addressee = Addressee( + cnVal: "myKey", + serialNumber: nil, + certType: .passwordType, + validTo: nil, + data: Data(), + concatKDFAlgorithmURI: "", + lockLabel: "data:v=1&label=myKey&type=pw", + lockType: "PASSWORD", + lockIndex: 2 + ) + + #expect(addressee.lockIndex == 2) + } } diff --git a/RIADigiDoc/Info.plist b/RIADigiDoc/Info.plist index 3acbafb5..f28f5fb2 100644 --- a/RIADigiDoc/Info.plist +++ b/RIADigiDoc/Info.plist @@ -12,7 +12,7 @@ CFBundleTypeIconSystemGenerated 0 CFBundleTypeName - DigiDoc Container asice + DigiDoc Container ASiC-E CFBundleTypeRole Editor LSHandlerRank @@ -28,7 +28,7 @@ CFBundleTypeIconSystemGenerated 0 CFBundleTypeName - DigiDoc file bdoc + DigiDoc Container BDOC CFBundleTypeRole Editor LSHandlerRank @@ -44,7 +44,7 @@ CFBundleTypeIconSystemGenerated 0 CFBundleTypeName - DigiDoc file edoc + DigiDoc Container EDOC CFBundleTypeRole Editor LSHandlerRank @@ -60,7 +60,7 @@ CFBundleTypeIconSystemGenerated 0 CFBundleTypeName - DigiDoc file sce + DigiDoc Container SCE CFBundleTypeRole Editor LSHandlerRank @@ -76,7 +76,7 @@ CFBundleTypeIconSystemGenerated 0 CFBundleTypeName - DigiDoc file cdoc + DigiDoc Container CDOC CFBundleTypeRole Editor LSHandlerRank @@ -86,13 +86,25 @@ ee.ria.digidoc-cdoc + + CFBundleTypeName + DigiDoc Container CDOC2 + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + ee.ria.digidoc-cdoc2 + + CFBundleTypeIconFile digidoc_icon CFBundleTypeIconSystemGenerated 0 CFBundleTypeName - DigiDoc file adoc + DigiDoc Container ADOC CFBundleTypeRole Editor LSHandlerRank @@ -108,7 +120,7 @@ CFBundleTypeIconSystemGenerated 0 CFBundleTypeName - DigiDoc file ddoc + DigiDoc Container DDOC CFBundleTypeRole Editor LSHandlerRank @@ -124,7 +136,7 @@ CFBundleTypeIconSystemGenerated 0 CFBundleTypeName - DigiDoc file asics + DigiDoc Container ASiC-S CFBundleTypeRole Editor LSHandlerRank @@ -140,7 +152,7 @@ CFBundleTypeIconSystemGenerated 0 CFBundleTypeName - DigiDoc file scs + DigiDoc Container SCS CFBundleTypeRole Editor LSHandlerRank @@ -260,7 +272,7 @@ public.item UTTypeDescription - DigiDoc Container asice + DigiDoc Container ASiC-E UTTypeIconFile digidoc_icon UTTypeIcons @@ -292,7 +304,7 @@ public.item UTTypeDescription - DigiDoc Container bdoc + DigiDoc Container BDOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -325,7 +337,7 @@ public.item UTTypeDescription - DigiDoc Container edoc + DigiDoc Container EDOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -357,7 +369,7 @@ public.item UTTypeDescription - DigiDoc Container sce + DigiDoc Container SCE UTTypeIconFile digidoc_icon UTTypeIcons @@ -389,7 +401,7 @@ public.item UTTypeDescription - DigiDoc Container cdoc + DigiDoc Container CDOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -421,7 +433,7 @@ public.item UTTypeDescription - DigiDoc Container cdoc2 + DigiDoc Container CDOC2 UTTypeIconFile digidoc_icon UTTypeIcons @@ -453,7 +465,7 @@ public.item UTTypeDescription - DigiDoc Container adoc + DigiDoc Container ADOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -485,7 +497,7 @@ public.item UTTypeDescription - DigiDoc Container ddoc + DigiDoc Container DDOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -516,7 +528,7 @@ public.item UTTypeDescription - DigiDoc Container asics + DigiDoc Container ASiC-S UTTypeIconFile digidoc_icon UTTypeIcons @@ -548,7 +560,7 @@ public.item UTTypeDescription - DigiDoc Container scs + DigiDoc Container SCS UTTypeIconFile digidoc_icon UTTypeIcons @@ -651,7 +663,7 @@ public.item UTTypeDescription - DigiDoc Container asice + DigiDoc Container ASiC-E UTTypeIconFile digidoc_icon UTTypeIcons @@ -684,7 +696,7 @@ public.item UTTypeDescription - DigiDoc Container bdoc + DigiDoc Container BDOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -718,7 +730,7 @@ public.item UTTypeDescription - Digidoc Container cdoc + DigiDoc Container CDOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -750,7 +762,7 @@ public.item UTTypeDescription - Digidoc Container cdoc2 + DigiDoc Container CDOC2 UTTypeIconFile digidoc_icon UTTypeIcons @@ -782,7 +794,7 @@ public.item UTTypeDescription - Digidoc Container adoc + DigiDoc Container ADOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -815,7 +827,7 @@ public.item UTTypeDescription - Digidoc Container ddoc + DigiDoc Container DDOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -847,7 +859,7 @@ public.item UTTypeDescription - Digidoc Container edoc + DigiDoc Container EDOC UTTypeIconFile digidoc_icon UTTypeIcons @@ -880,7 +892,7 @@ public.item UTTypeDescription - Digidoc Container sce + DigiDoc Container SCE UTTypeIconFile digidoc_icon UTTypeIcons @@ -913,7 +925,7 @@ public.item UTTypeDescription - Digidoc Container asics + DigiDoc Container ASiC-S UTTypeIconFile digidoc_icon UTTypeIcons @@ -946,7 +958,7 @@ public.item UTTypeDescription - Digidoc Container scs + DigiDoc Container SCS UTTypeIconFile digidoc_icon UTTypeIcons diff --git a/RIADigiDoc/Supporting files/Localizable.xcstrings b/RIADigiDoc/Supporting files/Localizable.xcstrings index 78b66871..ae458573 100644 --- a/RIADigiDoc/Supporting files/Localizable.xcstrings +++ b/RIADigiDoc/Supporting files/Localizable.xcstrings @@ -864,6 +864,24 @@ } } }, + "Choose key label" : { + "comment" : "Title of the modal that picks which password lock to decrypt with", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose key label" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vali võtme silt" + } + } + } + }, "Citizenship" : { "comment" : "My eID public data", "extractionState" : "manual", @@ -1907,6 +1925,24 @@ } } }, + "Decrypt with ID-card" : { + "comment" : "Decrypt button label when the container also holds password locks", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Decrypt with ID-card" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dekrüpteeri ID-kaardiga" + } + } + } + }, "Decrypt wrong password error" : { "comment" : "CryptoContainer password decrypt — wrong password error", "extractionState" : "manual", diff --git a/RIADigiDoc/UI/Component/Bottom Sheet/RecipientBottomSheetActions.swift b/RIADigiDoc/UI/Component/Bottom Sheet/RecipientBottomSheetActions.swift index 5c55e74e..e4091455 100644 --- a/RIADigiDoc/UI/Component/Bottom Sheet/RecipientBottomSheetActions.swift +++ b/RIADigiDoc/UI/Component/Bottom Sheet/RecipientBottomSheetActions.swift @@ -3,11 +3,20 @@ struct RecipientBottomSheetActions { static func actions( + decryptAccessibilityLabel: String? = nil, showRemoveRecipientButton: Bool, + onDecryptButtonClick: @escaping () -> Void = {}, onDetailsButtonClick: @escaping () -> Void, onRemoveRecipientButtonClick: @escaping () -> Void, ) -> [BottomSheetButton] { [ + BottomSheetButton( + showButton: decryptAccessibilityLabel != nil, + icon: "ic_m3_encrypted_off_48pt_wght400", + title: "Decrypt", + accessibilityLabel: decryptAccessibilityLabel ?? "", + onClick: onDecryptButtonClick + ), BottomSheetButton( icon: "ic_m3_edit_48pt_wght400", title: "Recipient details", diff --git a/RIADigiDoc/UI/Component/Container/CertificateDetailView.swift b/RIADigiDoc/UI/Component/Container/CertificateDetailView.swift index 7807ae96..6a6a922d 100644 --- a/RIADigiDoc/UI/Component/Container/CertificateDetailView.swift +++ b/RIADigiDoc/UI/Component/Container/CertificateDetailView.swift @@ -144,6 +144,48 @@ struct CertificateDetailView: View { self.certificate = certificate } + private var subjectNameItems: [SignatureDataItem] { + [ + SignatureDataItem(title: "Country or Region", value: subjectCountryName), + SignatureDataItem(title: "Organization", value: subjectOrganizationName), + SignatureDataItem(title: "Organizational Unit", value: subjectOrganizationalUnitName), + SignatureDataItem(title: "Common Name", value: subjectCommonName), + SignatureDataItem(title: "Surname", value: subjectSurname), + SignatureDataItem(title: "Given Name", value: subjectGivenName), + SignatureDataItem(title: "Serial number", value: subjectSerialNumber) + ] + } + + private var issuerNameItems: [SignatureDataItem] { + [ + SignatureDataItem(title: "Country or Region", value: issuerCountryName), + SignatureDataItem(title: "Organization", value: issuerOrganizationName), + SignatureDataItem(title: "Common Name", value: issuerCommonName), + SignatureDataItem(title: "Other name", value: issuerOtherName), + SignatureDataItem(title: "Serial Number", value: issuerSerialNumber), + SignatureDataItem(title: "Version", value: version), + SignatureDataItem(title: "Signature Algorithm", value: signatureAlgorithm), + SignatureDataItem(title: "Not Valid Before", value: notValidBefore), + SignatureDataItem(title: "Not Valid After", value: notValidAfter) + ] + } + + private var publicKeyItems: [SignatureDataItem] { + [ + SignatureDataItem(title: "Algorithm", value: publicKeyAlgorithm), + SignatureDataItem(title: "Public Key", value: publicKey), + SignatureDataItem(title: "Key Usage", value: keyUsage), + SignatureDataItem(title: "Signature", value: signature) + ] + } + + private var fingerprintItems: [SignatureDataItem] { + [ + SignatureDataItem(title: "SHA-256", value: sha256Fingerprint), + SignatureDataItem(title: "SHA-1", value: sha1Fingerprint) + ] + } + var body: some View { TopBarContainer( title: languageSettings.localized("Certificate details"), @@ -151,266 +193,83 @@ struct CertificateDetailView: View { content: { ScrollView { LazyVStack(alignment: .leading) { - Text(verbatim: "Subject Name") - .foregroundStyle(theme.onSurface) - .font(typography.titleLarge.bold()) - .padding(.vertical, Dimensions.Padding.XSPadding) - .padding(.top, Dimensions.Padding.XSPadding) - .accessibilityHeading(.h1) - .accessibilityAddTraits([.isHeader]) - - if !subjectCountryName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Country or Region", - value: subjectCountryName - ) - ) - } - - if !subjectOrganizationName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Organization", - value: subjectOrganizationName - ) - ) - } - - if !subjectOrganizationalUnitName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Organizational Unit", - value: subjectOrganizationalUnitName - ) - ) - } - - if !subjectCommonName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Common Name", - value: subjectCommonName - ) - ) - } - - if !subjectSurname.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Surname", - value: subjectSurname - ) - ) - } - - if !subjectGivenName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Given Name", - value: subjectGivenName - ) - ) - } - - if !subjectSerialNumber.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Serial number", - value: subjectSerialNumber - ) - ) - } - - Text(verbatim: "Issuer Name") - .foregroundStyle(theme.onSurface) - .font(typography.titleLarge.bold()) - .padding(.vertical, Dimensions.Padding.XSPadding) - .padding(.top, Dimensions.Padding.MPadding) - .accessibilityHeading(.h1) - .accessibilityAddTraits([.isHeader]) - - if !issuerCountryName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Country or Region", - value: issuerCountryName - ) - ) - } - - if !issuerOrganizationName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Organization", - value: issuerOrganizationName - ) - ) - } - - if !issuerCommonName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Common Name", - value: issuerCommonName - ) - ) - } - - if !issuerOtherName.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Other name", - value: issuerOtherName - ) - ) - } - - if !issuerSerialNumber.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Serial Number", - value: issuerSerialNumber - ) - ) - } - - if !version.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Version", - value: version - ) - ) - } - - if !signatureAlgorithm.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Signature Algorithm", - value: signatureAlgorithm - ) - ) - } - - if !notValidBefore.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Not Valid Before", - value: notValidBefore - ) - ) - } - - if !notValidAfter.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Not Valid After", - value: notValidAfter - ) - ) - } - - Text(verbatim: "Public Key") - .foregroundStyle(theme.onSurface) - .font(typography.titleLarge.bold()) - .padding(.vertical, Dimensions.Padding.XSPadding) - .padding(.top, Dimensions.Padding.MPadding) - .accessibilityHeading(.h1) - .accessibilityAddTraits([.isHeader]) - - if !publicKeyAlgorithm.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Algorithm", - value: publicKeyAlgorithm - ) - ) - } - - if !publicKey.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Public Key", - value: publicKey - ) - ) - } - - if !keyUsage.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Key Usage", - value: keyUsage - ) - ) - } + detailSection( + "Subject Name", + topPadding: Dimensions.Padding.XSPadding, + items: subjectNameItems + ) + detailSection( + "Issuer Name", + topPadding: Dimensions.Padding.MPadding, + items: issuerNameItems + ) + detailSection( + "Public Key", + topPadding: Dimensions.Padding.MPadding, + items: publicKeyItems + ) + extensionsSection + detailSection( + "Fingerprints", + topPadding: Dimensions.Padding.MPadding, + items: fingerprintItems + ) + } + .padding(Dimensions.Padding.SPadding) + } + }) + } - if !signature.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "Signature", - value: signature - ) - ) - } + @ViewBuilder + private func detailSection( + _ title: String, + topPadding: CGFloat, + items: [SignatureDataItem] + ) -> some View { + let populatedItems = items.filter { !$0.value.isEmpty } + if !populatedItems.isEmpty { + sectionHeader(title, topPadding: topPadding) + ForEach(populatedItems, id: \.title) { item in + SignerDetailView(signatureDataItem: item) + } + } + } - Text(verbatim: "Extensions") + @ViewBuilder + private var extensionsSection: some View { + if !extensions.isEmpty { + sectionHeader("Extensions", topPadding: Dimensions.Padding.MPadding, heading: .h2) + ForEach(extensions, id: \.id) { extensionItem in + Group { + Text(verbatim: "Extension") .foregroundStyle(theme.onSurface) - .font(typography.titleLarge.bold()) - .padding(.vertical, Dimensions.Padding.XSPadding) - .padding(.top, Dimensions.Padding.MPadding) - .accessibilityHeading(.h2) - .accessibilityAddTraits([.isHeader]) - - if !extensions.isEmpty { - ForEach(extensions, id: \.id) { extensionItem in - Group { - Text(verbatim: "Extension") - .foregroundStyle(theme.onSurface) - .font(typography.bodyLarge.bold()) - - Text(verbatim: "\(extensionItem.name) (\(extensionItem.oid))") - - VStack(alignment: .leading, spacing: Dimensions.Padding.XXSPadding) { - Text(verbatim: "Critical: \(extensionItem.critical ? "Yes" : "No")") - Text(verbatim: "ID: \(extensionItem.values)") - } - .padding(.leading, Dimensions.Padding.SPadding) - } - .padding(Dimensions.Padding.XSPadding) - } - } + .font(typography.bodyLarge.bold()) - Text(verbatim: "Fingerprints") - .foregroundStyle(theme.onSurface) - .font(typography.titleLarge.bold()) - .padding(.vertical, Dimensions.Padding.XSPadding) - .padding(.top, Dimensions.Padding.MPadding) - .accessibilityHeading(.h1) - .accessibilityAddTraits([.isHeader]) - - if !sha256Fingerprint.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "SHA-256", - value: sha256Fingerprint - ) - ) - } + Text(verbatim: "\(extensionItem.name) (\(extensionItem.oid))") - if !sha1Fingerprint.isEmpty { - SignerDetailView( - signatureDataItem: SignatureDataItem( - title: "SHA-1", - value: sha1Fingerprint - ) - ) + VStack(alignment: .leading, spacing: Dimensions.Padding.XXSPadding) { + Text(verbatim: "Critical: \(extensionItem.critical ? "Yes" : "No")") + Text(verbatim: "ID: \(extensionItem.values)") } + .padding(.leading, Dimensions.Padding.SPadding) } - .padding(Dimensions.Padding.SPadding) + .padding(Dimensions.Padding.XSPadding) } - }) + } + } + + private func sectionHeader( + _ title: String, + topPadding: CGFloat, + heading: AccessibilityHeadingLevel = .h1 + ) -> some View { + Text(verbatim: title) + .foregroundStyle(theme.onSurface) + .font(typography.titleLarge.bold()) + .padding(.vertical, Dimensions.Padding.XSPadding) + .padding(.top, topPadding) + .accessibilityHeading(heading) + .accessibilityAddTraits([.isHeader]) } } diff --git a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift index 977fa929..004f2635 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/EncryptView.swift @@ -24,8 +24,8 @@ struct EncryptView: View { @State private var selectedTab: EncryptViewTab = .files @State private var selectedRecipient: Addressee? @State private var cdocOption: EncryptionCdocOption - @State private var showDecryptPasswordModal = false - @State private var passwordDecryptKeyLabel = "" + @State private var passwordDecryptTarget: Addressee? + @State private var keyLabelChoices: [Addressee] = [] @State private var viewModel: EncryptViewModel @@ -65,7 +65,9 @@ struct EncryptView: View { cryptoContainer: viewModel.cryptoContainer, isNestedContainer: isNestedContainer ) { - return languageSettings.localized("Decrypt") + return languageSettings.localized( + viewModel.hasBothDecryptionMethods ? "Decrypt with ID-card" : "Decrypt" + ) } else if await viewModel.isEncryptButtonShown( cryptoContainer: viewModel.cryptoContainer, isNestedContainer: isNestedContainer @@ -81,7 +83,9 @@ struct EncryptView: View { cryptoContainer: viewModel.cryptoContainer, isNestedContainer: isNestedContainer ) { - return languageSettings.localized("Decrypt container") + return languageSettings.localized( + viewModel.hasBothDecryptionMethods ? "Decrypt with ID-card" : "Decrypt container" + ) } else if await viewModel.isEncryptButtonShown( cryptoContainer: viewModel.cryptoContainer, isNestedContainer: isNestedContainer @@ -231,16 +235,7 @@ struct EncryptView: View { } } } else if viewModel.isDecryptButtonShown { - if let passwordRecipient = viewModel.recipients.first( - where: { $0.certType == .passwordType } - ) { - passwordDecryptKeyLabel = passwordRecipient.identifier - showDecryptPasswordModal = true - } else { - isWithEncryption = false - isWithDecryption = false - pathManager.navigate(to: .decryptRootView) - } + handleDecryptAction() } }, onSaveContainerButtonClick: { @@ -356,9 +351,14 @@ struct EncryptView: View { recipients: viewModel.recipients, selectedRecipient: $selectedRecipient, showRemoveRecipientButton: viewModel.isRecipientRemoveButtonShown(), + showDecryptRecipientButton: viewModel.hasBothDecryptionMethods && + viewModel.isDecryptButtonShown, isCDOC2Container: viewModel.isContainerCDOC2, isEncryptedOrDecrypted: viewModel.isContainerEncryptedOrDecrypted, showRemoveRecipientModal: $showRemoveRecipientModal, + onDecryptRecipient: { recipient in + passwordDecryptTarget = recipient + }, nameUtil: nameUtil, recipientUtil: recipientUtil ) @@ -473,14 +473,27 @@ struct EncryptView: View { } ) - if showDecryptPasswordModal { + if !keyLabelChoices.isEmpty { + DecryptKeyLabelModalView( + recipients: keyLabelChoices, + onConfirm: { recipient in + keyLabelChoices = [] + passwordDecryptTarget = recipient + }, + onCancel: { + keyLabelChoices = [] + } + ) + } + + if let passwordDecryptTarget { DecryptPasswordModalView( - keyLabel: passwordDecryptKeyLabel, + keyLabel: passwordDecryptTarget.identifier, onDecrypt: { password in - Task { await handlePasswordDecrypt(password) } + Task { await handlePasswordDecrypt(password, for: passwordDecryptTarget) } }, onCancel: { - showDecryptPasswordModal = false + self.passwordDecryptTarget = nil } ) } @@ -564,7 +577,20 @@ struct EncryptView: View { } } - private func handlePasswordDecrypt(_ password: String) async { + private func handleDecryptAction() { + switch viewModel.decryptRoute() { + case .idCard: + isWithEncryption = false + isWithDecryption = false + pathManager.navigate(to: .decryptRootView) + case .chooseKeyLabel(let recipients): + keyLabelChoices = recipients + case .password(let recipient): + passwordDecryptTarget = recipient + } + } + + private func handlePasswordDecrypt(_ password: String, for recipient: Addressee) async { guard let containerFile = viewModel.containerURL else { showMessage("Decrypt general error") return @@ -573,13 +599,14 @@ struct EncryptView: View { let decryptedContainer = try await CryptoContainer.decryptWithPassword( containerFile: containerFile, recipients: viewModel.recipients, - password: password + password: password, + lockIndex: recipient.lockIndex ) let sharedVM = Container.shared.sharedContainerViewModel() sharedVM.removeLastContainer() sharedVM.setCryptoContainer(decryptedContainer) await viewModel.loadContainerData(cryptoContainer: decryptedContainer) - showDecryptPasswordModal = false + passwordDecryptTarget = nil selectedTab = .files await updateAsyncLabels() await viewModel.updateAsyncProperties() diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Modal/DecryptKeyLabelModalView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Modal/DecryptKeyLabelModalView.swift new file mode 100644 index 00000000..cb65be49 --- /dev/null +++ b/RIADigiDoc/UI/Component/Container/Crypto/Modal/DecryptKeyLabelModalView.swift @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import SwiftUI +import FactoryKit +import CryptoObjCWrapper + +struct DecryptKeyLabelModalView: View { + @Environment(LanguageSettings.self) private var languageSettings + + let recipients: [Addressee] + let onConfirm: (Addressee) -> Void + let onCancel: () -> Void + + @State private var selectedIndex: Int = 0 + + private func accessibilityLabel(keyLabel: String, isSelected: Bool) -> String { + let state = isSelected + ? languageSettings.localized("Radiobutton selected") + : languageSettings.localized("Radiobutton unselected") + return "\(keyLabel) \(state)" + } + + var body: some View { + ZStack { + Color.black + .opacity(Dimensions.Shadow.LOpacity) + .ignoresSafeArea() + .accessibilityHidden(true) + .allowsHitTesting(true) + + ModalContainer( + title: languageSettings.localized("Choose key label"), + confirmButtonTitle: "Choose button", + onConfirm: { + guard recipients.indices.contains(selectedIndex) else { return } + onConfirm(recipients[selectedIndex]) + }, + onCancel: onCancel, + content: { + VStack(spacing: Dimensions.Padding.ZeroPadding) { + ForEach(Array(recipients.enumerated()), id: \.offset) { index, recipient in + RadioButtonRow( + title: recipient.identifier, + isSelected: index == selectedIndex, + onTap: { selectedIndex = index }, + accessibilityLabel: accessibilityLabel( + keyLabel: recipient.identifier, + isSelected: index == selectedIndex + ), + accessibilityInputLabel: recipient.identifier + ) + .accessibilityIdentifier("decryptKeyLabelOption\(index)") + Divider() + } + } + } + ) + .accessibilityAddTraits(.isModal) + .accessibilityElement(children: .contain) + } + .accessibilityAddTraits(.isModal) + } +} + +#Preview { + DecryptKeyLabelModalView( + recipients: [ + Addressee(data: Data(), cnVal: "Allkirjastamata lepingud 2026"), + Addressee(data: Data(), cnVal: "Raamatupidamine") + ], + onConfirm: { _ in }, + onCancel: {} + ) + .environment(Container.shared.languageSettings()) + .environment(Container.shared.themeSettings()) +} diff --git a/RIADigiDoc/UI/Component/Container/Crypto/RecipientDetailView.swift b/RIADigiDoc/UI/Component/Container/Crypto/RecipientDetailView.swift index b8ed24e1..0fcc4233 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/RecipientDetailView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/RecipientDetailView.swift @@ -87,6 +87,7 @@ struct RecipientDetailView: View { @ViewBuilder private var certificateRecipientDetails: some View { + detailRow("Recipient", value: recipient.lockLabel) detailRow("Recipient certificate issuer", value: viewModel.getIssuerName(cert: recipient.data)) if !nameText.isEmpty { NavigationLink(value: NavigationDestination.certificateDetailView(certificate: recipient.data)) { diff --git a/RIADigiDoc/UI/Component/Container/Crypto/RecipientView.swift b/RIADigiDoc/UI/Component/Container/Crypto/RecipientView.swift index e61c0cc4..a23ee683 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/RecipientView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/RecipientView.swift @@ -24,10 +24,12 @@ struct RecipientView: View { let recipientUtil: RecipientUtilProtocol let showMoreOptionsButton: Bool var showRemoveRecipientButton: Bool + var showDecryptRecipientButton: Bool let isCDOC2Container: Bool let isEncryptedOrDecrypted: Bool @Binding var showRemoveRecipientModal: Bool var onSelect: (() -> Void)? + var onDecryptRecipient: (() -> Void)? @State private var showDetail = false @State private var showBottomSheetFromButton = false @@ -39,9 +41,18 @@ struct RecipientView: View { "\(recipientIndex), \(languageSettings.localized("More options"))" } + private var decryptAccessibilityLabel: String? { + guard showDecryptRecipientButton, isPasswordRecipient else { return nil } + return "\(languageSettings.localized("Decrypt container")) \(recipient.identifier)" + } + private var bottomSheetActions: [BottomSheetButton] { RecipientBottomSheetActions.actions( + decryptAccessibilityLabel: decryptAccessibilityLabel, showRemoveRecipientButton: showRemoveRecipientButton, + onDecryptButtonClick: { + onDecryptRecipient?() + }, onDetailsButtonClick: { pathManager .navigate( @@ -110,10 +121,12 @@ struct RecipientView: View { recipientUtil: RecipientUtilProtocol = Container.shared.recipientUtil(), showMoreOptionsButton: Bool = true, showRemoveRecipientButton: Bool = true, + showDecryptRecipientButton: Bool = false, isCDOC2Container: Bool = false, isEncryptedOrDecrypted: Bool = false, showRemoveRecipientModal: Binding, - onSelect: (() -> Void)? = nil + onSelect: (() -> Void)? = nil, + onDecryptRecipient: (() -> Void)? = nil ) { self.recipientIndex = recipientIndex self.recipient = recipient @@ -121,10 +134,12 @@ struct RecipientView: View { self.recipientUtil = recipientUtil self.showMoreOptionsButton = showMoreOptionsButton self.showRemoveRecipientButton = showRemoveRecipientButton + self.showDecryptRecipientButton = showDecryptRecipientButton self.isCDOC2Container = isCDOC2Container self.isEncryptedOrDecrypted = isEncryptedOrDecrypted self._showRemoveRecipientModal = showRemoveRecipientModal self.onSelect = onSelect + self.onDecryptRecipient = onDecryptRecipient } var body: some View { diff --git a/RIADigiDoc/UI/Component/Container/Crypto/RecipientsListView.swift b/RIADigiDoc/UI/Component/Container/Crypto/RecipientsListView.swift index 39eeab89..758cbf75 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/RecipientsListView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/RecipientsListView.swift @@ -13,9 +13,11 @@ struct RecipientsListView: View { let recipients: [Addressee] @Binding var selectedRecipient: Addressee? var showRemoveRecipientButton: Bool + var showDecryptRecipientButton: Bool = false var isCDOC2Container: Bool = false var isEncryptedOrDecrypted: Bool = false @Binding var showRemoveRecipientModal: Bool + var onDecryptRecipient: ((Addressee) -> Void)? let nameUtil: NameUtilProtocol let recipientUtil: RecipientUtilProtocol @@ -30,11 +32,15 @@ struct RecipientsListView: View { nameUtil: nameUtil, recipientUtil: recipientUtil, showRemoveRecipientButton: showRemoveRecipientButton, + showDecryptRecipientButton: showDecryptRecipientButton, isCDOC2Container: isCDOC2Container, isEncryptedOrDecrypted: isEncryptedOrDecrypted, showRemoveRecipientModal: $showRemoveRecipientModal, onSelect: { selectedRecipient = recipient + }, + onDecryptRecipient: { + onDecryptRecipient?(recipient) } ) } @@ -47,11 +53,15 @@ struct RecipientsListView: View { nameUtil: nameUtil, recipientUtil: recipientUtil, showRemoveRecipientButton: showRemoveRecipientButton, + showDecryptRecipientButton: showDecryptRecipientButton, isCDOC2Container: isCDOC2Container, isEncryptedOrDecrypted: isEncryptedOrDecrypted, showRemoveRecipientModal: $showRemoveRecipientModal, onSelect: { selectedRecipient = recipient + }, + onDecryptRecipient: { + onDecryptRecipient?(recipient) } ) } diff --git a/RIADigiDoc/ViewModel/EncryptViewModel.swift b/RIADigiDoc/ViewModel/EncryptViewModel.swift index 570e454b..e797bf30 100644 --- a/RIADigiDoc/ViewModel/EncryptViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptViewModel.swift @@ -9,6 +9,12 @@ import CommonsLib import UtilsLib import LibdigidocLibSwift +enum DecryptRoute: Equatable { + case idCard + case chooseKeyLabel([Addressee]) + case password(Addressee) +} + @Observable @MainActor class EncryptViewModel: EncryptViewModelProtocol, Loggable { @@ -59,6 +65,29 @@ class EncryptViewModel: EncryptViewModelProtocol, Loggable { !isContainerEncrypted && !isContainerDecrypted } + var passwordRecipients: [Addressee] { + recipients.filter { $0.certType == .passwordType } + } + + private var certificateRecipients: [Addressee] { + recipients.filter { $0.certType != .passwordType } + } + + var hasBothDecryptionMethods: Bool { + !passwordRecipients.isEmpty && !certificateRecipients.isEmpty + } + + func decryptRoute() -> DecryptRoute { + let candidates = passwordRecipients + guard !candidates.isEmpty, !hasBothDecryptionMethods else { + return .idCard + } + guard candidates.count == 1, let recipient = candidates.first else { + return .chooseKeyLabel(candidates) + } + return .password(recipient) + } + var isContainerEncryptedOrDecrypted: Bool { isContainerEncrypted || isContainerDecrypted } diff --git a/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift b/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift index ed635498..d25921c5 100644 --- a/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift +++ b/RIADigiDocTests/ViewModel/EncryptViewModelTests.swift @@ -3,6 +3,7 @@ import CommonsLib import CommonsLibMocks +import CryptoObjCWrapper import CryptoSwift import Foundation import LibdigidocLibSwiftMocks @@ -264,4 +265,115 @@ struct EncryptViewModelTests { #expect(viewModel.isContainerEncryptedOrDecrypted == false) } + + private func passwordRecipient(keyLabel: String, lockIndex: Int) -> Addressee { + Addressee( + cnVal: keyLabel, + serialNumber: nil, + certType: .passwordType, + validTo: nil, + data: Data(), + concatKDFAlgorithmURI: "", + lockLabel: "data:v=1&label=\(keyLabel)&type=pw", + lockType: "PASSWORD", + lockIndex: lockIndex + ) + } + + private func certificateRecipient(lockIndex: Int) -> Addressee { + Addressee( + cnVal: "USER,TEST,38001010001", + serialNumber: nil, + certType: .iDCardType, + validTo: nil, + data: Data([0x01]), + concatKDFAlgorithmURI: "", + lockLabel: "", + lockType: "", + lockIndex: lockIndex + ) + } + + @Test + func passwordRecipients_keepsOnlyPasswordLocksInContainerOrder() { + viewModel.recipients = [ + certificateRecipient(lockIndex: 0), + passwordRecipient(keyLabel: "first", lockIndex: 1), + passwordRecipient(keyLabel: "second", lockIndex: 2) + ] + + #expect(viewModel.passwordRecipients.map(\.identifier) == ["first", "second"]) + #expect(viewModel.passwordRecipients.map(\.lockIndex) == [1, 2]) + } + + @Test + func hasBothDecryptionMethods_isTrueOnlyWhenBothLockKindsArePresent() { + viewModel.recipients = [ + certificateRecipient(lockIndex: 0), + passwordRecipient(keyLabel: "first", lockIndex: 1) + ] + #expect(viewModel.hasBothDecryptionMethods) + + viewModel.recipients = [passwordRecipient(keyLabel: "only", lockIndex: 0)] + #expect(!viewModel.hasBothDecryptionMethods) + + viewModel.recipients = [certificateRecipient(lockIndex: 0)] + #expect(!viewModel.hasBothDecryptionMethods) + + viewModel.recipients = [] + #expect(!viewModel.hasBothDecryptionMethods) + } + + @Test + func decryptRoute_choosesIdCardWhenNoPasswordLockExists() { + viewModel.recipients = [certificateRecipient(lockIndex: 0)] + + #expect(viewModel.decryptRoute() == .idCard) + } + + @Test + func decryptRoute_choosesIdCardWhenContainerAlsoHoldsCertificateLocks() { + viewModel.recipients = [ + certificateRecipient(lockIndex: 0), + passwordRecipient(keyLabel: "only", lockIndex: 1) + ] + + #expect(viewModel.decryptRoute() == .idCard) + } + + @Test + func decryptRoute_promptsForPasswordWhenASingleLockIsTheOnlyOption() { + let only = passwordRecipient(keyLabel: "only", lockIndex: 3) + viewModel.recipients = [only] + + #expect(viewModel.decryptRoute() == .password(only)) + } + + @Test + func decryptRoute_offersAChoiceBetweenSeveralPasswordLocks() { + let first = passwordRecipient(keyLabel: "first", lockIndex: 1) + let second = passwordRecipient(keyLabel: "second", lockIndex: 2) + viewModel.recipients = [first, second] + + #expect(viewModel.decryptRoute() == .chooseKeyLabel([first, second])) + } + + @Test + func decryptRoute_carriesTheLockIndexThatReachesTheNativeLayer() { + let third = passwordRecipient(keyLabel: "third", lockIndex: 3) + viewModel.recipients = [third] + + guard case .password(let recipient) = viewModel.decryptRoute() else { + Issue.record("Expected a password route") + return + } + #expect(recipient.lockIndex == 3) + } + + @Test + func decryptRoute_choosesIdCardWhenTheContainerHasNoRecipients() { + viewModel.recipients = [] + + #expect(viewModel.decryptRoute() == .idCard) + } }