From 8b230ab79a07618d5b889d221cd66cdb2ba51949 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Mon, 24 Jun 2024 15:36:30 +0200 Subject: [PATCH 1/5] feat: hasGenericPassword --- RNKeychainManager/RNKeychainManager.m | 25 +++++++++++++++++++ .../com/oblador/keychain/KeychainModule.java | 14 +++++++++++ index.js | 11 ++++++++ typings/react-native-keychain.d.ts | 4 +++ 4 files changed, 54 insertions(+) diff --git a/RNKeychainManager/RNKeychainManager.m b/RNKeychainManager/RNKeychainManager.m index dc7a1060..78cdf1aa 100644 --- a/RNKeychainManager/RNKeychainManager.m +++ b/RNKeychainManager/RNKeychainManager.m @@ -486,6 +486,31 @@ - (OSStatus)deleteCredentialsForServer:(NSString *)server return rejectWithError(reject, error); } +RCT_EXPORT_METHOD(hasGenericPasswordForService:(NSString *)service + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSDictionary *query = @{ + (__bridge NSString *)kSecClass: (__bridge id)(kSecClassGenericPassword), + (__bridge NSString *)kSecAttrService: service, + (__bridge id)kSecUseNoAuthenticationUI: @YES + }; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + CFTypeRef dataTypeRef = NULL; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)(query), &dataTypeRef); + if (status == errSecInteractionNotAllowed || status == errSecSuccess) { + resolve(@(YES)); + } else if (status == errSecItemNotFound) { + resolve(@(NO)); + } else { + NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:status userInfo:nil]; + rejectWithError(reject, error); + } + }); + +} + RCT_EXPORT_METHOD(getInternetCredentialsForServer:(NSString *)server withOptions:(NSDictionary * __nullable)options resolver:(RCTPromiseResolveBlock)resolve diff --git a/android/src/main/java/com/oblador/keychain/KeychainModule.java b/android/src/main/java/com/oblador/keychain/KeychainModule.java index 9413000c..62ca0fcb 100644 --- a/android/src/main/java/com/oblador/keychain/KeychainModule.java +++ b/android/src/main/java/com/oblador/keychain/KeychainModule.java @@ -422,6 +422,20 @@ public void hasInternetCredentialsForServer(@NonNull final String server, promise.resolve(results); } + @ReactMethod + public void hasGenericPasswordForService(@NonNull final String service, + @NonNull final Promise promise) { + final ResultSet resultSet = prefsStorage.getEncryptedEntry(service); + + if (resultSet == null) { + Log.e(KEYCHAIN_MODULE, "No entry found for service: " + alias); + promise.resolve(false); + return; + } + + promise.resolve(true); + } + @ReactMethod public void setInternetCredentialsForServer(@NonNull final String server, @NonNull final String username, diff --git a/index.js b/index.js index 083938e6..8e34bfbe 100644 --- a/index.js +++ b/index.js @@ -200,6 +200,17 @@ export function getGenericPassword( return RNKeychainManager.getGenericPasswordForOptions(options); } +/** + * Checks if we have generic password for `service`. + * @param {string} service Service to fetch generic password for. + * @return {Promise} Resolved to `true` when successful + */ +export function hasGenericPassword( + service: string +): Promise { + return RNKeychainManager.hasGenericPasswordForService(service); +} + /** * Deletes all generic password keychain entries for `service`. * @param {object} options An Keychain options object. diff --git a/typings/react-native-keychain.d.ts b/typings/react-native-keychain.d.ts index 6846468e..0cb6c787 100644 --- a/typings/react-native-keychain.d.ts +++ b/typings/react-native-keychain.d.ts @@ -93,6 +93,10 @@ declare module 'react-native-keychain' { options?: Options ): Promise; + function hasGenericPassword( + service: string + ): Promise; + function resetGenericPassword(options?: Options): Promise; function getAllGenericPasswordServices(): Promise; From a4207452ee08760e0a0e43fff10715275015b5ef Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Tue, 6 Aug 2024 19:22:49 +0200 Subject: [PATCH 2/5] fix: improve hasGenericPassword, align to hasInternetCredentials --- README.md | 8 +++- RNKeychainManager/RNKeychainManager.m | 44 +++++++++++-------- .../com/oblador/keychain/KeychainModule.java | 3 +- index.js | 5 ++- 4 files changed, 38 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index aeb55fe1..00908df1 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - [Usage](#usage) - [API](#api) - [`setGenericPassword(username, password, [{ accessControl, accessible, accessGroup, service, securityLevel }])`](#setgenericpasswordusername-password--accesscontrol-accessible-accessgroup-service-securitylevel-) + - [`hasGenericPassword([{ service }])`](#hasgenericpasswordservice) - [`getGenericPassword([{ authenticationPrompt, service, accessControl }])`](#getgenericpassword-authenticationprompt-service-accesscontrol-) - [`resetGenericPassword([{ service }])`](#resetgenericpassword-service-) - [`getAllGenericPasswordServices()`](#getallgenericpasswordservices) @@ -38,7 +39,7 @@ - [`Keychain.STORAGE_TYPE` enum (Android only)](#keychainstorage_type-enum-android-only) - [`Keychain.SECURITY_RULES` enum (Android only)](#keychainsecurity_rules-enum-android-only) - [Important Behavior](#important-behavior) - - [Rule 1: Automatic Security Level Upgrade](#rule-1-automatic-security-level-upgrade) + - [Rule 1: Automatic Security Level](#rule-1-automatic-security-level) - [Manual Installation](#manual-installation) - [iOS](#ios) - [Option: Manually](#option-manually) @@ -55,6 +56,7 @@ - [Configuring the Android-specific behavior](#configuring-the-android-specific-behavior) - [iOS Notes](#ios-notes) - [macOS Catalyst](#macos-catalyst) + - [visionOS](#visionos) - [Security](#security) - [Maintainers](#maintainers) - [For Developers / Contributors](#for-developers--contributors) @@ -109,6 +111,10 @@ Both `setGenericPassword` and `setInternetCredentials` are limited to strings on Will store the username/password combination in the secure storage. Resolves to `{service, storage}` or rejects in case of an error. `storage` - is a name of used internal cipher for saving secret; `service` - name used for storing secret in internal storage (empty string resolved to valid default name). +### `hasGenericPassword([{ service }])` + +Will check if the username/password combination is available for service in the secure storage. Resolves to `true` if an entry exists or `false` if it doesn't. + ### `getGenericPassword([{ authenticationPrompt, service, accessControl }])` Will retrieve the username/password combination from the secure storage. Resolves to `{ username, password, service, storage }` if an entry exists or `false` if it doesn't. It will reject only if an unexpected error is encountered like lacking entitlements or permission. diff --git a/RNKeychainManager/RNKeychainManager.m b/RNKeychainManager/RNKeychainManager.m index 78cdf1aa..4a8078c3 100644 --- a/RNKeychainManager/RNKeychainManager.m +++ b/RNKeychainManager/RNKeychainManager.m @@ -486,29 +486,37 @@ - (OSStatus)deleteCredentialsForServer:(NSString *)server return rejectWithError(reject, error); } -RCT_EXPORT_METHOD(hasGenericPasswordForService:(NSString *)service +RCT_EXPORT_METHOD(hasGenericPasswordForOptions:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - NSDictionary *query = @{ - (__bridge NSString *)kSecClass: (__bridge id)(kSecClassGenericPassword), - (__bridge NSString *)kSecAttrService: service, - (__bridge id)kSecUseNoAuthenticationUI: @YES - }; + NSString *service = serviceValue(options); - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - CFTypeRef dataTypeRef = NULL; - OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)(query), &dataTypeRef); - if (status == errSecInteractionNotAllowed || status == errSecSuccess) { - resolve(@(YES)); - } else if (status == errSecItemNotFound) { - resolve(@(NO)); - } else { - NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:status userInfo:nil]; - rejectWithError(reject, error); - } - }); + NSMutableDictionary *queryParts = [[NSMutableDictionary alloc] init]; + queryParts[(__bridge NSString *)kSecClass] = (__bridge id)(kSecClassGenericPassword); + queryParts[(__bridge NSString *)kSecAttrServer] = service; + queryParts[(__bridge NSString *)kSecMatchLimit] = (__bridge NSString *)kSecMatchLimitOne; + + if (@available(iOS 9, *)) { + queryParts[(__bridge NSString *)kSecUseAuthenticationUI] = (__bridge NSString *)kSecUseAuthenticationUIFail; + } + + NSDictionary *query = [queryParts copy]; + // Look up server in the keychain + OSStatus osStatus = SecItemCopyMatching((__bridge CFDictionaryRef) query, nil); + + switch (osStatus) { + case noErr: + case errSecInteractionNotAllowed: + return resolve(@(YES)); + + case errSecItemNotFound: + return resolve(@(NO)); + } + + NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:osStatus userInfo:nil]; + return rejectWithError(reject, error); } RCT_EXPORT_METHOD(getInternetCredentialsForServer:(NSString *)server diff --git a/android/src/main/java/com/oblador/keychain/KeychainModule.java b/android/src/main/java/com/oblador/keychain/KeychainModule.java index 62ca0fcb..801ace46 100644 --- a/android/src/main/java/com/oblador/keychain/KeychainModule.java +++ b/android/src/main/java/com/oblador/keychain/KeychainModule.java @@ -423,8 +423,9 @@ public void hasInternetCredentialsForServer(@NonNull final String server, } @ReactMethod - public void hasGenericPasswordForService(@NonNull final String service, + public void hasGenericPasswordForOptions(@Nullable final ReadableMap options, @NonNull final Promise promise) { + final String service = getServiceOrDefault(options); final ResultSet resultSet = prefsStorage.getEncryptedEntry(service); if (resultSet == null) { diff --git a/index.js b/index.js index 8e34bfbe..de681f25 100644 --- a/index.js +++ b/index.js @@ -206,9 +206,10 @@ export function getGenericPassword( * @return {Promise} Resolved to `true` when successful */ export function hasGenericPassword( - service: string + serviceOrOptions?: string | Options ): Promise { - return RNKeychainManager.hasGenericPasswordForService(service); + const options = normalizeOptions(serviceOrOptions); + return RNKeychainManager.hasGenericPasswordForOptions(options); } /** From d5609326b61233e418b98fbc2d85553435228006 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Tue, 6 Aug 2024 19:29:03 +0200 Subject: [PATCH 3/5] test: fix android unit test --- android/src/main/java/com/oblador/keychain/KeychainModule.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/oblador/keychain/KeychainModule.java b/android/src/main/java/com/oblador/keychain/KeychainModule.java index 801ace46..5b61d075 100644 --- a/android/src/main/java/com/oblador/keychain/KeychainModule.java +++ b/android/src/main/java/com/oblador/keychain/KeychainModule.java @@ -429,7 +429,7 @@ public void hasGenericPasswordForOptions(@Nullable final ReadableMap options, final ResultSet resultSet = prefsStorage.getEncryptedEntry(service); if (resultSet == null) { - Log.e(KEYCHAIN_MODULE, "No entry found for service: " + alias); + Log.e(KEYCHAIN_MODULE, "No entry found for service: " + service); promise.resolve(false); return; } From 817a651488b8a3b7559e1074a486770025f88700 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Sat, 14 Sep 2024 18:54:47 +0300 Subject: [PATCH 4/5] fix: wrong kSec attribute in hasGenericPasswordForOptions --- ios/RNKeychainManager/RNKeychainManager.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/RNKeychainManager/RNKeychainManager.m b/ios/RNKeychainManager/RNKeychainManager.m index 4a8078c3..2e431ccd 100644 --- a/ios/RNKeychainManager/RNKeychainManager.m +++ b/ios/RNKeychainManager/RNKeychainManager.m @@ -494,7 +494,7 @@ - (OSStatus)deleteCredentialsForServer:(NSString *)server NSMutableDictionary *queryParts = [[NSMutableDictionary alloc] init]; queryParts[(__bridge NSString *)kSecClass] = (__bridge id)(kSecClassGenericPassword); - queryParts[(__bridge NSString *)kSecAttrServer] = service; + queryParts[(__bridge NSString *)kSecAttrService] = service; queryParts[(__bridge NSString *)kSecMatchLimit] = (__bridge NSString *)kSecMatchLimitOne; if (@available(iOS 9, *)) { @@ -503,7 +503,7 @@ - (OSStatus)deleteCredentialsForServer:(NSString *)server NSDictionary *query = [queryParts copy]; - // Look up server in the keychain + // Look up service in the keychain OSStatus osStatus = SecItemCopyMatching((__bridge CFDictionaryRef) query, nil); switch (osStatus) { From fdbe95e29dededfd83bacb0d7bb1b03166f8cb07 Mon Sep 17 00:00:00 2001 From: MazurDorian Date: Sat, 14 Sep 2024 18:56:40 +0300 Subject: [PATCH 5/5] test: add e2e tests for hasGenericPassword --- KeychainExample/App.tsx | 7 +++++++ .../e2e/testCases/biometricsAccessControlTest.spec.js | 1 + KeychainExample/e2e/testCases/noneAccessControTest.spec.js | 1 + 3 files changed, 9 insertions(+) diff --git a/KeychainExample/App.tsx b/KeychainExample/App.tsx index 071fc0af..a6f957ca 100644 --- a/KeychainExample/App.tsx +++ b/KeychainExample/App.tsx @@ -56,12 +56,16 @@ export default class KeychainExample extends Component { selectedSecurityIndex: 0, selectedAccessControlIndex: 0, selectedRulesIndex: 0, + hasGenericPassword: false, }; componentDidMount() { Keychain.getSupportedBiometryType().then((biometryType) => { this.setState({ biometryType }); }); + Keychain.hasGenericPassword().then((hasGenericPassword) => { + this.setState({ hasGenericPassword }); + }); } async save() { @@ -334,6 +338,9 @@ export default class KeychainExample extends Component { )} + + hasGenericPassword: {String(this.state.hasGenericPassword)} + ); diff --git a/KeychainExample/e2e/testCases/biometricsAccessControlTest.spec.js b/KeychainExample/e2e/testCases/biometricsAccessControlTest.spec.js index 0e6ab184..00bbb394 100644 --- a/KeychainExample/e2e/testCases/biometricsAccessControlTest.spec.js +++ b/KeychainExample/e2e/testCases/biometricsAccessControlTest.spec.js @@ -42,6 +42,7 @@ describe('Biometrics Access Control', () => { it('should retrieve username and password after app launch', async () => { await expect(element(by.text('Keychain Example'))).toExist(); + await expect(element(by.text('hasGenericPassword: true'))).toBeVisible(); // Biometric prompt is not available in the IOS simulator // https://github.com/oblador/react-native-keychain/issues/340 if (device.getPlatform() === 'android') { diff --git a/KeychainExample/e2e/testCases/noneAccessControTest.spec.js b/KeychainExample/e2e/testCases/noneAccessControTest.spec.js index 088eb24f..bb6292a3 100644 --- a/KeychainExample/e2e/testCases/noneAccessControTest.spec.js +++ b/KeychainExample/e2e/testCases/noneAccessControTest.spec.js @@ -27,6 +27,7 @@ describe('None Access Control', () => { it('should retrieve username and password after app launch', async () => { await expect(element(by.text('Keychain Example'))).toExist(); + await expect(element(by.text('hasGenericPassword: true'))).toBeVisible(); await element(by.text('Load')).tap(); await matchLoadInfo('testUsername', 'testPassword'); });