Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions MendixNative.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
32 changes: 32 additions & 0 deletions example/__tests__/encrypted-storage.harness.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,44 @@
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 () => {
// Clear storage before each test
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';
Expand Down
36 changes: 36 additions & 0 deletions example/__tests__/encryption.harness.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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'
);
});
}
});
4 changes: 4 additions & 0 deletions example/ios/MendixNativeExample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -21,6 +22,7 @@
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = MendixNativeExample/Info.plist; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = MendixNativeExample/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = MendixNativeExample/AppDelegate.swift; sourceTree = "<group>"; };
761780EE2CA45674006654EE /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = MendixNativeExample/SceneDelegate.swift; sourceTree = "<group>"; };
Expand All @@ -45,6 +47,7 @@
13B07FAE1A68108700A75B9A /* MendixNativeExample */ = {
isa = PBXGroup;
children = (
254000000000000000000002 /* KeychainTestSupport.m */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
761780EC2CA45674006654EE /* AppDelegate.swift */,
13B07FB61A68108700A75B9A /* Info.plist */,
Expand Down Expand Up @@ -259,6 +262,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
254000000000000000000001 /* KeychainTestSupport.m in Sources */,
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
761780EF2CA45674006654EE /* SceneDelegate.swift in Sources */,
);
Expand Down
67 changes: 67 additions & 0 deletions example/ios/MendixNativeExample/KeychainTestSupport.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#if DEBUG
#import <React/RCTBridgeModule.h>
#import <Security/Security.h>

@interface KeychainTestSupport : NSObject <RCTBridgeModule>
@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
1 change: 1 addition & 0 deletions example/ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
38 changes: 32 additions & 6 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
18 changes: 18 additions & 0 deletions example/test-support/keychain.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
inspectItem(key: string): Promise<KeychainAttributes | null>;
}

export function getKeychainTestSupport() {
return TurboModuleRegistry.getEnforcing<KeychainTestSupport>(
'KeychainTestSupport'
);
}
Loading
Loading