diff --git a/FlowCrypt.xcodeproj/project.pbxproj b/FlowCrypt.xcodeproj/project.pbxproj index fe1e18528..dba9c9764 100644 --- a/FlowCrypt.xcodeproj/project.pbxproj +++ b/FlowCrypt.xcodeproj/project.pbxproj @@ -420,9 +420,12 @@ D2FD0F692453245E00259FF0 /* Either.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FD0F682453245E00259FF0 /* Either.swift */; }; D2FF6966243115EC007182F0 /* SetupImapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FF6965243115EC007182F0 /* SetupImapViewController.swift */; }; D2FF6968243115F9007182F0 /* SetupImapViewDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FF6967243115F9007182F0 /* SetupImapViewDecorator.swift */; }; + D717EC262D06B37E00AE7BFF /* CustomAlertNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717EC252D06B37900AE7BFF /* CustomAlertNode.swift */; }; D73F7D9D2CD46AE900955806 /* PgpOnlySwitchNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73F7D9C2CD46AE700955806 /* PgpOnlySwitchNode.swift */; }; D73F7D9F2CD4922500955806 /* NotificationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73F7D9E2CD4922100955806 /* NotificationExtension.swift */; }; D741F9B22CA5661C00E1CAFF /* SecurityWarningNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D741F9B12CA5661400E1CAFF /* SecurityWarningNode.swift */; }; + D7478BDE2D09113100D42659 /* PasswordProtectedMsgTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7478BDD2D09112500D42659 /* PasswordProtectedMsgTest.swift */; }; + D7478BE02D0912E600D42659 /* CoreAlertNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7478BDF2D0912E200D42659 /* CoreAlertNode.swift */; }; F191F621272511790053833E /* BlurViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F191F620272511790053833E /* BlurViewController.swift */; }; F8678DCC2722143300BB1710 /* GmailService+draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8678DCB2722143300BB1710 /* GmailService+draft.swift */; }; F8A72FA12729F82800E4BCAB /* DraftGatewayMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A72FA02729F82800E4BCAB /* DraftGatewayMock.swift */; }; @@ -889,9 +892,12 @@ D2FD0F682453245E00259FF0 /* Either.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Either.swift; sourceTree = ""; }; D2FF6965243115EC007182F0 /* SetupImapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupImapViewController.swift; sourceTree = ""; }; D2FF6967243115F9007182F0 /* SetupImapViewDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupImapViewDecorator.swift; sourceTree = ""; }; + D717EC252D06B37900AE7BFF /* CustomAlertNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertNode.swift; sourceTree = ""; }; D73F7D9C2CD46AE700955806 /* PgpOnlySwitchNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PgpOnlySwitchNode.swift; sourceTree = ""; }; D73F7D9E2CD4922100955806 /* NotificationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationExtension.swift; sourceTree = ""; }; D741F9B12CA5661400E1CAFF /* SecurityWarningNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityWarningNode.swift; sourceTree = ""; }; + D7478BDD2D09112500D42659 /* PasswordProtectedMsgTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordProtectedMsgTest.swift; sourceTree = ""; }; + D7478BDF2D0912E200D42659 /* CoreAlertNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreAlertNode.swift; sourceTree = ""; }; E26D5E20275AA417007B8802 /* BundleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtensions.swift; sourceTree = ""; }; F191F620272511790053833E /* BlurViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurViewController.swift; sourceTree = ""; }; F8678DCB2722143300BB1710 /* GmailService+draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GmailService+draft.swift"; sourceTree = ""; }; @@ -1502,6 +1508,7 @@ 9F4164162665757700106194 /* PGP */ = { isa = PBXGroup; children = ( + D7478BDD2D09112500D42659 /* PasswordProtectedMsgTest.swift */, 9FA0157926565B7800CBBA05 /* KeyMethodsTest.swift */, ); path = PGP; @@ -2261,6 +2268,7 @@ D2A1D3D223FD9AE600D626D6 /* Nodes */ = { isa = PBXGroup; children = ( + D7478BDF2D0912E200D42659 /* CoreAlertNode.swift */, D73F7D9C2CD46AE700955806 /* PgpOnlySwitchNode.swift */, 9F7ECCA6272C3FB4008A1770 /* TextImageNode.swift */, 51EBC56F2746A06600178DE8 /* TextWithIconNode.swift */, @@ -2282,6 +2290,7 @@ 51DAD9BC273E7DD20076CBA7 /* BadgeNode.swift */, 51B9EE6E27567B520080B2D5 /* MessageRecipientsNode.swift */, 9547EF202A5F106E00A048FF /* PassPhraseAlertNode.swift */, + D717EC252D06B37900AE7BFF /* CustomAlertNode.swift */, 95E014CE2A8BF27C00D4B4F5 /* AvatarCheckboxNode.swift */, 9577CEDC2AA7A4A40084AC62 /* PublicKeyDetailNode.swift */, 955475FA2B0650AC00F52076 /* WebNode.swift */, @@ -2618,6 +2627,7 @@ 9F5F504A26FA6C8F00294FA2 /* ClientConfigurationProviderMock.swift in Sources */, 9FC4117D268118AE004C0A69 /* PassPhraseStorageMock.swift in Sources */, 9F97650E267E16620058419D /* WKDURLsConstructorTests.swift in Sources */, + D7478BDE2D09113100D42659 /* PasswordProtectedMsgTest.swift in Sources */, 9F976585267E194F0058419D /* FlowCryptCoreTests.swift in Sources */, 9F6F3C3C26ADFBC7005BD9C6 /* CoreComposeMessageMock.swift in Sources */, 9FC4116B2681186D004C0A69 /* KeyMethodsTest.swift in Sources */, @@ -2899,11 +2909,13 @@ D211CE7123FC35AC00D1CE38 /* TextFieldCellNode.swift in Sources */, D73F7D9D2CD46AE900955806 /* PgpOnlySwitchNode.swift in Sources */, 9FA19890253C841F008C9CF2 /* TableViewController.swift in Sources */, + D717EC262D06B37E00AE7BFF /* CustomAlertNode.swift in Sources */, 51DAD9BD273E7DD20076CBA7 /* BadgeNode.swift in Sources */, 51DE2FEE2714DA0400916222 /* ContactKeyCellNode.swift in Sources */, 9547EF242A5FBA2B00A048FF /* MenuSeparatorCellNode.swift in Sources */, D2A9CA432426210200E1D898 /* SetupTitleNode.swift in Sources */, D2F6D12F24324ACC00DB4065 /* SwitchCellNode.swift in Sources */, + D7478BE02D0912E600D42659 /* CoreAlertNode.swift in Sources */, 51EBC5702746A06600178DE8 /* TextWithIconNode.swift in Sources */, 5180CB97273724E9001FC7EF /* ThreadMessageInfoCellNode.swift in Sources */, D2E26F6824F169E300612AF1 /* ContactCellNode.swift in Sources */, diff --git a/FlowCrypt/Controllers/Compose/ComposeViewController.swift b/FlowCrypt/Controllers/Compose/ComposeViewController.swift index 612aef95e..1791f7ab2 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewController.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewController.swift @@ -56,7 +56,7 @@ final class ComposeViewController: TableNodeViewController { let photosManager: PhotosManagerType let router: GlobalRouterType - private let clientConfiguration: ClientConfiguration + let clientConfiguration: ClientConfiguration var isMessagePasswordSupported: Bool { clientConfiguration.isUsingFes } let search = PassthroughSubject() diff --git a/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+Drafts.swift b/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+Drafts.swift index 2e7d8cbc5..0d792d8b4 100644 --- a/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+Drafts.swift +++ b/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+Drafts.swift @@ -71,6 +71,7 @@ extension ComposeViewController { contextToSend.hasRecipientsWithActivePubKey let sendableMsg = try await composeMessageHelper.createSendableMsg( + clientConfiguration: clientConfiguration, input: draft.input, contextToSend: draft.contextToSend, shouldValidate: false, diff --git a/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+ErrorHandling.swift b/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+ErrorHandling.swift index b0dbc272f..c5ca0f423 100644 --- a/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+ErrorHandling.swift +++ b/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+ErrorHandling.swift @@ -79,6 +79,8 @@ extension ComposeViewController { } else { refreshEKMAndProceed(error: error) } + case let MessageValidationError.messagePasswordDisallowed(error): + alertsFactory.makeCustomAlert(viewController: self, message: error) case MessageValidationError.notUniquePassword, MessageValidationError.subjectContainsPassword, MessageValidationError.weakPassword: diff --git a/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+MessageSend.swift b/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+MessageSend.swift index e1eb40dff..67933690a 100644 --- a/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+MessageSend.swift +++ b/FlowCrypt/Controllers/Compose/Extensions/ComposeViewController+MessageSend.swift @@ -55,8 +55,10 @@ extension ComposeViewController { private func sendMessage(isPlain: Bool) async throws -> MessageIdentifier { let sendableMsg = try await composeMessageHelper.createSendableMsg( + clientConfiguration: clientConfiguration, input: input, contextToSend: contextToSend, + shouldSendPlainMessage: isPlain, shouldSign: !isPlain, withPubKeys: !isPlain ) diff --git a/FlowCrypt/Controllers/Threads/Models/AlertsFactory.swift b/FlowCrypt/Controllers/Threads/Models/AlertsFactory.swift index 52031546a..ad1d41d93 100644 --- a/FlowCrypt/Controllers/Threads/Models/AlertsFactory.swift +++ b/FlowCrypt/Controllers/Threads/Models/AlertsFactory.swift @@ -79,6 +79,25 @@ class AlertsFactory { viewController.present(alertViewController, animated: true, completion: nil) } + + func makeCustomAlert( + viewController: UIViewController, + title: String = "error".localized, + message: String + ) { + let alertNode = CustomAlertNode( + title: title, + message: message + ) + alertNode.onOkay = { + viewController.dismiss(animated: true) + } + let alertViewController = ASDKViewController(node: alertNode) + alertViewController.modalPresentationStyle = UIModalPresentationStyle.overCurrentContext + alertViewController.modalTransitionStyle = UIModalTransitionStyle.crossDissolve + + viewController.present(alertViewController, animated: true, completion: nil) + } } class SubmitOnPasteTextFieldDelegate: NSObject, UITextFieldDelegate { diff --git a/FlowCrypt/Functionality/DataManager/Encrypted Storage/EncryptedStorage.swift b/FlowCrypt/Functionality/DataManager/Encrypted Storage/EncryptedStorage.swift index ad1070474..10cdbaee7 100644 --- a/FlowCrypt/Functionality/DataManager/Encrypted Storage/EncryptedStorage.swift +++ b/FlowCrypt/Functionality/DataManager/Encrypted Storage/EncryptedStorage.swift @@ -53,6 +53,7 @@ final class EncryptedStorage: EncryptedStorageType { case version15 case version16 case version17 + case version18 var version: SchemaVersion { switch self { @@ -84,6 +85,8 @@ final class EncryptedStorage: EncryptedStorageType { return SchemaVersion(appVersion: "1.2.3", dbSchemaVersion: 16) case .version17: return SchemaVersion(appVersion: "1.3.0", dbSchemaVersion: 17) + case .version18: + return SchemaVersion(appVersion: "1.3.0", dbSchemaVersion: 18) } } } @@ -91,7 +94,7 @@ final class EncryptedStorage: EncryptedStorageType { private lazy var migrationLogger = Logger.nested(in: Self.self, with: .migration) private lazy var logger = Logger.nested(Self.self) - private let currentSchema: EncryptedStorageSchema = .version17 + private let currentSchema: EncryptedStorageSchema = .version18 private let supportedSchemas = EncryptedStorageSchema.allCases private let storageEncryptionKey: Data diff --git a/FlowCrypt/Functionality/Services/Client Configuration Provider/ClientConfiguration.swift b/FlowCrypt/Functionality/Services/Client Configuration Provider/ClientConfiguration.swift index 96493f7fa..b5bbcbe67 100644 --- a/FlowCrypt/Functionality/Services/Client Configuration Provider/ClientConfiguration.swift +++ b/FlowCrypt/Functionality/Services/Client Configuration Provider/ClientConfiguration.swift @@ -64,6 +64,16 @@ class ClientConfiguration { raw.enforceKeygenAlgo } + /// An array of strings to check against the subject of the composed password-protected message. If any string in this array is found in the subject, an error alert must be displayed + var disallowPasswordMessagesForTerms: [String]? { + raw.disallowPasswordMessagesForTerms + } + + /// The text to be displayed in the password protected message compliance error alert. + var disallowPasswordMessagesErrorText: String? { + raw.disallowPasswordMessagesErrorText + } + /// Some orgs want to have newly generated keys include self-signatures that expire some time in the future. var getEnforcedKeygenExpirationMonths: Int? { raw.enforceKeygenExpireMonths diff --git a/FlowCrypt/Functionality/Services/Compose Message Helper/ComposeMessageError.swift b/FlowCrypt/Functionality/Services/Compose Message Helper/ComposeMessageError.swift index a021d2373..e3a20bd02 100644 --- a/FlowCrypt/Functionality/Services/Compose Message Helper/ComposeMessageError.swift +++ b/FlowCrypt/Functionality/Services/Compose Message Helper/ComposeMessageError.swift @@ -21,6 +21,7 @@ enum MessageValidationError: Error, CustomStringConvertible, Equatable { case expiredKeyRecipients case notUsableForEncryptionKeyRecipients case invalidEmailRecipient + case messagePasswordDisallowed(String) case internalError(String) var description: String { @@ -49,6 +50,8 @@ enum MessageValidationError: Error, CustomStringConvertible, Equatable { return "compose_recipient_unusuable_for_encryption".localized case .invalidEmailRecipient: return "compose_recipient_invalid_email".localized + case let .messagePasswordDisallowed(message): + return message case let .internalError(message): return message } diff --git a/FlowCrypt/Functionality/Services/Compose Message Helper/ComposeMessageHelper.swift b/FlowCrypt/Functionality/Services/Compose Message Helper/ComposeMessageHelper.swift index 8e677855a..3d68c4683 100644 --- a/FlowCrypt/Functionality/Services/Compose Message Helper/ComposeMessageHelper.swift +++ b/FlowCrypt/Functionality/Services/Compose Message Helper/ComposeMessageHelper.swift @@ -99,8 +99,10 @@ final class ComposeMessageHelper { // MARK: - Validation // swiftlint:disable:next function_body_length func createSendableMsg( + clientConfiguration: ClientConfiguration, input: ComposeMessageInput, contextToSend: ComposeMessageContext, + shouldSendPlainMessage: Bool = true, shouldValidate: Bool = true, shouldSign: Bool = true, withPubKeys: Bool = true @@ -136,6 +138,14 @@ final class ComposeMessageHelper { throw MessageValidationError.emptyMessage } + if !shouldSendPlainMessage, + contextToSend.hasRecipientsWithoutPubKey, + let termsToDisallow = clientConfiguration.disallowPasswordMessagesForTerms, + let errorText = clientConfiguration.disallowPasswordMessagesErrorText, + !subject.isPasswordMessageEnabled(disallowTerms: termsToDisallow) { + throw MessageValidationError.messagePasswordDisallowed(errorText) + } + if let password = contextToSend.messagePassword, password.isNotEmpty { if subject.lowercased().contains(password.lowercased()) { throw MessageValidationError.subjectContainsPassword diff --git a/FlowCrypt/Models/Common/RawClientConfiguration.swift b/FlowCrypt/Models/Common/RawClientConfiguration.swift index 43f053d7e..ae8f897f9 100644 --- a/FlowCrypt/Models/Common/RawClientConfiguration.swift +++ b/FlowCrypt/Models/Common/RawClientConfiguration.swift @@ -40,6 +40,8 @@ struct RawClientConfiguration: Codable, Equatable { let disallowAttesterSearchForDomains: [String]? let enforceKeygenAlgo: String? let enforceKeygenExpireMonths: Int? + let disallowPasswordMessagesForTerms: [String]? + let disallowPasswordMessagesErrorText: String? init( flags: [ClientConfigurationFlag]? = nil, @@ -50,7 +52,9 @@ struct RawClientConfiguration: Codable, Equatable { allowAttesterSearchOnlyForDomains: [String]? = nil, disallowAttesterSearchForDomains: [String]? = nil, enforceKeygenAlgo: String? = nil, - enforceKeygenExpireMonths: Int? = nil + enforceKeygenExpireMonths: Int? = nil, + disallowPasswordMessagesForTerms: [String]? = nil, + disallowPasswordMessagesErrorText: String? = nil ) { self.flags = flags self.customKeyserverUrl = customKeyserverUrl @@ -61,6 +65,8 @@ struct RawClientConfiguration: Codable, Equatable { self.disallowAttesterSearchForDomains = disallowAttesterSearchForDomains self.enforceKeygenAlgo = enforceKeygenAlgo self.enforceKeygenExpireMonths = enforceKeygenExpireMonths + self.disallowPasswordMessagesForTerms = disallowPasswordMessagesForTerms + self.disallowPasswordMessagesErrorText = disallowPasswordMessagesErrorText } } @@ -96,7 +102,11 @@ extension RawClientConfiguration { try JSONDecoder().decode([String].self, from: $0) }, enforceKeygenAlgo: unwrappedObject.enforceKeygenAlgo, - enforceKeygenExpireMonths: unwrappedObject.enforceKeygenExpireMonths + enforceKeygenExpireMonths: unwrappedObject.enforceKeygenExpireMonths, + disallowPasswordMessagesForTerms: try? object?.disallowPasswordMessagesForTerms.ifNotNil { + try JSONDecoder().decode([String].self, from: $0) + }, + disallowPasswordMessagesErrorText: unwrappedObject.disallowPasswordMessagesErrorText ) } } diff --git a/FlowCrypt/Models/Realm Models/ClientConfigurationRealmObject.swift b/FlowCrypt/Models/Realm Models/ClientConfigurationRealmObject.swift index c2461fa2d..ed3a8e1f7 100644 --- a/FlowCrypt/Models/Realm Models/ClientConfigurationRealmObject.swift +++ b/FlowCrypt/Models/Realm Models/ClientConfigurationRealmObject.swift @@ -19,6 +19,8 @@ final class ClientConfigurationRealmObject: Object { @Persisted var disallowAttesterSearchForDomains: Data? @Persisted var enforceKeygenAlgo: String? @Persisted var enforceKeygenExpireMonths: Int + @Persisted var disallowPasswordMessagesForTerms: Data? + @Persisted var disallowPasswordMessagesErrorText: String? convenience init( flags: [String]?, @@ -30,6 +32,8 @@ final class ClientConfigurationRealmObject: Object { disallowAttesterSearchForDomains: [String]?, enforceKeygenAlgo: String?, enforceKeygenExpireMonths: Int?, + disallowPasswordMessagesForTerms: [String]?, + disallowPasswordMessagesErrorText: String?, email: String ) { self.init() @@ -44,6 +48,8 @@ final class ClientConfigurationRealmObject: Object { self.disallowAttesterSearchForDomains = try? disallowAttesterSearchForDomains.ifNotNil { try JSONEncoder().encode($0) } self.enforceKeygenAlgo = enforceKeygenAlgo self.enforceKeygenExpireMonths = enforceKeygenExpireMonths ?? -1 + self.disallowPasswordMessagesForTerms = try? disallowPasswordMessagesForTerms.ifNotNil { try JSONEncoder().encode($0) } + self.disallowPasswordMessagesErrorText = disallowPasswordMessagesErrorText self.userEmail = email } } @@ -60,6 +66,8 @@ extension ClientConfigurationRealmObject { disallowAttesterSearchForDomains: configuration.disallowAttesterSearchForDomains, enforceKeygenAlgo: configuration.enforceKeygenAlgo, enforceKeygenExpireMonths: configuration.enforceKeygenExpireMonths, + disallowPasswordMessagesForTerms: configuration.disallowPasswordMessagesForTerms, + disallowPasswordMessagesErrorText: configuration.disallowPasswordMessagesErrorText, email: email ) } diff --git a/FlowCryptAppTests/Functionality/PGP/PasswordProtectedMsgTest.swift b/FlowCryptAppTests/Functionality/PGP/PasswordProtectedMsgTest.swift new file mode 100644 index 000000000..84c4565ec --- /dev/null +++ b/FlowCryptAppTests/Functionality/PGP/PasswordProtectedMsgTest.swift @@ -0,0 +1,33 @@ +// +// PasswordProtectedMsgTest.swift +// FlowCryptTests +// +// Created by Ioan Moldovan on 20.05.2021. +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +@testable import FlowCrypt +import XCTest + +class PasswordProtectedMsgTest: XCTestCase { + + func testPasswordProtectedMessageCompliance() { + let disallowTerms = ["[Classification: Data Control: Internal Data Control]", "droid", "forbidden data"] + + let subjectsToTest: [String: Bool] = [ + "[Classification: Data Control: Internal Data Control] Quarter results": false, + "Conference information [Classification: Data Control: Internal Data Control]": false, + "Classification: Data Control: Internal Data Control - Tomorrow meeting": true, + "Internal Data Control - Finance monitoring": true, + "Android phone update": true, + "droid phone": false, + "DROiD phone": false, + "[forbidden data] year results": false, + ] + + for (subject, expectedValue) in subjectsToTest { + let result = subject.isPasswordMessageEnabled(disallowTerms: disallowTerms) + XCTAssertEqual(result, expectedValue, "Failed for subject: \(subject)") + } + } +} diff --git a/FlowCryptCommon/Extensions/StringExtensions.swift b/FlowCryptCommon/Extensions/StringExtensions.swift index 1c713b331..9b480ab69 100644 --- a/FlowCryptCommon/Extensions/StringExtensions.swift +++ b/FlowCryptCommon/Extensions/StringExtensions.swift @@ -158,4 +158,32 @@ public extension String { guard parts.count == 2 else { return nil } return (String(parts[0]), String(parts[1])) } + + func isPasswordMessageEnabled(disallowTerms: [String]) -> Bool { + // Allow by default if subject is nil, disallowTerms is empty, or no terms are specified + guard disallowTerms.isNotEmpty else { + return true + } + + let lowerSubject = self.lowercased() + + for term in disallowTerms { + // Escape special regex characters + let escapedTerm = NSRegularExpression.escapedPattern(for: term) + + // Create regex pattern to ensure term appears as a separate token + let pattern = #"(^|\W)\#(escapedTerm)(\W|$)"# + + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + continue + } + + let range = NSRange(lowerSubject.startIndex..., in: lowerSubject) + if regex.firstMatch(in: lowerSubject, range: range) != nil { + // Found a disallowed term as a separate token + return false + } + } + return true // Allow if no matches are found + } } diff --git a/FlowCryptUI/Nodes/CoreAlertNode.swift b/FlowCryptUI/Nodes/CoreAlertNode.swift new file mode 100644 index 000000000..324bf2189 --- /dev/null +++ b/FlowCryptUI/Nodes/CoreAlertNode.swift @@ -0,0 +1,91 @@ +// +// CoreAlertNode.swift +// FlowCrypt +// +// Created by Ioan Moldovan on 12/10/24 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +import AsyncDisplayKit + +public class CoreAlertNode: ASDisplayNode { + + enum Constants { + static let antiBruteForceProtectionAttemptsMaxValue = 5 + static let blockingTimeInSeconds: Double = 5 * 60 + static let buttonFont = UIFont.systemFont(ofSize: 16) + static let submitButtonText = "submit".localized + static let cancelButtonText = "cancel".localized + } + + func createContentView() -> ASDisplayNode { + let node = ASDisplayNode() + node.backgroundColor = UIColor.colorFor( + darkStyle: UIColor(hex: "282828") ?? .black, + lightStyle: UIColor(hex: "F0F0F0") ?? .white + ) + node.clipsToBounds = true + node.cornerRadius = 13 + node.shadowColor = UIColor.black.cgColor + node.shadowRadius = 15 + node.shadowOpacity = 0.1 + node.shadowOffset = CGSize(width: 0, height: 2) + return node + } + + func createOverlayNode() -> ASDisplayNode { + let node = ASDisplayNode() + node.backgroundColor = UIColor(white: 0, alpha: 0.4) // semi-transparent black + return node + } + + func createSeparatorNode() -> ASDisplayNode { + let node = ASDisplayNode() + node.backgroundColor = UIColor.separator + node.style.height = ASDimension(unit: .points, value: 0.5) + return node + } + + func createTextNode(text: String, isBold: Bool, fontSize: CGFloat, identifier: String? = nil, detectLinks: Bool = false) -> ASTextNode { + let node = ASTextNode() + node.isUserInteractionEnabled = true + + let font = isBold ? UIFont.boldSystemFont(ofSize: fontSize) : UIFont.systemFont(ofSize: fontSize) + let attributedString = NSMutableAttributedString( + string: text, + attributes: [ + .font: font, + .foregroundColor: UIColor.mainTextColor + ] + ) + + if detectLinks { + let types: NSTextCheckingResult.CheckingType = .link + if let detector = try? NSDataDetector(types: types.rawValue) { + let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) + for match in matches { + if let url = match.url, let range = Range(match.range, in: text) { + attributedString.addAttribute(.link, value: url, range: NSRange(range, in: text)) + attributedString.addAttribute(.underlineColor, value: UIColor.clear, range: NSRange(range, in: text)) + attributedString.addAttribute(.foregroundColor, value: UIColor.blue, range: NSRange(range, in: text)) + } + } + } + } + + node.attributedText = attributedString + node.accessibilityIdentifier = identifier + return node + } + + func createButtonNode(title: String, color: UIColor, identifier: String, action: Selector) -> ASButtonNode { + let node = ASButtonNode() + node.setTitle(title, with: Constants.buttonFont, with: color, for: .normal) + node.style.flexGrow = 1 + node.style.preferredSize.height = 35 + node.addTarget(self, action: action, forControlEvents: .touchUpInside) + node.accessibilityIdentifier = identifier + node.setBackgroundColor(UIColor.colorFor(darkStyle: .darkGray, lightStyle: .lightGray), forState: .highlighted) + return node + } +} diff --git a/FlowCryptUI/Nodes/CustomAlertNode.swift b/FlowCryptUI/Nodes/CustomAlertNode.swift new file mode 100644 index 000000000..a6657b9ee --- /dev/null +++ b/FlowCryptUI/Nodes/CustomAlertNode.swift @@ -0,0 +1,125 @@ +// +// CustomAlertNode.swift +// FlowCrypt +// +// Created by Ioan Moldovan on 12/8/24 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +import AsyncDisplayKit + +public final class CustomAlertNode: CoreAlertNode, ASTextNodeDelegate { + + private var overlayNode: ASDisplayNode! + private var contentView: ASDisplayNode! + private var separatorNode: ASDisplayNode! + private var titleLabel: ASTextNode! + private var messageLabel: ASTextNode! + private var okayButton: ASButtonNode! + + private let title: String + private let message: String? + + public var onOkay: (() -> Void)? + + // MARK: - Initialization + + public init( + title: String, + message: String? = nil + ) { + self.title = title + self.message = message + super.init() + setupNodes() + } + + private func createNodes() { + overlayNode = createOverlayNode() + contentView = createContentView() + separatorNode = createSeparatorNode() + titleLabel = createTextNode(text: title, isBold: true, fontSize: 17) + messageLabel = createTextNode(text: message ?? "", isBold: false, fontSize: 13, identifier: "aid-custom-alert-message", detectLinks: true) + messageLabel.delegate = self + okayButton = createButtonNode( + title: "ok".localized, + color: .systemBlue, + identifier: "aid-ok-button", + action: #selector(okayButtonTapped) + ) + } + + private func setupNodes() { + createNodes() + contentView.addSubnode(titleLabel) + if message != nil { + contentView.addSubnode(messageLabel) + } + contentView.addSubnode(separatorNode) + contentView.addSubnode(okayButton) + overlayNode.addSubnode(contentView) + addSubnode(overlayNode) + } + + // MARK: - Layout + override public func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { + let separatorInsetSpec = ASInsetLayoutSpec( + insets: UIEdgeInsets(top: 10, left: 0, bottom: 0, right: 0), + child: separatorNode + ) + + let contentStack = ASStackLayoutSpec( + direction: .vertical, + spacing: 10, + justifyContent: .center, + alignItems: .center, + children: [titleLabel, messageLabel] + ) + contentStack.style.flexGrow = 1.0 + + let buttonStack = ASStackLayoutSpec( + direction: .horizontal, + spacing: 0, + justifyContent: .spaceBetween, + alignItems: .center, + children: [okayButton] + ) + buttonStack.style.flexGrow = 1.0 + + let verticalStack = ASStackLayoutSpec( + direction: .vertical, + spacing: 10, + justifyContent: .center, + alignItems: .stretch, + children: [contentStack, separatorInsetSpec, buttonStack] + ) + + let contentLayout = ASInsetLayoutSpec( + insets: UIEdgeInsets(top: 20, left: 20, bottom: 10, right: 20), + child: verticalStack + ) + + contentView.layoutSpecBlock = { _, _ in + return contentLayout + } + + let contentViewInset = ASInsetLayoutSpec(insets: UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10), child: contentView) + + let centerSpec = ASCenterLayoutSpec(centeringOptions: .XY, sizingOptions: [], child: contentViewInset) + + overlayNode.layoutSpecBlock = { _, _ in + return centerSpec + } + return ASWrapperLayoutSpec(layoutElement: overlayNode) + } + + @objc private func okayButtonTapped() { + onOkay?() + } + + public func textNode(_ textNode: ASTextNode, tappedLinkAttribute attribute: String, value: Any, at point: CGPoint, textRange: NSRange) { + if let url = value as? URL { + UIApplication.shared.open(url) + } + } +} diff --git a/FlowCryptUI/Nodes/PassPhraseAlertNode.swift b/FlowCryptUI/Nodes/PassPhraseAlertNode.swift index 077133268..2fff9ca21 100644 --- a/FlowCryptUI/Nodes/PassPhraseAlertNode.swift +++ b/FlowCryptUI/Nodes/PassPhraseAlertNode.swift @@ -8,15 +8,7 @@ import AsyncDisplayKit -public class PassPhraseAlertNode: ASDisplayNode { - - enum Constants { - static let antiBruteForceProtectionAttemptsMaxValue = 5 - static let blockingTimeInSeconds: Double = 5 * 60 - static let buttonFont = UIFont.systemFont(ofSize: 16) - static let submitButtonText = "submit".localized - static let cancelButtonText = "cancel".localized - } +public final class PassPhraseAlertNode: CoreAlertNode { private var overlayNode: ASDisplayNode! private var contentView: ASDisplayNode! @@ -183,60 +175,6 @@ public class PassPhraseAlertNode: ASDisplayNode { } } - private func createContentView() -> ASDisplayNode { - let node = ASDisplayNode() - node.backgroundColor = UIColor.colorFor( - darkStyle: UIColor(hex: "282828") ?? .black, - lightStyle: UIColor(hex: "F0F0F0") ?? .white - ) - node.clipsToBounds = true - node.cornerRadius = 13 - node.shadowColor = UIColor.black.cgColor - node.shadowRadius = 15 - node.shadowOpacity = 0.1 - node.shadowOffset = CGSize(width: 0, height: 2) - return node - } - - private func createOverlayNode() -> ASDisplayNode { - let node = ASDisplayNode() - node.backgroundColor = UIColor(white: 0, alpha: 0.4) // semi-transparent black - return node - } - - private func createSeparatorNode() -> ASDisplayNode { - let node = ASDisplayNode() - node.backgroundColor = UIColor.separator - node.style.height = ASDimension(unit: .points, value: 0.5) - return node - } - - private func createTextNode(text: String, isBold: Bool, fontSize: CGFloat, identifier: String? = nil) -> ASTextNode { - let node = ASTextNode() - let font = isBold ? UIFont.boldSystemFont(ofSize: fontSize) : UIFont.systemFont(ofSize: fontSize) - node.attributedText = NSAttributedString( - string: text, - attributes: [ - .font: font, - .foregroundColor: UIColor.mainTextColor - ] - ) - - node.accessibilityIdentifier = identifier - return node - } - - private func createButtonNode(title: String, color: UIColor, identifier: String, action: Selector) -> ASButtonNode { - let node = ASButtonNode() - node.setTitle(title, with: Constants.buttonFont, with: color, for: .normal) - node.style.flexGrow = 1 - node.style.preferredSize.height = 35 - node.addTarget(self, action: action, forControlEvents: .touchUpInside) - node.accessibilityIdentifier = identifier - node.setBackgroundColor(UIColor.colorFor(darkStyle: .darkGray, lightStyle: .lightGray), forState: .highlighted) - return node - } - private func updateIntroduction() { if let introduction, !introduction.isEmpty { introductionLabel.attributedText = NSAttributedString( diff --git a/appium/api-mocks/lib/configuration-types.ts b/appium/api-mocks/lib/configuration-types.ts index b28eedf7e..711032ec3 100644 --- a/appium/api-mocks/lib/configuration-types.ts +++ b/appium/api-mocks/lib/configuration-types.ts @@ -29,6 +29,8 @@ type Fes$ClientConfiguration = { allow_attester_search_only_for_domains?: string[]; disallow_attester_search_for_domains?: string[]; enforce_keygen_algo?: string; + disallow_password_messages_for_terms?: string[]; + disallow_password_messages_error_text?: string; enforce_keygen_expire_months?: number; }; diff --git a/appium/tests/screenobjects/new-message.screen.ts b/appium/tests/screenobjects/new-message.screen.ts index 9b2d420be..d25a9b748 100644 --- a/appium/tests/screenobjects/new-message.screen.ts +++ b/appium/tests/screenobjects/new-message.screen.ts @@ -21,6 +21,8 @@ const SELECTORS = { DELETE_BUTTON: '~aid-compose-delete', SEND_BUTTON: '~aid-compose-send', SEND_PLAIN_MESSAGE_BUTTON: '~aid-compose-send-plain', + SEND_MESSAGE_PASSWORD_BUTTON: '~aid-compose-send-message-password', + CUSTOM_ALERT_MESSAGE_LABEL: '~aid-custom-alert-message', CONFIRM_DELETING: '~aid-confirm-button', MESSAGE_PASSWORD_TEXTFIELD: '~aid-message-password-textfield', ALERT: "-ios predicate string:type == 'XCUIElementTypeAlert'", @@ -98,6 +100,14 @@ class NewMessageScreen extends BaseScreen { return $(SELECTORS.SEND_PLAIN_MESSAGE_BUTTON); } + get sendMessagePasswordButton() { + return $(SELECTORS.SEND_MESSAGE_PASSWORD_BUTTON); + } + + get customAlertMessageLabel() { + return $(SELECTORS.CUSTOM_ALERT_MESSAGE_LABEL); + } + get confirmDeletingButton() { return $(SELECTORS.CONFIRM_DELETING); } @@ -203,6 +213,10 @@ class NewMessageScreen extends BaseScreen { await this.setSubject(subject); }; + checkCustomAlertMessage = async (content: string) => { + const customAlertMessageLabel = await this.customAlertMessageLabel; + expect(await customAlertMessageLabel.getText()).toContain(content); + }; changeFromEmail = async (email: string) => { await this.showRecipientInputIfNeeded(); await ElementHelper.waitAndClick(await this.toggleFromButton); @@ -407,6 +421,10 @@ class NewMessageScreen extends BaseScreen { await ElementHelper.waitAndClick(await this.sendPlainMessageButton); }; + clickSendMessagePasswordButton = async () => { + await ElementHelper.waitAndClick(await this.sendMessagePasswordButton); + }; + confirmDelete = async () => { await ElementHelper.waitAndClick(await this.confirmDeletingButton); await browser.pause(500); diff --git a/appium/tests/specs/mock/composeEmail/CheckPasswordMessageCompliance.spec.ts b/appium/tests/specs/mock/composeEmail/CheckPasswordMessageCompliance.spec.ts new file mode 100644 index 000000000..05cfc85d2 --- /dev/null +++ b/appium/tests/specs/mock/composeEmail/CheckPasswordMessageCompliance.spec.ts @@ -0,0 +1,43 @@ +import { MockApi } from 'api-mocks/mock'; +import { MockApiConfig } from 'api-mocks/mock-config'; +import { SplashScreen } from '../../../screenobjects/all-screens'; +import MailFolderScreen from '../../../screenobjects/mail-folder.screen'; +import NewMessageScreen from '../../../screenobjects/new-message.screen'; +import SetupKeyScreen from '../../../screenobjects/setup-key.screen'; +import { CommonData } from 'tests/data'; + +describe('SETUP: ', () => { + it('check password message compliance', async () => { + const mockApi = new MockApi(); + const disallowedPasswordMessageErrorText = + 'Password-protected messages are disabled. Please check https://test.com'; + const emailPassword = CommonData.recipientWithoutPublicKey.password; + + mockApi.fesConfig = { + clientConfiguration: { + ...MockApiConfig.defaultEnterpriseFesConfiguration.clientConfiguration, + disallow_password_messages_for_terms: ['forbidden', 'test'], + disallow_password_messages_error_text: disallowedPasswordMessageErrorText, + }, + }; + mockApi.ekmConfig = MockApiConfig.defaultEnterpriseEkmConfiguration; + mockApi.attesterConfig = { + servedPubkeys: {}, + }; + + await mockApi.withMockedApis(async () => { + await SplashScreen.mockLogin(); + await SetupKeyScreen.setPassPhrase(); + await MailFolderScreen.checkInboxScreen(); + await MailFolderScreen.clickCreateEmail(); + await NewMessageScreen.composeEmail('test@gmail.com', 'forbidden subject', 'test message'); + await NewMessageScreen.clickSendButton(); + + await NewMessageScreen.clickSendMessagePasswordButton(); + await NewMessageScreen.setMessagePassword(emailPassword); + await NewMessageScreen.clickSendButton(); + + await NewMessageScreen.checkCustomAlertMessage(disallowedPasswordMessageErrorText); + }); + }); +});