diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java index c164a2f1ac3..eafc1d8bf57 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSecureStorage.java @@ -1580,7 +1580,19 @@ private SecretKey getSecretKey() { keyRevoked = false; try { keyStore().load(null); - return (SecretKey) keyStore.getKey(KEY_ID, null); + SecretKey k = (SecretKey) keyStore.getKey(KEY_ID, null); + if (k == null) { + // Android deletes a biometric-bound key outright when the user removes + // EVERY enrolled biometric (and on some devices when the set changes), + // rather than leaving it to fail with KeyPermanentlyInvalidatedException. + // The only caller that reads this flag is the decrypt path, and get() + // returns before reaching it when no entry is stored -- so an absent key + // there always means an entry whose key was taken away: revoked. Reporting + // it as a generic failure made apps treat a re-enrolment as an error + // instead of asking the user to set biometric login up again. + keyRevoked = true; + } + return k; } catch (UnrecoverableKeyException e) { keyRevoked = true; } catch (KeyStoreException e) { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 5aec42bdaef..a42cd30e853 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -23045,6 +23045,31 @@ static BOOL cn1_secureStorageHasLegacyService(NSString *current, NSString *legac return legacy != nil && current != nil && ![legacy isEqualToString:current]; } +// Not an OSStatus: Security.framework codes are all in -25xxx/-34xxx/-67xxx and -128, so this +// cannot collide. Must match IOSSecureStorage.KEY_REVOKED_CODE. +#define CN1_SECURE_STORAGE_KEY_REVOKED -990001 + +// Whether the biometric item is in the keychain at all. Asks for attributes only, which an +// access-control list does not guard, so it never prompts and still sees an item whose +// biometric binding was invalidated. +static BOOL cn1_secureStorageBiometricItemExists(NSString *account, NSString *appName, NSString *accessGroup) { + NSMutableDictionary *q = [NSMutableDictionary dictionary]; + [q setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass]; + [q setObject:account forKey:(__bridge id)kSecAttrAccount]; + [q setObject:appName forKey:(__bridge id)kSecAttrService]; + [q setObject:@YES forKey:(__bridge id)kSecReturnAttributes]; + [q setObject:(__bridge id)kSecMatchLimitOne forKey:(__bridge id)kSecMatchLimit]; + if (accessGroup != nil) { + [q setObject:accessGroup forKey:(__bridge id)kSecAttrAccessGroup]; + } + CFTypeRef attrs = NULL; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)q, &attrs); + if (attrs != NULL) { + CFRelease(attrs); + } + return status == errSecSuccess; +} + void com_codename1_impl_ios_IOSNative_secureStorageGet___int_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, JAVA_OBJECT reason, JAVA_OBJECT account) { POOL_BEGIN(); NSString *nsReason = (reason == JAVA_NULL) ? @"Authenticate" : toNSString(CN1_THREAD_STATE_PASS_ARG reason); @@ -23075,6 +23100,25 @@ void com_codename1_impl_ios_IOSNative_secureStorageGet___int_java_lang_String_ja NSString *value = [[[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding] autorelease]; JAVA_OBJECT jv = fromNSString(getThreadLocalData(), value); com_codename1_impl_ios_IOSSecureStorage_nativeStorageStringResult___int_java_lang_String(getThreadLocalData(), requestId, jv); + } else if (status == errSecItemNotFound && cn1_secureStorageBiometricItemExists(nsAccount, appName, accessGroup)) { + // The item is stored under kSecAccessControlTouchIDCurrentSet, so enrolling a + // different Face ID / Touch ID set invalidates it: its attributes are still + // there, but the data read answers errSecItemNotFound. That is a revoked key, + // the same case Android reports as KEY_REVOKED, and it used to surface as a + // generic failure -- iOS apps could never tell re-enrolment from an error. + // The item can never be read again, and leaving it makes a later set() fall + // into SecItemUpdate on an invalidated item, so delete it, as Android deletes + // its invalidated key. + NSMutableDictionary *del = [NSMutableDictionary dictionary]; + [del setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass]; + [del setObject:nsAccount forKey:(__bridge id)kSecAttrAccount]; + [del setObject:appName forKey:(__bridge id)kSecAttrService]; + if (accessGroup != nil) { + [del setObject:accessGroup forKey:(__bridge id)kSecAttrAccessGroup]; + } + SecItemDelete((__bridge CFDictionaryRef)del); + JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), @"Key has been invalidated"); + com_codename1_impl_ios_IOSSecureStorage_nativeStorageError___int_int_java_lang_String(getThreadLocalData(), requestId, CN1_SECURE_STORAGE_KEY_REVOKED, jmsg); } else { JAVA_OBJECT jmsg = fromNSString(getThreadLocalData(), [NSString stringWithFormat:@"OSStatus %d", (int)status]); com_codename1_impl_ios_IOSSecureStorage_nativeStorageError___int_int_java_lang_String(getThreadLocalData(), requestId, (int)status, jmsg); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSecureStorage.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSecureStorage.java index c4fe06ecc1e..2ffa4b76b11 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSSecureStorage.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSSecureStorage.java @@ -278,10 +278,17 @@ private static AsyncResource take(int requestId) { } } + /// Reported by the native get when the item survives but its Face ID / Touch ID binding + /// was invalidated by a change to the enrolled set. Not an OSStatus; must match + /// CN1_SECURE_STORAGE_KEY_REVOKED in IOSNative.m. + static final int KEY_REVOKED_CODE = -990001; + private static BiometricError mapStorageError(int code) { // Reuse the LAError mapping (negative values) and the dominant // OSStatus values returned by Security.framework. switch (code) { + case KEY_REVOKED_CODE: + return BiometricError.KEY_REVOKED; case -128: // errSecUserCanceled return BiometricError.USER_CANCELED; case -25291: // errSecNotAvailable