diff --git a/MendixNative.podspec b/MendixNative.podspec index d21b9a8..5880b0d 100644 --- a/MendixNative.podspec +++ b/MendixNative.podspec @@ -22,4 +22,9 @@ Pod::Spec.new do |s| s.dependency "ReactAppDependencyProvider" install_modules_dependencies(s) + + s.test_spec 'KeychainTests' do |tests| + tests.source_files = 'tests/ios/**/*.swift' + tests.requires_app_host = true + end end diff --git a/README.md b/README.md index e5036a0..143dbf2 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,51 @@ Run tests: yarn test ``` +## Tests for iOS encrypted storage + +New encrypted-storage values and persisted session cookies use +`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. They remain readable while +locked after the first unlock, but cannot migrate to a different device through +a backup. Legacy default-accessibility items are upgraded in place on successful +read without rotating keys or changing their values. Failed migrations are logged +and retried on later reads; they do not delete the item. + +`MxEncryption.getItemStrict(key)` returns `null` only for an absent iOS Keychain +item and rejects inaccessible or failed reads. The existing `getItem` still +returns `null` for `errSecInteractionNotAllowed`. On Android the strict getter +delegates to the existing storage implementation; its policy is unchanged. +The strict getter requires a rebuilt native binary; do not deliver callers to +older binaries through OTA without matching native-dependency requirements. + +Release this policy together with client recovery for an existing encrypted +offline database whose key is genuinely absent after device restore. Never +delete a database or generate a replacement key based on the legacy getter's +`null` result. Recovery must clear the affected session, recreate the database, +and require login/resync. Persistent authentication cookies are handled separately. + +### Keychain tests + +The example's Debug-only Keychain inspector supports the encryption harness +attribute and legacy-migration checks. Rebuild before running +`yarn example harness:ios:with:build`; these tests cannot run under ordinary Jest. + +The CocoaPods `KeychainTests` test spec includes native cookie lifecycle, +migration-failure, and strict-read tests. Run `yarn example pod` to install its +test target, then run that target's tests on an iOS simulator in Xcode. These +tests use the test host's cookie store and Keychain accounts, not production +app storage. Physical-device backup/restore and locked-background checks remain +separate integration requirements. + +For example, from the repository root: + +```sh +xcodebuild test \ + -workspace example/ios/MendixNativeExample.xcworkspace \ + -scheme MendixNative-Unit-KeychainTests \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ + -parallel-testing-enabled NO +``` + ### 6. Clean Build Artifacts If you encounter build issues: diff --git a/android/src/main/java/com/mendixnative/encryption/MxEncryptionModule.kt b/android/src/main/java/com/mendixnative/encryption/MxEncryptionModule.kt index 9c4f9e7..9464f30 100644 --- a/android/src/main/java/com/mendixnative/encryption/MxEncryptionModule.kt +++ b/android/src/main/java/com/mendixnative/encryption/MxEncryptionModule.kt @@ -22,6 +22,10 @@ class MxEncryptionModule(reactContext: ReactApplicationContext) : encryptedStorage.getItem(key, promise) } + override fun getItemStrict(key: String, promise: Promise) { + encryptedStorage.getItem(key, promise) + } + override fun removeItem(key: String, promise: Promise) { encryptedStorage.removeItem(key, promise) } diff --git a/example/__tests__/encrypted-storage.harness.ts b/example/__tests__/encrypted-storage.harness.ts index fb81841..a5ffebf 100644 --- a/example/__tests__/encrypted-storage.harness.ts +++ b/example/__tests__/encrypted-storage.harness.ts @@ -1,5 +1,7 @@ import { describe, test, expect, beforeEach } from 'react-native-harness'; import { RNMendixEncryptedStorage } from 'mendix-native'; +import { Platform } from 'react-native'; +import { getKeychainTestSupport } from '../test-support/keychain'; describe('RNMendixEncryptedStorage', () => { beforeEach(async () => { @@ -7,6 +9,36 @@ describe('RNMendixEncryptedStorage', () => { await RNMendixEncryptedStorage.clear(); }); + if (Platform.OS === 'ios') { + test('writes and migrates values with device-only accessibility', async () => { + const support = getKeychainTestSupport(); + await RNMendixEncryptedStorage.setItem('new-policy-key', 'new-value'); + const created = await support.inspectItem('new-policy-key'); + expect(created).not.toBe(null); + expect(created?.accessibility).toBe(created?.expectedAccessibility); + + await support.seedLegacyItem('legacy-policy-key', 'unchanged-value'); + await expect( + RNMendixEncryptedStorage.getItem('legacy-policy-key') + ).resolves.toBe('unchanged-value'); + const migrated = await support.inspectItem('legacy-policy-key'); + expect(migrated).not.toBe(null); + expect(migrated?.accessibility).toBe(migrated?.expectedAccessibility); + }); + + test('replaces legacy items without filtering deletion by accessibility', async () => { + const support = getKeychainTestSupport(); + await support.seedLegacyItem('legacy-replacement', 'old'); + await RNMendixEncryptedStorage.setItem('legacy-replacement', 'new'); + await expect( + RNMendixEncryptedStorage.getItem('legacy-replacement') + ).resolves.toBe('new'); + const attributes = await support.inspectItem('legacy-replacement'); + expect(attributes).not.toBe(null); + expect(attributes?.accessibility).toBe(attributes?.expectedAccessibility); + }); + } + describe('setItem and getItem', () => { test('should store and retrieve a string value', async () => { const key = 'testKey'; diff --git a/example/__tests__/encryption.harness.ts b/example/__tests__/encryption.harness.ts index 02c3c29..5b7359d 100644 --- a/example/__tests__/encryption.harness.ts +++ b/example/__tests__/encryption.harness.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, test } from 'react-native-harness'; import { MxEncryption, RNMendixEncryptedStorage } from 'mendix-native'; +import { Platform } from 'react-native'; +import { getKeychainTestSupport } from '../test-support/keychain'; describe('MxEncryption', () => { beforeEach(async () => { @@ -41,4 +43,38 @@ describe('MxEncryption', () => { RNMendixEncryptedStorage.IS_ENCRYPTED ); }); + + test('strict reads return stored values and null for missing keys', async () => { + await expect(MxEncryption.getItemStrict('missing')).resolves.toBe(null); + await MxEncryption.setItem('strict-key', 'value'); + await expect(MxEncryption.getItemStrict('strict-key')).resolves.toBe( + 'value' + ); + }); + + if (Platform.OS === 'ios') { + test('new writes use device-only after-first-unlock accessibility', async () => { + await MxEncryption.setItem('policy-key', 'value'); + const attributes = + await getKeychainTestSupport().inspectItem('policy-key'); + expect(attributes).not.toBe(null); + expect(attributes?.accessibility).toBe(attributes?.expectedAccessibility); + }); + + test('strict reads migrate legacy values without rotating them', async () => { + const support = getKeychainTestSupport(); + await support.seedLegacyItem('legacy-strict-key', 'original-password'); + const before = await support.inspectItem('legacy-strict-key'); + expect(before?.accessibility).toBe(before?.legacyAccessibility); + await expect( + MxEncryption.getItemStrict('legacy-strict-key') + ).resolves.toBe('original-password'); + const after = await support.inspectItem('legacy-strict-key'); + expect(after).not.toBe(null); + expect(after?.accessibility).toBe(after?.expectedAccessibility); + await expect(MxEncryption.getItem('legacy-strict-key')).resolves.toBe( + 'original-password' + ); + }); + } }); diff --git a/example/ios/MendixNativeExample.xcodeproj/project.pbxproj b/example/ios/MendixNativeExample.xcodeproj/project.pbxproj index 21408d6..a185a1f 100644 --- a/example/ios/MendixNativeExample.xcodeproj/project.pbxproj +++ b/example/ios/MendixNativeExample.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 1499ABBDB7935B67C3951198 /* libPods-MendixNativeExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 24A5FB93E78C6A91516539F4 /* libPods-MendixNativeExample.a */; }; + 254000000000000000000001 /* KeychainTestSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = 254000000000000000000002 /* KeychainTestSupport.m */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 761780EF2CA45674006654EE /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EE2CA45674006654EE /* SceneDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; @@ -21,6 +22,7 @@ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = MendixNativeExample/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = MendixNativeExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; 24A5FB93E78C6A91516539F4 /* libPods-MendixNativeExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MendixNativeExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 254000000000000000000002 /* KeychainTestSupport.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = KeychainTestSupport.m; path = MendixNativeExample/KeychainTestSupport.m; sourceTree = ""; }; 503B3921D3702B3C7DF3D941 /* Pods-MendixNativeExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MendixNativeExample.release.xcconfig"; path = "Target Support Files/Pods-MendixNativeExample/Pods-MendixNativeExample.release.xcconfig"; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = MendixNativeExample/AppDelegate.swift; sourceTree = ""; }; 761780EE2CA45674006654EE /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = MendixNativeExample/SceneDelegate.swift; sourceTree = ""; }; @@ -45,6 +47,7 @@ 13B07FAE1A68108700A75B9A /* MendixNativeExample */ = { isa = PBXGroup; children = ( + 254000000000000000000002 /* KeychainTestSupport.m */, 13B07FB51A68108700A75B9A /* Images.xcassets */, 761780EC2CA45674006654EE /* AppDelegate.swift */, 13B07FB61A68108700A75B9A /* Info.plist */, @@ -259,6 +262,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 254000000000000000000001 /* KeychainTestSupport.m in Sources */, 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, 761780EF2CA45674006654EE /* SceneDelegate.swift in Sources */, ); diff --git a/example/ios/MendixNativeExample/KeychainTestSupport.m b/example/ios/MendixNativeExample/KeychainTestSupport.m new file mode 100644 index 0000000..c90e250 --- /dev/null +++ b/example/ios/MendixNativeExample/KeychainTestSupport.m @@ -0,0 +1,67 @@ +#if DEBUG +#import +#import + +@interface KeychainTestSupport : NSObject +@end + +@implementation KeychainTestSupport +RCT_EXPORT_MODULE() + ++ (BOOL)requiresMainQueueSetup { return NO; } + +RCT_EXPORT_METHOD(seedLegacyItem:(NSString *)key + value:(NSString *)value + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSDictionary *identity = @{ + (__bridge NSString *)kSecClass: (__bridge NSString *)kSecClassGenericPassword, + (__bridge NSString *)kSecAttrAccount: key + }; + OSStatus status = SecItemDelete((__bridge CFDictionaryRef)identity); + if (status != errSecSuccess && status != errSecItemNotFound) { + reject([NSString stringWithFormat:@"%d", (int)status], @"Failed to remove test item", nil); + return; + } + NSMutableDictionary *attributes = [identity mutableCopy]; + attributes[(__bridge NSString *)kSecValueData] = [value dataUsingEncoding:NSUTF8StringEncoding]; + attributes[(__bridge NSString *)kSecAttrAccessible] = (__bridge NSString *)kSecAttrAccessibleWhenUnlocked; + status = SecItemAdd((__bridge CFDictionaryRef)attributes, NULL); + if (status != errSecSuccess) { + reject([NSString stringWithFormat:@"%d", (int)status], @"Failed to seed test item", nil); + return; + } + resolve(nil); +} + +RCT_EXPORT_METHOD(inspectItem:(NSString *)key + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSDictionary *query = @{ + (__bridge NSString *)kSecClass: (__bridge NSString *)kSecClassGenericPassword, + (__bridge NSString *)kSecAttrAccount: key, + (__bridge NSString *)kSecReturnAttributes: @YES, + (__bridge NSString *)kSecMatchLimit: (__bridge NSString *)kSecMatchLimitOne, + (__bridge NSString *)kSecUseAuthenticationUI: (__bridge NSString *)kSecUseAuthenticationUIFail + }; + CFTypeRef result = NULL; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result); + if (status == errSecItemNotFound) { + resolve(nil); + return; + } + if (status != errSecSuccess) { + reject([NSString stringWithFormat:@"%d", (int)status], @"Failed to inspect test item", nil); + return; + } + NSDictionary *attributes = CFBridgingRelease(result); + resolve(@{ + @"accessibility": attributes[(__bridge NSString *)kSecAttrAccessible], + @"expectedAccessibility": (__bridge NSString *)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + @"legacyAccessibility": (__bridge NSString *)kSecAttrAccessibleWhenUnlocked + }); +} +@end +#endif diff --git a/example/ios/Podfile b/example/ios/Podfile index 6d139ba..f1bc455 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -20,6 +20,7 @@ ENV['RCT_USE_RN_DEP'] = '1' ENV['RCT_USE_PREBUILT_RNCORE'] = '1' target 'MendixNativeExample' do + pod 'MendixNative', :path => '../..', :testspecs => ['KeychainTests'] config = use_native_modules! use_react_native!( diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 212459d..0ccf9c8 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -3,7 +3,32 @@ PODS: - hermes-engine (250829098.0.9): - hermes-engine/Pre-built (= 250829098.0.9) - hermes-engine/Pre-built (250829098.0.9) - - MendixNative (0.5.2): + - MendixNative (0.5.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactAppDependencyProvider + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNCAsyncStorage + - SSZipArchive + - Yoga + - MendixNative/KeychainTests (0.5.3): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1885,6 +1910,7 @@ DEPENDENCIES: - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - MendixNative (from `../..`) + - MendixNative/KeychainTests (from `../..`) - "op-sqlite (from `../node_modules/@op-engineering/op-sqlite`)" - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`) @@ -2125,8 +2151,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: e97c19a5a442429d1988f182a1940fb08df514da - hermes-engine: a7179a4cd45fa3f8143712e52bd3c2d20b5274a0 - MendixNative: b789c95cb7e776e332a0dabd4f39b91e20c1f31d + hermes-engine: 9df9b07f08ae444417c6eb053170f1e77c1605e6 + MendixNative: 799ad101721b38766402427435b2648171863645 op-sqlite: e9ef65bcf95a97863874cee87841425bb71c8396 OpenSSL-Universal: 9110d21982bb7e8b22a962b6db56a8aa805afde7 RCTDeprecation: af44b104091a34482596cd9bd7e8d90c4e9b4bd7 @@ -2137,7 +2163,7 @@ SPEC CHECKSUMS: React: 1ba7d364ade7d883a1ec055bfc3606f35fdee17b React-callinvoker: bc2a26f8d84fb01f003fc6de6c9337b64715f95b React-Core: 7840d3a80b43a95c5e80ef75146bd70925ebab0f - React-Core-prebuilt: a666604237ac1fb51754ee37234701cf197d851b + React-Core-prebuilt: c2c0cfd25078328cf622f0f6e0cef4b7171444e5 React-CoreModules: 2eb010400b63b89e53a324ffb3c112e4c7c3ce42 React-cxxreact: a558e92199d26f145afa9e62c4233cf8e7950efe React-debug: 755200a6e7f5e6e0a40ff8d215493d43cce285fc @@ -2198,12 +2224,12 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: e96e93b493d8d86eeaee3e590ba0be53f6abe46f ReactCodegen: 797de5178718324c6eba3327b07f9a423fbd5787 ReactCommon: 07572bf9e687c8a52fbe4a3641e9e3a1a477c78e - ReactNativeDependencies: 9ed9fcba1a22047730c895521de699bacbb90312 + ReactNativeDependencies: c363420f097c025836bf2b1d455817d78bc6195e RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 RNGestureHandler: b4f6de20b30325ee40b693f558ad02213201ce91 SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef Yoga: c0b3f2c7e8d3e327e450223a2414ca3fa296b9a2 -PODFILE CHECKSUM: 0cee92783ebe27187df65ef7b23a6a340d1aa921 +PODFILE CHECKSUM: 58944cbe2d22c891ab216489e0ca2d6913f019d8 COCOAPODS: 1.15.2 diff --git a/example/test-support/keychain.ts b/example/test-support/keychain.ts new file mode 100644 index 0000000..f92001c --- /dev/null +++ b/example/test-support/keychain.ts @@ -0,0 +1,18 @@ +import { TurboModuleRegistry, type TurboModule } from 'react-native'; + +interface KeychainAttributes { + accessibility: string; + expectedAccessibility: string; + legacyAccessibility: string; +} + +interface KeychainTestSupport extends TurboModule { + seedLegacyItem(key: string, value: string): Promise; + inspectItem(key: string): Promise; +} + +export function getKeychainTestSupport() { + return TurboModuleRegistry.getEnforcing( + 'KeychainTestSupport' + ); +} diff --git a/ios/Modules/Encryption/EncryptedStorage.swift b/ios/Modules/Encryption/EncryptedStorage.swift index c0c1bf7..58f8835 100644 --- a/ios/Modules/Encryption/EncryptedStorage.swift +++ b/ios/Modules/Encryption/EncryptedStorage.swift @@ -1,5 +1,6 @@ import Foundation +import Security @objcMembers public class EncryptedStorage: NSObject { @@ -14,10 +15,19 @@ import Foundation let storeQuery = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, - kSecValueData: dataFromValue + kSecValueData: dataFromValue, + kSecAttrAccessible: KeychainHelper.accessibility ] as CFDictionary - SecItemDelete(storeQuery) + let identity = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key + ] as CFDictionary + let deleteStatus = SecItemDelete(identity) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + promise.reject("An error occured while saving value", errorCode: Int(deleteStatus)) + return + } let status = SecItemAdd(storeQuery, nil) @@ -29,6 +39,20 @@ import Foundation } public func getItem(key: String, promise: Promise) { + readItem(key: key, promise: promise, strict: false) + } + + public func getItemStrict(key: String, promise: Promise) { + readItem(key: key, promise: promise, strict: true) + } + + @nonobjc func readItem( + key: String, + promise: Promise, + strict: Bool, + copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemCopyMatching, + migrate: (String) -> Bool = { KeychainHelper.migrateAccessibility(key: $0) } + ) { let query = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, @@ -37,15 +61,16 @@ import Foundation kSecUseAuthenticationUI: kSecUseAuthenticationUIFail ] as CFDictionary var dataRef: CFTypeRef? - let status = SecItemCopyMatching(query, &dataRef) - if status == errSecInteractionNotAllowed { + let status = copyMatching(query, &dataRef) + if status == errSecInteractionNotAllowed && !strict { // Device locked or item requires auth — don't delete, resolve as absent promise.resolve(NSNull()) } else if status == errSecSuccess { guard let data = dataRef as? Data, let value = String(data: data, encoding: .utf8) else { - promise.reject("An error occured while retrieving value", errorCode: Int(status)) + promise.reject("An error occured while retrieving value", errorCode: Int(errSecDecode)) return } + _ = migrate(key) promise.resolve(value) } else if status == errSecItemNotFound { promise.resolve(NSNull()) diff --git a/ios/Modules/Helper/KeychainHelper.swift b/ios/Modules/Helper/KeychainHelper.swift index f6beeb3..7a1616c 100644 --- a/ios/Modules/Helper/KeychainHelper.swift +++ b/ios/Modules/Helper/KeychainHelper.swift @@ -1,6 +1,51 @@ import Foundation +import Security public class KeychainHelper { + + static let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + static func migrateAccessibility( + key: String, + copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemCopyMatching, + update: (CFDictionary, CFDictionary) -> OSStatus = SecItemUpdate + ) -> Bool { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key, + kSecReturnAttributes: true, + kSecMatchLimit: kSecMatchLimitOne, + kSecUseAuthenticationUI: kSecUseAuthenticationUIFail + ] + var result: CFTypeRef? + let readStatus = copyMatching(query as CFDictionary, &result) + guard readStatus == errSecSuccess else { + NSLog("KeychainHelper: Failed to inspect accessibility with status: \(readStatus)") + return false + } + guard let attributes = result as? [String: Any], + let current = attributes[kSecAttrAccessible as String] as? String else { + NSLog("KeychainHelper: Missing accessibility attribute") + return false + } + if current == accessibility as String { + return true + } + // Only migrate the old default; do not weaken explicitly stricter items. + guard current == kSecAttrAccessibleWhenUnlocked as String else { + NSLog("KeychainHelper: Preserving non-default accessibility") + return false + } + let identity = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key + ] as CFDictionary + let status = update(identity, [kSecAttrAccessible: accessibility] as CFDictionary) + if status != errSecSuccess { + NSLog("KeychainHelper: Failed to migrate accessibility with status: \(status)") + } + return status == errSecSuccess + } public static func clear() { clear(scopeKey: MxConfiguration.appName) diff --git a/ios/Modules/NativeCookieModule/SessionCookieStore.swift b/ios/Modules/NativeCookieModule/SessionCookieStore.swift index ea2dd2d..a48dd49 100644 --- a/ios/Modules/NativeCookieModule/SessionCookieStore.swift +++ b/ios/Modules/NativeCookieModule/SessionCookieStore.swift @@ -1,4 +1,5 @@ import Foundation +import Security public class SessionCookieStore { @@ -9,21 +10,38 @@ public class SessionCookieStore { // MARK: - Public API public static func restore() { + restore( + storage: HTTPCookieStorage.shared, + read: { get(key: storageKey) }, + remove: { clear() } + ) + } - guard let cookies = get(key: storageKey) else { + static func restore( + storage: HTTPCookieStorage, + read: () -> (cookies: [HTTPCookie], migrated: Bool)?, + remove: () -> Void + ) { + guard let restored = read() else { NSLog("SessionCookieStore: No cookies to restore") return } - let storage = HTTPCookieStorage.shared let existing = Set(storage.cookies ?? []) - cookies.filter { !existing.contains($0) }.forEach { storage.setCookie($0) } + restored.cookies.filter { !existing.contains($0) }.forEach { storage.setCookie($0) } - clear() + if restored.migrated { + remove() + } } public static func persist() { + persist(completion: {}) + } + + static func persist(completion: @escaping () -> Void) { queue.async { + defer { completion() } let sessionCookies = HTTPCookieStorage.shared.cookies?.filter { isSessionCookie($0) } ?? [] guard !sessionCookies.isEmpty else { clear() @@ -47,7 +65,12 @@ public class SessionCookieStore { do { let data = try NSKeyedArchiver.archivedData(withRootObject: cookies, requiringSecureCoding: false) clear(key: key) - let storeQuery = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data] as CFDictionary + let storeQuery = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key, + kSecValueData: data, + kSecAttrAccessible: KeychainHelper.accessibility + ] as CFDictionary let status = SecItemAdd(storeQuery, nil) if status != noErr { NSLog("SessionCookieStore: Failed to persist session cookies with status: \(status)") @@ -57,7 +80,12 @@ public class SessionCookieStore { } } - private static func get(key: String) -> [HTTPCookie]? { + static func get( + key: String, + copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemCopyMatching, + migrate: (String) -> Bool = { KeychainHelper.migrateAccessibility(key: $0) }, + remove: (String) -> Void = { clear(key: $0) } + ) -> (cookies: [HTTPCookie], migrated: Bool)? { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, @@ -65,26 +93,38 @@ public class SessionCookieStore { kSecUseAuthenticationUI: kSecUseAuthenticationUIFail ] var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) + let status = copyMatching(query as CFDictionary, &item) if status == errSecInteractionNotAllowed { NSLog("SessionCookieStore: Item inaccessible (device locked), skipping restore") return nil } else if status == errSecSuccess, let data = item as? Data { let result = ObjCExceptionCatcher.catchException { - try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, HTTPCookie.self], from: data) + do { + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + // HTTPCookie uses NSCoding, matching the existing non-secure archive format. + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return unarchiver.decodeObject( + of: [NSArray.self, HTTPCookie.self], + forKey: NSKeyedArchiveRootObjectKey + ) + } catch { + NSLog("SessionCookieStore: Cookie decoding failed with code: \((error as NSError).code)") + return nil + } } guard let cookies = result as? [HTTPCookie] else { NSLog("SessionCookieStore: Failed to deserialize cookies, clearing") - clear(key: key) + remove(key) return nil } - return cookies + return (cookies, migrate(key)) } else if status != errSecItemNotFound { // Any other unreadable state — delete to prevent repeated failures NSLog("SessionCookieStore: Unreadable legacy item (status: \(status)), clearing") - clear(key: key) + remove(key) return nil } else { NSLog("SessionCookieStore: No session cookies found") diff --git a/ios/TurboModules/MxEncryption/MxEncryption.mm b/ios/TurboModules/MxEncryption/MxEncryption.mm index 1476dec..b119787 100644 --- a/ios/TurboModules/MxEncryption/MxEncryption.mm +++ b/ios/TurboModules/MxEncryption/MxEncryption.mm @@ -35,6 +35,13 @@ - (void)removeItem:(nonnull NSString *)key [[[EncryptedStorage alloc] init] removeItemWithKey:key promise:promise]; } +- (void)getItemStrict:(nonnull NSString *)key + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject { + Promise *promise = [[Promise alloc] initWithResolve:resolve reject:reject]; + [[[EncryptedStorage alloc] init] getItemStrictWithKey:key promise:promise]; +} + - (void)clear:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject { Promise *promise = [[Promise alloc] initWithResolve:resolve reject:reject]; diff --git a/package.json b/package.json index 3fa4343..fdadb15 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "android", "ios", "cpp", + "tests/ios", "*.podspec", "react-native.config.js", "!ios/build", diff --git a/src/encryption/NativeMxEncryption.ts b/src/encryption/NativeMxEncryption.ts index 02975af..22d0cc3 100644 --- a/src/encryption/NativeMxEncryption.ts +++ b/src/encryption/NativeMxEncryption.ts @@ -3,6 +3,7 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; export interface Spec extends TurboModule { setItem(key: string, value: string): Promise; getItem(key: string): Promise; + getItemStrict(key: string): Promise; removeItem(key: string): Promise; clear(): Promise; isEncrypted(): boolean; diff --git a/src/encryption/index.ts b/src/encryption/index.ts index 2186d79..0d199c2 100644 --- a/src/encryption/index.ts +++ b/src/encryption/index.ts @@ -7,6 +7,9 @@ export const MxEncryption = { async getItem(key: string): Promise { return NativeMxEncryption.getItem(key); }, + async getItemStrict(key: string): Promise { + return NativeMxEncryption.getItemStrict(key); + }, async removeItem(key: string): Promise { return NativeMxEncryption.removeItem(key); }, diff --git a/tests/ios/EncryptedStorageReadTests.swift b/tests/ios/EncryptedStorageReadTests.swift new file mode 100644 index 0000000..0cd8c7a --- /dev/null +++ b/tests/ios/EncryptedStorageReadTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Security +import XCTest +@testable import MendixNative + +final class EncryptedStorageReadTests: XCTestCase { + func testFailedMigrationStillReturnsReadableValue() { + for strict in [true, false] { + var resolved = false + var migrated = false + let promise = Promise( + resolve: { value in + resolved = true + XCTAssertEqual(value as? String, "unchanged-password") + }, + reject: { _, _, _ in XCTFail("Migration failure must not discard a readable value") } + ) + EncryptedStorage().readItem( + key: "test-password", + promise: promise, + strict: strict, + copyMatching: { _, result in + result?.pointee = Data("unchanged-password".utf8) as CFData + return errSecSuccess + }, + migrate: { key in + XCTAssertEqual(key, "test-password") + migrated = true + return false + } + ) + XCTAssertTrue(migrated) + XCTAssertTrue(resolved) + } + } + + func testStrictReadRejectsInaccessibleAndFailedReads() { + for status in [errSecInteractionNotAllowed, errSecNotAvailable, errSecAuthFailed] { + var rejectedCode: String? + let promise = Promise( + resolve: { _ in XCTFail("An inaccessible item must not appear absent") }, + reject: { code, _, _ in rejectedCode = code } + ) + EncryptedStorage().readItem( + key: "test-password", + promise: promise, + strict: true, + copyMatching: { query, _ in + XCTAssertEqual( + (query as NSDictionary)[kSecUseAuthenticationUI] as? String, + kSecUseAuthenticationUIFail as String + ) + return status + } + ) + XCTAssertEqual(rejectedCode, String(status)) + } + } + + func testBothReadModesResolveMissingItemsAsNull() { + for strict in [true, false] { + var resolved = false + let promise = Promise( + resolve: { value in + resolved = true + XCTAssertTrue(value is NSNull) + }, + reject: { _, _, _ in XCTFail("An absent item must resolve") } + ) + EncryptedStorage().readItem( + key: "test-password", + promise: promise, + strict: strict, + copyMatching: { _, _ in errSecItemNotFound } + ) + XCTAssertTrue(resolved) + } + } + + func testLegacyReadStillResolvesInaccessibleItemsAsNull() { + var resolved = false + let promise = Promise( + resolve: { value in + resolved = true + XCTAssertTrue(value is NSNull) + }, + reject: { _, _, _ in XCTFail("The legacy contract must not change") } + ) + EncryptedStorage().readItem( + key: "test-password", + promise: promise, + strict: false, + copyMatching: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertTrue(resolved) + } + + func testInvalidUTF8RejectsInsteadOfAppearingAbsent() { + for strict in [true, false] { + var rejectedCode: String? + let promise = Promise( + resolve: { _ in XCTFail("Malformed data must not appear absent") }, + reject: { code, _, _ in rejectedCode = code } + ) + EncryptedStorage().readItem( + key: "test-password", + promise: promise, + strict: strict, + copyMatching: { _, result in + result?.pointee = Data([0xff]) as CFData + return errSecSuccess + } + ) + XCTAssertEqual(rejectedCode, String(errSecDecode)) + } + } +} diff --git a/tests/ios/KeychainMigrationTests.swift b/tests/ios/KeychainMigrationTests.swift new file mode 100644 index 0000000..2bdcddd --- /dev/null +++ b/tests/ios/KeychainMigrationTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Security +import XCTest +@testable import MendixNative + +final class KeychainMigrationTests: XCTestCase { + func testMigrationChangesOnlyAccessibility() { + var updates = 0 + let migrated = KeychainHelper.migrateAccessibility( + key: "test-account", + copyMatching: { query, result in + let attributes = query as NSDictionary + XCTAssertEqual(attributes[kSecUseAuthenticationUI] as? String, kSecUseAuthenticationUIFail as String) + result?.pointee = [kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked] as CFDictionary + return errSecSuccess + }, + update: { query, attributes in + updates += 1 + let identity = query as NSDictionary + XCTAssertEqual(identity.count, 2) + XCTAssertEqual(identity[kSecAttrAccount] as? String, "test-account") + XCTAssertEqual(identity[kSecClass] as? String, kSecClassGenericPassword as String) + let changes = attributes as NSDictionary + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes[kSecAttrAccessible] as? String, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + XCTAssertNil(changes[kSecValueData]) + return errSecSuccess + } + ) + XCTAssertTrue(migrated) + XCTAssertEqual(updates, 1) + } + + func testInspectionFailuresNeverUpdate() { + for status in [errSecInteractionNotAllowed, errSecItemNotFound, errSecNotAvailable] { + XCTAssertFalse(KeychainHelper.migrateAccessibility( + key: "test-account", + copyMatching: { _, _ in status }, + update: { _, _ in + XCTFail("An unreadable item must not be modified") + return errSecSuccess + } + )) + } + } + + func testMissingAttributesNeverUpdate() { + XCTAssertFalse(KeychainHelper.migrateAccessibility( + key: "test-account", + copyMatching: { _, result in + result?.pointee = [:] as CFDictionary + return errSecSuccess + }, + update: { _, _ in + XCTFail("An item without accessibility must not be modified") + return errSecSuccess + } + )) + } + + func testMigratedAndStricterItemsAreNotUpdated() { + for accessibility in [ + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly + ] { + let result = KeychainHelper.migrateAccessibility( + key: "test-account", + copyMatching: { _, output in + output?.pointee = [kSecAttrAccessible: accessibility] as CFDictionary + return errSecSuccess + }, + update: { _, _ in + XCTFail("Already migrated or stricter items must not be changed") + return errSecSuccess + } + ) + XCTAssertEqual(result, accessibility as String == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + } + } + + func testFailedUpdateCanBeRetried() { + var attempts = 0 + func migrate() -> Bool { + KeychainHelper.migrateAccessibility( + key: "test-account", + copyMatching: { _, result in + result?.pointee = [kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked] as CFDictionary + return errSecSuccess + }, + update: { _, _ in + attempts += 1 + return attempts == 1 ? errSecInteractionNotAllowed : errSecSuccess + } + ) + } + XCTAssertFalse(migrate()) + XCTAssertTrue(migrate()) + XCTAssertEqual(attempts, 2) + } +} diff --git a/tests/ios/SessionCookieStoreTests.swift b/tests/ios/SessionCookieStoreTests.swift new file mode 100644 index 0000000..41e19d2 --- /dev/null +++ b/tests/ios/SessionCookieStoreTests.swift @@ -0,0 +1,205 @@ +import Foundation +import Security +import XCTest +@testable import MendixNative + +final class SessionCookieStoreTests: XCTestCase { + private let cookieName = "MOO2540-test-session" + private var storageKey: String { + (Bundle.main.bundleIdentifier ?? "com.mendix.app") + "sessionCookies" + } + + private func cookie() throws -> HTTPCookie { + try XCTUnwrap(HTTPCookie(properties: [ + .name: cookieName, + .value: "test-session-value", + .domain: "moo2540.invalid", + .path: "/" + ])) + } + + private func persistAndWait() { + let completed = expectation(description: "Cookie persistence completed") + SessionCookieStore.persist { completed.fulfill() } + wait(for: [completed], timeout: 5) + } + + private func removeInMemoryCookies() { + for cookie in HTTPCookieStorage.shared.cookies ?? [] { + HTTPCookieStorage.shared.deleteCookie(cookie) + } + } + + override func setUp() { + super.setUp() + removeInMemoryCookies() + SessionCookieStore.clear() + } + + override func tearDown() { + removeInMemoryCookies() + SessionCookieStore.clear() + super.tearDown() + } + + func testPersistenceUsesDeviceOnlyAccessibilityAndRestoreConsumesCopy() throws { + let original = try cookie() + HTTPCookieStorage.shared.setCookie(original) + persistAndWait() + + var result: CFTypeRef? + let query = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: storageKey, + kSecReturnAttributes: true + ] as CFDictionary + XCTAssertEqual(SecItemCopyMatching(query, &result), errSecSuccess) + let attributes = try XCTUnwrap(result as? [String: Any]) + XCTAssertEqual( + attributes[kSecAttrAccessible as String] as? String, + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String + ) + + removeInMemoryCookies() + SessionCookieStore.restore() + let restored = (HTTPCookieStorage.shared.cookies ?? []).filter { $0.name == cookieName } + XCTAssertEqual(restored.count, 1) + XCTAssertEqual(restored.first?.value, original.value) + XCTAssertNil(SessionCookieStore.get(key: storageKey)) + SessionCookieStore.restore() + XCTAssertEqual((HTTPCookieStorage.shared.cookies ?? []).filter { $0.name == cookieName }.count, 1) + } + + func testEmptyPersistenceAndExplicitClearRemoveStoredCopy() throws { + HTTPCookieStorage.shared.setCookie(try cookie()) + persistAndWait() + XCTAssertNotNil(SessionCookieStore.get(key: storageKey)) + removeInMemoryCookies() + persistAndWait() + XCTAssertNil(SessionCookieStore.get(key: storageKey)) + + HTTPCookieStorage.shared.setCookie(try cookie()) + persistAndWait() + SessionCookieStore.clear() + XCTAssertNil(SessionCookieStore.get(key: storageKey)) + } + + func testPersistenceExcludesPersistentCookies() throws { + let session = try cookie() + let persistent = try XCTUnwrap(HTTPCookie(properties: [ + .name: "MOO2540-test-persistent", + .value: "persistent-value", + .domain: "moo2540.invalid", + .path: "/", + .expires: Date().addingTimeInterval(3600) + ])) + HTTPCookieStorage.shared.setCookie(session) + HTTPCookieStorage.shared.setCookie(persistent) + persistAndWait() + + let stored = try XCTUnwrap(SessionCookieStore.get(key: storageKey)) + XCTAssertEqual(stored.cookies.count, 1) + XCTAssertEqual(stored.cookies.first?.name, cookieName) + + removeInMemoryCookies() + HTTPCookieStorage.shared.setCookie(persistent) + persistAndWait() + XCTAssertNil(SessionCookieStore.get(key: storageKey)) + } + + func testLegacyCookieMigrationPreservesPayload() throws { + let original = try cookie() + let data = try NSKeyedArchiver.archivedData(withRootObject: [original], requiringSecureCoding: false) + let attributes = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: storageKey, + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked + ] as CFDictionary + XCTAssertEqual(SecItemAdd(attributes, nil), errSecSuccess) + let restored = try XCTUnwrap(SessionCookieStore.get(key: storageKey)) + XCTAssertTrue(restored.migrated) + XCTAssertEqual(restored.cookies.first?.value, original.value) + var result: CFTypeRef? + let query = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: storageKey, + kSecReturnData: true + ] as CFDictionary + XCTAssertEqual(SecItemCopyMatching(query, &result), errSecSuccess) + XCTAssertEqual(result as? Data, data) + let attributeQuery = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: storageKey, + kSecReturnAttributes: true + ] as CFDictionary + XCTAssertEqual(SecItemCopyMatching(attributeQuery, &result), errSecSuccess) + let migratedAttributes = try XCTUnwrap(result as? [String: Any]) + XCTAssertEqual( + migratedAttributes[kSecAttrAccessible as String] as? String, + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String + ) + } + + func testFailedMigrationRestoresCookiesButDoesNotConsumeCopy() throws { + let original = try cookie() + let data = try NSKeyedArchiver.archivedData(withRootObject: [original], requiringSecureCoding: false) + var removed = false + let restored = SessionCookieStore.get( + key: storageKey, + copyMatching: { _, output in + output?.pointee = data as CFData + return errSecSuccess + }, + migrate: { _ in false }, + remove: { _ in removed = true } + ) + XCTAssertNotNil(restored) + SessionCookieStore.restore( + storage: HTTPCookieStorage.shared, + read: { restored }, + remove: { removed = true } + ) + XCTAssertFalse(removed) + XCTAssertEqual((HTTPCookieStorage.shared.cookies ?? []).first { $0.name == cookieName }?.value, original.value) + + SessionCookieStore.restore( + storage: HTTPCookieStorage.shared, + read: { ([original], true) }, + remove: { removed = true } + ) + XCTAssertTrue(removed) + XCTAssertEqual((HTTPCookieStorage.shared.cookies ?? []).filter { $0.name == cookieName }.count, 1) + } + + func testLockedAndMissingItemsAreNotMigratedOrDeleted() { + for status in [errSecInteractionNotAllowed, errSecItemNotFound] { + XCTAssertNil(SessionCookieStore.get( + key: storageKey, + copyMatching: { _, _ in status }, + migrate: { _ in + XCTFail("An inaccessible or absent item must not migrate") + return false + }, + remove: { _ in XCTFail("An inaccessible or absent item must not be deleted") } + )) + } + } + + func testCorruptArchiveKeepsExistingCleanupBehavior() { + var removed = false + XCTAssertNil(SessionCookieStore.get( + key: storageKey, + copyMatching: { _, output in + output?.pointee = Data([0, 1, 2]) as CFData + return errSecSuccess + }, + migrate: { _ in + XCTFail("Corrupt cookies must not migrate") + return false + }, + remove: { _ in removed = true } + )) + XCTAssertTrue(removed) + } +}