diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01012e3..2ef7c50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,10 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 + with: + # The action's default list includes the legacy 'tools' package, which current + # SDK repositories no longer carry, so sdkmanager exits 1 before anything builds. + packages: platform-tools - name: Set up Codename One Build Client shell: bash diff --git a/android/src/main/java/com/codename1/fingerprint/impl/InternalFingerprintImpl.java b/android/src/main/java/com/codename1/fingerprint/impl/InternalFingerprintImpl.java index a7798a2..f2cf538 100644 --- a/android/src/main/java/com/codename1/fingerprint/impl/InternalFingerprintImpl.java +++ b/android/src/main/java/com/codename1/fingerprint/impl/InternalFingerprintImpl.java @@ -604,6 +604,14 @@ private boolean getPassword29(final int requestId, final String reason, final St InternalCallback.requestError(requestId,"The specified item could not be found in the keychain."); return false; } + // Before the availability check: when the user deleted EVERY biometric, nothing + // is enrolled, so that check fails and the password would be reported as "not + // available" -- although Android has already deleted its key and it can never be + // read again, even after re-enrolment. A stored password with no key is revoked. + if (getSecretKey() == null && keyRevoked) { + InternalCallback.requestKeyRevokedError(requestId, "Key has been invalidated"); + return false; + } if (isBiometricAuthAvailable()) { SecretKey secretKey = getSecretKey(); @@ -736,6 +744,14 @@ private boolean getPassword(final int requestId, final String reason, final Stri InternalCallback.requestError(requestId,"The specified item could not be found in the keychain."); return false; } + // Before the availability check: when the user deleted EVERY biometric, nothing + // is enrolled, so that check fails and the password would be reported as "not + // available" -- although Android has already deleted its key and it can never be + // read again, even after re-enrolment. A stored password with no key is revoked. + if (getSecretKey() == null && keyRevoked) { + InternalCallback.requestKeyRevokedError(requestId, "Key has been invalidated"); + return false; + } if (isFingerprintAuthAvailable()) { SecretKey secretKey = getSecretKey(); @@ -918,6 +934,14 @@ private SecretKey getSecretKey() { try { mKeyStore().load(null); key = (SecretKey) mKeyStore.getKey(KEY_ID, null); + if (key == null) { + // Android deletes a biometric-bound key outright when the user removes + // EVERY enrolled biometric, instead of failing with + // KeyPermanentlyInvalidatedException. Both callers that read keyRevoked + // return first when no password is stored, so an absent key there means + // a stored password whose key was taken away: revoked, not "No key found". + keyRevoked = true; + } } catch (KeyStoreException e) { errorMessage = getSecretKeyExceptionErrorPrefix + "KeyStoreException"; } catch (CertificateException e) { diff --git a/fingerprint-scanner-test/pom.xml b/fingerprint-scanner-test/pom.xml index 1f418ac..fb86418 100644 --- a/fingerprint-scanner-test/pom.xml +++ b/fingerprint-scanner-test/pom.xml @@ -19,8 +19,8 @@ common - 7.0.228 - 7.0.228 + 7.0.281 + 7.0.281 UTF-8 1.8 11 @@ -32,6 +32,20 @@ myappname 1.1.4 + + + + codenameone + https://repo.codenameone.com/maven2 + + + + + codenameone + https://repo.codenameone.com/maven2 + + @@ -107,8 +121,6 @@ - - javascript diff --git a/ios/src/main/objectivec/com_codename1_fingerprint_impl_InternalFingerprintImpl.m b/ios/src/main/objectivec/com_codename1_fingerprint_impl_InternalFingerprintImpl.m index 7bf9c4e..2ddfb33 100644 --- a/ios/src/main/objectivec/com_codename1_fingerprint_impl_InternalFingerprintImpl.m +++ b/ios/src/main/objectivec/com_codename1_fingerprint_impl_InternalFingerprintImpl.m @@ -257,6 +257,27 @@ -(void)deletePassword:(int)requestId param1:(NSString*)reason param2:(NSString*) } }); } +// Attributes only: not guarded by the access-control list, so no prompt, and an item whose +// biometric binding was invalidated is still found. +-(BOOL)itemExists:(NSString*)account { + NSMutableDictionary *q = [NSMutableDictionary dictionary]; + [q setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass]; + [q setObject:account forKey:(__bridge id)kSecAttrAccount]; + [q setObject:[self getAppName] forKey:(__bridge id)kSecAttrService]; + [q setObject:@YES forKey:(__bridge id)kSecReturnAttributes]; + [q setObject:(__bridge id)kSecMatchLimitOne forKey:(__bridge id)kSecMatchLimit]; + NSString* accessGroup = [self getKeychainAccessGroup]; + 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)getPassword:(int)requestId param1:(NSString*)reason param2:(NSString*)account { NSMutableDictionary *dict = [NSMutableDictionary dictionary]; @@ -284,6 +305,21 @@ -(void)getPassword:(int)requestId param1:(NSString*)reason param2:(NSString*)acc } else if (status == errSecUserCanceled) { JAVA_OBJECT jErrorMessage = fromNSString(getThreadLocalData(), @"__CANCELLED__"); com_codename1_fingerprint_impl_InternalCallback_requestError___int_java_lang_String(getThreadLocalData(), requestId, jErrorMessage); + } else if (status == errSecItemNotFound && [self itemExists:account]) { + // Stored under kSecAccessControlTouchIDCurrentSet: a change to the enrolled + // Face ID / Touch ID set invalidates the item. Its attributes remain but the + // data read answers errSecItemNotFound. Report it as revoked, as Android does, + // and delete it, since it can never be read again and would break a later add. + NSMutableDictionary *del = [NSMutableDictionary dictionary]; + [del setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass]; + [del setObject:account forKey:(__bridge id)kSecAttrAccount]; + [del setObject:[self getAppName] forKey:(__bridge id)kSecAttrService]; + if (accessGroup != nil) { + [del setObject:accessGroup forKey:(__bridge id)kSecAttrAccessGroup]; + } + SecItemDelete((__bridge CFDictionaryRef)del); + JAVA_OBJECT jErrorMessage = fromNSString(getThreadLocalData(), @"Key has been invalidated"); + com_codename1_fingerprint_impl_InternalCallback_requestKeyRevokedError___int_java_lang_String(getThreadLocalData(), requestId, jErrorMessage); } else { NSString* errorMessage = [self errorString:status]; JAVA_OBJECT jErrorMessage = fromNSString(getThreadLocalData(), errorMessage);