diff --git a/Modules/IdCardLib/Sources/IdCardLib/Utilities/SecureData.swift b/Modules/IdCardLib/Sources/IdCardLib/Utilities/SecureData.swift index 08db4ce8..206b1a19 100644 --- a/Modules/IdCardLib/Sources/IdCardLib/Utilities/SecureData.swift +++ b/Modules/IdCardLib/Sources/IdCardLib/Utilities/SecureData.swift @@ -18,43 +18,30 @@ */ import Foundation -import Darwin // for memset_s +import Synchronization +import Darwin // memset_s -/// Holds sensitive bytes and reliably zeroes them on deinit. -public final class SecureData: @unchecked Sendable { - private var storage: Data +/// Holds sensitive bytes and reliably zeroes them on `secureZero()` / `deinit`. +public final class SecureData: Sendable { + private let storage: Mutex - public init(_ bytes: [UInt8]) { - self.storage = Data(bytes) - } - - public init(_ data: Data) { - self.storage = data - } + public init(_ bytes: [UInt8]) { storage = Mutex(Data(bytes)) } + public init(_ data: Data) { storage = Mutex(data) } deinit { secureZero() } - /// Mutating read-only access to the underlying bytes. - func withUnsafeBytes(_ body: (UnsafeRawBufferPointer) throws -> R) rethrows -> R { - try storage.withUnsafeBytes(body) + /// Scoped read-only access to the underlying bytes + func withUnsafeBytes(_ body: (UnsafeRawBufferPointer) -> R) -> R { + let snapshot = storage.withLock { $0 } + return snapshot.withUnsafeBytes(body) } - /// Mutating access when you need to write into the buffer. - func withUnsafeMutableBytes(_ body: (UnsafeMutableRawBufferPointer) throws -> R) rethrows -> R { - try storage.withUnsafeMutableBytes(body) - } - - public var count: Int { storage.count } - - /// Explicitly wipe now (also runs on deinit). + /// Explicitly wipe now (also runs on `deinit`) public func secureZero() { - guard storage.count > 0 else { return } - storage.withUnsafeMutableBytes { buf in - _ = memset_s(buf.baseAddress, buf.count, 0, buf.count) + storage.withLock { data in + guard !data.isEmpty else { return } + data.withUnsafeMutableBytes { _ = memset_s($0.baseAddress, $0.count, 0, $0.count) } + data.removeAll(keepingCapacity: false) } - storage.removeAll(keepingCapacity: false) } - - /// If you need a temporary `Data` view (try to avoid). - func asData() -> Data { storage } } diff --git a/Modules/IdCardLib/Tests/IdCardLibTests/Utilities/SecureDataTests.swift b/Modules/IdCardLib/Tests/IdCardLibTests/Utilities/SecureDataTests.swift new file mode 100644 index 00000000..8343f850 --- /dev/null +++ b/Modules/IdCardLib/Tests/IdCardLibTests/Utilities/SecureDataTests.swift @@ -0,0 +1,100 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +import Foundation +import Testing + +@testable import IdCardLib + +struct SecureDataTests { + + private func bytes(of secureData: SecureData) -> [UInt8] { + secureData.withUnsafeBytes { Array($0) } + } + + @Test + func init_fromByteArray_exposesSameBytes() { + let input: [UInt8] = [1, 2, 3, 4, 5, 6] + let secureData = SecureData(input) + + #expect(bytes(of: secureData) == input) + } + + @Test + func init_fromData_exposesSameBytes() { + let input = Data([0xAA, 0xBB, 0xCC]) + let secureData = SecureData(input) + + #expect(bytes(of: secureData) == Array(input)) + } + + @Test + func init_empty_exposesNoBytes() { + let secureData = SecureData([]) + + #expect(bytes(of: secureData).isEmpty) + } + + @Test + func init_preservesNullAndAllByteValues() { + let input: [UInt8] = [0x00, 0x30, 0xFF, 0x00, 0x01] + let secureData = SecureData(input) + + #expect(bytes(of: secureData) == input) + } + + @Test + func secureZero_clearsTheBuffer() { + let secureData = SecureData([1, 2, 3, 4]) + + secureData.secureZero() + + #expect(bytes(of: secureData).isEmpty) + } + + @Test + func secureZero_staysEmptyWhenCalledTwice() { + let secureData = SecureData([9, 9, 9]) + + secureData.secureZero() + secureData.secureZero() + + #expect(bytes(of: secureData).isEmpty) + } + + @Test + func secureZero_staysEmptyWhenAlreadyEmpty() { + let secureData = SecureData([]) + + secureData.secureZero() + + #expect(bytes(of: secureData).isEmpty) + } + + @Test + func withUnsafeBytes_returnsValueFromClosure() { + let secureData = SecureData([2, 4, 6, 8]) + + let sum = secureData.withUnsafeBytes { raw in + raw.reduce(0) { $0 + Int($1) } + } + + #expect(sum == 20) + } +} diff --git a/RIADigiDoc/Domain/NFC/OperationChangePin.swift b/RIADigiDoc/Domain/NFC/OperationChangePin.swift index a7afc783..c23e6037 100644 --- a/RIADigiDoc/Domain/NFC/OperationChangePin.swift +++ b/RIADigiDoc/Domain/NFC/OperationChangePin.swift @@ -75,6 +75,11 @@ public class OperationChangePin: NFCOperationBase, OperationChangePinProtocol { } do { + defer { + currentPin.secureZero() + newPin.secureZero() + } + updateAlertMessage(step: 1) OperationChangePin.logger().info("NFC: Setting up NFC connection for PIN change...") let tag = try await self.connection.setup(session, tags: tags) diff --git a/RIADigiDoc/Domain/NFC/OperationDecrypt.swift b/RIADigiDoc/Domain/NFC/OperationDecrypt.swift index 4e770d69..889b7f3a 100644 --- a/RIADigiDoc/Domain/NFC/OperationDecrypt.swift +++ b/RIADigiDoc/Domain/NFC/OperationDecrypt.swift @@ -102,6 +102,8 @@ public class OperationDecrypt: NFCOperationBase, OperationDecryptProtocol { OperationDecrypt.logger().info("NFC: Checks complete starting decryption") do { + defer { pin1Number.secureZero() } + updateAlertMessage(step: 1) let tag = try await connection.setup(session, tags: tags) updateAlertMessage(step: 2) diff --git a/RIADigiDoc/Domain/NFC/OperationReadCertAndSign.swift b/RIADigiDoc/Domain/NFC/OperationReadCertAndSign.swift index e8c55533..a48a5a17 100644 --- a/RIADigiDoc/Domain/NFC/OperationReadCertAndSign.swift +++ b/RIADigiDoc/Domain/NFC/OperationReadCertAndSign.swift @@ -118,6 +118,8 @@ public class OperationReadCertAndSign: NFCOperationBase, OperationReadCertAndSig OperationReadCertAndSign.logger().info("NFC: Checks complete starting signing") do { + defer { pin2Number.secureZero() } + updateAlertMessage(step: 1) let tag = try await connection.setup(session, tags: tags) diff --git a/RIADigiDoc/Domain/NFC/OperationUnblockPin.swift b/RIADigiDoc/Domain/NFC/OperationUnblockPin.swift index feb97bd0..6edeca4c 100644 --- a/RIADigiDoc/Domain/NFC/OperationUnblockPin.swift +++ b/RIADigiDoc/Domain/NFC/OperationUnblockPin.swift @@ -78,6 +78,11 @@ public class OperationUnblockPin: NFCOperationBase, OperationUnblockPinProtocol return } do { + defer { + puk.secureZero() + newPin.secureZero() + } + updateAlertMessage(step: 1) let tag = try await self.connection.setup(session, tags: tags) diff --git a/RIADigiDoc/UI/Component/Container/Signing/IdCard/IdCardView.swift b/RIADigiDoc/UI/Component/Container/Signing/IdCard/IdCardView.swift index 1156857f..85d44a06 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/IdCard/IdCardView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/IdCard/IdCardView.swift @@ -28,6 +28,7 @@ struct IdCardView: View { @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled @Environment(\.openURL) private var openURL @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase @Environment(LanguageSettings.self) private var languageSettings @Environment(NavigationPathManager.self) private var pathManager @@ -395,6 +396,11 @@ struct IdCardView: View { pinNumber.removeAll() cancelIdCardAction() } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .background { + cancelIdCardAction() + } + } } private func handleCardError() async { diff --git a/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCView.swift b/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCView.swift index 80bf779a..a5a9b2be 100644 --- a/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCView.swift +++ b/RIADigiDoc/UI/Component/Container/Signing/NFC/NFCView.swift @@ -28,6 +28,7 @@ struct NFCView: View { @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL + @Environment(\.scenePhase) private var scenePhase @Environment(LanguageSettings.self) private var languageSettings @Environment(NavigationPathManager.self) private var pathManager @@ -267,6 +268,17 @@ struct NFCView: View { .onDisappear { cancelSigning() } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .background { + resetPinState() + } + } + } + + private func resetPinState() { + pinNumber.removeAll() + isActionEnabled = viewModel + .isActionEnabled(canNumber: canNumber, pinNumber: pinNumber, pinType: pinType) } func saveInputData() { @@ -317,17 +329,13 @@ struct NFCView: View { } private func cancelDecrypt() { - pinNumber.isEmpty ? () : (pinNumber.removeAll()) - isActionEnabled = viewModel - .isActionEnabled(canNumber: canNumber, pinNumber: pinNumber, pinType: pinType) + resetPinState() taskDecrypt?.cancel() taskDecrypt = nil } private func cancelSigning() { - pinNumber.isEmpty ? () : (pinNumber.removeAll()) - isActionEnabled = viewModel - .isActionEnabled(canNumber: canNumber, pinNumber: pinNumber, pinType: pinType) + resetPinState() taskSign?.cancel() taskSign = nil } diff --git a/RIADigiDoc/UI/Component/My eID/MyEidPinChangeView.swift b/RIADigiDoc/UI/Component/My eID/MyEidPinChangeView.swift index 04e0881c..abd04e76 100644 --- a/RIADigiDoc/UI/Component/My eID/MyEidPinChangeView.swift +++ b/RIADigiDoc/UI/Component/My eID/MyEidPinChangeView.swift @@ -25,6 +25,7 @@ import CommonsLib struct MyEidPinChangeView: View { @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase @Environment(LanguageSettings.self) private var languageSettings @AccessibilityFocusState private var flowTitleFocused: Bool @@ -295,6 +296,11 @@ struct MyEidPinChangeView: View { stepTitleFocused = true } } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .background { + viewModel.clearSensitiveDataOnBackground() + } + } } ) } diff --git a/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift b/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift index bd2cee94..aa6e1b38 100644 --- a/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift +++ b/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift @@ -341,6 +341,7 @@ struct FloatingLabelTextField: View { onDone() } ) + .privacySensitive(isSecure) .toolbar { keyboardToolbar } .accessibilitySortPriority(sortPriority) .accessibilityLabel(Text(verbatim: title)) diff --git a/RIADigiDoc/ViewModel/MyEid/MyEidPinChangeViewModel.swift b/RIADigiDoc/ViewModel/MyEid/MyEidPinChangeViewModel.swift index 93cc06a5..c409e72f 100644 --- a/RIADigiDoc/ViewModel/MyEid/MyEidPinChangeViewModel.swift +++ b/RIADigiDoc/ViewModel/MyEid/MyEidPinChangeViewModel.swift @@ -141,6 +141,11 @@ final class MyEidPinChangeViewModel: MyEidPinChangeViewModelProtocol, Loggable { isSuccess = false } + func clearSensitiveDataOnBackground() { + clearPinCodes() + resetErrors() + } + func handleConfirmStepError() { if step == .confirm && !verifyRepeatedCode() && !isSuccess { inputErrorMessage = "PIN repeat error" diff --git a/RIADigiDoc/ViewModel/Protocols/MyEid/MyEidPinChangeViewModelProtocol.swift b/RIADigiDoc/ViewModel/Protocols/MyEid/MyEidPinChangeViewModelProtocol.swift index d61ec677..7d66f2b6 100644 --- a/RIADigiDoc/ViewModel/Protocols/MyEid/MyEidPinChangeViewModelProtocol.swift +++ b/RIADigiDoc/ViewModel/Protocols/MyEid/MyEidPinChangeViewModelProtocol.swift @@ -32,6 +32,7 @@ protocol MyEidPinChangeViewModelProtocol: Sendable { func submit(nfcStringsUtil: NFCSessionStringsUtil) async func resetErrors() + func clearSensitiveDataOnBackground() func verifyNewCode() func verifyRepeatedCode() -> Bool