From 895a656679af43f5d70274eee7add130900df16e Mon Sep 17 00:00:00 2001 From: Lucas BIANCIOTTO Date: Mon, 8 Jun 2026 14:34:43 +0200 Subject: [PATCH] =?UTF-8?q?PAYLSQUAD3-3800=20-=20[XMCO][IOS]=20-=203.2.2.2?= =?UTF-8?q?=20-=20=20Vuln=C3=A9rabilit=C3=A9=20Absence=20de=20protection?= =?UTF-8?q?=20contre=20les=20captures=20d'=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Example/Example.entitlements | 4 +- .../CardFormSection/CardForm.swift | 225 ++++++++++-------- .../AlternativePaymentMethodForm.swift | 3 +- .../Views/FormField/FormFieldView.swift | 19 +- .../Views/ScreenshotProtected.swift | 92 +++++++ 5 files changed, 234 insertions(+), 109 deletions(-) create mode 100644 Sources/Monext/Presentation/Views/ScreenshotProtected.swift diff --git a/Example/Example.entitlements b/Example/Example.entitlements index 938e10d..7f0de4a 100644 --- a/Example/Example.entitlements +++ b/Example/Example.entitlements @@ -4,7 +4,9 @@ com.apple.developer.in-app-payments - merchant.com.payline.prod + merchant.homo.sdk.app.monext.com + merchant.homo.sdk.monext.com + merchant.prod.sdk.app.monext.com diff --git a/Sources/Monext/Presentation/PaymentMethodScreen/CardFormSection/CardForm.swift b/Sources/Monext/Presentation/PaymentMethodScreen/CardFormSection/CardForm.swift index b8c7ddc..52579eb 100644 --- a/Sources/Monext/Presentation/PaymentMethodScreen/CardFormSection/CardForm.swift +++ b/Sources/Monext/Presentation/PaymentMethodScreen/CardFormSection/CardForm.swift @@ -26,110 +26,16 @@ struct CardForm: View { var onFieldFocused: ((CardField?) -> Void)? var body: some View { - VStack(spacing: 10) { - - FormFieldView( - label: "Card number", - textValue: $viewModel.cardNumber, - errorMessage: viewModel.cardNumberError, - formatter: CardNumberFormatter(), - keyboardType: .numberPad, - focusedState: $focusedField, - focusedField: .cardNumber - ) - .id(CardField.cardNumber) - - HStack { - - if viewModel.showExpirationDate { - FormFieldView( - label: "Expiry", - textValue: $viewModel.cardExpiration, - errorMessage: viewModel.cardExpirationError, - formatter: CardDateFormatter(), - keyboardType: .numberPad, - focusedState: $focusedField, - focusedField: .expiration - ) - .id(CardField.expiration) - } - - if viewModel.showCardCvv { - FormFieldView( - label: "CVV", - textValue: $viewModel.cardCvv, - errorMessage: viewModel.cardCvvError, - formatter: CardCvvFormatter(), - keyboardType: .numberPad, - focusedState: $focusedField, - focusedField: .cvv, - onTappedInfoAccessory: { - isPresentedCvvInfo = true - } - ) - .id(CardField.cvv) - .modifier(CvvInfoDialog(isPresented: $isPresentedCvvInfo)) - } - } - - if viewModel.showCardHolderName { - FormFieldView( - label: "Name on card", - textValue: $viewModel.cardHolderName, - errorMessage: viewModel.cardHolderNameError, - formatter: nil, - focusedState: $focusedField, - focusedField: .holder - ) - .id(CardField.holder) - .onSubmit { - focusedField = nil - } - .submitLabel(.done) - } - - if let availableNetworks = viewModel.availableNetworks, - let defaultNetwork = availableNetworks.defaultCardNetwork, - let altNetwork = availableNetworks.altCardNetwork, - viewModel.showNetworkPicker { - CardNetworkSelector( - defaultNetwork: defaultNetwork, - altNetwork: altNetwork, - selectedNetwork: $viewModel.selectedNetwork - ) - } - - if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { - CardNetworkSelector( - defaultNetwork: .init(network: "CB", code: "1"), - altNetwork: .init(network: "VISA", code: "2"), - selectedNetwork: .constant(.init(network: "CB", code: "1")) - ) - } - - if viewModel.showSaveCard { - - ToggleButton( - "I want to save my credit card information for later.", - isOn: $viewModel.saveCard - ) - } - + cardNumberField + expirationAndCvvRow + holderField + networkPickerSection + saveCardToggle CompliancyNotice() } .background(sessionStore.appearance.backgroundColor) - .toolbar { - if [CardField.cardNumber, CardField.expiration, CardField.cvv].contains(focusedField) { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button(action: nextFocus) { - Text("Next") - .foregroundStyle(colorScheme == .dark ? .white : .black) - } - } - } - } + .toolbar { keyboardToolbar } .onChange(of: focusedField) { field in viewModel.focusedField = field onFieldFocused?(field) @@ -138,20 +44,129 @@ struct CardForm: View { focusedField = nil } .onChange(of: viewModel.selectedNetwork) { [oldValue = viewModel.selectedNetwork] _ in - if oldValue != nil { - focusedField = nil - } + if oldValue != nil { focusedField = nil } } .onChange(of: viewModel.formValid) { isValid in formValid = isValid } .onChange(of: viewModel.cardExpiration) { - if DateFormatter.isValidCardExpiration($0) { - nextFocus() + if DateFormatter.isValidCardExpiration($0) { nextFocus() } + } + } + + // MARK: - Subviews + + private var cardNumberField: some View { + FormFieldView( + label: "Card number", + textValue: $viewModel.cardNumber, + errorMessage: viewModel.cardNumberError, + formatter: CardNumberFormatter(), + keyboardType: .numberPad, + focusedState: $focusedField, + focusedField: .cardNumber, + isScreenshotProtected: true + ) + .id(CardField.cardNumber) + } + + private var expirationAndCvvRow: some View { + HStack { + if viewModel.showExpirationDate { + FormFieldView( + label: "Expiry", + textValue: $viewModel.cardExpiration, + errorMessage: viewModel.cardExpirationError, + formatter: CardDateFormatter(), + keyboardType: .numberPad, + focusedState: $focusedField, + focusedField: .expiration, + isScreenshotProtected: true + ) + .id(CardField.expiration) } + if viewModel.showCardCvv { + FormFieldView( + label: "CVV", + textValue: $viewModel.cardCvv, + errorMessage: viewModel.cardCvvError, + formatter: CardCvvFormatter(), + keyboardType: .numberPad, + focusedState: $focusedField, + focusedField: .cvv, + onTappedInfoAccessory: { isPresentedCvvInfo = true }, + isScreenshotProtected: true + ) + .id(CardField.cvv) + .modifier(CvvInfoDialog(isPresented: $isPresentedCvvInfo)) + } + } + } + + @ViewBuilder + private var holderField: some View { + if viewModel.showCardHolderName { + FormFieldView( + label: "Name on card", + textValue: $viewModel.cardHolderName, + errorMessage: viewModel.cardHolderNameError, + formatter: nil, + focusedState: $focusedField, + focusedField: .holder, + isScreenshotProtected: true + ) + .id(CardField.holder) + .onSubmit { focusedField = nil } + .submitLabel(.done) } } + @ViewBuilder + private var networkPickerSection: some View { + if let availableNetworks = viewModel.availableNetworks, + let defaultNetwork = availableNetworks.defaultCardNetwork, + let altNetwork = availableNetworks.altCardNetwork, + viewModel.showNetworkPicker { + CardNetworkSelector( + defaultNetwork: defaultNetwork, + altNetwork: altNetwork, + selectedNetwork: $viewModel.selectedNetwork + ) + } + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + CardNetworkSelector( + defaultNetwork: .init(network: "CB", code: "1"), + altNetwork: .init(network: "VISA", code: "2"), + selectedNetwork: .constant(.init(network: "CB", code: "1")) + ) + } + } + + @ViewBuilder + private var saveCardToggle: some View { + if viewModel.showSaveCard { + ToggleButton( + "I want to save my credit card information for later.", + isOn: $viewModel.saveCard + ) + } + } + + @ToolbarContentBuilder + private var keyboardToolbar: some ToolbarContent { + if [CardField.cardNumber, CardField.expiration, CardField.cvv].contains(focusedField) { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(action: nextFocus) { + Text("Next") + .foregroundStyle(colorScheme == .dark ? .white : .black) + } + } + } + } + + // MARK: - Actions + private func nextFocus() { guard let focusedField else { return } self.focusedField = viewModel.nextFocus(focusedField) diff --git a/Sources/Monext/Presentation/PaymentMethodScreen/PaymentMethodFormSection/AlternativePaymentMethodForm.swift b/Sources/Monext/Presentation/PaymentMethodScreen/PaymentMethodFormSection/AlternativePaymentMethodForm.swift index 1eae01b..4ff89e7 100644 --- a/Sources/Monext/Presentation/PaymentMethodScreen/PaymentMethodFormSection/AlternativePaymentMethodForm.swift +++ b/Sources/Monext/Presentation/PaymentMethodScreen/PaymentMethodFormSection/AlternativePaymentMethodForm.swift @@ -89,7 +89,8 @@ struct AlternativePaymentMethodForm: View { keyboardType: getKeyboardType(for: field), focusedState: $focusedField, focusedField: field.id, - placeholder: field.placeholder + placeholder: field.placeholder, + isScreenshotProtected: field.secured ?? false ) .padding(.vertical, 8) default: diff --git a/Sources/Monext/Presentation/Views/FormField/FormFieldView.swift b/Sources/Monext/Presentation/Views/FormField/FormFieldView.swift index f4a0b07..7196435 100644 --- a/Sources/Monext/Presentation/Views/FormField/FormFieldView.swift +++ b/Sources/Monext/Presentation/Views/FormField/FormFieldView.swift @@ -38,6 +38,14 @@ struct FormFieldView: View { @EnvironmentObject var sessionStore: SessionStateStore + + /// Active la protection contre les screenshots et screen recordings iOS natifs. + /// À utiliser sur les champs sensibles : PAN, CVV, etc. + var isScreenshotProtected: Bool = false + private var shouldProtectSensitiveData: Bool { + isScreenshotProtected && (sessionStore.env == .production || sessionStore.env == .sandbox) + } + private var config: Appearance { sessionStore.appearance } @@ -117,6 +125,11 @@ struct FormFieldView: View { .tint(config.textfieldTextColor) .keyboardType(keyboardType) .textFieldStyle(MonextTextFieldStyle(config: config)) + // Cache les champs uniquement en Production et Homologation + .privacySensitive(shouldProtectSensitiveData) + .if(shouldProtectSensitiveData) { + $0.screenshotProtected() + } if onTappedInfoAccessory != nil { Image(moduleImage: "ic.i.circle.filled") @@ -191,7 +204,8 @@ struct FormFieldView: View { keyboardType: .numberPad, focusedState: FocusState().projectedValue, focusedField: .cardNumber, - placeholder: "0000 0000 0000 0000" + placeholder: "0000 0000 0000 0000", + isScreenshotProtected: true ) .padding() @@ -205,7 +219,8 @@ struct FormFieldView: View { keyboardType: .numberPad, focusedState: FocusState().projectedValue, focusedField: .cvv, - placeholder: "123" + placeholder: "123", + isScreenshotProtected: true ) .padding() diff --git a/Sources/Monext/Presentation/Views/ScreenshotProtected.swift b/Sources/Monext/Presentation/Views/ScreenshotProtected.swift new file mode 100644 index 0000000..3079782 --- /dev/null +++ b/Sources/Monext/Presentation/Views/ScreenshotProtected.swift @@ -0,0 +1,92 @@ +// +// ScreenshotProtected.swift +// Monext +// + +import SwiftUI +import UIKit + +// MARK: - UIViewRepresentable + +private struct ScreenshotProtectedWrapper: UIViewRepresentable { + let content: Content + + func makeUIView(context: Context) -> UIView { + let secureField = UITextField() + secureField.isSecureTextEntry = true + secureField.isUserInteractionEnabled = false + + guard let secureView = secureField.layer.sublayers?.first?.delegate as? UIView else { + let hosting = UIHostingController(rootView: content) + hosting.view.backgroundColor = .clear + return hosting.view + } + + secureView.subviews.forEach { $0.removeFromSuperview() } + secureView.isUserInteractionEnabled = true + + let hosting = UIHostingController(rootView: content) + hosting.view.backgroundColor = .clear + hosting.view.translatesAutoresizingMaskIntoConstraints = false + secureView.addSubview(hosting.view) + + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: secureView.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: secureView.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: secureView.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: secureView.trailingAnchor), + ]) + + let container = UIView() + container.backgroundColor = .clear + secureView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(secureView) + + NSLayoutConstraint.activate([ + secureView.topAnchor.constraint(equalTo: container.topAnchor), + secureView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + secureView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + secureView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + ]) + + container.tag = 42 + objc_setAssociatedObject(container, &hostingKey, hosting, .OBJC_ASSOCIATION_RETAIN) + + return container + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? { + let hosting = objc_getAssociatedObject(uiView, &hostingKey) as? UIHostingController + let width = proposal.width ?? UIScreen.main.bounds.width + return hosting?.sizeThatFits(in: CGSize(width: width, height: .infinity)) + } +} + +private nonisolated(unsafe) var hostingKey: UInt8 = 0 + +// MARK: - ViewModifier + +struct ScreenshotProtected: ViewModifier { + func body(content: Content) -> some View { + ScreenshotProtectedWrapper(content: content) + } +} + +// MARK: - View extensions + +extension View { + func screenshotProtected() -> some View { + modifier(ScreenshotProtected()) + } + + @ViewBuilder + func `if`(_ condition: Bool, transform: (Self) -> some View) -> some View { + if condition { + transform(self) + } else { + self + } + } +}